~6UYoyIVQ>mUDj+}j|U@PN4W
zQn&t>@D&S@71Z-3!*f2K<}*^;psD<31X9SB&!b#9MeNcRFa!Cx9Rk@20r0vn7ayj3
zqL&Bv0X}Y4$%bAtxF?ar&@+RRHPM`C;|g)-gDd*GRNa1($*oZZH8NBwAh0ko(>EqGh&tSbu>iunL35v&G$F#~fw#5TL_jUc-%SJr=wN*Tv<T6^)EoQAT=Y6W3;0V4sw1#U$6sxh}s^^P4G8zV1XF
zQpS^<_rL^(N#xPKI+E%!pgp)4!9wPFxa*W=jJ>_)FbgSVDj{VOp{b(Wu6c$L*iG^`
z{dAPB)Edih(1=h7p4s
zNKu5Xn3a+|dTm2qFaQO$J|KpLF8~M4miaM|-Iq8uLl_}vA9n=Sj>|>p{6flnauHl&
zq*MX?6Ql{if1F3%529@-Q?!`9Z7&T8UZ7+Y2A}
zWKw+tsErc2RX`}%OqR@~=dSL-h?zg3$JCnH@eDdkQhIMlDLH#cF9VBb7)KU*^hU8)
z52?t(gPao_nkV7B7mznWy$uSy3Q%HI*Q(tem&0>AAHfJ(BjI>Uj*}j?yrH&cd?^^uqE!&A!kjD^&3I#e1K&E0kybd+dRBVgoD`ZIL!w;ZV?8{2
zXh|9cquizu^H%B-B-;Tin6QCNGZAk$NOpJ-+NO+)Vsr>2h1fArdRh3z%r*9mCcw6KEyVogK^VCh?eBWjy%}PPt79fw
z@Vp?sLbszBh^q?Vi%*kn(l#jh-Sk#GCUPl4pE5xoJ
z!z}XfA3V&A^Kh=;zUu@G`(Roj!$oVTZmAR&7=BRh2EP}Jh8X`#N-;K`OG@_0^LDP!m)#feb;@ea38RQutrWCG26D(sCV
zC7@$I=%^bhjR+3UmgfL^6w(m%S-1x>GQ`qQ-C)UfP>53@dvIPbNxWJ&U0NL!{)yZO
z+d)UU5wy#Ikm)*??7EHWRMAr{rG|+N3jacG>d^HCvITxG!gS6Jlk|JLgUgh5Mu*x5
zk(y`4$jobC|HABd86ubu@bpE^6neY~5UHE7QlrGY+#e%5k0m&G(abVa+~*&^228SAgHV(+ePvXJ!Deq>wt2aII4=OkN=_5UZ?lNToa#9Hq|8Z=tE{EBRA5)&Z~s
z5JfIwHAset@u9k8$#=i*HK_=kIk*u^La>FaV|bN3AQ)V!0JcqVA*gC*<5}+|<+&E?
zIvQ|bn-DwZWcCR7CJ3`qwYiAfMuQXT3NS@k6i6>Y{t?uBsjAaSoHazZUdjy&-$DI(
zyU4ADM3L73PWeeaUy9g+czy_T9DJ$*x;{_F_5}b!!vOZs&*%`HPD&32r-=k(&!K~g
zIfAiAMia*CttPwebGfW>3q&WXIbW<@d*9{*Ty<#jG-8?q28(oZPzf0GxlPOoE58f8
zpM41wPnNW>{aJL!FtOrn##JGwCtylFHvr(t!nbMyMiz64q4{J4a9t*ln{8zEM6yNX
z6ST}}2j{yi#D;_>&!osPjE3)acxF@$b#m9<>@67=kWnlt0B|C&q0UzJ+%&NKKqx;<
zN+$8=u21UOmx4a=^)smMC=E*7r%M!TU)H%Of|S`YM@Bj|#gzPSg6HtNg;*3!!cmR!
zAXsr^IaeMe6i|B>%IToma528i*UOJOb>!PV$yX|>C!Rso0K5?^_mF8b1L{ALI}We^
zo7=Nty5s^a8|f@5uYr{854BGx)p@B4H0FM_Y8%vxGpY5VRRIV)<~%P3dKF!J8inIcK15HOK>8Y%e(1Msg)L7!w;fDHJL3R)Rp7NSvw!I2eIVX6%}A
zWEFhsNO&l%m}+G1D+svn?3%}c_&72Oi1(LFz;%kqufKWm!@xY`ze0c(Jm_cSJZ>Bb7mo8N5-#$u-@MqkO(FL-O_qZpx);Jt7Wp{}c+%WvSV6xr>J}Oa+~broGo
zY)5p5*G~q$xHSB*e0t1qF|7#4r0xmC`s0_&|M9xh_DgOd7l-pw_^dde(bPrQ&lRaR
z%ex&s3ru23YQU3qx;!yTO2)z%!Jwzdov(7arjq*~43;1{0|o%wSNlG>>+LTEcgth-
z{4|=&%3b7Ko#Xn#zONk%EVtV%5m=~W%=_v|YFcQ5eyQa19dQ8UVnr4wde3E)4cohy
ztJP@1&cp<5H&`*vmT@Y6GI>Pvs8nsmLfX5Z+aEN&waH_;easAlORP0hq!h!aKi8dO
zpv5;i5#UDH5jvY;;DSbIvl~^}FmAX2ds*qJuVGT+18XnzSn&-lc=ZMs-GSo41XgT1
zd(Cc`OFbz^?t9U0m`7Lx_)U|LrxGk|Id%DRxi7Ri5e?|<5mwsN;6l<*w2LCGw||=g
zkIl3l=hCV&^QTW@rPWUCK1}<_x)ad8fj@!yOb2+3f@T+OwnPWNi+lM8XNYH^e
zNpJaJhb*|`ly1qJ+GSs;8JyGINVIe~;U1b@4!$0x4A`_T^8M}bemmcS;m0r!y$#@|
zOUuHCOAuErE;NsW6pK*5JD`&X{J{AOS7Z7?7b9xGG;!t1{Xn{h@131x0Y+@DG8fS3
zxjutqA@s-rr8|P5&newwBJ^M@Xn#~n4%^Aq+uJtu#x<9jHl^gKT^o=Iy}Q6aE%DDH
zd#*-|{KnGktIhe@Kp(^^vlVqINxZhv*HRBmA%B8laU_*@>mgTCGu(S*(FV@}MQK*e
z?&`oWFEH7zO#2}n*xcvu$owZ>`H@%u7cR{s$}30pCtl2G?)g}`r(h&Qqo}(WcgKYa
z6~x0QVG7Dohj>D=-tiL)(HMNU{&|6QcTTK~0Kq4{bZVhE^Z+G)QE84V^HqG{A9SOq
z!}yE6JJ9&URW7}Epg~e|*LSL0@7aa9mSZV|Em`%~_bM#AV&I< {
+ event.waitUntil(
+ caches
+ .open(SHELL_CACHE)
+ .then((cache) => cache.addAll(SHELL_URLS))
+ .then(() => self.skipWaiting()),
+ );
+});
+
+self.addEventListener("activate", (event) => {
+ event.waitUntil(
+ caches
+ .keys()
+ .then((keys) =>
+ Promise.all(
+ keys
+ .filter((key) => key.startsWith(CACHE_PREFIX) && !CURRENT_CACHES.includes(key))
+ .map((key) => caches.delete(key)),
+ ),
+ )
+ .then(() => self.clients.claim()),
+ );
+});
+
+self.addEventListener("fetch", (event) => {
+ const request = event.request;
+ if (request.method !== "GET") return;
+ if (new URL(request.url).origin !== self.location.origin) return;
+
+ if (request.mode === "navigate") {
+ event.respondWith(
+ fetch(request).catch(() =>
+ caches.match("/index.html", { cacheName: SHELL_CACHE }).then(
+ (cached) => cached || Response.error(),
+ ),
+ ),
+ );
+ return;
+ }
+
+ if (STATIC_DESTINATIONS.includes(request.destination)) {
+ event.respondWith(cacheFirst(request));
+ }
+});
+
+async function cacheFirst(request) {
+ const cached = await caches.match(request);
+ if (cached) return cached;
+ const response = await fetch(request);
+ if (response.ok && response.type === "basic") {
+ const cache = await caches.open(RUNTIME_CACHE);
+ await cache.put(request, response.clone());
+ }
+ return response;
+}
diff --git a/admin/scripts/generate-icons.mjs b/admin/scripts/generate-icons.mjs
new file mode 100644
index 0000000..89cd01f
--- /dev/null
+++ b/admin/scripts/generate-icons.mjs
@@ -0,0 +1,80 @@
+// Generates the committed PWA icons under public/icons/ as genuine PNGs.
+// Pure Node (zlib only) — no network, no image dependencies.
+// Run: node scripts/generate-icons.mjs
+import { writeFileSync } from "node:fs";
+import { deflateSync } from "node:zlib";
+import { fileURLToPath } from "node:url";
+
+// Brand tokens (design/tokens/nestquest-admin-tokens.css).
+const BLUE = [0x3b, 0x3a, 0xb8];
+const PURPLE = [0xa0, 0x35, 0xcc];
+const WHITE = [0xff, 0xff, 0xff];
+
+const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
+ let c = n;
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+ return c >>> 0;
+});
+
+function crc32(buf) {
+ let c = 0xffffffff;
+ for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
+ return (c ^ 0xffffffff) >>> 0;
+}
+
+function chunk(type, data) {
+ const len = Buffer.alloc(4);
+ len.writeUInt32BE(data.length);
+ const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
+ const crc = Buffer.alloc(4);
+ crc.writeUInt32BE(crc32(body));
+ return Buffer.concat([len, body, crc]);
+}
+
+function encodePng(size, pixel) {
+ const ihdr = Buffer.alloc(13);
+ ihdr.writeUInt32BE(size, 0);
+ ihdr.writeUInt32BE(size, 4);
+ ihdr[8] = 8; // bit depth
+ ihdr[9] = 2; // colour type: RGB
+ const raw = Buffer.alloc(size * (size * 3 + 1));
+ for (let y = 0; y < size; y++) {
+ const row = y * (size * 3 + 1);
+ raw[row] = 0; // filter: none
+ for (let x = 0; x < size; x++) raw.set(pixel(x, y), row + 1 + x * 3);
+ }
+ return Buffer.concat([
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
+ chunk("IHDR", ihdr),
+ chunk("IDAT", deflateSync(raw, { level: 9 })),
+ chunk("IEND", Buffer.alloc(0)),
+ ]);
+}
+
+// Flat brand mark: diagonal blue→purple gradient with a white ring ("nest")
+// around a white dot ("quest"). `scale` shrinks the mark for the maskable
+// safe zone (inner 80% circle).
+function brandMark(size, scale) {
+ const c = size / 2;
+ const outer = size * 0.34 * scale;
+ const inner = size * 0.24 * scale;
+ const dot = size * 0.1 * scale;
+ return (x, y) => {
+ const d = Math.hypot(x + 0.5 - c, y + 0.5 - c);
+ if ((d <= outer && d >= inner) || d <= dot) return WHITE;
+ const t = (x + y) / (2 * (size - 1));
+ return BLUE.map((b, i) => Math.round(b + (PURPLE[i] - b) * t));
+ };
+}
+
+const outDir = new URL("../public/icons/", import.meta.url);
+const icons = [
+ ["icon-192.png", 192, 1],
+ ["icon-512.png", 512, 1],
+ ["icon-maskable-512.png", 512, 0.75],
+];
+for (const [name, size, scale] of icons) {
+ const path = fileURLToPath(new URL(name, outDir));
+ writeFileSync(path, encodePng(size, brandMark(size, scale)));
+ console.log(`wrote ${path}`);
+}
diff --git a/admin/src/main.tsx b/admin/src/main.tsx
index 35e1ff7..5f81c94 100644
--- a/admin/src/main.tsx
+++ b/admin/src/main.tsx
@@ -2,9 +2,12 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import AppRoutes from "./AppRoutes";
import "./index.css";
+import { registerServiceWorker } from "./pwa/register";
createRoot(document.getElementById("root")!).render(
,
-);
\ No newline at end of file
+);
+
+void registerServiceWorker();
diff --git a/admin/src/pwa/pwa.test.ts b/admin/src/pwa/pwa.test.ts
new file mode 100644
index 0000000..4089ab4
--- /dev/null
+++ b/admin/src/pwa/pwa.test.ts
@@ -0,0 +1,396 @@
+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,
+ STATIC_PRECACHE_FILES,
+ injectServiceWorker,
+} from "../../vite-sw-plugin";
+import { registerServiceWorker } from "./register";
+
+const adminRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
+const adminPath = (rel: string) => resolve(adminRoot, rel);
+const publicPath = (urlPath: string) => adminPath(`public${urlPath}`);
+
+interface ManifestIcon {
+ src: string;
+ sizes: string;
+ type: string;
+ purpose?: string;
+}
+
+const manifest = JSON.parse(readFileSync(publicPath("/manifest.webmanifest"), "utf8"));
+const icons: ManifestIcon[] = manifest.icons;
+const swSource = readFileSync(publicPath("/sw.js"), "utf8");
+
+const sizeOf = (icon: ManifestIcon) => Number(icon.sizes.split("x")[0]);
+const purposes = (icon: ManifestIcon) => (icon.purpose ?? "any").split(/\s+/);
+
+describe("web app manifest", () => {
+ it("meets the installability criteria", () => {
+ expect(manifest.name).toBe("NestQuest Admin");
+ expect(manifest.short_name).toBe("NestQuest");
+ expect(manifest.start_url).toBe("/");
+ expect(manifest.scope).toBe("/");
+ expect(manifest.display).toBe("standalone");
+ expect(manifest.background_color).toMatch(/^#[0-9a-f]{6}$/i);
+ expect(manifest.theme_color).toMatch(/^#[0-9a-f]{6}$/i);
+
+ const any = icons.filter((i) => purposes(i).includes("any"));
+ expect(any.some((i) => sizeOf(i) >= 192)).toBe(true);
+ expect(any.some((i) => sizeOf(i) >= 512)).toBe(true);
+ expect(icons.some((i) => purposes(i).includes("maskable"))).toBe(true);
+ });
+
+ it("uses the admin design token colours", () => {
+ const css = readFileSync(adminPath("src/index.css"), "utf8");
+ expect(css).toContain(`--nq-brand-blue: ${manifest.theme_color};`);
+ expect(css).toContain(`--nq-a-page: ${manifest.background_color};`);
+ });
+
+ it.each(icons.map((i) => [i.src, i] as const))(
+ "icon %s is a real PNG of its declared size",
+ (_src, icon) => {
+ const path = publicPath(icon.src);
+ expect(existsSync(path)).toBe(true);
+ const png = readFileSync(path);
+ expect([...png.subarray(0, 8)]).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+ expect(png.toString("ascii", 12, 16)).toBe("IHDR");
+ const [w, h] = icon.sizes.split("x").map(Number);
+ expect(png.readUInt32BE(16)).toBe(w);
+ expect(png.readUInt32BE(20)).toBe(h);
+ expect(icon.type).toBe("image/png");
+ },
+ );
+});
+
+describe("index.html and entry point", () => {
+ const html = readFileSync(adminPath("index.html"), "utf8");
+
+ it("links the manifest, theme colour and apple touch icon", () => {
+ expect(html).toContain('');
+ expect(html).toContain(``);
+ expect(html).toContain('');
+ });
+
+ it("main.tsx registers the service worker", () => {
+ const main = readFileSync(adminPath("src/main.tsx"), "utf8");
+ expect(main).toMatch(/import \{ registerServiceWorker \} from "\.\/pwa\/register"/);
+ expect(main).toMatch(/registerServiceWorker\(\)/);
+ });
+});
+
+// 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,
+ source: string = swSource,
+) {
+ const listeners: Record void> = {};
+ const stores = new Map>();
+ const openStore = (name: string) => {
+ if (!stores.has(name)) stores.set(name, new Map());
+ return stores.get(name)!;
+ };
+ const keyOf = (req: string | { url: string }) =>
+ new URL(typeof req === "string" ? req : req.url, "https://admin.test").href;
+ const fetchFn = vi.fn((req: string | { url: string }) => network(keyOf(req)));
+
+ const caches = {
+ open: async (name: string) => {
+ const store = openStore(name);
+ return {
+ addAll: async (urls: string[]) => {
+ for (const u of urls) store.set(keyOf(u), await network(keyOf(u)));
+ },
+ put: async (req: { url: string }, res: FakeResponse) => void store.set(keyOf(req), res),
+ };
+ },
+ keys: async () => [...stores.keys()],
+ delete: async (name: string) => stores.delete(name),
+ match: async (req: string | { url: string }, opts?: { cacheName?: string }) => {
+ const names = opts?.cacheName ? [opts.cacheName] : [...stores.keys()];
+ for (const n of names) {
+ const hit = stores.get(n)?.get(keyOf(req));
+ if (hit) return hit;
+ }
+ return undefined;
+ },
+ };
+ const self = {
+ location: new URL("https://admin.test/"),
+ addEventListener: (type: string, fn: (event: any) => void) => (listeners[type] = fn),
+ skipWaiting: vi.fn(async () => undefined),
+ clients: { claim: vi.fn(async () => undefined) },
+ };
+ const ResponseStub = { error: () => new FakeResponse("", false, "error") };
+ new Function("self", "caches", "fetch", "Response", source)(self, caches, fetchFn, ResponseStub);
+
+ const dispatch = async (type: string, init: object = {}) => {
+ let pending: Promise | undefined;
+ const event = {
+ ...init,
+ waitUntil: (p: Promise) => (pending = p),
+ respondWith: (p: Promise) => (pending = p),
+ };
+ listeners[type](event);
+ return { responded: pending !== undefined, result: await pending };
+ };
+ return { listeners, stores, self, fetchFn, dispatch, openStore };
+}
+
+class FakeResponse {
+ constructor(
+ readonly body: string,
+ readonly ok = true,
+ readonly type = "basic",
+ ) {}
+ clone() {
+ return new FakeResponse(this.body, this.ok, this.type);
+ }
+}
+
+const request = (url: string, extra: object = {}) => ({
+ url: new URL(url, "https://admin.test").href,
+ method: "GET",
+ mode: "cors",
+ destination: "",
+ ...extra,
+});
+
+describe("service worker (public/sw.js)", () => {
+ it("is a static asset served from the site root", () => {
+ expect(existsSync(publicPath("/sw.js"))).toBe(true);
+ expect(swSource).toMatch(/const SHELL_CACHE = /);
+ expect(swSource).toMatch(/addEventListener\("install"/);
+ expect(swSource).toMatch(/addEventListener\("activate"/);
+ expect(swSource).toMatch(/addEventListener\("fetch"/);
+ });
+
+ it("precaches the shell on install, prunes stale caches on activate", async () => {
+ const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`));
+ sw.openStore("nestquest-admin-shell-v0");
+
+ await sw.dispatch("install");
+ expect(sw.self.skipWaiting).toHaveBeenCalled();
+ const precached = [...sw.stores.values()].flatMap((s) => [...s.keys()]);
+ for (const path of ["/", "/index.html", "/manifest.webmanifest", ...icons.map((i) => i.src)]) {
+ expect(precached).toContain(`https://admin.test${path}`);
+ }
+
+ await sw.dispatch("activate");
+ expect(sw.stores.has("nestquest-admin-shell-v0")).toBe(false);
+ expect(sw.self.clients.claim).toHaveBeenCalled();
+ });
+
+ it("serves the cached shell for navigations when offline", async () => {
+ let online = true;
+ const sw = loadServiceWorker(async (url) => {
+ if (!online) throw new TypeError("Failed to fetch");
+ return new FakeResponse(`net:${url}`);
+ });
+ await sw.dispatch("install");
+ online = false;
+
+ const { result } = await sw.dispatch("fetch", {
+ request: request("/children/42", { mode: "navigate", destination: "document" }),
+ });
+ expect((result as FakeResponse).body).toBe("net:https://admin.test/index.html");
+ });
+
+ it("serves static assets cache-first and fills the runtime cache", async () => {
+ const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`));
+ const asset = request("/assets/index-abc123.js", { destination: "script" });
+
+ const first = await sw.dispatch("fetch", { request: asset });
+ expect((first.result as FakeResponse).body).toBe("net:https://admin.test/assets/index-abc123.js");
+ expect(sw.fetchFn).toHaveBeenCalledTimes(1);
+
+ const second = await sw.dispatch("fetch", { request: asset });
+ expect((second.result as FakeResponse).body).toBe("net:https://admin.test/assets/index-abc123.js");
+ expect(sw.fetchFn).toHaveBeenCalledTimes(1);
+ });
+
+ it("never handles cross-origin, non-GET or non-static requests", async () => {
+ const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`));
+ const cases = [
+ request("https://auth.example.com/logo.png", { destination: "image" }),
+ request("/assets/app.js", { destination: "script", method: "POST" }),
+ request("/api/admin/children"),
+ ];
+ for (const req of cases) {
+ const { responded } = await sw.dispatch("fetch", { request: req });
+ expect(responded).toBe(false);
+ }
+ expect(sw.fetchFn).not.toHaveBeenCalled();
+ });
+});
+
+// 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);
+
+// The unhashed files the build hashes into the revision, read from public/.
+const staticFiles = STATIC_PRECACHE_FILES.map((path) => ({
+ path,
+ bytes: readFileSync(publicPath(`/${path}`)),
+}));
+const revisionOf = (src: string) => /const REVISION = "([0-9a-f]+)";/.exec(src)?.[1];
+
+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, staticFiles);
+ 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, staticFiles);
+ const b = injectServiceWorker(builtIndexHtml("bbbb2222"), swSource, staticFiles);
+
+ 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, staticFiles)).toBe(a);
+ });
+
+ it("derives a new revision when only a precached static file changes (PWA-002)", () => {
+ const html = builtIndexHtml("aaaa1111");
+ const base = revisionOf(injectServiceWorker(html, swSource, staticFiles));
+ const withChanged = (path: string) =>
+ staticFiles.map((f) =>
+ f.path === path ? { path, bytes: Buffer.concat([f.bytes, Buffer.from([0])]) } : f,
+ );
+
+ expect(revisionOf(injectServiceWorker(html, swSource, [...staticFiles].reverse()))).toBe(base);
+ for (const path of STATIC_PRECACHE_FILES) {
+ const changed = revisionOf(injectServiceWorker(html, swSource, withChanged(path)));
+ expect(changed, path).toMatch(/^[0-9a-f]{12}$/);
+ expect(changed, path).not.toBe(base);
+ }
+ });
+
+ it("hashes every unhashed URL sw.js precaches (PWA-002)", () => {
+ const shellUrls = [...swSource.matchAll(/^\s*"(\/[^"]*)",$/gm)].map((m) => m[1]);
+ const fixed = shellUrls.filter((url) => url !== "/" && url !== "/index.html");
+ expect(fixed.map((url) => url.slice(1)).sort()).toEqual([...STATIC_PRECACHE_FILES].sort());
+ });
+
+ it("names both caches after the build revision (PWA-002)", async () => {
+ const built = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles);
+ 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 = [];", staticFiles)).toThrow();
+ });
+
+ it("activation deletes only stale nestquest-admin caches (PWA-003)", async () => {
+ const built = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles);
+ 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();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ });
+
+ const stubServiceWorker = (register: ReturnType) =>
+ vi.stubGlobal("navigator", { ...navigator, serviceWorker: { register } });
+
+ it("does not register outside production builds", async () => {
+ const register = vi.fn();
+ stubServiceWorker(register);
+ expect(import.meta.env.PROD).toBe(false);
+ await registerServiceWorker();
+ expect(register).not.toHaveBeenCalled();
+ });
+
+ it("registers /sw.js in production", async () => {
+ vi.stubEnv("PROD", true);
+ const register = vi.fn().mockResolvedValue({});
+ stubServiceWorker(register);
+ await registerServiceWorker();
+ expect(register).toHaveBeenCalledWith("/sw.js", { scope: "/" });
+ });
+
+ it("logs instead of throwing when registration fails", async () => {
+ vi.stubEnv("PROD", true);
+ stubServiceWorker(vi.fn().mockRejectedValue(new Error("boom")));
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
+ await expect(registerServiceWorker()).resolves.toBeUndefined();
+ expect(error).toHaveBeenCalled();
+ });
+
+ it("does nothing when the browser lacks service workers", async () => {
+ vi.stubEnv("PROD", true);
+ vi.stubGlobal("navigator", {});
+ await expect(registerServiceWorker()).resolves.toBeUndefined();
+ });
+});
diff --git a/admin/src/pwa/register.ts b/admin/src/pwa/register.ts
new file mode 100644
index 0000000..14f00f6
--- /dev/null
+++ b/admin/src/pwa/register.ts
@@ -0,0 +1,13 @@
+/**
+ * Service worker registration for the installable admin PWA.
+ * Registers /sw.js only in production builds, so dev servers and tests never
+ * run a worker. Failures are logged, never thrown — the app works without it.
+ */
+export async function registerServiceWorker(): Promise {
+ if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;
+ try {
+ await navigator.serviceWorker.register("/sw.js", { scope: "/" });
+ } catch (error) {
+ console.error("Service worker registration failed:", error);
+ }
+}
diff --git a/admin/vite-sw-plugin.ts b/admin/vite-sw-plugin.ts
new file mode 100644
index 0000000..4db9f9e
--- /dev/null
+++ b/admin/vite-sw-plugin.ts
@@ -0,0 +1,93 @@
+/* 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 = [];";
+// Unhashed files sw.js precaches by fixed URL, relative to the build output.
+export const STATIC_PRECACHE_FILES = [
+ "manifest.webmanifest",
+ "icons/icon-192.png",
+ "icons/icon-512.png",
+ "icons/icon-maskable-512.png",
+];
+
+export interface PrecacheFile {
+ path: string;
+ bytes: string | Uint8Array;
+}
+
+// Every same-origin URL an index.html loads via