route-bundle-size 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +370 -2
- package/dist/cli.js +125 -0
- package/dist/config.js +124 -0
- package/dist/lib.js +289 -0
- package/dist/measure.js +51 -0
- package/dist/stats.js +57 -0
- package/package.json +53 -4
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,371 @@
|
|
|
1
|
-
#
|
|
1
|
+
# route-bundle-size
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Per-route first-load JS budget for Next.js Turbopack builds.
|
|
4
|
+
|
|
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.
|
|
8
|
+
|
|
9
|
+
- Sizes are per route, first-load JS only.
|
|
10
|
+
- The budget gates on gzip size by default. Raw bytes are also supported.
|
|
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.
|
|
12
|
+
|
|
13
|
+
## Requirements
|
|
14
|
+
|
|
15
|
+
- Next.js >= 16.2 with Turbopack. Turbopack is the default for `next build`.
|
|
16
|
+
- Node.js >= 22.12.
|
|
17
|
+
|
|
18
|
+
The stats file is written on every Turbopack production build. No flag or env var is needed.
|
|
19
|
+
|
|
20
|
+
## Quick start
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# At the baseline commit
|
|
24
|
+
npx next build
|
|
25
|
+
npx route-bundle-size measure --out /tmp/baseline.json
|
|
26
|
+
|
|
27
|
+
# At the commit you want to check
|
|
28
|
+
npx next build
|
|
29
|
+
npx route-bundle-size measure --out /tmp/current.json
|
|
30
|
+
|
|
31
|
+
npx route-bundle-size diff --current /tmp/current.json --baseline /tmp/baseline.json
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
In a monorepo, build the app and point `--app-dir` at it, for example `--app-dir apps/web`.
|
|
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
|
+
|
|
178
|
+
## Commands
|
|
179
|
+
|
|
180
|
+
`npx route-bundle-size --version` prints the installed version.
|
|
181
|
+
|
|
182
|
+
### `measure`
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
npx route-bundle-size measure \
|
|
186
|
+
--out <file> \
|
|
187
|
+
[--app-dir <dir>] \
|
|
188
|
+
[--stats <file>]
|
|
189
|
+
```
|
|
190
|
+
|
|
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 `.`.
|
|
193
|
+
- `--stats`: path to the stats file. Default: `<app-dir>/.next/diagnostics/route-bundle-stats.json`.
|
|
194
|
+
|
|
195
|
+
`measure` gzips each unique chunk once, then sums the chunks of each route. It writes a snapshot:
|
|
196
|
+
|
|
197
|
+
```json
|
|
198
|
+
{ "schemaVersion": 1, "routes": [{ "route": "/", "rawBytes": 65000, "compressedBytes": 49276, "chunkPaths": ["..."] }] }
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
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.
|
|
202
|
+
|
|
203
|
+
Run `measure` right after each build. The next build overwrites `.next`.
|
|
204
|
+
|
|
205
|
+
### `diff`
|
|
206
|
+
|
|
207
|
+
`diff` is the default command, so `npx route-bundle-size --current ...` works too.
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
npx route-bundle-size diff \
|
|
211
|
+
--current <file> \
|
|
212
|
+
[--baseline <file>] \
|
|
213
|
+
[--format terminal|markdown] \
|
|
214
|
+
[--config <file>] \
|
|
215
|
+
[--no-exclude]
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
- `--current` (required): the snapshot to check.
|
|
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.
|
|
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.
|
|
221
|
+
- `--config`: path to the config file. Default: `route-bundle-size.config.json` in the current directory, if it exists.
|
|
222
|
+
- `--no-exclude`: ignore the `exclude` patterns from the config.
|
|
223
|
+
|
|
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.
|
|
225
|
+
|
|
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/**`.
|
|
227
|
+
|
|
228
|
+
## Config
|
|
229
|
+
|
|
230
|
+
`route-bundle-size.config.json` in the current directory, or the file passed with `--config`. All keys are optional.
|
|
231
|
+
|
|
232
|
+
```json
|
|
233
|
+
{
|
|
234
|
+
"exclude": ["/api/**"],
|
|
235
|
+
"budget": {
|
|
236
|
+
"metric": "compressed",
|
|
237
|
+
"maxIncrease": "30kb",
|
|
238
|
+
"maxIncreasePercent": 10,
|
|
239
|
+
"maxSize": "300kb",
|
|
240
|
+
"overrides": [
|
|
241
|
+
{ "match": "/dashboard", "maxSize": "450kb" },
|
|
242
|
+
{ "match": "/blog/**", "maxIncrease": "10kb", "maxIncreasePercent": 5 }
|
|
243
|
+
]
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
- `exclude`: glob patterns ([picomatch](https://github.com/micromatch/picomatch)) matched against the route. Default `[]`.
|
|
249
|
+
- `budget.metric`: `"compressed"` (gzip, default) or `"raw"` (uncompressed bytes from the stats file).
|
|
250
|
+
- `budget.maxIncrease`: a byte count, or a string such as `"30kb"`, `"1.5mb"`, `"512b"`. Default `"30kb"`.
|
|
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.
|
|
260
|
+
|
|
261
|
+
Square brackets are literal, so `/blog/[slug]` matches only the dynamic route `/blog/[slug]`.
|
|
262
|
+
|
|
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.
|
|
264
|
+
|
|
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.
|
|
266
|
+
|
|
267
|
+
## Output
|
|
268
|
+
|
|
269
|
+
The samples below use the example config above.
|
|
270
|
+
|
|
271
|
+
Terminal (`--format terminal`, the default):
|
|
272
|
+
|
|
273
|
+
```text
|
|
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%)
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
The last line goes to stderr. Under GitHub Actions it starts with `::error::`, and GitHub shows it as an annotation.
|
|
291
|
+
|
|
292
|
+
Markdown (`--format markdown`):
|
|
293
|
+
|
|
294
|
+
```markdown
|
|
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 | — |
|
|
306
|
+
|
|
307
|
+
### Summary
|
|
308
|
+
|
|
309
|
+
- **Routes:** 7
|
|
310
|
+
- **Avg per route:** 177 KiB → 203 KiB
|
|
311
|
+
|
|
312
|
+
🔴 grew by at least 30 KiB or 10%, or is larger than 300 KiB (compressed; some routes have their own limits) · 🟢 notable improvement
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
How to read it:
|
|
316
|
+
|
|
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.
|
|
322
|
+
|
|
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:
|
|
324
|
+
|
|
325
|
+
```markdown
|
|
326
|
+
## Bundle size
|
|
327
|
+
|
|
328
|
+
Baseline not yet established. 6 route(s) captured. Sizes are compressed.
|
|
329
|
+
|
|
330
|
+
| Route | First Load JS |
|
|
331
|
+
| -------------- | ------------- |
|
|
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
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
Here `/onboarding` is above `maxSize`, so this run exits `1`.
|
|
343
|
+
|
|
344
|
+
## Exit codes
|
|
345
|
+
|
|
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.
|
|
348
|
+
|
|
349
|
+
## Limitations
|
|
350
|
+
|
|
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.
|
|
352
|
+
- Turbopack only. `next build --webpack` writes no stats file.
|
|
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.
|
|
354
|
+
- Only first-load JS is measured.
|
|
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.
|
|
356
|
+
|
|
357
|
+
## Development
|
|
358
|
+
|
|
359
|
+
```bash
|
|
360
|
+
npm test
|
|
361
|
+
npm run types
|
|
362
|
+
```
|
|
363
|
+
|
|
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`).
|
|
368
|
+
|
|
369
|
+
## License
|
|
370
|
+
|
|
371
|
+
MIT
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { Command, Option } from "commander";
|
|
5
|
+
import { loadConfig } from "./config.js";
|
|
6
|
+
import { assertComparable, assertMaxSizeComparable, buildRows, buildSummary, findUnmatchedOverrides, FORMATS, formatFailure, loadSizes, renderBootstrap, renderMarkdown, renderTerminal, } from "./lib.js";
|
|
7
|
+
import { defaultStatsFile, measure, writeSnapshot } from "./measure.js";
|
|
8
|
+
const writeSummary = (markdown) => {
|
|
9
|
+
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
|
|
10
|
+
if (summaryPath !== undefined) {
|
|
11
|
+
fs.appendFileSync(summaryPath, markdown + "\n");
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
console.log(markdown);
|
|
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
|
+
};
|
|
38
|
+
const diffCommand = new Command("diff")
|
|
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")
|
|
43
|
+
.choices([...FORMATS])
|
|
44
|
+
.default("terminal"))
|
|
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')
|
|
47
|
+
.action((options) => {
|
|
48
|
+
const config = loadConfig({ configPath: options.config });
|
|
49
|
+
const exclude = options.exclude ? config.exclude : [];
|
|
50
|
+
const budget = config.budget;
|
|
51
|
+
const current = loadInput(options.current, exclude);
|
|
52
|
+
if (options.baseline === undefined || !fs.existsSync(options.baseline)) {
|
|
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);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const baseline = loadInput(options.baseline, exclude);
|
|
69
|
+
assertComparable(current, baseline, budget.metric);
|
|
70
|
+
const { rows, anyExceeded } = buildRows(current.sizes, baseline.sizes, budget);
|
|
71
|
+
const summary = buildSummary(rows, budget.metric);
|
|
72
|
+
const output = options.format === "terminal"
|
|
73
|
+
? renderTerminal(rows, summary, budget)
|
|
74
|
+
: renderMarkdown(rows, summary, budget);
|
|
75
|
+
if (options.format === "terminal") {
|
|
76
|
+
console.log(output);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
writeSummary(output);
|
|
80
|
+
}
|
|
81
|
+
reportVerdict(rows, anyExceeded, budget);
|
|
82
|
+
});
|
|
83
|
+
const measureCommand = new Command("measure")
|
|
84
|
+
.description("Gzip every unique first-load chunk once and write a snapshot with raw + compressed bytes per route.")
|
|
85
|
+
.option("--app-dir <dir>", "App root that the chunk paths in the stats file resolve against (the directory that contains .next)", ".")
|
|
86
|
+
.option("--stats <file>", "Path to route-bundle-stats.json (default: <app-dir>/.next/diagnostics/route-bundle-stats.json)")
|
|
87
|
+
.requiredOption("--out <file>", "Path to write the snapshot JSON to")
|
|
88
|
+
.action((options) => {
|
|
89
|
+
const statsFile = options.stats ?? defaultStatsFile(options.appDir);
|
|
90
|
+
const { snapshot, uniqueChunkCount } = measure({
|
|
91
|
+
appDir: options.appDir,
|
|
92
|
+
statsFile,
|
|
93
|
+
});
|
|
94
|
+
if (snapshot.routes.length === 0) {
|
|
95
|
+
console.error(formatMessage("warning", `${statsFile} lists no routes, so the snapshot is empty.`));
|
|
96
|
+
}
|
|
97
|
+
writeSnapshot(options.out, snapshot);
|
|
98
|
+
console.log(`Wrote ${options.out}: ${snapshot.routes.length} route(s), ${uniqueChunkCount} unique chunk(s)`);
|
|
99
|
+
});
|
|
100
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
101
|
+
const program = new Command();
|
|
102
|
+
program
|
|
103
|
+
.name("route-bundle-size")
|
|
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);
|
|
106
|
+
program.addCommand(diffCommand, { isDefault: true });
|
|
107
|
+
program.addCommand(measureCommand);
|
|
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;
|
|
116
|
+
}
|
|
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
|
+
}
|
|
125
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
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
|
+
maxSize: null,
|
|
12
|
+
overrides: [],
|
|
13
|
+
},
|
|
14
|
+
};
|
|
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;
|
|
23
|
+
export const parseByteSize = (value, field) => {
|
|
24
|
+
const numeric = typeof value === "number" ? value : Number.NaN;
|
|
25
|
+
const match = typeof value === "string" ? BYTE_SIZE_PATTERN.exec(value) : null;
|
|
26
|
+
const rounded = Math.round(match === null
|
|
27
|
+
? numeric
|
|
28
|
+
: Number(match[1]) * BYTE_UNITS[(match[2] ?? "b").toLowerCase()]);
|
|
29
|
+
if (!Number.isSafeInteger(rounded) || rounded < 0) {
|
|
30
|
+
throw new Error(`Invalid ${field}: ${JSON.stringify(value)}. Expected a byte count or a size string like "30kb", "1.5mb", "512b".`);
|
|
31
|
+
}
|
|
32
|
+
return rounded;
|
|
33
|
+
};
|
|
34
|
+
const asObject = (value, label) => {
|
|
35
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
36
|
+
throw new Error(`Invalid ${label}: expected a JSON object.`);
|
|
37
|
+
}
|
|
38
|
+
return { ...value };
|
|
39
|
+
};
|
|
40
|
+
const assertKnownKeys = (value, validKeys, where) => {
|
|
41
|
+
for (const key of Object.keys(value)) {
|
|
42
|
+
if (!validKeys.includes(key)) {
|
|
43
|
+
throw new Error(`Unknown key "${key}" in ${where}. Valid keys: ${validKeys.join(", ")}.`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
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
|
+
};
|
|
73
|
+
export const parseConfig = (value, source) => {
|
|
74
|
+
const raw = asObject(value, `config in ${source}`);
|
|
75
|
+
assertKnownKeys(raw, Object.keys(DEFAULT_CONFIG), source);
|
|
76
|
+
const { exclude, budget: budgetValue } = {
|
|
77
|
+
...DEFAULT_CONFIG,
|
|
78
|
+
budget: {},
|
|
79
|
+
...raw,
|
|
80
|
+
};
|
|
81
|
+
if (!Array.isArray(exclude) ||
|
|
82
|
+
!exclude.every((item) => typeof item === "string")) {
|
|
83
|
+
throw new Error(`Invalid "exclude" in ${source}: expected an array of glob patterns.`);
|
|
84
|
+
}
|
|
85
|
+
const budgetSource = `"budget" in ${source}`;
|
|
86
|
+
const budgetRaw = asObject(budgetValue, budgetSource);
|
|
87
|
+
assertKnownKeys(budgetRaw, Object.keys(DEFAULT_CONFIG.budget), budgetSource);
|
|
88
|
+
const { metric: metricValue, maxIncrease: maxIncreaseValue, maxIncreasePercent: maxIncreasePercentValue, overrides: overridesValue, } = { ...DEFAULT_CONFIG.budget, ...budgetRaw };
|
|
89
|
+
const metric = METRICS.find((known) => known === metricValue);
|
|
90
|
+
if (metric === undefined) {
|
|
91
|
+
throw new Error(`Invalid "budget.metric" in ${source}: ${JSON.stringify(metricValue)}. Valid values: ${METRICS.join(", ")}.`);
|
|
92
|
+
}
|
|
93
|
+
const maxIncreasePercent = parsePercent(maxIncreasePercentValue, `"budget.maxIncreasePercent" in ${source}`);
|
|
94
|
+
const maxIncrease = parseByteSize(maxIncreaseValue, `"budget.maxIncrease" in ${source}`);
|
|
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
|
+
};
|
|
106
|
+
};
|
|
107
|
+
export const loadConfig = (options) => {
|
|
108
|
+
const cwd = options.cwd ?? process.cwd();
|
|
109
|
+
const filePath = path.resolve(cwd, options.configPath ?? CONFIG_FILE_NAME);
|
|
110
|
+
if (!fs.existsSync(filePath)) {
|
|
111
|
+
if (options.configPath === undefined) {
|
|
112
|
+
return DEFAULT_CONFIG;
|
|
113
|
+
}
|
|
114
|
+
throw new Error(`Config file not found: ${filePath}`);
|
|
115
|
+
}
|
|
116
|
+
let parsed;
|
|
117
|
+
try {
|
|
118
|
+
parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
throw new Error(`Invalid JSON in ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
122
|
+
}
|
|
123
|
+
return parseConfig(parsed, filePath);
|
|
124
|
+
};
|
package/dist/lib.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
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
|
+
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 });
|
|
13
|
+
export const isExcluded = (route, exclude) => routeMatcher(exclude)(route);
|
|
14
|
+
export const loadSizes = (filePath, exclude) => {
|
|
15
|
+
if (!fs.existsSync(filePath)) {
|
|
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).`);
|
|
17
|
+
}
|
|
18
|
+
const data = readJsonFile(filePath);
|
|
19
|
+
const excluded = routeMatcher(exclude);
|
|
20
|
+
const sizes = new Map();
|
|
21
|
+
if (Array.isArray(data)) {
|
|
22
|
+
for (const entry of parseRawStats(data, filePath)) {
|
|
23
|
+
if (excluded(entry.route)) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
sizes.set(entry.route, {
|
|
27
|
+
bytes: entry.firstLoadUncompressedJsBytes,
|
|
28
|
+
chunks: entry.firstLoadChunkPaths?.length ?? 0,
|
|
29
|
+
compressedBytes: null,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return { compressed: false, sizes };
|
|
33
|
+
}
|
|
34
|
+
if (typeof data !== "object" || data === null || !("schemaVersion" in data)) {
|
|
35
|
+
throw new Error(`Unrecognized input ${filePath}: expected a route-bundle-stats.json array or a route-bundle-size snapshot with "schemaVersion".`);
|
|
36
|
+
}
|
|
37
|
+
for (const entry of parseSnapshot(data, filePath).routes) {
|
|
38
|
+
if (excluded(entry.route)) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
sizes.set(entry.route, {
|
|
42
|
+
bytes: entry.rawBytes,
|
|
43
|
+
chunks: entry.chunkPaths.length,
|
|
44
|
+
compressedBytes: entry.compressedBytes,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return { compressed: true, sizes };
|
|
48
|
+
};
|
|
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.`);
|
|
50
|
+
export const assertComparable = (current, baseline, metric) => {
|
|
51
|
+
if (metric !== "compressed") {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const uncompressed = [
|
|
55
|
+
current.compressed ? null : "current",
|
|
56
|
+
baseline.compressed ? null : "baseline",
|
|
57
|
+
].filter((side) => side !== null);
|
|
58
|
+
if (uncompressed.length > 0) {
|
|
59
|
+
throw noCompressedDataError(uncompressed);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
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;
|
|
71
|
+
export const buildRows = (current, baseline, budget) => {
|
|
72
|
+
const allRoutes = new Set([...current.keys(), ...baseline.keys()]);
|
|
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
|
+
}));
|
|
83
|
+
let anyExceeded = false;
|
|
84
|
+
for (const route of allRoutes) {
|
|
85
|
+
const cur = current.get(route) ?? null;
|
|
86
|
+
const base = baseline.get(route) ?? null;
|
|
87
|
+
const currentBytes = cur === null ? 0 : metricBytes(cur, budget.metric);
|
|
88
|
+
const baselineBytes = base === null ? 0 : metricBytes(base, budget.metric);
|
|
89
|
+
const delta = currentBytes - baselineBytes;
|
|
90
|
+
const deltaPercent = base !== null && baselineBytes > 0 ? (delta / baselineBytes) * 100 : null;
|
|
91
|
+
const chunkDelta = (cur?.chunks ?? 0) - (base?.chunks ?? 0);
|
|
92
|
+
const limits = {
|
|
93
|
+
...globalLimits,
|
|
94
|
+
...overrides.find((override) => override.matches(route))?.limits,
|
|
95
|
+
};
|
|
96
|
+
// New routes (no baseline) never exceeded — a fresh page legitimately ships
|
|
97
|
+
// its full size as "delta", which would otherwise always trip the abs rule.
|
|
98
|
+
const exceeded = base !== null &&
|
|
99
|
+
delta > 0 &&
|
|
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) {
|
|
104
|
+
anyExceeded = true;
|
|
105
|
+
}
|
|
106
|
+
rows.push({
|
|
107
|
+
route,
|
|
108
|
+
current: cur,
|
|
109
|
+
baseline: base,
|
|
110
|
+
delta,
|
|
111
|
+
deltaPercent,
|
|
112
|
+
chunkDelta,
|
|
113
|
+
limits,
|
|
114
|
+
exceeded,
|
|
115
|
+
overMaxSize,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
rows.sort((a, b) => Number(isFailing(b)) - Number(isFailing(a)) ||
|
|
119
|
+
Math.abs(b.delta) - Math.abs(a.delta));
|
|
120
|
+
return { rows, anyExceeded };
|
|
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
|
+
});
|
|
128
|
+
export const buildSummary = (rows, metric) => {
|
|
129
|
+
let totalCurrent = 0;
|
|
130
|
+
let totalBaseline = 0;
|
|
131
|
+
let routesCurrent = 0;
|
|
132
|
+
let routesBaseline = 0;
|
|
133
|
+
for (const row of rows) {
|
|
134
|
+
if (row.current !== null) {
|
|
135
|
+
totalCurrent += metricBytes(row.current, metric);
|
|
136
|
+
routesCurrent += 1;
|
|
137
|
+
}
|
|
138
|
+
if (row.baseline !== null) {
|
|
139
|
+
totalBaseline += metricBytes(row.baseline, metric);
|
|
140
|
+
routesBaseline += 1;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
totalRoutes: rows.length,
|
|
145
|
+
routesCurrent,
|
|
146
|
+
routesBaseline,
|
|
147
|
+
avgCurrent: routesCurrent > 0 ? totalCurrent / routesCurrent : 0,
|
|
148
|
+
avgBaseline: routesBaseline > 0 ? totalBaseline / routesBaseline : 0,
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
export const isNotableShrink = (row) => row.current !== null &&
|
|
152
|
+
row.delta < 0 &&
|
|
153
|
+
(row.delta <= -row.limits.maxIncrease ||
|
|
154
|
+
(row.deltaPercent !== null &&
|
|
155
|
+
row.deltaPercent <= -row.limits.maxIncreasePercent));
|
|
156
|
+
export const formatDelta = (row) => {
|
|
157
|
+
// Sub-threshold deltas (incl. zero) are visual noise — show as "no change".
|
|
158
|
+
if (!row.exceeded && Math.abs(row.delta) < MIN_DISPLAY) {
|
|
159
|
+
return "—";
|
|
160
|
+
}
|
|
161
|
+
const sign = row.delta > 0 ? "+" : "";
|
|
162
|
+
const marker = row.exceeded ? " 🔴" : isNotableShrink(row) ? " 🟢" : "";
|
|
163
|
+
// Removed: the size column already says "(removed)"; -100% is noise.
|
|
164
|
+
if (row.current === null) {
|
|
165
|
+
return `${sign}${formatBytes(row.delta)}${marker}`;
|
|
166
|
+
}
|
|
167
|
+
const percentLabel = row.deltaPercent === null
|
|
168
|
+
? "new"
|
|
169
|
+
: `${sign}${row.deltaPercent.toFixed(1)}%`;
|
|
170
|
+
return `${sign}${formatBytes(row.delta)} (${percentLabel})${marker}`;
|
|
171
|
+
};
|
|
172
|
+
const formatSignedInt = (n) => {
|
|
173
|
+
if (n === 0)
|
|
174
|
+
return "—";
|
|
175
|
+
return n > 0 ? `+${n}` : `${n}`;
|
|
176
|
+
};
|
|
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
|
+
};
|
|
216
|
+
export const renderMarkdown = (rows, summary, budget) => {
|
|
217
|
+
if (rows.length === 0) {
|
|
218
|
+
return "## Bundle size\n\nNo client routes detected.";
|
|
219
|
+
}
|
|
220
|
+
const table = markdownTable([
|
|
221
|
+
["Route", "First Load JS", "Δ"],
|
|
222
|
+
...rows.map((r) => [
|
|
223
|
+
`\`${r.route}\``,
|
|
224
|
+
formatCurrent(r, budget.metric, "— (removed)"),
|
|
225
|
+
formatDelta(r),
|
|
226
|
+
]),
|
|
227
|
+
]);
|
|
228
|
+
const summaryLines = [
|
|
229
|
+
"",
|
|
230
|
+
"### Summary",
|
|
231
|
+
"",
|
|
232
|
+
`- **Routes:** ${summary.totalRoutes}`,
|
|
233
|
+
`- **Avg per route:** ${formatBytes(summary.avgBaseline)} → ${formatBytes(summary.avgCurrent)}`,
|
|
234
|
+
];
|
|
235
|
+
return [
|
|
236
|
+
"## Bundle size",
|
|
237
|
+
"",
|
|
238
|
+
table,
|
|
239
|
+
...summaryLines,
|
|
240
|
+
"",
|
|
241
|
+
`${budgetLegend(budget)} · 🟢 notable improvement`,
|
|
242
|
+
].join("\n");
|
|
243
|
+
};
|
|
244
|
+
export const renderTerminal = (rows, summary, budget) => {
|
|
245
|
+
if (rows.length === 0) {
|
|
246
|
+
return "Bundle size: no client routes detected.";
|
|
247
|
+
}
|
|
248
|
+
const table = new Table({
|
|
249
|
+
head: ["Route", "Baseline", "Current", "Δ bytes", "Δ chunks"],
|
|
250
|
+
colAligns: ["left", "right", "right", "right", "right"],
|
|
251
|
+
style: { head: [], border: [], compact: true },
|
|
252
|
+
});
|
|
253
|
+
for (const row of rows) {
|
|
254
|
+
table.push([
|
|
255
|
+
row.route,
|
|
256
|
+
row.baseline === null
|
|
257
|
+
? "(new)"
|
|
258
|
+
: formatBytes(metricBytes(row.baseline, budget.metric)),
|
|
259
|
+
formatCurrent(row, budget.metric, "(removed)"),
|
|
260
|
+
formatDelta(row),
|
|
261
|
+
formatSignedInt(row.chunkDelta),
|
|
262
|
+
]);
|
|
263
|
+
}
|
|
264
|
+
return [
|
|
265
|
+
table.toString(),
|
|
266
|
+
`Avg per route: ${formatBytes(summary.avgBaseline)} → ${formatBytes(summary.avgCurrent)}`,
|
|
267
|
+
`${budgetLegend(budget)} 🟢 notable improvement ${summary.totalRoutes} route(s)`,
|
|
268
|
+
].join("\n");
|
|
269
|
+
};
|
|
270
|
+
export const renderBootstrap = (rows, metric) => {
|
|
271
|
+
const table = markdownTable([
|
|
272
|
+
["Route", "First Load JS"],
|
|
273
|
+
...rows.map((row) => [
|
|
274
|
+
`\`${row.route}\``,
|
|
275
|
+
formatCurrent(row, metric, "(removed)"),
|
|
276
|
+
]),
|
|
277
|
+
]);
|
|
278
|
+
const legend = rows.some((row) => row.overMaxSize)
|
|
279
|
+
? ["", "🔴 larger than its max size"]
|
|
280
|
+
: [];
|
|
281
|
+
return [
|
|
282
|
+
"## Bundle size",
|
|
283
|
+
"",
|
|
284
|
+
`Baseline not yet established. ${rows.length} route(s) captured. Sizes are ${metric}.`,
|
|
285
|
+
"",
|
|
286
|
+
table,
|
|
287
|
+
...legend,
|
|
288
|
+
].join("\n");
|
|
289
|
+
};
|
package/dist/measure.js
ADDED
|
@@ -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 { isStringArray, 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 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);
|
|
15
|
+
if (sizedWithoutChunks.length > 0) {
|
|
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.`);
|
|
17
|
+
}
|
|
18
|
+
const uniqueChunks = [
|
|
19
|
+
...new Set(entries.flatMap((entry) => entry.firstLoadChunkPaths ?? [])),
|
|
20
|
+
];
|
|
21
|
+
const missing = uniqueChunks.filter((chunk) => !fs.existsSync(path.resolve(options.appDir, chunk)));
|
|
22
|
+
if (missing.length > 0) {
|
|
23
|
+
const shown = missing.slice(0, MISSING_CHUNKS_SHOWN).join(", ");
|
|
24
|
+
const rest = missing.length > MISSING_CHUNKS_SHOWN
|
|
25
|
+
? ` (and ${missing.length - MISSING_CHUNKS_SHOWN} more)`
|
|
26
|
+
: "";
|
|
27
|
+
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.`);
|
|
28
|
+
}
|
|
29
|
+
const compressedByChunk = new Map();
|
|
30
|
+
for (const chunk of uniqueChunks) {
|
|
31
|
+
compressedByChunk.set(chunk, zlib.gzipSync(fs.readFileSync(path.resolve(options.appDir, chunk)))
|
|
32
|
+
.byteLength);
|
|
33
|
+
}
|
|
34
|
+
const snapshot = {
|
|
35
|
+
schemaVersion: SNAPSHOT_SCHEMA_VERSION,
|
|
36
|
+
routes: entries.map((entry) => {
|
|
37
|
+
const chunks = entry.firstLoadChunkPaths ?? [];
|
|
38
|
+
return {
|
|
39
|
+
route: entry.route,
|
|
40
|
+
rawBytes: entry.firstLoadUncompressedJsBytes,
|
|
41
|
+
compressedBytes: chunks.reduce((total, chunk) => total + (compressedByChunk.get(chunk) ?? 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,57 @@
|
|
|
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
|
+
export 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 ?? {});
|
|
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}. 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
|
+
}
|
|
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 ??
|
|
46
|
+
{});
|
|
47
|
+
if (typeof route !== "string" ||
|
|
48
|
+
!isFiniteNumber(rawBytes) ||
|
|
49
|
+
!isStringArray(chunkPaths) ||
|
|
50
|
+
!isFiniteNumber(compressedBytes) ||
|
|
51
|
+
compressedBytes < 0) {
|
|
52
|
+
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".`);
|
|
53
|
+
}
|
|
54
|
+
return { route, rawBytes, compressedBytes, chunkPaths };
|
|
55
|
+
});
|
|
56
|
+
return { schemaVersion: SNAPSHOT_SCHEMA_VERSION, routes };
|
|
57
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
|
+
"$schema": "https://www.schemastore.org/package.json",
|
|
2
3
|
"name": "route-bundle-size",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
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
|
+
"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 --disable-warning=ExperimentalWarning' 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": "^22.12.0",
|
|
49
|
+
"@types/picomatch": "^4.0.3",
|
|
50
|
+
"jest": "^30.4.2",
|
|
51
|
+
"ts-jest": "^29.4.6",
|
|
52
|
+
"tsx": "^4.9.1",
|
|
53
|
+
"typescript": "^6.0.3"
|
|
54
|
+
}
|
|
55
|
+
}
|