next-leak 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,19 +23,33 @@ $ npx next-leak . --quick
23
23
  ```
24
24
 
25
25
  That first line is a real run against the reproduction for
26
- [vercel/next.js#95094](https://github.com/vercel/next.js/issues/95094), an open
27
- Next.js issue: the sandbox's `TimeoutsManager` never releases timeout ids from
28
- middleware. next-leak found the growth, the retaining object and the chain that
29
- holds it — without being told what to look for.
30
-
31
- **Verified against real, open Next.js issues**, not synthetic fixtures:
32
-
33
- | Issue | What it is | Result |
34
- |---|---|---|
35
- | [#95094](https://github.com/vercel/next.js/issues/95094) | Middleware `setTimeout` ids retained by the sandbox | **Reproduced** · mechanism named · 112 MB retained |
36
- | [#94890](https://github.com/vercel/next.js/issues/94890) | Router LRU cache doesn't count its keys | **Reproduced** · 26.7 71.9 MB |
37
- | [#84884](https://github.com/vercel/next.js/issues/84884) | axios + `AbortSignal` in middleware | **Reproduced** · 32.8369.9 MB |
38
- | [#94919](https://github.com/vercel/next.js/issues/94919) | RSC tree retained on client aborts | **Reproduced** · 39 139 MB · [with a caveat](#scope-and-limits-read-before-filing-issues) |
26
+ [vercel/next.js#95094](https://github.com/vercel/next.js/issues/95094): the
27
+ sandbox's `TimeoutsManager` never released timeout ids from middleware.
28
+ next-leak found the growth, the retaining object and the chain that holds it —
29
+ without being told what to look for. Next.js 16.3.0 has since fixed it.
30
+
31
+ **Verified against real Next.js issues**, not synthetic fixtures. Issue states
32
+ checked 2026-08-18:
33
+
34
+ | Issue | What it is | Measured | State today |
35
+ |---|---|---|---|
36
+ | [#96533](https://github.com/vercel/next.js/issues/96533) | ISR revalidation holds RSC buffers between collections | 4–5 MB of `arrayBuffers` held vs 0.32 MB retained | **open** |
37
+ | [#97464](https://github.com/vercel/next.js/issues/97464) | Static-gen worker retains per prerendered page | worker rss 1.072.96 GB, then OOM | **open** |
38
+ | [#92287](https://github.com/vercel/next.js/issues/92287) | Cache Components: unbounded `arrayBuffers` under load | 37.5 MB of arrayBuffers held between collections, 37x what it retains (16.3.1) | **open** |
39
+ | [#84884](https://github.com/vercel/next.js/issues/84884) | axios + `AbortSignal` in middleware | 32.8 → 369.9 MB | **open** |
40
+ | [#89091](https://github.com/vercel/next.js/issues/89091) | zlib retention on mid-stream aborts | +42.5 MB/1000 aborted req on 16.1.5; **+0.03 on 16.3.1** | open, no longer reproduces |
41
+ | [#95094](https://github.com/vercel/next.js/issues/95094) | Middleware `setTimeout` ids retained by the sandbox | 112 MB retained; flat after the fix | fixed in 16.3.0 |
42
+ | [#94890](https://github.com/vercel/next.js/issues/94890) | Router LRU cache doesn't count its keys | 26.7 → 71.9 MB | fixed in 16.3.0 |
43
+ | [#94919](https://github.com/vercel/next.js/issues/94919) | Retention on client aborts | 39 → 139 MB · [with a caveat](#scope-and-limits-read-before-filing-issues) | fixed in 16.3.0 |
44
+
45
+ The three fixed ones are kept deliberately: a tool that only lists open bugs
46
+ looks impressive until the bugs close, and what those rows show is that the
47
+ measurements matched what the fixes turned out to be. #94919 is the sharpest —
48
+ the PR that closed it discarded the RSC-WeakMap hypothesis in the title and
49
+ attributed the leak to native zlib retention instead, which is the same
50
+ mechanism the #89091 measurement had already isolated — and re-measuring #89091
51
+ on 16.3.1 (2026-08-18) shows it gone, from +42.5 MB per 1000 aborted requests
52
+ down to +0.03, which is the same fix arriving from the other direction.
39
53
 
40
54
  The full causal chain, measured on that same issue: leak found (28.7 -> 138.9 MB
41
55
  across 8 cycles), the workaround from the thread applied (`clearTimeout(id)`
@@ -102,11 +116,26 @@ Every report prints the gate it used.
102
116
  | `--requests <n>` | 5000 | Requests per cycle. Raises sensitivity as well as duration: the growth gate scales with it, down to a noise floor around 5000 |
103
117
  | `--connections <n>` | 100 | Concurrent connections |
104
118
  | `--idle <seconds>` | 30 | **Maximum** wait before each sample; the run continues as soon as the heap settles |
119
+ | `--warmup <n>` | 200 | Requests before the baseline snapshot. Lower it on apps that cache per request: warm-up fills those caches and the baseline then measures the warm-up, not the app. The run says so when it happens |
105
120
  | `--max-old-space <mb>` | 512 | Heap cap of each measured process. Raise it for apps whose legitimate working set is larger, or they die under measurement |
106
121
  | `--quick` | off | Fast preset (2000 requests × 4 cycles, 8s idle) — the exact profile the real-app validation ran with. Same cycle count as the default; what it trades away is traffic per cycle, so it sits on the noise floor and is less sensitive to slow leaks. Explicit flags override it |
107
122
  | `--no-resolve` | off | Skip the second pass on inconclusive routes |
108
123
  | `--diff-all` | off | Diff snapshots for stable routes too |
109
124
  | `--output <dir>` | `<app>/.next-leak` | Where runs are written |
125
+ | `--write-config` | off | Write `next-leak.config.json` for the routes that need sample params, then exit. Never overwrites an existing file |
126
+
127
+ There is a second command for the other half of the problem:
128
+
129
+ ```bash
130
+ # Measure the build itself, not a built server
131
+ npx next-leak build .
132
+ ```
133
+
134
+ A large site can run out of heap while prerendering, before any server exists to
135
+ measure ([#97464](https://github.com/vercel/next.js/issues/97464)). That command
136
+ runs your build unmodified and samples the resident memory of each
137
+ static-generation worker. It needs neither a previous build nor standalone
138
+ output, and takes `--output` only.
110
139
 
111
140
  Dynamic routes need sample params in `next-leak.config.json` in your app dir:
112
141
 
@@ -118,6 +147,13 @@ Dynamic routes need sample params in `next-leak.config.json` in your app dir:
118
147
  }
119
148
  ```
