#!/usr/bin/env node // Bundle size manifest generator + comparator, used by the CI bundle-size report. // // Usage: // node tools/bundle-size.mjs generate // node tools/bundle-size.mjs compare // // No dependencies — only node:fs, node:path and node:zlib. Sizes are gzip'd since // server/nginx/default.conf.template serves assets gzipped; that's the number that // matters for what a browser actually downloads. import { readdirSync, readFileSync, writeFileSync } from 'node:fs' import { gzipSync } from 'node:zlib' import { join, relative, sep } from 'node:path' // Vite/rolldown content hashes are always 8 chars of [A-Za-z0-9_-], immediately // before the extension (e.g. `setupMonaco-BGNH_NaM.js` -> `setupMonaco-*.js`). // Stripping exactly 8 (not "8 or more") avoids false-positives on names that // merely end in enough letters, e.g. `editor.worker-D9zwrD0f.js`'s `.worker` part. const HASH_RE = /-[\w-]{8}(\.[a-z0-9]+)$/ function stripHash (filename) { return filename.replace(HASH_RE, '-*$1') } function walk (distDir) { const entries = {} for (const entry of readdirSync(distDir, { recursive: true, withFileTypes: true })) { if (entry.isDirectory() || !/\.(?:js|css)$/.test(entry.name)) continue const full = join(entry.parentPath, entry.name) const gzip = gzipSync(readFileSync(full), { level: 9 }).length const relDir = relative(distDir, entry.parentPath).split(sep).join('/') const key = (relDir ? `${relDir}/` : '') + stripHash(entry.name) // A hash collision after stripping (unlikely, but not impossible for tiny // chunks) would silently overwrite — sum instead so nothing goes missing. entries[key] = (entries[key] ?? 0) + gzip } return entries } function generate (distDir, outFile) { const entries = walk(distDir) const manifest = { generatedAt: new Date().toISOString(), entries } writeFileSync(outFile, JSON.stringify(manifest, null, 2)) const count = Object.keys(entries).length console.error(`Wrote ${count} entries to ${outFile}`) } function formatBytes (bytes) { if (bytes === 0) return '0 B' const units = ['B', 'kB', 'MB'] let value = Math.abs(bytes) let unit = 0 while (value >= 1024 && unit < units.length - 1) { value /= 1024 unit++ } const formatted = unit === 0 ? String(Math.round(value)) : value.toFixed(1) return `${bytes < 0 ? '-' : ''}${formatted} ${units[unit]}` } function formatDelta (bytes) { if (bytes === 0) return '±0 B' return `${bytes > 0 ? '+' : ''}${formatBytes(bytes)}` } // A malformed (or older-format) manifest would flow through as NaN and publish a // table of `NaN B` cells on a green check. Fail instead. function readManifest (file) { const { entries } = JSON.parse(readFileSync(file, 'utf8')) if (entries === null || typeof entries !== 'object' || Array.isArray(entries)) { throw new Error(`${file}: "entries" is not an object`) } for (const [key, value] of Object.entries(entries)) { if (!Number.isFinite(value)) { throw new Error(`${file}: entry "${key}" is not a byte count (got ${JSON.stringify(value)}) — manifest is malformed, or was written by an incompatible version of this tool`) } } return entries } function compare (baseFile, headFile) { const base = readManifest(baseFile) const head = readManifest(headFile) const keys = new Set([...Object.keys(base), ...Object.keys(head)]) const rows = [...keys].map(key => { const baseGzip = base[key] ?? null const headGzip = head[key] ?? null const delta = (headGzip ?? 0) - (baseGzip ?? 0) const status = baseGzip == null ? 'added' : headGzip == null ? 'removed' : 'changed' return { key, baseGzip, headGzip, delta, status } }) const changed = rows .filter(row => row.delta !== 0) .sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta) || a.key.localeCompare(b.key)) const totalBase = rows.reduce((sum, row) => sum + (row.baseGzip ?? 0), 0) const totalHead = rows.reduce((sum, row) => sum + (row.headGzip ?? 0), 0) const totalDelta = totalHead - totalBase const lines = [] lines.push('## Bundle size report (gzip)') lines.push('') if (changed.length === 0) { lines.push('No chunk size changes detected.') } else { lines.push('| Chunk | Base | Head | Δ |') lines.push('|---|---|---|---|') for (const row of changed) { const chunk = row.status === 'added' ? `${row.key} 🆕` : row.status === 'removed' ? `${row.key} 🗑️` : row.key lines.push(`| \`${chunk}\` | ${row.baseGzip == null ? '—' : formatBytes(row.baseGzip)} | ${row.headGzip == null ? '—' : formatBytes(row.headGzip)} | ${formatDelta(row.delta)} |`) } lines.push(`| **Total** | **${formatBytes(totalBase)}** | **${formatBytes(totalHead)}** | **${formatDelta(totalDelta)}** |`) } lines.push('') lines.push(`${rows.length} chunks compared, ${changed.length} changed. Sizes are gzip, matching what nginx serves.`) console.log(lines.join('\n')) } const [, , command, ...args] = process.argv switch (command) { case 'generate': { const [distDir, outFile] = args if (!distDir || !outFile) { console.error('Usage: bundle-size.mjs generate ') process.exit(1) } generate(distDir, outFile) break } case 'compare': { const [baseFile, headFile] = args if (!baseFile || !headFile) { console.error('Usage: bundle-size.mjs compare ') process.exit(1) } compare(baseFile, headFile) break } default: console.error('Usage: bundle-size.mjs ...') process.exit(1) }