Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ jobs:
- run: pnpm format:check
- run: pnpm build

# Fails when the initial load, the Stellar scanner worker, or any
# lazy-loaded route/chain chunk grows past its budget. See
# scripts/bundle-budget-gate.mjs for what each budget covers and why.
- run: pnpm build:budget-check

# Fails when a new critical (or more than the accepted number of high)
# production dependency finding appears. See docs/SECURITY_AUDIT.md.
- run: pnpm audit:gate
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"format:check": "prettier --check .",
"audit:prod": "pnpm audit --prod",
"audit:gate": "node scripts/audit-gate.mjs",
"build:budget-check": "node scripts/bundle-budget-gate.mjs",
"prepare": "husky",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
Expand Down
172 changes: 172 additions & 0 deletions scripts/bundle-budget-gate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
#!/usr/bin/env node
// CI gate for production bundle size budgets (Wave 10, #219).
//
// Reads `dist/` after `pnpm build` and checks three things:
// 1. Initial load — every chunk referenced synchronously from
// dist/index.html (the `<script type="module">` entry plus any
// `<link rel="modulepreload">` chunks). This is everything the browser
// must fetch before the app shell can render; anything reachable only
// through a dynamic import() is excluded by construction.
// 2. Scanner worker — src/workers/stellar-scanner.worker.ts, bundled as
// its own standalone file (workers can't share chunks with the main
// thread). It pulls in @stellar/stellar-sdk for XDR parsing, which is
// already close to the practical floor for that dependency (see the
// PR description for what was tried and why it didn't help further).
// 3. Per-chunk — every other chunk in dist/assets: lazy-loaded routes
// (pages/*.tsx), lazy-loaded per-chain send/receive components, and
// wagmi/RainbowKit's own internal per-wallet-connector chunks (e.g.
// MetaMask SDK), which are dynamically imported by those libraries
// and outside this app's chunking choices. Keeps any one chunk from
// silently growing back into "the old monolith, just renamed."
//
// Usage:
// pnpm build && pnpm build:budget-check
import { readFile, readdir, stat } from 'node:fs/promises';
import path from 'node:path';
import { pathToFileURL } from 'node:url';

// --- Budgets (bytes) ---------------------------------------------------
// Headroom above the measured baseline at the time this gate was added
// (see the PR this shipped in for the exact numbers), so this catches
// regressions without being so tight it flakes on routine minification
// differences between dependency patch versions.
export const INITIAL_LOAD_BUDGET_BYTES = 3_300_000; // ~3.3 MB
export const WORKER_BUDGET_BYTES = 1_100_000; // ~1.1 MB
// 750 KB covers the largest legitimate lazy chunk today (a wagmi/RainbowKit
// wallet-connector chunk at ~689 KB), which this app's own code doesn't
// control the size of; any lazy chunk this app does control (a page or a
// per-chain send/receive component) should stay well under this in practice.
export const PER_CHUNK_BUDGET_BYTES = 750_000;
// -------------------------------------------------------------------------

export class BundleBudgetError extends Error {
constructor(message) {
super(message);
this.name = 'BundleBudgetError';
}
}

