Files
bitvid/scripts/build-dist.mjs
T
thePR0M3TH3ANandClaude Opus 4.8 4c5465eb35 build: auto-stamp service worker cache version with the build hash
The SW cache key (bitvid-sw-YYYY-MM-DD) and the informational meta in
index.html/embed.html were hand-bumped, so client caches only invalidated when
someone remembered to change the date — stale app shells (and stale footer
versions) persisted across deploys.

build-dist now stamps the dist artifacts with `bitvid-sw-<buildDate>-<hash8>`
(same content hash shown in the footer), so every content change yields a new
cache name and the SW's activate step drops the old cache automatically. Only
the dist output is rewritten — the committed (minified) sw.min.js source is left
as-is, so the sw-compat guard and tests that reference the source value are
unaffected. Build verified: all three dist files share one stamped version,
dist/sw.min.js still parses, verify:dist:deploy-artifact passes.

Also confirmed `npm run build` is reproducible (same inputs => same hash), so
deploys are deterministic.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 19:42:47 -04:00

304 lines
9.7 KiB
JavaScript

import fs from 'node:fs';
import path from 'node:path';
import { execSync } from 'node:child_process';
import crypto from 'node:crypto';
import { buildHashState } from './hash-dist.mjs';
const DIST = 'dist';
const FILES_TO_COPY = [
'index.html',
'embed.html',
'_redirects',
'_headers',
'site.webmanifest',
'sw.min.js'
];
const DIRS_TO_COPY = [
'assets',
'bitvid_logo',
'components',
'config',
'content',
'css',
'docs',
'examples',
'js',
'releases',
'torrent',
'vendor',
'views'
];
const HASHED_ENTRY_HTML_FILES = ['index.html', 'embed.html'];
const HASHED_ASSET_PATHS = [
'css/docs.css',
'css/tailwind.generated.css',
'js/embed.js',
'js/index.js',
'js/nostrToolsBootstrap.js'
];
const ASSET_MANIFEST_PATH = 'asset-manifest.json';
function cleanDist() {
if (fs.existsSync(DIST)) {
fs.rmSync(DIST, { recursive: true, force: true });
}
fs.mkdirSync(DIST);
}
function copyFile(src, dest) {
if (fs.existsSync(src)) {
fs.copyFileSync(src, dest);
} else {
const message = `Error: Source file ${src} does not exist.`;
console.error(message);
// Debug: list source directory to check what IS there
try {
console.log(`Source directory listing for context:`);
const dir = path.dirname(src);
console.log(fs.readdirSync(dir || '.'));
} catch (e) {
console.error('Failed to list source directory:', e);
}
if (['_headers', '_redirects', 'index.html'].includes(path.basename(src))) {
throw new Error(message);
}
}
}
function copyDir(src, dest) {
if (!fs.existsSync(src)) return;
fs.mkdirSync(dest, { recursive: true });
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
// Exclude reproducers from distribution
if (src.endsWith('examples') && entry.name === 'reproducers') {
continue;
}
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDir(srcPath, destPath);
} else {
copyFile(srcPath, destPath);
}
}
}
function hashContent(content) {
return crypto.createHash('sha256').update(content).digest('hex');
}
function createHashedFilePath(assetPath, hash) {
const ext = path.extname(assetPath);
const baseName = path.basename(assetPath, ext);
const dirName = path.dirname(assetPath);
const shortHash = hash.slice(0, 16);
const hashedName = `${baseName}.${shortHash}${ext}`;
return dirName === '.' ? hashedName : path.posix.join(dirName, hashedName);
}
function escapeForRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function generateHashedAssetManifest() {
const sortedAssetPaths = [...HASHED_ASSET_PATHS].sort();
const manifest = {};
for (const logicalAssetPath of sortedAssetPaths) {
const sourcePath = path.join(DIST, logicalAssetPath);
if (!fs.existsSync(sourcePath)) {
throw new Error(`Missing required asset for hashing: ${logicalAssetPath}`);
}
const content = fs.readFileSync(sourcePath);
const hash = hashContent(content);
const hashedRelativePath = createHashedFilePath(logicalAssetPath, hash);
const hashedDistPath = path.join(DIST, hashedRelativePath);
fs.mkdirSync(path.dirname(hashedDistPath), { recursive: true });
fs.writeFileSync(hashedDistPath, content);
manifest[logicalAssetPath] = hashedRelativePath;
}
const sortedManifest = Object.fromEntries(
Object.entries(manifest).sort(([a], [b]) => a.localeCompare(b))
);
fs.writeFileSync(
path.join(DIST, ASSET_MANIFEST_PATH),
`${JSON.stringify(sortedManifest, null, 2)}\n`
);
return sortedManifest;
}
function rewriteEntryHtmlAssetPaths(manifest) {
for (const htmlFile of HASHED_ENTRY_HTML_FILES) {
const htmlPath = path.join(DIST, htmlFile);
if (!fs.existsSync(htmlPath)) {
continue;
}
let htmlContent = fs.readFileSync(htmlPath, 'utf8');
for (const [logicalPath, hashedPath] of Object.entries(manifest)) {
const escapedLogicalPath = escapeForRegExp(logicalPath);
const attributePattern = new RegExp(
`((?:src|href)=["'])${escapedLogicalPath}(?:\\?[^"']*)?(["'])`,
'g'
);
htmlContent = htmlContent.replace(attributePattern, `$1${hashedPath}$2`);
}
fs.writeFileSync(htmlPath, htmlContent);
}
}
function injectVersionInfo() {
console.log('Injecting version hash and date...');
try {
const hashState = buildHashState(DIST);
const hash = hashState.combined;
const date = new Date().toISOString().split('T')[0];
const indexHtmlPath = path.join(DIST, 'index.html');
if (fs.existsSync(indexHtmlPath)) {
let content = fs.readFileSync(indexHtmlPath, 'utf8');
const versionHtml = `
<div class="mt-4 text-xs text-muted opacity-50 font-mono">
v: ${hash.slice(0, 8)} • ${date}
</div>`;
// Robust replacement using explicit comment placeholder
const placeholder = '<!-- VERSION_INFO -->';
if (content.includes(placeholder)) {
content = content.replace(placeholder, versionHtml);
fs.writeFileSync(indexHtmlPath, content);
console.log(`Injected version: ${hash.slice(0, 8)} • ${date}`);
} else {
console.warn('Warning: <!-- VERSION_INFO --> placeholder not found. Attempting regex fallback...');
// Fallback search if placeholder is missing (legacy behavior)
const taglineRegex = /(seed\. zap\. subscribe\.\s*<\/h2>)/;
if (taglineRegex.test(content)) {
content = content.replace(taglineRegex, '$1' + versionHtml);
fs.writeFileSync(indexHtmlPath, content);
console.log(`Injected version (via fallback regex): ${hash.slice(0, 8)} • ${date}`);
} else {
throw new Error('Could not find placeholder or tagline to inject version info.');
}
}
}
// Return the build-derived service worker cache version so the SW + meta
// can be stamped with the same hash (see stampServiceWorkerVersion).
return `bitvid-sw-${date}-${hash.slice(0, 8)}`;
} catch (error) {
console.error('Failed to inject version info:', error);
process.exit(1);
}
}
// The service worker cache key (and the informational meta in the entry HTML)
// was a hand-bumped date string, so caches only invalidated when someone
// remembered to change it. Stamp it with the build hash at build time so EVERY
// content change produces a new cache name -> the SW's activate step drops the
// stale cache automatically. We rewrite only the dist artifacts, leaving the
// committed (minified) source untouched so the sw-compat guard and unit tests
// that reference the source value are unaffected.
const SW_VERSION_PATTERN = /bitvid-sw-\d{4}-\d{2}-\d{2}(?:-[0-9a-f]{6,})?/g;
const SW_VERSIONED_DIST_FILES = ['sw.min.js', 'index.html', 'embed.html'];
function stampServiceWorkerVersion(version) {
if (!version || !/^bitvid-sw-/.test(version)) {
throw new Error(`Refusing to stamp an invalid service worker version: ${version}`);
}
console.log(`Stamping service worker cache version: ${version}`);
for (const file of SW_VERSIONED_DIST_FILES) {
const filePath = path.join(DIST, file);
if (!fs.existsSync(filePath)) {
throw new Error(`Cannot stamp SW version: dist/${file} is missing.`);
}
const original = fs.readFileSync(filePath, 'utf8');
const matches = original.match(SW_VERSION_PATTERN);
if (!matches || matches.length === 0) {
throw new Error(
`Cannot stamp SW version: no 'bitvid-sw-*' cache key found in dist/${file}. ` +
'The service worker cache-busting contract changed — update build-dist.mjs.'
);
}
fs.writeFileSync(filePath, original.replace(SW_VERSION_PATTERN, version));
console.log(` ${file}: stamped ${matches.length} reference(s)`);
}
}
function main() {
console.log('Validating service worker compatibility guard...');
try {
execSync('npm run lint:sw-compat', { stdio: 'inherit' });
} catch (error) {
console.error('Failed service worker compatibility guard.');
process.exit(1);
}
console.log('Cleaning dist...');
cleanDist();
console.log('Running build:css...');
try {
execSync('npm run build:css', { stdio: 'inherit' });
} catch (error) {
console.error('Failed to run build:css');
process.exit(1);
}
console.log('Copying files...');
for (const file of FILES_TO_COPY) {
copyFile(file, path.join(DIST, file));
}
// Verify critical CSS generation
const generatedCssSource = path.join('css', 'tailwind.generated.css');
const generatedCssDist = path.join(DIST, 'css', 'tailwind.generated.css');
if (!fs.existsSync(generatedCssSource) && !fs.existsSync(generatedCssDist)) {
console.error('Error: css/tailwind.generated.css was not generated.');
try {
console.log('Listing css/ directory:');
if (fs.existsSync('css')) {
console.log(fs.readdirSync('css'));
} else {
console.log('css/ directory does not exist.');
}
} catch (e) {
console.error('Failed to list css directory:', e);
}
process.exit(1);
}
console.log('Copying directories...');
for (const dir of DIRS_TO_COPY) {
copyDir(dir, path.join(DIST, dir));
}
// Verify components were copied (critical for client-side injection)
const componentsDist = path.join(DIST, 'components');
if (!fs.existsSync(componentsDist) || fs.readdirSync(componentsDist).length === 0) {
throw new Error('Critical Error: dist/components directory is missing or empty.');
}
console.log('Generating hashed asset manifest...');
const manifest = generateHashedAssetManifest();
console.log('Rewriting HTML entry points with hashed assets...');
rewriteEntryHtmlAssetPaths(manifest);
const swVersion = injectVersionInfo();
stampServiceWorkerVersion(swVersion);
console.log('Build complete.');
}
main();