next-leak 0.1.0 → 0.1.3

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
@@ -7,6 +7,8 @@
7
7
 
8
8
  > Find out whether your Next.js app actually leaks memory — how much, on which route, and whose fault it is.
9
9
 
10
+ <img src="https://raw.githubusercontent.com/xabierlameiro/next-leak/main/docs/demo.svg" alt="next-leak finding a real Next.js memory leak (43-second run, idle time compressed)" width="720">
11
+
10
12
  ```
11
13
  $ npx next-leak . --quick
12
14
 
@@ -35,10 +37,27 @@ holds it — without being told what to look for.
35
37
  | [#84884](https://github.com/vercel/next.js/issues/84884) | axios + `AbortSignal` in middleware | **Reproduced** · 32.8 → 369.9 MB |
36
38
  | [#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
39
 
40
+ The full causal chain, measured on that same issue: leak found (28.7 -> 138.9 MB
41
+ across 8 cycles), the workaround from the thread applied (`clearTimeout(id)`
42
+ inside the callback), same app re-measured with identical parameters:
43
+ **27.8 -> 25.6 MB, flat**. That is what a diagnostic tool should prove - not
44
+ that installing it saves memory, but that what it points at is the real cause.
45
+
38
46
  Across ~25 healthy routes on production applications (PPR, MDX, Auth.js,
39
47
  Sentry, i18n), it reported **zero false positives**.
40
48
 
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.
49
+ Your self-hosted Next.js server's memory climbs until Node gives up:
50
+
51
+ ```
52
+ FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
53
+ ```
54
+
55
+ Under Docker or Kubernetes you may not even get that: the process is `OOMKilled`,
56
+ the container exits with **code 137**, and the restart wipes the evidence before
57
+ you can look at it. Almost every report of this ends the same way — *"please
58
+ provide heap snapshots taken after forced GC"* — which almost nobody produces
59
+ correctly. `next-leak` runs that controlled measurement for you and answers with
60
+ evidence a maintainer would accept.
42
61
 
43
62
  Three possible answers, all valuable:
44
63
 
@@ -107,6 +126,20 @@ Dynamic routes need sample params in `next-leak.config.json` in your app dir:
107
126
 
108
127
  Before measuring, the CLI prints a duration estimate — a 60-route app under defaults is **hours**; narrow with `--routes` for iteration.
109
128
 
129
+ ## What it tells apart
130
+
131
+ "Memory leak" is one name for six different situations. The verdict machinery
132
+ separates them, because each one has a different fix:
133
+
134
+ | Looks like a leak | What next-leak reports | How it knows |
135
+ |---|---|---|
136
+ | One-time warm-up growth (JIT, lazy caches) | `stable` | The first cycle is excluded from the verdict; warm-up flattens, leaks keep climbing |
137
+ | A route that is expensive, not leaky | `failed` under load it cannot sustain, flat once concurrency fits | Real leaks survive forced GC at any concurrency; saturation disappears when load drops |
138
+ | Growth that pauses and resumes (stepwise) | `leak` | A healthy route gives back 20-30% of its growth; a stepwise leak gives back nothing |
139
+ | Native/buffer memory with a flat JS heap | `leak (external)` or an explicit RSS note | Heap, `external` and RSS are sampled and judged separately |
140
+ | A leak in your code vs a dependency vs Next itself | `culprit: src/app/x/page.tsx (your code)` — or the package, or framework internals | Retainer chains mapped through the build's source maps |
141
+ | A run whose own evidence is weak | `low confidence` warnings, or the verdict is withdrawn | Every run audits itself: did the load land, did the heap settle, does one cycle carry the average |
142
+
110
143
  ## Reading the verdicts
111
144
 
112
145
  - **`stable`** — done, stop hunting. The report proves it. If the heap is flat
@@ -18,8 +18,6 @@ export type RouteAttribution = FindingAttribution & {
18
18
  * (Next 16.2 sectioned maps). Exported for direct unit testing.
19
19
  */
20
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
21
  /**
24
22
  * Resolves a finding's harvested module ids against the registry. When a
25
23
  * chain crosses several modules (e.g. Next's page-template wrapper retaining
package/dist/bootstrap.js CHANGED
@@ -1,5 +1,5 @@
1
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";
2
+ import "./chunk-6XYFBOL2.js";
3
3
 
4
4
  // src/bootstrap.ts
5
5
  import { mkdir, writeFile } from "fs/promises";
@@ -0,0 +1,129 @@
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/confidence.ts
4
+ function effectiveVerdict(report) {
5
+ return report.confidence.supersededVerdict ?? report.trend.verdict;
6
+ }
7
+ var VERDICT_WEAKENING = /* @__PURE__ */ new Set([
8
+ "near-threshold",
9
+ "spiky-growth"
10
+ ]);
11
+ function warrantsIssueDraft(report) {
12
+ return effectiveVerdict(report) === "leak" && !report.confidence.warnings.some((warning) => VERDICT_WEAKENING.has(warning.code));
13
+ }
14
+ var DEFAULT_MIN_GROWTH = 256 * 1024;
15
+ var LOAD_COMPLETION_FLOOR = 0.99;
16
+ var ABANDON_EFFECTIVE_FLOOR = 0.9;
17
+ var MID_STREAM_FLOOR = 0.1;
18
+ var SPIKE_RATIO = 4;
19
+ var NOISE_FLOOR_MULTIPLE = 2;
20
+ var mb = (bytes) => `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
21
+ var pct = (part, whole) => `${(part / whole * 100).toFixed(1)}%`;
22
+ function settleWarnings(outcomes) {
23
+ const warnings = [];
24
+ const moving = outcomes.filter((outcome) => outcome.status === "moving");
25
+ if (moving.length > 0) {
26
+ warnings.push({
27
+ code: "unsettled",
28
+ detail: `the heap never held steady before sampling on ${moving.map((outcome) => outcome.phase).join(", ")} \u2014 raise --idle-ms so post-load transients finish draining`
29
+ });
30
+ }
31
+ const unverified = outcomes.filter((outcome) => outcome.status === "unknown");
32
+ if (unverified.length > 0) {
33
+ warnings.push({
34
+ code: "settle-unverified",
35
+ detail: `the idle budget was too short to check whether the heap had settled on ${unverified.map((outcome) => outcome.phase).join(", ")} \u2014 the samples may include post-load transients`
36
+ });
37
+ }
38
+ return warnings;
39
+ }
40
+ function abandonmentWarnings(outcome) {
41
+ const abandoned = outcome.abandoned ?? 0;
42
+ if (outcome.sent > 0 && abandoned < outcome.sent * ABANDON_EFFECTIVE_FLOOR) {
43
+ return [{
44
+ code: "abandon-ineffective",
45
+ detail: `${outcome.phase} disconnected early on only ${abandoned} of ${outcome.sent} requests (${pct(abandoned, outcome.sent)}) \u2014 the early-disconnect path was largely not exercised`
46
+ }];
47
+ }
48
+ const midStream = outcome.abandonedMidStream ?? 0;
49
+ if (abandoned > 0 && midStream < abandoned * MID_STREAM_FLOOR) {
50
+ return [{
51
+ code: "abandon-before-response",
52
+ detail: `${outcome.phase} cut ${abandoned} requests before the server sent anything (${midStream} mid-stream) \u2014 this tested pre-response disconnects, not mid-stream teardown; raise abandonAfterMs above the route's time-to-first-byte`
53
+ }];
54
+ }
55
+ return [];
56
+ }
57
+ function loadWarnings(outcomes, abandonAfterMs) {
58
+ const warnings = [];
59
+ for (const outcome of outcomes) {
60
+ if (abandonAfterMs !== void 0) {
61
+ warnings.push(...abandonmentWarnings(outcome));
62
+ continue;
63
+ }
64
+ const landed = outcome.ok2xx ?? 0;
65
+ if (outcome.sent > 0 && landed < outcome.sent * LOAD_COMPLETION_FLOOR) {
66
+ warnings.push({
67
+ code: "load-incomplete",
68
+ detail: `${outcome.phase} landed ${landed} of ${outcome.sent} requests (${pct(landed, outcome.sent)}) \u2014 the route saw less traffic than reported`
69
+ });
70
+ }
71
+ }
72
+ return warnings;
73
+ }
74
+ function growthShapeWarnings(trend) {
75
+ const judged = trend.verdict === "leak" || trend.verdict === "inconclusive";
76
+ if (!judged || trend.deltas.length < 2) {
77
+ return [];
78
+ }
79
+ const positive = trend.deltas.filter((delta) => delta > 0);
80
+ if (positive.length !== trend.deltas.length) {
81
+ return [];
82
+ }
83
+ const smallest = Math.min(...positive);
84
+ const largest = Math.max(...positive);
85
+ if (largest <= smallest * SPIKE_RATIO) {
86
+ return [];
87
+ }
88
+ return [{
89
+ code: "spiky-growth",
90
+ detail: `one cycle grew ${mb(largest)} and another ${mb(smallest)} \u2014 the mean of ${mb(trend.growthPerCycle)}/cycle summarizes an uneven series; measure more cycles before quoting it`
91
+ }];
92
+ }
93
+ function noiseFloorWarnings(trend, minGrowth) {
94
+ if (trend.verdict !== "leak" || trend.growthPerCycle >= minGrowth * NOISE_FLOOR_MULTIPLE) {
95
+ return [];
96
+ }
97
+ return [{
98
+ code: "near-threshold",
99
+ detail: `growth of ${mb(trend.growthPerCycle)}/cycle barely clears the ${mb(minGrowth)} threshold \u2014 raise --load-requests so the signal outgrows the noise`
100
+ }];
101
+ }
102
+ function isVerdictInvalid(input) {
103
+ if (input.trend.verdict !== "leak") {
104
+ return false;
105
+ }
106
+ const neverSettled = input.settleOutcomes.length > 0 && input.settleOutcomes.every((outcome) => outcome.status === "moving");
107
+ const abandonedNothing = input.abandonAfterMs !== void 0 && input.loadOutcomes.length > 0 && input.loadOutcomes.every((outcome) => (outcome.abandoned ?? 0) === 0);
108
+ return neverSettled || abandonedNothing;
109
+ }
110
+ function assessConfidence(input) {
111
+ const minGrowth = input.minGrowthPerCycle ?? DEFAULT_MIN_GROWTH;
112
+ const warnings = [
113
+ ...settleWarnings(input.settleOutcomes),
114
+ ...loadWarnings(input.loadOutcomes, input.abandonAfterMs),
115
+ ...growthShapeWarnings(input.trend),
116
+ ...noiseFloorWarnings(input.trend, minGrowth)
117
+ ];
118
+ return {
119
+ level: warnings.length === 0 ? "high" : "low",
120
+ warnings,
121
+ ...isVerdictInvalid(input) && { supersededVerdict: "inconclusive" }
122
+ };
123
+ }
124
+
125
+ export {
126
+ effectiveVerdict,
127
+ warrantsIssueDraft,
128
+ assessConfidence
129
+ };
@@ -3,11 +3,25 @@ import { createRequire as __nextLeakCreateRequire } from 'node:module';import {
3
3
  // src/issue-report.ts
4
4
  import path from "path";
5
5
  var MB = 1024 * 1024;
6
+ function ownerLabel(attribution) {
7
+ if (attribution === void 0 || attribution.owner === "unattributed") {
8
+ return "unattributed";
9
+ }
10
+ const source = attribution.source ? ` \u2014 \`${attribution.source}\`` : "";
11
+ const packageName = attribution.packageName ? ` \u2014 ${attribution.packageName}` : "";
12
+ return `${attribution.owner}${source}${packageName}`;
13
+ }
14
+ function blamedParty(owner, culprit) {
15
+ if (owner === "app") {
16
+ return `**your own code** (\`${culprit?.source ?? "app code"}\`)`;
17
+ }
18
+ return `the dependency **${culprit?.packageName ?? "a dependency"}**`;
19
+ }
6
20
  function evidenceRows(route) {
7
21
  const findings = [...route.diff?.grownNodes ?? [], ...route.diff?.newNodes ?? []];
8
22
  return findings.slice(0, 6).map((finding, index) => {
9
23
  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}` : ""}`;
24
+ const owner = ownerLabel(attribution);
11
25
  return `- **${finding.kind}** \`[${finding.nodeType}] ${finding.name}\` ${(finding.retainedBytes / MB).toFixed(2)} MB retained (${owner})
12
26
  - retainers: \`${finding.retainerChain || "(none)"}\``;
13
27
  }).join("\n");
@@ -18,7 +32,7 @@ function renderIssueMarkdown(route, run) {
18
32
  const owner = route.attribution?.route.owner ?? "unattributed";
19
33
  const culprit = route.attribution?.route;
20
34
  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.
35
+ > next-leak attributes this leak to ${blamedParty(owner, culprit)}. Fix or report it there \u2014 do **not** file this against Next.js.
22
36
 
23
37
  ` : "";
24
38
  const caveats = route.confidence.warnings.length === 0 ? "" : `
@@ -11,8 +11,24 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
11
11
  if (typeof require !== "undefined") return require.apply(this, arguments);
12
12
  throw Error('Dynamic require of "' + x + '" is not supported');
13
13
  });
