import { SERVICE_WORKER_BOUNDS, isOwnedStaticCacheName, staticCacheName, type ServiceWorkerHandlerId, type ServiceWorkerProtocolIdentity, type StaticAssetManifestV1, } from "../../contracts/service-worker.ts"; import { createServiceWorkerMessage, parseServiceWorkerMessage, } from "./service-worker-protocol.ts"; import { classifyFetch, installStaticAssets, selectCachesToDelete, } from "./service-worker-static-assets.ts"; /** * §17.9–§17.15. Worker-side lifecycle, expressed against structural types so it * can be unit-tested outside a real Service Worker global and compiled under * `tsconfig.service-worker.json` without pulling in DOM globals. */ export type WorkerClientLike = Readonly<{ id: string; url: string; postMessage(message: unknown): void; }>; export type WorkerScopeLike = Readonly<{ caches: Readonly<{ open(cacheName: string): Promise; keys(): Promise; delete(cacheName: string): Promise; }>; clients: Readonly<{ matchAll( options?: Readonly<{ type?: "window"; includeUncontrolled?: boolean; }>, ): Promise< readonly WorkerClientLike[] >; }>; registrationScope: string; skipWaiting(): Promise; fetcher: typeof fetch; digest(bytes: Uint8Array): Promise; }>; export type WorkerRuntimeConfig = Readonly<{ identity: ServiceWorkerProtocolIdentity; handlers: readonly ServiceWorkerHandlerId[]; manifest: StaticAssetManifestV1 | null; runtimeConfigUrl: string; releaseManifestUrl: string; }>; /** * SW-URL-01. Root-relative generated URLs become absolute same-origin URLs * exactly once. Anything that escapes the scope origin is dropped rather than * silently classified. */ function canonicalManifestUrls( assets: readonly Readonly<{ url: string }>[], scopeHref: string, ): readonly string[] { let base: URL; try { base = new URL(scopeHref); } catch { return []; } const canonical: string[] = []; for (const asset of assets) { try { const absolute = new URL(asset.url, base); if (absolute.origin !== base.origin) continue; canonical.push(absolute.href); } catch { // A manifest URL that cannot be canonicalized is never classified. } } return canonical; } const ACTIVATION_MARKER_URL = "https://clean-architecture.invalid/__service-worker-activation-v1__"; const ACTIVATION_MARKER_MAX_BYTES = 256; type ActivationMarker = Readonly<{ cacheName: string; activationSequence: number; }>; export function createServiceWorkerRuntime( scope: WorkerScopeLike, config: WorkerRuntimeConfig, ) { const staticEnabled = config.handlers.includes("PWA_STATIC_ASSETS"); /** * SW-URL-01. The generated manifest stores root-relative URLs while `Request` * exposes absolute ones, so comparing the two directly classified every * verified asset as a network fallback. Canonicalize once against the * registration scope, re-check same-origin, and share that identity across * install cache keys, fetch classification and cache lookup or delete. */ const manifestUrls: ReadonlySet = Object.freeze( new Set( staticEnabled ? canonicalManifestUrls( config.manifest?.assets ?? [], scope.registrationScope, ) : [], ), ); const consumedNonces = new Set(); type PendingActivation = Readonly<{ requesterBuildId: string; expectedClientIds: ReadonlySet; acknowledgedClientIds: Set; resolve(drained: boolean): void; timer: ReturnType; }>; const pendingActivations = new Map(); /** * §17.9. Without the static asset handler the install step opens zero caches; * it only registers lifecycle, push and sync handlers. */ async function onInstall(): Promise { if (!staticEnabled || !config.manifest) return; const outcome = await installStaticAssets(config.manifest, { caches: scope.caches, fetcher: scope.fetcher, digest: scope.digest, }); if (outcome.kind === "REJECTED") { throw new Error(`STATIC_INSTALL_REJECTED:${outcome.code}`); } } /** * §17.15. Delete only owned caches outside the current and one previous * revision. `clients.claim()` is never called (§17.12). */ async function onActivate(): Promise { if (!staticEnabled || !config.manifest) return 0; const current = staticCacheName(config.manifest.setDigest); const names = await scope.caches.keys(); const owned = names.filter(isOwnedStaticCacheName); const currentCache = await scope.caches.open(current); const oldCaches = owned.filter((name) => name !== current); const markers: ActivationMarker[] = []; for (const name of oldCaches) { const marker = await readActivationMarker(await scope.caches.open(name), name); if (marker) markers.push(marker); } const currentMarker = await readActivationMarker(currentCache, current); const highestOld = markers.reduce( (highest, marker) => !highest || marker.activationSequence >= highest.activationSequence ? marker : highest, null, ); const previous = highestOld?.cacheName ?? oldCaches.at(-1) ?? null; if ( !currentMarker || currentMarker.activationSequence < (highestOld?.activationSequence ?? 0) ) { const nextSequence = (highestOld?.activationSequence ?? 0) + 1; if (!Number.isSafeInteger(nextSequence)) { throw new Error("SERVICE_WORKER_ACTIVATION_SEQUENCE_EXHAUSTED"); } await currentCache.put( ACTIVATION_MARKER_URL, new Response( JSON.stringify({ schemaVersion: 1, cacheName: current, activationSequence: nextSequence, }), { status: 200, headers: { "content-type": "application/json" }, }, ), ); } const stale = selectCachesToDelete(names, current, previous); let deleted = 0; for (const name of stale) { if (await scope.caches.delete(name)) deleted += 1; } void SERVICE_WORKER_BOUNDS.retainedPreviousCaches; return deleted; } /** * §18.5–§18.7. A verified cache hit is returned; anything else goes to the * network and is never written back into the active cache at runtime. */ async function onFetch( request: Readonly<{ method: string; url: string; mode?: string }>, ): Promise { const classification = classifyFetch({ method: request.method, requestUrl: request.url, isNavigation: request.mode === "navigate", runtimeConfigUrl: config.runtimeConfigUrl, releaseManifestUrl: config.releaseManifestUrl, manifestUrls, }); if (classification !== "VERIFIED_CACHE_FIRST") return null; // SW-01. Only the current release cache may answer. A CacheStorage-wide // match could return a previous release's response for the same URL, and // the subsequent delete would then target a cache that was never read. const currentCacheName = config.manifest ? staticCacheName(config.manifest.setDigest) : null; if (!currentCacheName) return null; // SW-RR-04. Both `open` and `match` are storage calls that can throw // synchronously or reject. Either one escaping here rejects `respondWith` // itself, so the entry never reaches its network fallback and the page // gets a network error instead of the live response. let cached: Response | undefined; let currentCache: Cache; try { currentCache = await scope.caches.open(currentCacheName); cached = await currentCache.match(request.url); } catch { return null; } if (!cached) return null; if (cached.status !== 200 || cached.type === "opaque") { // §18.6. An invalid hit is deleted from the cache it was read from and // treated as a release mismatch. await currentCache.delete(request.url).catch(() => false); return null; } return cached; } /** * §17.11. The waiting worker validates the request, drains every controlled * client, and only then calls `skipWaiting()`. */ async function onActivateRequest( data: unknown, ): Promise<"ACCEPTED" | "REJECTED" | "IGNORED"> { const parsed = parseServiceWorkerMessage(data); if (!parsed.ok || parsed.message.kind !== "ACTIVATE_REQUEST") return "IGNORED"; const nonce = parsed.message.nonce; if (!nonce || consumedNonces.has(nonce)) return "REJECTED"; if ( parsed.message.targetBuildId !== undefined && parsed.message.targetBuildId !== config.identity.buildId ) { return "REJECTED"; } consumedNonces.add(nonce); if (consumedNonces.size > 64) { const oldest = consumedNonces.values().next().value; if (oldest !== undefined) consumedNonces.delete(oldest); } const candidates = await scope.clients.matchAll({ type: "window", includeUncontrolled: true, }); const clients = candidates.filter((client) => isClientWithinRegistrationScope(client.url, scope.registrationScope), ); const drained = await drainClients( clients, nonce, parsed.message.sourceBuildId, ); if (!drained) { notifyClients(clients, "ACTIVATE_REJECTED", parsed.message.sourceBuildId, nonce); return "REJECTED"; } // SW-08. `skipWaiting()` is the activation commit. It must succeed before // any client is told the activation was accepted, and its failure is a // rejection rather than an accepted-then-failed activation. try { await scope.skipWaiting(); } catch { notifyClients(clients, "ACTIVATE_REJECTED", parsed.message.sourceBuildId, nonce); return "REJECTED"; } // Post-commit notifications are per-client best effort. notifyClients(clients, "ACTIVATE_ACCEPTED", parsed.message.sourceBuildId, nonce); notifyClients( clients, "ACTIVATED_RELOAD_REQUIRED", parsed.message.sourceBuildId, nonce, ); return "ACCEPTED"; } /** * SW-08. One client's `postMessage()` throwing must not break the whole * activation event; delivery is isolated per client. */ function notifyClients( clients: readonly WorkerClientLike[], kind: "ACTIVATE_REJECTED" | "ACTIVATE_ACCEPTED" | "ACTIVATED_RELOAD_REQUIRED", targetBuildId: string, nonce: string, ): void { for (const client of clients) { try { client.postMessage( createServiceWorkerMessage({ kind, sourceBuildId: config.identity.buildId, targetBuildId, nonce, }), ); } catch { // A dead client cannot change the already committed activation. } } } async function drainClients( clients: readonly WorkerClientLike[], nonce: string, requesterBuildId: string, ): Promise { // SW-07. No in-scope client means nothing dirty to drain, so the set is // vacuously drained. A `clients.matchAll()` failure still rejects upstream. if (clients.length === 0) return true; const drained = new Promise((resolve) => { const timer = setTimeout(() => { pendingActivations.delete(nonce); resolve(false); }, SERVICE_WORKER_BOUNDS.clientDrainMs); pendingActivations.set( nonce, Object.freeze({ requesterBuildId, expectedClientIds: new Set(clients.map((client) => client.id)), acknowledgedClientIds: new Set(), resolve, timer, }), ); }); // SW-08. A client that cannot receive the drain request can never // acknowledge it, so it fails immediately instead of holding the pending // state until the timeout. for (const client of clients) { try { client.postMessage( createServiceWorkerMessage({ kind: "CLIENT_DRAIN_REQUEST", sourceBuildId: config.identity.buildId, targetBuildId: requesterBuildId, nonce, }), ); } catch { const pending = pendingActivations.get(nonce); if (pending) settlePendingActivation(nonce, pending, false); return await drained; } } return drained; } function onClientMessage(data: unknown, sourceClientId: string): void { const parsed = parseServiceWorkerMessage(data); if (!parsed.ok || !parsed.message.nonce) return; if ( parsed.message.targetBuildId !== config.identity.buildId || (parsed.message.kind !== "CLIENT_DRAINED" && parsed.message.kind !== "ACTIVATE_REJECTED") ) { return; } const pending = pendingActivations.get(parsed.message.nonce); if ( !pending || parsed.message.sourceBuildId !== pending.requesterBuildId || !pending.expectedClientIds.has(sourceClientId) ) { return; } if (parsed.message.kind === "ACTIVATE_REJECTED") { settlePendingActivation(parsed.message.nonce, pending, false); return; } pending.acknowledgedClientIds.add(sourceClientId); if ( pending.acknowledgedClientIds.size === pending.expectedClientIds.size ) { settlePendingActivation(parsed.message.nonce, pending, true); } } async function onCacheResetRequest( data: unknown, source: WorkerClientLike, ): Promise { const parsed = parseServiceWorkerMessage(data); if ( !parsed.ok || parsed.message.kind !== "CACHE_RESET_REQUEST" || !parsed.message.nonce || (parsed.message.targetBuildId !== undefined && parsed.message.targetBuildId !== config.identity.buildId) ) { return; } let cachesDeleted = 0; const names = await scope.caches.keys(); for (const name of names) { // SW-02. Exact ownership only: a prefix match would also delete // `ca-static-v1-not-owned` and any longer-suffixed foreign cache. if (!isOwnedStaticCacheName(name)) continue; try { if (await scope.caches.delete(name)) cachesDeleted += 1; } catch { return; } } source.postMessage( createServiceWorkerMessage({ kind: "CACHE_RESET_RESULT", sourceBuildId: config.identity.buildId, targetBuildId: parsed.message.sourceBuildId, nonce: parsed.message.nonce, cachesDeleted, }), ); } return Object.freeze({ onInstall, onActivate, onFetch, onActivateRequest, onClientMessage, onCacheResetRequest, manifestUrls: manifestUrls as ReadonlySet, }); function settlePendingActivation( nonce: string, pending: PendingActivation, drained: boolean, ): void { clearTimeout(pending.timer); pendingActivations.delete(nonce); pending.resolve(drained); } } function isClientWithinRegistrationScope( clientUrl: string, registrationScope: string, ): boolean { try { const client = new URL(clientUrl); const scope = new URL(registrationScope); return client.origin === scope.origin && client.href.startsWith(scope.href); } catch { return false; } } /** * SW-RR-01. Reads at most one byte beyond the marker ceiling, cancels the * reader as soon as that byte arrives, and fails closed on invalid UTF-8. The * reader is also raced against a deadline so a stream that never produces a * chunk cannot hold `activate` open. */ const ACTIVATION_MARKER_READ_DEADLINE_MS = 5_000; async function readBoundedMarkerText( response: Response, ): Promise { if (!response.body) { try { const text = await response.text(); return new TextEncoder().encode(text).byteLength > ACTIVATION_MARKER_MAX_BYTES ? null : text; } catch { return null; } } const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let total = 0; let timer: ReturnType | undefined; const deadline = new Promise<"DEADLINE">((resolve) => { timer = setTimeout( () => resolve("DEADLINE"), ACTIVATION_MARKER_READ_DEADLINE_MS, ); }); try { for (;;) { const next = await Promise.race([reader.read(), deadline]); if (next === "DEADLINE") return null; if (next.done) break; if (!next.value) continue; total += next.value.byteLength; if (total > ACTIVATION_MARKER_MAX_BYTES) return null; chunks.push(next.value); } } catch { return null; } finally { if (timer !== undefined) clearTimeout(timer); // Never awaited: cancelling a stream whose source ignores cancellation can // itself hang, and the marker read already has its answer. void reader.cancel().catch(() => {}); } const bytes = new Uint8Array(total); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { return null; } } async function readActivationMarker( cache: Cache, expectedCacheName: string, ): Promise { try { const response = await cache.match(ACTIVATION_MARKER_URL); if (!response || response.status !== 200) return null; const declaredLength = response.headers.get("content-length"); if ( declaredLength !== null && (!/^\d+$/u.test(declaredLength) || Number(declaredLength) > ACTIVATION_MARKER_MAX_BYTES) ) { return null; } // SW-RR-01. A Content-Length is a claim, not a bound. Without one the // previous `response.text()` read the whole body, so a large or // non-terminating stream could consume the activation step indefinitely. const text = await readBoundedMarkerText(response); if (text === null) return null; const value: unknown = JSON.parse(text); if ( value === null || typeof value !== "object" || (value as { schemaVersion?: unknown }).schemaVersion !== 1 || (value as { cacheName?: unknown }).cacheName !== expectedCacheName || !Number.isSafeInteger( (value as { activationSequence?: unknown }).activationSequence, ) || ((value as { activationSequence: number }).activationSequence ?? 0) < 1 ) { return null; } return Object.freeze({ cacheName: expectedCacheName, activationSequence: (value as { activationSequence: number }) .activationSequence, }); } catch { return null; } }