route-bundle-size 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sergey Patrakeev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,296 @@
1
- # Temporary Holding Version
1
+ # route-bundle-size
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Per-route first-load JS budget for Next.js Turbopack builds.
4
+
5
+ It reads the route stats that `next build` writes, gzips each route's first-load chunks, and compares a PR build with its merge-base build. It fails when a route grows past the budget.
6
+
7
+ - Sizes are per route, first-load JS only.
8
+ - The budget gates on gzip size by default. Raw bytes are also supported.
9
+ - The [example workflow](#github-actions) uses the PR's merge base as the baseline, so later commits on the base branch never count against the PR.
10
+
11
+ ## Requirements
12
+
13
+ - Next.js >= 16.2 with Turbopack. Turbopack is the default for `next build`.
14
+ - Node.js >= 22.12.
15
+
16
+ Next.js 16.2 and later writes `.next/diagnostics/route-bundle-stats.json` on every Turbopack production build. No flag or env var is needed. `next build --webpack` does not write this file, so webpack builds are not supported.
17
+
18
+ ## Quick start
19
+
20
+ ```bash
21
+ # At the baseline commit
22
+ npx next build
23
+ npx route-bundle-size measure --app-dir . --out /tmp/baseline.json
24
+
25
+ # At the commit you want to check
26
+ npx next build
27
+ npx route-bundle-size measure --app-dir . --out /tmp/current.json
28
+
29
+ npx route-bundle-size diff --current /tmp/current.json --baseline /tmp/baseline.json
30
+ ```
31
+
32
+ In a monorepo, build the app and point `--app-dir` at it, for example `--app-dir apps/web`.
33
+
34
+ ## Commands
35
+
36
+ ### `measure`
37
+
38
+ ```bash
39
+ npx route-bundle-size measure \
40
+ --app-dir <dir> \
41
+ --out <file> \
42
+ [--stats <file>]
43
+ ```
44
+
45
+ - `--app-dir` (required): the app root. Chunk paths in the stats file resolve against it.
46
+ - `--out` (required): where to write the snapshot JSON.
47
+ - `--stats`: path to the stats file. Default: `<app-dir>/.next/diagnostics/route-bundle-stats.json`.
48
+
49
+ `measure` gzips each unique chunk once, then sums the chunks of each route. It writes a snapshot:
50
+
51
+ ```json
52
+ { "schemaVersion": 1, "routes": [{ "route": "/", "rawBytes": 65000, "compressedBytes": 49276, "chunkPaths": ["..."] }] }
53
+ ```
54
+
55
+ It fails if a chunk file is missing under `--app-dir`. It also fails if a route reports first-load JS but lists no chunk paths. A missing chunk is never counted as zero.
56
+
57
+ Run `measure` right after each build. The next build overwrites `.next`.
58
+
59
+ ### `diff`
60
+
61
+ `diff` is the default command, so `npx route-bundle-size --current ...` works too.
62
+
63
+ ```bash
64
+ npx route-bundle-size diff \
65
+ --current <file> \
66
+ [--baseline <file>] \
67
+ [--format terminal|markdown] \
68
+ [--config <file>] \
69
+ [--no-exclude]
70
+ ```
71
+
72
+ - `--current` (required): the snapshot to check.
73
+ - `--baseline`: the snapshot to compare with. If it is omitted or the file does not exist, `diff` prints the current sizes and exits `0`. Use this to bootstrap.
74
+ - `--format`: `terminal` (default) prints a table to stdout. `markdown` writes a GitHub-flavored table to the file in `$GITHUB_STEP_SUMMARY`, or to stdout when that variable is not set.
75
+ - `--config`: path to the config file. Default: `route-bundle-size.config.json` in the current directory, if it exists.
76
+ - `--no-exclude`: ignore the `exclude` patterns from the config.
77
+
78
+ Both inputs can be a `measure` snapshot or a raw `route-bundle-stats.json`. The tool detects the shape. A raw stats file has no gzip sizes, so with `metric: "compressed"` it is an error. Use `measure` snapshots, or set `metric` to `"raw"`.
79
+
80
+ `--no-exclude` helps when you look for shared-chunk growth. Pages that ship only shared chunks, such as `/_not-found`, all grow by the same amount when a shared chunk grows.
81
+
82
+ ## Config
83
+
84
+ `route-bundle-size.config.json` in the current directory, or the file passed with `--config`. All keys are optional.
85
+
86
+ ```json
87
+ {
88
+ "exclude": ["/api/**"],
89
+ "budget": {
90
+ "metric": "compressed",
91
+ "maxIncrease": "30kb",
92
+ "maxIncreasePercent": 10
93
+ }
94
+ }
95
+ ```
96
+
97
+ - `exclude`: glob patterns ([picomatch](https://github.com/micromatch/picomatch)) matched against the route. Default `[]`.
98
+ - `budget.metric`: `"compressed"` (gzip, default) or `"raw"` (uncompressed bytes from the stats file).
99
+ - `budget.maxIncrease`: a byte count, or a string such as `"30kb"`, `"1.5mb"`, `"512b"`. Default `"30kb"`.
100
+ - `budget.maxIncreasePercent`: a non-negative number. Default `10`.
101
+
102
+ A route fails when its size grows by at least `maxIncrease` bytes or at least `maxIncreasePercent` percent, whichever comes first.
103
+
104
+ Units in the config use 1024: `"30kb"` is 30,720 bytes. Output uses 1000-byte kB, so `"30kb"` prints as `30.7 kB`.
105
+
106
+ Unknown keys are an error, so a typo such as `"excludes"` does not silently turn off filtering. A missing default config file means defaults are used. A missing file passed with `--config` is an error.
107
+
108
+ ## Output
109
+
110
+ Terminal (`--format terminal`, the default):
111
+
112
+ ```text
113
+ ┌───────────────┬──────────┬───────────┬──────────────────────┬──────────┐
114
+ │ Route │ Baseline │ Current │ Δ bytes │ Δ chunks │
115
+ ├───────────────┼──────────┼───────────┼──────────────────────┼──────────┤
116
+ │ /settings │ 185 kB │ (removed) │ -185 kB 🟢 │ -6 │
117
+ ├───────────────┼──────────┼───────────┼──────────────────────┼──────────┤
118
+ │ /onboarding │ (new) │ 173 kB │ +173 kB (new) │ +5 │
119
+ ├───────────────┼──────────┼───────────┼──────────────────────┼──────────┤
120
+ │ /dashboard │ 255 kB │ 292 kB │ +37.1 kB (+14.5%) 🔴 │ +1 │
121
+ ├───────────────┼──────────┼───────────┼──────────────────────┼──────────┤
122
+ │ /pricing │ 166 kB │ 148 kB │ -17.9 kB (-10.8%) 🟢 │ -1 │
123
+ ├───────────────┼──────────┼───────────┼──────────────────────┼──────────┤
124
+ │ / │ 161 kB │ 162 kB │ +470 B (+0.3%) │ — │
125
+ ├───────────────┼──────────┼───────────┼──────────────────────┼──────────┤
126
+ │ /blog/[slug] │ 157 kB │ 157 kB │ — │ — │
127
+ ├───────────────┼──────────┼───────────┼──────────────────────┼──────────┤
128
+ │ /_not-found │ 138 kB │ 138 kB │ — │ — │
129
+ ├───────────────┼──────────┼───────────┼──────────────────────┼──────────┤
130
+ │ TOTAL │ 1.06 MB │ 1.07 MB │ +7.17 kB (+0.7%) │ -1 │
131
+ ├───────────────┼──────────┼───────────┼──────────────────────┼──────────┤
132
+ │ avg per route │ 177 kB │ 178 kB │ │ │
133
+ └───────────────┴──────────┴───────────┴──────────────────────┴──────────┘
134
+ 🔴 exceeds +30.7 kB OR +10% compressed 🟢 notable improvement 7 route(s)
135
+ ::error::Bundle size regression — see step summary
136
+ ```
137
+
138
+ Markdown (`--format markdown`):
139
+
140
+ ```markdown
141
+ ## 📦 Bundle size
142
+
143
+ | Route | First Load JS | Δ |
144
+ | -------------- | ------------- | -------------------- |
145
+ | `/settings` | — (removed) | -185 kB 🟢 |
146
+ | `/onboarding` | 173 kB | +173 kB (new) |
147
+ | `/dashboard` | 292 kB | +37.1 kB (+14.5%) 🔴 |
148
+ | `/pricing` | 148 kB | -17.9 kB (-10.8%) 🟢 |
149
+ | `/` | 162 kB | +470 B (+0.3%) |
150
+ | `/blog/[slug]` | 157 kB | — |
151
+ | `/_not-found` | 138 kB | — |
152
+
153
+ ### Summary
154
+
155
+ - **Routes:** 7
156
+ - **Total:** 1.06 MB → 1.07 MB (+7.17 kB, +0.7%)
157
+ - **Avg per route:** 177 kB → 178 kB
158
+ - **Chunks:** 30 → 29 (-1)
159
+
160
+ 🔴 exceeds `+30.7 kB` OR `+10%` compressed · 🟢 notable improvement (≥30.7 kB OR ≥10% smaller)
161
+ ```
162
+
163
+ How to read it:
164
+
165
+ - Sizes and deltas use the budget metric. Rows are sorted by the size of the change.
166
+ - 🔴 marks a route over budget. 🟢 marks a route that shrank by at least the budget.
167
+ - A change under 250 bytes shows as `—`.
168
+ - New routes are marked `(new)` and never fail the budget. Removed routes never fail either.
169
+ - The `::error::` line goes to stderr. GitHub Actions shows it as an annotation.
170
+
171
+ Without a baseline, `diff` prints the current routes only. This table always shows raw bytes, and it goes to the step summary or stdout for both formats:
172
+
173
+ ```markdown
174
+ ## 📦 Bundle size
175
+
176
+ Baseline not yet established. 6 routes captured.
177
+
178
+ | Route | First Load JS |
179
+ | -------------- | ------------- |
180
+ | `/dashboard` | 969 kB |
181
+ | `/onboarding` | 557 kB |
182
+ | `/` | 514 kB |
183
+ | `/blog/[slug]` | 499 kB |
184
+ | `/pricing` | 472 kB |
185
+ | `/_not-found` | 432 kB |
186
+ ```
187
+
188
+ ## Exit codes
189
+
190
+ - `0`: every route is within budget, or there is no baseline yet. A `--baseline` path that does not exist also exits `0`, so the first run passes.
191
+ - `1`: at least one route is over budget, or any error: bad config, unreadable input, missing chunk files, or raw stats used with `metric: "compressed"`.
192
+
193
+ ## GitHub Actions
194
+
195
+ This workflow builds the PR head and its merge base in one job, then compares them. It writes the result to the job summary. It posts no PR comment and needs only `contents: read`, so it works for PRs from forks.
196
+
197
+ ```yaml
198
+ name: Bundle size
199
+
200
+ on:
201
+ pull_request:
202
+
203
+ permissions:
204
+ contents: read
205
+
206
+ jobs:
207
+ bundle-size:
208
+ runs-on: ubuntu-latest
209
+ timeout-minutes: 30
210
+ steps:
211
+ - uses: actions/checkout@v6
212
+ with:
213
+ ref: ${{ github.event.pull_request.head.sha }}
214
+ fetch-depth: 0
215
+
216
+ - uses: actions/setup-node@v6
217
+ with:
218
+ node-version: 22
219
+ cache: npm
220
+
221
+ # An empty sha would make "git checkout --detach" stay at HEAD,
222
+ # so both sides would measure the same build and always pass.
223
+ - name: Resolve merge base
224
+ id: merge_base
225
+ run: |
226
+ sha=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
227
+ if [ -z "$sha" ]; then
228
+ echo "::error::Could not resolve a merge base with origin/${{ github.base_ref }}."
229
+ exit 1
230
+ fi
231
+ echo "sha=$sha" >> "$GITHUB_OUTPUT"
232
+ echo "head=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
233
+
234
+ - name: Install dependencies
235
+ run: npm ci
236
+
237
+ - name: Build current
238
+ run: npx next build
239
+
240
+ - name: Measure current
241
+ run: npx --yes route-bundle-size@0.1.0 measure --app-dir . --out "$RUNNER_TEMP/current.json"
242
+
243
+ - name: Check out merge base
244
+ run: git checkout --detach ${{ steps.merge_base.outputs.sha }}
245
+
246
+ - name: Install dependencies at merge base
247
+ run: npm ci
248
+
249
+ - name: Build baseline
250
+ run: npx next build
251
+
252
+ - name: Return to PR head
253
+ run: git checkout --detach ${{ steps.merge_base.outputs.head }}
254
+
255
+ # .next is gitignored, so the baseline build survives the checkout.
256
+ # Running diff at the PR head uses the PR's config file.
257
+ - name: Measure baseline
258
+ run: npx --yes route-bundle-size@0.1.0 measure --app-dir . --out "$RUNNER_TEMP/baseline.json"
259
+
260
+ - name: Compare
261
+ run: |
262
+ npx --yes route-bundle-size@0.1.0 diff \
263
+ --current "$RUNNER_TEMP/current.json" \
264
+ --baseline "$RUNNER_TEMP/baseline.json" \
265
+ --format markdown
266
+ ```
267
+
268
+ Notes:
269
+
270
+ - Pin the tool version. Both snapshots are then made by the same version.
271
+ - The tool runs through `npx`, so it does not need the project's `node_modules`. No reinstall is needed after returning to the PR head.
272
+ - If the merge-base build fails, the job fails. It does not pass quietly.
273
+ - GitHub shows the job summary even when the step exits `1`.
274
+ - The cost is a second build in each PR run.
275
+ - If your repo tracks `next-env.d.ts`, `next build` rewrites it and the checkout fails. Run `git checkout HEAD -- next-env.d.ts` before each `git checkout --detach`. Projects made with `create-next-app` ignore this file.
276
+
277
+ ## Limitations
278
+
279
+ - The stats file `route-bundle-stats.json` has no official docs and no stability promise. A Next.js release can change it, so this package stays on 0.x.
280
+ - Turbopack only. `next build --webpack` writes no stats file.
281
+ - The default `--stats` path assumes the default `distDir` (`.next`). With a custom `distDir`, pass `--stats <app-dir>/<distDir>/diagnostics/route-bundle-stats.json`.
282
+ - Only first-load JS is measured.
283
+ - One budget applies to every route. There are no per-route budgets.
284
+
285
+ ## Development
286
+
287
+ ```bash
288
+ npm test
289
+ npm run types
290
+ ```
291
+
292
+ The fixture `fixtures/sample-build/diagnostics/route-bundle-stats.json` is the parser contract. If Next.js changes the stats format, update `parseRawStats` in `src/stats.ts` and the fixture together.
293
+
294
+ ## License
295
+
296
+ MIT
package/dist/cli.js ADDED
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ import * as fs from "node:fs";
3
+ import { Command, Option } from "commander";
4
+ import { loadConfig } from "./config.js";
5
+ import { assertComparable, buildRows, buildSummary, FORMATS, loadSizes, renderBootstrap, renderMarkdown, renderTerminal, } from "./lib.js";
6
+ import { defaultStatsFile, measure, writeSnapshot } from "./measure.js";
7
+ const writeSummary = (markdown) => {
8
+ const summaryPath = process.env.GITHUB_STEP_SUMMARY;
9
+ if (summaryPath !== undefined) {
10
+ fs.appendFileSync(summaryPath, markdown + "\n");
11
+ return;
12
+ }
13
+ console.log(markdown);
14
+ };
15
+ const diffCommand = new Command("diff")
16
+ .description("Diff route bundle sizes against a baseline; render markdown or terminal table; exit 1 on exceeded.")
17
+ .requiredOption("--current <file>", "Path to the PR's snapshot or route-bundle-stats.json")
18
+ .option("--baseline <file>", "Path to main's snapshot or route-bundle-stats.json (bootstrap mode if absent)")
19
+ .addOption(new Option("--format <format>", "Output format. 'terminal' (default) renders an aligned cli-table3 view for local debugging; 'markdown' writes a GitHub-flavored table to $GITHUB_STEP_SUMMARY and is the CI-only mode.")
20
+ .choices([...FORMATS])
21
+ .default("terminal"))
22
+ .option("--config <file>", "Path to route-bundle-size.config.json (default: the one in the current directory, if any)")
23
+ .option("--no-exclude", "Ignore the configured exclude patterns. Useful in diagnostic mode — bare pages surface pure shared-chunk drift.")
24
+ .action((options) => {
25
+ const config = loadConfig({ configPath: options.config });
26
+ const exclude = options.exclude ? config.exclude : [];
27
+ const budget = config.budget;
28
+ const current = loadSizes(options.current, exclude);
29
+ if (options.baseline === undefined || !fs.existsSync(options.baseline)) {
30
+ writeSummary(renderBootstrap(current.sizes));
31
+ return;
32
+ }
33
+ const baseline = loadSizes(options.baseline, exclude);
34
+ assertComparable(current, baseline, budget.metric);
35
+ const { rows, anyExceeded } = buildRows(current.sizes, baseline.sizes, budget);
36
+ const summary = buildSummary(rows, budget.metric);
37
+ const output = options.format === "terminal"
38
+ ? renderTerminal(rows, summary, budget)
39
+ : renderMarkdown(rows, summary, budget);
40
+ if (options.format === "terminal") {
41
+ console.log(output);
42
+ }
43
+ else {
44
+ writeSummary(output);
45
+ }
46
+ if (anyExceeded) {
47
+ console.error("::error::Bundle size regression — see step summary");
48
+ process.exit(1);
49
+ }
50
+ });
51
+ const measureCommand = new Command("measure")
52
+ .description("Gzip every unique first-load chunk once and write a snapshot with raw + compressed bytes per route.")
53
+ .requiredOption("--app-dir <dir>", "App root that the chunk paths in the stats file resolve against (the directory that contains .next)")
54
+ .option("--stats <file>", "Path to route-bundle-stats.json (default: <app-dir>/.next/diagnostics/route-bundle-stats.json)")
55
+ .requiredOption("--out <file>", "Path to write the snapshot JSON to")
56
+ .action((options) => {
57
+ var _a;
58
+ const statsFile = (_a = options.stats) !== null && _a !== void 0 ? _a : defaultStatsFile(options.appDir);
59
+ const { snapshot, uniqueChunkCount } = measure({
60
+ appDir: options.appDir,
61
+ statsFile,
62
+ });
63
+ writeSnapshot(options.out, snapshot);
64
+ console.log(`Wrote ${options.out}: ${snapshot.routes.length} route(s), ${uniqueChunkCount} unique chunk(s)`);
65
+ });
66
+ const program = new Command();
67
+ program
68
+ .name("route-bundle-size")
69
+ .description("Per-route bundle size budget for Next.js builds: measure compressed sizes, diff against a baseline, exit 1 on regression.");
70
+ program.addCommand(diffCommand, { isDefault: true });
71
+ program.addCommand(measureCommand);
72
+ try {
73
+ program.parse();
74
+ }
75
+ catch (error) {
76
+ console.error(error instanceof Error ? error.message : String(error));
77
+ process.exit(1);
78
+ }
package/dist/config.js ADDED
@@ -0,0 +1,82 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ export const CONFIG_FILE_NAME = "route-bundle-size.config.json";
4
+ export const METRICS = ["raw", "compressed"];
5
+ export const DEFAULT_CONFIG = {
6
+ exclude: [],
7
+ budget: {
8
+ metric: "compressed",
9
+ maxIncrease: 30 * 1024,
10
+ maxIncreasePercent: 10,
11
+ },
12
+ };
13
+ const BYTE_UNITS = { b: 1, kb: 1024, mb: 1024 * 1024 };
14
+ const BYTE_SIZE_PATTERN = /^\s*(\d+(?:\.\d+)?)\s*(b|kb|mb)?\s*$/i;
15
+ export const parseByteSize = (value, field) => {
16
+ var _a;
17
+ const numeric = typeof value === "number" ? value : Number.NaN;
18
+ const match = typeof value === "string" ? BYTE_SIZE_PATTERN.exec(value) : null;
19
+ const rounded = Math.round(match === null
20
+ ? numeric
21
+ : Number(match[1]) * BYTE_UNITS[((_a = match[2]) !== null && _a !== void 0 ? _a : "b").toLowerCase()]);
22
+ if (!Number.isSafeInteger(rounded) || rounded < 0) {
23
+ throw new Error(`Invalid ${field}: ${JSON.stringify(value)}. Expected a byte count or a size string like "30kb", "1.5mb", "512b".`);
24
+ }
25
+ return rounded;
26
+ };
27
+ const asObject = (value, label) => {
28
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
29
+ throw new Error(`Invalid ${label}: expected a JSON object.`);
30
+ }
31
+ return Object.assign({}, value);
32
+ };
33
+ const assertKnownKeys = (value, validKeys, where) => {
34
+ for (const key of Object.keys(value)) {
35
+ if (!validKeys.includes(key)) {
36
+ throw new Error(`Unknown key "${key}" in ${where}. Valid keys: ${validKeys.join(", ")}.`);
37
+ }
38
+ }
39
+ };
40
+ export const parseConfig = (value, source) => {
41
+ const raw = asObject(value, `config in ${source}`);
42
+ assertKnownKeys(raw, Object.keys(DEFAULT_CONFIG), source);
43
+ const { exclude, budget: budgetValue } = Object.assign(Object.assign({}, DEFAULT_CONFIG), raw);
44
+ if (!Array.isArray(exclude) ||
45
+ !exclude.every((item) => typeof item === "string")) {
46
+ throw new Error(`Invalid "exclude" in ${source}: expected an array of glob patterns.`);
47
+ }
48
+ const budgetSource = `"budget" in ${source}`;
49
+ const budgetRaw = asObject(budgetValue, budgetSource);
50
+ assertKnownKeys(budgetRaw, Object.keys(DEFAULT_CONFIG.budget), budgetSource);
51
+ const { metric: metricValue, maxIncrease: maxIncreaseValue, maxIncreasePercent, } = Object.assign(Object.assign({}, DEFAULT_CONFIG.budget), budgetRaw);
52
+ const metric = METRICS.find((known) => known === metricValue);
53
+ if (metric === undefined) {
54
+ throw new Error(`Invalid "budget.metric" in ${source}: ${JSON.stringify(metricValue)}. Valid values: ${METRICS.join(", ")}.`);
55
+ }
56
+ if (typeof maxIncreasePercent !== "number" ||
57
+ !Number.isFinite(maxIncreasePercent) ||
58
+ maxIncreasePercent < 0) {
59
+ throw new Error(`Invalid "budget.maxIncreasePercent" in ${source}: ${JSON.stringify(maxIncreasePercent)}. Expected a non-negative number.`);
60
+ }
61
+ const maxIncrease = parseByteSize(maxIncreaseValue, `"budget.maxIncrease" in ${source}`);
62
+ return { exclude, budget: { metric, maxIncrease, maxIncreasePercent } };
63
+ };
64
+ export const loadConfig = (options) => {
65
+ var _a, _b;
66
+ const cwd = (_a = options.cwd) !== null && _a !== void 0 ? _a : process.cwd();
67
+ const filePath = path.resolve(cwd, (_b = options.configPath) !== null && _b !== void 0 ? _b : CONFIG_FILE_NAME);
68
+ if (!fs.existsSync(filePath)) {
69
+ if (options.configPath === undefined) {
70
+ return DEFAULT_CONFIG;
71
+ }
72
+ throw new Error(`Config file not found: ${filePath}`);
73
+ }
74
+ let parsed;
75
+ try {
76
+ parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
77
+ }
78
+ catch (error) {
79
+ throw new Error(`Invalid JSON in ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
80
+ }
81
+ return parseConfig(parsed, filePath);
82
+ };
package/dist/lib.js ADDED
@@ -0,0 +1,264 @@
1
+ import * as fs from "node:fs";
2
+ import Table from "cli-table3";
3
+ import { markdownTable } from "markdown-table";
4
+ import picomatch from "picomatch";
5
+ import prettyBytes from "pretty-bytes";
6
+ import { parseRawStats, parseSnapshot, readJsonFile } from "./stats.js";
7
+ export const FORMATS = ["markdown", "terminal"];
8
+ export const MIN_DISPLAY = 250;
9
+ const routeMatcher = (exclude) => picomatch(exclude, { dot: true });
10
+ export const isExcluded = (route, exclude) => routeMatcher(exclude)(route);
11
+ export const loadSizes = (filePath, exclude) => {
12
+ var _a, _b;
13
+ if (!fs.existsSync(filePath)) {
14
+ throw new Error(`Input file not found: ${filePath}. Expected a "measure" snapshot or a route-bundle-stats.json (a Turbopack production build on Next.js >= 16.2 writes one).`);
15
+ }
16
+ const data = readJsonFile(filePath);
17
+ const excluded = routeMatcher(exclude);
18
+ const sizes = new Map();
19
+ if (Array.isArray(data)) {
20
+ for (const entry of parseRawStats(data, filePath)) {
21
+ if (excluded(entry.route)) {
22
+ continue;
23
+ }
24
+ sizes.set(entry.route, {
25
+ bytes: entry.firstLoadUncompressedJsBytes,
26
+ chunks: (_b = (_a = entry.firstLoadChunkPaths) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0,
27
+ compressedBytes: null,
28
+ });
29
+ }
30
+ return { compressed: false, sizes };
31
+ }
32
+ if (typeof data !== "object" || data === null || !("schemaVersion" in data)) {
33
+ throw new Error(`Unrecognized input ${filePath}: expected a route-bundle-stats.json array or a route-bundle-size snapshot with "schemaVersion".`);
34
+ }
35
+ for (const entry of parseSnapshot(data, filePath).routes) {
36
+ if (excluded(entry.route)) {
37
+ continue;
38
+ }
39
+ sizes.set(entry.route, {
40
+ bytes: entry.rawBytes,
41
+ chunks: entry.chunkPaths.length,
42
+ compressedBytes: entry.compressedBytes,
43
+ });
44
+ }
45
+ return { compressed: true, sizes };
46
+ };
47
+ export const hasCompressedData = (loaded) => loaded.compressed;
48
+ export const assertComparable = (current, baseline, metric) => {
49
+ if (metric !== "compressed") {
50
+ return;
51
+ }
52
+ const uncompressed = [
53
+ hasCompressedData(current) ? null : "current",
54
+ hasCompressedData(baseline) ? null : "baseline",
55
+ ].filter((side) => side !== null);
56
+ if (uncompressed.length > 0) {
57
+ throw new Error(`Budget metric is "compressed" but ${uncompressed.join(" and ")} ${uncompressed.length > 1 ? "have" : "has"} no compressed data. Produce snapshots with the "measure" command, or set "budget.metric" to "raw" in the config.`);
58
+ }
59
+ };
60
+ const metricBytes = (stats, metric) => { var _a; return metric === "compressed" ? ((_a = stats.compressedBytes) !== null && _a !== void 0 ? _a : 0) : stats.bytes; };
61
+ export const buildRows = (current, baseline, budget) => {
62
+ var _a, _b, _c, _d;
63
+ const allRoutes = new Set([...current.keys(), ...baseline.keys()]);
64
+ const rows = [];
65
+ let anyExceeded = false;
66
+ for (const route of allRoutes) {
67
+ const cur = (_a = current.get(route)) !== null && _a !== void 0 ? _a : null;
68
+ const base = (_b = baseline.get(route)) !== null && _b !== void 0 ? _b : null;
69
+ const currentBytes = cur === null ? 0 : metricBytes(cur, budget.metric);
70
+ const baselineBytes = base === null ? 0 : metricBytes(base, budget.metric);
71
+ const delta = currentBytes - baselineBytes;
72
+ const deltaPercent = base !== null && baselineBytes > 0 ? (delta / baselineBytes) * 100 : null;
73
+ const chunkDelta = ((_c = cur === null || cur === void 0 ? void 0 : cur.chunks) !== null && _c !== void 0 ? _c : 0) - ((_d = base === null || base === void 0 ? void 0 : base.chunks) !== null && _d !== void 0 ? _d : 0);
74
+ // New routes (no baseline) never exceeded — a fresh page legitimately ships
75
+ // its full size as "delta", which would otherwise always trip the abs rule.
76
+ const exceeded = base !== null &&
77
+ delta > 0 &&
78
+ (delta >= budget.maxIncrease ||
79
+ (deltaPercent !== null && deltaPercent >= budget.maxIncreasePercent));
80
+ if (exceeded) {
81
+ anyExceeded = true;
82
+ }
83
+ rows.push({
84
+ route,
85
+ current: cur,
86
+ baseline: base,
87
+ delta,
88
+ deltaPercent,
89
+ chunkDelta,
90
+ exceeded,
91
+ });
92
+ }
93
+ rows.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
94
+ return { rows, anyExceeded };
95
+ };
96
+ export const buildSummary = (rows, metric) => {
97
+ let totalCurrent = 0;
98
+ let totalBaseline = 0;
99
+ let chunksCurrent = 0;
100
+ let chunksBaseline = 0;
101
+ let routesCurrent = 0;
102
+ let routesBaseline = 0;
103
+ for (const row of rows) {
104
+ if (row.current !== null) {
105
+ totalCurrent += metricBytes(row.current, metric);
106
+ chunksCurrent += row.current.chunks;
107
+ routesCurrent += 1;
108
+ }
109
+ if (row.baseline !== null) {
110
+ totalBaseline += metricBytes(row.baseline, metric);
111
+ chunksBaseline += row.baseline.chunks;
112
+ routesBaseline += 1;
113
+ }
114
+ }
115
+ const totalDelta = totalCurrent - totalBaseline;
116
+ const totalDeltaPercent = totalBaseline > 0 ? (totalDelta / totalBaseline) * 100 : null;
117
+ return {
118
+ totalRoutes: rows.length,
119
+ routesCurrent,
120
+ routesBaseline,
121
+ totalCurrent,
122
+ totalBaseline,
123
+ totalDelta,
124
+ totalDeltaPercent,
125
+ avgCurrent: routesCurrent > 0 ? totalCurrent / routesCurrent : 0,
126
+ avgBaseline: routesBaseline > 0 ? totalBaseline / routesBaseline : 0,
127
+ chunksCurrent,
128
+ chunksBaseline,
129
+ };
130
+ };
131
+ export const isNotableShrink = (row, budget) => row.delta < 0 &&
132
+ (row.delta <= -budget.maxIncrease ||
133
+ (row.deltaPercent !== null &&
134
+ row.deltaPercent <= -budget.maxIncreasePercent));
135
+ export const formatDelta = (row, budget) => {
136
+ // Sub-threshold deltas (incl. zero) are visual noise — show as "no change".
137
+ if (Math.abs(row.delta) < MIN_DISPLAY) {
138
+ return "—";
139
+ }
140
+ const sign = row.delta > 0 ? "+" : "";
141
+ const marker = row.exceeded
142
+ ? " 🔴"
143
+ : isNotableShrink(row, budget)
144
+ ? " 🟢"
145
+ : "";
146
+ // Removed: the size column already says "(removed)"; -100% is noise.
147
+ if (row.current === null) {
148
+ return `${sign}${prettyBytes(row.delta)}${marker}`;
149
+ }
150
+ const percentLabel = row.deltaPercent === null
151
+ ? "new"
152
+ : `${sign}${row.deltaPercent.toFixed(1)}%`;
153
+ return `${sign}${prettyBytes(row.delta)} (${percentLabel})${marker}`;
154
+ };
155
+ export const formatSignedBytes = (bytes) => {
156
+ if (bytes === 0)
157
+ return "—";
158
+ const sign = bytes > 0 ? "+" : "";
159
+ return `${sign}${prettyBytes(bytes)}`;
160
+ };
161
+ export const formatPercent = (pct) => {
162
+ if (pct === null)
163
+ return "—";
164
+ const sign = pct > 0 ? "+" : "";
165
+ return `${sign}${pct.toFixed(1)}%`;
166
+ };
167
+ export const formatSignedInt = (n) => {
168
+ if (n === 0)
169
+ return "—";
170
+ return n > 0 ? `+${n}` : `${n}`;
171
+ };
172
+ const budgetLegendSuffix = (budget) => budget.metric === "compressed" ? " compressed" : "";
173
+ export const renderMarkdown = (rows, summary, budget) => {
174
+ if (rows.length === 0) {
175
+ return "## 📦 Bundle size\n\nNo client routes detected.";
176
+ }
177
+ const table = markdownTable([
178
+ ["Route", "First Load JS", "Δ"],
179
+ ...rows.map((r) => [
180
+ `\`${r.route}\``,
181
+ r.current === null
182
+ ? "— (removed)"
183
+ : prettyBytes(metricBytes(r.current, budget.metric)),
184
+ formatDelta(r, budget),
185
+ ]),
186
+ ]);
187
+ const summaryLines = [
188
+ "",
189
+ "### Summary",
190
+ "",
191
+ `- **Routes:** ${summary.totalRoutes}`,
192
+ `- **Total:** ${prettyBytes(summary.totalBaseline)} → ${prettyBytes(summary.totalCurrent)} (${formatSignedBytes(summary.totalDelta)}, ${formatPercent(summary.totalDeltaPercent)})`,
193
+ `- **Avg per route:** ${prettyBytes(summary.avgBaseline)} → ${prettyBytes(summary.avgCurrent)}`,
194
+ `- **Chunks:** ${summary.chunksBaseline} → ${summary.chunksCurrent} (${formatSignedInt(summary.chunksCurrent - summary.chunksBaseline)})`,
195
+ ];
196
+ return [
197
+ "## 📦 Bundle size",
198
+ "",
199
+ table,
200
+ ...summaryLines,
201
+ "",
202
+ `🔴 exceeds \`+${prettyBytes(budget.maxIncrease)}\` OR \`+${budget.maxIncreasePercent}%\`${budgetLegendSuffix(budget)} · 🟢 notable improvement (≥${prettyBytes(budget.maxIncrease)} OR ≥${budget.maxIncreasePercent}% smaller)`,
203
+ ].join("\n");
204
+ };
205
+ export const renderTerminal = (rows, summary, budget) => {
206
+ if (rows.length === 0) {
207
+ return "📦 Bundle size: no client routes detected.";
208
+ }
209
+ const table = new Table({
210
+ head: ["Route", "Baseline", "Current", "Δ bytes", "Δ chunks"],
211
+ colAligns: ["left", "right", "right", "right", "right"],
212
+ style: { head: [], border: [] },
213
+ });
214
+ for (const row of rows) {
215
+ table.push([
216
+ row.route,
217
+ row.baseline === null
218
+ ? "(new)"
219
+ : prettyBytes(metricBytes(row.baseline, budget.metric)),
220
+ row.current === null
221
+ ? "(removed)"
222
+ : prettyBytes(metricBytes(row.current, budget.metric)),
223
+ formatDelta(row, budget),
224
+ row.chunkDelta === 0
225
+ ? "—"
226
+ : `${row.chunkDelta > 0 ? "+" : ""}${row.chunkDelta}`,
227
+ ]);
228
+ }
229
+ table.push([
230
+ { content: "TOTAL", hAlign: "left" },
231
+ prettyBytes(summary.totalBaseline),
232
+ prettyBytes(summary.totalCurrent),
233
+ `${formatSignedBytes(summary.totalDelta)} (${formatPercent(summary.totalDeltaPercent)})`,
234
+ formatSignedInt(summary.chunksCurrent - summary.chunksBaseline),
235
+ ]);
236
+ table.push([
237
+ { content: "avg per route", hAlign: "left" },
238
+ prettyBytes(summary.avgBaseline),
239
+ prettyBytes(summary.avgCurrent),
240
+ "",
241
+ "",
242
+ ]);
243
+ return [
244
+ table.toString(),
245
+ `🔴 exceeds +${prettyBytes(budget.maxIncrease)} OR +${budget.maxIncreasePercent}%${budgetLegendSuffix(budget)} 🟢 notable improvement ${summary.totalRoutes} route(s)`,
246
+ ].join("\n");
247
+ };
248
+ export const renderBootstrap = (sizes) => {
249
+ const sorted = [...sizes.entries()].sort((a, b) => b[1].bytes - a[1].bytes);
250
+ const table = markdownTable([
251
+ ["Route", "First Load JS"],
252
+ ...sorted.map(([route, stats]) => [
253
+ `\`${route}\``,
254
+ prettyBytes(stats.bytes),
255
+ ]),
256
+ ]);
257
+ return [
258
+ "## 📦 Bundle size",
259
+ "",
260
+ `Baseline not yet established. ${sizes.size} routes captured.`,
261
+ "",
262
+ table,
263
+ ].join("\n");
264
+ };
@@ -0,0 +1,51 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as zlib from "node:zlib";
4
+ import { readRawStats, SNAPSHOT_SCHEMA_VERSION } from "./stats.js";
5
+ export const defaultStatsFile = (appDir) => path.join(appDir, ".next", "diagnostics", "route-bundle-stats.json");
6
+ const MISSING_CHUNKS_SHOWN = 5;
7
+ export const measure = (options) => {
8
+ const entries = readRawStats(options.statsFile);
9
+ const sizedWithoutChunks = entries.filter((entry) => {
10
+ var _a;
11
+ return entry.firstLoadUncompressedJsBytes > 0 &&
12
+ ((_a = entry.firstLoadChunkPaths) !== null && _a !== void 0 ? _a : []).length === 0;
13
+ });
14
+ if (sizedWithoutChunks.length > 0) {
15
+ throw new Error(`${sizedWithoutChunks.length} route(s) in ${options.statsFile} report first-load JS but list no "firstLoadChunkPaths": ${sizedWithoutChunks.map((entry) => entry.route).join(", ")}. Their compressed size would be recorded as 0 and the snapshot would understate their size, so the budget could not see them grow.`);
16
+ }
17
+ const uniqueChunks = [
18
+ ...new Set(entries.flatMap((entry) => { var _a; return (_a = entry.firstLoadChunkPaths) !== null && _a !== void 0 ? _a : []; })),
19
+ ];
20
+ const missing = uniqueChunks.filter((chunk) => !fs.existsSync(path.resolve(options.appDir, chunk)));
21
+ if (missing.length > 0) {
22
+ const shown = missing.slice(0, MISSING_CHUNKS_SHOWN).join(", ");
23
+ const rest = missing.length > MISSING_CHUNKS_SHOWN
24
+ ? ` (and ${missing.length - MISSING_CHUNKS_SHOWN} more)`
25
+ : "";
26
+ throw new Error(`${missing.length} chunk file(s) referenced by ${options.statsFile} are missing under --app-dir ${options.appDir}: ${shown}${rest}. Point --app-dir at the app root that the stats paths resolve against.`);
27
+ }
28
+ const compressedByChunk = new Map();
29
+ for (const chunk of uniqueChunks) {
30
+ compressedByChunk.set(chunk, zlib.gzipSync(fs.readFileSync(path.resolve(options.appDir, chunk)))
31
+ .byteLength);
32
+ }
33
+ const snapshot = {
34
+ schemaVersion: SNAPSHOT_SCHEMA_VERSION,
35
+ routes: entries.map((entry) => {
36
+ var _a;
37
+ const chunks = (_a = entry.firstLoadChunkPaths) !== null && _a !== void 0 ? _a : [];
38
+ return {
39
+ route: entry.route,
40
+ rawBytes: entry.firstLoadUncompressedJsBytes,
41
+ compressedBytes: chunks.reduce((total, chunk) => { var _a; return total + ((_a = compressedByChunk.get(chunk)) !== null && _a !== void 0 ? _a : 0); }, 0),
42
+ chunkPaths: chunks,
43
+ };
44
+ }),
45
+ };
46
+ return { snapshot, uniqueChunkCount: uniqueChunks.length };
47
+ };
48
+ export const writeSnapshot = (filePath, snapshot) => {
49
+ fs.mkdirSync(path.dirname(path.resolve(filePath)), { recursive: true });
50
+ fs.writeFileSync(filePath, `${JSON.stringify(snapshot, null, 2)}\n`);
51
+ };
package/dist/stats.js ADDED
@@ -0,0 +1,56 @@
1
+ import * as fs from "node:fs";
2
+ export const SNAPSHOT_SCHEMA_VERSION = 1;
3
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4
+ const isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
5
+ const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
6
+ export const parseRawStats = (value, source) => {
7
+ if (!Array.isArray(value)) {
8
+ throw new Error(`Invalid stats file ${source}: expected a top-level array of route entries.`);
9
+ }
10
+ return value.map((entry, index) => {
11
+ const { route, firstLoadUncompressedJsBytes, firstLoadChunkPaths } = (entry !== null && entry !== void 0 ? entry : {});
12
+ if (typeof route !== "string" ||
13
+ !isFiniteNumber(firstLoadUncompressedJsBytes)) {
14
+ throw new Error(`Invalid stats file ${source}: entry ${index} must have a string "route" and a finite "firstLoadUncompressedJsBytes".`);
15
+ }
16
+ return { route, firstLoadUncompressedJsBytes, firstLoadChunkPaths };
17
+ });
18
+ };
19
+ export const readJsonFile = (filePath) => {
20
+ const text = fs.readFileSync(filePath, "utf8");
21
+ try {
22
+ return JSON.parse(text);
23
+ }
24
+ catch (error) {
25
+ throw new Error(`Invalid JSON in ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
26
+ }
27
+ };
28
+ export const readRawStats = (filePath) => {
29
+ if (!fs.existsSync(filePath)) {
30
+ throw new Error(`Stats file not found: ${filePath}. A Turbopack production build on Next.js >= 16.2 writes this file.`);
31
+ }
32
+ return parseRawStats(readJsonFile(filePath), filePath);
33
+ };
34
+ export const parseSnapshot = (value, source) => {
35
+ if (!isRecord(value)) {
36
+ throw new Error(`Invalid snapshot ${source}: expected a JSON object.`);
37
+ }
38
+ if (value.schemaVersion !== SNAPSHOT_SCHEMA_VERSION) {
39
+ throw new Error(`Unsupported snapshot schemaVersion in ${source}: ${JSON.stringify(value.schemaVersion)}. Expected ${SNAPSHOT_SCHEMA_VERSION}.`);
40
+ }
41
+ if (!Array.isArray(value.routes)) {
42
+ throw new Error(`Invalid snapshot ${source}: "routes" must be an array.`);
43
+ }
44
+ const routes = value.routes.map((entry, index) => {
45
+ const { route, rawBytes, compressedBytes, chunkPaths } = (entry !== null && entry !== void 0 ? entry : {});
46
+ if (typeof route !== "string" ||
47
+ !isFiniteNumber(rawBytes) ||
48
+ !isStringArray(chunkPaths) ||
49
+ !isFiniteNumber(compressedBytes) ||
50
+ compressedBytes < 0) {
51
+ throw new Error(`Invalid snapshot ${source}: route ${index} must have a string "route", a finite "rawBytes", a "chunkPaths" array of strings, and a non-negative "compressedBytes".`);
52
+ }
53
+ return { route, rawBytes, compressedBytes, chunkPaths };
54
+ });
55
+ return { schemaVersion: SNAPSHOT_SCHEMA_VERSION, routes };
56
+ };
package/package.json CHANGED
@@ -1,6 +1,56 @@
1
1
  {
2
+ "$schema": "https://www.schemastore.org/package.json",
2
3
  "name": "route-bundle-size",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
4
+ "version": "0.1.0",
5
+ "description": "Per-route bundle size budget for Next.js builds: measure compressed sizes, diff against a baseline, exit 1 on regression.",
6
+ "keywords": [
7
+ "nextjs",
8
+ "turbopack",
9
+ "bundle-size",
10
+ "performance-budget",
11
+ "ci",
12
+ "cli"
13
+ ],
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/bra31k/route-bundle-size.git"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/bra31k/route-bundle-size/issues"
21
+ },
22
+ "homepage": "https://github.com/bra31k/route-bundle-size#readme",
23
+ "type": "module",
24
+ "bin": {
25
+ "route-bundle-size": "dist/cli.js"
26
+ },
27
+ "files": [
28
+ "dist"
29
+ ],
30
+ "engines": {
31
+ "node": ">=22.12.0"
32
+ },
33
+ "scripts": {
34
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
35
+ "prepack": "npm run build",
36
+ "test": "NODE_OPTIONS=--experimental-vm-modules jest",
37
+ "types": "tsc --noEmit"
38
+ },
39
+ "dependencies": {
40
+ "cli-table3": "^0.6.5",
41
+ "commander": "^15.0.0",
42
+ "markdown-table": "^3.0.4",
43
+ "picomatch": "^4.0.4",
44
+ "pretty-bytes": "^7.1.0"
45
+ },
46
+ "devDependencies": {
47
+ "@jest/globals": "^30.4.1",
48
+ "@types/node": "^25.6.0",
49
+ "@types/picomatch": "^4.0.3",
50
+ "jest": "^30.4.2",
51
+ "jest-environment-node": "^30.4.1",
52
+ "ts-jest": "^29.4.6",
53
+ "tsx": "^4.9.1",
54
+ "typescript": "^6.0.3"
55
+ }
56
+ }