route-bundle-size 0.1.0 → 0.2.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/README.md CHANGED
@@ -2,48 +2,194 @@
2
2
 
3
3
  Per-route first-load JS budget for Next.js Turbopack builds.
4
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.
5
+ Next.js 16 removed the per-route "First Load JS" column from the `next build` output ([vercel/next.js#83815](https://github.com/vercel/next.js/pull/83815)). Next.js 16.2 started writing `.next/diagnostics/route-bundle-stats.json` on Turbopack builds, and this tool turns that file into a per-route budget for CI.
6
+
7
+ It reads the route stats, 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 or is larger than its size limit.
6
8
 
7
9
  - Sizes are per route, first-load JS only.
8
10
  - 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.
11
+ - The [GitHub Action](#github-actions) uses the PR's merge base as the baseline, so later commits on the base branch never count against the PR.
10
12
 
11
13
  ## Requirements
12
14
 
13
15
  - Next.js >= 16.2 with Turbopack. Turbopack is the default for `next build`.
14
16
  - Node.js >= 22.12.
15
17
 
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.
18
+ The stats file is written on every Turbopack production build. No flag or env var is needed.
17
19
 
18
20
  ## Quick start
19
21
 
20
22
  ```bash
21
23
  # At the baseline commit
22
24
  npx next build
23
- npx route-bundle-size measure --app-dir . --out /tmp/baseline.json
25
+ npx route-bundle-size measure --out /tmp/baseline.json
24
26
 
25
27
  # At the commit you want to check
26
28
  npx next build
27
- npx route-bundle-size measure --app-dir . --out /tmp/current.json
29
+ npx route-bundle-size measure --out /tmp/current.json
28
30
 
29
31
  npx route-bundle-size diff --current /tmp/current.json --baseline /tmp/baseline.json
30
32
  ```
31
33
 
32
34
  In a monorepo, build the app and point `--app-dir` at it, for example `--app-dir apps/web`.
33
35
 
36
+ ## GitHub Actions
37
+
38
+ The action builds the PR head and its merge base in one job, compares them, and writes the result to the job summary. It posts no PR comment and needs only `contents: read`, so it works for PRs from forks.
39
+
40
+ ```yaml
41
+ name: Bundle size
42
+
43
+ on:
44
+ pull_request:
45
+
46
+ permissions:
47
+ contents: read
48
+
49
+ jobs:
50
+ bundle-size:
51
+ runs-on: ubuntu-latest
52
+ timeout-minutes: 30
53
+ steps:
54
+ - uses: actions/checkout@v6
55
+ with:
56
+ ref: ${{ github.event.pull_request.head.sha }}
57
+ fetch-depth: 0
58
+ persist-credentials: false
59
+
60
+ - uses: actions/setup-node@v6
61
+ with:
62
+ node-version: 22
63
+ cache: npm
64
+
65
+ - uses: bra31k/route-bundle-size@v0
66
+ ```
67
+
68
+ `fetch-depth: 0` is required, because the action needs the history to find the merge base. `ref` makes it build the PR head commit instead of the merge commit that GitHub creates.
69
+
70
+ Inputs, all optional:
71
+
72
+ - `app-dir`: the Next.js app directory. Default `.`.
73
+ - `install-command`: installs the dependencies. Default `npm ci`.
74
+ - `build-command`: builds the app. Default `npx next build`.
75
+ - `base`: the git ref to compare with. Default `origin/<base branch of the PR>`. Set it for events that are not pull requests, such as `push`.
76
+ - `package`: the npm spec of the CLI. Default: `route-bundle-size` at the action's own version.
77
+
78
+ Output:
79
+
80
+ - `report`: the path to the markdown report.
81
+
82
+ The action resolves the merge base of `base` and the head. It installs, builds and measures the head. Then it checks out the merge base with `git checkout --force`, installs, builds and measures it, and returns to the head. It runs `diff --format markdown` at the head, so the PR's config file is used.
83
+
84
+ Notes:
85
+
86
+ - pnpm, yarn or bun: set up the package manager before `setup-node`, and set `install-command`, for example `pnpm install --frozen-lockfile`. Make the `cache:` value of `setup-node` match your lockfile, or remove it.
87
+ - Monorepo: set `app-dir: apps/web`. Both commands run in `app-dir`, and the config file is read from there.
88
+ - The cost is a second build in each PR run.
89
+ - GitHub shows the job summary even when the step fails.
90
+ - Pin `@v0`, which moves with each 0.x release, or an exact `@v0.2.0`.
91
+
92
+ ### Without the action
93
+
94
+ For other CI systems or a custom setup, this workflow does the same steps by hand:
95
+
96
+ ```yaml
97
+ name: Bundle size
98
+
99
+ on:
100
+ pull_request:
101
+
102
+ permissions:
103
+ contents: read
104
+
105
+ jobs:
106
+ bundle-size:
107
+ runs-on: ubuntu-latest
108
+ timeout-minutes: 30
109
+ env:
110
+ ROUTE_BUNDLE_SIZE: route-bundle-size@0.2.0
111
+ steps:
112
+ - uses: actions/checkout@v6
113
+ with:
114
+ ref: ${{ github.event.pull_request.head.sha }}
115
+ fetch-depth: 0
116
+ persist-credentials: false
117
+
118
+ - uses: actions/setup-node@v6
119
+ with:
120
+ node-version: 22
121
+ cache: npm
122
+
123
+ # An empty sha would make "git checkout --detach" stay at HEAD,
124
+ # so both sides would measure the same build and always pass.
125
+ - name: Resolve merge base
126
+ id: merge_base
127
+ run: |
128
+ sha=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
129
+ if [ -z "$sha" ]; then
130
+ echo "::error::Could not resolve a merge base with origin/${{ github.base_ref }}."
131
+ exit 1
132
+ fi
133
+ echo "sha=$sha" >> "$GITHUB_OUTPUT"
134
+ echo "head=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
135
+
136
+ - name: Install dependencies
137
+ run: npm ci
138
+
139
+ - name: Build current
140
+ run: npx next build
141
+
142
+ - name: Measure current
143
+ run: npx --yes "$ROUTE_BUNDLE_SIZE" measure --out "$RUNNER_TEMP/current.json"
144
+
145
+ - name: Check out merge base
146
+ run: git checkout --force --detach ${{ steps.merge_base.outputs.sha }}
147
+
148
+ - name: Install dependencies at merge base
149
+ run: npm ci
150
+
151
+ - name: Build baseline
152
+ run: npx next build
153
+
154
+ - name: Return to PR head
155
+ run: git checkout --force --detach ${{ steps.merge_base.outputs.head }}
156
+
157
+ # .next is gitignored, so the baseline build survives the checkout.
158
+ # Running diff at the PR head uses the PR's config file.
159
+ - name: Measure baseline
160
+ run: npx --yes "$ROUTE_BUNDLE_SIZE" measure --out "$RUNNER_TEMP/baseline.json"
161
+
162
+ - name: Compare
163
+ run: |
164
+ npx --yes "$ROUTE_BUNDLE_SIZE" diff \
165
+ --current "$RUNNER_TEMP/current.json" \
166
+ --baseline "$RUNNER_TEMP/baseline.json" \
167
+ --format markdown
168
+ ```
169
+
170
+ Notes:
171
+
172
+ - The baseline is the merge base, not the tip of the base branch, so commits on the base branch that the PR does not contain never count against it.
173
+ - Pin the tool version in one place, as `ROUTE_BUNDLE_SIZE` does here. Both snapshots are then made by the same version.
174
+ - 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.
175
+ - If the merge-base build fails, the job fails. It does not pass quietly.
176
+ - On other CI systems `GITHUB_STEP_SUMMARY` is not set, so `--format markdown` prints the report to stdout.
177
+
34
178
  ## Commands
35
179
 
180
+ `npx route-bundle-size --version` prints the installed version.
181
+
36
182
  ### `measure`
37
183
 
38
184
  ```bash
39
185
  npx route-bundle-size measure \
40
- --app-dir <dir> \
41
186
  --out <file> \
187
+ [--app-dir <dir>] \
42
188
  [--stats <file>]
43
189
  ```
44
190
 
45
- - `--app-dir` (required): the app root. Chunk paths in the stats file resolve against it.
46
191
  - `--out` (required): where to write the snapshot JSON.
192
+ - `--app-dir`: the app root. Chunk paths in the stats file resolve against it. Default `.`.
47
193
  - `--stats`: path to the stats file. Default: `<app-dir>/.next/diagnostics/route-bundle-stats.json`.
48
194
 
49
195
  `measure` gzips each unique chunk once, then sums the chunks of each route. It writes a snapshot:
@@ -70,14 +216,14 @@ npx route-bundle-size diff \
70
216
  ```
71
217
 
72
218
  - `--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.
219
+ - `--baseline`: the snapshot to compare with. Without it, `diff` shows only the current sizes (see [Output](#output)). If the file does not exist, `diff` prints a warning and works as if no baseline was given.
74
220
  - `--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
221
  - `--config`: path to the config file. Default: `route-bundle-size.config.json` in the current directory, if it exists.
76
222
  - `--no-exclude`: ignore the `exclude` patterns from the config.
77
223
 
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"`.
224
+ 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"`. If an input has no routes left after `exclude`, `diff` prints a warning.
79
225
 
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.
226
+ `--no-exclude` helps when you look for shared-chunk growth. Pages-router `/api/*` entries carry only the `_app` chunks, so they all grow by the same amount when a shared chunk grows. This is also why the example config below excludes `/api/**`.
81
227
 
82
228
  ## Config
83
229
 
@@ -89,7 +235,12 @@ Both inputs can be a `measure` snapshot or a raw `route-bundle-stats.json`. The
89
235
  "budget": {
90
236
  "metric": "compressed",
91
237
  "maxIncrease": "30kb",
92
- "maxIncreasePercent": 10
238
+ "maxIncreasePercent": 10,
239
+ "maxSize": "300kb",
240
+ "overrides": [
241
+ { "match": "/dashboard", "maxSize": "450kb" },
242
+ { "match": "/blog/**", "maxIncrease": "10kb", "maxIncreasePercent": 5 }
243
+ ]
93
244
  }
94
245
  }
95
246
  ```
@@ -98,189 +249,110 @@ Both inputs can be a `measure` snapshot or a raw `route-bundle-stats.json`. The
98
249
  - `budget.metric`: `"compressed"` (gzip, default) or `"raw"` (uncompressed bytes from the stats file).
99
250
  - `budget.maxIncrease`: a byte count, or a string such as `"30kb"`, `"1.5mb"`, `"512b"`. Default `"30kb"`.
100
251
  - `budget.maxIncreasePercent`: a non-negative number. Default `10`.
252
+ - `budget.maxSize`: the largest size a route may have, in the same format as `maxIncrease`. Not set by default.
253
+ - `budget.overrides`: limits for specific routes. Default `[]`.
254
+
255
+ A route fails when its size grows by at least `maxIncrease` bytes or at least `maxIncreasePercent` percent, whichever comes first. `maxIncreasePercent: 0` means any growth fails. To rely on one threshold only, set the other one high.
256
+
257
+ With `maxSize` set, a route also fails when its size is above `maxSize`. This check needs no baseline, so it also applies to new routes and to a run without `--baseline`.
258
+
259
+ Each entry in `overrides` has a `match` glob and any of `maxIncrease`, `maxIncreasePercent` and `maxSize`; keys that the entry leaves out keep the value from `budget`. A route uses the first entry whose `match` fits it, so put specific routes such as `/blog/[slug]` above broad globs such as `/blog/**`. `metric` is the same for all routes. `diff` prints a warning for an entry that matches no route.
101
260
 
102
- A route fails when its size grows by at least `maxIncrease` bytes or at least `maxIncreasePercent` percent, whichever comes first.
261
+ Square brackets are literal, so `/blog/[slug]` matches only the dynamic route `/blog/[slug]`.
103
262
 
104
- Units in the config use 1024: `"30kb"` is 30,720 bytes. Output uses 1000-byte kB, so `"30kb"` prints as `30.7 kB`.
263
+ Sizes use 1024-byte units: `"30kb"` is 30,720 bytes and the report prints it as `30 KiB`. `kib` and `mib` are accepted as the same units.
105
264
 
106
265
  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
266
 
108
267
  ## Output
109
268
 
269
+ The samples below use the example config above.
270
+
110
271
  Terminal (`--format terminal`, the default):
111
272
 
112
273
  ```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
274
+ ┌──────────────┬──────────┬────────────┬───────────────────────┬──────────┐
275
+ │ Route │ Baseline │ Current │ Δ bytes │ Δ chunks │
276
+ ├──────────────┼──────────┼────────────┼───────────────────────┼──────────┤
277
+ │ /onboarding │ (new) │ 318 KiB 🔴 │ +318 KiB (new) │ +5 │
278
+ │ /dashboard │ 255 KiB │ 292 KiB │ +37.1 KiB (+14.5%) 🔴 │ +1 │
279
+ │ /settings │ 185 KiB │ (removed) │ -185 KiB │ -6 │
280
+ │ /pricing │ 166 KiB │ 148 KiB │ -17.9 KiB (-10.8%) 🟢 │ -1 │
281
+ │ / │ 161 KiB │ 162 KiB │ +470 B (+0.3%) │ — │
282
+ │ /blog/[slug] │ 157 KiB │ 157 KiB │ — │ — │
283
+ │ /_not-found │ 138 KiB │ 138 KiB │ — │ — │
284
+ └──────────────┴──────────┴────────────┴───────────────────────┴──────────┘
285
+ Avg per route: 177 KiB → 203 KiB
286
+ 🔴 grew by at least 30 KiB or 10%, or is larger than 300 KiB (compressed; some routes have their own limits) 🟢 notable improvement 7 route(s)
287
+ Bundle size budget exceeded for 2 route(s): /onboarding (max size 300 KiB), /dashboard (limit +30 KiB or +10%)
136
288
  ```
137
289
 
290
+ The last line goes to stderr. Under GitHub Actions it starts with `::error::`, and GitHub shows it as an annotation.
291
+
138
292
  Markdown (`--format markdown`):
139
293
 
140
294
  ```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 | — |
295
+ ## Bundle size
296
+
297
+ | Route | First Load JS | Δ |
298
+ | -------------- | ------------- | --------------------- |
299
+ | `/onboarding` | 318 KiB 🔴 | +318 KiB (new) |
300
+ | `/dashboard` | 292 KiB | +37.1 KiB (+14.5%) 🔴 |
301
+ | `/settings` | — (removed) | -185 KiB |
302
+ | `/pricing` | 148 KiB | -17.9 KiB (-10.8%) 🟢 |
303
+ | `/` | 162 KiB | +470 B (+0.3%) |
304
+ | `/blog/[slug]` | 157 KiB | — |
305
+ | `/_not-found` | 138 KiB | — |
152
306
 
153
307
  ### Summary
154
308
 
155
309
  - **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)
310
+ - **Avg per route:** 177 KiB → 203 KiB
159
311
 
160
- 🔴 exceeds `+30.7 kB` OR `+10%` compressed · 🟢 notable improvement (≥30.7 kB OR ≥10% smaller)
312
+ 🔴 grew by at least 30 KiB or 10%, or is larger than 300 KiB (compressed; some routes have their own limits) · 🟢 notable improvement
161
313
  ```
162
314
 
163
315
  How to read it:
164
316
 
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.
317
+ - Sizes and deltas use the budget metric. Failing routes come first, then the rest by the size of the change.
318
+ - 🔴 on the Δ cell means the route grew past `maxIncrease` or `maxIncreasePercent`. 🔴 on the size cell means the route is larger than its `maxSize`.
319
+ - 🟢 marks a route that shrank by at least the budget.
320
+ - A change under 250 B shows as `—`, unless the route failed the growth rule.
321
+ - New routes are marked `(new)`. They never fail the growth rule, but they can fail `maxSize`, as `/onboarding` does here. Removed routes never fail.
170
322
 
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:
323
+ Without a baseline, `diff` prints the current routes only and checks only `maxSize`. Sizes use the budget metric, or raw bytes when the input is a raw stats file. This table goes to the step summary or stdout for both formats:
172
324
 
173
325
  ```markdown
174
- ## 📦 Bundle size
326
+ ## Bundle size
175
327
 
176
- Baseline not yet established. 6 routes captured.
328
+ Baseline not yet established. 6 route(s) captured. Sizes are compressed.
177
329
 
178
330
  | Route | First Load JS |
179
331
  | -------------- | ------------- |
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 |
332
+ | `/onboarding` | 318 KiB 🔴 |
333
+ | `/dashboard` | 292 KiB |
334
+ | `/` | 162 KiB |
335
+ | `/blog/[slug]` | 157 KiB |
336
+ | `/pricing` | 148 KiB |
337
+ | `/_not-found` | 138 KiB |
338
+
339
+ 🔴 larger than its max size
186
340
  ```
187
341
 
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"
342
+ Here `/onboarding` is above `maxSize`, so this run exits `1`.
242
343
 
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:
344
+ ## Exit codes
269
345
 
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.
346
+ - `0`: no route failed. This includes a run without a baseline, or with a `--baseline` path that does not exist, unless a route is above `maxSize`.
347
+ - `1`: at least one route failed, or any error: bad config, unreadable input, missing chunk files.
276
348
 
277
349
  ## Limitations
278
350
 
279
351
  - 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
352
  - 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`.
353
+ - The default `--stats` path assumes the default `distDir` (`.next`). With a custom `distDir`, pass `--stats <app-dir>/<distDir>/diagnostics/route-bundle-stats.json`. The GitHub Action assumes the default.
282
354
  - Only first-load JS is measured.
283
- - One budget applies to every route. There are no per-route budgets.
355
+ - App-router route handlers (`route.ts`) and middleware/proxy are not in the stats file. Pages-router `/api/*` entries carry only the `_app` chunks.
284
356
 
285
357
  ## Development
286
358
 
@@ -289,7 +361,10 @@ npm test
289
361
  npm run types
290
362
  ```
291
363
 
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.
364
+ - `fixtures/sample-build/` is a small synthetic stats file. The CLI tests and the CI smoke run use it.
365
+ - `fixtures/next-app/` is a real Next.js app. `.github/workflows/action-test.yml` builds it to test the action: a run without changes must pass, and a run that adds a lodash import to the home page must fail.
366
+
367
+ To release, create a GitHub release with the tag `v<version in package.json>`. `release.yml` then publishes to npm with trusted publishing and moves the major tag (`v0`).
293
368
 
294
369
  ## License
295
370
 
package/dist/cli.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import * as fs from "node:fs";
3
+ import { createRequire } from "node:module";
3
4
  import { Command, Option } from "commander";
4
5
  import { loadConfig } from "./config.js";
5
- import { assertComparable, buildRows, buildSummary, FORMATS, loadSizes, renderBootstrap, renderMarkdown, renderTerminal, } from "./lib.js";
6
+ import { assertComparable, assertMaxSizeComparable, buildRows, buildSummary, findUnmatchedOverrides, FORMATS, formatFailure, loadSizes, renderBootstrap, renderMarkdown, renderTerminal, } from "./lib.js";
6
7
  import { defaultStatsFile, measure, writeSnapshot } from "./measure.js";
7
8
  const writeSummary = (markdown) => {
8
9
  const summaryPath = process.env.GITHUB_STEP_SUMMARY;
@@ -12,25 +13,59 @@ const writeSummary = (markdown) => {
12
13
  }
13
14
  console.log(markdown);
14
15
  };
16
+ const formatMessage = (level, message) => {
17
+ if (process.env.GITHUB_ACTIONS === "true") {
18
+ return `::${level}::${message}`;
19
+ }
20
+ return level === "warning" ? `Warning: ${message}` : message;
21
+ };
22
+ const loadInput = (filePath, exclude) => {
23
+ const loaded = loadSizes(filePath, exclude);
24
+ if (loaded.sizes.size === 0) {
25
+ console.error(formatMessage("warning", `No routes to check in ${filePath}. The file lists no routes, or "exclude" removes all of them.`));
26
+ }
27
+ return loaded;
28
+ };
29
+ const reportVerdict = (rows, anyExceeded, budget) => {
30
+ for (const match of findUnmatchedOverrides(rows, budget.overrides)) {
31
+ console.error(formatMessage("warning", `Override "${match}" matches no route.`));
32
+ }
33
+ if (anyExceeded) {
34
+ console.error(formatMessage("error", formatFailure(rows)));
35
+ process.exitCode = 1;
36
+ }
37
+ };
15
38
  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.")
39
+ .description("Compare route bundle sizes with a baseline and report the routes that grew past the budget.")
40
+ .requiredOption("--current <file>", "Snapshot or route-bundle-stats.json of the build to check")
41
+ .option("--baseline <file>", "Snapshot or route-bundle-stats.json to compare with. Without it, only the current sizes are shown")
42
+ .addOption(new Option("--format <format>", "Output format. 'terminal' prints a table to stdout. 'markdown' writes a GitHub-flavored table to $GITHUB_STEP_SUMMARY when it is set, otherwise to stdout")
20
43
  .choices([...FORMATS])
21
44
  .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.")
45
+ .option("--config <file>", "Path to the config file. Without it, route-bundle-size.config.json in the current directory is used if it exists")
46
+ .option("--no-exclude", 'Check every route and ignore the "exclude" patterns from the config')
24
47
  .action((options) => {
25
48
  const config = loadConfig({ configPath: options.config });
26
49
  const exclude = options.exclude ? config.exclude : [];
27
50
  const budget = config.budget;
28
- const current = loadSizes(options.current, exclude);
51
+ const current = loadInput(options.current, exclude);
29
52
  if (options.baseline === undefined || !fs.existsSync(options.baseline)) {
30
- writeSummary(renderBootstrap(current.sizes));
53
+ if (options.baseline !== undefined) {
54
+ console.error(formatMessage("warning", `Baseline file not found: ${options.baseline}. Nothing was compared, showing current sizes only.`));
55
+ }
56
+ assertMaxSizeComparable(current, budget);
57
+ const metric = budget.metric === "compressed" && !current.compressed
58
+ ? "raw"
59
+ : budget.metric;
60
+ const { rows, anyExceeded } = buildRows(current.sizes, new Map(), {
61
+ ...budget,
62
+ metric,
63
+ });
64
+ writeSummary(renderBootstrap(rows, metric));
65
+ reportVerdict(rows, anyExceeded, budget);
31
66
  return;
32
67
  }
33
- const baseline = loadSizes(options.baseline, exclude);
68
+ const baseline = loadInput(options.baseline, exclude);
34
69
  assertComparable(current, baseline, budget.metric);
35
70
  const { rows, anyExceeded } = buildRows(current.sizes, baseline.sizes, budget);
36
71
  const summary = buildSummary(rows, budget.metric);
@@ -43,36 +78,48 @@ const diffCommand = new Command("diff")
43
78
  else {
44
79
  writeSummary(output);
45
80
  }
46
- if (anyExceeded) {
47
- console.error("::error::Bundle size regression — see step summary");
48
- process.exit(1);
49
- }
81
+ reportVerdict(rows, anyExceeded, budget);
50
82
  });
51
83
  const measureCommand = new Command("measure")
52
84
  .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)")
85
+ .option("--app-dir <dir>", "App root that the chunk paths in the stats file resolve against (the directory that contains .next)", ".")
54
86
  .option("--stats <file>", "Path to route-bundle-stats.json (default: <app-dir>/.next/diagnostics/route-bundle-stats.json)")
55
87
  .requiredOption("--out <file>", "Path to write the snapshot JSON to")
56
88
  .action((options) => {
57
- var _a;
58
- const statsFile = (_a = options.stats) !== null && _a !== void 0 ? _a : defaultStatsFile(options.appDir);
89
+ const statsFile = options.stats ?? defaultStatsFile(options.appDir);
59
90
  const { snapshot, uniqueChunkCount } = measure({
60
91
  appDir: options.appDir,
61
92
  statsFile,
62
93
  });
94
+ if (snapshot.routes.length === 0) {
95
+ console.error(formatMessage("warning", `${statsFile} lists no routes, so the snapshot is empty.`));
96
+ }
63
97
  writeSnapshot(options.out, snapshot);
64
98
  console.log(`Wrote ${options.out}: ${snapshot.routes.length} route(s), ${uniqueChunkCount} unique chunk(s)`);
65
99
  });
100
+ const { version } = createRequire(import.meta.url)("../package.json");
66
101
  const program = new Command();
67
102
  program
68
103
  .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.");
104
+ .description("Per-route bundle size budget for Next.js Turbopack builds: measure compressed sizes, diff against a baseline, exit 1 on regression.")
105
+ .version(version);
70
106
  program.addCommand(diffCommand, { isDefault: true });
71
107
  program.addCommand(measureCommand);
72
- try {
73
- program.parse();
108
+ const commandNames = program.commands.map((command) => command.name());
109
+ const firstArg = process.argv[2];
110
+ if (firstArg !== undefined &&
111
+ !firstArg.startsWith("-") &&
112
+ !commandNames.includes(firstArg) &&
113
+ firstArg !== "help") {
114
+ console.error(`error: unknown command '${firstArg}'. Commands: ${commandNames.join(", ")}.`);
115
+ process.exitCode = 1;
74
116
  }
75
- catch (error) {
76
- console.error(error instanceof Error ? error.message : String(error));
77
- process.exit(1);
117
+ else {
118
+ try {
119
+ program.parse();
120
+ }
121
+ catch (error) {
122
+ console.error(formatMessage("error", error instanceof Error ? error.message : String(error)));
123
+ process.exitCode = 1;
124
+ }
78
125
  }
package/dist/config.js CHANGED
@@ -8,17 +8,24 @@ export const DEFAULT_CONFIG = {
8
8
  metric: "compressed",
9
9
  maxIncrease: 30 * 1024,
10
10
  maxIncreasePercent: 10,
11
+ maxSize: null,
12
+ overrides: [],
11
13
  },
12
14
  };
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
+ const BYTE_UNITS = {
16
+ b: 1,
17
+ kb: 1024,
18
+ kib: 1024,
19
+ mb: 1024 * 1024,
20
+ mib: 1024 * 1024,
21
+ };
22
+ const BYTE_SIZE_PATTERN = /^\s*(\d+(?:\.\d+)?)\s*(b|kb|kib|mb|mib)?\s*$/i;
15
23
  export const parseByteSize = (value, field) => {
16
- var _a;
17
24
  const numeric = typeof value === "number" ? value : Number.NaN;
18
25
  const match = typeof value === "string" ? BYTE_SIZE_PATTERN.exec(value) : null;
19
26
  const rounded = Math.round(match === null
20
27
  ? numeric
21
- : Number(match[1]) * BYTE_UNITS[((_a = match[2]) !== null && _a !== void 0 ? _a : "b").toLowerCase()]);
28
+ : Number(match[1]) * BYTE_UNITS[(match[2] ?? "b").toLowerCase()]);
22
29
  if (!Number.isSafeInteger(rounded) || rounded < 0) {
23
30
  throw new Error(`Invalid ${field}: ${JSON.stringify(value)}. Expected a byte count or a size string like "30kb", "1.5mb", "512b".`);
24
31
  }
@@ -28,7 +35,7 @@ const asObject = (value, label) => {
28
35
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
29
36
  throw new Error(`Invalid ${label}: expected a JSON object.`);
30
37
  }
31
- return Object.assign({}, value);
38
+ return { ...value };
32
39
  };
33
40
  const assertKnownKeys = (value, validKeys, where) => {
34
41
  for (const key of Object.keys(value)) {
@@ -37,10 +44,40 @@ const assertKnownKeys = (value, validKeys, where) => {
37
44
  }
38
45
  }
39
46
  };
47
+ const parsePercent = (value, field) => {
48
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
49
+ throw new Error(`Invalid ${field}: ${JSON.stringify(value)}. Expected a non-negative number.`);
50
+ }
51
+ return value;
52
+ };
53
+ const OVERRIDE_KEYS = ["match", "maxIncrease", "maxIncreasePercent", "maxSize"];
54
+ const parseOverride = (value, index, source) => {
55
+ const label = (key) => `"budget.overrides[${index}]${key}" in ${source}`;
56
+ const raw = asObject(value, label(""));
57
+ assertKnownKeys(raw, OVERRIDE_KEYS, label(""));
58
+ if (typeof raw.match !== "string" || raw.match === "") {
59
+ throw new Error(`Invalid ${label(".match")}: ${JSON.stringify(raw.match)}. Expected a glob pattern.`);
60
+ }
61
+ const override = { match: raw.match };
62
+ if (raw.maxIncrease !== undefined) {
63
+ override.maxIncrease = parseByteSize(raw.maxIncrease, label(".maxIncrease"));
64
+ }
65
+ if (raw.maxIncreasePercent !== undefined) {
66
+ override.maxIncreasePercent = parsePercent(raw.maxIncreasePercent, label(".maxIncreasePercent"));
67
+ }
68
+ if (raw.maxSize !== undefined) {
69
+ override.maxSize = parseByteSize(raw.maxSize, label(".maxSize"));
70
+ }
71
+ return override;
72
+ };
40
73
  export const parseConfig = (value, source) => {
41
74
  const raw = asObject(value, `config in ${source}`);
42
75
  assertKnownKeys(raw, Object.keys(DEFAULT_CONFIG), source);
43
- const { exclude, budget: budgetValue } = Object.assign(Object.assign({}, DEFAULT_CONFIG), raw);
76
+ const { exclude, budget: budgetValue } = {
77
+ ...DEFAULT_CONFIG,
78
+ budget: {},
79
+ ...raw,
80
+ };
44
81
  if (!Array.isArray(exclude) ||
45
82
  !exclude.every((item) => typeof item === "string")) {
46
83
  throw new Error(`Invalid "exclude" in ${source}: expected an array of glob patterns.`);
@@ -48,23 +85,28 @@ export const parseConfig = (value, source) => {
48
85
  const budgetSource = `"budget" in ${source}`;
49
86
  const budgetRaw = asObject(budgetValue, budgetSource);
50
87
  assertKnownKeys(budgetRaw, Object.keys(DEFAULT_CONFIG.budget), budgetSource);
51
- const { metric: metricValue, maxIncrease: maxIncreaseValue, maxIncreasePercent, } = Object.assign(Object.assign({}, DEFAULT_CONFIG.budget), budgetRaw);
88
+ const { metric: metricValue, maxIncrease: maxIncreaseValue, maxIncreasePercent: maxIncreasePercentValue, overrides: overridesValue, } = { ...DEFAULT_CONFIG.budget, ...budgetRaw };
52
89
  const metric = METRICS.find((known) => known === metricValue);
53
90
  if (metric === undefined) {
54
91
  throw new Error(`Invalid "budget.metric" in ${source}: ${JSON.stringify(metricValue)}. Valid values: ${METRICS.join(", ")}.`);
55
92
  }
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
- }
93
+ const maxIncreasePercent = parsePercent(maxIncreasePercentValue, `"budget.maxIncreasePercent" in ${source}`);
61
94
  const maxIncrease = parseByteSize(maxIncreaseValue, `"budget.maxIncrease" in ${source}`);
62
- return { exclude, budget: { metric, maxIncrease, maxIncreasePercent } };
95
+ const maxSize = budgetRaw.maxSize === undefined
96
+ ? null
97
+ : parseByteSize(budgetRaw.maxSize, `"budget.maxSize" in ${source}`);
98
+ if (!Array.isArray(overridesValue)) {
99
+ throw new Error(`Invalid "budget.overrides" in ${source}: expected an array of objects.`);
100
+ }
101
+ const overrides = overridesValue.map((override, index) => parseOverride(override, index, source));
102
+ return {
103
+ exclude,
104
+ budget: { metric, maxIncrease, maxIncreasePercent, maxSize, overrides },
105
+ };
63
106
  };
64
107
  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);
108
+ const cwd = options.cwd ?? process.cwd();
109
+ const filePath = path.resolve(cwd, options.configPath ?? CONFIG_FILE_NAME);
68
110
  if (!fs.existsSync(filePath)) {
69
111
  if (options.configPath === undefined) {
70
112
  return DEFAULT_CONFIG;
package/dist/lib.js CHANGED
@@ -5,11 +5,13 @@ import picomatch from "picomatch";
5
5
  import prettyBytes from "pretty-bytes";
6
6
  import { parseRawStats, parseSnapshot, readJsonFile } from "./stats.js";
7
7
  export const FORMATS = ["markdown", "terminal"];
8
- export const MIN_DISPLAY = 250;
9
- const routeMatcher = (exclude) => picomatch(exclude, { dot: true });
8
+ const MIN_DISPLAY = 250;
9
+ const MAX_LISTED_ROUTES = 5;
10
+ export const formatBytes = (bytes) => prettyBytes(bytes, { binary: true });
11
+ const escapeBrackets = (pattern) => pattern.replace(/[[\]]/g, "\\$&");
12
+ const routeMatcher = (exclude) => picomatch(exclude.map(escapeBrackets), { dot: true });
10
13
  export const isExcluded = (route, exclude) => routeMatcher(exclude)(route);
11
14
  export const loadSizes = (filePath, exclude) => {
12
- var _a, _b;
13
15
  if (!fs.existsSync(filePath)) {
14
16
  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
17
  }
@@ -23,7 +25,7 @@ export const loadSizes = (filePath, exclude) => {
23
25
  }
24
26
  sizes.set(entry.route, {
25
27
  bytes: entry.firstLoadUncompressedJsBytes,
26
- chunks: (_b = (_a = entry.firstLoadChunkPaths) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0,
28
+ chunks: entry.firstLoadChunkPaths?.length ?? 0,
27
29
  compressedBytes: null,
28
30
  });
29
31
  }
@@ -44,40 +46,61 @@ export const loadSizes = (filePath, exclude) => {
44
46
  }
45
47
  return { compressed: true, sizes };
46
48
  };
47
- export const hasCompressedData = (loaded) => loaded.compressed;
49
+ const noCompressedDataError = (sides) => new Error(`Budget metric is "compressed" but ${sides.join(" and ")} ${sides.length > 1 ? "have" : "has"} no compressed data. Produce snapshots with the "measure" command, or set "budget.metric" to "raw" in the config.`);
48
50
  export const assertComparable = (current, baseline, metric) => {
49
51
  if (metric !== "compressed") {
50
52
  return;
51
53
  }
52
54
  const uncompressed = [
53
- hasCompressedData(current) ? null : "current",
54
- hasCompressedData(baseline) ? null : "baseline",
55
+ current.compressed ? null : "current",
56
+ baseline.compressed ? null : "baseline",
55
57
  ].filter((side) => side !== null);
56
58
  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.`);
59
+ throw noCompressedDataError(uncompressed);
58
60
  }
59
61
  };
60
- const metricBytes = (stats, metric) => { var _a; return metric === "compressed" ? ((_a = stats.compressedBytes) !== null && _a !== void 0 ? _a : 0) : stats.bytes; };
62
+ export const assertMaxSizeComparable = (current, budget) => {
63
+ const hasMaxSize = budget.maxSize !== null ||
64
+ budget.overrides.some((override) => override.maxSize !== undefined);
65
+ if (hasMaxSize && budget.metric === "compressed" && !current.compressed) {
66
+ throw noCompressedDataError(["current"]);
67
+ }
68
+ };
69
+ const metricBytes = (stats, metric) => metric === "compressed" ? (stats.compressedBytes ?? 0) : stats.bytes;
70
+ const isFailing = (row) => row.exceeded || row.overMaxSize;
61
71
  export const buildRows = (current, baseline, budget) => {
62
- var _a, _b, _c, _d;
63
72
  const allRoutes = new Set([...current.keys(), ...baseline.keys()]);
64
73
  const rows = [];
74
+ const globalLimits = {
75
+ maxIncrease: budget.maxIncrease,
76
+ maxIncreasePercent: budget.maxIncreasePercent,
77
+ maxSize: budget.maxSize,
78
+ };
79
+ const overrides = budget.overrides.map(({ match, ...limits }) => ({
80
+ matches: routeMatcher([match]),
81
+ limits,
82
+ }));
65
83
  let anyExceeded = false;
66
84
  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;
85
+ const cur = current.get(route) ?? null;
86
+ const base = baseline.get(route) ?? null;
69
87
  const currentBytes = cur === null ? 0 : metricBytes(cur, budget.metric);
70
88
  const baselineBytes = base === null ? 0 : metricBytes(base, budget.metric);
71
89
  const delta = currentBytes - baselineBytes;
72
90
  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);
91
+ const chunkDelta = (cur?.chunks ?? 0) - (base?.chunks ?? 0);
92
+ const limits = {
93
+ ...globalLimits,
94
+ ...overrides.find((override) => override.matches(route))?.limits,
95
+ };
74
96
  // New routes (no baseline) never exceeded — a fresh page legitimately ships
75
97
  // its full size as "delta", which would otherwise always trip the abs rule.
76
98
  const exceeded = base !== null &&
77
99
  delta > 0 &&
78
- (delta >= budget.maxIncrease ||
79
- (deltaPercent !== null && deltaPercent >= budget.maxIncreasePercent));
80
- if (exceeded) {
100
+ (delta >= limits.maxIncrease ||
101
+ (deltaPercent !== null && deltaPercent >= limits.maxIncreasePercent));
102
+ const overMaxSize = cur !== null && limits.maxSize !== null && currentBytes > limits.maxSize;
103
+ if (exceeded || overMaxSize) {
81
104
  anyExceeded = true;
82
105
  }
83
106
  rows.push({
@@ -87,101 +110,119 @@ export const buildRows = (current, baseline, budget) => {
87
110
  delta,
88
111
  deltaPercent,
89
112
  chunkDelta,
113
+ limits,
90
114
  exceeded,
115
+ overMaxSize,
91
116
  });
92
117
  }
93
- rows.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
118
+ rows.sort((a, b) => Number(isFailing(b)) - Number(isFailing(a)) ||
119
+ Math.abs(b.delta) - Math.abs(a.delta));
94
120
  return { rows, anyExceeded };
95
121
  };
122
+ export const findUnmatchedOverrides = (rows, overrides) => overrides
123
+ .map((override) => override.match)
124
+ .filter((match) => {
125
+ const matches = routeMatcher([match]);
126
+ return !rows.some((row) => matches(row.route));
127
+ });
96
128
  export const buildSummary = (rows, metric) => {
97
129
  let totalCurrent = 0;
98
130
  let totalBaseline = 0;
99
- let chunksCurrent = 0;
100
- let chunksBaseline = 0;
101
131
  let routesCurrent = 0;
102
132
  let routesBaseline = 0;
103
133
  for (const row of rows) {
104
134
  if (row.current !== null) {
105
135
  totalCurrent += metricBytes(row.current, metric);
106
- chunksCurrent += row.current.chunks;
107
136
  routesCurrent += 1;
108
137
  }
109
138
  if (row.baseline !== null) {
110
139
  totalBaseline += metricBytes(row.baseline, metric);
111
- chunksBaseline += row.baseline.chunks;
112
140
  routesBaseline += 1;
113
141
  }
114
142
  }
115
- const totalDelta = totalCurrent - totalBaseline;
116
- const totalDeltaPercent = totalBaseline > 0 ? (totalDelta / totalBaseline) * 100 : null;
117
143
  return {
118
144
  totalRoutes: rows.length,
119
145
  routesCurrent,
120
146
  routesBaseline,
121
- totalCurrent,
122
- totalBaseline,
123
- totalDelta,
124
- totalDeltaPercent,
125
147
  avgCurrent: routesCurrent > 0 ? totalCurrent / routesCurrent : 0,
126
148
  avgBaseline: routesBaseline > 0 ? totalBaseline / routesBaseline : 0,
127
- chunksCurrent,
128
- chunksBaseline,
129
149
  };
130
150
  };
131
- export const isNotableShrink = (row, budget) => row.delta < 0 &&
132
- (row.delta <= -budget.maxIncrease ||
151
+ export const isNotableShrink = (row) => row.current !== null &&
152
+ row.delta < 0 &&
153
+ (row.delta <= -row.limits.maxIncrease ||
133
154
  (row.deltaPercent !== null &&
134
- row.deltaPercent <= -budget.maxIncreasePercent));
135
- export const formatDelta = (row, budget) => {
155
+ row.deltaPercent <= -row.limits.maxIncreasePercent));
156
+ export const formatDelta = (row) => {
136
157
  // Sub-threshold deltas (incl. zero) are visual noise — show as "no change".
137
- if (Math.abs(row.delta) < MIN_DISPLAY) {
158
+ if (!row.exceeded && Math.abs(row.delta) < MIN_DISPLAY) {
138
159
  return "—";
139
160
  }
140
161
  const sign = row.delta > 0 ? "+" : "";
141
- const marker = row.exceeded
142
- ? " 🔴"
143
- : isNotableShrink(row, budget)
144
- ? " 🟢"
145
- : "";
162
+ const marker = row.exceeded ? " 🔴" : isNotableShrink(row) ? " 🟢" : "";
146
163
  // Removed: the size column already says "(removed)"; -100% is noise.
147
164
  if (row.current === null) {
148
- return `${sign}${prettyBytes(row.delta)}${marker}`;
165
+ return `${sign}${formatBytes(row.delta)}${marker}`;
149
166
  }
150
167
  const percentLabel = row.deltaPercent === null
151
168
  ? "new"
152
169
  : `${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)}`;
170
+ return `${sign}${formatBytes(row.delta)} (${percentLabel})${marker}`;
160
171
  };
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) => {
172
+ const formatSignedInt = (n) => {
168
173
  if (n === 0)
169
174
  return "—";
170
175
  return n > 0 ? `+${n}` : `${n}`;
171
176
  };
172
- const budgetLegendSuffix = (budget) => budget.metric === "compressed" ? " compressed" : "";
177
+ const formatCurrent = (row, metric, removedLabel) => {
178
+ if (row.current === null) {
179
+ return removedLabel;
180
+ }
181
+ const marker = row.overMaxSize ? " 🔴" : "";
182
+ return `${formatBytes(metricBytes(row.current, metric))}${marker}`;
183
+ };
184
+ const budgetLegend = (budget) => {
185
+ const maxSize = budget.maxSize === null
186
+ ? ""
187
+ : `, or is larger than ${formatBytes(budget.maxSize)}`;
188
+ const notes = [
189
+ budget.metric === "compressed" ? "compressed" : null,
190
+ budget.overrides.length > 0 ? "some routes have their own limits" : null,
191
+ ].filter((note) => note !== null);
192
+ const suffix = notes.length > 0 ? ` (${notes.join("; ")})` : "";
193
+ return `🔴 grew by at least ${formatBytes(budget.maxIncrease)} or ${budget.maxIncreasePercent}%${maxSize}${suffix}`;
194
+ };
195
+ const brokenLimits = ({ exceeded, overMaxSize, limits }) => {
196
+ const broken = [];
197
+ if (exceeded) {
198
+ broken.push(`limit +${formatBytes(limits.maxIncrease)} or +${limits.maxIncreasePercent}%`);
199
+ }
200
+ if (overMaxSize && limits.maxSize !== null) {
201
+ broken.push(`max size ${formatBytes(limits.maxSize)}`);
202
+ }
203
+ return broken.join(", ");
204
+ };
205
+ export const formatFailure = (rows) => {
206
+ const failing = rows.filter(isFailing);
207
+ const listed = failing
208
+ .slice(0, MAX_LISTED_ROUTES)
209
+ .map((row) => `${row.route} (${brokenLimits(row)})`)
210
+ .join(", ");
211
+ const more = failing.length > MAX_LISTED_ROUTES
212
+ ? ` (and ${failing.length - MAX_LISTED_ROUTES} more)`
213
+ : "";
214
+ return `Bundle size budget exceeded for ${failing.length} route(s): ${listed}${more}`;
215
+ };
173
216
  export const renderMarkdown = (rows, summary, budget) => {
174
217
  if (rows.length === 0) {
175
- return "## 📦 Bundle size\n\nNo client routes detected.";
218
+ return "## Bundle size\n\nNo client routes detected.";
176
219
  }
177
220
  const table = markdownTable([
178
221
  ["Route", "First Load JS", "Δ"],
179
222
  ...rows.map((r) => [
180
223
  `\`${r.route}\``,
181
- r.current === null
182
- ? "— (removed)"
183
- : prettyBytes(metricBytes(r.current, budget.metric)),
184
- formatDelta(r, budget),
224
+ formatCurrent(r, budget.metric, "— (removed)"),
225
+ formatDelta(r),
185
226
  ]),
186
227
  ]);
187
228
  const summaryLines = [
@@ -189,76 +230,60 @@ export const renderMarkdown = (rows, summary, budget) => {
189
230
  "### Summary",
190
231
  "",
191
232
  `- **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)})`,
233
+ `- **Avg per route:** ${formatBytes(summary.avgBaseline)} → ${formatBytes(summary.avgCurrent)}`,
195
234
  ];
196
235
  return [
197
- "## 📦 Bundle size",
236
+ "## Bundle size",
198
237
  "",
199
238
  table,
200
239
  ...summaryLines,
201
240
  "",
202
- `🔴 exceeds \`+${prettyBytes(budget.maxIncrease)}\` OR \`+${budget.maxIncreasePercent}%\`${budgetLegendSuffix(budget)} · 🟢 notable improvement (≥${prettyBytes(budget.maxIncrease)} OR ≥${budget.maxIncreasePercent}% smaller)`,
241
+ `${budgetLegend(budget)} · 🟢 notable improvement`,
203
242
  ].join("\n");
204
243
  };
205
244
  export const renderTerminal = (rows, summary, budget) => {
206
245
  if (rows.length === 0) {
207
- return "📦 Bundle size: no client routes detected.";
246
+ return "Bundle size: no client routes detected.";
208
247
  }
209
248
  const table = new Table({
210
249
  head: ["Route", "Baseline", "Current", "Δ bytes", "Δ chunks"],
211
250
  colAligns: ["left", "right", "right", "right", "right"],
212
- style: { head: [], border: [] },
251
+ style: { head: [], border: [], compact: true },
213
252
  });
214
253
  for (const row of rows) {
215
254
  table.push([
216
255
  row.route,
217
256
  row.baseline === null
218
257
  ? "(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}`,
258
+ : formatBytes(metricBytes(row.baseline, budget.metric)),
259
+ formatCurrent(row, budget.metric, "(removed)"),
260
+ formatDelta(row),
261
+ formatSignedInt(row.chunkDelta),
227
262
  ]);
228
263
  }
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
264
  return [
244
265
  table.toString(),
245
- `🔴 exceeds +${prettyBytes(budget.maxIncrease)} OR +${budget.maxIncreasePercent}%${budgetLegendSuffix(budget)} 🟢 notable improvement ${summary.totalRoutes} route(s)`,
266
+ `Avg per route: ${formatBytes(summary.avgBaseline)} → ${formatBytes(summary.avgCurrent)}`,
267
+ `${budgetLegend(budget)} 🟢 notable improvement ${summary.totalRoutes} route(s)`,
246
268
  ].join("\n");
247
269
  };
248
- export const renderBootstrap = (sizes) => {
249
- const sorted = [...sizes.entries()].sort((a, b) => b[1].bytes - a[1].bytes);
270
+ export const renderBootstrap = (rows, metric) => {
250
271
  const table = markdownTable([
251
272
  ["Route", "First Load JS"],
252
- ...sorted.map(([route, stats]) => [
253
- `\`${route}\``,
254
- prettyBytes(stats.bytes),
273
+ ...rows.map((row) => [
274
+ `\`${row.route}\``,
275
+ formatCurrent(row, metric, "(removed)"),
255
276
  ]),
256
277
  ]);
278
+ const legend = rows.some((row) => row.overMaxSize)
279
+ ? ["", "🔴 larger than its max size"]
280
+ : [];
257
281
  return [
258
- "## 📦 Bundle size",
282
+ "## Bundle size",
259
283
  "",
260
- `Baseline not yet established. ${sizes.size} routes captured.`,
284
+ `Baseline not yet established. ${rows.length} route(s) captured. Sizes are ${metric}.`,
261
285
  "",
262
286
  table,
287
+ ...legend,
263
288
  ].join("\n");
264
289
  };
package/dist/measure.js CHANGED
@@ -1,21 +1,22 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import * as zlib from "node:zlib";
4
- import { readRawStats, SNAPSHOT_SCHEMA_VERSION } from "./stats.js";
4
+ import { isStringArray, readRawStats, SNAPSHOT_SCHEMA_VERSION, } from "./stats.js";
5
5
  export const defaultStatsFile = (appDir) => path.join(appDir, ".next", "diagnostics", "route-bundle-stats.json");
6
6
  const MISSING_CHUNKS_SHOWN = 5;
7
7
  export const measure = (options) => {
8
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
- });
9
+ const invalidChunkPathsIndex = entries.findIndex((entry) => !isStringArray(entry.firstLoadChunkPaths ?? []));
10
+ if (invalidChunkPathsIndex !== -1) {
11
+ throw new Error(`Invalid stats file ${options.statsFile}: entry ${invalidChunkPathsIndex} must have "firstLoadChunkPaths" as an array of strings.`);
12
+ }
13
+ const sizedWithoutChunks = entries.filter((entry) => entry.firstLoadUncompressedJsBytes > 0 &&
14
+ (entry.firstLoadChunkPaths ?? []).length === 0);
14
15
  if (sizedWithoutChunks.length > 0) {
15
16
  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
  }
17
18
  const uniqueChunks = [
18
- ...new Set(entries.flatMap((entry) => { var _a; return (_a = entry.firstLoadChunkPaths) !== null && _a !== void 0 ? _a : []; })),
19
+ ...new Set(entries.flatMap((entry) => entry.firstLoadChunkPaths ?? [])),
19
20
  ];
20
21
  const missing = uniqueChunks.filter((chunk) => !fs.existsSync(path.resolve(options.appDir, chunk)));
21
22
  if (missing.length > 0) {
@@ -33,12 +34,11 @@ export const measure = (options) => {
33
34
  const snapshot = {
34
35
  schemaVersion: SNAPSHOT_SCHEMA_VERSION,
35
36
  routes: entries.map((entry) => {
36
- var _a;
37
- const chunks = (_a = entry.firstLoadChunkPaths) !== null && _a !== void 0 ? _a : [];
37
+ const chunks = entry.firstLoadChunkPaths ?? [];
38
38
  return {
39
39
  route: entry.route,
40
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),
41
+ compressedBytes: chunks.reduce((total, chunk) => total + (compressedByChunk.get(chunk) ?? 0), 0),
42
42
  chunkPaths: chunks,
43
43
  };
44
44
  }),
package/dist/stats.js CHANGED
@@ -2,13 +2,13 @@ import * as fs from "node:fs";
2
2
  export const SNAPSHOT_SCHEMA_VERSION = 1;
3
3
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4
4
  const isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
5
- const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
5
+ export const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
6
6
  export const parseRawStats = (value, source) => {
7
7
  if (!Array.isArray(value)) {
8
8
  throw new Error(`Invalid stats file ${source}: expected a top-level array of route entries.`);
9
9
  }
10
10
  return value.map((entry, index) => {
11
- const { route, firstLoadUncompressedJsBytes, firstLoadChunkPaths } = (entry !== null && entry !== void 0 ? entry : {});
11
+ const { route, firstLoadUncompressedJsBytes, firstLoadChunkPaths } = (entry ?? {});
12
12
  if (typeof route !== "string" ||
13
13
  !isFiniteNumber(firstLoadUncompressedJsBytes)) {
14
14
  throw new Error(`Invalid stats file ${source}: entry ${index} must have a string "route" and a finite "firstLoadUncompressedJsBytes".`);
@@ -27,7 +27,7 @@ export const readJsonFile = (filePath) => {
27
27
  };
28
28
  export const readRawStats = (filePath) => {
29
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.`);
30
+ throw new Error(`Stats file not found: ${filePath}. Possible causes: --app-dir does not point to the app root; the app was built with webpack (next build --webpack) or with Next.js older than 16.2, which write no stats file; next.config sets a custom distDir, so pass --stats <app-dir>/<distDir>/diagnostics/route-bundle-stats.json.`);
31
31
  }
32
32
  return parseRawStats(readJsonFile(filePath), filePath);
33
33
  };
@@ -42,7 +42,8 @@ export const parseSnapshot = (value, source) => {
42
42
  throw new Error(`Invalid snapshot ${source}: "routes" must be an array.`);
43
43
  }
44
44
  const routes = value.routes.map((entry, index) => {
45
- const { route, rawBytes, compressedBytes, chunkPaths } = (entry !== null && entry !== void 0 ? entry : {});
45
+ const { route, rawBytes, compressedBytes, chunkPaths } = (entry ??
46
+ {});
46
47
  if (typeof route !== "string" ||
47
48
  !isFiniteNumber(rawBytes) ||
48
49
  !isStringArray(chunkPaths) ||
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "https://www.schemastore.org/package.json",
3
3
  "name": "route-bundle-size",
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.",
4
+ "version": "0.2.0",
5
+ "description": "Per-route bundle size budget for Next.js Turbopack builds: measure compressed sizes, diff against a baseline, exit 1 on regression.",
6
6
  "keywords": [
7
7
  "nextjs",
8
8
  "turbopack",
@@ -33,7 +33,7 @@
33
33
  "scripts": {
34
34
  "build": "rm -rf dist && tsc -p tsconfig.build.json",
35
35
  "prepack": "npm run build",
36
- "test": "NODE_OPTIONS=--experimental-vm-modules jest",
36
+ "test": "NODE_OPTIONS='--experimental-vm-modules --disable-warning=ExperimentalWarning' jest",
37
37
  "types": "tsc --noEmit"
38
38
  },
39
39
  "dependencies": {
@@ -45,10 +45,9 @@
45
45
  },
46
46
  "devDependencies": {
47
47
  "@jest/globals": "^30.4.1",
48
- "@types/node": "^25.6.0",
48
+ "@types/node": "^22.12.0",
49
49
  "@types/picomatch": "^4.0.3",
50
50
  "jest": "^30.4.2",
51
- "jest-environment-node": "^30.4.1",
52
51
  "ts-jest": "^29.4.6",
53
52
  "tsx": "^4.9.1",
54
53
  "typescript": "^6.0.3"