Skip to content

Commit 32afd8d

Browse files
committed
feat(scripts): add a client-bundle measurement so regressions are visible
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 <file>` 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.
1 parent c0e7ea9 commit 32afd8d

2 files changed

Lines changed: 124 additions & 0 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"check:bare-icons": "bun run scripts/check-bare-icons.ts",
3838
"check:icon-paths": "bun run scripts/check-icon-paths.ts",
3939
"check:migrations": "bun run scripts/check-migrations-safety.ts",
40+
"measure:bundle": "bun run scripts/measure-client-bundle.ts",
4041
"check:desktop-bridge": "bun run scripts/check-desktop-bridge-contract.ts --check",
4142
"check:desktop-ipc": "bun run scripts/check-desktop-ipc-contract.ts",
4243
"desktop-bridge-contract:update": "bun run scripts/check-desktop-bridge-contract.ts --update",

scripts/measure-client-bundle.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* Reports the size of the client JavaScript a production build ships, so a
3+
* bundle regression is something CI can see rather than something a user
4+
* reports as a slow page.
5+
*
6+
* Nothing else measures this today. Turbopack's route table prints only
7+
* `Revalidate`/`Expire` — no `First Load JS` — and it emits flat,
8+
* content-hashed chunk names with no `app-build-manifest.json`, so there is no
9+
* route -> chunk mapping to attribute bytes with. `@next/bundle-analyzer` is a
10+
* webpack plugin and is not installed. This measures what is reliably
11+
* available: the total client payload, plus the largest chunks so a jump can be
12+
* traced to a culprit.
13+
*
14+
* Deliberately not per-route. A per-route number would need either a
15+
* route->chunk manifest Turbopack does not emit, or a browser loading each
16+
* route; inventing an attribution from flat chunk names would produce a
17+
* confident number that is wrong.
18+
*
19+
* Usage:
20+
* bun run scripts/measure-client-bundle.ts # human-readable
21+
* bun run scripts/measure-client-bundle.ts --json # machine-readable
22+
* bun run scripts/measure-client-bundle.ts --baseline b.json # compare, fail on regression
23+
* bun run scripts/measure-client-bundle.ts --json > baseline.json
24+
*/
25+
26+
import fs from 'fs'
27+
import path from 'path'
28+
import { fileURLToPath } from 'url'
29+
30+
const __filename = fileURLToPath(import.meta.url)
31+
const rootDir = path.resolve(path.dirname(__filename), '..')
32+
const chunkDir = path.join(rootDir, 'apps/sim/.next/static/chunks')
33+
34+
/** Fraction a total may grow past the baseline before `--baseline` fails. */
35+
const REGRESSION_TOLERANCE = 0.02
36+
37+
interface Chunk {
38+
file: string
39+
bytes: number
40+
}
41+
42+
interface Report {
43+
totalBytes: number
44+
chunkCount: number
45+
largest: Chunk[]
46+
}
47+
48+
function walk(dir: string, acc: Chunk[] = []): Chunk[] {
49+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
50+
const full = path.join(dir, entry.name)
51+
if (entry.isDirectory()) {
52+
walk(full, acc)
53+
continue
54+
}
55+
// `.map` files are excluded: they are never requested during page load, so
56+
// counting them would let a source-map change masquerade as a bundle
57+
// regression.
58+
if (entry.isFile() && entry.name.endsWith('.js')) {
59+
acc.push({ file: path.relative(chunkDir, full), bytes: fs.statSync(full).size })
60+
}
61+
}
62+
return acc
63+
}
64+
65+
function measure(): Report {
66+
if (!fs.existsSync(chunkDir)) {
67+
throw new Error(
68+
`No build found at ${path.relative(rootDir, chunkDir)}. Run \`bun run build\` in apps/sim first.`
69+
)
70+
}
71+
const chunks = walk(chunkDir)
72+
if (chunks.length === 0) {
73+
throw new Error(`No .js chunks under ${path.relative(rootDir, chunkDir)} — build looks empty.`)
74+
}
75+
return {
76+
totalBytes: chunks.reduce((sum, c) => sum + c.bytes, 0),
77+
chunkCount: chunks.length,
78+
largest: [...chunks].sort((a, b) => b.bytes - a.bytes).slice(0, 15),
79+
}
80+
}
81+
82+
const mb = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(2)} MB`
83+
84+
function main(): void {
85+
const args = process.argv.slice(2)
86+
const report = measure()
87+
88+
if (args.includes('--json')) {
89+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
90+
return
91+
}
92+
93+
console.log(`Client JS: ${mb(report.totalBytes)} across ${report.chunkCount} chunks\n`)
94+
console.log('Largest chunks:')
95+
for (const c of report.largest) {
96+
console.log(` ${mb(c.bytes).padStart(9)} ${c.file}`)
97+
}
98+
99+
const baselineIdx = args.indexOf('--baseline')
100+
if (baselineIdx === -1) return
101+
102+
const baselinePath = args[baselineIdx + 1]
103+
if (!baselinePath) {
104+
console.error('\n--baseline requires a path to a JSON report')
105+
process.exit(1)
106+
}
107+
const baseline: Report = JSON.parse(fs.readFileSync(baselinePath, 'utf8'))
108+
const delta = report.totalBytes - baseline.totalBytes
109+
const pct = (delta / baseline.totalBytes) * 100
110+
const sign = delta >= 0 ? '+' : ''
111+
console.log(
112+
`\nvs baseline: ${mb(baseline.totalBytes)} -> ${mb(report.totalBytes)} (${sign}${pct.toFixed(1)}%)`
113+
)
114+
115+
if (delta > baseline.totalBytes * REGRESSION_TOLERANCE) {
116+
console.error(
117+
`\nClient JS grew more than ${(REGRESSION_TOLERANCE * 100).toFixed(0)}% over the baseline.`
118+
)
119+
process.exit(1)
120+
}
121+
}
122+
123+
main()

0 commit comments

Comments
 (0)