120
149
 
150
+ `--write-config` generates that file for you, filling in values from paths your
151
+ build already prerendered where it knows them, so it usually resolves on the
152
+ first try. When a run skips a route it prints the same fragment.
153
+
154
+ ```json
155
+ ```
156
+
121
157
  - **`headers`** are sent with every request. Real traffic is not header-less:
122
158
  compression, sessions and auth change which code paths run, and some leaks
123
159
  only live on those paths.
@@ -131,10 +167,19 @@ Dynamic routes need sample params in `next-leak.config.json` in your app dir:
131
167
  load-balancer timeouts and bots do. Some leaks only exist on that path
132
168
  (`ServerResponse` retained after an early disconnect; the RSC tee branch in
133
169
  [#94919](https://github.com/vercel/next.js/issues/94919)). The clock starts
134
- at the **first byte of the response**, not at the request under load a
135
- request-relative window cuts before the stream begins and tests a different
136
- path. Small values are the point: `4` means "read the first chunk, then
137
- vanish". Requests abandoned on purpose are not counted as failures.
170
+ at the **first byte of the response**, so the cut lands mid-stream however
171
+ long the route takes to answer. Small values are the point: `4` means "read
172
+ the first chunk, then vanish". Requests abandoned on purpose are not counted
173
+ as failures.
174
+ - **`abandonFrom`** moves that clock to the request itself
175
+ (`"abandonFrom": "request"`). Use it for the opposite experiment: a client
176
+ that is already gone before the server produces anything. On a route slower
177
+ than the deadline the default never cuts early — against the reproduction
178
+ for [#84648](https://github.com/vercel/next.js/issues/84648), whose upstream
179
+ answers in 400 ms while its load generator cuts at 60 ms, first-byte reached
180
+ 7% of requests and `request` reached all of them. It stays opt-in because a
181
+ deadline armed on connect loses the race to the first byte on fast routes,
182
+ which measures the wrong path silently.
138
183
 
139
184
  `run.json` records what every load phase actually did — requests sent,
140
185
  2xx, abandoned — so a run can be audited instead of trusted.
@@ -168,7 +213,7 @@ separates them, because each one has a different fix:
168
213
  with the traffic, so the longer run decides the same thing. If the heap is
169
214
  flat but RSS keeps climbing, the report says so explicitly: that is an
170
215
  allocator, external-buffer or fragmentation problem, not a JS-heap leak.
171
- - **`leak`** — the report names the culprit when attribution resolves: your file (`culprit: src/app/x/page.tsx (your code)`), a dependency (package name), or framework internals. An `ISSUE-<route>.md` draft is generated; if the leak is app-owned, the draft tells you **not** to file it upstream.
216
+ - **`leak`** — the report names the culprit when attribution resolves: your file (`culprit: src/app/x/page.tsx (your code)`), a dependency (package name), or framework internals. An `ISSUE-<route>.md` draft is generated **when the evidence plainly supports the verdict** — a `leak` carrying low-confidence warnings (growth barely over the threshold, one cycle dominating the mean, too few cycles for its size) gets the verdict but no draft, because a draft is written to be pasted into someone else's tracker. If the leak is app-owned, the draft tells you **not** to file it upstream.
172
217
  - **`inconclusive`** — the evidence does not decide. The run does not stop there: any inconclusive route is **measured again automatically**, with twice the cycles, and the second pass is what you see (`resolved at 8 cycles` next to the verdict). On the reproduction for [#95094](https://github.com/vercel/next.js/issues/95094), `--quick` alone reports `inconclusive` on three deltas and then comes back with the leak. `--no-resolve` turns the second pass off; when even that is undecided, the re-run command is still printed.
173
218
  - **`failed`** — the route errored under load (auth redirects, POST-only endpoints). >1% non-2xx aborts measurement instead of measuring garbage. That's by design.
174
219
 
@@ -188,8 +233,10 @@ the verdict:
188
233
  ```
189
234
 
190
235
  That is a real measurement of the reproduction in
191
- [vercel/next.js#92287](https://github.com/vercel/next.js/issues/92287): no
192
- retention, and 3 GB reached. The note fires when the peak heap comes within
236
+ [vercel/next.js#92287](https://github.com/vercel/next.js/issues/92287) on
237
+ 16.2.2: no retention, and 3 GB reached under its own sustained load. The same
238
+ app on 16.3.1 under a shorter profile still reaches 544 MB against 33.8 MB
239
+ retained, so the shape has not gone anywhere. The note fires when the peak heap comes within
193
240
  75% of `--max-old-space`, or when peak RSS is at least 8× the retained heap
194
241
  and above 512 MB. It never changes the verdict — retention and peak are
195
242
  different questions, and only one of them is a leak. A peak is the highest
@@ -199,6 +246,49 @@ If the measured process dies at the limit instead of merely approaching it,
199
246
  the route fails saying exactly that, with the limit in force and how to raise
200
247
  it.
201
248
 
249
+ ### Memory a GC reclaims that production never reclaims
250
+
251
+ There is a third question again, and it is the shape of most of the leaks
252
+ reported in August: memory the process *holds between collections*, which a
253
+ forced GC takes back and a long-running server may never run one to take back.
254
+ Every verdict above is post-GC, so that class is invisible to it by
255
+ construction.
256
+
257
+ Each cycle is therefore read twice — once before any forced collection, once
258
+ after — and the gap between them is reported when it is large:
259
+
260
+ ```
261
+ ✖ /posts/[slug] leak (+0.16 MB/1000 req) heap 27.1 → 26.3 → … → 27.9 MB
262
+ driven through ISR revalidation (revalidates every 3600s; without it the load
263
+ would serve the cache)
264
+ ▲ unreclaimed: held 4.72 MB of arrayBuffers between collections that a GC took
265
+ back (4.7x what it retains) — a forced GC reclaims this, a long-running
266
+ process may not run one often enough to
267
+ ```
268
+
269
+ A real measurement of the reproduction in
270
+ [#96533](https://github.com/vercel/next.js/issues/96533), whose reporter
271
+ accumulated 1.16 GB of `arrayBuffers` over four days against a flat JS heap.
272
+ The note fires on the **gap**, not on a trend: that pre-collection series
273
+ oscillates rather than climbs, and a rule keyed on it climbing reported nothing
274
+ at all. It needs both a floor (2 MB) and a ratio (0.35× what the route retains),
275
+ because the absolute size alone does not separate this from an ordinary leak.
276
+
277
+ Its limit, stated on the line itself: the reading is taken seconds after load,
278
+ not the hours a production process runs between full collections, so it
279
+ includes memory that had not been collected yet. It never changes the verdict.
280
+
281
+ ### ISR routes are driven, not served from cache
282
+
283
+ A route with a revalidation period serves its cache unless the request carries
284
+ the build's own `x-prerender-revalidate` header. Measured on that same app: the
285
+ identical run reports `leak` with the header and `stable` without it. next-leak
286
+ reads `previewModeId` from `.next/prerender-manifest.json` and drives those
287
+ routes itself; a header you set in `next-leak.config.json` wins untouched. When
288
+ the manifest cannot supply one, the route is reported `not exercised` with no
289
+ verdict, because a flat curve measured against a static cache says nothing about
290
+ the app.
291
+
202
292
  ## The tool grades its own measurement
203
293
 
204
294
  A leak detector is an instrument, and a miscalibrated instrument doesn't fail
@@ -260,10 +350,27 @@ through the build's source maps.
260
350
 
261
351
  ## Scope and limits (read before filing issues)
262
352
 
263
- - **Supported:** App Router · `output: "standalone"` · Node ≥ 22 · Linux/macOS. Pages Router, non-standalone, and Windows are rejected with a clear message.
353
+ - **Supported (default command):** App Router · `output: "standalone"` · Node ≥ 22 · Linux/macOS. Pages Router, non-standalone, and Windows are rejected with a clear message.
354
+ - **Sample values can vary per request.** `"slug": "post-{n}"` gives every
355
+ request its own URL — the shape of bot traffic and of a cache that never
356
+ repeats a key. `"slug": "post-{n%200}"` cycles through exactly 200 distinct
357
+ values, revisiting them across cycles, which is the shape the reported leaks
358
+ actually have ([#96533](https://github.com/vercel/next.js/issues/96533)
359
+ revalidates a fixed set of posts;
360
+ [#92287](https://github.com/vercel/next.js/issues/92287) turns on how many
361
+ keys a cache holds at once). A value carrying both markers is rejected before
362
+ the run starts.
363
+ - **`next-leak build <dir>`** measures the build instead of the server, and needs
364
+ neither a previous build nor standalone output — it runs `next build` and
365
+ samples the resident memory of each static-generation worker, which is where
366
+ large sites run out of heap while prerendering
367
+ ([#97464](https://github.com/vercel/next.js/issues/97464)). It does not apply
368
+ to projects using `experimental.workerThreads: true`: a worker thread has no
369
+ resident memory of its own to sample, and the run says so instead of
370
+ reporting a number that would be measuring the parent.
264
371
  - **Architectures:** verified on **arm64 and x64** (linux/amd64 in Docker) — same app, same parameters, same verdicts.
265
372
  - **Attribution** (naming the file) needs a Turbopack build with server sourcemaps — the Next 15+ default. On webpack builds the registry is empty by design and findings degrade to `unattributed` with raw retainer chains; measurement itself does not depend on it. Note that `output: "standalone"` + `--webpack` produced a bundle that could not start at all on `16.3.0-canary.90` (missing `@swc/helpers`), independently of this tool.
266
- - Empirically validated on Next **15.5.4, 16.0.x, 16.1.5, 16.2.x and 16.3-canary** (incl. Sentry, OpenTelemetry, PPR and i18n apps), against real reproductions from open issues. The contracts it relies on are stable since Next 13–14, but older versions are untested.
373
+ - Empirically validated on Next **15.5.4, 16.0.x, 16.1.5, 16.2.x, 16.3.0/16.3.1 and 16.3-canary** (incl. Sentry, OpenTelemetry, PPR and i18n apps), against the public reproductions attached to real issues (open and since-fixed). Most recent measurements, 2026-08-17/18: the runtime path on 16.2.12 and 16.3.1-canary.18, the build path on 16.2.12 and 16.3.1. The contracts it relies on are stable since Next 13–14, but older versions are untested.
267
374
  - **Each measured process runs under a 512 MB heap cap** by default, so a leak
268
375
  reaches a ceiling in minutes instead of the hours a production container
269
376
  takes. An app whose legitimate working set is larger needs
@@ -1,9 +1,17 @@
1
+ /**
2
+ * Where the abandon deadline starts. `first-byte` puts the cut mid-stream
3
+ * whatever the route's latency; `request` puts it before the response exists,
4
+ * which is a different leak path and not reachable from the other origin.
5
+ */
6
+ export type AbandonOrigin = "first-byte" | "request";
1
7
  export type AbandonPhaseOptions = {
2
8
  url: string;
3
9
  amount: number;
4
10
  connections: number;
5
- /** Destroy the socket this many ms after the first byte of the response. */
11
+ /** Destroy the socket this many ms after the origin below. */
6
12
  abandonAfterMs: number;
13
+ /** Defaults to `first-byte`. */
14
+ abandonFrom?: AbandonOrigin;
7
15
  headers?: Record<string, string>;
8
16
  };
9
17
  export type AbandonPhaseResult = {
@@ -30,6 +38,8 @@ export type AbandonPhaseResult = {
30
38
  * a client goes away mid-flight (closed tabs, load-balancer timeouts, bots).
31
39
  *
32
40
  * Raw sockets keep this honest: write the request, wait for the response to
33
- * start, then wait `abandonAfterMs` and destroy the socket mid-stream.
41
+ * start, then wait `abandonAfterMs` and destroy the socket mid-stream. With
42
+ * `abandonFrom: "request"` the wait starts at the write instead, which is the
43
+ * only way to cut a route that has not begun answering yet.
34
44
  */
35
45
  export declare function runAbandonPhase(options: AbandonPhaseOptions): Promise<AbandonPhaseResult>;
@@ -0,0 +1,11 @@
1
+ import type { BuildRunResult } from "./build-run.js";
2
+ /**
3
+ * Renders the build report.
4
+ *
5
+ * Says "worker rss" everywhere rather than "heap" or "retained": these samples
6
+ * are resident memory with no forced collection behind them, and calling them
7
+ * retention would borrow a precision the measurement does not have. RSS is
8
+ * still the honest axis here — it is what a CI runner's limit is enforced
9
+ * against and what the OOM killer reads.
10
+ */
11
+ export declare function formatBuildReport(result: BuildRunResult): string;
@@ -0,0 +1,58 @@
1
+ import { type ChildProcess } from "node:child_process";
2
+ import { type BuildSample } from "./build-verdict.js";
3
+ import { type ProcessTableSample } from "./process-tree.js";
4
+ import type { TrendResult } from "./trend.js";
5
+ export type WorkerSeries = {
6
+ pid: number;
7
+ samples: BuildSample[];
8
+ };
9
+ export type BuildRunResult = {
10
+ appDir: string;
11
+ /**
12
+ * `measured` carries a verdict. `build-failed` means the build broke for a
13
+ * reason that is not memory, and makes no memory claim. `nothing-to-measure`
14
+ * means no static-generation worker ever ran. `cannot-sample` means the
15
+ * process table could not be read, which is not the same as finding nothing
16
+ * in it.
17
+ */
18
+ status: "measured" | "build-failed" | "nothing-to-measure" | "cannot-sample";
19
+ /** Why sampling stopped, when it did. */
20
+ samplingFailure: string | null;
21
+ verdict: TrendResult["verdict"] | null;
22
+ trend: TrendResult | null;
23
+ /** Segmented levels the verdict was read from, in bytes. */
24
+ levels: number[];
25
+ workers: WorkerSeries[];
26
+ /** The build's own process, reported but never judged: it sheds while workers climb. */
27
+ parentSamples: BuildSample[];
28
+ peakWorkerRssBytes: number;
29
+ netGrowthBytes: number;
30
+ pagesGenerated: number | null;
31
+ retentionPerPageBytes: number | null;
32
+ /** True when a worker hit the V8 heap limit — the finding, not a failure. */
33
+ heapExhausted: boolean;
34
+ strippedCapWarning: string | null;
35
+ exitCode: number | null;
36
+ output: string;
37
+ };
38
+ export type BuildRunOptions = {
39
+ appDir: string;
40
+ signal?: AbortSignal;
41
+ onProgress?: (message: string) => void;
42
+ /** Overrides `process.env` for the build. */
43
+ env?: NodeJS.ProcessEnv;
44
+ };
45
+ export type BuildRunDeps = {
46
+ spawnBuild: (appDir: string, env: NodeJS.ProcessEnv) => ChildProcess;
47
+ sampleTable: () => Promise<ProcessTableSample>;
48
+ now: () => number;
49
+ sleep: (ms: number) => Promise<void>;
50
+ };
51
+ /**
52
+ * Measures the memory of a `next build`'s static-generation workers.
53
+ *
54
+ * The build runs unmodified — nothing is injected into it. Workers are child
55
+ * processes with their own resident memory, so the whole measurement is made
56
+ * from outside by watching the process tree.
57
+ */
58
+ export declare function runBuildMeasurement(options: BuildRunOptions, deps?: BuildRunDeps): Promise<BuildRunResult>;
@@ -0,0 +1,13 @@
1
+ export type ValidatedBuildTarget = {
2
+ appDir: string;
3
+ /** The package script to run, when one of the usual names exists. */
4
+ buildScript: string | null;
5
+ };
6
+ /**
7
+ * Validates a target for a *build* measurement.
8
+ *
9
+ * Deliberately weaker than `validateTarget`: this command measures the build
10
+ * itself, so demanding `.next` or a standalone bundle would reject exactly the
11
+ * projects it exists to help — a build that OOMs never produces either.
12
+ */
13
+ export declare function validateBuildTarget(appDir: string): Promise<ValidatedBuildTarget>;
@@ -0,0 +1,80 @@
1
+ import { type TrendResult } from "./trend.js";
2
+ export type BuildSample = {
3
+ /** Milliseconds since the build started. */
4
+ atMs: number;
5
+ rssBytes: number;
6
+ };
7
+ /**
8
+ * Segments the sampling window into equal slices and takes each slice's last
9
+ * reading.
10
+ *
11
+ * A build has no cycles. Its only natural unit of work is a page, and pages are
12
+ * not observable from outside the worker — so the window is divided by time
13
+ * instead, which yields a series with the shape semantics the trend classifier
14
+ * already reads: one level per unit of work. Taking the last sample of each
15
+ * slice rather than the mean keeps a climb a climb; averaging would flatten the
16
+ * end of every segment into its start.
17
+ */
18
+ export declare function segmentSamples(samples: readonly BuildSample[], segments: number): number[];
19
+ /**
20
+ * Segments a build needs before its curve can be judged.
21
+ *
22
+ * The classifier drops the first delta as warm-up and wants at least three
23
+ * more, and a worker's first segment is genuinely warm-up: module loading and
24
+ * JIT, the same reason the runtime ritual discards its baseline delta.
25
+ */
26
+ export declare const BUILD_SEGMENTS = 6;
27
+ export type BuildTrend = {
28
+ trend: TrendResult;
29
+ /** The segmented levels the verdict was read from. */
30
+ levels: number[];
31
+ };
32
+ /**
33
+ * Per-segment growth a build worker must clear to count as retaining.
34
+ *
35
+ * Not the runtime gate. That one is the noise floor of a *post-GC heap* sample,
36
+ * 256 KiB, and it does not describe RSS: a build worker doing legitimate work
37
+ * moves tens of megabytes between readings through allocator behaviour alone,
38
+ * with nothing retained.
39
+ *
40
+ * Anchored on two builds of the same project, same heap cap, 2026-08-17:
41
+ * 16.2.12 stayed flat (428 → 530 MB, largest segment delta 17 MB) while 16.3.1
42
+ * climbed 1070 → 2960 MB and then OOMed, ~315 MB per segment. 32 MB is roughly
43
+ * twice the largest healthy delta and an order of magnitude below the leaking
44
+ * one. The consequence to be honest about: a build retaining less than this per
45
+ * segment reads as stable — and one retaining less than this does not OOM.
46
+ */
47
+ export declare const BUILD_GROWTH_GATE_BYTES: number;
48
+ /** Classifies a worker's resident-memory curve. */
49
+ export declare function classifyBuildSamples(samples: readonly BuildSample[]): BuildTrend;
50
+ /** Growth across the analyzed window, in bytes. */
51
+ export declare function netGrowthOf(levels: readonly number[]): number;
52
+ /**
53
+ * Retention per page generated.
54
+ *
55
+ * Null when the page count is unknown: a growth figure with an invented
56
+ * denominator is worse than no figure, and this is the number people quote.
57
+ */
58
+ export declare function retentionPerPage(levels: readonly number[], pagesGenerated: number | null): number | null;
59
+ /**
60
+ * The heap cap Next removes from the worker's environment.
61
+ *
62
+ * `lib/worker.js` deletes `max-old-space-size` and `max_old_space_size` from
63
+ * `NODE_OPTIONS` when it spawns an isolated-memory worker, so the flag every
64
+ * OOM guide recommends never reaches the process that runs out of memory.
65
+ * Verified by measurement on the #97464 reproduction: a build capped at 50 MB
66
+ * with `--max-old-space-size` completes, while `--max-heap-size=50` kills it in
67
+ * 374 ms.
68
+ */
69
+ export declare function strippedHeapCap(nodeOptions: string | undefined): string | null;
70
+ /** Whether build output carries a V8 heap-limit fatal error. */
71
+ export declare function diedOfHeapExhaustion(output: string): boolean;
72
+ /**
73
+ * How many pages the build generated, read from its own progress line.
74
+ *
75
+ * Next prints `Generating static pages (1234/2504)`; the largest first number
76
+ * seen is how far it got, which is the useful figure whether it finished or
77
+ * died. Null when the build never printed one — an invented denominator would
78
+ * turn an unknown into a wrong number.
79
+ */
80
+ export declare function pagesGeneratedFrom(output: string): number | null;