import { isOwnedStaticCacheName, SERVICE_WORKER_BOUNDS, staticCacheName, type StaticAssetManifestV1, } from "../../contracts/service-worker.ts"; /** * §17.9 / §18. Static asset install and fetch classification. * * Only immutable hashed build assets are cached, all-or-nothing, verified at * install time. Navigation, runtime config, the release manifest and every API * response are network-only, and no runtime response is ever written into the * active cache. */ export type FetchClassification = | "NETWORK_PASSTHROUGH" | "NETWORK_ONLY" | "VERIFIED_CACHE_FIRST"; export type ClassificationInput = Readonly<{ method: string; requestUrl: string; isNavigation: boolean; runtimeConfigUrl: string; releaseManifestUrl: string; manifestUrls: ReadonlySet; }>; /** * §18.5. Order matters: the exact static hit is evaluated before the generic * network passthrough, because an API base may legitimately be `/`. */ export function classifyFetch(input: ClassificationInput): FetchClassification { if (input.method !== "GET") return "NETWORK_PASSTHROUGH"; if (input.isNavigation) return "NETWORK_ONLY"; if ( sameResource(input.requestUrl, input.runtimeConfigUrl) || sameResource(input.requestUrl, input.releaseManifestUrl) ) { return "NETWORK_ONLY"; } if (input.manifestUrls.has(input.requestUrl)) return "VERIFIED_CACHE_FIRST"; return "NETWORK_PASSTHROUGH"; } function sameResource(left: string, right: string): boolean { try { const a = new URL(left); const b = new URL(right, left); return a.origin === b.origin && a.pathname === b.pathname; } catch { return false; } } export type InstallOutcome = | Readonly<{ kind: "INSTALLED"; cacheName: string; assets: number }> | Readonly<{ kind: "REJECTED"; code: | "MANIFEST_INVALID" | "ASSET_COUNT_EXCEEDED" | "ASSET_TOO_LARGE" | "ASSET_SET_TOO_LARGE" | "INSTALL_DEADLINE_EXCEEDED" | "FETCH_FAILED" | "STATUS_INVALID" | "CONTENT_TYPE_INVALID" | "BYTES_MISMATCH" | "INTEGRITY_MISMATCH" | "QUOTA_EXCEEDED"; }>; export type InstallDependencies = Readonly<{ caches: Readonly<{ open(cacheName: string): Promise; delete(cacheName: string): Promise; }>; fetcher: typeof fetch; digest(bytes: Uint8Array): Promise; }>; export function validateStaticAssetManifest( manifest: StaticAssetManifestV1, ): InstallOutcome | null { const bounds = SERVICE_WORKER_BOUNDS; if ( manifest.schemaVersion !== 1 || !/^sha256:[0-9a-f]{64}$/.test(manifest.setDigest) ) { return rejected("MANIFEST_INVALID"); } if (manifest.assets.length > bounds.assets) { return rejected("ASSET_COUNT_EXCEEDED"); } let total = 0; for (const asset of manifest.assets) { if ( !asset.url || !/^sha256:[0-9a-f]{64}$/.test(asset.sha256) || !Number.isSafeInteger(asset.bytes) || asset.bytes < 0 ) { return rejected("MANIFEST_INVALID"); } if (asset.bytes > bounds.singleAssetBytes) return rejected("ASSET_TOO_LARGE"); total += asset.bytes; } if (total > bounds.assetSetBytes) return rejected("ASSET_SET_TOO_LARGE"); return null; } /** * §17.9. A partial candidate is never used: any failure deletes the candidate * cache and rejects install, leaving the previous verified revision in place. */ export async function installStaticAssets( manifest: StaticAssetManifestV1, dependencies: InstallDependencies, ): Promise { const invalid = validateStaticAssetManifest(manifest); if (invalid) return invalid; const cacheName = staticCacheName(manifest.setDigest); const abortController = new AbortController(); let deadlineExceeded = false; let deadlineTimer: ReturnType | undefined; const deadline = new Promise((resolve) => { deadlineTimer = setTimeout(() => { deadlineExceeded = true; abortController.abort(); resolve(rejected("INSTALL_DEADLINE_EXCEEDED")); }, SERVICE_WORKER_BOUNDS.installDeadlineMs); }); const installation = installCandidate( manifest, cacheName, dependencies, abortController, ); const raced = await Promise.race([installation, deadline]); if (deadlineTimer !== undefined) clearTimeout(deadlineTimer); const outcome = deadlineExceeded ? rejected("INSTALL_DEADLINE_EXCEEDED") : raced; if (outcome.kind === "REJECTED") { await dependencies.caches.delete(cacheName).catch(() => false); // SW-09. A non-cooperative fetch, digest or `cache.put` started before the // deadline cannot be cancelled, so it may recreate the candidate cache // after that delete. The public result already closed at the deadline; a // second exact delete is registered once the abandoned work settles. It is // deliberately not awaited, so the public bound is not extended. if (deadlineExceeded) { void installation .catch(() => undefined) .then(async () => { await dependencies.caches.delete(cacheName).catch(() => false); }) .catch(() => undefined); } } return outcome; } async function installCandidate( manifest: StaticAssetManifestV1, cacheName: string, dependencies: InstallDependencies, abortController: AbortController, ): Promise { const signal = abortController.signal; let cache: Cache; try { cache = await dependencies.caches.open(cacheName); } catch { return rejected("QUOTA_EXCEEDED"); } const queue = [...manifest.assets]; let failure: InstallOutcome | null = null; const worker = async (): Promise => { for (;;) { if (failure) return; // SW-09. Once fenced, no new candidate work is started. if (signal.aborted) { failure ??= rejected("INSTALL_DEADLINE_EXCEEDED"); return; } const asset = queue.shift(); if (!asset) return; const outcome = await storeAsset(asset, cache, dependencies, signal); if (outcome) { failure ??= outcome; abortController.abort(); return; } } }; await Promise.all( Array.from({ length: SERVICE_WORKER_BOUNDS.fetchConcurrency }, worker), ); if (failure) return failure; return Object.freeze({ kind: "INSTALLED" as const, cacheName, assets: manifest.assets.length, }); } async function storeAsset( asset: StaticAssetManifestV1["assets"][number], cache: Cache, dependencies: InstallDependencies, signal: AbortSignal, ): Promise { if (signal.aborted) return rejected("FETCH_FAILED"); let response: Response; try { // SW-09. A non-cooperative fetch that ignores the signal still settles // later; its body is compensated so an abandoned response is not left open. const pending = dependencies.fetcher(asset.url, { cache: "no-store", credentials: "omit", redirect: "error", signal, }); const fetched = await abortable(pending, signal); if (fetched === ABORTED) { void pending .then(async (late) => { await late.body?.cancel(); }) .catch(() => undefined); } if (fetched === ABORTED) return rejected("FETCH_FAILED"); response = fetched; } catch { return rejected("FETCH_FAILED"); } if (response.status !== 200 || response.type === "opaque") { return rejected("STATUS_INVALID"); } const contentType = response.headers.get("content-type") ?? ""; if ( contentType.split(";", 1)[0]?.trim().toLowerCase() !== asset.contentType.toLowerCase() ) { return rejected("CONTENT_TYPE_INVALID"); } const body = await readBoundedBody(response, asset.bytes, signal); if (!body.ok) return rejected(body.code); const bytes = body.bytes; // SW-09. A digest dependency that throws becomes a closed typed outcome // rather than an escaping rejection. let digest: string | typeof ABORTED; try { digest = await abortable( Promise.resolve(dependencies.digest(bytes)), signal, ); } catch { return rejected("INTEGRITY_MISMATCH"); } if (digest === ABORTED) return rejected("FETCH_FAILED"); if (digest !== asset.sha256) return rejected("INTEGRITY_MISMATCH"); try { if (signal.aborted) return rejected("FETCH_FAILED"); await cache.put( asset.url, new Response(bytes.slice(), { status: response.status, statusText: response.statusText, headers: response.headers, }), ); } catch { return rejected("QUOTA_EXCEEDED"); } return null; } const ABORTED = Symbol("service-worker-install-aborted"); async function abortable( operation: Promise, signal: AbortSignal, ): Promise { if (signal.aborted) return ABORTED; let onAbort: (() => void) | undefined; const aborted = new Promise((resolve) => { onAbort = () => resolve(ABORTED); signal.addEventListener("abort", onAbort, { once: true }); }); try { return await Promise.race([operation, aborted]); } finally { if (onAbort) signal.removeEventListener("abort", onAbort); } } async function readBoundedBody( response: Response, expectedBytes: number, signal: AbortSignal, ): Promise< | Readonly<{ ok: true; bytes: Uint8Array }> | Readonly<{ ok: false; code: "BYTES_MISMATCH" | "FETCH_FAILED" }> > { const declaredLength = response.headers.get("content-length"); if ( declaredLength !== null && /^\d+$/u.test(declaredLength) && Number(declaredLength) !== expectedBytes ) { await response.body?.cancel().catch(() => {}); return Object.freeze({ ok: false as const, code: "BYTES_MISMATCH" as const }); } if (!response.body) { return expectedBytes === 0 ? Object.freeze({ ok: true as const, bytes: new Uint8Array() }) : Object.freeze({ ok: false as const, code: "BYTES_MISMATCH" as const }); } const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let total = 0; try { for (;;) { const result = await abortable(reader.read(), signal); if (result === ABORTED) { await reader.cancel().catch(() => {}); return Object.freeze({ ok: false as const, code: "FETCH_FAILED" as const }); } if (result.done) break; total += result.value.byteLength; if (total > expectedBytes) { await reader.cancel().catch(() => {}); return Object.freeze({ ok: false as const, code: "BYTES_MISMATCH" as const, }); } chunks.push(result.value); } } catch { return Object.freeze({ ok: false as const, code: "FETCH_FAILED" as const }); } finally { reader.releaseLock(); } if (total !== expectedBytes) { return Object.freeze({ ok: false as const, code: "BYTES_MISMATCH" as const }); } const bytes = new Uint8Array(total); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } return Object.freeze({ ok: true as const, bytes }); } /** * §17.15. Keep the current revision plus exactly one previous verified cache. * A cache found outside the owned prefix is left alone; a cache holding config, * manifest or API data is a security violation and is deleted. */ export function selectCachesToDelete( names: readonly string[], currentCacheName: string, previousCacheName: string | null, ): readonly string[] { return Object.freeze( names.filter( (name) => isOwnedStaticCacheName(name) && name !== currentCacheName && name !== previousCacheName, ), ); } function rejected(code: Extract["code"]) { return Object.freeze({ kind: "REJECTED" as const, code }); }