next-leak 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/dist/abandon-load.d.ts +33 -0
- package/dist/attribution.d.ts +40 -0
- package/dist/bootstrap.d.ts +1 -0
- package/dist/bootstrap.js +100 -0
- package/dist/chunk-4BMPC45G.js +81 -0
- package/dist/chunk-5K4WEMIS.js +88 -0
- package/dist/chunk-K5PZFVJH.js +103 -0
- package/dist/chunk-OSZ6ND6E.js +38 -0
- package/dist/chunk-UZTZWHKH.js +67803 -0
- package/dist/cli-args.d.ts +24 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +106 -0
- package/dist/confidence.d.ts +78 -0
- package/dist/control-client.d.ts +11 -0
- package/dist/control-server.d.ts +33 -0
- package/dist/environment.d.ts +11 -0
- package/dist/guards.d.ts +9 -0
- package/dist/heap-diff.d.ts +96 -0
- package/dist/html-report-3JIRVRBF.js +9 -0
- package/dist/html-report.d.ts +3 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +95 -0
- package/dist/issue-report-AB62FRNM.js +8 -0
- package/dist/issue-report.d.ts +10 -0
- package/dist/launcher.d.ts +39 -0
- package/dist/load.d.ts +34 -0
- package/dist/manifests.d.ts +44 -0
- package/dist/module-registry.d.ts +24 -0
- package/dist/report.d.ts +3 -0
- package/dist/ritual.d.ts +95 -0
- package/dist/route-config.d.ts +36 -0
- package/dist/run-report.fixture.d.ts +3 -0
- package/dist/runner.d.ts +127 -0
- package/dist/signatures.d.ts +28 -0
- package/dist/target.d.ts +21 -0
- package/dist/trend.d.ts +38 -0
- package/dist/vlq.d.ts +16 -0
- package/package.json +75 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Xabier Lameiro
|
|
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
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# next-leak
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/next-leak)
|
|
4
|
+
[](https://github.com/xabierlameiro/next-leak/actions/workflows/ci.yml)
|
|
5
|
+
[](https://nodejs.org)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
|
|
8
|
+
> Find out whether your Next.js app actually leaks memory — how much, on which route, and whose fault it is.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
$ npx next-leak . --quick
|
|
12
|
+
|
|
13
|
+
✖ /api/heap leak (+4.70 MB/1000 req)
|
|
14
|
+
heap 28.7 → 40.3 → 59.0 → 75.8 → 75.9 → 101.2 → 101.2 → 139.0 → 139.0 MB
|
|
15
|
+
↳ grown [object] Array 112.5 MB — TimeoutsManager#object[.resources]
|
|
16
|
+
<- system / Context#object[.timeoutsManager] <- destroy#closure[.context]
|
|
17
|
+
<- ResourceManager#object[.properties] <- IntervalsManager#object[.map]
|
|
18
|
+
|
|
19
|
+
✔ / stable (+0.02 MB/1000 req) heap 40.9 → 35.3 → 35.3 → 35.4 MB
|
|
20
|
+
✔ /convenio stable (+0.02 MB/1000 req) heap 36.3 → 37.0 → 37.1 → 37.1 MB
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
That first line is a real run against the reproduction for
|
|
24
|
+
[vercel/next.js#95094](https://github.com/vercel/next.js/issues/95094), an open
|
|
25
|
+
Next.js issue: the sandbox's `TimeoutsManager` never releases timeout ids from
|
|
26
|
+
middleware. next-leak found the growth, the retaining object and the chain that
|
|
27
|
+
holds it — without being told what to look for.
|
|
28
|
+
|
|
29
|
+
**Verified against real, open Next.js issues**, not synthetic fixtures:
|
|
30
|
+
|
|
31
|
+
| Issue | What it is | Result |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| [#95094](https://github.com/vercel/next.js/issues/95094) | Middleware `setTimeout` ids retained by the sandbox | **Reproduced** · mechanism named · 112 MB retained |
|
|
34
|
+
| [#94890](https://github.com/vercel/next.js/issues/94890) | Router LRU cache doesn't count its keys | **Reproduced** · 26.7 → 71.9 MB |
|
|
35
|
+
| [#84884](https://github.com/vercel/next.js/issues/84884) | axios + `AbortSignal` in middleware | **Reproduced** · 32.8 → 369.9 MB |
|
|
36
|
+
| [#94919](https://github.com/vercel/next.js/issues/94919) | RSC tree retained on client aborts | Not reproduced on standalone — [and it says why](#scope-and-limits-read-before-filing-issues) |
|
|
37
|
+
|
|
38
|
+
Across ~25 healthy routes on production applications (PPR, MDX, Auth.js,
|
|
39
|
+
Sentry, i18n), it reported **zero false positives**.
|
|
40
|
+
|
|
41
|
+
Your server's memory climbs and the container gets OOM-killed. Almost every report of this ends the same way: *"please provide heap snapshots taken after forced GC"* — which almost nobody produces correctly. `next-leak` runs that controlled measurement for you and answers with evidence a maintainer would accept.
|
|
42
|
+
|
|
43
|
+
Three possible answers, all valuable:
|
|
44
|
+
|
|
45
|
+
1. **You don't have a leak** — the spike is transient and drains during idle (the most common case).
|
|
46
|
+
2. **The leak is in your code (or a dependency)** — named down to the source file when possible.
|
|
47
|
+
3. **It looks like framework internals** — with a ready-to-file issue draft.
|
|
48
|
+
|
|
49
|
+
## Quickstart
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# 1. Your app must build with standalone output — in next.config:
|
|
53
|
+
# output: "standalone"
|
|
54
|
+
next build
|
|
55
|
+
|
|
56
|
+
# 2. Measure it
|
|
57
|
+
npx next-leak .
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
For each discovered route, in a fresh process, it runs the validated ritual:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
warm-up → forced GC → baseline snapshot → [load → idle → GC → sample] ×3 → snapshot
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The verdict comes from the **shape of the post-GC curve**: retained heap that keeps growing every cycle is a leak; growth that flattens is warm-up. Absolute sizes are noise; shapes are robust.
|
|
67
|
+
|
|
68
|
+
## Options
|
|
69
|
+
|
|
70
|
+
| Flag | Default | What it does |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `--routes <list>` | all | Only measure these routes (comma-separated templates or prefixes) |
|
|
73
|
+
| `--cycles <n>` | 3 | Load cycles per route (min 3) |
|
|
74
|
+
| `--requests <n>` | 5000 | Requests per cycle |
|
|
75
|
+
| `--connections <n>` | 100 | Concurrent connections |
|
|
76
|
+
| `--idle <seconds>` | 30 | **Maximum** wait before each sample; the run continues as soon as the heap settles |
|
|
77
|
+
| `--quick` | off | Fast preset (2000 requests × 4 cycles, 8s idle) — the exact profile the real-app validation ran with. Explicit flags override it |
|
|
78
|
+
| `--diff-all` | off | Diff snapshots for stable routes too |
|
|
79
|
+
| `--output <dir>` | `<app>/.next-leak` | Where runs are written |
|
|
80
|
+
|
|
81
|
+
Dynamic routes need sample params in `next-leak.config.json` in your app dir:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"params": { "lang": "en" },
|
|
86
|
+
"routes": { "/products/[id]": { "id": "42" } },
|
|
87
|
+
"headers": { "accept-encoding": "gzip, br", "cookie": "session=..." }
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
- **`headers`** are sent with every request. Real traffic is not header-less:
|
|
92
|
+
compression, sessions and auth change which code paths run, and some leaks
|
|
93
|
+
only live on those paths.
|
|
94
|
+
- **`{n}` inside a param value** makes every request use a *unique* URL
|
|
95
|
+
(`{ "id": "item-{n}" }` → `/logs/item-1`, `/logs/item-2`, …). Leaks keyed by
|
|
96
|
+
URL — route caches, LRUs, bot traffic with varied tails — are invisible
|
|
97
|
+
without it.
|
|
98
|
+
- **`query`** appends a query string per route template
|
|
99
|
+
(`{ "/api/payload/[slug]": "weightKb=2048" }`).
|
|
100
|
+
- **`abandonAfterMs`** makes clients hang up before the response arrives, the
|
|
101
|
+
way closed tabs, load-balancer timeouts and bots do. Some leaks only exist
|
|
102
|
+
on that path (`ServerResponse` retained after an early disconnect). Requests
|
|
103
|
+
abandoned on purpose are not counted as failures.
|
|
104
|
+
|
|
105
|
+
`run.json` records what every load phase actually did — requests sent,
|
|
106
|
+
2xx, abandoned — so a run can be audited instead of trusted.
|
|
107
|
+
|
|
108
|
+
Before measuring, the CLI prints a duration estimate — a 60-route app under defaults is **hours**; narrow with `--routes` for iteration.
|
|
109
|
+
|
|
110
|
+
## Reading the verdicts
|
|
111
|
+
|
|
112
|
+
- **`stable`** — done, stop hunting. The report proves it. If the heap is flat
|
|
113
|
+
but RSS keeps climbing, the report says so explicitly: that is an allocator,
|
|
114
|
+
external-buffer or fragmentation problem, not a JS-heap leak.
|
|
115
|
+
- **`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.
|
|
116
|
+
- **`inconclusive`** — sustained sub-threshold growth: measure longer. The CLI prints the exact re-run command (`--routes <those> --cycles 6`).
|
|
117
|
+
- **`failed`** — the route errored under load (auth redirects, POST-only endpoints). >1% non-2xx aborts measurement instead of measuring garbage. That's by design.
|
|
118
|
+
|
|
119
|
+
## The tool grades its own measurement
|
|
120
|
+
|
|
121
|
+
A leak detector is an instrument, and a miscalibrated instrument doesn't fail
|
|
122
|
+
loudly — it reports confident, wrong numbers. So every run is audited against
|
|
123
|
+
its own evidence, and anything that undermines a verdict is printed next to it:
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
✖ /api/items leak (+3.10 MB/1000 req) heap 28.4 MB → 41.9 MB → …
|
|
127
|
+
⚠ low confidence: cycle 2 landed 4310 of 5000 requests (86.2%) — the route
|
|
128
|
+
saw less traffic than reported
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
What gets checked: whether the heap actually held still before each sample,
|
|
132
|
+
whether the requests you asked for really landed, whether an early-disconnect
|
|
133
|
+
run disconnected anything, whether one cycle dominates the average, and
|
|
134
|
+
whether the growth barely clears the noise floor.
|
|
135
|
+
|
|
136
|
+
When the run didn't observe what a `leak` verdict requires — the heap never
|
|
137
|
+
settled, or an abandonment run abandoned nothing — the verdict is **withdrawn**
|
|
138
|
+
and reported as `inconclusive`, with what was measured still on the record. A
|
|
139
|
+
withdrawn verdict produces no `ISSUE-*.md` draft: only a verdict the evidence
|
|
140
|
+
supports is worth pasting into someone else's tracker. Caveats that don't
|
|
141
|
+
overturn a verdict still travel with the draft, under *Measurement caveats*.
|
|
142
|
+
|
|
143
|
+
Stable verdicts are never withdrawn. Quietly missing a leak costs you less
|
|
144
|
+
than a false accusation, and the warnings are on the report either way.
|
|
145
|
+
|
|
146
|
+
## Every run leaves evidence
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
.next-leak/<timestamp>/
|
|
150
|
+
├── report.html # heap curves per route — self-contained, opens offline
|
|
151
|
+
├── ISSUE-<route>.md # issue draft per leaking route (Next.js bug-template shape)
|
|
152
|
+
├── run.json # everything, machine-readable: environment, per-phase
|
|
153
|
+
│ # timings, heap AND RSS samples per cycle, what each
|
|
154
|
+
│ # load phase actually did, and the confidence audit
|
|
155
|
+
└── <nn>-<route>/ # raw baseline/after .heapsnapshot per route
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Snapshots are the ground truth: load them in Chrome DevTools (Memory → Load → Comparison) and check every claim yourself. Runs accumulate — each keeps its snapshots (tens of MB per route); delete old timestamp folders when done.
|
|
159
|
+
|
|
160
|
+
## Why not just use…
|
|
161
|
+
|
|
162
|
+
| | What it gives you | Where it stops |
|
|
163
|
+
|---|---|---|
|
|
164
|
+
| **Chrome DevTools** | The ground truth: two snapshots and a comparison view | You reproduce the load, force the GC, pick the moments and read the retainers yourself. Doing it *correctly* is the hard part |
|
|
165
|
+
| **[memlab](https://github.com/facebook/memlab)** | A superb heap-analysis engine — next-leak **uses it** to parse snapshots | It is built around browser scenarios you script. It does not drive HTTP load against your routes, and it knows nothing about Next.js route manifests or your bundle's source maps |
|
|
166
|
+
| **[clinic.js](https://github.com/clinicjs/node-clinic)** | Broad Node performance profiling | [Its own README](https://github.com/clinicjs/node-clinic#readme) states it is no longer actively maintained |
|
|
167
|
+
| **`--inspect` + manual snapshots** | Full control | Same as DevTools, plus you must keep the process, the load and the snapshots in sync by hand |
|
|
168
|
+
|
|
169
|
+
What next-leak adds is not analysis — it is **the controlled experiment around
|
|
170
|
+
it**: a fresh process per route, warm-up before the baseline, forced GC and an
|
|
171
|
+
adaptive idle before every sample, an audit of whether the load it claims to
|
|
172
|
+
have sent actually landed, and a verdict from the curve's shape rather than
|
|
173
|
+
absolute sizes. Then it maps the retaining objects back to *your* source files
|
|
174
|
+
through the build's source maps.
|
|
175
|
+
|
|
176
|
+
## Scope and limits (read before filing issues)
|
|
177
|
+
|
|
178
|
+
- **Supported:** App Router · `output: "standalone"` · Node ≥ 22 · Linux/macOS. Pages Router, non-standalone, and Windows are rejected with a clear message.
|
|
179
|
+
- **Architectures:** verified on **arm64 and x64** (linux/amd64 in Docker) — same app, same parameters, same verdicts.
|
|
180
|
+
- **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.
|
|
181
|
+
- 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.
|
|
182
|
+
- Borderline routes can flip between `stable`/`leak` across runs — more cycles resolves this.
|
|
183
|
+
- The measured app runs with its real environment: routes that call external services will call them under load. Scope with `--routes` and moderate `--requests` accordingly.
|
|
184
|
+
|
|
185
|
+
## Development
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
pnpm install
|
|
189
|
+
pnpm typecheck && pnpm test && pnpm build
|
|
190
|
+
pnpm pack:smoke # release gate: installs the real tarball and measures the fixture app
|
|
191
|
+
pnpm test:mutation # Stryker — slow; run before releases, weekly in CI
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
CI runs typecheck, tests, build, `pnpm audit --prod` and the pack smoke on
|
|
195
|
+
Node 22 and 24; mutation testing runs weekly and uploads its report.
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
MIT
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type AbandonPhaseOptions = {
|
|
2
|
+
url: string;
|
|
3
|
+
amount: number;
|
|
4
|
+
connections: number;
|
|
5
|
+
/** Destroy the socket this many ms after sending the request. */
|
|
6
|
+
abandonAfterMs: number;
|
|
7
|
+
headers?: Record<string, string>;
|
|
8
|
+
};
|
|
9
|
+
export type AbandonPhaseResult = {
|
|
10
|
+
sent: number;
|
|
11
|
+
abandoned: number;
|
|
12
|
+
/**
|
|
13
|
+
* Abandonments where the server had already started responding. Only these
|
|
14
|
+
* exercise mid-stream teardown; cutting before the first byte tests a
|
|
15
|
+
* different path (the server may never have begun rendering).
|
|
16
|
+
*/
|
|
17
|
+
abandonedMidStream: number;
|
|
18
|
+
completed: number;
|
|
19
|
+
errors: number;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Sends requests and hangs up before the response arrives.
|
|
23
|
+
*
|
|
24
|
+
* autocannon cannot express this: its `timeout` is in whole seconds, so
|
|
25
|
+
* against a route answering in milliseconds nothing is ever abandoned. Yet
|
|
26
|
+
* several real leaks live exactly on that path — vercel/next.js#89091 traces
|
|
27
|
+
* `ServerResponse` retention to an early disconnect, which only happens when
|
|
28
|
+
* a client goes away mid-flight (closed tabs, load-balancer timeouts, bots).
|
|
29
|
+
*
|
|
30
|
+
* Raw sockets keep this honest: write the request, wait `abandonAfterMs`,
|
|
31
|
+
* destroy the socket. No response is read.
|
|
32
|
+
*/
|
|
33
|
+
export declare function runAbandonPhase(options: AbandonPhaseOptions): Promise<AbandonPhaseResult>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { HeapDiff, NodeFinding } from "./heap-diff.js";
|
|
2
|
+
import type { ModuleRegistry } from "./module-registry.js";
|
|
3
|
+
export type Owner = "app" | "dependency" | "framework" | "unattributed";
|
|
4
|
+
export type FindingAttribution = {
|
|
5
|
+
owner: Owner;
|
|
6
|
+
/** Display path relative to the project (e.g. `src/app/leaky/page.tsx`). */
|
|
7
|
+
source: string | null;
|
|
8
|
+
packageName: string | null;
|
|
9
|
+
};
|
|
10
|
+
export type RouteAttribution = FindingAttribution & {
|
|
11
|
+
/** Share of attributed retained bytes held by the winning owner+source. */
|
|
12
|
+
dominance: number;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Classifies one bundler source path. Handles both real-world dialects seen
|
|
16
|
+
* in Turbopack builds: `[project]/…` prefixes (Next 16.0) and URL-encoded
|
|
17
|
+
* relative paths like `../../../node_modules/.pnpm/%40scope%2Bpkg@1/…`
|
|
18
|
+
* (Next 16.2 sectioned maps). Exported for direct unit testing.
|
|
19
|
+
*/
|
|
20
|
+
export declare function classifySource(rawSource: string): FindingAttribution;
|
|
21
|
+
/** Best-effort owner for chains the module registry cannot resolve. */
|
|
22
|
+
export declare function classifyByChain(retainerChain: string): FindingAttribution | null;
|
|
23
|
+
/**
|
|
24
|
+
* Resolves a finding's harvested module ids against the registry. When a
|
|
25
|
+
* chain crosses several modules (e.g. Next's page-template wrapper retaining
|
|
26
|
+
* the user's page module), the most user-actionable owner wins: app over
|
|
27
|
+
* dependency over framework. Ties keep chain order (closest to the leak).
|
|
28
|
+
*/
|
|
29
|
+
export declare function attributeFinding(finding: Pick<NodeFinding, "moduleIds" | "retainerChain">, registry: ModuleRegistry): FindingAttribution;
|
|
30
|
+
export type AttributedDiff = {
|
|
31
|
+
/** Aligned with `[...diff.grownNodes, ...diff.newNodes]`. */
|
|
32
|
+
findings: FindingAttribution[];
|
|
33
|
+
route: RouteAttribution;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Attributes every finding and derives the route-level verdict: the
|
|
37
|
+
* owner+source group holding the most attributed retained bytes wins;
|
|
38
|
+
* with nothing attributed the route stays `unattributed`.
|
|
39
|
+
*/
|
|
40
|
+
export declare function attributeDiff(diff: HeapDiff, registry: ModuleRegistry): AttributedDiff;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createRequire as __nextLeakCreateRequire } from 'node:module';import { fileURLToPath as __nextLeakFileURLToPath } from 'node:url';import { dirname as __nextLeakDirname } from 'node:path';const require = __nextLeakCreateRequire(import.meta.url);const __filename = __nextLeakFileURLToPath(import.meta.url);const __dirname = __nextLeakDirname(__filename);
|
|
2
|
+
import "./chunk-OSZ6ND6E.js";
|
|
3
|
+
|
|
4
|
+
// src/bootstrap.ts
|
|
5
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
6
|
+
import path2 from "path";
|
|
7
|
+
|
|
8
|
+
// src/control-server.ts
|
|
9
|
+
import http from "http";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import { writeHeapSnapshot } from "v8";
|
|
12
|
+
var g = globalThis;
|
|
13
|
+
var tick = () => new Promise((resolve) => setImmediate(resolve));
|
|
14
|
+
async function forceGc(passes = 3) {
|
|
15
|
+
if (typeof g.gc !== "function") {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
for (let i = 0; i < passes; i += 1) {
|
|
19
|
+
g.gc();
|
|
20
|
+
await tick();
|
|
21
|
+
}
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
function sampleMemory(gcExposed) {
|
|
25
|
+
const usage = process.memoryUsage();
|
|
26
|
+
return {
|
|
27
|
+
gcExposed,
|
|
28
|
+
heapUsed: usage.heapUsed,
|
|
29
|
+
rss: usage.rss,
|
|
30
|
+
external: usage.external,
|
|
31
|
+
arrayBuffers: usage.arrayBuffers
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
async function startControlServer(options) {
|
|
35
|
+
const write = options.writeSnapshot ?? writeHeapSnapshot;
|
|
36
|
+
const server = http.createServer((request, response) => {
|
|
37
|
+
void handle(request, response);
|
|
38
|
+
});
|
|
39
|
+
async function handle(request, response) {
|
|
40
|
+
const url = new URL(request.url ?? "/", "http://control.local");
|
|
41
|
+
const respond = (status, body) => {
|
|
42
|
+
response.writeHead(status, { "content-type": "application/json" });
|
|
43
|
+
response.end(JSON.stringify(body));
|
|
44
|
+
};
|
|
45
|
+
try {
|
|
46
|
+
if (url.pathname === "/gc") {
|
|
47
|
+
const gcExposed = await forceGc();
|
|
48
|
+
respond(200, sampleMemory(gcExposed));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (url.pathname === "/snapshot") {
|
|
52
|
+
const name = url.searchParams.get("name");
|
|
53
|
+
if (name === null || name === "") {
|
|
54
|
+
respond(400, { error: "missing ?name=<label>" });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const gcExposed = await forceGc();
|
|
58
|
+
const sample = sampleMemory(gcExposed);
|
|
59
|
+
const file = write(
|
|
60
|
+
path.join(options.snapshotDir, `${path.basename(name)}.heapsnapshot`)
|
|
61
|
+
);
|
|
62
|
+
respond(200, { file, sample });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
respond(404, { error: `unknown path ${url.pathname}` });
|
|
66
|
+
} catch (cause) {
|
|
67
|
+
respond(500, { error: cause instanceof Error ? cause.message : String(cause) });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
await new Promise((resolve, reject) => {
|
|
71
|
+
server.once("error", reject);
|
|
72
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
73
|
+
});
|
|
74
|
+
server.unref();
|
|
75
|
+
const address = server.address();
|
|
76
|
+
if (address === null || typeof address === "string") {
|
|
77
|
+
throw new Error("control server has no bound port");
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
port: address.port,
|
|
81
|
+
close: () => new Promise((resolve, reject) => {
|
|
82
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
83
|
+
})
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// src/bootstrap.ts
|
|
88
|
+
var workDir = process.env["NEXT_LEAK_DIR"];
|
|
89
|
+
if (workDir !== void 0 && workDir !== "") {
|
|
90
|
+
try {
|
|
91
|
+
await mkdir(workDir, { recursive: true });
|
|
92
|
+
const server = await startControlServer({ snapshotDir: workDir });
|
|
93
|
+
await writeFile(
|
|
94
|
+
path2.join(workDir, `control-${process.pid}.json`),
|
|
95
|
+
JSON.stringify({ port: server.port, pid: process.pid })
|
|
96
|
+
);
|
|
97
|
+
} catch (cause) {
|
|
98
|
+
console.error(`[next-leak] control channel failed to start: ${String(cause)}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createRequire as __nextLeakCreateRequire } from 'node:module';import { fileURLToPath as __nextLeakFileURLToPath } from 'node:url';import { dirname as __nextLeakDirname } from 'node:path';const require = __nextLeakCreateRequire(import.meta.url);const __filename = __nextLeakFileURLToPath(import.meta.url);const __dirname = __nextLeakDirname(__filename);
|
|
2
|
+
import {
|
|
3
|
+
effectiveVerdict
|
|
4
|
+
} from "./chunk-K5PZFVJH.js";
|
|
5
|
+
|
|
6
|
+
// src/html-report.ts
|
|
7
|
+
var MB = 1024 * 1024;
|
|
8
|
+
var VERDICT_COLOR = { leak: "#c0392b", stable: "#27ae60", inconclusive: "#e67e22" };
|
|
9
|
+
function escapeHtml(value) {
|
|
10
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
11
|
+
}
|
|
12
|
+
function heapCurveSvg(samples, color) {
|
|
13
|
+
const width = 320;
|
|
14
|
+
const height = 96;
|
|
15
|
+
const pad = 8;
|
|
16
|
+
const min = Math.min(...samples);
|
|
17
|
+
const max = Math.max(...samples);
|
|
18
|
+
const span = Math.max(max - min, 1);
|
|
19
|
+
const points = samples.map((sample, index) => {
|
|
20
|
+
const x = pad + index * (width - 2 * pad) / Math.max(samples.length - 1, 1);
|
|
21
|
+
const y = height - pad - (sample - min) * (height - 2 * pad) / span;
|
|
22
|
+
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
|
23
|
+
}).join(" ");
|
|
24
|
+
const labels = `<text x="${pad}" y="10" class="axis">${(max / MB).toFixed(1)} MB</text><text x="${pad}" y="${height - 1}" class="axis">${(min / MB).toFixed(1)} MB</text>`;
|
|
25
|
+
return `<svg viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img">${labels}<polyline points="${points}" fill="none" stroke="${color}" stroke-width="2"/>` + samples.map((sample, index) => {
|
|
26
|
+
const x = pad + index * (width - 2 * pad) / Math.max(samples.length - 1, 1);
|
|
27
|
+
const y = height - pad - (sample - min) * (height - 2 * pad) / span;
|
|
28
|
+
return `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="2.5" fill="${color}"/>`;
|
|
29
|
+
}).join("") + `</svg>`;
|
|
30
|
+
}
|
|
31
|
+
function measuredSection(route) {
|
|
32
|
+
if (route.status !== "measured") {
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
const verdict = effectiveVerdict(route);
|
|
36
|
+
const color = VERDICT_COLOR[verdict];
|
|
37
|
+
const withdrawn = route.confidence.supersededVerdict === void 0 ? "" : `<p class="warn">Measured <strong>${route.trend.verdict}</strong>, withdrawn: the run did not observe what that verdict needs.</p>`;
|
|
38
|
+
const warnings = route.confidence.warnings.length === 0 ? "" : `<ul class="warn">${route.confidence.warnings.map((warning) => `<li>${escapeHtml(warning.detail)}</li>`).join("")}</ul>`;
|
|
39
|
+
const curve = route.samples.map((sample) => (sample / MB).toFixed(1)).join(" \u2192 ");
|
|
40
|
+
const findings = [...route.diff?.grownNodes ?? [], ...route.diff?.newNodes ?? []];
|
|
41
|
+
const findingRows = findings.slice(0, 6).map((finding, index) => {
|
|
42
|
+
const attribution = route.attribution?.findings[index];
|
|
43
|
+
const owner = attribution === void 0 || attribution.owner === "unattributed" ? "\u2014" : `${attribution.owner}${attribution.source ? `: ${escapeHtml(attribution.source)}` : ""}${attribution.packageName ? ` (${escapeHtml(attribution.packageName)})` : ""}`;
|
|
44
|
+
return `<tr><td>${finding.kind}</td><td>${escapeHtml(finding.nodeType)}</td><td>${escapeHtml(finding.name)}</td><td>${(finding.retainedBytes / MB).toFixed(2)} MB</td><td>${owner}</td></tr>`;
|
|
45
|
+
}).join("");
|
|
46
|
+
return `<section><h2><span class="badge" style="background:${color}">${verdict}</span> <code>${escapeHtml(route.route)}</code></h2>` + heapCurveSvg(route.samples, color) + `<p class="curve">heap ${curve} MB \xB7 ${(route.growthPer1000Requests / MB).toFixed(2)} MB/1000 req</p>` + withdrawn + warnings + (findingRows === "" ? "" : `<table><tr><th>kind</th><th>type</th><th>node</th><th>retained</th><th>owner</th></tr>${findingRows}</table>`) + `</section>`;
|
|
47
|
+
}
|
|
48
|
+
function renderHtmlReport(run) {
|
|
49
|
+
const measured = run.routes.filter((route) => route.status === "measured");
|
|
50
|
+
const skipped = run.routes.filter((route) => route.status === "skipped");
|
|
51
|
+
const failed = run.routes.filter((route) => route.status === "failed");
|
|
52
|
+
const environment = run.environment;
|
|
53
|
+
return `<!doctype html>
|
|
54
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
55
|
+
<title>next-leak \u2014 ${escapeHtml(run.appDir)}</title>
|
|
56
|
+
<style>
|
|
57
|
+
body{font:14px/1.5 system-ui,sans-serif;max-width:760px;margin:2rem auto;padding:0 1rem;color:#222}
|
|
58
|
+
h1{font-size:1.3rem} h2{font-size:1rem;margin:1.5rem 0 .3rem}
|
|
59
|
+
.badge{color:#fff;border-radius:4px;padding:1px 8px;font-size:.8rem}
|
|
60
|
+
table{border-collapse:collapse;font-size:.85rem;margin:.5rem 0}
|
|
61
|
+
td,th{border:1px solid #ddd;padding:2px 8px;text-align:left}
|
|
62
|
+
.curve,.meta{color:#555;font-size:.85rem} .axis{font-size:9px;fill:#888}
|
|
63
|
+
.warn{color:#8a5a00;background:#fff8e6;border-left:3px solid #e67e22;padding:.4rem .7rem;font-size:.85rem}
|
|
64
|
+
code{background:#f4f4f4;padding:0 4px;border-radius:3px}
|
|
65
|
+
</style></head><body>
|
|
66
|
+
<h1>next-leak report</h1>
|
|
67
|
+
<p class="meta">${escapeHtml(run.appDir)} \xB7 ${escapeHtml(run.startedAt)} \xB7 node ${escapeHtml(
|
|
68
|
+
environment.nodeVersion
|
|
69
|
+
)} \xB7 ${escapeHtml(environment.platform)}/${escapeHtml(environment.arch)} \xB7 next ${escapeHtml(
|
|
70
|
+
environment.nextVersion ?? "unknown"
|
|
71
|
+
)} \xB7 next-leak ${escapeHtml(environment.nextLeakVersion)}</p>
|
|
72
|
+
${measured.map(measuredSection).join("\n")}
|
|
73
|
+
${skipped.length === 0 ? "" : `<h2>Skipped</h2><ul>${skipped.map((route) => `<li><code>${escapeHtml(route.route)}</code> \u2014 ${escapeHtml(route.status === "skipped" ? route.reason : "")}</li>`).join("")}</ul>`}
|
|
74
|
+
${failed.length === 0 ? "" : `<h2>Failed</h2><ul>${failed.map((route) => `<li><code>${escapeHtml(route.route)}</code> \u2014 ${escapeHtml(route.status === "failed" ? route.reason : "")}</li>`).join("")}</ul>`}
|
|
75
|
+
<p class="meta">Raw snapshots and run.json live next to this file \u2014 verify in Chrome DevTools \u2192 Memory \u2192 Load.</p>
|
|
76
|
+
</body></html>`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export {
|
|
80
|
+
renderHtmlReport
|
|
81
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createRequire as __nextLeakCreateRequire } from 'node:module';import { fileURLToPath as __nextLeakFileURLToPath } from 'node:url';import { dirname as __nextLeakDirname } from 'node:path';const require = __nextLeakCreateRequire(import.meta.url);const __filename = __nextLeakFileURLToPath(import.meta.url);const __dirname = __nextLeakDirname(__filename);
|
|
2
|
+
|
|
3
|
+
// src/issue-report.ts
|
|
4
|
+
import path from "path";
|
|
5
|
+
var MB = 1024 * 1024;
|
|
6
|
+
function evidenceRows(route) {
|
|
7
|
+
const findings = [...route.diff?.grownNodes ?? [], ...route.diff?.newNodes ?? []];
|
|
8
|
+
return findings.slice(0, 6).map((finding, index) => {
|
|
9
|
+
const attribution = route.attribution?.findings[index];
|
|
10
|
+
const owner = attribution === void 0 || attribution.owner === "unattributed" ? "unattributed" : `${attribution.owner}${attribution.source ? ` \u2014 \`${attribution.source}\`` : ""}${attribution.packageName ? ` \u2014 ${attribution.packageName}` : ""}`;
|
|
11
|
+
return `- **${finding.kind}** \`[${finding.nodeType}] ${finding.name}\` ${(finding.retainedBytes / MB).toFixed(2)} MB retained (${owner})
|
|
12
|
+
- retainers: \`${finding.retainerChain || "(none)"}\``;
|
|
13
|
+
}).join("\n");
|
|
14
|
+
}
|
|
15
|
+
function renderIssueMarkdown(route, run) {
|
|
16
|
+
const environment = run.environment;
|
|
17
|
+
const parameters = run.parameters;
|
|
18
|
+
const owner = route.attribution?.route.owner ?? "unattributed";
|
|
19
|
+
const culprit = route.attribution?.route;
|
|
20
|
+
const preamble = owner === "app" || owner === "dependency" ? `> [!WARNING]
|
|
21
|
+
> next-leak attributes this leak to ${owner === "app" ? `**your own code** (\`${culprit?.source ?? "app code"}\`)` : `the dependency **${culprit?.packageName ?? "a dependency"}**`}. Fix or report it there \u2014 do **not** file this against Next.js.
|
|
22
|
+
|
|
23
|
+
` : "";
|
|
24
|
+
const caveats = route.confidence.warnings.length === 0 ? "" : `
|
|
25
|
+
### Measurement caveats
|
|
26
|
+
|
|
27
|
+
next-leak audits its own run and reports these limits. They do not overturn the verdict above, but they bound how much weight it carries:
|
|
28
|
+
|
|
29
|
+
` + route.confidence.warnings.map((warning) => `- ${warning.detail}`).join("\n") + `
|
|
30
|
+
`;
|
|
31
|
+
const curve = route.samples.map((sample) => (sample / MB).toFixed(1)).join(" \u2192 ");
|
|
32
|
+
const deltas = route.trend.deltas.map((delta) => `+${(delta / MB).toFixed(2)}`).join(", ");
|
|
33
|
+
const signatures = route.signatures.map(
|
|
34
|
+
(signature) => `- ${signature.historical ? "(historical) " : ""}${signature.title} \u2014 ${signature.issue}`
|
|
35
|
+
).join("\n");
|
|
36
|
+
return `${preamble}# Memory leak on route \`${route.route}\`
|
|
37
|
+
|
|
38
|
+
Report generated by \`next-leak\` \u2014 every number below is reproducible from
|
|
39
|
+
the raw heap snapshots referenced at the bottom.
|
|
40
|
+
|
|
41
|
+
### Provide environment information
|
|
42
|
+
|
|
43
|
+
\`\`\`
|
|
44
|
+
Node.js: ${environment.nodeVersion}
|
|
45
|
+
OS: ${environment.platform} ${environment.arch}${environment.cpuModel ? ` (${environment.cpuModel})` : ""}
|
|
46
|
+
Memory: ${(environment.totalMemoryBytes / (1024 * MB)).toFixed(0)} GB
|
|
47
|
+
Next.js: ${environment.nextVersion ?? "unknown"}
|
|
48
|
+
next-leak: ${environment.nextLeakVersion}
|
|
49
|
+
Deployment: output "standalone", node --expose-gc --max-old-space-size=512
|
|
50
|
+
\`\`\`
|
|
51
|
+
|
|
52
|
+
### To Reproduce
|
|
53
|
+
|
|
54
|
+
1. Build the app with \`output: "standalone"\` and run:
|
|
55
|
+
\`\`\`
|
|
56
|
+
npx next-leak <app-dir>
|
|
57
|
+
\`\`\`
|
|
58
|
+
2. next-leak boots \`.next/standalone/server.js\` in a fresh process per route and runs, against \`${route.requestPath}\`:
|
|
59
|
+
warm-up ${parameters.warmupRequests} requests \u2192 forced GC \u2192 baseline heap snapshot \u2192
|
|
60
|
+
${parameters.cycles} \xD7 [${parameters.loadRequests} requests at ${parameters.connections} connections \u2192 ${(parameters.idleMs / 1e3).toFixed(0)}s idle \u2192 forced GC \u2192 post-GC sample] \u2192 final snapshot.
|
|
61
|
+
|
|
62
|
+
### Current vs. Expected behavior
|
|
63
|
+
|
|
64
|
+
**Current:** retained heap after forced GC keeps growing every cycle \u2014
|
|
65
|
+
${curve} MB (per-cycle deltas ${deltas} MB), \u2248 **${(route.growthPer1000Requests / MB).toFixed(2)} MB per 1000 requests**.
|
|
66
|
+
The first cycle is excluded from the verdict (engine warm-up).
|
|
67
|
+
|
|
68
|
+
**Expected:** retained heap flattens after warm-up (plateau), as measured on this app's healthy routes.
|
|
69
|
+
|
|
70
|
+
### Heap evidence
|
|
71
|
+
|
|
72
|
+
${evidenceRows(route) || "- (no findings above thresholds)"}
|
|
73
|
+
${signatures === "" ? "" : `
|
|
74
|
+
**Matched known causes:**
|
|
75
|
+
${signatures}
|
|
76
|
+
`}${caveats}
|
|
77
|
+
### Verify it yourself
|
|
78
|
+
|
|
79
|
+
Raw snapshots (Chrome DevTools \u2192 Memory \u2192 Load, compare baseline vs after):
|
|
80
|
+
|
|
81
|
+
- \`${path.basename(route.baselineSnapshot)}\` / \`${path.basename(route.afterSnapshot)}\` in \`${run.workDir}\`
|
|
82
|
+
- machine-readable data: \`run.json\`, curves: \`report.html\`
|
|
83
|
+
`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export {
|
|
87
|
+
renderIssueMarkdown
|
|
88
|
+
};
|