feat(offline): app-shell precache service worker (vite-plugin-pwa)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s

Migrate the former install-only public/sw.js to a custom (injectManifest)
service worker built from ui/src/sw.ts by vite-plugin-pwa. It precaches the
app shell + hashed assets (Workbox) so an installed web PWA cold-launches with
no network, and falls in-scope navigations back to the precached shell (the
hash router resolves the route client-side). This is the C1 prerequisite for a
usable offline mode: without it a cold offline launch cannot load the bundle.

- ui/src/sw.ts: skipWaiting + clientsClaim + cleanupOutdatedCaches +
  precacheAndRoute(__WB_MANIFEST) + a NavigationRoute fallback to index.html,
  deny-listing the RPC path and /_gm. The Connect stream and API POSTs are
  never precached nor intercepted.
- vite.config.ts: VitePWA(injectManifest); manifest:false (we ship our own),
  injectRegister:false (registration stays manual + web-only in pwa.svelte.ts),
  disabled in the mock build (Playwright unperturbed); landing + polyfills
  excluded from the precache.
- Removed public/sw.js; the registration path (dist/sw.js at /app/) is unchanged.
- New dev deps: vite-plugin-pwa + workbox-{core,precaching,routing}.

Docs: ARCHITECTURE + gateway/README. Offline cold-launch is contour-verified
(the mock e2e harness disables the SW).
This commit is contained in:
Ilia Denisov
2026-07-06 11:06:59 +02:00
parent 8abd8b2ae6
commit 1a95a5f2cb
8 changed files with 3282 additions and 88 deletions
+9 -6
View File
@@ -1238,12 +1238,15 @@ separate site; the SPA shell is `noindex`, and the landing always boots in Russi
browser-language detection — crawlers render with arbitrary languages). The favicon set, browser-language detection — crawlers render with arbitrary languages). The favicon set,
`og-image.png` and `robots.txt` ship unhashed from `ui/public/` (generated by `og-image.png` and `robots.txt` ship unhashed from `ui/public/` (generated by
`assets/icons/`, same tile design as the VK loader). On the web (`/app/`) the SPA is an installable **PWA**: `assets/icons/`, same tile design as the VK loader). On the web (`/app/`) the SPA is an installable **PWA**:
`manifest.webmanifest` and an install-only **service worker** (`sw.js`) ship unhashed from `manifest.webmanifest` ships unhashed from `ui/public/`, while the **service worker** (`sw.js`) is
`ui/public/`, and the client registers the worker **web-only** — never inside a Mini App or the built from `ui/src/sw.ts` by **vite-plugin-pwa** (injectManifest strategy) — the client registers it
mock build. The worker intercepts only top-level navigations (network-first with a cached-shell **web-only**, never inside a Mini App or the mock build (the plugin is disabled there entirely). It
fallback), leaving `/assets/*` and the Connect stream untouched; it exists to satisfy Chromium's **precaches the app shell and the hashed assets** (Workbox) so an installed PWA cold-launches with
installability requirement (a registered SW, needed for install on Android) and is the single no network, and falls every in-scope navigation back to the precached shell (the hash router
growth point for the opt-in **offline mode** (in progress): a deliberate, device-scoped Settings resolves the route client-side); the landing page and the conditional polyfill bundle are excluded,
and the Connect stream and runtime API POSTs are never precached nor intercepted, so the live app is
never served stale. This satisfies Chromium's installability requirement (a registered SW, needed
for install on Android) and powers the opt-in **offline mode** (in progress): a deliberate, device-scoped Settings
toggle — distinct from the transient gateway-reachability signal — that tints the header blue with toggle — distinct from the transient gateway-reachability signal — that tints the header blue with
an *Offline* chip and confines play to on-device `vs_ai` games. To have data ready before the an *Offline* chip and confines play to on-device `vs_ai` games. To have data ready before the
switch, the **Profile advertises the current dictionary version per variant** (`dict_versions`, switch, the **Profile advertises the current dictionary version per variant** (`dict_versions`,
+4 -3
View File
@@ -8,9 +8,10 @@ backend over REST/JSON, and bridges the backend's gRPC push stream to each
client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked
in by the gateway image's node stage) and serves a **landing page** at `/` and the game in by the gateway image's node stage) and serves a **landing page** at `/` and the game
**SPA** at `/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App) — the single-origin model. **SPA** at `/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App) — the single-origin model.
The web SPA is an installable **PWA**: it serves `manifest.webmanifest` (with the `.webmanifest` The web SPA is an installable **PWA**: it serves `manifest.webmanifest` (unhashed from `ui/public/`,
MIME type registered in-process — the distroless image has no `/etc/mime.types`) and the with the `.webmanifest` MIME type registered in-process — the distroless image has no
install-only `sw.js`, both shipped unhashed from `ui/public/`. `/etc/mime.types`) and the app-shell `sw.js` (built from `ui/src/sw.ts` by vite-plugin-pwa, which
precaches the shell + assets so an installed PWA cold-launches offline).
Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the
backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run; backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run;
in the deployed contour the front caddy owns `/_gm` (see in the deployed contour the front caddy owns `/_gm` (see
+5 -1
View File
@@ -32,6 +32,10 @@
"svelte-check": "^4.1.0", "svelte-check": "^4.1.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",
"vite": "^6.0.0", "vite": "^6.0.0",
"vitest": "^3.0.0" "vite-plugin-pwa": "^0.21.2",
"vitest": "^3.0.0",
"workbox-core": "^7.4.1",
"workbox-precaching": "^7.4.1",
"workbox-routing": "^7.4.1"
} }
} }
+3195 -21
View File
File diff suppressed because it is too large Load Diff
-53
View File
@@ -1,53 +0,0 @@
// Install-only service worker.
//
// Its sole job today is to satisfy Chromium's PWA installability requirement (a registered
// service worker with a fetch handler) so the web app can be installed to the home screen /
// desktop — notably on Android, where the manifest alone is not enough. It deliberately does
// NOT cache assets or the Connect-RPC stream: only top-level navigations are handled,
// network-first with a cached-shell fallback, so the live app, its immutable hashed assets and
// the live event stream are never served stale.
//
// This is the single designated growth point for the planned offline mode (a Settings toggle,
// default online — see docs/ARCHITECTURE.md). Extend the fetch router here; keep the same
// /app/ scope; gate any real caching behind the toggle and coordinate updates with the boot
// version guard.
const SHELL = 'scrabble-shell-v1'; // bump only when the SW strategy itself changes.
self.addEventListener('install', () => {
// No cached content to migrate carefully, so activate the new worker immediately.
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(keys.filter((k) => k !== SHELL).map((k) => caches.delete(k)));
await self.clients.claim();
})(),
);
});
self.addEventListener('fetch', (event) => {
const req = event.request;
// Only top-level navigations are handled. Hashed assets and the Connect-RPC requests are
// left untouched (no respondWith -> the browser performs its default fetch), so the worker
// can never break the live stream or serve a stale immutable asset.
if (req.mode !== 'navigate') return;
event.respondWith(
(async () => {
try {
const res = await fetch(req);
const cache = await caches.open(SHELL);
// One shell entry regardless of the deep-link path: the hash router resolves the route
// client-side, so any offline navigation can be served the same cached shell.
cache.put('shell', res.clone());
return res;
} catch {
const cache = await caches.open(SHELL);
const cached = await cache.match('shell');
return cached ?? Response.error();
}
})(),
);
});
+5 -3
View File
@@ -76,9 +76,11 @@ export async function promptInstall(): Promise<void> {
} }
/** /**
* registerServiceWorker registers the install-only worker (ui/public/sw.js). Web-only: skipped in * registerServiceWorker registers the app-shell service worker (built from ui/src/sw.ts to
* the mock build (a real worker would perturb the Playwright run) and inside a Telegram/VK Mini * dist/sw.js by vite-plugin-pwa): it satisfies Chromium's installability requirement and precaches
* App. Best-effort — a failed registration only means the app is not installable. * the shell + assets so an installed PWA cold-launches offline. Web-only: skipped in the mock build
* (a real worker would perturb the Playwright run) and inside a Telegram/VK Mini App. Best-effort —
* a failed registration only means the app is not installable and cannot launch offline.
*/ */
export function registerServiceWorker(): void { export function registerServiceWorker(): void {
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return; if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
+37
View File
@@ -0,0 +1,37 @@
/// <reference lib="webworker" />
// Offline app-shell service worker (vite-plugin-pwa, injectManifest strategy). It precaches the app
// shell and its hashed assets so the installed PWA cold-launches with no network, and falls every
// in-scope navigation back to the precached shell (the hash router resolves the route client-side,
// so any deep link boots offline). It supersedes the former install-only public/sw.js. The
// Connect-RPC stream and the runtime API calls are POSTs — never precached, never matched by the
// navigation route — so the live app, its immutable hashed assets and the event stream are never
// served stale. Registration stays manual and web-only (pwa.svelte.ts): skipped in the mock build
// and inside a Telegram/VK Mini App.
import { clientsClaim } from 'workbox-core';
import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching';
import { NavigationRoute, registerRoute } from 'workbox-routing';
// The injectManifest build replaces self.__WB_MANIFEST with the precache list; the DOM lib knows
// neither it nor the service-worker scope, so narrow self here.
declare const self: ServiceWorkerGlobalScope & {
__WB_MANIFEST: (string | { url: string; revision: string | null })[];
};
// Activate a new revision immediately and take control of open clients, matching the former SW's
// skipWaiting; the no-cache HTML shell + immutable hashed assets keep a mid-session update safe.
self.skipWaiting();
clientsClaim();
// Drop precaches left by a previous SW revision (including the old install-only shell cache).
cleanupOutdatedCaches();
// Precache the shell + hashed assets injected at build time — exactly what a cold offline launch
// needs. The landing page and the old-engine polyfill bundle are excluded via the build globs.
precacheAndRoute(self.__WB_MANIFEST);
// Serve the precached shell for any in-scope top-level navigation. Deny-list the RPC path and the
// admin console so those are never resolved to the app shell.
registerRoute(
new NavigationRoute(createHandlerBoundToURL('index.html'), {
denylist: [/^\/scrabble\.edge\.v1\.Gateway/, /^\/_gm\//],
}),
);
+27 -1
View File
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { defineConfig, type Plugin } from 'vite'; import { defineConfig, type Plugin } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte'; import { svelte } from '@sveltejs/vite-plugin-svelte';
import { VitePWA } from 'vite-plugin-pwa';
/** /**
* injectBootVersion stamps the app version into index.html's boot-capability guard, replacing its * injectBootVersion stamps the app version into index.html's boot-capability guard, replacing its
@@ -56,7 +57,32 @@ export default defineConfig(({ mode }) => ({
}, },
// emitPolyfills ships dist/polyfills.js (loaded only by old engines; see its docstring + the // emitPolyfills ships dist/polyfills.js (loaded only by old engines; see its docstring + the
// index.html boot guard). injectBootVersion stamps the app version into that guard's diagnostic. // index.html boot guard). injectBootVersion stamps the app version into that guard's diagnostic.
plugins: [svelte(), emitPolyfills(), injectBootVersion()], plugins: [
svelte(),
emitPolyfills(),
injectBootVersion(),
// App-shell precache for the offline mode: a custom (injectManifest) service worker precaches
// index.html + the hashed assets so the installed web PWA cold-launches with no network. It
// supersedes the former install-only public/sw.js and outputs to dist/sw.js (same path, so the
// existing /app/ registration is unchanged). Registration is manual + web-only (injectRegister
// off; see pwa.svelte.ts) and the whole plugin is disabled in the mock build so Playwright is
// never perturbed. manifest:false — we already ship public/manifest.webmanifest.
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
injectRegister: false,
manifest: false,
disable: mode === 'mock',
injectManifest: {
// The app shell only: exclude the separate landing page and the conditional polyfill bundle
// (loaded document.write-only on an old engine; never part of the offline shell).
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff,woff2}'],
globIgnores: ['**/landing*', '**/polyfills.js'],
},
devOptions: { enabled: false },
}),
],
server: { server: {
port: 5173, port: 5173,
proxy: proxy: