// Boot-time snapshot of ./public, independent of frontend source locations. import { createHash } from 'node:crypto'; import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'; import { join } from 'node:path'; export const assetHash = bytes => createHash('sha256').update(bytes).digest('hex').slice(0, 10); const tagHash = bytes => createHash('sha256').update(bytes).digest('hex').slice(0, 12); export const injectBuildTag = (source, tag) => source.replace( /typeof __BUILD_TAG__ !== 'undefined' \? __BUILD_TAG__ : '[^']*'/, JSON.stringify(tag)); function embedAssets(source, files, groups, buildTag, hashing, assetSync) { const pageFiles = {}; for (const group of Object.values(groups)) for (const path of group.files) { if (path !== '/index.html' && path !== '/sw.js') pageFiles[path] = { ...files[path], h: hashing ? files[path].h : buildTag }; } const json = JSON.stringify({ buildTag, groups, files: pageFiles, appCache: assetSync }).replace(/)[\s\S]*?(<\/script>)/, (_, open, close) => open + json + close); } export function stampIndex(source, files, { hashing = true, buildTag = '__BUILD_TAG__' } = {}) { if (!hashing) return source.replace('__BUILD_TAG__', buildTag) .replace(/((?:href|src)=")((?![a-z]+:|\/)[^"?]+\.(?:css|js))(")/g, `$1$2?v=${buildTag}$3`); // Rewrite resource tags only, not anchors. Preserve unrelated query/fragment // components and absolute external URLs; include root-relative local URLs. return source.replace(/<(?:script|link)\b[^>]*>/gi, tag => tag.replace( /\b(href|src)=(['"])([^'"]+)\2/gi, (attribute, name, quote, url) => { if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(url)) return attribute; const parsed = new URL(url, 'https://assets.invalid/'); const file = files[decodeURIComponent(parsed.pathname)]; // Font preload URLs must stay identical to fonts.css's relative URLs. if (!file || !/\.(?:css|js)$/.test(parsed.pathname) || parsed.pathname === '/sw.js') return attribute; parsed.searchParams.set('v', file.h); const originalPath = url.split(/[?#]/)[0]; return `${name}=${quote}${originalPath}${parsed.search}${parsed.hash}${quote}`; })).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; const bytes = new Map(); const legacy = createHash('sha256'); function walk(dir, prefix = '') { for (const name of readdirSync(dir).sort()) { const path = join(dir, name), url = `${prefix}/${name}`; if (statSync(path).isDirectory()) walk(path, url); else { const content = readFileSync(path); bytes.set(url, 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, 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] = 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 = {}; 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 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 }; const buildTag = hashing ? tagHash(JSON.stringify(canonical)) : (override || legacy.digest('hex').slice(0, 12)); const index = source === null ? null : embedAssets(stampIndex(source, files, { hashing, buildTag }), files, groups, buildTag, hashing, assetSync); const sw = swSource === null ? null : injectBuildTag(swSource, buildTag) .replace("typeof __ASSET_SYNC__ !== 'undefined' ? __ASSET_SYNC__ : true", JSON.stringify(assetSync)) .replace("importScripts('/asset-sync-core.js')", "importScripts('/asset-sync-core.js?v=" + (files['/asset-sync-core.js']?.h || '') + "')"); if (index !== null) files['/index.html'] = { ...files['/index.html'], h: assetHash(index), s: Buffer.byteLength(index) }; 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)); Object.assign(deliveryFiles, files); return { manifest: { buildTag, files, groups, contracts }, index, sw, swSource, bytes, deliveryFiles }; } 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 ? deliveryFiles[path]?.h : manifest.buildTag; if (version !== undefined) { if (version === current) return 'public, max-age=31536000, immutable'; return hashing ? 'no-store' : 'no-cache'; } return /(^|\/)(fonts|icons)\//.test(path) && (hashing || !/\.(?:js|css|html|json|webmanifest|svg|txt)$/i.test(path)) ? 'public, max-age=2592000' : 'no-cache'; }