/** Extract every same-origin script/modulepreload src from built index.html. */
export function extractInitialLoadPaths(html) {
const paths = new Set();
const tagPattern =
/<(?:script[^>]*\stype="module"[^>]*\ssrc="([^"]+)"|link[^>]*\srel="modulepreload"[^>]*\shref="([^"]+)")/g;
let match;
while ((match = tagPattern.exec(html)) !== null) {
const src = match[1] ?? match[2];
if (src && !/^https?:\/\//.test(src)) paths.add(src);
}
return [...paths];
}

async function sizeOf(distDir, relativePath) {
const cleaned = relativePath.replace(/^\/+/, '');
const absolute = path.join(distDir, cleaned);
try {
const info = await stat(absolute);
return info.size;
} catch {
throw new BundleBudgetError(`Referenced in index.html but not found on disk: ${cleaned}`);
}
}

async function findWorkerFile(assetsDir) {
const entries = await readdir(assetsDir);
const worker = entries.find((name) => name.startsWith('stellar-scanner.worker-'));
if (!worker) {
throw new BundleBudgetError('Could not find the stellar-scanner.worker chunk in dist/assets.');
}
return worker;
}

function formatKb(bytes) {
return `${(bytes / 1000).toFixed(1)} kB`;
}

export async function checkBudgets({
distDir = path.resolve(process.cwd(), 'dist'),
log = console.log,
} = {}) {
const findings = [];
const assetsDir = path.join(distDir, 'assets');

// 1. Initial load
const html = await readFile(path.join(distDir, 'index.html'), 'utf8');
const initialPaths = extractInitialLoadPaths(html);
if (initialPaths.length === 0) {
throw new BundleBudgetError('No <script type="module"> entry found in dist/index.html.');
}
let initialLoadBytes = 0;
for (const relativePath of initialPaths) {
initialLoadBytes += await sizeOf(distDir, relativePath);
}
log(
`Initial load: ${formatKb(initialLoadBytes)} across ${initialPaths.length} file(s) ` +
`(budget ${formatKb(INITIAL_LOAD_BUDGET_BYTES)})`,
);
if (initialLoadBytes > INITIAL_LOAD_BUDGET_BYTES) {
findings.push(
`Initial load is ${formatKb(initialLoadBytes)}, over the ${formatKb(INITIAL_LOAD_BUDGET_BYTES)} budget.`,
);
}

// 2. Scanner worker
const workerFile = await findWorkerFile(assetsDir);
const workerBytes = await sizeOf(distDir, `assets/${workerFile}`);
log(
`Scanner worker (${workerFile}): ${formatKb(workerBytes)} (budget ${formatKb(WORKER_BUDGET_BYTES)})`,
);
if (workerBytes > WORKER_BUDGET_BYTES) {
findings.push(
`${workerFile} is ${formatKb(workerBytes)}, over the ${formatKb(WORKER_BUDGET_BYTES)} worker budget.`,
);
}

// 3. Every other chunk (lazy routes + lazy per-chain components)
const initialBasenames = new Set(initialPaths.map((p) => path.basename(p)));
const assetFiles = (await readdir(assetsDir)).filter(
(name) => name.endsWith('.js') && name !== workerFile && !initialBasenames.has(name),
);
for (const file of assetFiles) {
const bytes = await sizeOf(distDir, `assets/${file}`);
if (bytes > PER_CHUNK_BUDGET_BYTES) {
findings.push(
`assets/${file} is ${formatKb(bytes)}, over the ${formatKb(PER_CHUNK_BUDGET_BYTES)} per-chunk budget.`,
);
}
}
log(`Checked ${assetFiles.length} additional chunk(s) against the per-chunk budget.`);

return findings;
}

export async function main({ log = console.log, error = console.error } = {}) {
let findings;
try {
findings = await checkBudgets({ log });
} catch (err) {
if (!(err instanceof BundleBudgetError)) throw err;
error(`\nBundle budget gate failed: ${err.message}`);
error('\nRun `pnpm build` first, then `pnpm build:budget-check`.');
return 1;
}

if (findings.length > 0) {
error('\nBundle budget gate failed:');
for (const finding of findings) error(` - ${finding}`);
error(
'\nEither lazy-load the offending code further, or — if the increase is ' +
'deliberate and justified (a real new dependency, not a regression) — ' +
'raise the relevant budget constant in scripts/bundle-budget-gate.mjs ' +
'with a comment explaining why.',
);
return 1;
}

log('\nBundle budget gate passed.');
return 0;
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(await main());
}
122 changes: 122 additions & 0 deletions scripts/bundle-budget-gate.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
BundleBudgetError,
checkBudgets,
extractInitialLoadPaths,
INITIAL_LOAD_BUDGET_BYTES,
PER_CHUNK_BUDGET_BYTES,
WORKER_BUDGET_BYTES,
} from './bundle-budget-gate.mjs';

