chore: initialize from frontend template 4dc033c
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
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<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.");
|
||||
}
|
||||
|
||||
// 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<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();
|
||||
}
|
||||
Reference in New Issue
Block a user