import { createHash } from "node:crypto"; import { CACHEABLE_ASSET_CONTENT_TYPES, canonicalStaticManifestBytes, decodeStaticAssetManifest, isCanonicalStaticAssetUrl, } 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"; // SW-RR-03. The generator and the shared decoder read the same table, so a // manifest this script produces can never be one the runtime contract refuses. const CACHEABLE_EXTENSIONS = CACHEABLE_ASSET_CONTENT_TYPES; const EXCLUDED_FILES: ReadonlySet = 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 { 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}`); } // SW-02. The URL is checked against the same predicate the runtime decoder // applies. Emitting a path the decoder will refuse turned a correct build // into a runtime contract failure discovered only at install time. const url = `/${relative.split(path.sep).join("/")}`; if (!isCanonicalStaticAssetUrl(url)) { throw new Error( `Static asset path is not canonical for the service worker manifest: ${relative}`, ); } assets.push({ url, 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")}`; const manifest: StaticAssetManifestV1 = { schemaVersion: 1, buildId, releaseId, setDigest, assets, }; // SW-02. Every manifest this generator returns has already passed the exact // decoder the runtime will apply to it, so the build stops here rather than // at install time. const decoded = decodeStaticAssetManifest(manifest); if (!decoded.ok) { throw new Error( `Generated service worker manifest is not decodable: ${decoded.error.reason}`, ); } return manifest; } async function walk(root: string, current: string): Promise { 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 { 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(); }