Files
ytplayer/server/asset-manifest.js

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';
}