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 +229 -154
- package/dist/cli.js +70 -23
- package/dist/config.js +58 -16
- package/dist/lib.js +122 -97
- package/dist/measure.js +10 -10
- package/dist/stats.js +5 -4
- package/package.json +4 -5
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
|
-
|
|
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 [
|
|
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
|
-
|
|
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 --
|
|
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 --
|
|
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.
|
|
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
|
|
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
|
-
|
|
261
|
+
Square brackets are literal, so `/blog/[slug]` matches only the dynamic route `/blog/[slug]`.
|
|
103
262
|
|
|
104
|
-
|
|
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
|
|
115
|
-
|
|
116
|
-
│ /
|
|
117
|
-
|
|
118
|
-
│ /
|
|
119
|
-
|
|
120
|
-
│ /
|
|
121
|
-
|
|
122
|
-
│ /
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
##
|
|
142
|
-
|
|
143
|
-
| Route | First Load JS | Δ
|
|
144
|
-
| -------------- | ------------- |
|
|
145
|
-
| `/
|
|
146
|
-
| `/
|
|
147
|
-
| `/
|
|
148
|
-
| `/pricing` | 148
|
|
149
|
-
| `/` | 162
|
|
150
|
-
| `/blog/[slug]` | 157
|
|
151
|
-
| `/_not-found` | 138
|
|
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
|
-
- **
|
|
157
|
-
- **Avg per route:** 177 kB → 178 kB
|
|
158
|
-
- **Chunks:** 30 → 29 (-1)
|
|
310
|
+
- **Avg per route:** 177 KiB → 203 KiB
|
|
159
311
|
|
|
160
|
-
🔴
|
|
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.
|
|
166
|
-
- 🔴
|
|
167
|
-
-
|
|
168
|
-
-
|
|
169
|
-
-
|
|
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
|
|
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
|
-
##
|
|
326
|
+
## Bundle size
|
|
175
327
|
|
|
176
|
-
Baseline not yet established. 6
|
|
328
|
+
Baseline not yet established. 6 route(s) captured. Sizes are compressed.
|
|
177
329
|
|
|
178
330
|
| Route | First Load JS |
|
|
179
331
|
| -------------- | ------------- |
|
|
180
|
-
| `/
|
|
181
|
-
| `/
|
|
182
|
-
| `/` |
|
|
183
|
-
| `/blog/[slug]` |
|
|
184
|
-
| `/pricing` |
|
|
185
|
-
| `/_not-found` |
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
271
|
-
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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("
|
|
17
|
-
.requiredOption("--current <file>", "
|
|
18
|
-
.option("--baseline <file>", "
|
|
19
|
-
.addOption(new Option("--format <format>", "Output format. 'terminal'
|
|
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
|
|
23
|
-
.option("--no-exclude",
|
|
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 =
|
|
51
|
+
const current = loadInput(options.current, exclude);
|
|
29
52
|
if (options.baseline === undefined || !fs.existsSync(options.baseline)) {
|
|
30
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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 = {
|
|
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;
|
|
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[(
|
|
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
|
|
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 } =
|
|
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, } =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
const
|
|
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
|
-
|
|
9
|
-
const
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
+
current.compressed ? null : "current",
|
|
56
|
+
baseline.compressed ? null : "baseline",
|
|
55
57
|
].filter((side) => side !== null);
|
|
56
58
|
if (uncompressed.length > 0) {
|
|
57
|
-
throw
|
|
59
|
+
throw noCompressedDataError(uncompressed);
|
|
58
60
|
}
|
|
59
61
|
};
|
|
60
|
-
const
|
|
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 =
|
|
68
|
-
const base =
|
|
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 = (
|
|
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 >=
|
|
79
|
-
(deltaPercent !== null && deltaPercent >=
|
|
80
|
-
|
|
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) =>
|
|
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
|
|
132
|
-
|
|
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 <= -
|
|
135
|
-
export const formatDelta = (row
|
|
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}${
|
|
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}${
|
|
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
|
-
|
|
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
|
|
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 "##
|
|
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.
|
|
182
|
-
|
|
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
|
-
`- **
|
|
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
|
-
"##
|
|
236
|
+
"## Bundle size",
|
|
198
237
|
"",
|
|
199
238
|
table,
|
|
200
239
|
...summaryLines,
|
|
201
240
|
"",
|
|
202
|
-
|
|
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 "
|
|
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
|
-
:
|
|
220
|
-
row.
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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 = (
|
|
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
|
-
...
|
|
253
|
-
`\`${route}\``,
|
|
254
|
-
|
|
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
|
-
"##
|
|
282
|
+
"## Bundle size",
|
|
259
283
|
"",
|
|
260
|
-
`Baseline not yet established. ${
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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) =>
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
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}.
|
|
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
|
|
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.
|
|
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
|
|
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": "^
|
|
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"
|