next-leak 0.5.0 → 0.6.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.
@@ -19,6 +19,26 @@ export declare const routesManifestSchema: z.ZodObject<{
19
19
  }, z.core.$loose>>;
20
20
  }, z.core.$loose>;
21
21
  export type RoutesManifest = z.infer<typeof routesManifestSchema>;
22
+ /**
23
+ * `.next/prerender-manifest.json` — only the fields ISR handling relies on.
24
+ *
25
+ * `routes` holds concrete prerendered paths; a dynamic template's revalidation
26
+ * period lives on those entries, linked back by `srcRoute`, not on the
27
+ * `dynamicRoutes` entry for the template itself. On the vercel/next.js#96533
28
+ * reproduction, `/posts/[slug]` carries no period while its 200 concrete
29
+ * `/posts/post-N` entries each carry `initialRevalidateSeconds: 3600` with
30
+ * `srcRoute: "/posts/[slug]"`.
31
+ */
32
+ export declare const prerenderManifestSchema: z.ZodObject<{
33
+ routes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
34
+ initialRevalidateSeconds: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodLiteral<false>]>>;
35
+ srcRoute: z.ZodOptional<z.ZodNullable<z.ZodString>>;
36
+ }, z.core.$loose>>>;
37
+ preview: z.ZodOptional<z.ZodObject<{
38
+ previewModeId: z.ZodString;
39
+ }, z.core.$loose>>;
40
+ }, z.core.$loose>;
41
+ export type PrerenderManifest = z.infer<typeof prerenderManifestSchema>;
22
42
  export type RouteKind = "page" | "route-handler";
