next-leak 0.1.2 → 0.2.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.
@@ -60,7 +60,7 @@ OS: ${environment.platform} ${environment.arch}${environment.cpuModel ? ` (${env
60
60
  Memory: ${(environment.totalMemoryBytes / (1024 * MB)).toFixed(0)} GB
61
61
  Next.js: ${environment.nextVersion ?? "unknown"}
62
62
  next-leak: ${environment.nextLeakVersion}
63
- Deployment: output "standalone", node --expose-gc --max-old-space-size=512
63
+ Deployment: output "standalone", node --expose-gc --max-old-space-size=${parameters.maxOldSpaceMb}
64
64
  \`\`\`
65
65
 
66
66
  ### To Reproduce
@@ -72,6 +72,8 @@ Deployment: output "standalone", node --expose-gc --max-old-space-size=512
72
72
  2. next-leak boots \`.next/standalone/server.js\` in a fresh process per route and runs, against \`${route.requestPath}\`:
73
73
  warm-up ${parameters.warmupRequests} requests \u2192 forced GC \u2192 baseline heap snapshot \u2192
74
74
  ${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.
75
+ 3. A cycle counts as growing when it retains at least ${(parameters.minGrowthPerCycle / 1024).toFixed(0)} KiB
76
+ (the gate for this run's ${parameters.loadRequests} requests per cycle); the warm-up cycle is excluded.
75
77
 
76
78
  ### Current vs. Expected behavior
77
79
 
@@ -1,7 +1,7 @@
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-2BQZCZ4Z.js";
4
+ } from "./chunk-E5ZKAANQ.js";
5
5
 
6
6
  // src/html-report.ts
7
7
  var MB = 1024 * 1024;
@@ -77,6 +77,7 @@ code{background:#f4f4f4;padding:0 4px;border-radius:3px}
77
77
  )} \xB7 ${escapeHtml(environment.platform)}/${escapeHtml(environment.arch)} \xB7 next ${escapeHtml(
78
78
  environment.nextVersion ?? "unknown"
79
79
  )} \xB7 next-leak ${escapeHtml(environment.nextLeakVersion)}</p>
80
+ <p class="meta">${run.parameters.cycles} cycles \xD7 ${run.parameters.loadRequests} requests \xB7 heap cap ${run.parameters.maxOldSpaceMb} MB \xB7 growth gate ${(run.parameters.minGrowthPerCycle / 1024).toFixed(0)} KiB/cycle</p>
80
81
  ${measured.map(measuredSection).join("\n")}
81
82
  ${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>`}
