fix(pwa): harden service-worker caching
- commit
- e8f63e21006ac73b406ffcf677cd12eab80d088e
- parent
- 360bf7e6db079fe22397be863d3e34c285be3792
- parent
- 22166dba1811eff38d2f4ab2fe0badd1f067e45f
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | docs/ | 17 | + |
| M | package/ | 115 | +++++++--- |
| M | package/ | 380 | ++++++++++++++++++++++++++++-- |
@@ -88,6 +88,23 @@ Every prerendered route is included in the shell precache. While offline, a dire
8888 as `/field-notes/settings/` resolves its cached `settings/index.html`; if that route was not8989 emitted, the worker falls back to the cached application root.9090 91+### Offline cache policy92+93+The build's precache manifest contains required shell resources only. If any listed response cannot94+be fetched, service-worker installation fails and reports a precache error rather than exposing a95+worker that cannot cold-start the application. Product-specific optional resources do not belong in96+that manifest.97+98+Runtime caching is a separate, best-effort policy. It accepts only successful, public, same-origin99+responses inside the worker scope for fonts, images, scripts, styles, and workers. Navigation,100+cross-origin requests, partial responses, `private` or `no-store` responses, and `Vary: *` responses101+are not added. The cache retains at most 64 entries, moves refreshed URLs to the end, evicts the102+oldest inserted overflow, and removes previous build revisions during activation.103+104+Navigation preload remains disabled: generated routes and their assets are precached, so starting a105+parallel network request before the normal cache-first lookup would spend bandwidth on the expected106+offline-ready path. Jazz sync, OPFS storage, background sync, and push remain outside the worker.107+91108 Do not run a server-side Jazz credential in the static host or expose `JAZZ_ADMIN_SECRET` or92109 `BACKEND_SECRET` as public environment variables.93110 @@ -1,6 +1,11 @@
11 const BUILD_REVISION = "__LOFI_BUILD_REVISION__";22 // Package-owned service worker source.3-const CACHE_NAME = `lofi-shell-${BUILD_REVISION}`;3+const SHELL_CACHE_PREFIX = "lofi-shell-";4+const RUNTIME_CACHE_PREFIX = "lofi-runtime-";5+const SHELL_CACHE_NAME = `${SHELL_CACHE_PREFIX}${BUILD_REVISION}`;6+const RUNTIME_CACHE_NAME = `${RUNTIME_CACHE_PREFIX}${BUILD_REVISION}`;7+const MAX_RUNTIME_ENTRIES = 64;8+const RUNTIME_DESTINATIONS = new Set(["font", "image", "script", "style", "worker"]);49 510 async function reportFailure(code, message) {611 const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });@@ -16,7 +21,9 @@ self.addEventListener("install", (event) => {
1621 const response = await fetch(manifestUrl, { cache: "no-store" });1722 if (!response.ok) throw new Error(`precache manifest failed: ${response.status}`);1823 const paths = await response.json();19- const cache = await caches.open(CACHE_NAME);24+ // Every emitted path is part of the generated offline shell. Installation25+ // fails as one transaction rather than claiming readiness with a partial shell.26+ const cache = await caches.open(SHELL_CACHE_NAME);2027 await cache.addAll(paths.map((path) => new URL(path, self.registration.scope)));2128 } catch (error) {2229 await reportFailure(@@ -35,8 +42,13 @@ self.addEventListener("message", (event) => {
3542 self.addEventListener("activate", (event) => {3643 event.waitUntil((async () => {3744 for (const name of await caches.keys()) {38- if (name.startsWith("lofi-shell-") && name !== CACHE_NAME) await caches.delete(name);45+ const staleShell = name.startsWith(SHELL_CACHE_PREFIX) && name !== SHELL_CACHE_NAME;46+ const staleRuntime = name.startsWith(RUNTIME_CACHE_PREFIX) && name !== RUNTIME_CACHE_NAME;47+ if (staleShell || staleRuntime) await caches.delete(name);3948 }49+ // Navigation preload is intentionally disabled: generated routes and their50+ // assets are precached, so starting a network request before the cache lookup51+ // would spend bandwidth on the normal cache-first path.4052 await self.clients.claim();4153 })());4254 });@@ -52,37 +64,76 @@ function cachedPrerenderedNavigation(request) {
5264 return caches.match(new URL(`${route}/index.html`, scope));5365 }5466 67+function isRuntimeCacheEligible(request) {68+ if (request.method !== "GET" || request.mode === "navigate") return false;69+ if (!RUNTIME_DESTINATIONS.has(request.destination)) return false;70+ const url = new URL(request.url);71+ const scope = new URL(self.registration.scope);72+ return url.origin === scope.origin && url.pathname.startsWith(scope.pathname);73+}74+75+function isRuntimeResponseCacheable(response) {76+ if (!response.ok || response.status === 206) return false;77+ if (response.type !== "basic" && response.type !== "default") return false;78+ const cacheControl = response.headers.get("cache-control") ?? "";79+ if (/(?:^|,)\s*(?:no-store|private)(?:\s|,|$)/i.test(cacheControl)) return false;80+ return response.headers.get("vary")?.trim() !== "*";81+}82+83+async function trimRuntimeCache(cache) {84+ const keys = await cache.keys();85+ const overflow = keys.length - MAX_RUNTIME_ENTRIES;86+ for (const request of keys.slice(0, Math.max(0, overflow))) await cache.delete(request);87+}88+89+async function writeRuntimeCache(request, response) {90+ const cache = await caches.open(RUNTIME_CACHE_NAME);91+ // Reinsert a refreshed URL so cache key order tracks the latest successful write.92+ await cache.delete(request);93+ await cache.put(request, response);94+ await trimRuntimeCache(cache);95+}96+97+async function routeRequest(request) {98+ const cached = await caches.match(request, {99+ ignoreSearch: request.mode === "navigate",100+ });101+ if (cached) return { response: cached };102+ if (request.mode === "navigate") {103+ const prerendered = await cachedPrerenderedNavigation(request);104+ if (prerendered) return { response: prerendered };105+ }106+ try {107+ const response = await fetch(request);108+ const runtimeWrite = isRuntimeCacheEligible(request) && isRuntimeResponseCacheable(response)109+ ? writeRuntimeCache(request, response.clone())110+ : undefined;111+ return { response, runtimeWrite };112+ } catch (error) {113+ if (request.mode === "navigate") {114+ const shell = await caches.match(new URL("./", self.registration.scope));115+ if (shell) return { response: shell };116+ }117+ throw error;118+ }119+}120+55121 self.addEventListener("fetch", (event) => {56122 if (event.request.method !== "GET") return;57123 const requestUrl = new URL(event.request.url);58124 if (requestUrl.origin !== self.location.origin) return;59- event.respondWith((async () => {60- const cached = await caches.match(event.request, {61- ignoreSearch: event.request.mode === "navigate",62- });63- if (cached) return cached;64- if (event.request.mode === "navigate") {65- const prerendered = await cachedPrerenderedNavigation(event.request);66- if (prerendered) return prerendered;67- }68- try {69- const response = await fetch(event.request);70- if (response.ok) {71- const cache = await caches.open(CACHE_NAME);72- void cache.put(event.request, response.clone()).catch((error) =>73- reportFailure(74- "runtime-cache",75- error instanceof Error ? error.message : "runtime cache write failed",76- )77- );78- }79- return response;80- } catch (error) {81- if (event.request.mode === "navigate") {82- const shell = await caches.match(new URL("./", self.registration.scope));83- if (shell) return shell;84- }85- throw error;86- }87- })());125+ const result = routeRequest(event.request);126+ event.respondWith(result.then(({ response }) => response));127+ // waitUntil is attached synchronously. It follows the eventual cache write128+ // without delaying the response or misclassifying ordinary network failures.129+ event.waitUntil(result.then(130+ ({ runtimeWrite }) =>131+ runtimeWrite?.catch((error) =>132+ reportFailure(133+ "runtime-cache",134+ error instanceof Error ? error.message : "runtime cache write failed",135+ )136+ ),137+ () => undefined,138+ ));88139 });@@ -1,30 +1,364 @@
11 const serviceWorkerUrl = new URL("./sw.js", import.meta.url);2+const revision = "__LOFI_BUILD_REVISION__";3+const shellCacheName = `lofi-shell-${revision}`;4+const runtimeCacheName = `lofi-runtime-${revision}`;25 3-Deno.test("service worker precaches the shell and reports install failures", async () => {4- const source = await Deno.readTextFile(serviceWorkerUrl);5- for (6- const contract of [7- 'new URL("./lofi-precache.json", self.registration.scope)',8- "cache.addAll",9- 'reportFailure(\n "precache"',10- 'name.startsWith("lofi-shell-")',11- ]12- ) {13- if (!source.includes(contract)) throw new Error(`service worker omitted ${contract}`);6+function assert(condition: unknown, message: string): asserts condition {7+ if (!condition) throw new Error(message);8+}9+10+function requestUrl(input: RequestInfo | URL | Record<string, unknown>): string {11+ if (input instanceof Request) return input.url;12+ if (typeof input === "object" && "url" in input) return String(input.url);13+ return String(input);14+}15+16+class MemoryCache {17+ readonly entries = new Map<string, Response>();18+ putGate: Promise<void> | undefined;19+ failPut = false;20+21+ match(22+ input: RequestInfo | URL,23+ options: CacheQueryOptions = {},24+ ): Promise<Response | undefined> {25+ const requested = new URL(requestUrl(input));26+ if (options.ignoreSearch) requested.search = "";27+ for (const [key, response] of this.entries) {28+ const candidate = new URL(key);29+ if (options.ignoreSearch) candidate.search = "";30+ if (candidate.href === requested.href) return Promise.resolve(response.clone());31+ }32+ return Promise.resolve(undefined);33+ }34+35+ async put(input: RequestInfo | URL, response: Response): Promise<void> {36+ if (this.putGate) await this.putGate;37+ if (this.failPut) throw new Error("simulated cache write failure");38+ this.entries.set(requestUrl(input), response.clone());39+ }40+41+ delete(input: RequestInfo | URL): Promise<boolean> {42+ return Promise.resolve(this.entries.delete(requestUrl(input)));43+ }44+45+ keys(): Promise<Request[]> {46+ return Promise.resolve([...this.entries.keys()].map((url) => new Request(url)));47+ }48+49+ async addAll(inputs: readonly (RequestInfo | URL)[]): Promise<void> {50+ const responses = await Promise.all(inputs.map(async (input) => {51+ const response = await fetch(input);52+ if (!response.ok) throw new Error(`cache add failed: ${response.status}`);53+ return { input, response };54+ }));55+ for (const { input, response } of responses) await this.put(input, response);56+ }57+}58+59+class MemoryCacheStorage {60+ readonly stores = new Map<string, MemoryCache>();61+62+ open(name: string): Promise<MemoryCache> {63+ let cache = this.stores.get(name);64+ if (!cache) {65+ cache = new MemoryCache();66+ this.stores.set(name, cache);67+ }68+ return Promise.resolve(cache);69+ }70+71+ async match(72+ input: RequestInfo | URL,73+ options?: MultiCacheQueryOptions,74+ ): Promise<Response | undefined> {75+ if (options?.cacheName) return await (await this.open(options.cacheName)).match(input, options);76+ for (const cache of this.stores.values()) {77+ const response = await cache.match(input, options);78+ if (response) return response;79+ }80+ }81+82+ keys(): Promise<string[]> {83+ return Promise.resolve([...this.stores.keys()]);84+ }85+86+ delete(name: string): Promise<boolean> {87+ return Promise.resolve(this.stores.delete(name));88+ }89+}90+91+type ServiceWorkerHandler = (event: Record<string, unknown>) => void;92+type FetchResult = {93+ response?: Promise<Response>;94+ lifetimes: Promise<unknown>[];95+ waitUntilCalls: number;96+};97+98+async function serviceWorkerHarness(99+ fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,100+) {101+ const handlers = new Map<string, ServiceWorkerHandler>();102+ const messages: unknown[] = [];103+ const caches = new MemoryCacheStorage();104+ let claimed = 0;105+ const fakeSelf = {106+ location: { origin: "https://example.com" },107+ registration: { scope: "https://example.com/app/" },108+ clients: {109+ claim() {110+ claimed += 1;111+ return Promise.resolve();112+ },113+ matchAll() {114+ return Promise.resolve([{ postMessage: (message: unknown) => messages.push(message) }]);115+ },116+ },117+ skipWaiting: () => Promise.resolve(),118+ addEventListener(type: string, handler: ServiceWorkerHandler) {119+ handlers.set(type, handler);120+ },121+ };122+ const originals = new Map<string, PropertyDescriptor | undefined>();123+ const replacements: Array<[string, unknown]> = [124+ ["self", fakeSelf],125+ ["caches", caches],126+ ["fetch", fetchImpl],127+ ];128+ for (const [name, value] of replacements) {129+ originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));130+ Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });131+ }132+ await import(`${serviceWorkerUrl.href}?test=${crypto.randomUUID()}`);133+134+ const dispatchLifecycle = async (type: "install" | "activate") => {135+ const lifetimes: Promise<unknown>[] = [];136+ const handler = handlers.get(type);137+ assert(handler, `service worker omitted ${type} handler`);138+ handler({ waitUntil: (promise: Promise<unknown>) => lifetimes.push(Promise.resolve(promise)) });139+ await Promise.all(lifetimes);140+ };141+ const dispatchFetch = (request: Record<string, unknown>): FetchResult => {142+ const lifetimes: Promise<unknown>[] = [];143+ let response: Promise<Response> | undefined;144+ let waitUntilCalls = 0;145+ const handler = handlers.get("fetch");146+ assert(handler, "service worker omitted fetch handler");147+ handler({148+ request,149+ respondWith: (promise: Promise<Response>) => response = Promise.resolve(promise),150+ waitUntil: (promise: Promise<unknown>) => {151+ waitUntilCalls += 1;152+ lifetimes.push(Promise.resolve(promise));153+ },154+ });155+ return { response, lifetimes, waitUntilCalls };156+ };157+ const close = () => {158+ for (const [name, descriptor] of originals) {159+ if (descriptor) Object.defineProperty(globalThis, name, descriptor);160+ else delete (globalThis as Record<string, unknown>)[name];161+ }162+ };163+ return { caches, claimed: () => claimed, close, dispatchFetch, dispatchLifecycle, messages };164+}165+166+function fakeRequest(167+ url: string,168+ options: { destination?: string; mode?: string; method?: string } = {},169+): Record<string, unknown> {170+ return {171+ url,172+ destination: options.destination ?? "",173+ mode: options.mode ?? "same-origin",174+ method: options.method ?? "GET",175+ };176+}177+178+Deno.test("precache installation is atomic and reports an incomplete required shell", async () => {179+ const harness = await serviceWorkerHarness((input) => {180+ const url = requestUrl(input);181+ if (url.endsWith("lofi-precache.json")) {182+ return Promise.resolve(Response.json(["./", "./missing.js"]));183+ }184+ return Promise.resolve(185+ new Response(url.endsWith("missing.js") ? "missing" : "shell", {186+ status: url.endsWith("missing.js") ? 404 : 200,187+ }),188+ );189+ });190+ try {191+ let rejected = false;192+ try {193+ await harness.dispatchLifecycle("install");194+ } catch {195+ rejected = true;196+ }197+ assert(rejected, "partial required shell did not reject installation");198+ assert(199+ JSON.stringify(harness.messages).includes('"code":"precache"'),200+ "precache failure was not reported",201+ );202+ } finally {203+ harness.close();204+ }205+});206+207+Deno.test("offline nested navigation uses its precached prerendered page", async () => {208+ let offline = false;209+ const harness = await serviceWorkerHarness((input) => {210+ if (offline) return Promise.reject(new Error("offline"));211+ const url = requestUrl(input);212+ if (url.endsWith("lofi-precache.json")) {213+ return Promise.resolve(Response.json(["./", "./settings/index.html"]));214+ }215+ return Promise.resolve(216+ new Response(url.endsWith("settings/index.html") ? "nested settings" : "root shell"),217+ );218+ });219+ try {220+ await harness.dispatchLifecycle("install");221+ offline = true;222+ const event = harness.dispatchFetch(fakeRequest("https://example.com/app/settings/", {223+ mode: "navigate",224+ }));225+ assert(event.response, "navigation was not handled");226+ assert(await (await event.response).text() === "nested settings", "nested shell was not used");227+ await Promise.all(event.lifetimes);228+ } finally {229+ harness.close();230+ }231+});232+233+Deno.test("runtime cache writes remain attached to the fetch event lifetime", async () => {234+ const harness = await serviceWorkerHarness(() => Promise.resolve(new Response("image bytes")));235+ let releasePut: () => void = () => undefined;236+ const putGate = new Promise<void>((resolve) => releasePut = resolve);237+ try {238+ const runtime = await harness.caches.open(runtimeCacheName);239+ runtime.putGate = putGate;240+ const request = fakeRequest("https://example.com/app/photo.png", { destination: "image" });241+ const event = harness.dispatchFetch(request);242+ assert(event.waitUntilCalls === 1, "runtime lifecycle was not attached synchronously");243+ assert(event.response, "eligible request was not handled");244+ assert(await (await event.response).text() === "image bytes", "network response was delayed");245+ let settled = false;246+ void Promise.all(event.lifetimes).then(() => settled = true);247+ await Promise.resolve();248+ assert(!settled, "fetch lifetime settled before the cache write");249+ releasePut();250+ await Promise.all(event.lifetimes);251+ assert(252+ await runtime.match("https://example.com/app/photo.png"),253+ "runtime response was not cached",254+ );255+ } finally {256+ harness.close();257+ }258+});259+260+Deno.test("runtime cache rejects disallowed destinations and private responses", async () => {261+ const harness = await serviceWorkerHarness((input) => {262+ const privateResponse = requestUrl(input).endsWith("private.png");263+ return Promise.resolve(264+ new Response("network", {265+ headers: privateResponse ? { "cache-control": "private" } : undefined,266+ }),267+ );268+ });269+ try {270+ const documentEvent = harness.dispatchFetch(271+ fakeRequest("https://example.com/app/data.json", { destination: "" }),272+ );273+ assert(documentEvent.response, "same-origin request was not answered");274+ await documentEvent.response;275+ await Promise.all(documentEvent.lifetimes);276+277+ const privateEvent = harness.dispatchFetch(278+ fakeRequest("https://example.com/app/private.png", { destination: "image" }),279+ );280+ assert(privateEvent.response, "private image request was not answered");281+ await privateEvent.response;282+ await Promise.all(privateEvent.lifetimes);283+284+ const outsideScope = harness.dispatchFetch(285+ fakeRequest("https://example.com/outside/photo.png", { destination: "image" }),286+ );287+ assert(outsideScope.response, "same-origin outside-scope request was not answered");288+ await outsideScope.response;289+ await Promise.all(outsideScope.lifetimes);290+ const runtime = await harness.caches.open(runtimeCacheName);291+ assert((await runtime.keys()).length === 0, "unsuitable responses entered runtime cache");292+293+ const crossOrigin = harness.dispatchFetch(294+ fakeRequest("https://cdn.example.net/photo.png", { destination: "image" }),295+ );296+ assert(!crossOrigin.response, "cross-origin request was intercepted");297+ } finally {298+ harness.close();299+ }300+});301+302+Deno.test("runtime cache evicts its oldest entry at the fixed size bound", async () => {303+ const harness = await serviceWorkerHarness(() => Promise.resolve(new Response("new")));304+ try {305+ const runtime = await harness.caches.open(runtimeCacheName);306+ for (let index = 0; index < 64; index += 1) {307+ await runtime.put(`https://example.com/app/${index}.png`, new Response(String(index)));308+ }309+ const event = harness.dispatchFetch(310+ fakeRequest("https://example.com/app/new.png", { destination: "image" }),311+ );312+ assert(event.response, "bounded cache request was not answered");313+ await event.response;314+ await Promise.all(event.lifetimes);315+ assert((await runtime.keys()).length === 64, "runtime cache exceeded its size bound");316+ assert(!(await runtime.match("https://example.com/app/0.png")), "oldest entry was not evicted");317+ assert(await runtime.match("https://example.com/app/new.png"), "newest entry was not retained");318+ } finally {319+ harness.close();320+ }321+});322+323+Deno.test("activation removes old shell and runtime revisions but preserves unrelated caches", async () => {324+ const harness = await serviceWorkerHarness(() => Promise.resolve(new Response("unused")));325+ try {326+ await harness.caches.open("lofi-shell-old");327+ await harness.caches.open("lofi-runtime-old");328+ await harness.caches.open(shellCacheName);329+ await harness.caches.open(runtimeCacheName);330+ await harness.caches.open("product-cache");331+ await harness.dispatchLifecycle("activate");332+ const names = await harness.caches.keys();333+ assert(!names.includes("lofi-shell-old"), "stale shell cache survived activation");334+ assert(!names.includes("lofi-runtime-old"), "stale runtime cache survived activation");335+ assert(336+ names.includes(shellCacheName) && names.includes(runtimeCacheName),337+ "current caches removed",338+ );339+ assert(names.includes("product-cache"), "unrelated cache was removed");340+ assert(harness.claimed() === 1, "activation did not claim clients");341+ } finally {342+ harness.close();14343 }15344 });16345 17-Deno.test("service worker uses cached navigation shell as the offline fallback", async () => {18- const source = await Deno.readTextFile(serviceWorkerUrl);19- for (20- const contract of [21- 'event.request.mode === "navigate"',22- "cachedPrerenderedNavigation(event.request)",23- "new URL(`${route}/index.html`, scope)",24- 'caches.match(new URL("./", self.registration.scope))',25- 'reportFailure(\n "runtime-cache"',26- ]27- ) {28- if (!source.includes(contract)) throw new Error(`service worker omitted ${contract}`);346+Deno.test("runtime cache failure is reported without failing the network response", async () => {347+ const harness = await serviceWorkerHarness(() => Promise.resolve(new Response("network image")));348+ try {349+ const runtime = await harness.caches.open(runtimeCacheName);350+ runtime.failPut = true;351+ const event = harness.dispatchFetch(352+ fakeRequest("https://example.com/app/photo.png", { destination: "image" }),353+ );354+ assert(event.response, "runtime failure request was not answered");355+ assert(await (await event.response).text() === "network image", "cache failure lost response");356+ await Promise.all(event.lifetimes);357+ assert(358+ JSON.stringify(harness.messages).includes('"code":"runtime-cache"'),359+ "runtime cache failure was not reported",360+ );361+ } finally {362+ harness.close();29363 }30364 });