/** * EP-13 Bundle Budget Checker * Statically analyses the Vite build output (dist/assets) against size thresholds. * Runs without a browser — suitable for CI gate after `npm run build`. * * Usage: * node scripts/check-bundle-budget.mjs [--fail-on=error|warning] [--json] * * Exit codes: * 0 — all budgets passed * 1 — one or more budgets exceeded (or --fail-on=warning and a warning fired) */ import { promises as fs } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import { createGzip } from 'node:zlib' import { pipeline } from 'node:stream/promises' import { createReadStream } from 'node:fs' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const uiRoot = path.resolve(__dirname, '..') const distDir = path.join(uiRoot, 'dist') const assetsDir = path.join(distDir, 'assets') // --------------------------------------------------------------------------- // Budget table (all sizes in KiB) // --------------------------------------------------------------------------- const BUDGETS = { // Total JS in dist/assets total_js_kib: { warn: 1800, error: 2500 }, // Largest single JS chunk largest_chunk_kib: { warn: 600, error: 900 }, // Total CSS total_css_kib: { warn: 120, error: 200 }, // Gzip-compressed total JS estimate (approx ratio 0.30) total_js_gz_kib: { warn: 600, error: 800 }, // Total dist size (all files) total_dist_kib: { warn: 5000, error: 8000 }, } const args = process.argv.slice(2) const failOnStr = args.find((a) => a.startsWith('--fail-on=')) const failOn = failOnStr ? failOnStr.split('=')[1] : 'error' const outputJson = args.includes('--json') function toKiB(bytes) { return Math.round((bytes / 1024) * 100) / 100 } async function gzipSize(filePath) { let size = 0 const src = createReadStream(filePath) const gz = createGzip({ level: 9 }) gz.on('data', (chunk) => { size += chunk.length }) await pipeline(src, gz) return size } async function collectFiles(dir, extFilter = null) { let all = [] let entries try { entries = await fs.readdir(dir, { withFileTypes: true }) } catch { return all } for (const entry of entries) { const full = path.join(dir, entry.name) if (entry.isDirectory()) { all = all.concat(await collectFiles(full, extFilter)) } else if (!extFilter || extFilter.test(entry.name)) { const stat = await fs.stat(full) all.push({ path: full, name: entry.name, size: stat.size }) } } return all } async function main() { // Ensure dist exists try { await fs.access(assetsDir) } catch { console.error('ERROR: dist/assets not found — run `npm run build` first.') process.exit(1) } const jsFiles = await collectFiles(assetsDir, /\.js$/) const cssFiles = await collectFiles(assetsDir, /\.css$/) const allDistFiles = await collectFiles(distDir) const totalJsBytes = jsFiles.reduce((s, f) => s + f.size, 0) const largestChunk = jsFiles.reduce((max, f) => (f.size > max.size ? f : max), { size: 0, name: '(none)' }) const totalCssBytes = cssFiles.reduce((s, f) => s + f.size, 0) const totalDistBytes = allDistFiles.reduce((s, f) => s + f.size, 0) // Estimate gzip for JS (compute on largest JS file only for speed) let largestGzBytes = 0 if (largestChunk.size > 0) { try { largestGzBytes = await gzipSize(largestChunk.path) } catch { largestGzBytes = Math.round(largestChunk.size * 0.30) // fallback estimate } } // Estimate total JS gzip via approximation (saves time in CI) const totalJsGzBytes = Math.round(totalJsBytes * 0.30) const measurements = { total_js_kib: toKiB(totalJsBytes), largest_chunk_kib: toKiB(largestChunk.size), largest_chunk_name: largestChunk.name, largest_chunk_gz_kib: toKiB(largestGzBytes), total_css_kib: toKiB(totalCssBytes), total_js_gz_kib: toKiB(totalJsGzBytes), total_dist_kib: toKiB(totalDistBytes), js_file_count: jsFiles.length, css_file_count: cssFiles.length, } const results = [] let hasError = false let hasWarning = false for (const [key, budget] of Object.entries(BUDGETS)) { const actual = measurements[key] ?? 0 let status = 'pass' if (actual > budget.error) { status = 'error' hasError = true } else if (actual > budget.warn) { status = 'warning' hasWarning = true } results.push({ metric: key, actual, warn: budget.warn, error: budget.error, status }) } const report = { generated_at: new Date().toISOString(), measurements, budgets: results, summary: { pass: results.filter((r) => r.status === 'pass').length, warning: results.filter((r) => r.status === 'warning').length, error: results.filter((r) => r.status === 'error').length, }, } if (outputJson) { console.log(JSON.stringify(report, null, 2)) } else { console.log('\nšŸ“¦ EP-13 Bundle Budget Report') console.log('─'.repeat(72)) console.log( ` ${'Metric'.padEnd(30)} ${'Actual'.padStart(10)} ${'Warn'.padStart(8)} ${'Error'.padStart(8)} ${'Status'.padStart(10)}` ) console.log('─'.repeat(72)) for (const r of results) { const icon = r.status === 'pass' ? 'āœ”' : r.status === 'warning' ? '⚠' : 'āœ–' console.log( ` ${icon} ${r.metric.padEnd(28)} ${String(r.actual + ' KiB').padStart(10)} ${String(r.warn + ' KiB').padStart(8)} ${String(r.error + ' KiB').padStart(8)} ${r.status.padStart(10)}` ) } console.log('─'.repeat(72)) console.log( ` Summary: ${report.summary.pass} passed, ${report.summary.warning} warnings, ${report.summary.error} errors` ) console.log(` Largest chunk: ${measurements.largest_chunk_name} (${measurements.largest_chunk_kib} KiB)`) console.log() } const shouldFail = hasError || (failOn === 'warning' && hasWarning) if (shouldFail) { if (!outputJson) { console.error(`āŒ Bundle budget check FAILED (fail-on=${failOn})`) } process.exit(1) } else { if (!outputJson) { console.log(`āœ… Bundle budget check passed`) } process.exit(0) } } main().catch((err) => { console.error('Bundle budget check crashed:', err) process.exit(1) })