// 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) { 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 }).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); } 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); 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 }; 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() } }, }; 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) { 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 source = bytes.get('/index.html')?.toString() ?? 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. let canonicalIndex = source === null ? null : stampIndex(embedAssets(source, files, groups, '__BUILD_TAG__', hashing), 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); 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)); return { manifest: { buildTag, files, groups, contracts }, index, sw, swSource, bytes }; } export function assetCacheControl(path, version, manifest, hashing = true) { 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; 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'; }