Include all precached assets in the service worker revision
The build revision now hashes index.html, the sw.js source, the web app manifest and every precached icon (sorted by path, length-prefixed), so a deploy that changes only the manifest or an icon ships a new worker and fresh caches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,11 @@ import { existsSync, readFileSync } from "node:fs";
|
|||||||
import { dirname, resolve } from "node:path";
|
import { dirname, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { REVISION_MARKER, injectServiceWorker } from "../../vite-sw-plugin";
|
import {
|
||||||
|
REVISION_MARKER,
|
||||||
|
STATIC_PRECACHE_FILES,
|
||||||
|
injectServiceWorker,
|
||||||
|
} from "../../vite-sw-plugin";
|
||||||
import { registerServiceWorker } from "./register";
|
import { registerServiceWorker } from "./register";
|
||||||
|
|
||||||
const adminRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
const adminRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
@@ -245,10 +249,17 @@ const referencedAssets = (html: string) =>
|
|||||||
.filter((url) => url.origin === "https://admin.test")
|
.filter((url) => url.origin === "https://admin.test")
|
||||||
.map((url) => url.href);
|
.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", () => {
|
describe("build-time service worker injection", () => {
|
||||||
it("precaches every asset the cached index.html references on install (PWA-001)", async () => {
|
it("precaches every asset the cached index.html references on install (PWA-001)", async () => {
|
||||||
const html = builtIndexHtml("Ab12Cd34");
|
const html = builtIndexHtml("Ab12Cd34");
|
||||||
const built = injectServiceWorker(html, swSource);
|
const built = injectServiceWorker(html, swSource, staticFiles);
|
||||||
const sw = loadServiceWorker(async (url) =>
|
const sw = loadServiceWorker(async (url) =>
|
||||||
new FakeResponse(url.endsWith("/index.html") ? html : `net:${url}`),
|
new FakeResponse(url.endsWith("/index.html") ? html : `net:${url}`),
|
||||||
built);
|
built);
|
||||||
@@ -272,20 +283,41 @@ describe("build-time service worker injection", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("derives a new revision for different index.html content (PWA-002)", () => {
|
it("derives a new revision for different index.html content (PWA-002)", () => {
|
||||||
const a = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource);
|
const a = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles);
|
||||||
const b = injectServiceWorker(builtIndexHtml("bbbb2222"), swSource);
|
const b = injectServiceWorker(builtIndexHtml("bbbb2222"), swSource, staticFiles);
|
||||||
const revisionOf = (src: string) => /const REVISION = "([0-9a-f]+)";/.exec(src)?.[1];
|
|
||||||
|
|
||||||
expect(a).not.toContain(REVISION_MARKER);
|
expect(a).not.toContain(REVISION_MARKER);
|
||||||
expect(b).not.toContain(REVISION_MARKER);
|
expect(b).not.toContain(REVISION_MARKER);
|
||||||
expect(revisionOf(a)).toMatch(/^[0-9a-f]{12}$/);
|
expect(revisionOf(a)).toMatch(/^[0-9a-f]{12}$/);
|
||||||
expect(revisionOf(b)).toMatch(/^[0-9a-f]{12}$/);
|
expect(revisionOf(b)).toMatch(/^[0-9a-f]{12}$/);
|
||||||
expect(revisionOf(a)).not.toBe(revisionOf(b));
|
expect(revisionOf(a)).not.toBe(revisionOf(b));
|
||||||
expect(injectServiceWorker(builtIndexHtml("aaaa1111"), swSource)).toBe(a);
|
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 () => {
|
it("names both caches after the build revision (PWA-002)", async () => {
|
||||||
const built = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource);
|
const built = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles);
|
||||||
const revision = /const REVISION = "([0-9a-f]+)";/.exec(built)![1];
|
const revision = /const REVISION = "([0-9a-f]+)";/.exec(built)![1];
|
||||||
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`), built);
|
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`), built);
|
||||||
|
|
||||||
@@ -298,11 +330,11 @@ describe("build-time service worker injection", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("fails fast when sw.js lacks the placeholders", () => {
|
it("fails fast when sw.js lacks the placeholders", () => {
|
||||||
expect(() => injectServiceWorker(builtIndexHtml("x"), "const SHELL_URLS = [];")).toThrow();
|
expect(() => injectServiceWorker(builtIndexHtml("x"), "const SHELL_URLS = [];", staticFiles)).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("activation deletes only stale nestquest-admin caches (PWA-003)", async () => {
|
it("activation deletes only stale nestquest-admin caches (PWA-003)", async () => {
|
||||||
const built = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource);
|
const built = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles);
|
||||||
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`), built);
|
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`), built);
|
||||||
for (const name of [
|
for (const name of [
|
||||||
"nestquest-admin-shell-old",
|
"nestquest-admin-shell-old",
|
||||||
|
|||||||
+43
-7
@@ -10,6 +10,18 @@ import type { Plugin } from "vite";
|
|||||||
|
|
||||||
export const REVISION_MARKER = "__NQ_BUILD_REVISION__";
|
export const REVISION_MARKER = "__NQ_BUILD_REVISION__";
|
||||||
const PRECACHE_PLACEHOLDER = "const PRECACHE_ASSETS = [];";
|
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>,
|
// 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.
|
// normalised to an absolute path, in document order and without duplicates.
|
||||||
@@ -24,17 +36,34 @@ export function extractShellAssets(indexHtml: string): string[] {
|
|||||||
return [...new Set(urls)];
|
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.
|
// Returns swSource with the precache list and the build revision filled in.
|
||||||
export function injectServiceWorker(indexHtml: string, swSource: string): string {
|
// 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)) {
|
if (!swSource.includes(PRECACHE_PLACEHOLDER) || !swSource.includes(REVISION_MARKER)) {
|
||||||
throw new Error("sw.js is missing the PRECACHE_ASSETS placeholder or revision marker");
|
throw new Error("sw.js is missing the PRECACHE_ASSETS placeholder or revision marker");
|
||||||
}
|
}
|
||||||
const assets = extractShellAssets(indexHtml);
|
const assets = extractShellAssets(indexHtml);
|
||||||
const revision = createHash("sha256")
|
const revision = computeRevision([
|
||||||
.update(indexHtml)
|
{ path: "index.html", bytes: indexHtml },
|
||||||
.update(swSource)
|
{ path: "sw.js", bytes: swSource },
|
||||||
.digest("hex")
|
...staticFiles,
|
||||||
.slice(0, 12);
|
]);
|
||||||
return swSource
|
return swSource
|
||||||
.replace(PRECACHE_PLACEHOLDER, `const PRECACHE_ASSETS = ${JSON.stringify(assets)};`)
|
.replace(PRECACHE_PLACEHOLDER, `const PRECACHE_ASSETS = ${JSON.stringify(assets)};`)
|
||||||
.replaceAll(REVISION_MARKER, revision);
|
.replaceAll(REVISION_MARKER, revision);
|
||||||
@@ -51,7 +80,14 @@ export function serviceWorkerInjectPlugin(): Plugin {
|
|||||||
writeBundle() {
|
writeBundle() {
|
||||||
const swPath = resolve(outDir, "sw.js");
|
const swPath = resolve(outDir, "sw.js");
|
||||||
const indexHtml = readFileSync(resolve(outDir, "index.html"), "utf8");
|
const indexHtml = readFileSync(resolve(outDir, "index.html"), "utf8");
|
||||||
writeFileSync(swPath, injectServiceWorker(indexHtml, readFileSync(swPath, "utf8")));
|
const staticFiles = STATIC_PRECACHE_FILES.map((path) => ({
|
||||||
|
path,
|
||||||
|
bytes: readFileSync(resolve(outDir, path)),
|
||||||
|
}));
|
||||||
|
writeFileSync(
|
||||||
|
swPath,
|
||||||
|
injectServiceWorker(indexHtml, readFileSync(swPath, "utf8"), staticFiles),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user