import { createHash } from "node:crypto"; import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { join, relative, resolve } from "node:path"; /** Every file under `directory`, as paths relative to it with "/". */ export function listFiles(directory) { const files = []; const walk = (dir) => { for (const name of readdirSync(dir)) { const path = join(dir, name); if (statSync(path).isDirectory()) walk(path); else files.push(relative(directory, path).split("\\").join("/")); } }; walk(directory); return files; } /** The app shell's service worker. It precaches `files` (paths relative * to the worker) under a cache named by `version`, serves them first and * leaves every other request to the network. A new version waits until * the page asks it to take over, so play is never reloaded by surprise. */ export function serviceWorkerSource(files, version) { const shell = [ "./", ...files.filter((file) => file !== "index.html").map((file) => `./${file}`), ]; return `// Generated at build time by scripts/service-worker.mjs. const CACHE = ${JSON.stringify(`crystal-shell-${version}`)}; const SHELL = ${JSON.stringify(shell)}; const SCOPE = new URL("./", self.location).pathname; self.addEventListener("install", (event) => { event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL))); }); self.addEventListener("activate", (event) => { event.waitUntil( (async () => { for (const key of await caches.keys()) if (key.startsWith("crystal-shell-") && key !== CACHE) await caches.delete(key); await self.clients.claim(); })(), ); }); self.addEventListener("message", (event) => { if (event.data === "skip-waiting") self.skipWaiting(); }); 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 || !url.pathname.startsWith(SCOPE)) return; event.respondWith( (async () => { const cache = await caches.open(CACHE); // Pages load the cached shell; other files are cached by URL. Shell // files never vary, but hosts may send Vary: Origin, which module // script requests would otherwise fail to match. const cached = request.mode === "navigate" ? await cache.match("./", { ignoreVary: true }) : await cache.match(request, { ignoreSearch: true, ignoreVary: true }); return cached ?? fetch(request); })(), ); }); `; } /** Vite plugin: write sw.js next to the native build's files once they * exist, versioned by their names and contents. */ export function serviceWorkerPlugin() { let outDir = "dist"; return { name: "crystal-service-worker", apply: "build", configResolved(config) { outDir = resolve(config.root, config.build.outDir); }, writeBundle: { sequential: true, order: "post", handler() { const files = listFiles(outDir) .filter((file) => file !== "sw.js") .sort(); const hash = createHash("sha256"); for (const file of files) hash.update(file).update(readFileSync(join(outDir, file))); writeFileSync( join(outDir, "sw.js"), serviceWorkerSource(files, hash.digest("hex").slice(0, 16)), ); }, }, }; }