The product was materialized from the template at `4dc033c` and has stayed on it through 43 template commits, so it was missing all three rounds of adapter remediation — including files it never had, such as the shared `abortable-operation` primitive and the `exact-snapshot` decoder that later fixes are written against. Taking only the newest round was not possible for that reason: the delta is coherent only as a whole. The product had not touched `src/adapters` at all since materialization, so the 140-file delta applied with a three-way merge and no conflicts. `package.json` was the single overlap and merged cleanly: the product owns `name`, the template contributed `check:adapter-inventory`, `check:remediation-ledger` and the image-resolve-signal type fixture. All 24 product-owned files — README, index.html, CI workflow, i18n catalog, home page, generated schemas, evidence scripts, component and visual snapshots — are byte-identical to `main`. `template.lock.json` now pins the synced revision and tree. Verified in this repository, not inherited from the template: six type projects, lint, nine gates (adapter inventory, remediation ledger, registries, diagnostics, realtime boundaries, architecture, browser file/storage boundaries, optional recipes, documentation), the production build, and 2,054 of 2,073 tests. The 19 failures are all in `tests/unit/ci-artifact-contract.test.ts` and are the same pre-existing sandbox RLIMIT, EMFILE, umask and `/tmp` permission behaviour the template records; four suites that failed once under parallel load pass in isolation. 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();
|
|
}
|