Files
clean-architecture-frontend…/scripts/generate-service-worker-assets.ts
T
DongHyeonkaandClaude Opus 5 58efe6ddbd fix: make Service Worker cache and removal outcomes truthful
SW-URL-01: canonicalize each generated root-relative manifest URL against the
registration scope once, re-check same-origin, and share that absolute identity
across install cache keys, fetch classification and cache lookup or delete.
Previously every verified asset fell through to the network.

SW-01: serve verified static requests only from the current release cache. A
CacheStorage-wide match could return a previous release's response for the same
URL while the delete targeted a cache that was never read. The worker scope
facade no longer exposes a wide match at all.

SW-02: cache reset deletes only names that parse as owned, so a foreign cache
sharing the ca-static-v1- prefix survives.

SW-03: unregister() resolving to false is a FAILED unregister, not UNREGISTERED.

SW-04: staged removal reports what happened - ABSENT, UNREGISTERED and PURGED
map to DISABLED, OWNERSHIP_MISMATCH to INCOMPATIBLE and FAILED to FAILED - so a
later release cannot delete the worker while a registration or owned cache is
still present.

SW-05: add the runtime-neutral service-worker-static-manifest codec that owns
exact row keys, the extension and content-type allowlist, the root-relative URL
rule and the length-prefixed canonical bytes. The generator and the build gate
hash those same bytes, and the build gate now decodes and recomputes the set
digest instead of type-casting the manifest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 00:25:11 +09:00

136 lines
4.4 KiB
TypeScript

import { createHash } from "node:crypto";
import { canonicalStaticManifestBytes } from "../src/contracts/service-worker-static-manifest.ts";
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import {
SERVICE_WORKER_BOUNDS,
SERVICE_WORKER_SCRIPT_PATH,
type StaticAssetManifestV1,
} from "../src/contracts/service-worker.ts";
/**
* §17.2.2 step 4. Scans the completed app `dist` and emits the exact hashed
* asset list the Service Worker will verify at install time.
*
* `service-worker.js` itself and `index.html` are excluded (§17.2.2), as are
* the runtime config and release manifest, which are network-only (§18.4).
*/
const OUTPUT = ".generated/frontend-runtime/service-worker-assets.ts";
const CACHEABLE_EXTENSIONS: Readonly<Record<string, string>> = Object.freeze({
".js": "text/javascript",
".mjs": "text/javascript",
".css": "text/css",
".woff2": "font/woff2",
".svg": "image/svg+xml",
".png": "image/png",
".webp": "image/webp",
});
const EXCLUDED_FILES: ReadonlySet<string> = new Set([
"index.html",
SERVICE_WORKER_SCRIPT_PATH,
"config.json",
"release-manifest.json",
"runtime-config.schema.json",
]);
/** Vite emits content-hashed names; only those may be treated as immutable. */
const HASHED_NAME = /-[A-Za-z0-9_-]{8,}\.[a-z0-9]+$/;
export async function collectStaticAssets(
distDirectory: string,
buildId: string,
releaseId: string,
): Promise<StaticAssetManifestV1> {
const files = await walk(distDirectory, distDirectory);
const assets: StaticAssetManifestV1["assets"][number][] = [];
for (const relative of files.sort()) {
const base = path.basename(relative);
if (EXCLUDED_FILES.has(base) || relative.startsWith(".vite/")) continue;
const contentType = CACHEABLE_EXTENSIONS[path.extname(base).toLowerCase()];
if (!contentType || !HASHED_NAME.test(base)) continue;
const absolute = path.join(distDirectory, relative);
const bytes = await readFile(absolute);
if (bytes.byteLength > SERVICE_WORKER_BOUNDS.singleAssetBytes) {
throw new Error(`Static asset exceeds its byte bound: ${relative}`);
}
assets.push({
url: `/${relative.split(path.sep).join("/")}`,
sha256: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
bytes: bytes.byteLength,
contentType,
});
}
if (assets.length > SERVICE_WORKER_BOUNDS.assets) {
throw new Error("Static asset count exceeds its bound.");
}
const totalBytes = assets.reduce((sum, asset) => sum + asset.bytes, 0);
if (totalBytes > SERVICE_WORKER_BOUNDS.assetSetBytes) {
throw new Error("Static asset set exceeds its byte bound.");
}
// SW-05. The canonical byte serialization lives in the shared runtime-neutral
// codec so the worker can recompute the identical digest with WebCrypto.
const setDigest: `sha256:${string}` = `sha256:${createHash("sha256")
.update(canonicalStaticManifestBytes(assets))
.digest("hex")}`;
return {
schemaVersion: 1,
buildId,
releaseId,
setDigest,
assets,
};
}
async function walk(root: string, current: string): Promise<string[]> {
const entries = await readdir(current, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const absolute = path.join(current, entry.name);
if (entry.isDirectory()) {
files.push(...(await walk(root, absolute)));
} else if ((await stat(absolute)).isFile()) {
files.push(path.relative(root, absolute));
}
}
return files;
}
async function main(): Promise<void> {
const distDirectory = process.argv[2] ?? "dist";
const buildId = process.env.VITE_BUILD_ID ?? "local-build";
const releaseId = process.env.RELEASE_ID ?? "local-release";
const manifest = await collectStaticAssets(distDirectory, buildId, releaseId);
const source = [
"// Generated by scripts/generate-service-worker-assets.ts. Do not edit.",
"",
'import type { StaticAssetManifestV1 } from "../../src/contracts/service-worker.ts";',
"",
`export const SERVICE_WORKER_ASSETS: StaticAssetManifestV1 = ${JSON.stringify(
manifest,
null,
2,
)} as const;`,
"",
].join("\n");
await mkdir(path.dirname(OUTPUT), { recursive: true });
await writeFile(OUTPUT, source, "utf8");
process.stdout.write(
`service worker assets: ${manifest.assets.length} file(s) ${manifest.setDigest}\n`,
);
}
if (process.argv[1]?.endsWith("generate-service-worker-assets.ts")) {
await main();
}