import { createHash } from "node:crypto"; 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> = 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 = 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}`); } 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."); } // The set digest is a length-prefixed hash over the sorted asset identities, // so a reordered directory listing cannot change it. const hash = createHash("sha256"); hash.update("CA_STATIC_ASSET_SET_V1\0"); for (const asset of assets) { hash.update(lengthPrefixed(asset.url)); hash.update(lengthPrefixed(asset.sha256)); hash.update(lengthPrefixed(String(asset.bytes))); hash.update(lengthPrefixed(asset.contentType)); } return { schemaVersion: 1, buildId, releaseId, setDigest: `sha256:${hash.digest("hex")}`, assets, }; } function lengthPrefixed(value: string): Buffer { const bytes = Buffer.from(value, "utf8"); const prefix = Buffer.alloc(4); prefix.writeUInt32BE(bytes.byteLength, 0); return Buffer.concat([prefix, bytes]); } 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(); }