82
83
  ${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>`}
@@ -5,6 +5,7 @@ export type CliRunOptions = {
5
5
  requests: number | null;
6
6
  connections: number | null;
7
7
  idleSeconds: number | null;
8
+ maxOldSpaceMb: number | null;
8
9
  quick: boolean;
9
10
  diffAll: boolean;
10
11
  output: string | null;
package/dist/cli.js CHANGED
@@ -9,9 +9,9 @@ import {
9
9
  killActiveChildren,
10
10
  parseCliArgs,
11
11
  runMeasurement
12
- } from "./chunk-WHC6S57X.js";
13
- import "./chunk-2BQZCZ4Z.js";
14
- import "./chunk-PF7KYD5N.js";
12
+ } from "./chunk-HAKKAIHN.js";
13
+ import "./chunk-E5ZKAANQ.js";
14
+ import "./chunk-6XYFBOL2.js";
15
15
 
16
16
  // src/cli.ts
17
17
  import { spawn } from "child_process";
@@ -97,6 +97,7 @@ async function main() {
97
97
  ...options.requests !== null && { loadRequests: options.requests },
98
98
  ...options.connections !== null && { connections: options.connections },
99
99
  ...options.idleSeconds !== null && { idleMs: options.idleSeconds * 1e3 },
100
+ ...options.maxOldSpaceMb !== null && { maxOldSpaceMb: options.maxOldSpaceMb },
100
101
  ...options.diffAll && { diffAll: true },
101
102
  ...options.output !== null && { outputDir: options.output },
102
103
  onProgress: (message) => console.error(`\xB7 ${message}`)
@@ -1,5 +1,6 @@
1
+ import type { HeapSample } from "./control-server.js";
1
2
  import type { LoadOutcome, SettleOutcome } from "./ritual.js";
2
- import type { TrendResult, TrendVerdict } from "./trend.js";
3
+ import { type TrendResult, type TrendVerdict } from "./trend.js";
3
4
  /**
4
5
  * Why a measurement may not support its own verdict.
5
6
  *
@@ -11,8 +12,10 @@ import type { TrendResult, TrendVerdict } from "./trend.js";
11
12
  * mid-stream teardown path was never reached
12
13
  * `spiky-growth` — one cycle dominates, so the mean describes little
13
14
  * `near-threshold` — growth barely clears the noise floor
15
+ * `near-heap-ceiling` — the heap approached the cap the process ran under,
16
+ * so the curve was measured against a ceiling instead of running free
14
17
  */
15
- export type WarningCode = "unsettled" | "settle-unverified" | "load-incomplete" | "abandon-ineffective" | "abandon-before-response" | "spiky-growth" | "near-threshold";
18
+ export type WarningCode = "unsettled" | "settle-unverified" | "load-incomplete" | "abandon-ineffective" | "abandon-before-response" | "spiky-growth" | "near-threshold" | "near-heap-ceiling";
16
19
  export type MeasurementWarning = {
17
20
  code: WarningCode;
18
21
  detail: string;
@@ -36,6 +39,10 @@ export type ConfidenceInput = {
36
39
  abandonAfterMs?: number;
37
40
  /** Threshold the verdict used, for the noise-floor check. */
38
41
  minGrowthPerCycle?: number;
42
+ /** Post-GC samples, for the heap-ceiling check. */
43
+ memorySamples?: readonly HeapSample[];
44
+ /** Old-space cap the measured process ran under (MB). */
45
+ maxOldSpaceMb?: number;
39
46
  };
40
47
  /**
41
48
  * The verdict a route's evidence actually supports.
@@ -1,9 +1,9 @@
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
  renderHtmlReport
4
- } from "./chunk-C3KH5Z2W.js";
5
- import "./chunk-2BQZCZ4Z.js";
6
- import "./chunk-PF7KYD5N.js";
4
+ } from "./chunk-SKAPGI62.js";
5
+ import "./chunk-E5ZKAANQ.js";
6
+ import "./chunk-6XYFBOL2.js";
7
7
  export {
8
8
  renderHtmlReport
9
9
  };
package/dist/index.js CHANGED
@@ -13,7 +13,6 @@ import {
13
13
  captureEnvironment,
14
14
  checkRuntime,
15
15
  classifySource,
16
- classifyTrend,
17
16
  decodeMappings,
18
17
  decodeVlqLine,
19
18
  diffAgainstBaseline,
@@ -40,15 +39,17 @@ import {
40
39
  sourceIndexAt,
41
40
  summarizeBaseline,
42
41
  validateTarget
43
- } from "./chunk-WHC6S57X.js";
42
+ } from "./chunk-HAKKAIHN.js";
44
43
  import {
45
44
  renderHtmlReport
46
- } from "./chunk-C3KH5Z2W.js";
47
- import "./chunk-2BQZCZ4Z.js";
45
+ } from "./chunk-SKAPGI62.js";
46
+ import {
47
+ classifyTrend
48
+ } from "./chunk-E5ZKAANQ.js";
48
49
  import {
49
50
  renderIssueMarkdown
50
- } from "./chunk-5ZYZW2BL.js";
51
- import "./chunk-PF7KYD5N.js";
51
+ } from "./chunk-MYYPTZJW.js";
52
+ import "./chunk-6XYFBOL2.js";
52
53
  export {
53
54
  LaunchError,
54
55
  LoadError,
@@ -1,8 +1,8 @@
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
  renderIssueMarkdown
4
- } from "./chunk-5ZYZW2BL.js";
5
- import "./chunk-PF7KYD5N.js";
4
+ } from "./chunk-MYYPTZJW.js";
5
+ import "./chunk-6XYFBOL2.js";
6
6
  export {
7
7
  renderIssueMarkdown
8
8
  };
@@ -1,3 +1,12 @@
1
+ /**
2
+ * Old-space cap for measured processes, when the caller does not pick one.
3
+ *
4
+ * Small on purpose: a leak that would take a production container an hour to
5
+ * kill reaches a 512 MB ceiling in minutes. It is a default, not a constant —
6
+ * an app whose legitimate working set is larger cannot be measured under it,
7
+ * so `--max-old-space` exists and the chosen value is recorded in `run.json`.
8
+ */
9
+ export declare const DEFAULT_MAX_OLD_SPACE_MB = 512;
1
10
  export type LaunchOptions = {
2
11
  /** Absolute path to the standalone `server.js` (or any PORT/HOSTNAME-honoring server). */
3
12
  serverPath: string;
package/dist/ritual.d.ts CHANGED
@@ -17,6 +17,8 @@ export type RitualOptions = {
17
17
  connections?: number;
18
18
  cycles?: number;
19
19
  idleMs?: number;
20
+ /** Old-space cap for the measured process (MB). Default: 512. */
21
+ maxOldSpaceMb?: number;
20
22
  /** Headers sent with every request during warm-up and load. */
21
23
  headers?: Record<string, string>;
22
24
  /** Emulate clients that disconnect before the response arrives. */
@@ -68,6 +70,12 @@ export type RitualResult = {
68
70
  afterSnapshot: string;
69
71
  trend: TrendResult;
70
72
  requestsPerCycle: number;
73
+ /**
74
+ * The gate (bytes per cycle) this verdict was judged against. Travels with
75
+ * the result so the confidence audit grades against the same number the
76
+ * verdict used, and so the report can print what it measured against.
77
+ */
78
+ minGrowthPerCycle: number;
71
79
  };
72
80
  /** Injectable seams for unit tests; production uses the real implementations. */
73
81
  export type RitualDeps = {
@@ -75,12 +83,23 @@ export type RitualDeps = {
75
83
  load: typeof runLoadPhase;
76
84
  sleep: (ms: number) => Promise<void>;
77
85
  };
78
- /** Single source of truth for ritual defaults — reports must echo them. */
86
+ /**
87
+ * Single source of truth for ritual defaults — reports must echo them.
88
+ *
89
+ * `cycles` is 4 rather than the minimum 3 because of how much the verdict
90
+ * actually sees: the sample array is `[baseline, ...cycles]` and
91
+ * `classifyTrend` drops the warm-up delta, leaving `cycles - 1` numbers. At 3
92
+ * cycles that is **two** deltas, and `anyFlatOrDown` needs only one of the two
93
+ * to be non-positive to call a route stable — one noisy cycle flips the
94
+ * verdict. Four cycles give three deltas, which is what the real-app
95
+ * validation ran with. The 3-cycle minimum stays available for users who want
96
+ * a faster, weaker read.
97
+ */
79
98
  export declare const RITUAL_DEFAULTS: {
80
99
  readonly warmupRequests: 200;
81
100
  readonly loadRequests: 5000;
82
101
  readonly connections: 100;
83
- readonly cycles: 3;
102
+ readonly cycles: 4;
84
103
  readonly idleMs: 30000;
85
104
  };
86
105
  /**
package/dist/runner.d.ts CHANGED
@@ -6,7 +6,7 @@ import { diffSnapshotFiles, type HeapDiff } from "./heap-diff.js";
6
6
  import { extractModuleRegistry } from "./module-registry.js";
7
7
  import { runRitual, type LoadOutcome, type PhaseTiming, type SettleOutcome } from "./ritual.js";
8
8
  import { readNextVersion, type MatchedSignature } from "./signatures.js";
9
- import type { TrendResult } from "./trend.js";
9
+ import { type TrendResult } from "./trend.js";
10
10
  export type RouteReport = {
11
11
  route: string;
12
12
  status: "skipped";
@@ -61,6 +61,18 @@ export type RunParameters = {
61
61
  connections: number;
62
62
  cycles: number;
63
63
  idleMs: number;
64
+ /**
65
+ * Old-space cap of each measured process (MB). Part of the measurement
66
+ * regime: a run near its ceiling is not the same experiment as one with
67
+ * headroom, so it belongs on the record.
68
+ */
69
+ maxOldSpaceMb: number;
70
+ /**
71
+ * Per-cycle growth gate the verdicts were judged against (bytes). Derived
72
+ * from `loadRequests`; recorded because a verdict whose threshold is not
73
+ * printed cannot be audited or reproduced.
74
+ */
75
+ minGrowthPerCycle: number;
64
76
  };
65
77
  export type RunReport = {
66
78
  appDir: string;
@@ -88,6 +100,8 @@ export type RunOptions = {
88
100
  connections?: number;
89
101
  cycles?: number;
90
102
  idleMs?: number;
103
+ /** Old-space cap for each measured process (MB). Default 512. */
104
+ maxOldSpaceMb?: number;
91
105
  /** Also diff routes with a stable verdict. Default false: diffs are slow. */
92
106
  diffAll?: boolean;
93
107
  /** Only measure routes matching these templates or prefixes. */
@@ -96,7 +110,24 @@ export type RunOptions = {
96
110
  signal?: AbortSignal;
97
111
  onProgress?: (message: string) => void;
98
112
  };
99
- export declare function estimateRunSeconds(routeCount: number, parameters: RunParameters): number;
113
+ export type RunEstimate = {
114
+ /**
115
+ * Costs no run avoids: process launch, GC, snapshots and the minimum
116
+ * settle. Request-serving time is left out on purpose — throughput is the
117
+ * one term that swings 10x between a hello-world route and a real one, so a
118
+ * floor that guessed at it would not be a floor.
119
+ */
120
+ fastSeconds: number;
121
+ /** Full idle window every cycle, at the lowest throughput ever measured. */
122
+ slowSeconds: number;
123
+ };
124
+ export declare function estimateRun(routeCount: number, parameters: RunParameters): RunEstimate;
125
+ /**
126
+ * A point estimate cannot be right across app weights: the adaptive idle ends
127
+ * as soon as the heap holds still, which on a small app is almost at once. A
128
+ * range says that out loud instead of quoting the worst case as the price.
129
+ */
130
+ export declare function formatEstimate(estimate: RunEstimate): string;
100
131
  export declare function formatDuration(seconds: number): string;
101
132
  export type RunnerDeps = {
102
133
  ritual: typeof runRitual;
package/dist/trend.d.ts CHANGED
@@ -16,9 +16,44 @@ export type TrendResult = {
16
16
  source?: "heap" | "external";
17
17
  };
18
18
  export type TrendOptions = {
19
- /** Minimum per-cycle growth (bytes) considered leak-like. Default: 256 KiB. */
19
+ /**
20
+ * Minimum per-cycle growth (bytes) considered leak-like. Defaults to the
21
+ * noise floor alone — callers that know how much traffic ran should pass
22
+ * `minGrowthFor(requestsPerCycle)` instead.
23
+ */
20
24
  minGrowthPerCycle?: number;
21
25
  };
26
+ /**
27
+ * Smallest per-cycle growth distinguishable from measurement noise.
28
+ *
29
+ * This is a property of the *instrument*, not of the leak: post-GC samples
30
+ * jitter by roughly this much regardless of how much traffic ran, so no amount
31
+ * of load makes growth below it meaningful.
32
+ */
33
+ export declare const MIN_GROWTH_NOISE_FLOOR: number;
34
+ /**
35
+ * Growth rate that counts as leak-like, per 1000 requests.
36
+ *
37
+ * This is a property of the *leak*: a route that retains memory per request
38
+ * grows in proportion to the traffic it served, so the gate has to scale with
39
+ * it. 51.2 KiB is the rate that leaves the default profile (5000 requests per
40
+ * cycle) on exactly the 256 KiB gate this tool was validated against.
41
+ */
42
+ export declare const MIN_GROWTH_PER_1000_REQUESTS: number;
43
+ /**
44
+ * The gate a per-cycle delta must clear to count as growth.
45
+ *
46
+ * `max` rather than a sum, because the two terms bound different things: the
47
+ * floor is what the instrument can resolve, the rate is what the leak should
48
+ * produce. Below ~5000 requests per cycle the floor dominates and the run is
49
+ * noise-limited — which is a real limit of measuring less traffic, not a
50
+ * threshold that can be lowered.
51
+ *
52
+ * Without this, the verdict silently depended on `--requests`: the same route
53
+ * leaking 100 KiB per 1000 requests printed the same headline in every mode
54
+ * but came out `stable` at 2000 requests per cycle and `leak` at 5000.
55
+ */
56
+ export declare function minGrowthFor(requestsPerCycle: number): number;
22
57
  /**
23
58
  * Classifies a series of post-GC retained-heap samples — baseline first, then
24
59
  * one sample per load cycle — as leaking or stable.
package/package.json CHANGED
@@ -1,12 +1,21 @@
1
1
  {
2
2
  "name": "next-leak",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
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",
@@ -18,14 +27,15 @@
18
27
  "main": "./dist/index.js",
19
28
  "types": "./dist/index.d.ts",
20
29
  "files": [
21
- "dist"
30
+ "dist",
31
+ "THIRD-PARTY-NOTICES.md"
22
32
  ],
23
33
  "engines": {
24
34
  "node": ">=22"
25
35
  },
26
36
  "packageManager": "pnpm@10.20.0",
27
37
  "scripts": {
28
- "build": "tsup && tsc -p tsconfig.build.json",
38
+ "build": "tsup && tsc -p tsconfig.build.json && node scripts/generate-notices.mjs && node scripts/check-bundle.mjs",
29
39
  "dev": "tsup --watch",
30
40
  "test": "vitest run",
31
41
  "test:watch": "vitest",
@@ -37,7 +47,9 @@
37
47
  },
38
48
  "pnpm": {
39
49
  "overrides": {
40
- "uuid": ">=11.1.1"
50
+ "uuid": ">=11.1.1",
51
+ "esbuild": ">=0.28.1",
52
+ "qs": ">=6.15.2"
41
53
  }
42
54
  },
43
55
  "devDependencies": {