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 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
+ [![npm](https://img.shields.io/npm/v/next-leak.svg)](https://www.npmjs.com/package/next-leak)
4
+ [![CI](https://github.com/xabierlameiro/next-leak/actions/workflows/ci.yml/badge.svg)](https://github.com/xabierlameiro/next-leak/actions/workflows/ci.yml)
5
+ [![node](https://img.shields.io/node/v/next-leak.svg)](https://nodejs.org)
6
+ [![license](https://img.shields.io/npm/l/next-leak.svg)](./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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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
+ };