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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ jobs:
echo "Largest JS and CSS files:"
echo
echo '```'
find dist -type f \( -name '*.js' -o -name '*.css' \) -printf '%s %p\n' | sort -rn | head -15 | awk '{printf "%8.1f KB %s\n", $1/1024, $2}'
find dist -type f \( -name '*.js' -o -name '*.css' \) -printf '%s %p\n' | sort -rn | awk 'NR<=15 {printf "%8.1f KB %s\n", $1/1024, $2}'
echo '```'
else
echo
Expand Down
5 changes: 5 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ export default tseslint.config(
},
},

{
files: ['scripts/**/*.mjs'],
languageOptions: { globals: globals.node },
},

{
files: ['**/*.{jsx,tsx}'],
...react.configs.flat.recommended,
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
"lint": "eslint .",
"format:check": "prettier --check .",
"knip": "knip",
"prepare": "node scripts/install-hooks.mjs"
"prepare": "node scripts/install-hooks.mjs",
"size": "pnpm build && node scripts/check-bundle.mjs",
"size:update": "pnpm build && node scripts/check-bundle.mjs --update"
},
"dependencies": {
"@salla.sa/twilight-components-react": "^3.0.2",
Expand Down
121 changes: 121 additions & 0 deletions scripts/check-bundle.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Compares the client build output (dist/client/assets) with size-budget.json.
// Usage: node scripts/check-bundle.mjs [--update] (--update rewrites the budget: current size + margin)
import { readFileSync, readdirSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { gzipSync, brotliCompressSync } from 'node:zlib';

const root = process.env.BUNDLE_ROOT ?? join(dirname(fileURLToPath(import.meta.url)), '..');
const assetsDir = join(root, 'dist/client/assets');
const budgetPath = join(root, 'size-budget.json');
const reportPath = join(root, 'dist/size-report.json');
const MARGIN = 0.05;
const TRACK_MIN_GZIP = 4096;

const kb = (n) => (n / 1024).toFixed(1) + ' kB';
const diff = (n) => (n > 0 ? '+' : n < 0 ? '-' : '') + kb(Math.abs(n));

function measure() {
let files;
try {
files = readdirSync(assetsDir).filter((f) => /\.(js|css)$/.test(f));
Comment thread
salahmyn marked this conversation as resolved.
Resolved
} catch {
throw new Error(`No client build at ${assetsDir}. Run "pnpm build" first.`);
}
if (!files.some((f) => f.endsWith('.js'))) {
throw new Error(`No JS assets in ${assetsDir}; the build layout may have changed.`);
}
const chunks = {};
const totals = { js: { gzip: 0, brotli: 0 }, css: { gzip: 0, brotli: 0 } };
for (const file of files) {
const buf = readFileSync(join(assetsDir, file));
const gzip = gzipSync(buf, { level: 9 }).length;
const brotli = brotliCompressSync(buf).length;
const kind = file.endsWith('.css') ? 'css' : 'js';
totals[kind].gzip += gzip;
totals[kind].brotli += brotli;
// Drop every embedded content hash so a chunk keeps one budget across builds.
const name = file.replace(/(-[A-Za-z0-9_-]{8})+(\.(?:js|css))$/, '$2');
const prev = chunks[name] ?? { gzip: 0, brotli: 0 };
chunks[name] = { gzip: prev.gzip + gzip, brotli: prev.brotli + brotli };
}
return { chunks, totals };
}

const { chunks, totals } = measure();
const withMargin = (s) => ({
gzip: Math.ceil(s.gzip * (1 + MARGIN)),
brotli: Math.ceil(s.brotli * (1 + MARGIN)),
});

if (process.argv.includes('--update')) {
const tracked = Object.fromEntries(
Object.entries(chunks)
.filter(([, s]) => s.gzip >= TRACK_MIN_GZIP)
.sort(([a], [b]) => a.localeCompare(b))
.map(([n, s]) => [n, withMargin(s)])
);
const budget = {
margin: MARGIN,
untrackedChunk: { gzip: TRACK_MIN_GZIP * 2, brotli: TRACK_MIN_GZIP * 2 },
totals: { js: withMargin(totals.js), css: withMargin(totals.css) },
chunks: tracked,
};
writeFileSync(budgetPath, JSON.stringify(budget, null, 2) + '\n');
console.log(`Wrote ${budgetPath} (current size + ${MARGIN * 100}% margin)`);
process.exit(0);
}

const budget = JSON.parse(readFileSync(budgetPath, 'utf8'));
const rows = [];
const add = (name, cur, bud) => {
for (const metric of ['gzip', 'brotli']) {
rows.push({
name,
metric,
current: cur[metric],
budget: bud[metric],
diff: cur[metric] - bud[metric],
ok: cur[metric] <= bud[metric],
});
}
Comment thread
salahmyn marked this conversation as resolved.
Resolved
};
add('TOTAL js', totals.js, budget.totals.js);
add('TOTAL css', totals.css, budget.totals.css);
for (const [name, cur] of Object.entries(chunks)) {
const bud = budget.chunks[name] ?? budget.untrackedChunk;
// Small chunks without their own budget only matter if they grow past the untracked limit.
add(budget.chunks[name] ? name : `${name} (new)`, cur, bud);
}
for (const name of Object.keys(budget.chunks)) {
if (!chunks[name])
console.warn(`Budgeted chunk not found in build (renamed or removed?): ${name}`);
}

const shown = rows.filter(
(r) => r.name.startsWith('TOTAL') || budget.chunks[r.name.replace(' (new)', '')] || !r.ok
);
const width = Math.max(...shown.map((r) => r.name.length));
console.log(
`${'chunk'.padEnd(width)} type ${'current'.padStart(10)} ${'budget'.padStart(10)} ${'diff'.padStart(10)} status`
);
for (const r of shown) {
console.log(
`${r.name.padEnd(width)} ${r.metric.padEnd(6)} ${kb(r.current).padStart(10)} ${kb(r.budget).padStart(10)} ${diff(r.diff).padStart(10)} ${r.ok ? 'ok' : 'OVER'}`
);
}

const failures = rows.filter((r) => !r.ok);
mkdirSync(dirname(reportPath), { recursive: true });
writeFileSync(
reportPath,
JSON.stringify({ passed: failures.length === 0, totals, chunks, rows, failures }, null, 2) + '\n'
);
console.log(`\nReport: ${reportPath}`);
if (failures.length) {
console.error(
`\nBundle budget exceeded in ${failures.length} measurement(s). If the growth is intended, run "pnpm size:update" and commit size-budget.json.`
);
process.exit(1);
}
console.log('Bundle within budget.');
67 changes: 67 additions & 0 deletions size-budget.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
{
"margin": 0.05,
"untrackedChunk": {
"gzip": 8192,
"brotli": 8192
},
"totals": {
"js": {
"gzip": 449837,
"brotli": 390723
},
"css": {
"gzip": 73485,
"brotli": 54761
}
},
"chunks": {
"app.css": {
"gzip": 73485,
"brotli": 54761
},
"chunk.js": {
"gzip": 19663,
"brotli": 17321
},
"fslightbox.js": {
"gzip": 10532,
"brotli": 9332
},
"hydrate.js": {
"gzip": 44368,
"brotli": 38659
},
"index.client.js": {
"gzip": 30733,
"brotli": 27528
},
"OrderSinglePage.js": {
"gzip": 6113,
"brotli": 5460
},
"product-listing.js": {
"gzip": 5231,
"brotli": 4661
},
"shared-home.js": {
"gzip": 151357,
"brotli": 129188
},
"shared-product.js": {
"gzip": 8778,
"brotli": 7808
},
"ThankYouPage.js": {
"gzip": 6215,
"brotli": 5178
},
"vendor-react.js": {
"gzip": 58600,
"brotli": 50727
},
"vendor-router.js": {
"gzip": 30816,
"brotli": 27888
}
}
}
98 changes: 98 additions & 0 deletions tests/scripts/check-bundle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { spawnSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';

const script = resolve(__dirname, '../../scripts/check-bundle.mjs');
let root: string;

const asset = (name: string, bytes: number) =>
writeFileSync(join(root, 'dist/client/assets', name), randomBytes(bytes));
const budget = (value: unknown) =>
writeFileSync(join(root, 'size-budget.json'), JSON.stringify(value));
const run = (...args: string[]) =>
spawnSync('node', [script, ...args], {
env: { ...process.env, BUNDLE_ROOT: root },
encoding: 'utf8',
});
const limits = (gzip: number) => ({ gzip, brotli: gzip });
const baseBudget = (chunk: number, total = 100000) => ({
untrackedChunk: limits(8192),
totals: { js: limits(total), css: limits(total) },
chunks: { 'vendor.js': limits(chunk) },
});

beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'bundle-'));
mkdirSync(join(root, 'dist/client/assets'), { recursive: true });
});
afterEach(() => rmSync(root, { recursive: true, force: true }));

