openpetswithchatandmcp/website/scripts/check-bundle-budget.mjs
OpenPets Dev 65ac83849e Add 'website/' from commit '966aea480ffe1c340553aa878def30131d2af827'
git-subtree-dir: website
git-subtree-mainline: 8d3849caea
git-subtree-split: 966aea480f
2026-06-17 20:48:39 +00:00

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)
})