Task 96ab3c0b: PWA manifest and service worker (#189)
This commit was merged in pull request #189.
This commit is contained in:
@@ -3,6 +3,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#3b3ab8" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||||
<title>NestQuest Admin</title>
|
<title>NestQuest Admin</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "NestQuest Admin",
|
||||||
|
"short_name": "NestQuest",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#f4f4f6",
|
||||||
|
"theme_color": "#3b3ab8",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/* NestQuest Admin service worker — hand-written, served from the site root.
|
||||||
|
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.
|
||||||
|
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 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 = [
|
||||||
|
...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
|
||||||
|
// same-origin (e.g. API calls) goes straight to the network.
|
||||||
|
const STATIC_DESTINATIONS = ["script", "style", "image", "font", "manifest"];
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@ import { StrictMode } from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import AppRoutes from "./AppRoutes";
|
import AppRoutes from "./AppRoutes";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
import { registerServiceWorker } from "./pwa/register";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
void registerServiceWorker();
|
||||||
|
|||||||
@@ -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('<link rel="manifest" href="/manifest.webmanifest" />');
|
||||||
|
expect(html).toContain(`<meta name="theme-color" content="${manifest.theme_color}" />`);
|
||||||
|
expect(html).toContain('<link rel="apple-touch-icon" href="/icons/icon-192.png" />');
|
||||||
|
});
|
||||||
|
|
||||||
|
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<FakeResponse>,
|
||||||
|
source: string = swSource,
|
||||||
|
) {
|
||||||
|
const listeners: Record<string, (event: any) => void> = {};
|
||||||
|
const stores = new Map<string, Map<string, FakeResponse>>();
|
||||||
|
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<unknown> | undefined;
|
||||||
|
const event = {
|
||||||
|
...init,
|
||||||
|
waitUntil: (p: Promise<unknown>) => (pending = p),
|
||||||
|
respondWith: (p: Promise<unknown>) => (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) => `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||||
|
<script type="module" crossorigin src="/assets/index-${hash}.js"></script>
|
||||||
|
<link rel="modulepreload" crossorigin href="/assets/vendor-${hash}.js">
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-${hash}.css">
|
||||||
|
<link rel="preconnect" href="https://auth.example.com" />
|
||||||
|
</head>
|
||||||
|
<body><div id="root"></div></body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
// 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<typeof vi.fn>) =>
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <script src> or <link href>,
|
||||||
|
// normalised to an absolute path, in document order and without duplicates.
|
||||||
|
export function extractShellAssets(indexHtml: string): string[] {
|
||||||
|
const tags = indexHtml.match(/<(?:script|link)\b[^>]*>/gi) ?? [];
|
||||||
|
const urls = tags.flatMap((tag) => {
|
||||||
|
const attr = /\b(?:src|href)\s*=\s*["']([^"']+)["']/i.exec(tag);
|
||||||
|
if (!attr) return [];
|
||||||
|
const url = new URL(attr[1], "https://same-origin.invalid/");
|
||||||
|
return url.origin === "https://same-origin.invalid" ? [url.pathname] : [];
|
||||||
|
});
|
||||||
|
return [...new Set(urls)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash of every precached file, sorted by path; each entry is length-prefixed
|
||||||
|
// so no two different file sets hash the same concatenation.
|
||||||
|
export function computeRevision(files: PrecacheFile[]): string {
|
||||||
|
const hash = createHash("sha256");
|
||||||
|
for (const { path, bytes } of [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))) {
|
||||||
|
const data = typeof bytes === "string" ? Buffer.from(bytes, "utf8") : bytes;
|
||||||
|
hash.update(`${path}\0${data.byteLength}\0`).update(data);
|
||||||
|
}
|
||||||
|
return hash.digest("hex").slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns swSource with the precache list and the build revision filled in.
|
||||||
|
// The revision covers index.html (and so the hashed bundles it names), the
|
||||||
|
// sw.js source and the bytes of every unhashed static file it precaches.
|
||||||
|
export function injectServiceWorker(
|
||||||
|
indexHtml: string,
|
||||||
|
swSource: string,
|
||||||
|
staticFiles: PrecacheFile[],
|
||||||
|
): string {
|
||||||
|
if (!swSource.includes(PRECACHE_PLACEHOLDER) || !swSource.includes(REVISION_MARKER)) {
|
||||||
|
throw new Error("sw.js is missing the PRECACHE_ASSETS placeholder or revision marker");
|
||||||
|
}
|
||||||
|
const assets = extractShellAssets(indexHtml);
|
||||||
|
const revision = computeRevision([
|
||||||
|
{ path: "index.html", bytes: indexHtml },
|
||||||
|
{ path: "sw.js", bytes: swSource },
|
||||||
|
...staticFiles,
|
||||||
|
]);
|
||||||
|
return swSource
|
||||||
|
.replace(PRECACHE_PLACEHOLDER, `const PRECACHE_ASSETS = ${JSON.stringify(assets)};`)
|
||||||
|
.replaceAll(REVISION_MARKER, revision);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serviceWorkerInjectPlugin(): Plugin {
|
||||||
|
let outDir = "";
|
||||||
|
return {
|
||||||
|
name: "nestquest-sw-inject",
|
||||||
|
apply: "build",
|
||||||
|
configResolved(config) {
|
||||||
|
outDir = resolve(config.root, config.build.outDir);
|
||||||
|
},
|
||||||
|
writeBundle() {
|
||||||
|
const swPath = resolve(outDir, "sw.js");
|
||||||
|
const indexHtml = readFileSync(resolve(outDir, "index.html"), "utf8");
|
||||||
|
const staticFiles = STATIC_PRECACHE_FILES.map((path) => ({
|
||||||
|
path,
|
||||||
|
bytes: readFileSync(resolve(outDir, path)),
|
||||||
|
}));
|
||||||
|
writeFileSync(
|
||||||
|
swPath,
|
||||||
|
injectServiceWorker(indexHtml, readFileSync(swPath, "utf8"), staticFiles),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { serviceWorkerInjectPlugin } from "./vite-sw-plugin";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react(), serviceWorkerInjectPlugin()],
|
||||||
test: {
|
test: {
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user