94 lines
6.0 KiB
JavaScript
94 lines
6.0 KiB
JavaScript
// 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));
|
|
|
|
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 } = {}) {
|
|
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(source, 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 : stampIndex(source, files, { hashing, buildTag });
|
|
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';
|
|
}
|