describe('scripts.size.check-bundle', () => {
it('passes when every chunk and total is under budget, and writes the report', () => {
asset('vendor-AAAAAAAA.js', 5000);
budget(baseBudget(6000));
const res = run();
expect(res.status).toBe(0);
const report = JSON.parse(readFileSync(join(root, 'dist/size-report.json'), 'utf8'));
expect(report.passed).toBe(true);
expect(report.failures).toEqual([]);
});

it('fails when a chunk is above its budget', () => {
asset('vendor-AAAAAAAA.js', 5000);
budget(baseBudget(4000));
const res = run();
expect(res.status).toBe(1);
const report = JSON.parse(readFileSync(join(root, 'dist/size-report.json'), 'utf8'));
expect(report.failures.map((f: { name: string }) => f.name)).toContain('vendor.js');
});

it('fails when the JS total is above its budget', () => {
asset('vendor-AAAAAAAA.js', 5000);
budget(baseBudget(6000, 3000));
expect(run().status).toBe(1);
});

it('matches a chunk across rebuilds by dropping every embedded hash', () => {
asset('vendor-AAAAAAAA-BBBBBBBB.js', 5000);
budget(baseBudget(6000));
expect(run().status).toBe(0);
rmSync(join(root, 'dist/client/assets/vendor-AAAAAAAA-BBBBBBBB.js'));
asset('vendor-CCCCCCCC-D_E-F0Gh.js', 7000);
expect(run().status).toBe(1);
});

it('holds a new chunk without its own budget to the untracked limit', () => {
asset('vendor-AAAAAAAA.js', 5000);
asset('extra-AAAAAAAA.js', 9000);
budget(baseBudget(6000));
const res = run();
expect(res.status).toBe(1);
expect(res.stdout).toContain('extra.js (new)');
});

it('fails instead of passing when the build has no JS assets', () => {
budget(baseBudget(6000));
expect(run().status).toBe(1);
});

it('--update writes the current size plus the margin, keeping only chunks of 4 kB gzip or more', () => {
asset('vendor-AAAAAAAA.js', 5000);
asset('tiny-AAAAAAAA.js', 500);
expect(run('--update').status).toBe(0);
const written = JSON.parse(readFileSync(join(root, 'size-budget.json'), 'utf8'));
expect(Object.keys(written.chunks)).toEqual(['vendor.js']);
expect(written.chunks['vendor.js'].gzip).toBeGreaterThan(5000);
expect(written.chunks['vendor.js'].gzip).toBeLessThan(5000 * 1.06 + 100);
expect(run().status).toBe(0);
});

it('--update refuses to write a budget from an empty build', () => {
expect(run('--update').status).toBe(1);
expect(() => readFileSync(join(root, 'size-budget.json'))).toThrow();
});
});
Loading