next-leak 0.1.2 → 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,7 +7,7 @@
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 (3-minute run, sped up)" width="720">
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
11
 
12
12
  ```
13
13
  $ npx next-leak . --quick
@@ -46,7 +46,18 @@ that installing it saves memory, but that what it points at is the real cause.
46
46
  Across ~25 healthy routes on production applications (PPR, MDX, Auth.js,
47
47
  Sentry, i18n), it reported **zero false positives**.
48
48
 
49
- 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.
50
61
 
51
62
  Three possible answers, all valuable:
52
63
 
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-PF7KYD5N.js";
2
+ import "./chunk-6XYFBOL2.js";
3
3
 
4
4
  // src/bootstrap.ts
5
5
  import { mkdir, writeFile } from "fs/promises";
@@ -11,11 +11,20 @@ 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) => function __init() {
15
- return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
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
+ }
16
21
  };
17
22
  var __commonJS = (cb, mod) => function __require2() {
18
- 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
+ }
19
28
  };
20
29
  var __export = (target, all) => {
21
30
  for (var name in all)
@@ -11,7 +11,7 @@ import {
11
11
  __require,
12
12
  __toCommonJS,
13
13
  __toESM
14
- } from "./chunk-PF7KYD5N.js";
14
+ } from "./chunk-6XYFBOL2.js";
15
15
 
16
16
  // node_modules/.pnpm/fs-extra@4.0.3/node_modules/fs-extra/lib/util/assign.js
17
17
  var require_assign = __commonJS({
@@ -92475,11 +92475,12 @@ function formatReport(report) {
92475
92475
  for (const route of report.routes) {
92476
92476
  lines.push(...routeLines(route));
92477
92477
  }
92478
- lines.push("", `snapshots and run.json: ${report.workDir}`);
92479
- lines.push(`report: ${report.bundle.htmlReport}`);
92480
- for (const issue of report.bundle.issues) {
92481
- lines.push(`issue draft (${issue.route}): ${issue.file}`);
92482
- }
92478
+ lines.push(
92479
+ "",
92480
+ `snapshots and run.json: ${report.workDir}`,
92481
+ `report: ${report.bundle.htmlReport}`,
92482
+ ...report.bundle.issues.map((issue) => `issue draft (${issue.route}): ${issue.file}`)
92483
+ );
92483
92484
  const inconclusive = report.routes.filter(
92484
92485
  (route) => route.status === "measured" && effectiveVerdict(route) === "inconclusive"
92485
92486
  );
@@ -92582,7 +92583,7 @@ function classifySource(rawSource) {
92582
92583
  }
92583
92584
  const nodeModulesSplit = source.split("/node_modules/");
92584
92585
  if (nodeModulesSplit.length > 1) {
92585
- const tail = nodeModulesSplit[nodeModulesSplit.length - 1] ?? "";
92586
+ const tail = nodeModulesSplit.at(-1) ?? "";
92586
92587
  const segments = tail.split("/");
92587
92588
  const packageName = (segments[0]?.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0]) ?? null;
92588
92589
  if (packageName === "next" || packageName === "react" || packageName === "react-dom") {
@@ -92890,6 +92891,16 @@ var routesManifestSchema = z3.looseObject({
92890
92891
  });
92891
92892
  var INTERNAL_PATHS = /* @__PURE__ */ new Set(["/_global-error", "/_not-found"]);
92892
92893
  var INTERCEPTING_SEGMENT = /^(\(\.{1,3}\))+/;
92894
+ var STATIC_ASSET_PATHS = /* @__PURE__ */ new Set(["/favicon.ico"]);
92895
+ function unaddressableReason(routePath) {
92896
+ if (routePath.split("/").some((segment) => INTERCEPTING_SEGMENT.test(segment))) {
92897
+ return "intercepting route \u2014 only reachable via client navigation";
92898
+ }
92899
+ if (STATIC_ASSET_PATHS.has(routePath)) {
92900
+ return "static asset served by a generated handler \u2014 no user code to leak";
92901
+ }
92902
+ return void 0;
92903
+ }
92893
92904
  function toRequestPath(manifestKey) {
92894
92905
  let kind;
92895
92906
  let withoutSuffix;
@@ -92927,14 +92938,12 @@ function discoverRoutes(appPaths) {
92927
92938
  if (parsed === null || INTERNAL_PATHS.has(parsed.path) || routes.has(parsed.path)) {
92928
92939
  continue;
92929
92940
  }
92930
- const intercepting = parsed.path.split("/").some((segment) => INTERCEPTING_SEGMENT.test(segment));
92941
+ const reason = unaddressableReason(parsed.path);
92931
92942
  routes.set(parsed.path, {
92932
92943
  path: parsed.path,
92933
92944
  kind: parsed.kind,
92934
92945
  dynamic: parsed.path.includes("["),
92935
- ...intercepting && {
92936
- unaddressableReason: "intercepting route \u2014 only reachable via client navigation"
92937
- }
92946
+ ...reason !== void 0 && { unaddressableReason: reason }
92938
92947
  });
92939
92948
  }
92940
92949
  return [...routes.values()].sort((a, b) => a.path.localeCompare(b.path));
@@ -93283,55 +93292,55 @@ Host: ${target.host}\r
93283
93292
  errors: 0
93284
93293
  };
93285
93294
  let remaining = options.amount;
93295
+ const sendOne = () => new Promise((resolve) => {
93296
+ const socket = net.connect({ host: target.hostname, port });
93297
+ let settled = false;
93298
+ let responseStarted = false;
93299
+ let timer;
93300
+ const finish = () => {
93301
+ if (settled) {
93302
+ return;
93303
+ }
93304
+ settled = true;
93305
+ clearTimeout(timer);
93306
+ socket.destroy();
93307
+ resolve();
93308
+ };
93309
+ const giveUp = () => {
93310
+ if (!settled) {
93311
+ result.abandoned += 1;
93312
+ if (responseStarted) {
93313
+ result.abandonedMidStream += 1;
93314
+ }
93315
+ }
93316
+ finish();
93317
+ };
93318
+ socket.once("connect", () => {
93319
+ result.sent += 1;
93320
+ socket.write(request2);
93321
+ timer = setTimeout(giveUp, options.abandonAfterMs);
93322
+ timer.unref();
93323
+ });
93324
+ socket.on("data", () => {
93325
+ responseStarted = true;
93326
+ });
93327
+ socket.once("end", () => {
93328
+ if (!settled) {
93329
+ result.completed += 1;
93330
+ }
93331
+ finish();
93332
+ });
93333
+ socket.once("error", () => {
93334
+ if (!settled) {
93335
+ result.errors += 1;
93336
+ }
93337
+ finish();
93338
+ });
93339
+ });
93286
93340
  const worker = async () => {
93287
93341
  while (remaining > 0) {
93288
93342
  remaining -= 1;
93289
- await new Promise((resolve) => {
93290
- const socket = net.connect({ host: target.hostname, port });
93291
- let settled = false;
93292
- let responseStarted = false;
93293
- let timer;
93294
- const finish = () => {
93295
- if (settled) {
93296
- return;
93297
- }
93298
- settled = true;
93299
- clearTimeout(timer);
93300
- socket.destroy();
93301
- resolve();
93302
- };
93303
- socket.once("connect", () => {
93304
- result.sent += 1;
93305
- socket.write(request2);
93306
- timer = setTimeout(() => {
93307
- if (!settled) {
93308
- result.abandoned += 1;
93309
- if (responseStarted) {
93310
- result.abandonedMidStream += 1;
93311
- }
93312
- }
93313
- finish();
93314
- }, options.abandonAfterMs);
93315
- timer.unref();
93316
- });
93317
- socket.on("data", () => {
93318
- responseStarted = true;
93319
- });
93320
- socket.once("end", () => {
93321
- if (!settled) {
93322
- result.completed += 1;
93323
- }
93324
- clearTimeout(timer);
93325
- finish();
93326
- });
93327
- socket.once("error", () => {
93328
- if (!settled) {
93329
- result.errors += 1;
93330
- }
93331
- clearTimeout(timer);
93332
- finish();
93333
- });
93334
- });
93343
+ await sendOne();
93335
93344
  }
93336
93345
  };
93337
93346
  await Promise.all(
@@ -93682,9 +93691,20 @@ import { createServer } from "net";
93682
93691
  import path6 from "path";
93683
93692
  var ESTIMATED_RPS = 250;
93684
93693
  var PER_ROUTE_OVERHEAD_SECONDS = 10;
93685
- function estimateRunSeconds(routeCount, parameters) {
93686
- const perRoute = parameters.warmupRequests / ESTIMATED_RPS + parameters.cycles * (parameters.loadRequests / ESTIMATED_RPS + parameters.idleMs / 1e3) + PER_ROUTE_OVERHEAD_SECONDS;
93687
- return Math.round(routeCount * perRoute);
93694
+ var MIN_SETTLE_SECONDS = 4;
93695
+ function estimateRun(routeCount, parameters) {
93696
+ const settleSeconds = Math.min(MIN_SETTLE_SECONDS, parameters.idleMs / 1e3);
93697
+ const slowPerRoute = parameters.warmupRequests / ESTIMATED_RPS + parameters.cycles * (parameters.loadRequests / ESTIMATED_RPS + parameters.idleMs / 1e3) + PER_ROUTE_OVERHEAD_SECONDS;
93698
+ const fastPerRoute = PER_ROUTE_OVERHEAD_SECONDS + parameters.cycles * settleSeconds;
93699
+ return {
93700
+ fastSeconds: Math.round(routeCount * fastPerRoute),
93701
+ slowSeconds: Math.round(routeCount * slowPerRoute)
93702
+ };
93703
+ }
93704
+ function formatEstimate(estimate) {
93705
+ const fast = formatDuration(estimate.fastSeconds);
93706
+ const slow = formatDuration(estimate.slowSeconds);
93707
+ return fast === slow ? `\u2248 ${slow}` : `\u2248 ${fast}\u2013${slow}`;
93688
93708
  }
93689
93709
  function formatDuration(seconds) {
93690
93710
  if (seconds < 90) {
@@ -93732,7 +93752,7 @@ function rssTrend(memorySamples) {
93732
93752
  return deltas.reduce((sum, delta) => sum + delta, 0) / deltas.length;
93733
93753
  }
93734
93754
  function routeSlug(route) {
93735
- const sanitized = route.replaceAll(/[^a-zA-Z0-9-]+/g, "_").replace(/^_+/, "").replace(/_+$/, "");
93755
+ const sanitized = route.replaceAll(/[^a-zA-Z0-9-]+/g, "_").replace(/^_/, "").replace(/_$/, "");
93736
93756
  if (route === "/") {
93737
93757
  return "root";
93738
93758
  }
@@ -93742,9 +93762,16 @@ function routeSlug(route) {
93742
93762
  const digest = createHash3("sha1").update(route).digest("hex").slice(0, 6);
93743
93763
  return sanitized === "" ? `route-${digest}` : `${sanitized}-${digest}`;
93744
93764
  }
93765
+ function trimTrailingSlashes(value) {
93766
+ let end = value.length;
93767
+ while (end > 0 && value[end - 1] === "/") {
93768
+ end -= 1;
93769
+ }
93770
+ return value.slice(0, end);
93771
+ }
93745
93772
  function filterRoutes(routes, selectors, progress) {
93746
93773
  const matches = (routePath, selector) => {
93747
- const normalized = selector.replace(/[/]+$/, "");
93774
+ const normalized = trimTrailingSlashes(selector);
93748
93775
  if (normalized === "") {
93749
93776
  return routePath === "/";
93750
93777
  }
@@ -93822,8 +93849,8 @@ async function measureRoute(context, route, requestPath, index) {
93822
93849
  };
93823
93850
  }
93824
93851
  async function writeEvidenceBundle(report, workDir) {
93825
- const { renderHtmlReport } = await import("./html-report-I4PNSLKY.js");
93826
- const { renderIssueMarkdown } = await import("./issue-report-VQEUXP2E.js");
93852
+ const { renderHtmlReport } = await import("./html-report-26DU7JFW.js");
93853
+ const { renderIssueMarkdown } = await import("./issue-report-FUTEOHN5.js");
93827
93854
  for (const route of report.routes) {
93828
93855
  if (route.status === "measured" && warrantsIssueDraft(route)) {
93829
93856
  const file = path6.join(workDir, `ISSUE-${routeSlug(route.route)}.md`);
@@ -93872,15 +93899,15 @@ async function planRun(options, target, deps, progress) {
93872
93899
  cycles: options.cycles ?? RITUAL_DEFAULTS.cycles,
93873
93900
  idleMs: options.idleMs ?? RITUAL_DEFAULTS.idleMs
93874
93901
  };
93875
- const estimatedSeconds = estimateRunSeconds(
93876
- routes.filter((route) => resolveRoutePath(route.path, routeConfig) !== null).length,
93877
- parameters
93878
- );
93902
+ const measurable = routes.filter(
93903
+ (route) => skipReason(route, resolveRoutePath(route.path, routeConfig)) === null
93904
+ ).length;
93905
+ const estimate = estimateRun(measurable, parameters);
93879
93906
  progress(
93880
- `${routes.length} routes discovered \xB7 estimated \u2248 ${formatDuration(estimatedSeconds)}` + // Long default runs are where first-time users give up; point at the two
93907
+ `${routes.length} routes discovered` + (measurable === routes.length ? "" : ` \xB7 ${measurable} measurable`) + ` \xB7 estimated ${formatEstimate(estimate)}` + // Long default runs are where first-time users give up; point at the two
93881
93908
  // ways out. Suppressed once load parameters were tuned by hand (or by
93882
93909
  // --quick, which arrives here as explicit loadRequests/idleMs).
93883
- (estimatedSeconds > 15 * 60 && options.loadRequests === void 0 && options.idleMs === void 0 ? " \u2014 use --quick for the fast validated preset, or narrow with --routes" : "")
93910
+ (estimate.slowSeconds > 15 * 60 && options.loadRequests === void 0 && options.idleMs === void 0 ? " \u2014 use --quick for the fast validated preset, or narrow with --routes" : "")
93884
93911
  );
93885
93912
  return { routes, routeConfig, registry, nextVersion, parameters };
93886
93913
  }
package/dist/cli.js CHANGED
@@ -9,9 +9,9 @@ import {
9
9
  killActiveChildren,
10
10
  parseCliArgs,
11
11
  runMeasurement
12
- } from "./chunk-WHC6S57X.js";
12
+ } from "./chunk-ZFYIR423.js";
13
13
  import "./chunk-2BQZCZ4Z.js";
14
- import "./chunk-PF7KYD5N.js";
14
+ import "./chunk-6XYFBOL2.js";
15
15
 
16
16
  // src/cli.ts
17
17
  import { spawn } from "child_process";
@@ -3,7 +3,7 @@ import {
3
3
  renderHtmlReport
4
4
  } from "./chunk-C3KH5Z2W.js";
5
5
  import "./chunk-2BQZCZ4Z.js";
6
- import "./chunk-PF7KYD5N.js";
6
+ import "./chunk-6XYFBOL2.js";
7
7
  export {
8
8
  renderHtmlReport
9
9
  };
package/dist/index.js CHANGED
@@ -40,7 +40,7 @@ import {
40
40
  sourceIndexAt,
41
41
  summarizeBaseline,
42
42
  validateTarget
43
- } from "./chunk-WHC6S57X.js";
43
+ } from "./chunk-ZFYIR423.js";
44
44
  import {
45
45
  renderHtmlReport
46
46
  } from "./chunk-C3KH5Z2W.js";
@@ -48,7 +48,7 @@ import "./chunk-2BQZCZ4Z.js";
48
48
  import {
49
49
  renderIssueMarkdown
50
50
  } from "./chunk-5ZYZW2BL.js";
51
- import "./chunk-PF7KYD5N.js";
51
+ import "./chunk-6XYFBOL2.js";
52
52
  export {
53
53
  LaunchError,
54
54
  LoadError,
@@ -2,7 +2,7 @@ import { createRequire as __nextLeakCreateRequire } from 'node:module';import {
2
2
  import {
3
3
  renderIssueMarkdown
4
4
  } from "./chunk-5ZYZW2BL.js";
5
- import "./chunk-PF7KYD5N.js";
5
+ import "./chunk-6XYFBOL2.js";
6
6
  export {
7
7
  renderIssueMarkdown
8
8
  };
package/dist/runner.d.ts CHANGED
@@ -96,7 +96,24 @@ export type RunOptions = {
96
96
  signal?: AbortSignal;
97
97
  onProgress?: (message: string) => void;
98
98
  };
99
- export declare function estimateRunSeconds(routeCount: number, parameters: RunParameters): number;
99
+ export type RunEstimate = {
100
+ /**
101
+ * Costs no run avoids: process launch, GC, snapshots and the minimum
102
+ * settle. Request-serving time is left out on purpose — throughput is the
103
+ * one term that swings 10x between a hello-world route and a real one, so a
104
+ * floor that guessed at it would not be a floor.
105
+ */
106
+ fastSeconds: number;
107
+ /** Full idle window every cycle, at the lowest throughput ever measured. */
108
+ slowSeconds: number;
109
+ };
110
+ export declare function estimateRun(routeCount: number, parameters: RunParameters): RunEstimate;
111
+ /**
112
+ * A point estimate cannot be right across app weights: the adaptive idle ends
113
+ * as soon as the heap holds still, which on a small app is almost at once. A
114
+ * range says that out loud instead of quoting the worst case as the price.
115
+ */
116
+ export declare function formatEstimate(estimate: RunEstimate): string;
100
117
  export declare function formatDuration(seconds: number): string;
101
118
  export type RunnerDeps = {
102
119
  ritual: typeof runRitual;
package/package.json CHANGED
@@ -1,12 +1,21 @@
1
1
  {
2
2
  "name": "next-leak",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Find out whether your Next.js app actually leaks memory — how much, on which route, and whose fault it is.",
5
5
  "keywords": [
6
6
  "nextjs",
7
+ "next.js",
7
8
  "memory-leak",
9
+ "memory",
10
+ "oom",
11
+ "oomkilled",
12
+ "heap",
8
13
  "heap-snapshot",
14
+ "profiling",
15
+ "performance",
16
+ "nodejs",
9
17
  "diagnostics",
18
+ "debugging",
10
19
  "cli"
11
20
  ],
12
21
  "license": "MIT",
@@ -37,7 +46,9 @@
37
46
  },
38
47
  "pnpm": {
39
48
  "overrides": {
40
- "uuid": ">=11.1.1"
49
+ "uuid": ">=11.1.1",
50
+ "esbuild": ">=0.28.1",
51
+ "qs": ">=6.15.2"
41
52
  }
42
53
  },
43
54
  "devDependencies": {