Files
whisper_web/src/pwa/sw.ts
T
2026-07-10 10:20:23 +02:00

138 lines
5.3 KiB
TypeScript

/**
* 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<unknown>): void;
}
interface FetchEventLike extends ExtendableEventLike {
readonly request: Request;
respondWith(response: Promise<Response> | Response): void;
}
interface ServiceWorkerScope {
location: Location;
skipWaiting(): Promise<void>;
clients: { claim(): Promise<void> };
addEventListener(type: "install" | "activate", listener: (event: ExtendableEventLike) => void): void;
addEventListener(type: "fetch", listener: (event: FetchEventLike) => void): void;
addEventListener(type: "message", listener: (event: MessageEvent<unknown>) => 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<Response> {
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<Response> {
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 {};