23
43
  export type DiscoveredRoute = {
24
44
  /** Request path, e.g. "/" or "/products/[id]". */
@@ -0,0 +1,44 @@
1
+ export type ProcessRow = {
2
+ pid: number;
3
+ ppid: number;
4
+ /** Resident set size in bytes. `ps` reports KB; converted once, here. */
5
+ rssBytes: number;
6
+ command: string;
7
+ };
8
+ /**
9
+ * Parses `ps -axo pid,ppid,rss,command` output.
10
+ *
11
+ * Columns are right-aligned to widths that vary with the values, and the
12
+ * command itself contains spaces — so the first three fields are split off and
13
+ * everything after them is the command. A line that does not start with three
14
+ * numbers is not a process row (the header, or a wrapped line) and is skipped
15
+ * rather than failing the sample.
16
+ */
17
+ export declare function parseProcessTable(output: string): ProcessRow[];
18
+ /**
19
+ * Every process below `rootPid`, at any depth, excluding the root itself.
20
+ *
21
+ * Walks generation by generation rather than recursing per row: a build tree is
22
+ * shallow but the machine's process table is not, and this visits each row once
23
+ * per generation instead of once per ancestor.
24
+ */
25
+ export declare function descendantsOf(rows: readonly ProcessRow[], rootPid: number): ProcessRow[];
26
+ /** Whether a row is one of the build's static-generation workers. */
27
+ export declare function isStaticGenWorker(row: ProcessRow): boolean;
28
+ export type ProcessTableSample = {
29
+ ok: true;
30
+ rows: ProcessRow[];
31
+ } | {
32
+ ok: false;
33
+ reason: string;
34
+ };
35
+ /**
36
+ * One sample of the whole process table.
37
+ *
38
+ * The failure is reported rather than swallowed. An empty table and an
39
+ * unreadable one look identical downstream — both yield no workers — and
40
+ * reporting "nothing to measure" when the truth is "could not look" is the
41
+ * silent false negative this tool exists to avoid. Seen for real: a sandbox
42
+ * that denies `ps` turned a leaking build into a clean run.
43
+ */
44
+ export declare function sampleProcessTable(): Promise<ProcessTableSample>;
package/dist/report.d.ts CHANGED
@@ -1,3 +1,2 @@
1
1
  import type { RunReport } from "./runner.js";
2
- /** Renders the terminal report. Pure: no I/O, no colors, stable output. */
3
2
  export declare function formatReport(report: RunReport): string;
package/dist/ritual.d.ts CHANGED
@@ -89,6 +89,21 @@ export type RitualResult = {
89
89
  memorySamples: HeapSample[];
90
90
  /** Highest memory seen during each load cycle, sampled without collecting. */
91
91
  peaks: PeakSample[];
92
+ /**
93
+ * One reading per cycle taken before any collection is forced — what the
94
+ * process is holding on its own.
95
+ *
96
+ * Every other number here is post-GC, which is what makes a verdict mean
97
+ * something and is also blind to a whole class of leak: memory a full GC
98
+ * would reclaim that a production process never runs one often enough to
99
+ * (vercel/next.js#96533). Absolute values overstate retention — this is
100
+ * taken seconds after load, not hours, so it includes garbage not yet
101
+ * collected — but a roughly constant offset across identical cycles cancels
102
+ * out of the deltas the trend is read from.
103
+ */
104
+ unreclaimedSamples: HeapSample[];
105
+ /** Trend over `unreclaimedSamples`. Reported, never the verdict. */
106
+ unreclaimedTrend: TrendResult;
92
107
  baselineSnapshot: string;
93
108
  afterSnapshot: string;
94
109
  trend: TrendResult;
@@ -110,6 +125,13 @@ export type RitualDeps = {
110
125
  /** GC-free read, polled while the app is under load. */
111
126
  readMemory: (port: number) => Promise<HeapSample>;
112
127
  };
128
+ /**
129
+ * The pre-collection wait, taken *out of* the idle rather than added to it.
130
+ *
131
+ * Capped at a quarter of the idle so short profiles keep their settle budget:
132
+ * `--quick` idles for 8 s, and a flat 3 s would be nearly half of it.
133
+ */
134
+ export declare function unreclaimedSettleFor(idleMs: number): number;
113
135
  /**
114
136
  * Single source of truth for ritual defaults — reports must echo them.
115
137
  *
@@ -1,9 +1,4 @@
1
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
2
  declare const routeConfigSchema: z.ZodObject<{
8
3
  params: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
9
4
  routes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>>>;
@@ -18,12 +13,31 @@ export declare class RouteConfigError extends Error {
18
13
  export declare const ROUTE_CONFIG_FILE = "next-leak.config.json";
19
14
  /** Missing file → empty config. Present but invalid → loud failure. */
20
15
  export declare function loadRouteConfig(appDir: string): Promise<RouteConfig>;
16
+ export type DynamicSegment = {
17
+ name: string;
18
+ /** `[...slug]` — the value may span several path segments. */
19
+ catchAll: boolean;
20
+ /** `[[...slug]]` — the segment can be omitted entirely. */
21
+ optional: boolean;
22
+ };
23
+ /**
24
+ * The dynamic segments of a route template, in order.
25
+ *
26
+ * Exported so guidance and resolution read the same grammar: a skeleton that
27
+ * named parameters the resolver does not recognise would be worse than none.
28
+ */
29
+ export declare function dynamicSegmentsOf(routeTemplate: string): DynamicSegment[];
21
30
  /**
22
31
  * Sample values may contain `{n}`, which the load phase replaces with a
23
32
  * per-request counter. Leaks keyed by URL (route caches, LRUs, bot traffic
24
33
  * with unique tails) are invisible when every request hits the same path.
25
34
  */
26
35
  export declare const UNIQUE_MARKER = "{n}";
36
+ /** The bound in a sample value, or null when it carries none. */
37
+ export declare function boundedMarkerOf(value: string): {
38
+ marker: string;
39
+ bound: number;
40
+ } | null;
27
41
  /**
28
42
  * Substitutes sample values into a dynamic route template and returns a
29
43
  * URL-safe request path (or null when a param has no configured value;
@@ -0,0 +1,21 @@
1
+ import type { PrerenderManifest } from "./manifests.js";
2
+ /**
3
+ * Sample values lifted from paths the build actually prerendered.
4
+ *
5
+ * A skeleton full of placeholders still leaves the user guessing what a valid
6
+ * value looks like. The build already knows: `prerender-manifest.json` lists
7
+ * concrete paths, each pointing back at its template through `srcRoute`, so a
8
+ * template like `/posts/[slug]` can be filled with a real `post-0` that will
9
+ * resolve on the first try.
10
+ */
11
+ export declare function sampleValuesFromManifest(manifest: PrerenderManifest | undefined, routeTemplate: string): Record<string, string> | null;
12
+ /**
13
+ * The `next-leak.config.json` that would measure the routes a run had to skip.
14
+ *
15
+ * Printed rather than merely referenced: telling someone a config file exists
16
+ * and leaving them to work out its shape is the difference between a run they
17
+ * can fix in ten seconds and one they abandon.
18
+ */
19
+ export declare function renderConfigSkeleton(routeTemplates: readonly string[], manifest?: PrerenderManifest | undefined): string | null;
20
+ /** Whether a rendered skeleton still needs the user to fill anything in. */
21
+ export declare function hasPlaceholders(skeleton: string): boolean;
package/dist/runner.d.ts CHANGED
@@ -3,6 +3,7 @@ import { type ConfidenceReport } from "./confidence.js";
3
3
  import type { HeapSample } from "./control-server.js";
4
4
  import { type MeasurementEnvironment } from "./environment.js";
5
5
  import { diffSnapshotFiles, type HeapDiff } from "./heap-diff.js";
6
+ import { type PrerenderManifest } from "./manifests.js";
6
7
  import { extractModuleRegistry } from "./module-registry.js";
7
8
  import { runRitual, type LoadOutcome, type PeakSample, type PhaseTiming, type SettleOutcome } from "./ritual.js";
8
9
  import { readNextVersion, type MatchedSignature } from "./signatures.js";
@@ -15,6 +16,17 @@ export type RouteReport = {
15
16
  route: string;
16
17
  status: "failed";
17
18
  reason: string;
19
+ }
20
+ /**
21
+ * The route was reachable, but the load could not have exercised the code
22
+ * path it represents — so no verdict is emitted. Measuring an ISR route
23
+ * without driving revalidation reports a flat curve about the static cache,
24
+ * which reads as health and is not.
25
+ */
26
+ | {
27
+ route: string;
28
+ status: "not-exercised";
29
+ reason: string;
18
30
  } | {
19
31
  route: string;
20
32
  status: "measured";
@@ -33,6 +45,30 @@ export type RouteReport = {
33
45
  * here is post-GC; this is the one a container limit is judged against.
34
46
  */
35
47
  peaks: PeakSample[];
48
+ /**
49
+ * One reading per cycle taken before any forced collection, and the
50
+ * trend over them. What a production process holds between full GCs is
51
+ * not what this tool's verdict measures — see `unreclaimed-retention`.
52
+ * Empty when a reading was lost: a hole would make every later delta
53
+ * span two cycles.
54
+ */
55
+ unreclaimedSamples: HeapSample[];
56
+ unreclaimedTrend: TrendResult;
57
+ /** Requests each cycle served — what the growth rates normalize by. */
58
+ requestsPerCycle: number;
59
+ /**
60
+ * Distinct keys the load cycled through, when the route asked for a
61
+ * bounded set. A verdict about a cache depends on how many keys it saw,
62
+ * so the number belongs on the record with the rest of the regime.
63
+ */
64
+ keyCardinality?: number;
65
+ /**
66
+ * Seconds of the ISR revalidation period this route was driven through,
67
+ * when it has one. Absent on routes not served from the ISR cache.
68
+ * Recorded because a curve measured against a cache and one measured
69
+ * against a re-render are different experiments.
70
+ */
71
+ revalidatedEverySeconds?: number;
36
72
  /** RSS growth per 1000 requests, computed like the heap figure. */
37
73
  rssPer1000Requests: number;
38
74
  /** Wall-clock per phase — explains where a long run spent its time. */
@@ -88,6 +124,8 @@ export type RunReport = {
88
124
  appDir: string;
89
125
  startedAt: string;
90
126
  workDir: string;
127
+ /** Carried so the report can suggest sample params the build already knows. */
128
+ prerender?: PrerenderManifest;
91
129
  environment: MeasurementEnvironment;
92
130
  parameters: RunParameters;
93
131
  routes: RouteReport[];
package/dist/target.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type AppPathsManifest, type PagesManifest, type RoutesManifest } from "./manifests.js";
1
+ import { type AppPathsManifest, type PagesManifest, type PrerenderManifest, type RoutesManifest } from "./manifests.js";
2
2
  export type TargetErrorCode = "NO_BUILD" | "NO_STANDALONE" | "BAD_MANIFEST";
3
3
  export declare class TargetError extends Error {
4
4
  readonly code: TargetErrorCode;
@@ -12,6 +12,11 @@ export type ValidatedTarget = {
12
12
  pages: PagesManifest;
13
13
  /** Absent on builds that do not emit it; nothing downstream reads it. */
14
14
  routes: RoutesManifest | undefined;
15
+ /**
16
+ * Absent on builds with nothing prerendered. Carries which routes revalidate
17
+ * and the `previewModeId` needed to make them re-render under load.
18
+ */
19
+ prerender: PrerenderManifest | undefined;
15
20
  };
16
21
  /**
17
22
  * Validates that `appDir` contains a production build with
@@ -0,0 +1,64 @@
1
+ import type { HeapSample } from "./control-server.js";
2
+ import type { TrendVerdict } from "./trend.js";
3
+ /**
4
+ * Memory classes compared between the two series, least to most specific.
5
+ *
6
+ * `arrayBuffers` is a subset of `external`, so when both are equally out of
7
+ * proportion the later one wins the tie below — naming the narrower class says
8
+ * more about the mechanism.
9
+ */
10
+ declare const CLASSES: readonly ["heap", "external", "arrayBuffers"];
11
+ export type MemoryClass = (typeof CLASSES)[number];
12
+ export type UnreclaimedRetention = {
13
+ /** Where holding-versus-retaining was most out of proportion. */
14
+ class: MemoryClass;
15
+ /** Largest per-cycle gap between what was held and what survived a GC. */
16
+ largestGapBytes: number;
17
+ /** What the route retained after collection, in that class. */
18
+ retainedBytes: number;
19
+ /** The gap as a multiple of what is retained. */
20
+ ratio: number;
21
+ };
22
+ export type UnreclaimedRetentionInput = {
23
+ /** One reading per cycle, taken before any forced collection. */
24
+ unreclaimedSamples: readonly HeapSample[];
25
+ /** Post-GC samples: baseline first, then one per cycle. */
26
+ memorySamples: readonly HeapSample[];
27
+ /** The route's own verdict, computed from the post-GC series. */
28
+ verdict: TrendVerdict;
29
+ /**
30
+ * Whether that `leak` verdict is one the evidence plainly supports — the same
31
+ * bar an issue draft has to clear.
32
+ *
33
+ * A weak `leak` must not silence this note. Measured on the #96533
34
+ * reproduction: the post-GC verdict is `leak` at +0.15 MB/1000 req carrying
35
+ * two low-confidence warnings, which is noise grazing the threshold, while
36
+ * the gap is 3.96 MB of arrayBuffers at 4x what the route retains. Silencing
37
+ * on any `leak` at all let the weaker finding hide the stronger one.
38
+ */
39
+ verdictIsWellSupported: boolean;
40
+ };
41
+ /**
42
+ * Whether a route holds materially more memory between collections than it
43
+ * retains after one.
44
+ *
45
+ * The shape of vercel/next.js#96533, where the reporter accumulated 1.16 GB of
46
+ * arrayBuffers over four days against a flat JS heap. Measured on that repro
47
+ * 2026-08-18, the signal is a *gap*, not a slope: arrayBuffers sat at 4–5 MB
48
+ * between collections against a flat 0.32 MB after one, while the series itself
49
+ * oscillated (+21.2, -3.0, -6.2, +14.2 MB) and classified as `inconclusive`. An
50
+ * earlier version keyed on that series climbing and reported nothing at all.
51
+ *
52
+ * Outside the verdict, like the peak-pressure note: those are statements about
53
+ * what survives collection, this is one about what precedes it. Silent when the
54
+ * route already leaks on evidence that supports it — but a marginal `leak` does
55
+ * not silence it, since on #96533 that weak verdict was hiding this finding.
56
+ */
57
+ export declare function assessUnreclaimedRetention(input: UnreclaimedRetentionInput): UnreclaimedRetention | null;
58
+ /**
59
+ * One line, phrased so it never contradicts the verdict beside it, and so it
60
+ * states its own limit: the reading is taken seconds after load, not the hours
61
+ * a production process runs between full collections.
62
+ */
63
+ export declare function describeUnreclaimedRetention(retention: UnreclaimedRetention): string;
64
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-leak",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",