Skip to content

Commit 0909733

Browse files
committed
ci(cashscript): add bundle size budget check + dependency treemap (#389)
1 parent 790d4a2 commit 0909733

7 files changed

Lines changed: 336 additions & 2 deletions

File tree

.github/workflows/github-actions.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,17 @@ jobs:
4040
- name: Run spellchecker
4141
run: yarn spellcheck
4242

43+
- name: Check bundle size
44+
run: yarn workspace cashscript size
45+
46+
- name: Upload bundle size treemap
47+
uses: actions/upload-artifact@v4
48+
if: always()
49+
with:
50+
name: bundle-size-treemap
51+
path: packages/cashscript/bundle-size/stats.html
52+
if-no-files-found: ignore
53+
4354
- name: Upload coverage reports to Codecov
4455
uses: codecov/codecov-action@v5
4556
with:

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,4 +107,7 @@ typings/
107107

108108
manual-test.ts
109109

110+
# Bundle size check output
111+
packages/cashscript/bundle-size/
112+
110113
.claude/

DEVELOPMENT.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ To run specific tests, you can use the `-t` flag to match the name mentioned in
7474
yarn test -t 'Transaction Builder'
7575
```
7676

77+
### Checking bundle size
78+
79+
The `cashscript` package ships to browsers, so we guard its bundle size against regressions. From the `packages/cashscript` directory, `yarn size` bundles the package through Vite/Rollup and fails if the gzipped size exceeds the budget in `bundle-size.budget.json`. It also writes a dependency treemap to `bundle-size/stats.html`. This check runs in CI too. If an increase is intentional, run `yarn size --update` to regenerate the budget and commit it.
80+
7781
## Code Coverage
7882

7983
New contributions have a code coverage target of 90%+. You can run `yarn test --coverage` to see the coverage report before submitting a PR.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"_comment": "Gzipped-byte budgets for the cashscript consumer bundle. Regenerate with `yarn size --update`. See issue #389.",
3+
"tolerance": 0.05,
4+
"scenarios": {
5+
"full": {
6+
"maxGzip": 247946
7+
},
8+
"typical": {
9+
"maxGzip": 242598
10+
}
11+
}
12+
}

packages/cashscript/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"lint": "eslint . --ext .ts --ignore-path ../../.eslintignore",
3939
"prepare": "yarn build",
4040
"prepublishOnly": "yarn test && yarn lint",
41+
"size": "tsx scripts/check-bundle-size.ts",
4142
"test": "vitest run"
4243
},
4344
"dependencies": {
@@ -53,7 +54,9 @@
5354
"eslint": "^8.54.0",
5455
"p-queue": "^9.1.2",
5556
"p-retry": "^8.0.0",
57+
"rollup-plugin-visualizer": "^7.0.1",
5658
"typescript": "^5.9.2",
59+
"vite": "7.2.7",
5760
"vitest": "^4.0.15"
5861
},
5962
"gitHead": "bf02a4b641d5d03c035d052247a545109c17b708"
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
#!/usr/bin/env tsx
2+
// Bundles the built package plus its runtime dependencies through Vite/Rollup and
3+
// checks the gzipped size against bundle-size.budget.json to catch regressions (#389).
4+
// Also emits a dependency treemap at bundle-size/stats.html.
5+
// Run `yarn size` to check, or `yarn size --update` to rewrite the budget.
6+
7+
import { build } from 'vite';
8+
import { visualizer } from 'rollup-plugin-visualizer';
9+
import { gzipSync } from 'node:zlib';
10+
import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
11+
import { fileURLToPath } from 'node:url';
12+
import { dirname, resolve, join } from 'node:path';
13+
14+
interface SizeResult {
15+
minified: number;
16+
gzip: number;
17+
}
18+
19+
interface Budget {
20+
tolerance?: number;
21+
scenarios?: Record<string, { maxGzip: number }>;
22+
}
23+
24+
const scriptDir = dirname(fileURLToPath(import.meta.url));
25+
const pkgDir = resolve(scriptDir, '..');
26+
const workDir = join(pkgDir, 'bundle-size');
27+
const budgetFile = join(pkgDir, 'bundle-size.budget.json');
28+
29+
// `full` references the whole namespace so nothing tree-shakes (like bundlephobia).
30+
// `typical` is a realistic app that builds and sends a transaction.
31+
const scenarios: Record<string, string> = {
32+
full: `import * as cashscript from '../dist/index.js';
33+
globalThis.__keepAlive = cashscript;`,
34+
typical: `import { Contract, TransactionBuilder, ElectrumNetworkProvider, SignatureTemplate } from '../dist/index.js';
35+
console.log(Contract, TransactionBuilder, ElectrumNetworkProvider, SignatureTemplate);`,
36+
};
37+
38+
// Build in app mode (not lib mode) so Vite bundles every dependency instead of
39+
// externalizing them.
40+
async function measure(name: string, contents: string): Promise<SizeResult> {
41+
const entryFile = join(workDir, `entry-${name}.js`);
42+
const outDir = join(workDir, `out-${name}`);
43+
writeFileSync(entryFile, contents);
44+
45+
await build({
46+
configFile: false,
47+
logLevel: 'silent',
48+
root: workDir,
49+
build: {
50+
outDir,
51+
emptyOutDir: true,
52+
target: 'esnext', // libauth instantiates its wasm crypto via top-level await
53+
minify: 'esbuild',
54+
modulePreload: false,
55+
reportCompressedSize: false,
56+
rollupOptions: {
57+
input: entryFile,
58+
output: { entryFileNames: 'bundle.js', format: 'es' },
59+
plugins: name === 'full'
60+
? [visualizer({ filename: join(workDir, 'stats.html'), gzipSize: true, brotliSize: true })]
61+
: [],
62+
},
63+
},
64+
});
65+
66+
const code = readFileSync(join(outDir, 'bundle.js'));
67+
return { minified: code.length, gzip: gzipSync(code, { level: 9 }).length };
68+
}
69+
70+
const kb = (bytes: number): string => `${(bytes / 1024).toFixed(1)} kB`;
71+
72+
mkdirSync(workDir, { recursive: true });
73+
74+
const update = process.argv.includes('--update');
75+
const budget: Budget = update ? {} : JSON.parse(readFileSync(budgetFile, 'utf8'));
76+
77+
const measured: Record<string, SizeResult> = {};
78+
for (const [name, contents] of Object.entries(scenarios)) {
79+
measured[name] = await measure(name, contents);
80+
}
81+
82+
// Clean up the transient build outputs, keeping the treemap.
83+
for (const name of Object.keys(scenarios)) {
84+
rmSync(join(workDir, `out-${name}`), { recursive: true, force: true });
85+
rmSync(join(workDir, `entry-${name}.js`), { force: true });
86+
}
87+
88+
if (update) {
89+
const next = {
90+
_comment: 'Gzipped-byte budgets for the cashscript consumer bundle. Regenerate with `yarn size --update`. See issue #389.',
91+
tolerance: 0.05,
92+
scenarios: Object.fromEntries(
93+
Object.entries(measured).map(([name, m]) => [name, { maxGzip: m.gzip }]),
94+
),
95+
};
96+
writeFileSync(budgetFile, `${JSON.stringify(next, null, 2)}\n`);
97+
console.log(`Updated ${budgetFile}`);
98+
for (const [name, m] of Object.entries(measured)) {
99+
console.log(` ${name.padEnd(8)} ${kb(m.gzip)} gzip (${kb(m.minified)} min)`);
100+
}
101+
process.exit(0);
102+
}
103+
104+
const tolerance = budget.tolerance ?? 0.05;
105+
let failed = false;
106+
107+
console.log(`Bundle size check (budget tolerance +${(tolerance * 100).toFixed(0)}%)\n`);
108+
console.log('scenario gzip min budget status');
109+
for (const [name, m] of Object.entries(measured)) {
110+
const max = budget.scenarios?.[name]?.maxGzip;
111+
if (max == null) {
112+
console.log(`${name.padEnd(10)} ${kb(m.gzip).padEnd(11)} ${kb(m.minified).padEnd(11)} (no budget) MISSING`);
113+
failed = true;
114+
continue;
115+
}
116+
const limit = max * (1 + tolerance);
117+
const ok = m.gzip <= limit;
118+
failed = failed || !ok;
119+
console.log(`${name.padEnd(10)} ${kb(m.gzip).padEnd(11)} ${kb(m.minified).padEnd(11)} ${kb(max).padEnd(11)} ${ok ? 'ok' : 'OVER BUDGET'}`);
120+
}
121+
122+
if (failed) {
123+
console.error('\nBundle size exceeded budget. If this is intentional, run `yarn size --update` and commit the new budget.');
124+
process.exit(1);
125+
}
126+
console.log('\nAll scenarios within budget.');

0 commit comments

Comments
 (0)