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.
@@ -0,0 +1,24 @@
1
+ import { type LineMapping } from "./vlq.js";
2
+ /** Bundler module id → source path (e.g. `[project]/src/app/leaky/page.tsx`). */
3
+ export type ModuleRegistry = ReadonlyMap<number, string>;
4
+ export type NormalizedSourceMap = {
5
+ sources: string[];
6
+ lines: LineMapping[];
7
+ };
8
+ /** Accepts flat or sectioned sourcemaps; null when it is neither. */
9
+ export declare function normalizeSourceMap(raw: unknown): NormalizedSourceMap | null;
10
+ /**
11
+ * Extracts `[id, factory, ...]` pairs from a Turbopack CJS chunk by walking
12
+ * the sourcemap instead of lexing JavaScript: each factory is a contiguous
13
+ * region mapped to one source, so a change of source index marks a factory
14
+ * boundary, and the module id is the last integer literal in the unmapped
15
+ * gap right before it. Non-Turbopack chunks contribute nothing.
16
+ */
17
+ export declare function extractChunkModules(code: string, rawMap: unknown): Map<number, string>;
18
+ /**
19
+ * Builds the module registry by scanning every server chunk that has an
20
+ * adjacent sourcemap. Missing maps, unparseable chunks, or non-Turbopack
21
+ * builds simply contribute nothing: an empty registry means every finding
22
+ * degrades to `unattributed`.
23
+ */
24
+ export declare function extractModuleRegistry(nextServerDir: string): Promise<ModuleRegistry>;
@@ -0,0 +1,3 @@
1
+ import type { RunReport } from "./runner.js";
2
+ /** Renders the terminal report. Pure: no I/O, no colors, stable output. */
3
+ export declare function formatReport(report: RunReport): string;
@@ -0,0 +1,95 @@
1
+ import type { HeapSample } from "./control-server.js";
2
+ import { launchInstrumented } from "./launcher.js";
3
+ import { runLoadPhase } from "./load.js";
4
+ import { type TrendResult } from "./trend.js";
5
+ export type RitualOptions = {
6
+ /** Absolute path to the standalone server.js. */
7
+ serverPath: string;
8
+ /** Concrete request path (dynamic params already resolved), e.g. "/products/42". */
9
+ route: string;
10
+ /** Directory for this route's snapshots and control file. */
11
+ workDir: string;
12
+ /** Built bootstrap module for `--import`. */
13
+ bootstrapPath: string;
14
+ appPort: number;
15
+ warmupRequests?: number;
16
+ loadRequests?: number;
17
+ connections?: number;
18
+ cycles?: number;
19
+ idleMs?: number;
20
+ /** Headers sent with every request during warm-up and load. */
21
+ headers?: Record<string, string>;
22
+ /** Emulate clients that disconnect before the response arrives. */
23
+ abandonAfterMs?: number;
24
+ };
25
+ export type PhaseTiming = {
26
+ phase: string;
27
+ seconds: number;
28
+ };
29
+ /** What each load phase actually did — auditable after the fact. */
30
+ export type LoadOutcome = {
31
+ phase: string;
32
+ sent: number;
33
+ ok2xx?: number;
34
+ non2xx?: number;
35
+ errors?: number;
36
+ timeouts?: number;
37
+ abandoned?: number;
38
+ /** Abandonments where the response had already started — the mid-stream path. */
39
+ abandonedMidStream?: number;
40
+ };
41
+ /**
42
+ * Whether the heap actually held still before each sample was taken.
43
+ *
44
+ * `unknown` is not a softer `moving`: with fewer than two GC readings there is
45
+ * nothing to compare, so the run never learned whether the heap was steady.
46
+ * Conflating the two made every short-idle run look like a moving heap.
47
+ */
48
+ export type SettleStatus = "settled" | "moving" | "unknown";
49
+ export type SettleOutcome = {
50
+ phase: string;
51
+ status: SettleStatus;
52
+ /** GC polls taken before converging or giving up. */
53
+ polls: number;
54
+ };
55
+ export type RitualResult = {
56
+ route: string;
57
+ /** Wall-clock per phase, so slow runs can be explained instead of guessed. */
58
+ timings: PhaseTiming[];
59
+ /** Per-phase request outcomes; without these a run cannot be audited. */
60
+ loadOutcomes: LoadOutcome[];
61
+ /** Per-cycle settle results: a sample taken while the heap moved is suspect. */
62
+ settleOutcomes: SettleOutcome[];
63
+ /** Post-GC heapUsed per phase: baseline first, then one per cycle. */
64
+ samples: number[];
65
+ /** Full memory samples in the same order. */
66
+ memorySamples: HeapSample[];
67
+ baselineSnapshot: string;
68
+ afterSnapshot: string;
69
+ trend: TrendResult;
70
+ requestsPerCycle: number;
71
+ };
72
+ /** Injectable seams for unit tests; production uses the real implementations. */
73
+ export type RitualDeps = {
74
+ launch: typeof launchInstrumented;
75
+ load: typeof runLoadPhase;
76
+ sleep: (ms: number) => Promise<void>;
77
+ };
78
+ /** Single source of truth for ritual defaults — reports must echo them. */
79
+ export declare const RITUAL_DEFAULTS: {
80
+ readonly warmupRequests: 200;
81
+ readonly loadRequests: 5000;
82
+ readonly connections: 100;
83
+ readonly cycles: 3;
84
+ readonly idleMs: 30000;
85
+ };
86
+ /**
87
+ * Runs the validated phase-0 ritual against one route in a fresh process:
88
+ *
89
+ * warm-up → GC → baseline snapshot
90
+ * → [load → idle → GC → sample] × cycles (last cycle snapshots)
91
+ *
92
+ * Warm-up before the baseline and idle+GC before every sample are what
93
+ * separate a real measurement from the classic false positive.
94
+ */
95
+ export declare function runRitual(options: RitualOptions, deps?: RitualDeps): Promise<RitualResult>;
@@ -0,0 +1,36 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * `next-leak.config.json` in the target app dir: sample values for dynamic
4
+ * route params. `params` applies globally; `routes` overrides per route
5
+ * template (keys as discovered, e.g. `/[lang]/candidate/[candidateId]`).
6
+ */
7
+ export declare const routeConfigSchema: z.ZodObject<{
8
+ params: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
9
+ routes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>>>;
10
+ headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
11
+ query: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
12
+ abandonAfterMs: z.ZodOptional<z.ZodNumber>;
13
+ }, z.core.$strict>;
14
+ export type RouteConfig = z.infer<typeof routeConfigSchema>;
15
+ export declare class RouteConfigError extends Error {
16
+ constructor(message: string);
17
+ }
18
+ export declare const ROUTE_CONFIG_FILE = "next-leak.config.json";
19
+ /** Missing file → empty config. Present but invalid → loud failure. */
20
+ export declare function loadRouteConfig(appDir: string): Promise<RouteConfig>;
21
+ /**
22
+ * Sample values may contain `{n}`, which the load phase replaces with a
23
+ * per-request counter. Leaks keyed by URL (route caches, LRUs, bot traffic
24
+ * with unique tails) are invisible when every request hits the same path.
25
+ */
26
+ export declare const UNIQUE_MARKER = "{n}";
27
+ /**
28
+ * Substitutes sample values into a dynamic route template and returns a
29
+ * URL-safe request path (or null when a param has no configured value;
30
+ * optional catch-alls without a value are dropped instead).
31
+ *
32
+ * Percent-encoding is not cosmetic: an unencoded `#` silently truncated the
33
+ * path (a different route got measured) and unencoded non-ASCII produced
34
+ * requests that never completed — both verified against a real server.
35
+ */
36
+ export declare function resolveRoutePath(routeTemplate: string, config: RouteConfig): string | null;
@@ -0,0 +1,3 @@
1
+ import type { RunReport } from "./runner.js";
2
+ /** Canonical report fixture shared by report/html/issue renderer tests. */
3
+ export declare function makeRunReport(): RunReport;
@@ -0,0 +1,127 @@
1
+ import { type AttributedDiff } from "./attribution.js";
2
+ import { type ConfidenceReport } from "./confidence.js";
3
+ import type { HeapSample } from "./control-server.js";
4
+ import { type MeasurementEnvironment } from "./environment.js";
5
+ import { diffSnapshotFiles, type HeapDiff } from "./heap-diff.js";
6
+ import { extractModuleRegistry } from "./module-registry.js";
7
+ import { runRitual, type LoadOutcome, type PhaseTiming, type SettleOutcome } from "./ritual.js";
8
+ import { readNextVersion, type MatchedSignature } from "./signatures.js";
9
+ import type { TrendResult } from "./trend.js";
10
+ export type RouteReport = {
11
+ route: string;
12
+ status: "skipped";
13
+ reason: string;
14
+ } | {
15
+ route: string;
16
+ status: "failed";
17
+ reason: string;
18
+ } | {
19
+ route: string;
20
+ status: "measured";
21
+ /** Concrete path requested (differs from `route` for dynamic templates). */
22
+ requestPath: string;
23
+ samples: number[];
24
+ /**
25
+ * Full post-GC memory samples. RSS matters as much as the heap: a
26
+ * process can hold gigabytes of RSS with a flat JS heap (allocator
27
+ * behaviour, external buffers), which is a different diagnosis and a
28
+ * different fix than a heap leak.
29
+ */
30
+ memorySamples: HeapSample[];
31
+ /** RSS growth per 1000 requests, computed like the heap figure. */
32
+ rssPer1000Requests: number;
33
+ /** Wall-clock per phase — explains where a long run spent its time. */
34
+ timings: PhaseTiming[];
35
+ /** What each load phase actually did (sent, 2xx, abandoned…). */
36
+ loadOutcomes: LoadOutcome[];
37
+ /** Whether the heap held still before each sample. */
38
+ settleOutcomes: SettleOutcome[];
39
+ /**
40
+ * Audit of the measurement against its own evidence. `trend` stays as
41
+ * measured; when the evidence does not support it, `confidence`
42
+ * carries the verdict that does — see `effectiveVerdict`.
43
+ */
44
+ confidence: ConfidenceReport;
45
+ trend: TrendResult;
46
+ growthPer1000Requests: number;
47
+ baselineSnapshot: string;
48
+ afterSnapshot: string;
49
+ /** Null when the verdict is stable and diffAll was not requested. */
50
+ diff: HeapDiff | null;
51
+ /** Null when there is no diff or no module registry. */
52
+ attribution: AttributedDiff | null;
53
+ signatures: MatchedSignature[];
54
+ };
55
+ export type MeasuredRoute = Extract<RouteReport, {
56
+ status: "measured";
57
+ }>;
58
+ export type RunParameters = {
59
+ warmupRequests: number;
60
+ loadRequests: number;
61
+ connections: number;
62
+ cycles: number;
63
+ idleMs: number;
64
+ };
65
+ export type RunReport = {
66
+ appDir: string;
67
+ startedAt: string;
68
+ workDir: string;
69
+ environment: MeasurementEnvironment;
70
+ parameters: RunParameters;
71
+ routes: RouteReport[];
72
+ bundle: {
73
+ htmlReport: string;
74
+ issues: Array<{
75
+ route: string;
76
+ file: string;
77
+ }>;
78
+ };
79
+ };
80
+ export type RunOptions = {
81
+ appDir: string;
82
+ /** Built bootstrap module for `--import` into measured processes. */
83
+ bootstrapPath: string;
84
+ /** Parent output directory. Default: `<appDir>/.next-leak`. */
85
+ outputDir?: string;
86
+ warmupRequests?: number;
87
+ loadRequests?: number;
88
+ connections?: number;
89
+ cycles?: number;
90
+ idleMs?: number;
91
+ /** Also diff routes with a stable verdict. Default false: diffs are slow. */
92
+ diffAll?: boolean;
93
+ /** Only measure routes matching these templates or prefixes. */
94
+ routeFilter?: string[];
95
+ /** Abort between phases; remaining routes are reported as interrupted. */
96
+ signal?: AbortSignal;
97
+ onProgress?: (message: string) => void;
98
+ };
99
+ export declare function estimateRunSeconds(routeCount: number, parameters: RunParameters): number;
100
+ export declare function formatDuration(seconds: number): string;
101
+ export type RunnerDeps = {
102
+ ritual: typeof runRitual;
103
+ diff: typeof diffSnapshotFiles;
104
+ freePort: () => Promise<number>;
105
+ registry: typeof extractModuleRegistry;
106
+ nextVersion: typeof readNextVersion;
107
+ };
108
+ export declare function freePort(): Promise<number>;
109
+ /**
110
+ * Filesystem-safe label for a route. Distinct routes MUST get distinct slugs:
111
+ * `/a/b` and `/a_b` used to collapse onto the same `ISSUE-a_b.md`, and any
112
+ * all-non-ASCII path (`/ñ`) became "root", colliding with `/`. A short digest
113
+ * disambiguates whenever the sanitized form loses information.
114
+ */
115
+ /**
116
+ * Mean RSS growth per cycle, excluding the warm-up cycle exactly like the
117
+ * heap verdict does. Reported alongside the heap so a flat heap with growing
118
+ * RSS is visible instead of invisible.
119
+ */
120
+ export declare function rssTrend(memorySamples: readonly HeapSample[]): number;
121
+ export declare function routeSlug(route: string): string;
122
+ /**
123
+ * Full measurement run: validate the target, discover routes, run the ritual
124
+ * per route in a fresh process, diff snapshots for non-stable verdicts, and
125
+ * persist `run.json` plus raw snapshots under the work directory.
126
+ */
127
+ export declare function runMeasurement(options: RunOptions, deps?: RunnerDeps): Promise<RunReport>;
@@ -0,0 +1,28 @@
1
+ import { z } from "zod";
2
+ import type { HeapDiff } from "./heap-diff.js";
3
+ declare const signatureSchema: z.ZodObject<{
4
+ id: z.ZodString;
5
+ title: z.ZodString;
6
+ nextRange: z.ZodString;
7
+ cause: z.ZodString;
8
+ issue: z.ZodURL;
9
+ historical: z.ZodBoolean;
10
+ match: z.ZodObject<{
11
+ chainIncludes: z.ZodOptional<z.ZodString>;
12
+ typeDeltaAbove: z.ZodOptional<z.ZodObject<{
13
+ type: z.ZodString;
14
+ bytes: z.ZodNumber;
15
+ }, z.core.$strip>>;
16
+ }, z.core.$strip>;
17
+ }, z.core.$strict>;
18
+ export type Signature = z.infer<typeof signatureSchema>;
19
+ export declare function loadSignatures(raw?: unknown[]): Signature[];
20
+ export type MatchedSignature = Pick<Signature, "id" | "title" | "cause" | "issue" | "historical">;
21
+ /**
22
+ * Matches signatures against a diff, gated by the measured app's Next.js
23
+ * version. Unknown version or no matching range → no annotations, no errors.
24
+ */
25
+ export declare function matchSignatures(diff: HeapDiff, nextVersion: string | null, signatures?: Signature[]): MatchedSignature[];
26
+ /** Reads the measured app's Next.js version from its standalone bundle. */
27
+ export declare function readNextVersion(appDir: string): Promise<string | null>;
28
+ export {};
@@ -0,0 +1,21 @@
1
+ import { type AppPathsManifest, type PagesManifest, type RoutesManifest } from "./manifests.js";
2
+ export type TargetErrorCode = "NO_BUILD" | "NO_STANDALONE" | "BAD_MANIFEST";
3
+ export declare class TargetError extends Error {
4
+ readonly code: TargetErrorCode;
5
+ constructor(code: TargetErrorCode, message: string);
6
+ }
7
+ export type ValidatedTarget = {
8
+ appDir: string;
9
+ /** Absolute path to `.next/standalone/server.js`. */
10
+ standaloneServer: string;
11
+ appPaths: AppPathsManifest;
12
+ pages: PagesManifest;
13
+ /** Absent on builds that do not emit it; nothing downstream reads it. */
14
+ routes: RoutesManifest | undefined;
15
+ };
16
+ /**
17
+ * Validates that `appDir` contains a production build with
18
+ * `output: "standalone"` and readable route manifests. Fails fast with an
19
+ * actionable message otherwise.
20
+ */
21
+ export declare function validateTarget(appDir: string): Promise<ValidatedTarget>;
@@ -0,0 +1,38 @@
1
+ export type TrendVerdict = "leak" | "stable" | "inconclusive";
2
+ export type TrendResult = {
3
+ verdict: TrendVerdict;
4
+ /** Mean retained-heap growth per cycle (bytes) over the analyzed window. */
5
+ growthPerCycle: number;
6
+ /** Per-cycle deltas (bytes) after dropping the warm-up cycle. */
7
+ deltas: number[];
8
+ /**
9
+ * Which memory produced the verdict. A Node process can leak in three
10
+ * places and only one of them is the JS heap: `external`/`arrayBuffers`
11
+ * hold fetch bodies, streams and Buffers, and can OOM a process while the
12
+ * heap stays flat (vercel/next.js#92287 reports 4.3 GB of arrayBuffers
13
+ * against a healthy heap). Reporting the heap alone would call that
14
+ * "stable".
15
+ */
16
+ source?: "heap" | "external";
17
+ };
18
+ export type TrendOptions = {
19
+ /** Minimum per-cycle growth (bytes) considered leak-like. Default: 256 KiB. */
20
+ minGrowthPerCycle?: number;
21
+ };
22
+ /**
23
+ * Classifies a series of post-GC retained-heap samples — baseline first, then
24
+ * one sample per load cycle — as leaking or stable.
25
+ *
26
+ * The baseline→cycle-1 delta is excluded from the verdict: measurements on
27
+ * healthy routes show it is dominated by one-time engine warm-up (JIT code,
28
+ * lazy caches) even after an HTTP-level warm-up phase. A leak must keep
29
+ * growing across the remaining cycles; warm-up flattens out.
30
+ */
31
+ export declare function classifyTrend(samples: readonly number[], options?: TrendOptions): TrendResult;
32
+ /**
33
+ * Verdict over both the JS heap and external memory, taking the worse of the
34
+ * two. External memory (`external`, which includes `arrayBuffers`) holds
35
+ * fetch bodies, streams and Buffers; a process can be killed by OOM with a
36
+ * perfectly flat heap, so judging the heap alone answers the wrong question.
37
+ */
38
+ export declare function classifyMemoryTrend(heapSamples: readonly number[], externalSamples: readonly number[], options?: TrendOptions): TrendResult;
package/dist/vlq.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ /** Decodes one line of base64-VLQ segments from a sourcemap `mappings` string. */
2
+ export declare function decodeVlqLine(line: string): number[][];
3
+ export type LineMapping = {
4
+ /** Generated column → source index, sorted by column. */
5
+ entries: Array<{
6
+ column: number;
7
+ sourceIndex: number;
8
+ }>;
9
+ };
10
+ /**
11
+ * Decodes a sourcemap `mappings` string into per-line column → source-index
12
+ * tables. Only the fields attribution needs; name/line positions are dropped.
13
+ */
14
+ export declare function decodeMappings(mappings: string): LineMapping[];
15
+ /** Returns the source index active at (line, column), or undefined. */
16
+ export declare function sourceIndexAt(lines: LineMapping[], line: number, column: number): number | undefined;
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "next-leak",
3
+ "version": "0.1.0",
4
+ "description": "Find out whether your Next.js app actually leaks memory — how much, on which route, and whose fault it is.",
5
+ "keywords": [
6
+ "nextjs",
7
+ "memory-leak",
8
+ "heap-snapshot",
9
+ "diagnostics",
10
+ "cli"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "Xabier Lameiro <xabier.lameiro@gmail.com>",
14
+ "type": "module",
15
+ "bin": {
16
+ "next-leak": "dist/cli.js"
17
+ },
18
+ "main": "./dist/index.js",
19
+ "types": "./dist/index.d.ts",
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "engines": {
24
+ "node": ">=22"
25
+ },
26
+ "packageManager": "pnpm@10.20.0",
27
+ "scripts": {
28
+ "build": "tsup && tsc -p tsconfig.build.json",
29
+ "dev": "tsup --watch",
30
+ "test": "vitest run",
31
+ "test:watch": "vitest",
32
+ "typecheck": "tsc --noEmit",
33
+ "prepublishOnly": "npm run typecheck && npm run test && npm run build",
34
+ "pack:smoke": "node scripts/pack-smoke.mjs",
35
+ "test:mutation": "stryker run",
36
+ "prepare": "husky >/dev/null 2>&1 || true",
37
+ "release": "release-it"
38
+ },
39
+ "pnpm": {
40
+ "overrides": {
41
+ "uuid": ">=11.1.1"
42
+ }
43
+ },
44
+ "devDependencies": {
45
+ "@commitlint/cli": "^21.2.1",
46
+ "@commitlint/config-conventional": "^21.2.0",
47
+ "@memlab/core": "^2.0.4",
48
+ "@memlab/heap-analysis": "^2.0.4",
49
+ "@release-it/conventional-changelog": "^11.0.1",
50
+ "@stryker-mutator/core": "^9.6.1",
51
+ "@stryker-mutator/vitest-runner": "^9.6.1",
52
+ "@types/autocannon": "^7.12.7",
53
+ "@types/node": "^26.1.1",
54
+ "@types/semver": "^7.7.1",
55
+ "@vitest/coverage-v8": "^4.1.10",
56
+ "husky": "^9.1.7",
57
+ "release-it": "^20.2.1",
58
+ "tsup": "^8.5.1",
59
+ "typescript": "^7.0.2",
60
+ "vitest": "^4.1.10"
61
+ },
62
+ "dependencies": {
63
+ "autocannon": "^8.0.0",
64
+ "semver": "^7.8.5",
65
+ "zod": "^4.4.3"
66
+ },
67
+ "repository": {
68
+ "type": "git",
69
+ "url": "git+https://github.com/xabierlameiro/next-leak.git"
70
+ },
71
+ "homepage": "https://github.com/xabierlameiro/next-leak#readme",
72
+ "bugs": {
73
+ "url": "https://github.com/xabierlameiro/next-leak/issues"
74
+ }
75
+ }