/** * Service worker (Decisions.md D-007) — hand-rolled, no Workbox. * * Strategy: * - App shell: precached on install (production; list injected by * `scripts/build.ts` via the placeholders below), cache-first thereafter. * In dev the list is empty and the shell is served network-first with a * cache fallback, so HMR keeps working while offline still functions. * - `/ort/*` (onnxruntime .wasm/.mjs, served from our origin — see * src/index.ts): runtime-cached, cache-first. These are required for * offline inference but too large to precache eagerly. * - `/api/*`: network-only — never cached (auth/server engine, M4/M5). * - Model weights are NOT cached here (D-005): Transformers.js persists Hub * downloads in its own `transformers-cache`, which we must never touch. */ // Placeholders replaced by injectPrecacheManifest (src/pwa/precache.ts). // In dev src/index.ts injects an empty list + "dev"; in production // scripts/build.ts injects the hashed dist asset list + a content hash. const PRECACHE: string[] = JSON.parse("__PRECACHE_MANIFEST__") as string[]; const VERSION = "__CACHE_VERSION__"; const SHELL_CACHE = `whisper-shell-${VERSION}`; const RUNTIME_CACHE = `whisper-runtime-${VERSION}`; /** Caches owned by this SW — anything else (e.g. transformers-cache) is kept. */ const OWN_CACHE_PREFIXES = ["whisper-shell-", "whisper-runtime-"]; // `lib: ["DOM"]` types `self` as Window; declare the worker-scope shape we // use (same approach as src/worker/inference.worker.ts). interface ExtendableEventLike extends Event { waitUntil(promise: Promise): void; } interface FetchEventLike extends ExtendableEventLike { readonly request: Request; respondWith(response: Promise | Response): void; } interface ServiceWorkerScope { location: Location; skipWaiting(): Promise; clients: { claim(): Promise }; addEventListener(type: "install" | "activate", listener: (event: ExtendableEventLike) => void): void; addEventListener(type: "fetch", listener: (event: FetchEventLike) => void): void; addEventListener(type: "message", listener: (event: MessageEvent) => void): void; } const sw = self as unknown as ServiceWorkerScope; const PRECACHE_URLS = PRECACHE.map(path => new URL(path, sw.location.href).pathname); sw.addEventListener("install", (event) => { event.waitUntil( (async () => { if (PRECACHE.length === 0) return; // dev — runtime caching only const cache = await caches.open(SHELL_CACHE); await cache.addAll(PRECACHE); })(), ); }); sw.addEventListener("activate", (event) => { event.waitUntil( (async () => { // Drop caches from previous SW versions; never touch transformers-cache. const stale = (await caches.keys()).filter( (name) => OWN_CACHE_PREFIXES.some((prefix) => name.startsWith(prefix)) && name !== SHELL_CACHE && name !== RUNTIME_CACHE, ); await Promise.all(stale.map((name) => caches.delete(name))); // Control already-open pages so /ort/* caching works without a reload. await sw.clients.claim(); })(), ); }); // The update toast posts this after user consent (src/pwa/register.ts). sw.addEventListener("message", (event) => { const data = event.data as { type?: string } | null; if (data?.type === "SKIP_WAITING") void sw.skipWaiting(); }); /** Serve from `cacheName`, populating it from the network on first miss. */ async function cacheFirst(cacheName: string, request: Request): Promise { const cache = await caches.open(cacheName); const hit = await cache.match(request); if (hit) return hit; const response = await fetch(request); if (response.status === 200) await cache.put(request, response.clone()); return response; } /** * Fetch, mirroring successes into the shell cache; on network failure fall * back to the cache (and `fallbackPath`, e.g. "/" for navigations). */ async function networkFirst(request: Request, fallbackPath: string | null): Promise { const cache = await caches.open(SHELL_CACHE); try { const response = await fetch(request); if (response.status === 200) await cache.put(request, response.clone()); return response; } catch (error) { const hit = await cache.match(request); if (hit) return hit; if (fallbackPath) { const fallback = await cache.match(fallbackPath); if (fallback) return fallback; } throw error; } } sw.addEventListener("fetch", (event) => { const request = event.request; if (request.method !== "GET") return; const url = new URL(request.url); // Cross-origin (Hugging Face Hub model weights, etc.): hands off — // Transformers.js caches those itself in transformers-cache (D-005). if (url.origin !== sw.location.origin) return; // API is network-only (D-007) — auth and the server engine must never be // served stale from cache. if (url.pathname.includes("/api/")) return; if (url.pathname.includes("/ort/")) { event.respondWith(cacheFirst(RUNTIME_CACHE, request)); return; } if (PRECACHE_URLS.includes(url.pathname)) { event.respondWith(cacheFirst(SHELL_CACHE, request)); return; } // Anything else (dev's unhashed shell, navigations to deep links): // network-first with cache fallback; navigations fall back to the shell. event.respondWith(networkFirst(request, request.mode === "navigate" ? "./" : null)); }); export {};