/** * `@nifrajs/web/service-worker` - generate a service worker from a build manifest. * * A service worker is the one piece of an app that outlives a deploy, keeps serving after you have * stopped, and can hand one user a response produced for another. So the generated worker is * deliberately narrow, and every rule below exists because the permissive version of it is a bug: * * - **Only content-hashed assets are precached.** A hashed URL names its bytes, so serving it from * cache forever is correct by construction. Anything unhashed is left to the network. * - **Documents are never cached.** Only navigations that FAIL are answered, and only with the * offline page you nominate. A cached HTML document is how a service worker serves one signed-in * user the page rendered for another; there is no per-user story here worth that risk. * - **GET, same-origin, `ok`, and not `no-store`.** Anything else goes straight to the network. * - **The cache name carries the build id.** A deploy that changed the assets gets a new cache, and * activation deletes every older one, so a stale worker cannot pin an old build indefinitely. * * It is opt-in and generated at build time; an app that does not call this ships nothing. */ /** The parts of a `BuildManifest` a worker needs. Structural, so a caller can pass extra fields. */ export interface ServiceWorkerManifest { readonly entry: string readonly assets: readonly string[] readonly css?: readonly string[] } export interface ServiceWorkerOptions { /** * Distinguishes this build's cache from the last one. Use something that changes exactly when the * assets do - a content hash, a commit sha, a release version. */ readonly buildId: string /** * URL of a page to serve when a navigation fails and the network is unreachable. It must be a * static, user-independent document (a prerendered `/offline`), because every visitor gets the same * bytes. Omit it and failed navigations simply fail, which is the honest default. */ readonly offlineUrl?: string /** Cache name prefix. Default `nifra`. */ readonly cacheName?: string /** Extra same-origin URLs to precache. Only pass immutable ones. */ readonly additionalPrecache?: readonly string[] } const SW_URL = /^\/[\w\-./@%]*$/ /** A precache URL has to be same-origin, rooted, and free of anything that could break out of a list. */ import { jsStringLiteral } from "./internal/js-string.ts" function assertPrecachable(url: string, label: string): void { if (typeof url !== "string" || url === "") { throw new Error(`[nifra/service-worker] ${label} must be a non-empty string`) } if (!url.startsWith("/") || url.startsWith("//")) { throw new Error( `[nifra/service-worker] ${label} must be a root-relative same-origin path, got ${JSON.stringify(url)}`, ) } if (!SW_URL.test(url)) { throw new Error( `[nifra/service-worker] ${label} contains characters that are not valid in an asset path: ${JSON.stringify(url)}`, ) } } /** * Generate the service worker source for a build. * * Write the result to a file served from the ORIGIN ROOT (`/sw.js`): a worker's default scope is its * own directory, so one served from `/assets/` could never control the pages it exists for. */ export function generateServiceWorker( manifest: ServiceWorkerManifest, options: ServiceWorkerOptions, ): string { if (typeof options.buildId !== "string" || options.buildId.trim() === "") { throw new Error("[nifra/service-worker] buildId is required and must be non-empty") } if (/[^\w.-]/.test(options.buildId)) { throw new Error( `[nifra/service-worker] buildId must be word characters, dots or hyphens, got ${JSON.stringify(options.buildId)}`, ) } const prefix = options.cacheName ?? "nifra" if (/[^\w.-]/.test(prefix)) { throw new Error(`[nifra/service-worker] cacheName must be word characters, dots or hyphens`) } const precache = [ manifest.entry, ...manifest.assets, ...(manifest.css ?? []), ...(options.additionalPrecache ?? []), ] for (const url of precache) assertPrecachable(url, "precache url") const offline = options.offlineUrl if (offline !== undefined) assertPrecachable(offline, "offlineUrl") // De-duped and sorted so an unchanged build produces an unchanged worker - a byte-identical file // means the browser does not treat it as an update. const urls = [...new Set(offline === undefined ? precache : [...precache, offline])].sort() return `// Generated by @nifrajs/web/service-worker. Do not edit. const CACHE = ${JSON.stringify(`${prefix}-${options.buildId}`)} const PRECACHE = ${JSON.stringify(urls)} const OFFLINE = ${offline === undefined ? "null" : JSON.stringify(offline)} self.addEventListener("install", (event) => { // Take over as soon as the new assets are stored; the activate handler drops the old cache, so // waiting would only serve stale bytes for longer. event.waitUntil(caches.open(CACHE).then((c) => c.addAll(PRECACHE)).then(() => self.skipWaiting())) }) self.addEventListener("activate", (event) => { event.waitUntil( caches .keys() .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) .then(() => self.clients.claim()), ) }) self.addEventListener("fetch", (event) => { const request = event.request if (request.method !== "GET") return const url = new URL(request.url) if (url.origin !== self.location.origin) return // Navigations: network only, with the offline document as a last resort. A cached HTML response is // how one user gets served the page rendered for another, so nothing is stored here. if (request.mode === "navigate") { if (OFFLINE === null) return event.respondWith(fetch(request).catch(() => caches.match(OFFLINE).then((r) => r ?? Response.error()))) return } // Assets: cache first, because every precached URL is content-hashed and therefore immutable. event.respondWith( caches.match(request).then((hit) => { if (hit !== undefined) return hit return fetch(request).then((response) => { const cacheable = response.ok && response.type === "basic" && !(response.headers.get("cache-control") ?? "").includes("no-store") if (cacheable && PRECACHE.includes(url.pathname)) { const copy = response.clone() void caches.open(CACHE).then((c) => c.put(request, copy)) } return response }) }), ) }) ` } /** * The registration snippet, for a `