Files
NestQuest/admin/vite-sw-plugin.ts
T
overseerandClaude Opus 5.5 934c788f30 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 <noreply@anthropic.com>
2026-09-23 19:31:03 -04:00

58 lines
2.4 KiB
TypeScript

/* 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 <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)];
}
// Returns swSource with the precache list and the build revision filled in.
export function injectServiceWorker(indexHtml: string, swSource: string): 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 = createHash("sha256")
.update(indexHtml)
.update(swSource)
.digest("hex")
.slice(0, 12);
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");
writeFileSync(swPath, injectServiceWorker(indexHtml, readFileSync(swPath, "utf8")));
},
};
}