From 934c788f30fa959ca9e388327994b1bafe8f1b69 Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 23 Sep 2026 19:31:03 -0400 Subject: [PATCH] Precache hashed bundles and revision the PWA service worker - Add a build-only Vite plugin (admin/vite-sw-plugin.ts) that, after the bundle is written, injects every same-origin script/link asset referenced by dist/index.html into dist/sw.js's precache, so the offline shell renders immediately after install (PWA-001). - Replace the literal cache version with a build revision (sha256 of the built index.html and sw.js source, truncated) used in both cache names, so each release installs a new worker with fresh caches (PWA-002). - On activate, delete only stale nestquest-admin-* caches; unrelated same-origin caches survive (PWA-003). - Add Vitest regressions for all three findings. Task: 96ab3c0b-7554-4098-8af1-2be7815f8929 Co-Authored-By: Claude Opus 5.5 --- admin/public/sw.js | 33 ++++++++---- admin/src/pwa/pwa.test.ts | 107 +++++++++++++++++++++++++++++++++++++- admin/vite-sw-plugin.ts | 57 ++++++++++++++++++++ admin/vite.config.ts | 5 +- 4 files changed, 187 insertions(+), 15 deletions(-) create mode 100644 admin/vite-sw-plugin.ts diff --git a/admin/public/sw.js b/admin/public/sw.js index b64fe2c..3c9f80e 100644 --- a/admin/public/sw.js +++ b/admin/public/sw.js @@ -2,20 +2,29 @@ Precaches the app shell, serves static assets cache-first, and falls back to the cached shell for navigations when offline. Only same-origin GET requests are handled; cross-origin requests (Authentik, the API host) are - never intercepted or cached. */ + never intercepted or cached. + The build (vite-sw-plugin.ts) replaces PRECACHE_ASSETS with the hashed + bundles referenced by index.html and the revision marker with a content + hash, so each release installs a new worker with fresh caches. */ -const VERSION = "v1"; -const SHELL_CACHE = `nestquest-admin-shell-${VERSION}`; -const RUNTIME_CACHE = `nestquest-admin-runtime-${VERSION}`; +const REVISION = "__NQ_BUILD_REVISION__"; +const CACHE_PREFIX = "nestquest-admin-"; +const SHELL_CACHE = `${CACHE_PREFIX}shell-${REVISION}`; +const RUNTIME_CACHE = `${CACHE_PREFIX}runtime-${REVISION}`; const CURRENT_CACHES = [SHELL_CACHE, RUNTIME_CACHE]; +const PRECACHE_ASSETS = []; + const SHELL_URLS = [ - "/", - "/index.html", - "/manifest.webmanifest", - "/icons/icon-192.png", - "/icons/icon-512.png", - "/icons/icon-maskable-512.png", + ...new Set([ + "/", + "/index.html", + "/manifest.webmanifest", + "/icons/icon-192.png", + "/icons/icon-512.png", + "/icons/icon-maskable-512.png", + ...PRECACHE_ASSETS, + ]), ]; // Request destinations treated as static assets (cache-first). Anything else @@ -37,7 +46,9 @@ self.addEventListener("activate", (event) => { .keys() .then((keys) => Promise.all( - keys.filter((key) => !CURRENT_CACHES.includes(key)).map((key) => caches.delete(key)), + keys + .filter((key) => key.startsWith(CACHE_PREFIX) && !CURRENT_CACHES.includes(key)) + .map((key) => caches.delete(key)), ), ) .then(() => self.clients.claim()), diff --git a/admin/src/pwa/pwa.test.ts b/admin/src/pwa/pwa.test.ts index 72e15ed..068a353 100644 --- a/admin/src/pwa/pwa.test.ts +++ b/admin/src/pwa/pwa.test.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { REVISION_MARKER, injectServiceWorker } from "../../vite-sw-plugin"; import { registerServiceWorker } from "./register"; const adminRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); @@ -78,7 +79,10 @@ describe("index.html and entry point", () => { // Minimal service-worker global scope: runs public/sw.js against in-memory // caches and a stubbed network, so handler behaviour is checked without I/O. -function loadServiceWorker(network: (url: string) => Promise) { +function loadServiceWorker( + network: (url: string) => Promise, + source: string = swSource, +) { const listeners: Record void> = {}; const stores = new Map>(); const openStore = (name: string) => { @@ -117,7 +121,7 @@ function loadServiceWorker(network: (url: string) => Promise) { clients: { claim: vi.fn(async () => undefined) }, }; const ResponseStub = { error: () => new FakeResponse("", false, "error") }; - new Function("self", "caches", "fetch", "Response", swSource)(self, caches, fetchFn, ResponseStub); + new Function("self", "caches", "fetch", "Response", source)(self, caches, fetchFn, ResponseStub); const dispatch = async (type: string, init: object = {}) => { let pending: Promise | undefined; @@ -219,6 +223,105 @@ describe("service worker (public/sw.js)", () => { }); }); +// A Vite-style built index.html referencing hashed bundles. +const builtIndexHtml = (hash: string) => ` + + + + + + + + + +
+`; + +// Same-origin script/link URLs of an HTML document, parsed independently of +// the build plugin so the regression check does not trust its own extractor. +const referencedAssets = (html: string) => + [...new DOMParser().parseFromString(html, "text/html").querySelectorAll("script[src], link[href]")] + .map((el) => new URL(el.getAttribute("src") ?? el.getAttribute("href")!, "https://admin.test/")) + .filter((url) => url.origin === "https://admin.test") + .map((url) => url.href); + +describe("build-time service worker injection", () => { + it("precaches every asset the cached index.html references on install (PWA-001)", async () => { + const html = builtIndexHtml("Ab12Cd34"); + const built = injectServiceWorker(html, swSource); + const sw = loadServiceWorker(async (url) => + new FakeResponse(url.endsWith("/index.html") ? html : `net:${url}`), + built); + + await sw.dispatch("install"); + const shell = [...sw.stores.entries()].find(([name]) => name.startsWith("nestquest-admin-shell-")); + expect(shell).toBeDefined(); + const [, shellStore] = shell!; + + const cachedHtml = shellStore.get("https://admin.test/index.html")!.body; + const assets = referencedAssets(cachedHtml); + expect(assets).toEqual( + expect.arrayContaining([ + "https://admin.test/assets/index-Ab12Cd34.js", + "https://admin.test/assets/vendor-Ab12Cd34.js", + "https://admin.test/assets/index-Ab12Cd34.css", + ]), + ); + for (const asset of assets) expect([...shellStore.keys()]).toContain(asset); + expect([...shellStore.keys()]).not.toContain("https://auth.example.com/"); + }); + + it("derives a new revision for different index.html content (PWA-002)", () => { + const a = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource); + const b = injectServiceWorker(builtIndexHtml("bbbb2222"), swSource); + const revisionOf = (src: string) => /const REVISION = "([0-9a-f]+)";/.exec(src)?.[1]; + + expect(a).not.toContain(REVISION_MARKER); + expect(b).not.toContain(REVISION_MARKER); + expect(revisionOf(a)).toMatch(/^[0-9a-f]{12}$/); + expect(revisionOf(b)).toMatch(/^[0-9a-f]{12}$/); + expect(revisionOf(a)).not.toBe(revisionOf(b)); + expect(injectServiceWorker(builtIndexHtml("aaaa1111"), swSource)).toBe(a); + }); + + it("names both caches after the build revision (PWA-002)", async () => { + const built = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource); + const revision = /const REVISION = "([0-9a-f]+)";/.exec(built)![1]; + const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`), built); + + await sw.dispatch("install"); + await sw.dispatch("fetch", { request: request("/assets/late.js", { destination: "script" }) }); + expect([...sw.stores.keys()].sort()).toEqual([ + `nestquest-admin-runtime-${revision}`, + `nestquest-admin-shell-${revision}`, + ]); + }); + + it("fails fast when sw.js lacks the placeholders", () => { + expect(() => injectServiceWorker(builtIndexHtml("x"), "const SHELL_URLS = [];")).toThrow(); + }); + + it("activation deletes only stale nestquest-admin caches (PWA-003)", async () => { + const built = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource); + const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`), built); + for (const name of [ + "nestquest-admin-shell-old", + "nestquest-admin-runtime-old", + "some-other-app-cache", + ]) { + sw.openStore(name); + } + + await sw.dispatch("install"); + await sw.dispatch("activate"); + const names = [...sw.stores.keys()]; + expect(names).not.toContain("nestquest-admin-shell-old"); + expect(names).not.toContain("nestquest-admin-runtime-old"); + expect(names).toContain("some-other-app-cache"); + expect(names.some((n) => n.startsWith("nestquest-admin-shell-") && n !== "nestquest-admin-shell-old")).toBe(true); + }); +}); + describe("registerServiceWorker", () => { afterEach(() => { vi.unstubAllEnvs(); diff --git a/admin/vite-sw-plugin.ts b/admin/vite-sw-plugin.ts new file mode 100644 index 0000000..bf9a8dc --- /dev/null +++ b/admin/vite-sw-plugin.ts @@ -0,0 +1,57 @@ +/* Build-time service worker injection. After Vite writes the bundle, the + hashed asset URLs referenced by dist/index.html and a content-derived + revision are written into dist/sw.js, so the offline shell is complete right + after install and every release ships a new worker with fresh caches. + Build tooling only — never imported by the app bundle. */ +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import type { Plugin } from "vite"; + +export const REVISION_MARKER = "__NQ_BUILD_REVISION__"; +const PRECACHE_PLACEHOLDER = "const PRECACHE_ASSETS = [];"; + +// Every same-origin URL an index.html loads via