From 32afd8debb7c1d4bac6118456bc99ecabf6252b6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 31 Jul 2026 14:34:18 -0700 Subject: [PATCH] feat(scripts): add a client-bundle measurement so regressions are visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing measured the JavaScript we ship. Turbopack's route table prints only Revalidate/Expire — no `First Load JS` — and it emits flat, content-hashed chunk names with no app-build-manifest.json, so there is no route->chunk mapping to attribute bytes with. @next/bundle-analyzer is a webpack plugin and is not installed. A bundle regression was therefore invisible until a user noticed a slow page. `bun run measure:bundle` reports total client JS and the largest chunks; `--json` emits a baseline and `--baseline ` fails on a >2% regression, so this can become a CI gate once a baseline is agreed. Deliberately not per-route. That needs either a manifest Turbopack does not emit or a browser loading each route; inventing an attribution from flat chunk names would produce a confident number that is wrong. First use already paid for it. Building twice — once normally, once with the existing SIM_DEV_MINIMAL_REGISTRY alias forced on — measures what the tool and block registries cost the client: baseline 110.08 MB across 553 chunks registries aliased away 52.96 MB across 553 chunks (-51.9%) The four 15.52 MB chunks in the baseline disappear entirely; they are the registry graph duplicated across four entry points. That is an upper bound for both registries together, not a promise — a metadata split would still ship params/outputs for referenced tools — but it establishes that the registry is roughly half of all client JS, measured rather than inferred from an import-graph tracer. --- package.json | 1 + scripts/measure-client-bundle.ts | 123 +++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 scripts/measure-client-bundle.ts diff --git a/package.json b/package.json index fec0621c543..37eb68bc8ee 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "check:bare-icons": "bun run scripts/check-bare-icons.ts", "check:icon-paths": "bun run scripts/check-icon-paths.ts", "check:migrations": "bun run scripts/check-migrations-safety.ts", + "measure:bundle": "bun run scripts/measure-client-bundle.ts", "check:desktop-bridge": "bun run scripts/check-desktop-bridge-contract.ts --check", "check:desktop-ipc": "bun run scripts/check-desktop-ipc-contract.ts", "desktop-bridge-contract:update": "bun run scripts/check-desktop-bridge-contract.ts --update", diff --git a/scripts/measure-client-bundle.ts b/scripts/measure-client-bundle.ts new file mode 100644 index 00000000000..95131b7a590 --- /dev/null +++ b/scripts/measure-client-bundle.ts @@ -0,0 +1,123 @@ +/** + * Reports the size of the client JavaScript a production build ships, so a + * bundle regression is something CI can see rather than something a user + * reports as a slow page. + * + * Nothing else measures this today. Turbopack's route table prints only + * `Revalidate`/`Expire` — no `First Load JS` — and it emits flat, + * content-hashed chunk names with no `app-build-manifest.json`, so there is no + * route -> chunk mapping to attribute bytes with. `@next/bundle-analyzer` is a + * webpack plugin and is not installed. This measures what is reliably + * available: the total client payload, plus the largest chunks so a jump can be + * traced to a culprit. + * + * Deliberately not per-route. A per-route number would need either a + * route->chunk manifest Turbopack does not emit, or a browser loading each + * route; inventing an attribution from flat chunk names would produce a + * confident number that is wrong. + * + * Usage: + * bun run scripts/measure-client-bundle.ts # human-readable + * bun run scripts/measure-client-bundle.ts --json # machine-readable + * bun run scripts/measure-client-bundle.ts --baseline b.json # compare, fail on regression + * bun run scripts/measure-client-bundle.ts --json > baseline.json + */ + +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const rootDir = path.resolve(path.dirname(__filename), '..') +const chunkDir = path.join(rootDir, 'apps/sim/.next/static/chunks') + +/** Fraction a total may grow past the baseline before `--baseline` fails. */ +const REGRESSION_TOLERANCE = 0.02 + +interface Chunk { + file: string + bytes: number +} + +interface Report { + totalBytes: number + chunkCount: number + largest: Chunk[] +} + +function walk(dir: string, acc: Chunk[] = []): Chunk[] { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + walk(full, acc) + continue + } + // `.map` files are excluded: they are never requested during page load, so + // counting them would let a source-map change masquerade as a bundle + // regression. + if (entry.isFile() && entry.name.endsWith('.js')) { + acc.push({ file: path.relative(chunkDir, full), bytes: fs.statSync(full).size }) + } + } + return acc +} + +function measure(): Report { + if (!fs.existsSync(chunkDir)) { + throw new Error( + `No build found at ${path.relative(rootDir, chunkDir)}. Run \`bun run build\` in apps/sim first.` + ) + } + const chunks = walk(chunkDir) + if (chunks.length === 0) { + throw new Error(`No .js chunks under ${path.relative(rootDir, chunkDir)} — build looks empty.`) + } + return { + totalBytes: chunks.reduce((sum, c) => sum + c.bytes, 0), + chunkCount: chunks.length, + largest: [...chunks].sort((a, b) => b.bytes - a.bytes).slice(0, 15), + } +} + +const mb = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(2)} MB` + +function main(): void { + const args = process.argv.slice(2) + const report = measure() + + if (args.includes('--json')) { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`) + return + } + + console.log(`Client JS: ${mb(report.totalBytes)} across ${report.chunkCount} chunks\n`) + console.log('Largest chunks:') + for (const c of report.largest) { + console.log(` ${mb(c.bytes).padStart(9)} ${c.file}`) + } + + const baselineIdx = args.indexOf('--baseline') + if (baselineIdx === -1) return + + const baselinePath = args[baselineIdx + 1] + if (!baselinePath) { + console.error('\n--baseline requires a path to a JSON report') + process.exit(1) + } + const baseline: Report = JSON.parse(fs.readFileSync(baselinePath, 'utf8')) + const delta = report.totalBytes - baseline.totalBytes + const pct = (delta / baseline.totalBytes) * 100 + const sign = delta >= 0 ? '+' : '' + console.log( + `\nvs baseline: ${mb(baseline.totalBytes)} -> ${mb(report.totalBytes)} (${sign}${pct.toFixed(1)}%)` + ) + + if (delta > baseline.totalBytes * REGRESSION_TOLERANCE) { + console.error( + `\nClient JS grew more than ${(REGRESSION_TOLERANCE * 100).toFixed(0)}% over the baseline.` + ) + process.exit(1) + } +} + +main()