describe('extractInitialLoadPaths', () => {
it('finds the module entry script', () => {
const html = '<script type="module" crossorigin src="/assets/index-ABC123.js"></script>';
expect(extractInitialLoadPaths(html)).toEqual(['/assets/index-ABC123.js']);
});

it('finds modulepreload links alongside the entry script', () => {
const html = [
'<link rel="modulepreload" href="/assets/vendor-XYZ.js">',
'<script type="module" src="/assets/index-ABC.js"></script>',
].join('\n');
expect(extractInitialLoadPaths(html)).toEqual([
'/assets/vendor-XYZ.js',
'/assets/index-ABC.js',
]);
});

it('ignores cross-origin scripts (e.g. analytics)', () => {
const html = [
'<script defer src="https://plausible.io/js/script.js"></script>',
'<script type="module" src="/assets/index-ABC.js"></script>',
].join('\n');
expect(extractInitialLoadPaths(html)).toEqual(['/assets/index-ABC.js']);
});

it('returns an empty array when there is no module entry', () => {
expect(extractInitialLoadPaths('<html></html>')).toEqual([]);
});
});

describe('checkBudgets', () => {
let distDir;

async function writeFixture({ entryBytes = 100, workerBytes = 100, extraChunks = {} } = {}) {
distDir = await mkdtemp(path.join(tmpdir(), 'bundle-budget-'));
await mkdir(path.join(distDir, 'assets'), { recursive: true });
await writeFile(
path.join(distDir, 'index.html'),
'<script type="module" src="/assets/index-ABC.js"></script>',
);
await writeFile(path.join(distDir, 'assets', 'index-ABC.js'), 'x'.repeat(entryBytes));
await writeFile(
path.join(distDir, 'assets', 'stellar-scanner.worker-XYZ.js'),
'x'.repeat(workerBytes),
);
for (const [name, bytes] of Object.entries(extraChunks)) {
await writeFile(path.join(distDir, 'assets', name), 'x'.repeat(bytes));
}
}

afterEach(async () => {
if (distDir) await rm(distDir, { recursive: true, force: true });
});

it('passes with no findings when everything is under budget', async () => {
await writeFixture({ extraChunks: { 'Vault-abc123.js': 1000 } });
const findings = await checkBudgets({ distDir, log: () => {} });
expect(findings).toEqual([]);
});

it('flags an over-budget initial load', async () => {
await writeFixture({ entryBytes: INITIAL_LOAD_BUDGET_BYTES + 1 });
const findings = await checkBudgets({ distDir, log: () => {} });
expect(findings).toEqual([expect.stringContaining('Initial load')]);
});

it('flags an over-budget scanner worker', async () => {
await writeFixture({ workerBytes: WORKER_BUDGET_BYTES + 1 });
const findings = await checkBudgets({ distDir, log: () => {} });
expect(findings).toEqual([expect.stringContaining('stellar-scanner.worker')]);
});

it('flags an over-budget lazy chunk', async () => {
await writeFixture({
extraChunks: { 'Portfolio-huge123.js': PER_CHUNK_BUDGET_BYTES + 1 },
});
const findings = await checkBudgets({ distDir, log: () => {} });
expect(findings).toEqual([expect.stringContaining('Portfolio-huge123.js')]);
});

it('does not double-count the entry chunk as a lazy chunk', async () => {
await writeFixture({ entryBytes: PER_CHUNK_BUDGET_BYTES + 1 });
const findings = await checkBudgets({ distDir, log: () => {} });
// Over the (much larger) initial-load budget only if huge enough to
// also exceed INITIAL_LOAD_BUDGET_BYTES; here it shouldn't be flagged
// as a *per-chunk* violation under its own entry filename.
expect(findings.some((f) => f.includes('assets/index-ABC.js'))).toBe(false);
});

it('throws BundleBudgetError when the scanner worker chunk is missing', async () => {
distDir = await mkdtemp(path.join(tmpdir(), 'bundle-budget-'));
await mkdir(path.join(distDir, 'assets'), { recursive: true });
await writeFile(
path.join(distDir, 'index.html'),
'<script type="module" src="/assets/index-ABC.js"></script>',
);
await writeFile(path.join(distDir, 'assets', 'index-ABC.js'), 'x');

await expect(checkBudgets({ distDir, log: () => {} })).rejects.toThrow(BundleBudgetError);
});

it('throws BundleBudgetError when index.html has no module entry', async () => {
distDir = await mkdtemp(path.join(tmpdir(), 'bundle-budget-'));
await mkdir(path.join(distDir, 'assets'), { recursive: true });
await writeFile(path.join(distDir, 'index.html'), '<html></html>');

await expect(checkBudgets({ distDir, log: () => {} })).rejects.toThrow(BundleBudgetError);
});
});
Loading
Loading