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
+5 -1
View File
@@ -32,6 +32,10 @@
"svelte-check": "^4.1.0",
"typescript": "^5.7.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
* 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.
* registerServiceWorker registers the app-shell service worker (built from ui/src/sw.ts to
* dist/sw.js by vite-plugin-pwa): it satisfies Chromium's installability requirement and precaches
* 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 {
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 { defineConfig, type Plugin } from 'vite';
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
@@ -56,7 +57,32 @@ export default defineConfig(({ mode }) => ({
},
// 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.
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: {
port: 5173,
proxy: