diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38f68f4..a18087b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/eslint.config.js b/eslint.config.js index b0f135f..56bb89e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -34,6 +34,11 @@ export default tseslint.config( }, }, + { + files: ['scripts/**/*.mjs'], + languageOptions: { globals: globals.node }, + }, + { files: ['**/*.{jsx,tsx}'], ...react.configs.flat.recommended, diff --git a/package.json b/package.json index 80e8dcd..48cf329 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-bundle.mjs b/scripts/check-bundle.mjs new file mode 100644 index 0000000..d72ad4a --- /dev/null +++ b/scripts/check-bundle.mjs @@ -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)); + } 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], + }); + } +}; +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.'); diff --git a/size-budget.json b/size-budget.json new file mode 100644 index 0000000..311c613 --- /dev/null +++ b/size-budget.json @@ -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 + } + } +} diff --git a/tests/scripts/check-bundle.test.ts b/tests/scripts/check-bundle.test.ts new file mode 100644 index 0000000..166c477 --- /dev/null +++ b/tests/scripts/check-bundle.test.ts @@ -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(); + }); +});