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 = `
v: ${hash.slice(0, 8)} • ${date}
`; // Robust replacement using explicit comment placeholder const placeholder = ''; 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: 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();