14
+ var __esm = (fn, res, err) => function __init() {
15
+ if (err) throw err[0];
16
+ try {
17
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
18
+ } catch (e) {
19
+ throw err = [e], e;
20
+ }
21
+ };
14
22
  var __commonJS = (cb, mod) => function __require2() {
15
- return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
23
+ try {
24
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
25
+ } catch (e) {
26
+ throw mod = 0, e;
27
+ }
28
+ };
29
+ var __export = (target, all) => {
30
+ for (var name in all)
31
+ __defProp(target, name, { get: all[name], enumerable: true });
16
32
  };
17
33
  var __copyProps = (to, from, except, desc) => {
18
34
  if (from && typeof from === "object" || typeof from === "function") {
@@ -30,9 +46,13 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
30
46
  isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
31
47
  mod
32
48
  ));
49
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
33
50
 
34
51
  export {
35
52
  __require,
53
+ __esm,
36
54
  __commonJS,
37
- __toESM
55
+ __export,
56
+ __toESM,
57
+ __toCommonJS
38
58
  };
@@ -1,13 +1,13 @@
1
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
2
  import {
3
3
  effectiveVerdict
4
- } from "./chunk-K5PZFVJH.js";
4
+ } from "./chunk-2BQZCZ4Z.js";
5
5
 
