From 4e7bdc263541f0a8487c6e9ac65303e4ac4cbbab Mon Sep 17 00:00:00 2001 From: Salah Al-Nawah Date: Mon, 26 Oct 2026 20:38:56 +0000 Subject: [PATCH 1/2] ci(devops): add a bundle size budget with pnpm size (SAL-3524) Sizes the client build (gzip and brotli per chunk, total JS and CSS) against size-budget.json and writes dist/size-report.json. Made-with: agent Co-authored-by: mc-agents[bot] <298817976+mc-agents[bot]@users.noreply.github.com> --- eslint.config.js | 5 ++ package.json | 4 +- scripts/check-bundle.mjs | 118 +++++++++++++++++++++++++++++++++++++++ size-budget.json | 63 +++++++++++++++++++++ 4 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 scripts/check-bundle.mjs create mode 100644 size-budget.json 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 8164f39..bf116ea 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage", "lint": "eslint .", - "format:check": "prettier --check ." + "format:check": "prettier --check .", + "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..e857a48 --- /dev/null +++ b/scripts/check-bundle.mjs @@ -0,0 +1,118 @@ +// 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 = 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.`); + } + 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 the content hash so a chunk keeps one budget across builds. + const name = file.replace(/-[A-Za-z0-9_-]{8}(\.(?:js|css))$/, '$1'); + 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..19dac6c --- /dev/null +++ b/size-budget.json @@ -0,0 +1,63 @@ +{ + "margin": 0.05, + "untrackedChunk": { + "gzip": 8192, + "brotli": 8192 + }, + "totals": { + "js": { + "gzip": 446581, + "brotli": 385005 + }, + "css": { + "gzip": 73485, + "brotli": 54761 + } + }, + "chunks": { + "app.css": { + "gzip": 73485, + "brotli": 54761 + }, + "chunk-RSKVPOMJ.js": { + "gzip": 11520, + "brotli": 10305 + }, + "fslightbox.js": { + "gzip": 10532, + "brotli": 9332 + }, + "hydrate.js": { + "gzip": 41887, + "brotli": 36005 + }, + "index.client.js": { + "gzip": 30733, + "brotli": 27520 + }, + "OrderSinglePage-HGEECI7Q.js": { + "gzip": 6103, + "brotli": 5441 + }, + "product-listing.js": { + "gzip": 5232, + "brotli": 4662 + }, + "shared-home.js": { + "gzip": 165106, + "brotli": 139116 + }, + "ThankYouPage-DAZOZH6Y.js": { + "gzip": 6219, + "brotli": 5176 + }, + "vendor-react.js": { + "gzip": 58600, + "brotli": 50755 + }, + "vendor-router.js": { + "gzip": 30817, + "brotli": 27904 + } + } +} From 55cf51e5b436374389ca230306f09f8da60e6058 Mon Sep 17 00:00:00 2001 From: Salah Al-Nawah Date: Tue, 27 Oct 2026 11:14:00 +0000 Subject: [PATCH 2/2] fix(devops): stop the size report step failing on a broken pipe (SAL-3524) `sort | head` under bash pipefail makes sort exit 2 when head closes the pipe early. awk reads all input, so sort finishes cleanly. Made-with: agent Co-authored-by: mc-agents[bot] <298817976+mc-agents[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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