import { SERVICE_WORKER_BOUNDS, type InstalledServiceWorkerSelection, type ServiceWorkerActivationOutcome, type ServiceWorkerRemovalOutcome, type ServiceWorkerResetOutcome, type ServiceWorkerRuntimeHost, type ServiceWorkerStartOutcome, } from "../../contracts/service-worker.ts"; import { createNonceRegistry, createServiceWorkerMessage, parseServiceWorkerMessage, } from "./service-worker-protocol.ts"; import { expectedServiceWorkerUrls, isOwnedRegistration, purgeOwnedResources, removeOwnedRegistration, } from "./service-worker-removal.ts"; /** * §17.5–§17.16. The page-side controller. * * Registration happens after Runtime Config, release and contract set have all * validated and the first React effect has committed. The controller never * calls `skipWaiting()` blindly and never calls `clients.claim()`. */ export type ActivationBlocker = () => boolean; export type PageControllerDependencies = Readonly<{ selection: InstalledServiceWorkerSelection | null; /** True when static selection is ACTIVE but Runtime Config disabled it. */ disabledCleanup: boolean; routerBasePath: string; origin: string; buildId: string; container?: ServiceWorkerContainer; caches?: CacheStorage; /** §17.10. Any blocker returning true rejects automatic activation. */ blockers?: readonly ActivationBlocker[]; now?: () => number; observe?: (observation: Readonly<{ event: string; outcome: string }>) => void; }>; export function createServiceWorkerPageController( dependencies: PageControllerDependencies, ): ServiceWorkerRuntimeHost { const nonces = createNonceRegistry(); const now = dependencies.now ?? (() => Date.now()); const urls = expectedServiceWorkerUrls( dependencies.routerBasePath, dependencies.origin, ); let registrationPromise: Promise | null = null; let registration: ServiceWorkerRegistration | null = null; let messageListener: ((event: MessageEvent) => void) | null = null; let updateTimer: ReturnType | null = null; /** SW-06. Single-flight command state. */ let activationInFlight: Promise | null = null; let resetInFlight: Promise | null = null; let stopped = false; const pendingStops = new Set<() => void>(); const observe = (event: string, outcome: string) => dependencies.observe?.({ event, outcome }); function isBlocked(): boolean { for (const blocker of dependencies.blockers ?? []) { try { if (blocker()) return true; } catch { // A defective blocker is treated as blocking: never activate on doubt. return true; } } return false; } /** * SW-04. Staged removal reports what actually happened. * * Returning DISABLED for every outcome let a later release delete the worker * source and handlers while a registration or an owned cache was still * present, or while the registration belonged to someone else. */ function removalStartOutcome( outcome: ServiceWorkerRemovalOutcome, failureReason: string, ): ServiceWorkerStartOutcome { switch (outcome.kind) { case "ABSENT": case "UNREGISTERED": case "PURGED": return Object.freeze({ kind: "DISABLED" as const }); case "OWNERSHIP_MISMATCH": return Object.freeze({ kind: "INCOMPATIBLE" as const }); case "FAILED": return failed(failureReason); } } async function start(): Promise { if (stopped) return failed("STOPPED"); const container = dependencies.container; // §3.6 / §17.6. Static ACTIVE plus runtime DISABLED performs exactly one // owned-registration lookup and at most one unregister. No new register, no // cache deletion, no message or update timer. if (dependencies.disabledCleanup) { if (!container) return Object.freeze({ kind: "DISABLED" as const }); const outcome = await removeOwnedRegistration({ container, routerBasePath: dependencies.routerBasePath, origin: dependencies.origin, }); observe("disable_cleanup", outcome.kind); return removalStartOutcome(outcome, "DISABLE_CLEANUP_FAILED"); } const selection = dependencies.selection; // §3.6 / §17.3 `null`: zero registration lookups and zero Cache Storage // access. The controller must not even probe. if (!selection) return Object.freeze({ kind: "DISABLED" as const }); if (!container) return Object.freeze({ kind: "INCOMPATIBLE" as const }); if (selection.mode === "REMOVE_REGISTRATION") { const outcome = await removeOwnedRegistration({ container, routerBasePath: dependencies.routerBasePath, origin: dependencies.origin, }); observe("remove_registration", outcome.kind); return removalStartOutcome(outcome, "REMOVE_FAILED"); } if (selection.mode === "PURGE_OWNED_RESOURCES") { const outcome = await purgeOwnedResources({ container, ...(dependencies.caches ? { caches: dependencies.caches } : {}), routerBasePath: dependencies.routerBasePath, origin: dependencies.origin, }); observe("purge_owned_resources", outcome.kind); return removalStartOutcome(outcome, "PURGE_FAILED"); } // §17.5. StrictMode's repeated effect returns the same in-flight promise // instead of issuing a second registration. registrationPromise ??= container.register(urls.scriptHref, { scope: urls.scopePath, type: "module", updateViaCache: "none", }); let installedRegistration: ServiceWorkerRegistration; try { installedRegistration = await registrationPromise; } catch { registrationPromise = null; observe("register", "FAILED"); return failed("REGISTRATION_FAILED"); } if (stopped) return failed("STOPPED"); registration = installedRegistration; if ( !isOwnedRegistration({ registration, expectedScopeHref: urls.scopeHref, expectedScriptHref: urls.scriptHref, }) ) { observe("register", "OWNERSHIP_MISMATCH"); return Object.freeze({ kind: "INCOMPATIBLE" as const }); } attachMessageListener(container); scheduleUpdateChecks(); if (registration.waiting) { observe("register", "UPDATE_WAITING"); return Object.freeze({ kind: "UPDATE_WAITING" as const }); } // §17.13. Without `clients.claim()` the first install leaves this page // uncontrolled. That is reported, never silently reloaded. if (registration.active && !container.controller) { observe("register", "RELOAD_TO_ENABLE"); return Object.freeze({ kind: "RELOAD_TO_ENABLE" as const }); } observe("register", "ACTIVE"); return Object.freeze({ kind: "ACTIVE" as const, buildId: dependencies.buildId, }); } function attachMessageListener(container: ServiceWorkerContainer): void { if (messageListener) return; messageListener = (event: MessageEvent) => { if (event.origin && event.origin !== dependencies.origin) return; const parsed = parseServiceWorkerMessage(event.data); if (!parsed.ok) { observe("message", parsed.code); return; } if ( parsed.message.targetBuildId !== undefined && parsed.message.targetBuildId !== dependencies.buildId ) { observe("message", "TARGET_BUILD_MISMATCH"); return; } if (parsed.message.kind === "CLIENT_DRAIN_REQUEST") { const nonce = parsed.message.nonce; const source = event.source; if (!nonce || !canPostMessage(source)) { observe("client_drain", "MALFORMED"); return; } // SW-06. An arbitrary same-origin source must not be able to close this // page's admission. The request has to come from the worker we are // actually waiting on or the one currently controlling us. if (!isExpectedWorkerSource(source)) { observe("client_drain", "SOURCE_MISMATCH"); return; } const rejected = isBlocked(); source.postMessage( createServiceWorkerMessage({ kind: rejected ? "ACTIVATE_REJECTED" : "CLIENT_DRAINED", sourceBuildId: dependencies.buildId, targetBuildId: parsed.message.sourceBuildId, nonce, }), ); observe("client_drain", rejected ? "BLOCKED" : "DRAINED"); return; } observe("message", parsed.message.kind); }; container.addEventListener("message", messageListener); } /** * SW-06. Source identity is checked by object identity against the * registration's waiting/installing/active worker and the container's * controller. An empty `event.origin` is never used as a trust signal. */ function isExpectedWorkerSource(source: unknown): boolean { const expected = [ registration?.waiting, registration?.installing, registration?.active, dependencies.container?.controller, ]; return expected.some( (candidate) => candidate != null && candidate === source, ); } function scheduleUpdateChecks(): void { // §17.14. At most one check per 6 hours, and none while the page is hidden. if (updateTimer) return; updateTimer = setInterval(() => { if (typeof document !== "undefined" && document.visibilityState === "hidden") { return; } void registration?.update().catch(() => { // A failed update check never fails a product flow. }); }, SERVICE_WORKER_BOUNDS.updateCheckIntervalMs); } /** * §17.11. Activation is a handshake: every controlled client must close new * admission and acknowledge within 30s. One missing client rejects it. */ function requestActivation(): Promise { // SW-06. Concurrent callers share one command: a second call must not issue // a second nonce, a second listener or a second postMessage. activationInFlight ??= runActivation().finally(() => { activationInFlight = null; }); return activationInFlight; } async function runActivation(): Promise { const waiting = registration?.waiting; if (!waiting) return Object.freeze({ kind: "NO_WAITING_WORKER" as const }); if (isBlocked()) { observe("activation", "BLOCKED_DIRTY_CLIENT"); return Object.freeze({ kind: "BLOCKED_DIRTY_CLIENT" as const }); } const nonce = nonces.issue(); const deadline = now() + SERVICE_WORKER_BOUNDS.clientDrainMs; const accepted = await new Promise( (resolve) => { const container = dependencies.container; if (!container) { resolve(Object.freeze({ kind: "PROTOCOL_MISMATCH" as const })); return; } let settled = false; const finish = (outcome: ServiceWorkerActivationOutcome) => { if (settled) return; settled = true; nonces.consume(nonce); clearTimeout(timer); container.removeEventListener("message", onMessage); pendingStops.delete(onStop); resolve(outcome); }; const onMessage = (event: MessageEvent) => { const parsed = parseServiceWorkerMessage(event.data); if (!parsed.ok) return; if ( parsed.message.targetBuildId !== undefined && parsed.message.targetBuildId !== dependencies.buildId ) { return; } // SW-06 / SW-RR-02. The reply must come from the exact worker this // request was sent to. A matching nonce is not identity: `null` // source means the sender cannot be established, so it is refused // like any other mismatch rather than accepted as this worker. if (event.source !== waiting) { finish(Object.freeze({ kind: "PROTOCOL_MISMATCH" as const })); return; } if (registration?.waiting !== waiting) { finish(Object.freeze({ kind: "PROTOCOL_MISMATCH" as const })); return; } if ( parsed.message.kind === "ACTIVATE_REJECTED" && parsed.message.nonce === nonce ) { finish(Object.freeze({ kind: "BLOCKED_DIRTY_CLIENT" as const })); return; } if ( parsed.message.kind === "ACTIVATED_RELOAD_REQUIRED" && parsed.message.nonce === nonce ) { finish( Object.freeze({ kind: "ACTIVATED_RELOAD_REQUIRED" as const }), ); } }; const onStop = () => finish(Object.freeze({ kind: "FAILED" as const, code: "STOPPED" })); const timer = setTimeout( () => finish(Object.freeze({ kind: "CLIENT_DRAIN_TIMEOUT" as const })), Math.max(0, deadline - now()), ); pendingStops.add(onStop); container.addEventListener("message", onMessage); try { waiting.postMessage( createServiceWorkerMessage({ kind: "ACTIVATE_REQUEST", sourceBuildId: dependencies.buildId, nonce, }), ); } catch { finish(Object.freeze({ kind: "FAILED" as const, code: "POST_FAILED" })); } }, ); observe("activation", accepted.kind); return accepted; } /** §18.10. Static caches only; the registration itself is left in place. */ function resetOwnedCaches(): Promise { // SW-06. Single-flight, like activation. resetInFlight ??= runReset().finally(() => { resetInFlight = null; }); return resetInFlight; } async function runReset(): Promise { const container = dependencies.container; if (!container?.controller) { return Object.freeze({ kind: "NOT_CONTROLLED" as const }); } // SW-06. The reply must come from the controller this request was sent to. const requestedController = container.controller; const nonce = nonces.issue(); return new Promise((resolve) => { let settled = false; const finish = (outcome: ServiceWorkerResetOutcome) => { if (settled) return; settled = true; nonces.consume(nonce); clearTimeout(timer); container.removeEventListener("message", onMessage); pendingStops.delete(onStop); resolve(outcome); }; const onMessage = (event: MessageEvent) => { if (event.origin && event.origin !== dependencies.origin) return; const parsed = parseServiceWorkerMessage(event.data); if ( !parsed.ok || parsed.message.kind !== "CACHE_RESET_RESULT" || parsed.message.targetBuildId !== dependencies.buildId || parsed.message.nonce !== nonce ) { return; } // SW-RR-02. An unattributable reset result is never proof this // controller performed the reset. if ( event.source !== requestedController || container.controller !== requestedController ) { finish( Object.freeze({ kind: "FAILED" as const, code: "PROTOCOL_MISMATCH", }), ); return; } finish( Object.freeze({ kind: "RESET" as const, cachesDeleted: parsed.message.cachesDeleted ?? 0, }), ); }; const onStop = () => finish(Object.freeze({ kind: "FAILED" as const, code: "STOPPED" })); const timer = setTimeout( () => finish( Object.freeze({ kind: "FAILED" as const, code: "RESET_TIMEOUT" }), ), SERVICE_WORKER_BOUNDS.clientDrainMs, ); pendingStops.add(onStop); container.addEventListener("message", onMessage); try { container.controller?.postMessage( createServiceWorkerMessage({ kind: "CACHE_RESET_REQUEST", sourceBuildId: dependencies.buildId, nonce, }), ); observe("cache_reset", "REQUESTED"); } catch { finish( Object.freeze({ kind: "FAILED" as const, code: "POST_FAILED" }), ); } }); } /** §17.16. Ordinary shutdown removes listeners and timers; it never unregisters. */ async function stop(): Promise { stopped = true; for (const stopPending of [...pendingStops]) stopPending(); pendingStops.clear(); if (updateTimer) { clearInterval(updateTimer); updateTimer = null; } if (messageListener && dependencies.container) { dependencies.container.removeEventListener("message", messageListener); messageListener = null; } nonces.clear(); } return Object.freeze({ start, requestActivation, resetOwnedCaches, stop }); } function canPostMessage( source: MessageEventSource | null, ): source is MessageEventSource & { postMessage(message: unknown): void } { return !!source && typeof source.postMessage === "function"; } function failed(code: string): ServiceWorkerStartOutcome { return Object.freeze({ kind: "FAILED" as const, code }); }