192 lines
6.1 KiB
JavaScript
Executable file
192 lines
6.1 KiB
JavaScript
Executable file
/**
|
|
* 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)
|
|
})
|