#!/usr/bin/env node import { brotliCompressSync, constants as zlibConstants } from 'node:zlib'; import { readFile, readdir, stat } from 'node:fs/promises'; import { relative, resolve, sep } from 'node:path'; import { classifyArtifact, evaluateBudgetTotals, PERFORMANCE_BUDGETS } from './lib/bundle-budgets.mjs'; const root = resolve(process.argv[2] ?? 'dist'); if (!(await stat(root)).isDirectory()) throw new Error('Bundle budget target must be a directory.'); const listFiles = async (directory) => { const files = []; for (const entry of await readdir(directory, { withFileTypes: true })) { const path = resolve(directory, entry.name); if (entry.isSymbolicLink()) throw new Error('Bundle budget input cannot contain symbolic links.'); if (entry.isDirectory()) files.push(...await listFiles(path)); else if (entry.isFile()) files.push(path); else throw new Error('Bundle budget input contains an unsupported entry.'); } return files; }; const compressedSize = (path, bytes) => (/\.(?:avif|br|gif|jpe?g|mp4|png|webm|webp|woff2?)$/iu.test(path) ? bytes.byteLength : brotliCompressSync(bytes, { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 11 }, }).byteLength); const records = []; for (const absolute of await listFiles(root)) { const path = relative(root, absolute).split(sep).join('/'); const bytes = await readFile(absolute); const source = /\.(?:css|html|js|json|txt|xml)$/u.test(path) ? bytes.toString('utf8') : ''; const bucket = classifyArtifact(path, source); records.push({ path, bucket, bytes: bytes.byteLength, transferBytes: compressedSize(path, bytes) }); } const sum = (bucket) => records.filter((record) => record.bucket === bucket).reduce((total, record) => total + record.transferBytes, 0); const totals = { initialHtmlCriticalCss: sum('initial-html') + sum('critical-css'), initialApplicationJs: sum('initial-application-js'), heroPoster: sum('hero-poster'), initialFonts: sum('initial-fonts'), firstLiveScene: sum('first-live-scene'), deferredProjectVisuals: sum('project-still') + sum('chart'), deferredNonVideo: sum('first-live-scene') + sum('project-still') + sum('chart'), }; for (const record of records.filter((item) => item.bucket === 'project-video')) { if (record.transferBytes > PERFORMANCE_BUDGETS.projectVideo) { throw new Error(`Project video exceeds its transfer budget: ${record.path}`); } } for (const record of records.filter((item) => item.bucket === 'project-still')) { if (record.transferBytes > PERFORMANCE_BUDGETS.projectStill) { throw new Error(`Project still exceeds its transfer budget: ${record.path}`); } } for (const record of records.filter((item) => item.bucket === 'chart')) { if (record.transferBytes > PERFORMANCE_BUDGETS.chart) { throw new Error(`Chart exceeds its transfer budget: ${record.path}`); } } const entry = records.find((record) => record.bucket === 'initial-application-js'); const scene = records.find((record) => record.bucket === 'first-live-scene'); if (entry === undefined || scene === undefined) throw new Error('Both initial application and deferred live-scene chunks are required.'); const entrySource = await readFile(resolve(root, entry.path), 'utf8'); if (!/import\([^)]*environment-/u.test(entrySource)) throw new Error('The live scene is not separated behind a dynamic import.'); if (/WebGLRenderer|WebGLProgram|ShaderMaterial/u.test(entrySource)) throw new Error('Three.js/live-scene code leaked into the reduced-motion initial chunk.'); const result = evaluateBudgetTotals(totals); const report = { schema: 'portfolio-bundle-budget/v1', compression: 'brotli-quality-11-or-raw-already-compressed', totals, records: records.sort((left, right) => left.path.localeCompare(right.path)), violations: result.violations, }; process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); if (result.violations.length > 0) process.exitCode = 1;