Merge manifest hygiene and reconcile runtime exclusions with CSP response capture

This commit is contained in:
Jonathan Sykes
2026-10-09 15:12:39 +08:00
14 changed files with 22494 additions and 15 deletions

View File

@@ -35,6 +35,14 @@ export function stampIndex(source, files, { hashing = true, buildTag = '__BUILD_
})).replace('__BUILD_TAG__', buildTag);
}
// Runtime set: all public files except developer material and the online-only admin.
// Exact root-relative includes in assets.json override these rules for real runtime data.
export function isRuntimeAsset(path, definition = {}) {
if (definition.include?.includes(path)) return true;
return path !== '/admin.html' && !/(?:^|\/)perf(?:\/|$)/i.test(path) &&
!/\.(?:test\.(?:js|mjs)|md|txt|c|entry\.js)$/i.test(path);
}
export function createAssetManifest(publicDir = './public', { hashing = true, buildTag: override, assetSync = true } = {}) {
// Single-tag URLs cannot be verified against per-file URL hashes.
assetSync = hashing && assetSync;
@@ -47,35 +55,43 @@ export function createAssetManifest(publicDir = './public', { hashing = true, bu
else {
const content = readFileSync(path);
bytes.set(url, content);
legacy.update(`./public${url}`); legacy.update(content);
}
}
}
// Missing public is the historical fixed fallback used by local tests.
if (!existsSync(publicDir)) return { manifest: { buildTag: override || 'dev-build', files: {}, groups: {}, contracts: {} }, index: null, sw: null, swSource: null, bytes };
if (!existsSync(publicDir)) return { manifest: { buildTag: override || 'dev-build', files: {}, groups: {}, contracts: {} }, index: null, sw: null, swSource: null, bytes, deliveryFiles: {} };
walk(publicDir);
const definition = bytes.has('/assets.json') ? JSON.parse(bytes.get('/assets.json').toString()) : {
groups: { core: { contract: 1, eager: true, files: [...bytes.keys()].sort() } },
};
if (definition.include !== undefined && (!Array.isArray(definition.include) || definition.include.some(path => typeof path !== 'string' || !path.startsWith('/') || path.includes('..') || !bytes.has(path)))) throw new Error('Invalid runtime asset include');
const runtimePaths = [...bytes.keys()].filter(path => isRuntimeAsset(path, definition)).sort();
for (const path of runtimePaths) { legacy.update(`./public${path}`); legacy.update(bytes.get(path)); }
const groups = {}, contracts = {}, membership = new Map();
for (const name of Object.keys(definition.groups).sort()) {
const group = definition.groups[name];
if (!Number.isInteger(group.contract) || group.contract < 1 || typeof group.eager !== 'boolean' || !Array.isArray(group.files)) throw new Error(`Invalid asset group ${name}`);
groups[name] = group; contracts[name] = group.contract;
for (const url of group.files) {
groups[name] = bytes.has('/assets.json') ? group : { ...group, files: group.files.filter(path => isRuntimeAsset(path, definition)) }; contracts[name] = group.contract;
for (const url of groups[name].files) {
if (!isRuntimeAsset(url, definition)) throw new Error(`Asset group ${name}: excluded ${url}; declare a runtime include if required`);
if (!bytes.has(url)) throw new Error(`Asset group ${name}: missing ${url}`);
if (membership.has(url)) throw new Error(`Duplicate asset membership: ${url}`);
membership.set(url, name);
}
}
const files = {};
for (const url of [...bytes.keys()].sort()) files[url] = { h: assetHash(bytes.get(url)), s: bytes.get(url).length, g: membership.get(url) || 'core' };
const deliveryFiles = {};
for (const url of [...bytes.keys()].sort()) {
const file = { h: assetHash(bytes.get(url)), s: bytes.get(url).length, g: membership.get(url) || 'core' };
deliveryFiles[url] = file;
if (isRuntimeAsset(url, definition)) files[url] = file;
}
const source = bytes.get('/index.html')?.toString()
.replaceAll('__APP_SCRIPT_HASH__', createHash('sha256').update(bytes.get('/app.js') || '').digest('base64')) ?? null;
const swSource = bytes.get('/sw.js')?.toString() ?? null;
// Derived build metadata cannot be an input to its own hash. Canonicalize
// the index meta and SW injected tag, then publish hashes of the final bytes.
// All original bytes (including index/SW source) remain inputs via source hashes.
// All runtime source bytes (including index/SW source) remain inputs via source hashes.
let canonicalIndex = source === null ? null : stampIndex(embedAssets(source, files, groups, '__BUILD_TAG__', hashing, assetSync), files, { hashing });
const canonical = { assetSync, files: { ...files }, groups, contracts };
if (canonicalIndex !== null && hashing) canonical.files['/index.html'] = { ...files['/index.html'], h: assetHash(canonicalIndex), s: Buffer.byteLength(canonicalIndex), source: files['/index.html'].h };
@@ -88,15 +104,16 @@ export function createAssetManifest(publicDir = './public', { hashing = true, bu
if (sw !== null) files['/sw.js'] = { ...files['/sw.js'], h: assetHash(sw), s: Buffer.byteLength(sw) };
if (index !== null) bytes.set('/index.html', Buffer.from(index));
if (sw !== null) bytes.set('/sw.js', Buffer.from(sw));
return { manifest: { buildTag, files, groups, contracts }, index, sw, swSource, bytes };
Object.assign(deliveryFiles, files);
return { manifest: { buildTag, files, groups, contracts }, index, sw, swSource, bytes, deliveryFiles };
}
export function assetCacheControl(path, version, manifest, hashing = true) {
export function assetCacheControl(path, version, manifest, hashing = true, deliveryFiles = manifest.files) {
const shortCache = /(^|\/)(fonts|icons)\//.test(path) ? 'public, max-age=2592000' : 'no-cache';
// The legacy index route always revalidates; legacy binary delivery ignored v.
if (!hashing && path === '/index.html') return 'no-cache';
if (!hashing && !/\.(?:js|css|html|json|webmanifest|svg|txt)$/i.test(path)) return shortCache;
const current = hashing ? manifest.files[path]?.h : manifest.buildTag;
const current = hashing ? deliveryFiles[path]?.h : manifest.buildTag;
if (version !== undefined) {
if (version === current) return 'public, max-age=31536000, immutable';
return hashing ? 'no-store' : 'no-cache';