The marker read added a whole chunk to a running total and compared the total afterwards, so a corrupt body could hand activation a 1 MiB chunk against a 257-byte ceiling. It now reads at most the remaining allowance — through a BYOB reader where the source offers one, and by refusing an oversized chunk before copying it otherwise. A declared oversize cancels the body it refuses instead of leaving the stream open, and the reader lock is released on every path. The build generator and the runtime decoder shared only the extension table, not the path grammar. The generator happily emitted `/assets/bad@name-abcdefgh.js`, which the decoder then refused — a correct build failing at install time. Both now use one exported canonical path predicate and the generator decodes its own output before returning it. The notification click handler emitted its terminal record from inside `process` and again from the `waitUntil` wrapper, so an ordinary click was counted twice. Worse, a late rejection downgraded `MAYBE_APPLIED` to `NOT_APPLIED` — telling operators the click had definitely not been applied when nobody knew that — and the late observation ran outside `waitUntil`, so a worker shutdown lost the evidence. There is one observation authority per click now, certainty is monotone, only an explicit null window confirms `NOT_APPLIED`, and the bounded tail is owned by `waitUntil` without extending the public deadline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
154 lines
5.3 KiB
TypeScript
154 lines
5.3 KiB
TypeScript
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<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}`);
|
|
}
|
|
// 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<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();
|
|
}
|