6
6
  // src/html-report.ts
7
7
  var MB = 1024 * 1024;
8
8
  var VERDICT_COLOR = { leak: "#c0392b", stable: "#27ae60", inconclusive: "#e67e22" };
9
9
  function escapeHtml(value) {
10
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
10
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
11
11
  }
12
12
  function heapCurveSvg(samples, color) {
13
13
  const width = 320;
@@ -28,6 +28,14 @@ function heapCurveSvg(samples, color) {
28
28
  return `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="2.5" fill="${color}"/>`;
29
29
  }).join("") + `</svg>`;
30
30
  }
31
+ function ownerCell(attribution) {
32
+ if (attribution === void 0 || attribution.owner === "unattributed") {
33
+ return "\u2014";
34
+ }
35
+ const source = attribution.source ? `: ${escapeHtml(attribution.source)}` : "";
36
+ const packageName = attribution.packageName ? ` (${escapeHtml(attribution.packageName)})` : "";
37
+ return `${attribution.owner}${source}${packageName}`;
38
+ }
31
39
  function measuredSection(route) {
32
40
  if (route.status !== "measured") {
33
41
  return "";
@@ -40,7 +48,7 @@ function measuredSection(route) {
40
48
  const findings = [...route.diff?.grownNodes ?? [], ...route.diff?.newNodes ?? []];
41
49
  const findingRows = findings.slice(0, 6).map((finding, index) => {
42
50
  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)})` : ""}`;
51
+ const owner = ownerCell(attribution);
44
52
  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
53
  }).join("");
46
54
  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>`;