next-leak 0.7.0 → 0.8.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.
@@ -91969,1602 +91969,1618 @@ var require_autocannon = __commonJS({
91969
91969
  }
91970
91970
  });
91971
91971
 
91972
- // src/manifests.ts
91973
- import { z } from "zod";
91974
- var appPathsManifestSchema = z.record(z.string(), z.string());
91975
- var pagesManifestSchema = z.record(z.string(), z.string());
91976
- var routeEntrySchema = z.looseObject({
91977
- page: z.string(),
91978
- regex: z.string()
91979
- });
91980
- var routesManifestSchema = z.looseObject({
91981
- version: z.number(),
91982
- basePath: z.string(),
91983
- staticRoutes: z.array(routeEntrySchema),
91984
- dynamicRoutes: z.array(routeEntrySchema)
91985
- });
91986
- var prerenderManifestSchema = z.looseObject({
91987
- routes: z.record(
91988
- z.string(),
91989
- z.looseObject({
91990
- // `false` on a route that is prerendered but never revalidates.
91991
- initialRevalidateSeconds: z.union([z.number(), z.literal(false)]).optional(),
91992
- srcRoute: z.string().nullable().optional()
91993
- })
91994
- ).optional(),
91995
- preview: z.looseObject({ previewModeId: z.string() }).optional()
91996
- });
91997
- var INTERNAL_PATHS = /* @__PURE__ */ new Set(["/_global-error", "/_not-found"]);
91998
- var INTERCEPTING_SEGMENT = /^(\(\.{1,3}\))+/;
91999
- var STATIC_ASSET_PATHS = /* @__PURE__ */ new Set(["/favicon.ico"]);
92000
- function unaddressableReason(routePath) {
92001
- if (routePath.split("/").some((segment) => INTERCEPTING_SEGMENT.test(segment))) {
92002
- return "intercepting route \u2014 only reachable via client navigation";
91972
+ // src/attribution.ts
91973
+ var UNATTRIBUTED = { owner: "unattributed", source: null, packageName: null };
91974
+ function classifySource(rawSource) {
91975
+ let source = rawSource.replace(/^turbopack:\/+(?=\[)/, "");
91976
+ try {
91977
+ source = decodeURIComponent(source);
91978
+ } catch {
92003
91979
  }
92004
- if (STATIC_ASSET_PATHS.has(routePath)) {
92005
- return "static asset served by a generated handler \u2014 no user code to leak";
91980
+ const nodeModulesSplit = source.split("/node_modules/");
91981
+ if (nodeModulesSplit.length > 1) {
91982
+ const tail = nodeModulesSplit.at(-1) ?? "";
91983
+ const segments = tail.split("/");
91984
+ const packageName = (segments[0]?.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0]) ?? null;
91985
+ if (packageName === "next" || packageName === "react" || packageName === "react-dom") {
91986
+ return { owner: "framework", source: null, packageName };
91987
+ }
91988
+ return { owner: "dependency", source: null, packageName };
92006
91989
  }
92007
- return void 0;
91990
+ if (source.startsWith("[project]/")) {
91991
+ return { owner: "app", source: source.slice("[project]/".length), packageName: null };
91992
+ }
91993
+ const relative = source.replace(/^(\.\.\/)+/, "");
91994
+ if (relative !== source && !relative.startsWith("[")) {
91995
+ return { owner: "app", source: relative, packageName: null };
91996
+ }
91997
+ return { owner: "framework", source: null, packageName: null };
92008
91998
  }
92009
- function toRequestPath(manifestKey) {
92010
- let kind;
92011
- let withoutSuffix;
92012
- if (manifestKey.endsWith("/page")) {
92013
- kind = "page";
92014
- withoutSuffix = manifestKey.slice(0, -"/page".length);
92015
- } else if (manifestKey.endsWith("/route")) {
92016
- kind = "route-handler";
92017
- withoutSuffix = manifestKey.slice(0, -"/route".length);
92018
- } else {
92019
- return null;
91999
+ var OWNER_PRIORITY = {
92000
+ app: 0,
92001
+ dependency: 1,
92002
+ framework: 2,
92003
+ unattributed: 3
92004
+ };
92005
+ var FRAMEWORK_CHAIN_MARKERS = [
92006
+ ["fsChecker", "route filesystem checker"],
92007
+ ["getDynamicRoutes", "dynamic route matcher"],
92008
+ ["NextNodeServer", "Next server"],
92009
+ ["AppPageRouteModule", "App Router page module"],
92010
+ ["AppRouteRouteModule", "App Router route handler module"],
92011
+ ["incrementalCache", "incremental cache"],
92012
+ ["IncrementalCache", "incremental cache"],
92013
+ ["ReactServer", "React Server Components renderer"],
92014
+ ["loadManifests", "manifest loader"],
92015
+ ["renderToHTML", "HTML renderer"]
92016
+ ];
92017
+ function classifyByChain(retainerChain2) {
92018
+ for (const [marker, component] of FRAMEWORK_CHAIN_MARKERS) {
92019
+ if (retainerChain2.includes(marker)) {
92020
+ return { owner: "framework", source: null, packageName: `next (${component})` };
92021
+ }
92020
92022
  }
92021
- const segments = withoutSuffix.split("/").filter((segment) => segment !== "").filter((segment) => !(segment.startsWith("(") && segment.endsWith(")"))).filter((segment) => !segment.startsWith("@"));
92022
- return { path: `/${segments.join("/")}`, kind };
92023
+ return null;
92023
92024
  }
92024
- var PAGES_INTERNAL = /* @__PURE__ */ new Set(["/_app", "/_document", "/_error", "/404", "/500"]);
92025
- function discoverPagesRoutes(pages) {
92026
- const routes = [];
92027
- for (const key of Object.keys(pages)) {
92028
- if (PAGES_INTERNAL.has(key)) {
92025
+ function attributeFinding(finding, registry) {
92026
+ let best = UNATTRIBUTED;
92027
+ for (const id of finding.moduleIds) {
92028
+ const source = registry.get(id);
92029
+ if (source === void 0) {
92029
92030
  continue;
92030
92031
  }
92031
- routes.push({
92032
- path: key,
92033
- kind: key === "/api" || key.startsWith("/api/") ? "route-handler" : "page",
92034
- dynamic: key.includes("[")
92035
- });
92032
+ const candidate = classifySource(source);
92033
+ if (OWNER_PRIORITY[candidate.owner] < OWNER_PRIORITY[best.owner]) {
92034
+ best = candidate;
92035
+ }
92036
92036
  }
92037
- return routes.sort((a, b) => a.path.localeCompare(b.path));
92037
+ if (best.owner === "unattributed") {
92038
+ return classifyByChain(finding.retainerChain ?? "") ?? UNATTRIBUTED;
92039
+ }
92040
+ return best;
92038
92041
  }
92039
- function discoverRoutes(appPaths) {
92040
- const routes = /* @__PURE__ */ new Map();
92041
- for (const key of Object.keys(appPaths)) {
92042
- const parsed = toRequestPath(key);
92043
- if (parsed === null || INTERNAL_PATHS.has(parsed.path) || routes.has(parsed.path)) {
92042
+ function attributeDiff(diff, registry) {
92043
+ const all = [...diff.grownNodes, ...diff.newNodes];
92044
+ const findings = all.map((finding) => attributeFinding(finding, registry));
92045
+ const byGroup = /* @__PURE__ */ new Map();
92046
+ let attributedBytes = 0;
92047
+ for (const [index, attribution] of findings.entries()) {
92048
+ if (attribution.owner === "unattributed") {
92044
92049
  continue;
92045
92050
  }
92046
- const reason = unaddressableReason(parsed.path);
92047
- routes.set(parsed.path, {
92048
- path: parsed.path,
92049
- kind: parsed.kind,
92050
- dynamic: parsed.path.includes("["),
92051
- ...reason !== void 0 && { unaddressableReason: reason }
92052
- });
92051
+ const bytes = all[index]?.retainedBytes ?? 0;
92052
+ attributedBytes += bytes;
92053
+ const key = `${attribution.owner}|${attribution.source ?? ""}|${attribution.packageName ?? ""}`;
92054
+ const group = byGroup.get(key) ?? { attribution, bytes: 0 };
92055
+ group.bytes += bytes;
92056
+ byGroup.set(key, group);
92053
92057
  }
92054
- return [...routes.values()].sort((a, b) => a.path.localeCompare(b.path));
92058
+ let winner = null;
92059
+ for (const group of byGroup.values()) {
92060
+ if (winner === null || group.bytes > winner.bytes) {
92061
+ winner = group;
92062
+ }
92063
+ }
92064
+ const route = winner === null || attributedBytes === 0 ? { ...UNATTRIBUTED, dominance: 0 } : { ...winner.attribution, dominance: winner.bytes / attributedBytes };
92065
+ return { findings, route };
92055
92066
  }
92056
92067
 
92057
- // src/target.ts
92058
- import { access, readFile } from "fs/promises";
92059
- import path from "path";
92060
- var TargetError = class extends Error {
92061
- code;
92062
- constructor(code, message) {
92063
- super(message);
92064
- this.name = "TargetError";
92065
- this.code = code;
92066
- }
92068
+ // src/heap-diff.ts
92069
+ var import_core = __toESM(require_dist(), 1);
92070
+ var import_heap_analysis = __toESM(require_dist4(), 1);
92071
+ var DEFAULTS = {
92072
+ minTypeDeltaBytes: 20 * 1024,
92073
+ grownThresholdBytes: 100 * 1024,
92074
+ newThresholdBytes: 2 * 1024,
92075
+ bigRetainedFloorBytes: 50 * 1024,
92076
+ maxFindings: 10,
92077
+ chainDepth: 7
92067
92078
  };
92068
- async function exists(file) {
92069
- try {
92070
- await access(file);
92071
- return true;
92072
- } catch {
92073
- return false;
92074
- }
92075
- }
92076
- async function readManifest(file, parse2) {
92077
- let raw;
92078
- try {
92079
- raw = JSON.parse(await readFile(file, "utf8"));
92080
- } catch (cause) {
92081
- throw new TargetError(
92082
- "BAD_MANIFEST",
92083
- `Could not read ${file}: ${cause instanceof Error ? cause.message : String(cause)}`
92079
+ function summarizeBaseline(heap, options = {}) {
92080
+ const resolved = { ...DEFAULTS, ...options };
92081
+ const summary = {
92082
+ nodeIds: /* @__PURE__ */ new Set(),
92083
+ bigRetained: /* @__PURE__ */ new Map(),
92084
+ typeSelfSizes: /* @__PURE__ */ new Map()
92085
+ };
92086
+ heap.nodes.forEach((node) => {
92087
+ summary.nodeIds.add(node.id);
92088
+ if (node.retainedSize >= resolved.bigRetainedFloorBytes) {
92089
+ summary.bigRetained.set(node.id, node.retainedSize);
92090
+ }
92091
+ summary.typeSelfSizes.set(
92092
+ node.type,
92093
+ (summary.typeSelfSizes.get(node.type) ?? 0) + node.self_size
92084
92094
  );
92085
- }
92086
- try {
92087
- return parse2(raw);
92088
- } catch {
92089
- throw new TargetError(
92090
- "BAD_MANIFEST",
92091
- `${file} does not match the expected Next.js manifest shape. This Next.js version may not be supported yet.`
92095
+ });
92096
+ return summary;
92097
+ }
92098
+ function truncateLabel(value, max2 = 60) {
92099
+ return value.replaceAll("\n", " ").slice(0, max2);
92100
+ }
92101
+ function walkChain(node, depth) {
92102
+ const parts = [];
92103
+ const nodes = [node];
92104
+ let current = node;
92105
+ const seen = /* @__PURE__ */ new Set([node.id]);
92106
+ for (let i = 0; i < depth; i += 1) {
92107
+ const referrers = current.referrers;
92108
+ if (referrers.length === 0) {
92109
+ break;
92110
+ }
92111
+ const edge = referrers.find(
92112
+ (candidate) => !seen.has(candidate.fromNode.id) && candidate.fromNode.type !== "synthetic" && candidate.type !== "weak"
92113
+ ) ?? referrers[0];
92114
+ if (edge === void 0 || seen.has(edge.fromNode.id)) {
92115
+ break;
92116
+ }
92117
+ current = edge.fromNode;
92118
+ seen.add(current.id);
92119
+ nodes.push(current);
92120
+ parts.push(
92121
+ `${truncateLabel(current.name)}#${current.type}[.${truncateLabel(
92122
+ String(edge.name_or_index),
92123
+ 40
92124
+ )}]`
92092
92125
  );
92093
92126
  }
92127
+ return { parts, nodes };
92094
92128
  }
92095
- async function validateTarget(appDir) {
92096
- const nextDir = path.resolve(appDir, ".next");
92097
- if (!await exists(nextDir)) {
92098
- throw new TargetError(
92099
- "NO_BUILD",
92100
- `No .next directory in ${appDir}. Run "next build" first.`
92129
+ function retainerChain(node, depth) {
92130
+ return walkChain(node, depth).parts.join(" <- ");
92131
+ }
92132
+ function collectModuleIds(chainNodes) {
92133
+ const ids = [];
92134
+ for (const node of chainNodes) {
92135
+ const isModuleInstance = (node.references ?? []).some(
92136
+ (edge) => edge.name_or_index === "namespaceObject" || edge.name_or_index === "exports"
92101
92137
  );
92138
+ if (!isModuleInstance) {
92139
+ continue;
92140
+ }
92141
+ for (const edge of node.referrers) {
92142
+ if (edge.type !== "element") {
92143
+ continue;
92144
+ }
92145
+ const id = Number(edge.name_or_index);
92146
+ if (Number.isInteger(id) && !ids.includes(id)) {
92147
+ ids.push(id);
92148
+ }
92149
+ }
92102
92150
  }
92103
- const standaloneServer = path.join(nextDir, "standalone", "server.js");
92104
- if (!await exists(standaloneServer)) {
92105
- throw new TargetError(
92106
- "NO_STANDALONE",
92107
- `No ${standaloneServer}.
92108
- next-leak measures the standalone server bundle. Enable it once in next.config:
92109
-
92110
- const nextConfig = {
92111
- output: "standalone",
92112
- // ...your existing config
92113
- };
92114
-
92115
- then rebuild: next build
92116
- This only changes how the build is packaged \u2014 not how your app behaves.`
92117
- );
92118
- }
92119
- const appPathsFile = path.join(nextDir, "server", "app-paths-manifest.json");
92120
- const pagesFile = path.join(nextDir, "server", "pages-manifest.json");
92121
- const appPaths = await exists(appPathsFile) ? await readManifest(appPathsFile, (raw) => appPathsManifestSchema.parse(raw)) : {};
92122
- const pages = await exists(pagesFile) ? await readManifest(pagesFile, (raw) => pagesManifestSchema.parse(raw)) : {};
92123
- if (Object.keys(appPaths).length === 0 && Object.keys(pages).length === 0) {
92124
- throw new TargetError(
92125
- "BAD_MANIFEST",
92126
- `No routes found in ${nextDir}/server: neither app-paths-manifest.json nor pages-manifest.json is present. Rebuild the app with "next build".`
92151
+ return ids;
92152
+ }
92153
+ function diffAgainstBaseline(baseline, after, options = {}) {
92154
+ const resolved = { ...DEFAULTS, ...options };
92155
+ const afterTypeSelfSizes = /* @__PURE__ */ new Map();
92156
+ const grown = [];
92157
+ const fresh = [];
92158
+ after.nodes.forEach((node) => {
92159
+ afterTypeSelfSizes.set(
92160
+ node.type,
92161
+ (afterTypeSelfSizes.get(node.type) ?? 0) + node.self_size
92127
92162
  );
92163
+ if (node.type === "synthetic") {
92164
+ return;
92165
+ }
92166
+ if (!baseline.nodeIds.has(node.id)) {
92167
+ if (node.retainedSize >= resolved.newThresholdBytes) {
92168
+ const chain = walkChain(node, resolved.chainDepth);
92169
+ fresh.push({
92170
+ kind: "new",
92171
+ nodeType: node.type,
92172
+ name: truncateLabel(node.name),
92173
+ retainedBytes: node.retainedSize,
92174
+ retainerChain: chain.parts.join(" <- "),
92175
+ moduleIds: collectModuleIds(chain.nodes)
92176
+ });
92177
+ }
92178
+ return;
92179
+ }
92180
+ const before = baseline.bigRetained.get(node.id);
92181
+ if (before !== void 0 && node.retainedSize - before >= resolved.grownThresholdBytes) {
92182
+ const chain = walkChain(node, resolved.chainDepth);
92183
+ grown.push({
92184
+ kind: "grown",
92185
+ nodeType: node.type,
92186
+ name: truncateLabel(node.name),
92187
+ retainedBytes: node.retainedSize - before,
92188
+ retainerChain: chain.parts.join(" <- "),
92189
+ moduleIds: collectModuleIds(chain.nodes)
92190
+ });
92191
+ }
92192
+ });
92193
+ const typeDeltas = [];
92194
+ const allTypes = /* @__PURE__ */ new Set([...baseline.typeSelfSizes.keys(), ...afterTypeSelfSizes.keys()]);
92195
+ for (const type of allTypes) {
92196
+ const delta = (afterTypeSelfSizes.get(type) ?? 0) - (baseline.typeSelfSizes.get(type) ?? 0);
92197
+ if (Math.abs(delta) >= resolved.minTypeDeltaBytes) {
92198
+ typeDeltas.push({ type, deltaBytes: delta });
92199
+ }
92128
92200
  }
92129
- const routesFile = path.join(nextDir, "routes-manifest.json");
92130
- const routes = await exists(routesFile) ? await readManifest(routesFile, (raw) => routesManifestSchema.parse(raw)) : void 0;
92131
- const prerenderFile = path.join(nextDir, "prerender-manifest.json");
92132
- const prerender = await exists(prerenderFile) ? await readManifest(prerenderFile, (raw) => prerenderManifestSchema.parse(raw)) : void 0;
92201
+ typeDeltas.sort((a, b) => b.deltaBytes - a.deltaBytes);
92202
+ grown.sort((a, b) => b.retainedBytes - a.retainedBytes);
92203
+ fresh.sort((a, b) => b.retainedBytes - a.retainedBytes);
92133
92204
  return {
92134
- appDir: path.resolve(appDir),
92135
- standaloneServer,
92136
- appPaths,
92137
- pages,
92138
- routes,
92139
- prerender
92205
+ typeDeltas,
92206
+ grownNodes: grown.slice(0, resolved.maxFindings),
92207
+ newNodes: fresh.slice(0, resolved.maxFindings)
92140
92208
  };
92141
92209
  }
92142
-
92143
- // src/launcher.ts
92144
- import { spawn } from "child_process";
92145
- import { readdir, readFile as readFile2 } from "fs/promises";
92146
- import path2 from "path";
92147
- import { pathToFileURL } from "url";
92148
- import { z as z2 } from "zod";
92149
- var controlFileSchema = z2.object({ port: z2.number(), pid: z2.number() });
92150
- var DEFAULT_MAX_OLD_SPACE_MB = 512;
92151
- var LaunchError = class extends Error {
92210
+ var SnapshotError = class extends Error {
92152
92211
  constructor(message) {
92153
92212
  super(message);
92154
- this.name = "LaunchError";
92213
+ this.name = "SnapshotError";
92155
92214
  }
92156
92215
  };
92157
- var activeChildren = /* @__PURE__ */ new Set();
92158
- var STDERR_WINDOW = 4096;
92159
- function killActiveChildren() {
92160
- for (const child of activeChildren) {
92161
- child.kill("SIGKILL");
92216
+ var SNAPSHOT_HEAD = '{"snapshot"';
92217
+ var PROBE_BYTES = 512;
92218
+ async function assertReadableSnapshot(file) {
92219
+ const { open, stat } = await import("fs/promises");
92220
+ const { constants } = await import("buffer");
92221
+ let size;
92222
+ try {
92223
+ size = (await stat(file)).size;
92224
+ } catch {
92225
+ throw new SnapshotError(`heap snapshot is missing: ${file}`);
92162
92226
  }
92163
- }
92164
- function registerChild(child) {
92165
- activeChildren.add(child);
92166
- child.once("exit", () => activeChildren.delete(child));
92167
- }
92168
- function unregisterChild(child) {
92169
- activeChildren.delete(child);
92170
- }
92171
- function explainStartupFailure(stderr) {
92172
- const missingModule = /Cannot find module '([^']+)'/.exec(stderr);
92173
- if (missingModule !== null) {
92174
- return `the standalone build is missing a dependency (${missingModule[1]}). This is a build problem, not a measurement one: \`node .next/standalone/server.js\` fails the same way on its own. Rebuild, or copy the missing package into .next/standalone/node_modules.`;
92227
+ if (size === 0) {
92228
+ throw new SnapshotError(`heap snapshot is empty: ${file}`);
92175
92229
  }
92176
- if (/EADDRINUSE/.test(stderr)) {
92177
- return "the port was taken by another process while starting.";
92230
+ if (size > constants.MAX_STRING_LENGTH) {
92231
+ throw new SnapshotError(
92232
+ `heap snapshot is ${(size / (1024 * 1024)).toFixed(0)} MB, past the ${(constants.MAX_STRING_LENGTH / (1024 * 1024)).toFixed(0)} MB a single string can hold, so it cannot be parsed: ${file}. The measurement itself is unaffected \u2014 only the snapshot diff, which names the retaining object, is unavailable. Lower --requests or --cycles to keep the heap smaller if you need it`
92233
+ );
92178
92234
  }
92179
- return `stderr:
92180
- ${stderr}`;
92181
- }
92182
- function explainRuntimeFailure(stderr, maxOldSpaceMb) {
92183
- if (/heap out of memory|Reached heap limit|Ineffective mark-compacts/i.test(stderr)) {
92184
- return `the measured process ran out of heap and was killed by V8 mid-run (limit in force: --max-old-space-size=${maxOldSpaceMb} MB). That is the measurement: this route does not fit in ${maxOldSpaceMb} MB under this load. Raise it with --max-old-space <mb> to match your deployment, or lower --requests/--connections to measure a lighter regime.`;
92235
+ const handle = await open(file, "r");
92236
+ try {
92237
+ const head = Buffer.alloc(Math.min(PROBE_BYTES, size));
92238
+ await handle.read(head, 0, head.length, 0);
92239
+ if (!head.toString("utf8").trimStart().startsWith(SNAPSHOT_HEAD)) {
92240
+ throw new SnapshotError(
92241
+ `not a V8 heap snapshot (missing "snapshot" header): ${file}`
92242
+ );
92243
+ }
92244
+ const tail = Buffer.alloc(Math.min(PROBE_BYTES, size));
92245
+ await handle.read(tail, 0, tail.length, size - tail.length);
92246
+ if (!tail.toString("utf8").trimEnd().endsWith("}")) {
92247
+ throw new SnapshotError(
92248
+ `heap snapshot looks truncated (JSON does not close): ${file} \u2014 the measured process may have been killed while writing it`
92249
+ );
92250
+ }
92251
+ } finally {
92252
+ await handle.close();
92185
92253
  }
92186
- return `the measured process exited mid-run. ${explainStartupFailure(stderr)}`;
92187
92254
  }
92188
- var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
92189
- async function pollUntil(deadline, what, probe, failed) {
92190
- for (; ; ) {
92191
- const failure = failed();
92192
- if (failure !== void 0) {
92193
- throw new LaunchError(failure);
92255
+ var defaultLoader = async (file) => {
92256
+ await assertReadableSnapshot(file);
92257
+ import_core.config.muteConsole = true;
92258
+ return (0, import_heap_analysis.getFullHeapFromFile)(file);
92259
+ };
92260
+ async function diffSnapshotFiles(baselineFile, afterFile, options = {}, loadHeap = defaultLoader) {
92261
+ const summary = summarizeBaseline(await loadHeap(baselineFile), options);
92262
+ const after = await loadHeap(afterFile);
92263
+ return diffAgainstBaseline(summary, after, options);
92264
+ }
92265
+
92266
+ // src/vlq.ts
92267
+ var BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
92268
+ var CHAR_TO_VALUE = new Map([...BASE64].map((char, index) => [char, index]));
92269
+ function decodeVlqSegment(raw) {
92270
+ const values = [];
92271
+ let value = 0;
92272
+ let shift = 0;
92273
+ for (const char of raw) {
92274
+ const digit = CHAR_TO_VALUE.get(char);
92275
+ if (digit === void 0) {
92276
+ throw new Error(`invalid VLQ character: ${char}`);
92194
92277
  }
92195
- const result = await probe();
92196
- if (result !== void 0) {
92197
- return result;
92278
+ value += (digit & 31) << shift;
92279
+ if ((digit & 32) !== 0) {
92280
+ shift += 5;
92281
+ } else {
92282
+ values.push((value & 1) === 1 ? -(value >>> 1) : value >>> 1);
92283
+ value = 0;
92284
+ shift = 0;
92198
92285
  }
92199
- if (Date.now() > deadline) {
92200
- throw new LaunchError(`timed out waiting for ${what}`);
92286
+ }
92287
+ return values;
92288
+ }
92289
+ function decodeVlqLine(line) {
92290
+ const segments = [];
92291
+ for (const raw of line.split(",")) {
92292
+ if (raw !== "") {
92293
+ segments.push(decodeVlqSegment(raw));
92201
92294
  }
92202
- await sleep(100);
92203
92295
  }
92296
+ return segments;
92204
92297
  }
92205
- async function launchInstrumented(options) {
92206
- const hostname = options.hostname ?? "127.0.0.1";
92207
- const deadline = Date.now() + (options.readyTimeoutMs ?? 15e3);
92208
- const child = spawn(
92209
- process.execPath,
92210
- [
92211
- "--expose-gc",
92212
- `--max-old-space-size=${options.maxOldSpaceMb ?? DEFAULT_MAX_OLD_SPACE_MB}`,
92213
- "--import",
92214
- pathToFileURL(options.bootstrapPath).href,
92215
- options.serverPath
92216
- ],
92217
- {
92218
- cwd: path2.dirname(options.serverPath),
92219
- env: {
92220
- ...process.env,
92221
- ...options.env,
92222
- NODE_ENV: "production",
92223
- PORT: String(options.appPort),
92224
- HOSTNAME: hostname,
92225
- NEXT_LEAK_DIR: options.workDir
92226
- },
92227
- stdio: ["ignore", "ignore", "pipe"]
92298
+ function decodeMappings(mappings) {
92299
+ const lines = [];
92300
+ let sourceIndex = 0;
92301
+ for (const line of mappings.split(";")) {
92302
+ const entries = [];
92303
+ let column = 0;
92304
+ for (const segment of decodeVlqLine(line)) {
92305
+ const columnDelta = segment[0];
92306
+ if (columnDelta === void 0) {
92307
+ continue;
92308
+ }
92309
+ column += columnDelta;
92310
+ if (segment.length >= 4) {
92311
+ sourceIndex += segment[1] ?? 0;
92312
+ entries.push({ column, sourceIndex });
92313
+ }
92228
92314
  }
92229
- );
92230
- let stderrHead = "";
92231
- let stderrTailBuffer = "";
92232
- child.stderr?.on("data", (chunk) => {
92233
- const text = chunk.toString();
92234
- if (stderrHead.length < STDERR_WINDOW) {
92235
- stderrHead = (stderrHead + text).slice(0, STDERR_WINDOW);
92236
- }
92237
- stderrTailBuffer = (stderrTailBuffer + text).slice(-STDERR_WINDOW);
92238
- });
92239
- const stderrWindow = () => stderrHead === stderrTailBuffer || stderrTailBuffer === "" ? stderrHead : `${stderrHead}
92240
- [...]
92241
- ${stderrTailBuffer}`;
92242
- let exited = false;
92243
- activeChildren.add(child);
92244
- child.once("exit", () => {
92245
- exited = true;
92246
- activeChildren.delete(child);
92247
- });
92248
- const failed = () => exited ? `server exited before becoming ready. ${explainStartupFailure(stderrWindow())}` : void 0;
92249
- try {
92250
- const controlPort = await pollUntil(
92251
- deadline,
92252
- `control channel in ${options.workDir}`,
92253
- // Several processes may announce a channel (clustered servers); accept
92254
- // the first one that actually answers instead of trusting a filename.
92255
- async () => {
92256
- let entries;
92257
- try {
92258
- entries = (await readdir(options.workDir)).filter(
92259
- (entry) => entry.startsWith("control-") && entry.endsWith(".json")
92260
- );
92261
- } catch {
92262
- return void 0;
92263
- }
92264
- for (const entry of entries) {
92265
- try {
92266
- const parsed = controlFileSchema.parse(
92267
- JSON.parse(await readFile2(path2.join(options.workDir, entry), "utf8"))
92268
- );
92269
- const response = await fetch(`http://127.0.0.1:${parsed.port}/gc`);
92270
- if (response.ok) {
92271
- return parsed.port;
92272
- }
92273
- } catch {
92274
- continue;
92275
- }
92276
- }
92277
- return void 0;
92278
- },
92279
- failed
92280
- );
92281
- await pollUntil(
92282
- deadline,
92283
- `app on ${hostname}:${options.appPort}`,
92284
- async () => {
92285
- try {
92286
- await fetch(`http://${hostname}:${options.appPort}/`, { method: "HEAD" });
92287
- return true;
92288
- } catch {
92289
- return void 0;
92290
- }
92291
- },
92292
- failed
92293
- );
92294
- return {
92295
- pid: child.pid ?? -1,
92296
- appPort: options.appPort,
92297
- controlPort,
92298
- explainExit: () => exited ? explainRuntimeFailure(stderrWindow(), options.maxOldSpaceMb ?? DEFAULT_MAX_OLD_SPACE_MB) : null,
92299
- close: async () => {
92300
- if (exited) {
92301
- return;
92302
- }
92303
- const gone = new Promise((resolve) => child.once("exit", () => resolve()));
92304
- child.kill("SIGTERM");
92305
- const killTimer = setTimeout(() => child.kill("SIGKILL"), 3e3);
92306
- await gone;
92307
- clearTimeout(killTimer);
92308
- }
92309
- };
92310
- } catch (cause) {
92311
- child.kill("SIGKILL");
92312
- throw cause;
92313
- }
92314
- }
92315
-
92316
- // src/cli-args.ts
92317
- var BUILD_COMMAND = "build";
92318
- var RUN_ONLY_FLAGS = [
92319
- ["routes", "--routes"],
92320
- ["cycles", "--cycles"],
92321
- ["requests", "--requests"],
92322
- ["connections", "--connections"],
92323
- ["idleSeconds", "--idle"],
92324
- ["warmupRequests", "--warmup"],
92325
- ["maxOldSpaceMb", "--max-old-space"],
92326
- ["quick", "--quick"],
92327
- ["noResolve", "--no-resolve"],
92328
- ["diffAll", "--diff-all"],
92329
- ["writeConfig", "--write-config"]
92330
- ];
92331
- function runOnlyFlagUsed(options) {
92332
- for (const [field, flag] of RUN_ONLY_FLAGS) {
92333
- const value = options[field];
92334
- if (value !== null && value !== false) {
92335
- return flag;
92315
+ lines.push({ entries });
92316
+ }
92317
+ return lines;
92318
+ }
92319
+ function sourceIndexAt(lines, line, column) {
92320
+ const mapping = lines[line];
92321
+ if (mapping === void 0) {
92322
+ return void 0;
92323
+ }
92324
+ let found;
92325
+ for (const entry of mapping.entries) {
92326
+ if (entry.column > column) {
92327
+ break;
92336
92328
  }
92329
+ found = entry.sourceIndex;
92337
92330
  }
92338
- return null;
92331
+ return found;
92339
92332
  }
92340
- var FLAGS = [
92341
- {
92342
- flag: "--routes",
92343
- value: "list",
92344
- argName: "<list>",
92345
- help: "Only measure these routes \u2014 comma-separated templates or prefixes (e.g. /api,/dashboard)"
92346
- },
92347
- { flag: "--cycles", value: "int", argName: "<n>", help: "Load cycles per route (default 4, minimum 3)" },
92348
- { flag: "--requests", value: "int", argName: "<n>", help: "Requests per cycle (default 5000)" },
92349
- { flag: "--connections", value: "int", argName: "<n>", help: "Concurrent connections (default 100)" },
92350
- { flag: "--idle", value: "int", argName: "<seconds>", help: "Idle seconds before each sample (default 30)" },
92351
- {
92352
- flag: "--warmup",
92353
- value: "int",
92354
- argName: "<n>",
92355
- help: "Requests before the baseline snapshot (default 200) \u2014 lower it on apps that cache per request"
92356
- },
92357
- {
92358
- flag: "--max-old-space",
92359
- value: "int",
92360
- argName: "<mb>",
92361
- help: "Heap cap of each measured process (default 512) \u2014 raise it for apps whose working set is larger"
92362
- },
92363
- {
92364
- flag: "--quick",
92365
- value: "none",
92366
- help: "Fast preset: 2000 requests x 4 cycles, 8s idle \u2014 same cycle count as the default, less traffic per cycle"
92367
- },
92368
- { flag: "--diff-all", value: "none", help: "Diff snapshots for stable routes too (slow)" },
92369
- {
92370
- flag: "--no-resolve",
92371
- value: "none",
92372
- help: "Do not re-measure inconclusive routes with more cycles (default: re-measure once)"
92373
- },
92374
- { flag: "--output", value: "string", argName: "<dir>", help: "Where to write runs (default <app-dir>/.next-leak)" },
92375
- {
92376
- flag: "--write-config",
92377
- value: "none",
92378
- help: "Write next-leak.config.json for the routes that need sample params, then exit (never overwrites)"
92379
- },
92380
- { flag: "--help", alias: "-h", value: "none", help: "Show this help" },
92381
- { flag: "--version", alias: "-v", value: "none", help: "Print the version" }
92382
- ];
92383
- function helpText(version2) {
92384
- const rows = FLAGS.map((spec) => {
92385
- const alias = spec.alias ? `${spec.alias}, ` : "";
92386
- const argName = spec.argName ? ` ${spec.argName}` : "";
92387
- const left = `${alias}${spec.flag}${argName}`;
92388
- return ` ${left.padEnd(26)}${spec.help}`;
92389
- }).join("\n");
92390
- return `next-leak ${version2}
92391
-
92392
- Find out whether your Next.js app actually leaks memory \u2014 how much, on which
92393
- route, and whose fault it is.
92394
-
92395
- Usage:
92396
- next-leak <app-dir> [options] measure a built server under load
92397
- next-leak build <app-dir> measure the memory of "next build"
92398
-
92399
- The app must be built with output: "standalone" (next build). For each
92400
- discovered route, next-leak boots a fresh instrumented process and runs:
92401
- warm-up \u2192 GC \u2192 baseline snapshot \u2192 [load \u2192 idle \u2192 GC \u2192 sample] \xD7 cycles \u2192
92402
- snapshot. Evidence (report.html, ISSUE drafts, raw snapshots, run.json) is
92403
- written under the output directory.
92404
-
92405
- The build command needs neither a previous build nor standalone output: it runs
92406
- the build itself and samples the resident memory of each static-generation
92407
- worker, which is where large sites run out of heap while prerendering. It takes
92408
- --output only; the options below shape HTTP load and do not apply to it.
92409
-
92410
- Options:
92411
- ${rows}
92412
92333
 
92413
- Exit codes: 0 when the run completes (whatever the verdicts), 130 when
92414
- interrupted, 1 on errors.
92415
- `;
92416
- }
92417
- var LIMITS = {
92418
- "--cycles": 100,
92419
- "--requests": 1e6,
92420
- "--connections": 1e4,
92421
- "--idle": 3600,
92422
- "--warmup": 1e6,
92423
- "--max-old-space": 65536
92424
- };
92425
- var MIN_MAX_OLD_SPACE_MB = 128;
92426
- function findSpec(argument) {
92427
- return FLAGS.find((spec) => spec.flag === argument || spec.alias === argument);
92428
- }
92429
- var flagError = (message) => ({ kind: "error", message });
92430
- var FLAG_OK = { kind: "ok" };
92431
- function applyRoutesFlag(value, options) {
92432
- const routes = value.split(",").map((route) => route.trim()).filter((route) => route !== "");
92433
- if (routes.length === 0) {
92434
- return flagError(`option "--routes" needs at least one route`);
92334
+ // src/module-registry.ts
92335
+ import { readdir, readFile } from "fs/promises";
92336
+ import path from "path";
92337
+ import { z } from "zod";
92338
+ var flatMapSchema = z.looseObject({
92339
+ version: z.number(),
92340
+ sources: z.array(z.string()),
92341
+ mappings: z.string()
92342
+ });
92343
+ var sectionedMapSchema = z.looseObject({
92344
+ version: z.number(),
92345
+ sections: z.array(
92346
+ z.looseObject({
92347
+ offset: z.looseObject({ line: z.number(), column: z.number() }),
92348
+ map: z.looseObject({ sources: z.array(z.string()), mappings: z.string() })
92349
+ })
92350
+ )
92351
+ });
92352
+ function normalizeSourceMap(raw) {
92353
+ const flat = flatMapSchema.safeParse(raw);
92354
+ if (flat.success) {
92355
+ return { sources: flat.data.sources, lines: decodeMappings(flat.data.mappings) };
92435
92356
  }
92436
- options.routes = routes;
92437
- return FLAG_OK;
92438
- }
92439
- function applyNumericFlag(flag, value, options) {
92440
- const parsed = Number(value);
92441
- if (!Number.isInteger(parsed) || parsed <= 0) {
92442
- return flagError(`option "${flag}" needs a positive integer, got "${value}"`);
92357
+ const sectioned = sectionedMapSchema.safeParse(raw);
92358
+ if (!sectioned.success) {
92359
+ return null;
92443
92360
  }
92444
- const maximum = LIMITS[flag];
92445
- if (maximum !== void 0 && parsed > maximum) {
92446
- return flagError(
92447
- `option "${flag}" is capped at ${maximum} (got ${parsed}) \u2014 a run that large would never finish`
92448
- );
92361
+ const sources = [];
92362
+ const lines = [];
92363
+ for (const section of sectioned.data.sections) {
92364
+ const sourceBase = sources.length;
92365
+ sources.push(...section.map.sources);
92366
+ for (const [lineIndex, mapping] of decodeMappings(section.map.mappings).entries()) {
92367
+ const targetLine = section.offset.line + lineIndex;
92368
+ const columnShift = lineIndex === 0 ? section.offset.column : 0;
92369
+ const target = lines[targetLine] ??= { entries: [] };
92370
+ for (const entry of mapping.entries) {
92371
+ target.entries.push({
92372
+ column: entry.column + columnShift,
92373
+ sourceIndex: entry.sourceIndex + sourceBase
92374
+ });
92375
+ }
92376
+ }
92449
92377
  }
92450
- if (flag === "--cycles" && parsed < 3) {
92451
- return flagError("the trend verdict needs at least 3 cycles (--cycles 3 or more)");
92378
+ for (const line of lines) {
92379
+ line?.entries.sort((a, b) => a.column - b.column);
92452
92380
  }
92453
- if (flag === "--max-old-space" && parsed < MIN_MAX_OLD_SPACE_MB) {
92454
- return flagError(
92455
- `option "--max-old-space" needs at least ${MIN_MAX_OLD_SPACE_MB} MB (got ${parsed}) \u2014 below that the measured app cannot start`
92456
- );
92381
+ for (let i = 0; i < lines.length; i += 1) {
92382
+ lines[i] ??= { entries: [] };
92457
92383
  }
92458
- if (flag === "--cycles") options.cycles = parsed;
92459
- if (flag === "--requests") options.requests = parsed;
92460
- if (flag === "--connections") options.connections = parsed;
92461
- if (flag === "--idle") options.idleSeconds = parsed;
92462
- if (flag === "--warmup") options.warmupRequests = parsed;
92463
- if (flag === "--max-old-space") options.maxOldSpaceMb = parsed;
92464
- return FLAG_OK;
92384
+ return { sources, lines };
92465
92385
  }
92466
- function applyFlag(spec, value, options) {
92467
- switch (spec.flag) {
92468
- case "--routes":
92469
- return applyRoutesFlag(value, options);
92470
- case "--cycles":
92471
- case "--requests":
92472
- case "--connections":
92473
- case "--idle":
92474
- case "--warmup":
92475
- case "--max-old-space":
92476
- return applyNumericFlag(spec.flag, value, options);
92477
- case "--quick":
92478
- options.quick = true;
92479
- return FLAG_OK;
92480
- case "--diff-all":
92481
- options.diffAll = true;
92482
- return FLAG_OK;
92483
- case "--write-config":
92484
- options.writeConfig = true;
92485
- return FLAG_OK;
92486
- case "--no-resolve":
92487
- options.noResolve = true;
92488
- return FLAG_OK;
92489
- case "--output":
92490
- options.output = value;
92491
- return FLAG_OK;
92492
- default:
92493
- return FLAG_OK;
92386
+ var ID_IN_GAP = /[,[](\d{1,9}),/g;
92387
+ function lastModuleIdIn(gap) {
92388
+ ID_IN_GAP.lastIndex = 0;
92389
+ let last = null;
92390
+ let match = ID_IN_GAP.exec(gap);
92391
+ while (match !== null) {
92392
+ last = Number(match[1]);
92393
+ match = ID_IN_GAP.exec(gap);
92494
92394
  }
92395
+ return last;
92495
92396
  }
92496
- function readFlagValue(spec, argv, index) {
92497
- if (spec.value === "none") {
92498
- return { value: "" };
92499
- }
92500
- const value = argv[index + 1] ?? "";
92501
- const looksNegativeNumber = /^-\d+(\.\d+)?$/.test(value);
92502
- if (value === "" || value.startsWith("-") && !looksNegativeNumber) {
92503
- return { error: `option "${spec.flag}" needs a value ${spec.argName ?? ""}` };
92397
+ function collectFirstArrayIds(codeLines, map, result) {
92398
+ for (const [lineIndex, lineText] of codeLines.entries()) {
92399
+ const first = /module\.exports ?= ?\[(\d+),/.exec(lineText);
92400
+ if (first?.[1] === void 0) {
92401
+ continue;
92402
+ }
92403
+ const idEnd = first.index + first[0].length;
92404
+ const nextId = /[,[]\d{1,9},/.exec(lineText.slice(idEnd));
92405
+ const spanEnd = nextId === null ? lineText.length : idEnd + nextId.index;
92406
+ const entry = map.lines[lineIndex]?.entries.find(
92407
+ (candidate) => candidate.column >= idEnd && candidate.column < spanEnd
92408
+ );
92409
+ const source = entry === void 0 ? void 0 : map.sources[entry.sourceIndex];
92410
+ if (source !== void 0) {
92411
+ result.set(Number(first[1]), source);
92412
+ }
92504
92413
  }
92505
- return { value };
92506
92414
  }
92507
- function parseFlagAt(argv, index, options) {
92508
- const argument = argv[index] ?? "";
92509
- const spec = findSpec(argument);
92510
- if (spec === void 0) {
92511
- return { done: { kind: "error", message: `unknown option "${argument}" \u2014 see --help` } };
92415
+ function collectTransitionIds(codeLines, map, result) {
92416
+ for (const [lineIndex, mapping] of map.lines.entries()) {
92417
+ const lineText = codeLines[lineIndex];
92418
+ if (lineText === void 0) {
92419
+ continue;
92420
+ }
92421
+ let previousColumn = 0;
92422
+ let previousSource = null;
92423
+ for (const entry of mapping.entries) {
92424
+ if (entry.sourceIndex !== previousSource) {
92425
+ const id = lastModuleIdIn(lineText.slice(previousColumn, entry.column));
92426
+ const source = map.sources[entry.sourceIndex];
92427
+ if (id !== null && source !== void 0 && !result.has(id)) {
92428
+ result.set(id, source);
92429
+ }
92430
+ previousSource = entry.sourceIndex;
92431
+ }
92432
+ previousColumn = entry.column;
92433
+ }
92512
92434
  }
92513
- if (spec.flag === "--help") {
92514
- return { done: { kind: "help" } };
92435
+ }
92436
+ function extractChunkModules(code, rawMap) {
92437
+ const result = /* @__PURE__ */ new Map();
92438
+ if (!code.includes("module.exports=[") && !code.includes("module.exports = [")) {
92439
+ return result;
92515
92440
  }
92516
- if (spec.flag === "--version") {
92517
- return { done: { kind: "version" } };
92441
+ const map = normalizeSourceMap(rawMap);
92442
+ if (map === null) {
92443
+ return result;
92518
92444
  }
92519
- const read2 = readFlagValue(spec, argv, index);
92520
- if ("error" in read2) {
92521
- return { done: { kind: "error", message: read2.error } };
92445
+ const codeLines = code.split("\n");
92446
+ collectFirstArrayIds(codeLines, map, result);
92447
+ collectTransitionIds(codeLines, map, result);
92448
+ return result;
92449
+ }
92450
+ async function jsFilesUnder(dir) {
92451
+ const out = [];
92452
+ let entries;
92453
+ try {
92454
+ entries = await readdir(dir, { withFileTypes: true, recursive: true });
92455
+ } catch {
92456
+ return out;
92522
92457
  }
92523
- const outcome = applyFlag(spec, read2.value, options);
92524
- if (outcome.kind === "error") {
92525
- return { done: { kind: "error", message: outcome.message } };
92458
+ for (const entry of entries) {
92459
+ if (entry.isFile() && entry.name.endsWith(".js")) {
92460
+ out.push(path.join(entry.parentPath, entry.name));
92461
+ }
92526
92462
  }
92527
- return { consumed: spec.value === "none" ? 0 : 1 };
92463
+ return out;
92528
92464
  }
92529
- function parseCliArgs(argv) {
92530
- const isBuild = argv[0] === BUILD_COMMAND;
92531
- const rest = isBuild ? argv.slice(1) : argv;
92532
- const options = {
92533
- appDir: "",
92534
- routes: null,
92535
- cycles: null,
92536
- requests: null,
92537
- connections: null,
92538
- idleSeconds: null,
92539
- warmupRequests: null,
92540
- maxOldSpaceMb: null,
92541
- quick: false,
92542
- noResolve: false,
92543
- diffAll: false,
92544
- writeConfig: false,
92545
- output: null
92546
- };
92547
- for (let index = 0; index < rest.length; index += 1) {
92548
- const argument = rest[index] ?? "";
92549
- if (!argument.startsWith("-")) {
92550
- if (options.appDir !== "") {
92551
- return { kind: "error", message: `unexpected extra argument "${argument}" \u2014 one app directory only` };
92552
- }
92553
- options.appDir = argument;
92465
+ async function extractModuleRegistry(nextServerDir) {
92466
+ const registry = /* @__PURE__ */ new Map();
92467
+ for (const file of await jsFilesUnder(nextServerDir)) {
92468
+ let code;
92469
+ let map;
92470
+ try {
92471
+ code = await readFile(file, "utf8");
92472
+ map = JSON.parse(await readFile(`${file}.map`, "utf8"));
92473
+ } catch {
92554
92474
  continue;
92555
92475
  }
92556
- const step = parseFlagAt(rest, index, options);
92557
- if ("done" in step) {
92558
- return step.done;
92476
+ for (const [id, source] of extractChunkModules(code, map)) {
92477
+ if (!registry.has(id)) {
92478
+ registry.set(id, source);
92479
+ }
92559
92480
  }
92560
- index += step.consumed;
92561
92481
  }
92562
- if (options.appDir === "") {
92563
- return { kind: "help" };
92482
+ return registry;
92483
+ }
92484
+
92485
+ // src/manifests.ts
92486
+ import { z as z2 } from "zod";
92487
+ var appPathsManifestSchema = z2.record(z2.string(), z2.string());
92488
+ var pagesManifestSchema = z2.record(z2.string(), z2.string());
92489
+ var routeEntrySchema = z2.looseObject({
92490
+ page: z2.string(),
92491
+ regex: z2.string()
92492
+ });
92493
+ var routesManifestSchema = z2.looseObject({
92494
+ version: z2.number(),
92495
+ basePath: z2.string(),
92496
+ staticRoutes: z2.array(routeEntrySchema),
92497
+ dynamicRoutes: z2.array(routeEntrySchema)
92498
+ });
92499
+ var prerenderManifestSchema = z2.looseObject({
92500
+ routes: z2.record(
92501
+ z2.string(),
92502
+ z2.looseObject({
92503
+ // `false` on a route that is prerendered but never revalidates.
92504
+ initialRevalidateSeconds: z2.union([z2.number(), z2.literal(false)]).optional(),
92505
+ srcRoute: z2.string().nullable().optional()
92506
+ })
92507
+ ).optional(),
92508
+ preview: z2.looseObject({ previewModeId: z2.string() }).optional()
92509
+ });
92510
+ var INTERNAL_PATHS = /* @__PURE__ */ new Set(["/_global-error", "/_not-found"]);
92511
+ var INTERCEPTING_SEGMENT = /^(\(\.{1,3}\))+/;
92512
+ var STATIC_ASSET_PATHS = /* @__PURE__ */ new Set(["/favicon.ico"]);
92513
+ function unaddressableReason(routePath) {
92514
+ if (routePath.split("/").some((segment) => INTERCEPTING_SEGMENT.test(segment))) {
92515
+ return "intercepting route \u2014 only reachable via client navigation";
92564
92516
  }
92565
- if (isBuild) {
92566
- const misplaced = runOnlyFlagUsed(options);
92567
- if (misplaced !== null) {
92568
- return {
92569
- kind: "error",
92570
- message: `option "${misplaced}" shapes HTTP load and does not apply to "next-leak build" \u2014 see --help`
92571
- };
92572
- }
92573
- return { kind: "build", options: { appDir: options.appDir, output: options.output } };
92517
+ if (STATIC_ASSET_PATHS.has(routePath)) {
92518
+ return "static asset served by a generated handler \u2014 no user code to leak";
92574
92519
  }
92575
- return { kind: "run", options };
92520
+ return void 0;
92576
92521
  }
92577
-
92578
- // src/guards.ts
92579
- function currentFacts() {
92580
- return {
92581
- nodeMajor: Number(process.versions.node.split(".")[0]),
92582
- platform: process.platform
92583
- };
92522
+ function toRequestPath(manifestKey) {
92523
+ let kind;
92524
+ let withoutSuffix;
92525
+ if (manifestKey.endsWith("/page")) {
92526
+ kind = "page";
92527
+ withoutSuffix = manifestKey.slice(0, -"/page".length);
92528
+ } else if (manifestKey.endsWith("/route")) {
92529
+ kind = "route-handler";
92530
+ withoutSuffix = manifestKey.slice(0, -"/route".length);
92531
+ } else {
92532
+ return null;
92533
+ }
92534
+ const segments = withoutSuffix.split("/").filter((segment) => segment !== "").filter((segment) => !(segment.startsWith("(") && segment.endsWith(")"))).filter((segment) => !segment.startsWith("@"));
92535
+ return { path: `/${segments.join("/")}`, kind };
92584
92536
  }
92585
- function checkRuntime(facts = currentFacts()) {
92586
- if (facts.platform === "win32") {
92587
- return "next-leak does not support Windows (the measurement ritual relies on POSIX process control). Run it inside WSL2 or a Linux container.";
92537
+ var PAGES_INTERNAL = /* @__PURE__ */ new Set(["/_app", "/_document", "/_error", "/404", "/500"]);
92538
+ function discoverPagesRoutes(pages) {
92539
+ const routes = [];
92540
+ for (const key of Object.keys(pages)) {
92541
+ if (PAGES_INTERNAL.has(key)) {
92542
+ continue;
92543
+ }
92544
+ routes.push({
92545
+ path: key,
92546
+ kind: key === "/api" || key.startsWith("/api/") ? "route-handler" : "page",
92547
+ dynamic: key.includes("[")
92548
+ });
92588
92549
  }
92589
- if (facts.nodeMajor < 22) {
92590
- return `next-leak needs Node.js >= 22 (you are on ${process.versions.node}). It relies on --expose-gc semantics and heap snapshot behavior verified on modern V8. Upgrade Node and retry.`;
92550
+ return routes.sort((a, b) => a.path.localeCompare(b.path));
92551
+ }
92552
+ function discoverRoutes(appPaths) {
92553
+ const routes = /* @__PURE__ */ new Map();
92554
+ for (const key of Object.keys(appPaths)) {
92555
+ const parsed = toRequestPath(key);
92556
+ if (parsed === null || INTERNAL_PATHS.has(parsed.path) || routes.has(parsed.path)) {
92557
+ continue;
92558
+ }
92559
+ const reason = unaddressableReason(parsed.path);
92560
+ routes.set(parsed.path, {
92561
+ path: parsed.path,
92562
+ kind: parsed.kind,
92563
+ dynamic: parsed.path.includes("["),
92564
+ ...reason !== void 0 && { unaddressableReason: reason }
92565
+ });
92591
92566
  }
92592
- return null;
92567
+ return [...routes.values()].sort((a, b) => a.path.localeCompare(b.path));
92593
92568
  }
92594
92569
 
92595
- // src/route-config.ts
92596
- import { readFile as readFile3 } from "fs/promises";
92597
- import path3 from "path";
92598
- import { z as z3 } from "zod";
92599
- var sampleValueSchema = z3.string().refine(
92600
- (value) => !(value.includes("{n}") && /\{n%\d+\}/.test(value)),
92601
- { message: 'a sample value cannot carry both "{n}" and "{n%N}" \u2014 pick one' }
92602
- );
92603
- var routeConfigSchema = z3.object({
92604
- params: z3.record(z3.string(), sampleValueSchema).optional(),
92605
- routes: z3.record(z3.string(), z3.record(z3.string(), sampleValueSchema)).optional(),
92606
- /**
92607
- * Headers sent with every request. Real traffic is not header-less:
92608
- * compression (`Accept-Encoding`), sessions and auth change which code
92609
- * paths run, and some leaks only exist on those paths (zlib, per-session
92610
- * caches). Measuring without them measures a different app.
92611
- */
92612
- headers: z3.record(z3.string(), z3.string()).optional(),
92613
- /** Query string appended per route template, e.g. `"weightKb=2048"`. */
92614
- query: z3.record(z3.string(), z3.string()).optional(),
92615
- /**
92616
- * Simulates clients that give up before the response arrives (closed
92617
- * tabs, load-balancer timeouts, bots). Several real leaks only exist on
92618
- * that path — vercel/next.js#89091 traces `ServerResponse` retention to
92619
- * an early disconnect — and a load generator that always waits politely
92620
- * never reaches it.
92621
- *
92622
- * Counted **from the first byte of the response**, not from the request:
92623
- * under load a server's first byte arrives long after any sane fixed
92624
- * window, so a request-relative clock cuts before the stream starts and
92625
- * tests the wrong path entirely.
92626
- */
92627
- abandonAfterMs: z3.number().int().positive().optional(),
92628
- /**
92629
- * Where the abandon deadline starts. `first-byte` is the default and the
92630
- * semantics everything else assumes.
92631
- *
92632
- * `request` starts it when the request is written, which is the only way
92633
- * to reach leaks that need the client already gone before the server
92634
- * produces anything: against a route whose time-to-first-byte exceeds the
92635
- * deadline, a first-byte clock never cuts early. Measured on the
92636
- * vercel/next.js#84648 repro, whose upstream answers in 400 ms while its
92637
- * own load generator cuts at 60 ms — the first-byte clock reached 7% of
92638
- * requests there, none of them on the path the issue describes.
92639
- *
92640
- * Opt-in per route, never a default: armed on connect, the deadline loses
92641
- * the race to the first byte on fast routes, which is how the earlier
92642
- * version of this measured 2 mid-stream cuts out of ~1500 on #94919.
92643
- */
92644
- abandonFrom: z3.enum(["first-byte", "request"]).optional()
92645
- }).strict().refine((config) => config.abandonFrom === void 0 || config.abandonAfterMs !== void 0, {
92646
- message: '"abandonFrom" configures nothing without "abandonAfterMs"',
92647
- path: ["abandonFrom"]
92648
- });
92649
- var RouteConfigError = class extends Error {
92650
- constructor(message) {
92570
+ // src/target.ts
92571
+ import { access, readFile as readFile2 } from "fs/promises";
92572
+ import path2 from "path";
92573
+ var TargetError = class extends Error {
92574
+ code;
92575
+ constructor(code, message) {
92651
92576
  super(message);
92652
- this.name = "RouteConfigError";
92577
+ this.name = "TargetError";
92578
+ this.code = code;
92653
92579
  }
92654
92580
  };
92655
- var ROUTE_CONFIG_FILE = "next-leak.config.json";
92656
- async function loadRouteConfig(appDir) {
92657
- const file = path3.join(appDir, ROUTE_CONFIG_FILE);
92658
- let raw;
92581
+ async function exists(file) {
92659
92582
  try {
92660
- raw = await readFile3(file, "utf8");
92583
+ await access(file);
92584
+ return true;
92661
92585
  } catch {
92662
- return {};
92586
+ return false;
92663
92587
  }
92588
+ }
92589
+ async function readManifest(file, parse2) {
92590
+ let raw;
92664
92591
  try {
92665
- return routeConfigSchema.parse(JSON.parse(raw));
92592
+ raw = JSON.parse(await readFile2(file, "utf8"));
92666
92593
  } catch (cause) {
92667
- throw new RouteConfigError(
92668
- `${file} is invalid: ${cause instanceof Error ? cause.message : String(cause)}`
92594
+ throw new TargetError(
92595
+ "BAD_MANIFEST",
92596
+ `Could not read ${file}: ${cause instanceof Error ? cause.message : String(cause)}`
92669
92597
  );
92670
92598
  }
92671
- }
92672
- var SEGMENT_PATTERN = /^\[(\[)?(\.\.\.)?([^\]]+?)\]?\]$/;
92673
- function dynamicSegmentsOf(routeTemplate) {
92674
- const segments = [];
92675
- for (const segment of routeTemplate.split("/")) {
92676
- const match = SEGMENT_PATTERN.exec(segment);
92677
- if (match === null) {
92678
- continue;
92679
- }
92680
- segments.push({
92681
- name: match[3] ?? "",
92682
- catchAll: match[2] !== void 0,
92683
- optional: match[1] !== void 0
92684
- });
92685
- }
92686
- return segments;
92687
- }
92688
- var UNIQUE_MARKER = "{n}";
92689
- var BOUNDED_MARKER = /\{n%(\d+)\}/;
92690
- function boundedMarkerOf(value) {
92691
- const match = BOUNDED_MARKER.exec(value);
92692
- const bound = Number(match?.[1]);
92693
- if (match === null || !Number.isInteger(bound) || bound <= 0) {
92694
- return null;
92599
+ try {
92600
+ return parse2(raw);
92601
+ } catch {
92602
+ throw new TargetError(
92603
+ "BAD_MANIFEST",
92604
+ `${file} does not match the expected Next.js manifest shape. This Next.js version may not be supported yet.`
92605
+ );
92695
92606
  }
92696
- return { marker: match[0], bound };
92697
92607
  }
92698
- var ENCODED_MARKER = encodeURIComponent(UNIQUE_MARKER);
92699
- var encodeSegment = (value) => restoreMarkers(encodeURIComponent(value).split(ENCODED_MARKER).join(UNIQUE_MARKER));
92700
- var ENCODED_BOUNDED = /%7Bn%25(\d+)%7D/gi;
92701
- var restoreMarkers = (value) => value.replace(ENCODED_BOUNDED, (_match, bound) => `{n%${bound}}`);
92702
- var encodeCatchAll = (value) => value.split("/").map(encodeSegment).join("/");
92703
- function resolveRoutePath(routeTemplate, config) {
92704
- const resolved = [];
92705
- for (const segment of routeTemplate.split("/")) {
92706
- const match = SEGMENT_PATTERN.exec(segment);
92707
- if (match === null) {
92708
- resolved.push(encodeSegment(segment));
92709
- continue;
92710
- }
92711
- const optional = match[1] !== void 0;
92712
- const catchAll = match[2] !== void 0;
92713
- const name = match[3] ?? "";
92714
- const value = config.routes?.[routeTemplate]?.[name] ?? config.params?.[name];
92715
- if (value !== void 0) {
92716
- resolved.push(catchAll ? encodeCatchAll(value) : encodeSegment(value));
92717
- } else if (!optional) {
92718
- return null;
92719
- }
92608
+ async function validateTarget(appDir) {
92609
+ const nextDir = path2.resolve(appDir, ".next");
92610
+ if (!await exists(nextDir)) {
92611
+ throw new TargetError(
92612
+ "NO_BUILD",
92613
+ `No .next directory in ${appDir}. Run "next build" first.`
92614
+ );
92720
92615
  }
92721
- const joined = resolved.join("/");
92722
- const path7 = joined === "" ? "/" : joined;
92723
- const query = config.query?.[routeTemplate];
92724
- return query === void 0 || query === "" ? path7 : `${path7}?${query}`;
92725
- }
92616
+ const standaloneServer = path2.join(nextDir, "standalone", "server.js");
92617
+ if (!await exists(standaloneServer)) {
92618
+ throw new TargetError(
92619
+ "NO_STANDALONE",
92620
+ `No ${standaloneServer}.
92621
+ next-leak measures the standalone server bundle. Enable it once in next.config:
92726
92622
 
92727
- // src/route-guidance.ts
92728
- var PLACEHOLDER = "REPLACE-ME";
92729
- function sampleValuesFromManifest(manifest, routeTemplate) {
92730
- const routes = manifest?.routes;
92731
- if (routes === void 0) {
92732
- return null;
92733
- }
92734
- const concrete = Object.entries(routes).find(([, entry]) => entry.srcRoute === routeTemplate)?.[0];
92735
- if (concrete === void 0) {
92736
- return null;
92623
+ const nextConfig = {
92624
+ output: "standalone",
92625
+ // ...your existing config
92626
+ };
92627
+
92628
+ then rebuild: next build
92629
+ This only changes how the build is packaged \u2014 not how your app behaves.`
92630
+ );
92737
92631
  }
92738
- const templateSegments = routeTemplate.split("/");
92739
- const concreteSegments = concrete.split("/");
92740
- const values = {};
92741
- for (const [index, segment] of templateSegments.entries()) {
92742
- const dynamic = dynamicSegmentsOf(segment);
92743
- const parameter = dynamic[0];
92744
- if (parameter === void 0) {
92745
- continue;
92746
- }
92747
- const value = parameter.catchAll ? concreteSegments.slice(index).join("/") : concreteSegments[index];
92748
- if (value === void 0 || value === "") {
92749
- return null;
92750
- }
92751
- values[parameter.name] = value;
92632
+ const appPathsFile = path2.join(nextDir, "server", "app-paths-manifest.json");
92633
+ const pagesFile = path2.join(nextDir, "server", "pages-manifest.json");
92634
+ const appPaths = await exists(appPathsFile) ? await readManifest(appPathsFile, (raw) => appPathsManifestSchema.parse(raw)) : {};
92635
+ const pages = await exists(pagesFile) ? await readManifest(pagesFile, (raw) => pagesManifestSchema.parse(raw)) : {};
92636
+ if (Object.keys(appPaths).length === 0 && Object.keys(pages).length === 0) {
92637
+ throw new TargetError(
92638
+ "BAD_MANIFEST",
92639
+ `No routes found in ${nextDir}/server: neither app-paths-manifest.json nor pages-manifest.json is present. Rebuild the app with "next build".`
92640
+ );
92752
92641
  }
92753
- return Object.keys(values).length === 0 ? null : values;
92642
+ const routesFile = path2.join(nextDir, "routes-manifest.json");
92643
+ const routes = await exists(routesFile) ? await readManifest(routesFile, (raw) => routesManifestSchema.parse(raw)) : void 0;
92644
+ const prerenderFile = path2.join(nextDir, "prerender-manifest.json");
92645
+ const prerender = await exists(prerenderFile) ? await readManifest(prerenderFile, (raw) => prerenderManifestSchema.parse(raw)) : void 0;
92646
+ return {
92647
+ appDir: path2.resolve(appDir),
92648
+ standaloneServer,
92649
+ appPaths,
92650
+ pages,
92651
+ routes,
92652
+ prerender
92653
+ };
92754
92654
  }
92755
- function renderConfigSkeleton(routeTemplates, manifest = void 0) {
92756
- const routes = {};
92757
- for (const template of routeTemplates) {
92758
- const segments = dynamicSegmentsOf(template);
92759
- if (segments.length === 0) {
92760
- continue;
92761
- }
92762
- const sampled = sampleValuesFromManifest(manifest, template);
92763
- const values = {};
92764
- for (const segment of segments) {
92765
- values[segment.name] = sampled?.[segment.name] ?? PLACEHOLDER;
92766
- }
92767
- routes[template] = values;
92655
+
92656
+ // src/launcher.ts
92657
+ import { spawn } from "child_process";
92658
+ import { readdir as readdir2, readFile as readFile3 } from "fs/promises";
92659
+ import path3 from "path";
92660
+ import { pathToFileURL } from "url";
92661
+ import { z as z3 } from "zod";
92662
+ var controlFileSchema = z3.object({ port: z3.number(), pid: z3.number() });
92663
+ var DEFAULT_MAX_OLD_SPACE_MB = 512;
92664
+ var LaunchError = class extends Error {
92665
+ constructor(message) {
92666
+ super(message);
92667
+ this.name = "LaunchError";
92668
+ }
92669
+ };
92670
+ var activeChildren = /* @__PURE__ */ new Set();
92671
+ var STDERR_WINDOW = 4096;
92672
+ function killActiveChildren() {
92673
+ for (const child of activeChildren) {
92674
+ child.kill("SIGKILL");
92768
92675
  }
92769
- return Object.keys(routes).length === 0 ? null : JSON.stringify({ routes }, null, 2);
92770
92676
  }
92771
- function hasPlaceholders(skeleton) {
92772
- return skeleton.includes(PLACEHOLDER);
92677
+ function registerChild(child) {
92678
+ activeChildren.add(child);
92679
+ child.once("exit", () => activeChildren.delete(child));
92773
92680
  }
92774
-
92775
- // src/unreclaimed-retention.ts
92776
- var MB = 1024 * 1024;
92777
- var CLASSES = ["heap", "external", "arrayBuffers"];
92778
- var MIN_GAP_BYTES = 2 * MB;
92779
- var MIN_GAP_RATIO = 0.35;
92780
- var MIN_RETAINED_BYTES = MB;
92781
- var read = (sample, memoryClass) => memoryClass === "heap" ? sample.heapUsed : memoryClass === "external" ? sample.external : sample.arrayBuffers;
92782
- function largestGap(values) {
92783
- return values.reduce((highest, value) => Math.max(highest, value), 0);
92681
+ function unregisterChild(child) {
92682
+ activeChildren.delete(child);
92784
92683
  }
92785
- function gapsFor(input, memoryClass) {
92786
- const gaps = [];
92787
- for (const [index, held] of input.unreclaimedSamples.entries()) {
92788
- const afterGc = input.memorySamples[index + 1];
92789
- if (afterGc === void 0) {
92790
- break;
92791
- }
92792
- gaps.push(read(held, memoryClass) - read(afterGc, memoryClass));
92684
+ function explainStartupFailure(stderr) {
92685
+ const missingModule = /Cannot find module '([^']+)'/.exec(stderr);
92686
+ if (missingModule !== null) {
92687
+ return `the standalone build is missing a dependency (${missingModule[1]}). This is a build problem, not a measurement one: \`node .next/standalone/server.js\` fails the same way on its own. Rebuild, or copy the missing package into .next/standalone/node_modules.`;
92793
92688
  }
92794
- return gaps;
92689
+ if (/EADDRINUSE/.test(stderr)) {
92690
+ return "the port was taken by another process while starting.";
92691
+ }
92692
+ return `stderr:
92693
+ ${stderr}`;
92795
92694
  }
92796
- function assessUnreclaimedRetention(input) {
92797
- if (input.verdict === "leak" && input.verdictIsWellSupported || input.unreclaimedSamples.length === 0) {
92798
- return null;
92695
+ function explainRuntimeFailure(stderr, maxOldSpaceMb) {
92696
+ if (/heap out of memory|Reached heap limit|Ineffective mark-compacts/i.test(stderr)) {
92697
+ return `the measured process ran out of heap and was killed by V8 mid-run (limit in force: --max-old-space-size=${maxOldSpaceMb} MB). That is the measurement: this route does not fit in ${maxOldSpaceMb} MB under this load. Raise it with --max-old-space <mb> to match your deployment, or lower --requests/--connections to measure a lighter regime.`;
92799
92698
  }
92800
- let worst = null;
92801
- for (const memoryClass of CLASSES) {
92802
- const gaps = gapsFor(input, memoryClass);
92803
- if (gaps.length === 0) {
92804
- continue;
92805
- }
92806
- const largestGapBytes = largestGap(gaps);
92807
- if (largestGapBytes < MIN_GAP_BYTES) {
92808
- continue;
92699
+ return `the measured process exited mid-run. ${explainStartupFailure(stderr)}`;
92700
+ }
92701
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
92702
+ async function pollUntil(deadline, what, probe, failed) {
92703
+ for (; ; ) {
92704
+ const failure = failed();
92705
+ if (failure !== void 0) {
92706
+ throw new LaunchError(failure);
92809
92707
  }
92810
- const retainedBytes = largestGap(
92811
- input.memorySamples.slice(1).map((sample) => read(sample, memoryClass))
92812
- );
92813
- const ratio = largestGapBytes / Math.max(retainedBytes, MIN_RETAINED_BYTES);
92814
- if (ratio < MIN_GAP_RATIO) {
92815
- continue;
92708
+ const result = await probe();
92709
+ if (result !== void 0) {
92710
+ return result;
92816
92711
  }
92817
- if (worst === null || ratio >= worst.ratio) {
92818
- worst = { class: memoryClass, largestGapBytes, retainedBytes, ratio };
92712
+ if (Date.now() > deadline) {
92713
+ throw new LaunchError(`timed out waiting for ${what}`);
92819
92714
  }
92715
+ await sleep(100);
92820
92716
  }
92821
- return worst;
92822
- }
92823
- var mb = (bytes) => `${(bytes / MB).toFixed(2)} MB`;
92824
- var CLASS_LABEL = {
92825
- heap: "heap",
92826
- external: "external memory",
92827
- arrayBuffers: "arrayBuffers"
92828
- };
92829
- function describeUnreclaimedRetention(retention) {
92830
- return `held ${mb(retention.largestGapBytes)} of ${CLASS_LABEL[retention.class]} between collections that a GC took back (${retention.ratio.toFixed(1)}x what it retains) \u2014 a forced GC reclaims this, a long-running process may not run one often enough to; read seconds after load, so it also includes memory not yet collected`;
92831
- }
92832
-
92833
- // src/report.ts
92834
- var MB2 = 1024 * 1024;
92835
- var formatMb = (bytes) => `${(bytes / MB2).toFixed(1)} MB`;
92836
- var formatGrowth = (bytes) => {
92837
- const sign = bytes >= 0 ? "+" : "";
92838
- return `${sign}${(bytes / MB2).toFixed(2)} MB/1000 req`;
92839
- };
92840
- var VERDICT_ICON = { leak: "\u2716", stable: "\u2714", inconclusive: "?" };
92841
- var RSS_MIN_GROWTH_PER_CYCLE = 16 * MB2;
92842
- var RSS_MIN_TOTAL_GROWTH = 64 * MB2;
92843
- function hasSustainedRssGrowth(memorySamples) {
92844
- const rss = memorySamples.map((sample) => sample.rss);
92845
- const first = rss[1];
92846
- const last = rss.at(-1);
92847
- if (first === void 0 || last === void 0 || rss.length < 4) {
92848
- return false;
92849
- }
92850
- return classifyTrend(rss, { minGrowthPerCycle: RSS_MIN_GROWTH_PER_CYCLE }).verdict === "leak" && last - first >= RSS_MIN_TOTAL_GROWTH;
92851
- }
92852
- function ownerLabel(attribution) {
92853
- switch (attribution.owner) {
92854
- case "app":
92855
- return `${attribution.source ?? "your code"} (your code)`;
92856
- case "dependency":
92857
- return `${attribution.packageName ?? "a dependency"} (dependency)`;
92858
- case "framework":
92859
- return attribution.packageName === null ? "Next.js/runtime internals" : `${attribution.packageName} (framework)`;
92860
- case "unattributed":
92861
- return null;
92862
- }
92863
- }
92864
- function revalidationLines(route) {
92865
- return route.revalidatedEverySeconds === void 0 ? [] : [
92866
- ` driven through ISR revalidation (revalidates every ${route.revalidatedEverySeconds}s; without it the load would serve the cache)`
92867
- ];
92868
- }
92869
- function abandonLines(route) {
92870
- if (route.abandon === void 0) {
92871
- return [];
92872
- }
92873
- return [
92874
- route.abandon.from === "request" ? ` cut ${route.abandon.afterMs}ms after the request was sent, so the client is gone before the response starts` : ` cut ${route.abandon.afterMs}ms after the first byte, so the cut lands mid-stream`
92875
- ];
92876
- }
92877
- function confidenceLines(route) {
92878
- const lines = [];
92879
- if (route.confidence.supersededVerdict !== void 0) {
92880
- lines.push(
92881
- ` measured ${route.trend.verdict}, withdrawn: the run did not observe what that verdict needs`
92882
- );
92883
- }
92884
- for (const warning of route.confidence.warnings) {
92885
- lines.push(` \u26A0 low confidence: ${warning.detail}`);
92886
- }
92887
- return lines;
92888
- }
92889
- function memorySourceLines(route, verdict) {
92890
- const lines = [];
92891
- if (route.trend.source === "external" && verdict !== "stable") {
92892
- const externalCurve = route.memorySamples.map((sample) => formatMb(sample.external)).join(" \u2192 ");
92893
- lines.push(
92894
- ` verdict comes from EXTERNAL memory (buffers, streams, fetch bodies), not the JS heap: external ${externalCurve}`
92895
- );
92896
- }
92897
- if (verdict === "stable" && hasSustainedRssGrowth(route.memorySamples)) {
92898
- const rssCurve = route.memorySamples.map((sample) => formatMb(sample.rss)).join(" \u2192 ");
92899
- lines.push(
92900
- ` note: heap is flat but RSS grows ${formatGrowth(route.rssPer1000Requests)} \u2014 not a JS-heap leak (allocator, external buffers or fragmentation): RSS ${rssCurve}`
92901
- );
92902
- }
92903
- return lines;
92904
- }
92905
- function diffFindingLines(route) {
92906
- if (route.diff === null) {
92907
- return [];
92908
- }
92909
- const findings = [...route.diff.grownNodes, ...route.diff.newNodes];
92910
- return findings.slice(0, 3).map((finding, index) => {
92911
- const size = formatMb(finding.retainedBytes);
92912
- const attribution = route.attribution?.findings[index];
92913
- const owner = attribution === void 0 ? null : ownerLabel(attribution);
92914
- const detail = owner ?? (finding.retainerChain === "" ? "" : finding.retainerChain);
92915
- return ` \u21B3 ${finding.kind} [${finding.nodeType}] ${finding.name} ${size}${detail === "" ? "" : ` \u2014 ${detail}`}`;
92916
- });
92917
- }
92918
- function findingLines(route) {
92919
- const lines = [];
92920
- const culprit = route.attribution === null ? null : ownerLabel(route.attribution.route);
92921
- if (culprit !== null) {
92922
- lines.push(` culprit: ${culprit}`);
92923
- }
92924
- lines.push(...diffFindingLines(route));
92925
- for (const signature of route.signatures) {
92926
- const flag = signature.historical ? " (historical)" : "";
92927
- lines.push(` \u26A0 known cause${flag}: ${signature.title} \u2014 ${signature.issue}`);
92928
- }
92929
- return lines;
92930
92717
  }
92931
- function peakPressureLines(route, parameters) {
92932
- const retained = route.memorySamples.at(-1)?.heapUsed;
92933
- if (retained === void 0) {
92934
- return [];
92935
- }
92936
- const pressure = assessPeakPressure({
92937
- peaks: route.peaks,
92938
- retainedHeapBytes: retained,
92939
- maxOldSpaceMb: parameters.maxOldSpaceMb
92718
+ async function launchInstrumented(options) {
92719
+ const hostname = options.hostname ?? "127.0.0.1";
92720
+ const deadline = Date.now() + (options.readyTimeoutMs ?? 15e3);
92721
+ const child = spawn(
92722
+ process.execPath,
92723
+ [
92724
+ "--expose-gc",
92725
+ `--max-old-space-size=${options.maxOldSpaceMb ?? DEFAULT_MAX_OLD_SPACE_MB}`,
92726
+ "--import",
92727
+ pathToFileURL(options.bootstrapPath).href,
92728
+ options.serverPath
92729
+ ],
92730
+ {
92731
+ cwd: path3.dirname(options.serverPath),
92732
+ env: {
92733
+ ...process.env,
92734
+ ...options.env,
92735
+ NODE_ENV: "production",
92736
+ PORT: String(options.appPort),
92737
+ HOSTNAME: hostname,
92738
+ NEXT_LEAK_DIR: options.workDir
92739
+ },
92740
+ stdio: ["ignore", "ignore", "pipe"]
92741
+ }
92742
+ );
92743
+ let stderrHead = "";
92744
+ let stderrTailBuffer = "";
92745
+ child.stderr?.on("data", (chunk) => {
92746
+ const text = chunk.toString();
92747
+ if (stderrHead.length < STDERR_WINDOW) {
92748
+ stderrHead = (stderrHead + text).slice(0, STDERR_WINDOW);
92749
+ }
92750
+ stderrTailBuffer = (stderrTailBuffer + text).slice(-STDERR_WINDOW);
92940
92751
  });
92941
- return pressure === null ? [] : [` \u25B2 peak pressure: ${describePeakPressure(pressure)}`];
92942
- }
92943
- function unreclaimedLines(route, verdict) {
92944
- const retention = assessUnreclaimedRetention({
92945
- unreclaimedSamples: route.unreclaimedSamples,
92946
- memorySamples: route.memorySamples,
92947
- verdict,
92948
- verdictIsWellSupported: warrantsIssueDraft(route)
92752
+ const stderrWindow = () => stderrHead === stderrTailBuffer || stderrTailBuffer === "" ? stderrHead : `${stderrHead}
92753
+ [...]
92754
+ ${stderrTailBuffer}`;
92755
+ let exited = false;
92756
+ activeChildren.add(child);
92757
+ child.once("exit", () => {
92758
+ exited = true;
92759
+ activeChildren.delete(child);
92949
92760
  });
92950
- return retention === null ? [] : [` \u25B2 unreclaimed: ${describeUnreclaimedRetention(retention)}`];
92951
- }
92952
- function routeLines(route, parameters) {
92953
- if (route.status === "skipped") {
92954
- return [` \u2013 ${route.route} skipped: ${route.reason}`];
92955
- }
92956
- if (route.status === "failed") {
92957
- return [` \u2716 ${route.route} failed: ${route.reason}`];
92958
- }
92959
- if (route.status === "not-exercised") {
92960
- return [` \u2013 ${route.route} not exercised: ${route.reason}`];
92761
+ const failed = () => exited ? `server exited before becoming ready. ${explainStartupFailure(stderrWindow())}` : void 0;
92762
+ try {
92763
+ const controlPort = await pollUntil(
92764
+ deadline,
92765
+ `control channel in ${options.workDir}`,
92766
+ // Several processes may announce a channel (clustered servers); accept
92767
+ // the first one that actually answers instead of trusting a filename.
92768
+ async () => {
92769
+ let entries;
92770
+ try {
92771
+ entries = (await readdir2(options.workDir)).filter(
92772
+ (entry) => entry.startsWith("control-") && entry.endsWith(".json")
92773
+ );
92774
+ } catch {
92775
+ return void 0;
92776
+ }
92777
+ for (const entry of entries) {
92778
+ try {
92779
+ const parsed = controlFileSchema.parse(
92780
+ JSON.parse(await readFile3(path3.join(options.workDir, entry), "utf8"))
92781
+ );
92782
+ const response = await fetch(`http://127.0.0.1:${parsed.port}/gc`);
92783
+ if (response.ok) {
92784
+ return parsed.port;
92785
+ }
92786
+ } catch {
92787
+ continue;
92788
+ }
92789
+ }
92790
+ return void 0;
92791
+ },
92792
+ failed
92793
+ );
92794
+ await pollUntil(
92795
+ deadline,
92796
+ `app on ${hostname}:${options.appPort}`,
92797
+ async () => {
92798
+ try {
92799
+ await fetch(`http://${hostname}:${options.appPort}/`, { method: "HEAD" });
92800
+ return true;
92801
+ } catch {
92802
+ return void 0;
92803
+ }
92804
+ },
92805
+ failed
92806
+ );
92807
+ return {
92808
+ pid: child.pid ?? -1,
92809
+ appPort: options.appPort,
92810
+ controlPort,
92811
+ explainExit: () => exited ? explainRuntimeFailure(stderrWindow(), options.maxOldSpaceMb ?? DEFAULT_MAX_OLD_SPACE_MB) : null,
92812
+ close: async () => {
92813
+ if (exited) {
92814
+ return;
92815
+ }
92816
+ const gone = new Promise((resolve) => child.once("exit", () => resolve()));
92817
+ child.kill("SIGTERM");
92818
+ const killTimer = setTimeout(() => child.kill("SIGKILL"), 3e3);
92819
+ await gone;
92820
+ clearTimeout(killTimer);
92821
+ }
92822
+ };
92823
+ } catch (cause) {
92824
+ child.kill("SIGKILL");
92825
+ throw cause;
92961
92826
  }
92962
- const verdict = effectiveVerdict(route);
92963
- const curve = route.samples.map(formatMb).join(" \u2192 ");
92964
- const resolved = route.resolvedWithCycles === void 0 ? "" : ` (resolved at ${route.resolvedWithCycles} cycles)`;
92965
- return [
92966
- ` ${VERDICT_ICON[verdict]} ${route.route} ${verdict} (${formatGrowth(
92967
- route.growthPer1000Requests
92968
- )}) heap ${curve}${resolved}`,
92969
- ...revalidationLines(route),
92970
- ...abandonLines(route),
92971
- ...confidenceLines(route),
92972
- ...memorySourceLines(route, verdict),
92973
- ...peakPressureLines(route, parameters),
92974
- ...unreclaimedLines(route, verdict),
92975
- ...findingLines(route)
92976
- ];
92977
92827
  }
92978
- function skippedGuidanceLines(report) {
92979
- const needConfig = report.routes.filter((route) => route.status === "skipped" && route.reason.includes("sample params")).map((route) => route.route);
92980
- const skeleton = renderConfigSkeleton(needConfig, report.prerender);
92981
- if (skeleton === null) {
92982
- return [];
92828
+
92829
+ // src/cli-args.ts
92830
+ var BUILD_COMMAND = "build";
92831
+ var RUN_ONLY_FLAGS = [
92832
+ ["routes", "--routes"],
92833
+ ["cycles", "--cycles"],
92834
+ ["requests", "--requests"],
92835
+ ["connections", "--connections"],
92836
+ ["idleSeconds", "--idle"],
92837
+ ["warmupRequests", "--warmup"],
92838
+ ["maxOldSpaceMb", "--max-old-space"],
92839
+ ["quick", "--quick"],
92840
+ ["noResolve", "--no-resolve"],
92841
+ ["diffAll", "--diff-all"],
92842
+ ["writeConfig", "--write-config"]
92843
+ ];
92844
+ function runOnlyFlagUsed(options) {
92845
+ for (const [field, flag] of RUN_ONLY_FLAGS) {
92846
+ const value = options[field];
92847
+ if (value !== null && value !== false) {
92848
+ return flag;
92849
+ }
92983
92850
  }
92984
- const editing = hasPlaceholders(skeleton) ? ` Replace each ${"REPLACE-ME"} with a value that exists in your app.` : ` The values come from paths your build already prerendered.`;
92985
- return [
92986
- "",
92987
- `${needConfig.length} route(s) need sample params. Write this to next-leak.config.json in the app directory:${editing}`,
92988
- ...skeleton.split("\n").map((line) => ` ${line}`)
92989
- ];
92851
+ return null;
92990
92852
  }
92991
- function coverageLine(report) {
92992
- const total = report.routes.length;
92993
- const measured = report.routes.filter((route) => route.status === "measured").length;
92994
- if (measured === total) {
92995
- return `measured all ${total} discovered route(s)`;
92996
- }
92997
- const breakdown = ["skipped", "not-exercised", "failed"].map((status) => ({
92998
- status,
92999
- count: report.routes.filter((route) => route.status === status).length
93000
- })).filter((entry) => entry.count > 0).map((entry) => `${entry.count} ${entry.status.replace("-", " ")}`).join(", ");
93001
- return `measured ${measured} of ${total} discovered route(s) \u2014 ${breakdown}; the rest is not a verdict about your app`;
92853
+ var FLAGS = [
92854
+ {
92855
+ flag: "--routes",
92856
+ value: "list",
92857
+ argName: "<list>",
92858
+ help: "Only measure these routes \u2014 comma-separated templates or prefixes (e.g. /api,/dashboard)"
92859
+ },
92860
+ { flag: "--cycles", value: "int", argName: "<n>", help: "Load cycles per route (default 4, minimum 3)" },
92861
+ { flag: "--requests", value: "int", argName: "<n>", help: "Requests per cycle (default 5000)" },
92862
+ { flag: "--connections", value: "int", argName: "<n>", help: "Concurrent connections (default 100)" },
92863
+ { flag: "--idle", value: "int", argName: "<seconds>", help: "Idle seconds before each sample (default 30)" },
92864
+ {
92865
+ flag: "--warmup",
92866
+ value: "int",
92867
+ argName: "<n>",
92868
+ help: "Requests before the baseline snapshot (default 200) \u2014 lower it on apps that cache per request"
92869
+ },
92870
+ {
92871
+ flag: "--max-old-space",
92872
+ value: "int",
92873
+ argName: "<mb>",
92874
+ help: "Heap cap of each measured process (default 512) \u2014 raise it for apps whose working set is larger"
92875
+ },
92876
+ {
92877
+ flag: "--quick",
92878
+ value: "none",
92879
+ help: "Fast preset: 2000 requests x 4 cycles, 8s idle \u2014 same cycle count as the default, less traffic per cycle"
92880
+ },
92881
+ { flag: "--diff-all", value: "none", help: "Diff snapshots for stable routes too (slow)" },
92882
+ {
92883
+ flag: "--attribute",
92884
+ value: "none",
92885
+ help: "build only: also name what the worker retains \u2014 signals it for heap snapshots, which can stall the build"
92886
+ },
92887
+ {
92888
+ flag: "--no-resolve",
92889
+ value: "none",
92890
+ help: "Do not re-measure inconclusive routes with more cycles (default: re-measure once)"
92891
+ },
92892
+ { flag: "--output", value: "string", argName: "<dir>", help: "Where to write runs (default <app-dir>/.next-leak)" },
92893
+ {
92894
+ flag: "--write-config",
92895
+ value: "none",
92896
+ help: "Write next-leak.config.json for the routes that need sample params, then exit (never overwrites)"
92897
+ },
92898
+ { flag: "--help", alias: "-h", value: "none", help: "Show this help" },
92899
+ { flag: "--version", alias: "-v", value: "none", help: "Print the version" }
92900
+ ];
92901
+ function helpText(version2) {
92902
+ const rows = FLAGS.map((spec) => {
92903
+ const alias = spec.alias ? `${spec.alias}, ` : "";
92904
+ const argName = spec.argName ? ` ${spec.argName}` : "";
92905
+ const left = `${alias}${spec.flag}${argName}`;
92906
+ return ` ${left.padEnd(26)}${spec.help}`;
92907
+ }).join("\n");
92908
+ return `next-leak ${version2}
92909
+
92910
+ Find out whether your Next.js app actually leaks memory \u2014 how much, on which
92911
+ route, and whose fault it is.
92912
+
92913
+ Usage:
92914
+ next-leak <app-dir> [options] measure a built server under load
92915
+ next-leak build <app-dir> measure the memory of "next build"
92916
+
92917
+ The app must be built with output: "standalone" (next build). For each
92918
+ discovered route, next-leak boots a fresh instrumented process and runs:
92919
+ warm-up \u2192 GC \u2192 baseline snapshot \u2192 [load \u2192 idle \u2192 GC \u2192 sample] \xD7 cycles \u2192
92920
+ snapshot. Evidence (report.html, ISSUE drafts, raw snapshots, run.json) is
92921
+ written under the output directory.
92922
+
92923
+ The build command needs neither a previous build nor standalone output: it runs
92924
+ the build itself and samples the resident memory of each static-generation
92925
+ worker, which is where large sites run out of heap while prerendering. It takes
92926
+ --output only; the options below shape HTTP load and do not apply to it.
92927
+
92928
+ Options:
92929
+ ${rows}
92930
+
92931
+ Exit codes: 0 when the run completes (whatever the verdicts), 130 when
92932
+ interrupted, 1 on errors.
92933
+ `;
93002
92934
  }
93003
- function formatReport(report) {
93004
- const lines = [`next-leak \u2014 ${report.appDir}`, ""];
93005
- for (const route of report.routes) {
93006
- lines.push(...routeLines(route, report.parameters));
93007
- }
93008
- lines.push(...skippedGuidanceLines(report));
93009
- const { minGrowthPerCycle, loadRequests, cycles, maxOldSpaceMb } = report.parameters;
93010
- const resolvedCycles = [
93011
- ...new Set(
93012
- report.routes.flatMap(
93013
- (route) => route.status === "measured" && route.resolvedWithCycles !== void 0 ? [route.resolvedWithCycles] : []
93014
- )
93015
- )
93016
- ].sort((a, b) => a - b);
93017
- const cyclesLabel = resolvedCycles.length === 0 ? `${cycles} cycles` : `${cycles} cycles (${resolvedCycles.join(", ")} where resolved)`;
93018
- lines.push("", coverageLine(report));
93019
- lines.push(
93020
- `judged over ${cyclesLabel} \xD7 ${loadRequests} requests, growth gate ${(minGrowthPerCycle / 1024).toFixed(0)} KiB/cycle (${formatGrowth(minGrowthPerCycle / loadRequests * 1e3)}), heap cap ${maxOldSpaceMb} MB`,
93021
- `snapshots and run.json: ${report.workDir}`,
93022
- `report: ${report.bundle.htmlReport}`,
93023
- ...report.bundle.issues.map((issue) => `issue draft (${issue.route}): ${issue.file}`)
93024
- );
93025
- const inconclusive = report.routes.filter(
93026
- (route) => route.status === "measured" && effectiveVerdict(route) === "inconclusive"
93027
- );
93028
- if (inconclusive.length > 0) {
93029
- const routeList = inconclusive.map((route) => route.route).join(",");
93030
- const moreCycles = resolveCycles(report.parameters.cycles);
93031
- lines.push(
93032
- "",
93033
- "hint: inconclusive means sustained sub-threshold growth \u2014 measure longer to resolve it:",
93034
- ` next-leak ${report.appDir} --routes ${routeList} --cycles ${moreCycles}`
93035
- );
92935
+ var LIMITS = {
92936
+ "--cycles": 100,
92937
+ "--requests": 1e6,
92938
+ "--connections": 1e4,
92939
+ "--idle": 3600,
92940
+ "--warmup": 1e6,
92941
+ "--max-old-space": 65536
92942
+ };
92943
+ var MIN_MAX_OLD_SPACE_MB = 128;
92944
+ function findSpec(argument) {
92945
+ return FLAGS.find((spec) => spec.flag === argument || spec.alias === argument);
92946
+ }
92947
+ var flagError = (message) => ({ kind: "error", message });
92948
+ var FLAG_OK = { kind: "ok" };
92949
+ function applyRoutesFlag(value, options) {
92950
+ const routes = value.split(",").map((route) => route.trim()).filter((route) => route !== "");
92951
+ if (routes.length === 0) {
92952
+ return flagError(`option "--routes" needs at least one route`);
93036
92953
  }
93037
- return lines.join("\n");
92954
+ options.routes = routes;
92955
+ return FLAG_OK;
93038
92956
  }
93039
-
93040
- // src/attribution.ts
93041
- var UNATTRIBUTED = { owner: "unattributed", source: null, packageName: null };
93042
- function classifySource(rawSource) {
93043
- let source = rawSource.replace(/^turbopack:\/+(?=\[)/, "");
93044
- try {
93045
- source = decodeURIComponent(source);
93046
- } catch {
92957
+ function applyNumericFlag(flag, value, options) {
92958
+ const parsed = Number(value);
92959
+ if (!Number.isInteger(parsed) || parsed <= 0) {
92960
+ return flagError(`option "${flag}" needs a positive integer, got "${value}"`);
93047
92961
  }
93048
- const nodeModulesSplit = source.split("/node_modules/");
93049
- if (nodeModulesSplit.length > 1) {
93050
- const tail = nodeModulesSplit.at(-1) ?? "";
93051
- const segments = tail.split("/");
93052
- const packageName = (segments[0]?.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0]) ?? null;
93053
- if (packageName === "next" || packageName === "react" || packageName === "react-dom") {
93054
- return { owner: "framework", source: null, packageName };
93055
- }
93056
- return { owner: "dependency", source: null, packageName };
92962
+ const maximum = LIMITS[flag];
92963
+ if (maximum !== void 0 && parsed > maximum) {
92964
+ return flagError(
92965
+ `option "${flag}" is capped at ${maximum} (got ${parsed}) \u2014 a run that large would never finish`
92966
+ );
93057
92967
  }
93058
- if (source.startsWith("[project]/")) {
93059
- return { owner: "app", source: source.slice("[project]/".length), packageName: null };
92968
+ if (flag === "--cycles" && parsed < 3) {
92969
+ return flagError("the trend verdict needs at least 3 cycles (--cycles 3 or more)");
93060
92970
  }
93061
- const relative = source.replace(/^(\.\.\/)+/, "");
93062
- if (relative !== source && !relative.startsWith("[")) {
93063
- return { owner: "app", source: relative, packageName: null };
92971
+ if (flag === "--max-old-space" && parsed < MIN_MAX_OLD_SPACE_MB) {
92972
+ return flagError(
92973
+ `option "--max-old-space" needs at least ${MIN_MAX_OLD_SPACE_MB} MB (got ${parsed}) \u2014 below that the measured app cannot start`
92974
+ );
93064
92975
  }
93065
- return { owner: "framework", source: null, packageName: null };
92976
+ if (flag === "--cycles") options.cycles = parsed;
92977
+ if (flag === "--requests") options.requests = parsed;
92978
+ if (flag === "--connections") options.connections = parsed;
92979
+ if (flag === "--idle") options.idleSeconds = parsed;
92980
+ if (flag === "--warmup") options.warmupRequests = parsed;
92981
+ if (flag === "--max-old-space") options.maxOldSpaceMb = parsed;
92982
+ return FLAG_OK;
93066
92983
  }
93067
- var OWNER_PRIORITY = {
93068
- app: 0,
93069
- dependency: 1,
93070
- framework: 2,
93071
- unattributed: 3
93072
- };
93073
- var FRAMEWORK_CHAIN_MARKERS = [
93074
- ["fsChecker", "route filesystem checker"],
93075
- ["getDynamicRoutes", "dynamic route matcher"],
93076
- ["NextNodeServer", "Next server"],
93077
- ["AppPageRouteModule", "App Router page module"],
93078
- ["AppRouteRouteModule", "App Router route handler module"],
93079
- ["incrementalCache", "incremental cache"],
93080
- ["IncrementalCache", "incremental cache"],
93081
- ["ReactServer", "React Server Components renderer"],
93082
- ["loadManifests", "manifest loader"],
93083
- ["renderToHTML", "HTML renderer"]
93084
- ];
93085
- function classifyByChain(retainerChain2) {
93086
- for (const [marker, component] of FRAMEWORK_CHAIN_MARKERS) {
93087
- if (retainerChain2.includes(marker)) {
93088
- return { owner: "framework", source: null, packageName: `next (${component})` };
93089
- }
92984
+ function applyFlag(spec, value, options) {
92985
+ switch (spec.flag) {
92986
+ case "--routes":
92987
+ return applyRoutesFlag(value, options);
92988
+ case "--cycles":
92989
+ case "--requests":
92990
+ case "--connections":
92991
+ case "--idle":
92992
+ case "--warmup":
92993
+ case "--max-old-space":
92994
+ return applyNumericFlag(spec.flag, value, options);
92995
+ case "--quick":
92996
+ options.quick = true;
92997
+ return FLAG_OK;
92998
+ case "--diff-all":
92999
+ options.diffAll = true;
93000
+ return FLAG_OK;
93001
+ case "--attribute":
93002
+ options.attributeBuild = true;
93003
+ return FLAG_OK;
93004
+ case "--write-config":
93005
+ options.writeConfig = true;
93006
+ return FLAG_OK;
93007
+ case "--no-resolve":
93008
+ options.noResolve = true;
93009
+ return FLAG_OK;
93010
+ case "--output":
93011
+ options.output = value;
93012
+ return FLAG_OK;
93013
+ default:
93014
+ return FLAG_OK;
93090
93015
  }
93091
- return null;
93092
93016
  }
93093
- function attributeFinding(finding, registry) {
93094
- let best = UNATTRIBUTED;
93095
- for (const id of finding.moduleIds) {
93096
- const source = registry.get(id);
93097
- if (source === void 0) {
93098
- continue;
93099
- }
93100
- const candidate = classifySource(source);
93101
- if (OWNER_PRIORITY[candidate.owner] < OWNER_PRIORITY[best.owner]) {
93102
- best = candidate;
93103
- }
93017
+ function readFlagValue(spec, argv, index) {
93018
+ if (spec.value === "none") {
93019
+ return { value: "" };
93104
93020
  }
93105
- if (best.owner === "unattributed") {
93106
- return classifyByChain(finding.retainerChain ?? "") ?? UNATTRIBUTED;
93021
+ const value = argv[index + 1] ?? "";
93022
+ const looksNegativeNumber = /^-\d+(\.\d+)?$/.test(value);
93023
+ if (value === "" || value.startsWith("-") && !looksNegativeNumber) {
93024
+ return { error: `option "${spec.flag}" needs a value ${spec.argName ?? ""}` };
93107
93025
  }
93108
- return best;
93026
+ return { value };
93109
93027
  }
93110
- function attributeDiff(diff, registry) {
93111
- const all = [...diff.grownNodes, ...diff.newNodes];
93112
- const findings = all.map((finding) => attributeFinding(finding, registry));
93113
- const byGroup = /* @__PURE__ */ new Map();
93114
- let attributedBytes = 0;
93115
- for (const [index, attribution] of findings.entries()) {
93116
- if (attribution.owner === "unattributed") {
93117
- continue;
93118
- }
93119
- const bytes = all[index]?.retainedBytes ?? 0;
93120
- attributedBytes += bytes;
93121
- const key = `${attribution.owner}|${attribution.source ?? ""}|${attribution.packageName ?? ""}`;
93122
- const group = byGroup.get(key) ?? { attribution, bytes: 0 };
93123
- group.bytes += bytes;
93124
- byGroup.set(key, group);
93028
+ function parseFlagAt(argv, index, options) {
93029
+ const argument = argv[index] ?? "";
93030
+ const spec = findSpec(argument);
93031
+ if (spec === void 0) {
93032
+ return { done: { kind: "error", message: `unknown option "${argument}" \u2014 see --help` } };
93125
93033
  }
93126
- let winner = null;
93127
- for (const group of byGroup.values()) {
93128
- if (winner === null || group.bytes > winner.bytes) {
93129
- winner = group;
93130
- }
93034
+ if (spec.flag === "--help") {
93035
+ return { done: { kind: "help" } };
93131
93036
  }
93132
- const route = winner === null || attributedBytes === 0 ? { ...UNATTRIBUTED, dominance: 0 } : { ...winner.attribution, dominance: winner.bytes / attributedBytes };
93133
- return { findings, route };
93134
- }
93135
-
93136
- // src/environment.ts
93137
- import { createRequire } from "module";
93138
- import os from "os";
93139
- var require2 = createRequire(import.meta.url);
93140
- var { version: nextLeakVersion } = require2("../package.json");
93141
- function captureEnvironment(nextVersion) {
93142
- return {
93143
- nodeVersion: process.version,
93144
- platform: os.platform(),
93145
- arch: os.arch(),
93146
- cpuModel: os.cpus()[0]?.model ?? null,
93147
- totalMemoryBytes: os.totalmem(),
93148
- nextVersion,
93149
- nextLeakVersion
93150
- };
93151
- }
93152
-
93153
- // src/heap-diff.ts
93154
- var import_core = __toESM(require_dist(), 1);
93155
- var import_heap_analysis = __toESM(require_dist4(), 1);
93156
- var DEFAULTS = {
93157
- minTypeDeltaBytes: 20 * 1024,
93158
- grownThresholdBytes: 100 * 1024,
93159
- newThresholdBytes: 2 * 1024,
93160
- bigRetainedFloorBytes: 50 * 1024,
93161
- maxFindings: 10,
93162
- chainDepth: 7
93163
- };
93164
- function summarizeBaseline(heap, options = {}) {
93165
- const resolved = { ...DEFAULTS, ...options };
93166
- const summary = {
93167
- nodeIds: /* @__PURE__ */ new Set(),
93168
- bigRetained: /* @__PURE__ */ new Map(),
93169
- typeSelfSizes: /* @__PURE__ */ new Map()
93170
- };
93171
- heap.nodes.forEach((node) => {
93172
- summary.nodeIds.add(node.id);
93173
- if (node.retainedSize >= resolved.bigRetainedFloorBytes) {
93174
- summary.bigRetained.set(node.id, node.retainedSize);
93175
- }
93176
- summary.typeSelfSizes.set(
93177
- node.type,
93178
- (summary.typeSelfSizes.get(node.type) ?? 0) + node.self_size
93179
- );
93180
- });
93181
- return summary;
93182
- }
93183
- function truncateLabel(value, max2 = 60) {
93184
- return value.replaceAll("\n", " ").slice(0, max2);
93185
- }
93186
- function walkChain(node, depth) {
93187
- const parts = [];
93188
- const nodes = [node];
93189
- let current = node;
93190
- const seen = /* @__PURE__ */ new Set([node.id]);
93191
- for (let i = 0; i < depth; i += 1) {
93192
- const referrers = current.referrers;
93193
- if (referrers.length === 0) {
93194
- break;
93195
- }
93196
- const edge = referrers.find(
93197
- (candidate) => !seen.has(candidate.fromNode.id) && candidate.fromNode.type !== "synthetic" && candidate.type !== "weak"
93198
- ) ?? referrers[0];
93199
- if (edge === void 0 || seen.has(edge.fromNode.id)) {
93200
- break;
93201
- }
93202
- current = edge.fromNode;
93203
- seen.add(current.id);
93204
- nodes.push(current);
93205
- parts.push(
93206
- `${truncateLabel(current.name)}#${current.type}[.${truncateLabel(
93207
- String(edge.name_or_index),
93208
- 40
93209
- )}]`
93210
- );
93037
+ if (spec.flag === "--version") {
93038
+ return { done: { kind: "version" } };
93039
+ }
93040
+ const read2 = readFlagValue(spec, argv, index);
93041
+ if ("error" in read2) {
93042
+ return { done: { kind: "error", message: read2.error } };
93043
+ }
93044
+ const outcome = applyFlag(spec, read2.value, options);
93045
+ if (outcome.kind === "error") {
93046
+ return { done: { kind: "error", message: outcome.message } };
93211
93047
  }
93212
- return { parts, nodes };
93213
- }
93214
- function retainerChain(node, depth) {
93215
- return walkChain(node, depth).parts.join(" <- ");
93048
+ return { consumed: spec.value === "none" ? 0 : 1 };
93216
93049
  }
93217
- function collectModuleIds(chainNodes) {
93218
- const ids = [];
93219
- for (const node of chainNodes) {
93220
- const isModuleInstance = (node.references ?? []).some(
93221
- (edge) => edge.name_or_index === "namespaceObject" || edge.name_or_index === "exports"
93222
- );
93223
- if (!isModuleInstance) {
93050
+ function parseCliArgs(argv) {
93051
+ const isBuild = argv[0] === BUILD_COMMAND;
93052
+ const rest = isBuild ? argv.slice(1) : argv;
93053
+ const options = {
93054
+ appDir: "",
93055
+ routes: null,
93056
+ cycles: null,
93057
+ requests: null,
93058
+ connections: null,
93059
+ idleSeconds: null,
93060
+ warmupRequests: null,
93061
+ maxOldSpaceMb: null,
93062
+ quick: false,
93063
+ noResolve: false,
93064
+ diffAll: false,
93065
+ attributeBuild: false,
93066
+ writeConfig: false,
93067
+ output: null
93068
+ };
93069
+ for (let index = 0; index < rest.length; index += 1) {
93070
+ const argument = rest[index] ?? "";
93071
+ if (!argument.startsWith("-")) {
93072
+ if (options.appDir !== "") {
93073
+ return { kind: "error", message: `unexpected extra argument "${argument}" \u2014 one app directory only` };
93074
+ }
93075
+ options.appDir = argument;
93224
93076
  continue;
93225
93077
  }
93226
- for (const edge of node.referrers) {
93227
- if (edge.type !== "element") {
93228
- continue;
93229
- }
93230
- const id = Number(edge.name_or_index);
93231
- if (Number.isInteger(id) && !ids.includes(id)) {
93232
- ids.push(id);
93233
- }
93078
+ const step = parseFlagAt(rest, index, options);
93079
+ if ("done" in step) {
93080
+ return step.done;
93234
93081
  }
93082
+ index += step.consumed;
93235
93083
  }
93236
- return ids;
93237
- }
93238
- function diffAgainstBaseline(baseline, after, options = {}) {
93239
- const resolved = { ...DEFAULTS, ...options };
93240
- const afterTypeSelfSizes = /* @__PURE__ */ new Map();
93241
- const grown = [];
93242
- const fresh = [];
93243
- after.nodes.forEach((node) => {
93244
- afterTypeSelfSizes.set(
93245
- node.type,
93246
- (afterTypeSelfSizes.get(node.type) ?? 0) + node.self_size
93247
- );
93248
- if (node.type === "synthetic") {
93249
- return;
93084
+ if (options.appDir === "") {
93085
+ return { kind: "help" };
93086
+ }
93087
+ if (isBuild) {
93088
+ const misplaced = runOnlyFlagUsed(options);
93089
+ if (misplaced !== null) {
93090
+ return {
93091
+ kind: "error",
93092
+ message: `option "${misplaced}" shapes HTTP load and does not apply to "next-leak build" \u2014 see --help`
93093
+ };
93250
93094
  }
93251
- if (!baseline.nodeIds.has(node.id)) {
93252
- if (node.retainedSize >= resolved.newThresholdBytes) {
93253
- const chain = walkChain(node, resolved.chainDepth);
93254
- fresh.push({
93255
- kind: "new",
93256
- nodeType: node.type,
93257
- name: truncateLabel(node.name),
93258
- retainedBytes: node.retainedSize,
93259
- retainerChain: chain.parts.join(" <- "),
93260
- moduleIds: collectModuleIds(chain.nodes)
93261
- });
93095
+ return {
93096
+ kind: "build",
93097
+ options: {
93098
+ appDir: options.appDir,
93099
+ output: options.output,
93100
+ attributeBuild: options.attributeBuild
93262
93101
  }
93263
- return;
93264
- }
93265
- const before = baseline.bigRetained.get(node.id);
93266
- if (before !== void 0 && node.retainedSize - before >= resolved.grownThresholdBytes) {
93267
- const chain = walkChain(node, resolved.chainDepth);
93268
- grown.push({
93269
- kind: "grown",
93270
- nodeType: node.type,
93271
- name: truncateLabel(node.name),
93272
- retainedBytes: node.retainedSize - before,
93273
- retainerChain: chain.parts.join(" <- "),
93274
- moduleIds: collectModuleIds(chain.nodes)
93275
- });
93276
- }
93277
- });
93278
- const typeDeltas = [];
93279
- const allTypes = /* @__PURE__ */ new Set([...baseline.typeSelfSizes.keys(), ...afterTypeSelfSizes.keys()]);
93280
- for (const type of allTypes) {
93281
- const delta = (afterTypeSelfSizes.get(type) ?? 0) - (baseline.typeSelfSizes.get(type) ?? 0);
93282
- if (Math.abs(delta) >= resolved.minTypeDeltaBytes) {
93283
- typeDeltas.push({ type, deltaBytes: delta });
93284
- }
93102
+ };
93285
93103
  }
93286
- typeDeltas.sort((a, b) => b.deltaBytes - a.deltaBytes);
93287
- grown.sort((a, b) => b.retainedBytes - a.retainedBytes);
93288
- fresh.sort((a, b) => b.retainedBytes - a.retainedBytes);
93104
+ return { kind: "run", options };
93105
+ }
93106
+
93107
+ // src/guards.ts
93108
+ function currentFacts() {
93289
93109
  return {
93290
- typeDeltas,
93291
- grownNodes: grown.slice(0, resolved.maxFindings),
93292
- newNodes: fresh.slice(0, resolved.maxFindings)
93110
+ nodeMajor: Number(process.versions.node.split(".")[0]),
93111
+ platform: process.platform
93293
93112
  };
93294
93113
  }
93295
- var SnapshotError = class extends Error {
93114
+ function checkRuntime(facts = currentFacts()) {
93115
+ if (facts.platform === "win32") {
93116
+ return "next-leak does not support Windows (the measurement ritual relies on POSIX process control). Run it inside WSL2 or a Linux container.";
93117
+ }
93118
+ if (facts.nodeMajor < 22) {
93119
+ return `next-leak needs Node.js >= 22 (you are on ${process.versions.node}). It relies on --expose-gc semantics and heap snapshot behavior verified on modern V8. Upgrade Node and retry.`;
93120
+ }
93121
+ return null;
93122
+ }
93123
+
93124
+ // src/route-config.ts
93125
+ import { readFile as readFile4 } from "fs/promises";
93126
+ import path4 from "path";
93127
+ import { z as z4 } from "zod";
93128
+ var sampleValueSchema = z4.string().refine(
93129
+ (value) => !(value.includes("{n}") && /\{n%\d+\}/.test(value)),
93130
+ { message: 'a sample value cannot carry both "{n}" and "{n%N}" \u2014 pick one' }
93131
+ );
93132
+ var routeConfigSchema = z4.object({
93133
+ params: z4.record(z4.string(), sampleValueSchema).optional(),
93134
+ routes: z4.record(z4.string(), z4.record(z4.string(), sampleValueSchema)).optional(),
93135
+ /**
93136
+ * Headers sent with every request. Real traffic is not header-less:
93137
+ * compression (`Accept-Encoding`), sessions and auth change which code
93138
+ * paths run, and some leaks only exist on those paths (zlib, per-session
93139
+ * caches). Measuring without them measures a different app.
93140
+ */
93141
+ headers: z4.record(z4.string(), z4.string()).optional(),
93142
+ /** Query string appended per route template, e.g. `"weightKb=2048"`. */
93143
+ query: z4.record(z4.string(), z4.string()).optional(),
93144
+ /**
93145
+ * Simulates clients that give up before the response arrives (closed
93146
+ * tabs, load-balancer timeouts, bots). Several real leaks only exist on
93147
+ * that path — vercel/next.js#89091 traces `ServerResponse` retention to
93148
+ * an early disconnect — and a load generator that always waits politely
93149
+ * never reaches it.
93150
+ *
93151
+ * Counted **from the first byte of the response**, not from the request:
93152
+ * under load a server's first byte arrives long after any sane fixed
93153
+ * window, so a request-relative clock cuts before the stream starts and
93154
+ * tests the wrong path entirely.
93155
+ */
93156
+ abandonAfterMs: z4.number().int().positive().optional(),
93157
+ /**
93158
+ * Where the abandon deadline starts. `first-byte` is the default and the
93159
+ * semantics everything else assumes.
93160
+ *
93161
+ * `request` starts it when the request is written, which is the only way
93162
+ * to reach leaks that need the client already gone before the server
93163
+ * produces anything: against a route whose time-to-first-byte exceeds the
93164
+ * deadline, a first-byte clock never cuts early. Measured on the
93165
+ * vercel/next.js#84648 repro, whose upstream answers in 400 ms while its
93166
+ * own load generator cuts at 60 ms — the first-byte clock reached 7% of
93167
+ * requests there, none of them on the path the issue describes.
93168
+ *
93169
+ * Opt-in per route, never a default: armed on connect, the deadline loses
93170
+ * the race to the first byte on fast routes, which is how the earlier
93171
+ * version of this measured 2 mid-stream cuts out of ~1500 on #94919.
93172
+ */
93173
+ abandonFrom: z4.enum(["first-byte", "request"]).optional()
93174
+ }).strict().refine((config) => config.abandonFrom === void 0 || config.abandonAfterMs !== void 0, {
93175
+ message: '"abandonFrom" configures nothing without "abandonAfterMs"',
93176
+ path: ["abandonFrom"]
93177
+ });
93178
+ var RouteConfigError = class extends Error {
93296
93179
  constructor(message) {
93297
93180
  super(message);
93298
- this.name = "SnapshotError";
93181
+ this.name = "RouteConfigError";
93299
93182
  }
93300
93183
  };
93301
- var SNAPSHOT_HEAD = '{"snapshot"';
93302
- var PROBE_BYTES = 512;
93303
- async function assertReadableSnapshot(file) {
93304
- const { open, stat } = await import("fs/promises");
93305
- const { constants } = await import("buffer");
93306
- let size;
93184
+ var ROUTE_CONFIG_FILE = "next-leak.config.json";
93185
+ async function loadRouteConfig(appDir) {
93186
+ const file = path4.join(appDir, ROUTE_CONFIG_FILE);
93187
+ let raw;
93307
93188
  try {
93308
- size = (await stat(file)).size;
93189
+ raw = await readFile4(file, "utf8");
93309
93190
  } catch {
93310
- throw new SnapshotError(`heap snapshot is missing: ${file}`);
93311
- }
93312
- if (size === 0) {
93313
- throw new SnapshotError(`heap snapshot is empty: ${file}`);
93191
+ return {};
93314
93192
  }
93315
- if (size > constants.MAX_STRING_LENGTH) {
93316
- throw new SnapshotError(
93317
- `heap snapshot is ${(size / (1024 * 1024)).toFixed(0)} MB, past the ${(constants.MAX_STRING_LENGTH / (1024 * 1024)).toFixed(0)} MB a single string can hold, so it cannot be parsed: ${file}. The measurement itself is unaffected \u2014 only the snapshot diff, which names the retaining object, is unavailable. Lower --requests or --cycles to keep the heap smaller if you need it`
93193
+ try {
93194
+ return routeConfigSchema.parse(JSON.parse(raw));
93195
+ } catch (cause) {
93196
+ throw new RouteConfigError(
93197
+ `${file} is invalid: ${cause instanceof Error ? cause.message : String(cause)}`
93318
93198
  );
93319
93199
  }
93320
- const handle = await open(file, "r");
93321
- try {
93322
- const head = Buffer.alloc(Math.min(PROBE_BYTES, size));
93323
- await handle.read(head, 0, head.length, 0);
93324
- if (!head.toString("utf8").trimStart().startsWith(SNAPSHOT_HEAD)) {
93325
- throw new SnapshotError(
93326
- `not a V8 heap snapshot (missing "snapshot" header): ${file}`
93327
- );
93200
+ }
93201
+ var SEGMENT_PATTERN = /^\[(\[)?(\.\.\.)?([^\]]+?)\]?\]$/;
93202
+ function dynamicSegmentsOf(routeTemplate) {
93203
+ const segments = [];
93204
+ for (const segment of routeTemplate.split("/")) {
93205
+ const match = SEGMENT_PATTERN.exec(segment);
93206
+ if (match === null) {
93207
+ continue;
93328
93208
  }
93329
- const tail = Buffer.alloc(Math.min(PROBE_BYTES, size));
93330
- await handle.read(tail, 0, tail.length, size - tail.length);
93331
- if (!tail.toString("utf8").trimEnd().endsWith("}")) {
93332
- throw new SnapshotError(
93333
- `heap snapshot looks truncated (JSON does not close): ${file} \u2014 the measured process may have been killed while writing it`
93334
- );
93209
+ segments.push({
93210
+ name: match[3] ?? "",
93211
+ catchAll: match[2] !== void 0,
93212
+ optional: match[1] !== void 0
93213
+ });
93214
+ }
93215
+ return segments;
93216
+ }
93217
+ var UNIQUE_MARKER = "{n}";
93218
+ var BOUNDED_MARKER = /\{n%(\d+)\}/;
93219
+ function boundedMarkerOf(value) {
93220
+ const match = BOUNDED_MARKER.exec(value);
93221
+ const bound = Number(match?.[1]);
93222
+ if (match === null || !Number.isInteger(bound) || bound <= 0) {
93223
+ return null;
93224
+ }
93225
+ return { marker: match[0], bound };
93226
+ }
93227
+ var ENCODED_MARKER = encodeURIComponent(UNIQUE_MARKER);
93228
+ var encodeSegment = (value) => restoreMarkers(encodeURIComponent(value).split(ENCODED_MARKER).join(UNIQUE_MARKER));
93229
+ var ENCODED_BOUNDED = /%7Bn%25(\d+)%7D/gi;
93230
+ var restoreMarkers = (value) => value.replace(ENCODED_BOUNDED, (_match, bound) => `{n%${bound}}`);
93231
+ var encodeCatchAll = (value) => value.split("/").map(encodeSegment).join("/");
93232
+ function resolveRoutePath(routeTemplate, config) {
93233
+ const resolved = [];
93234
+ for (const segment of routeTemplate.split("/")) {
93235
+ const match = SEGMENT_PATTERN.exec(segment);
93236
+ if (match === null) {
93237
+ resolved.push(encodeSegment(segment));
93238
+ continue;
93239
+ }
93240
+ const optional = match[1] !== void 0;
93241
+ const catchAll = match[2] !== void 0;
93242
+ const name = match[3] ?? "";
93243
+ const value = config.routes?.[routeTemplate]?.[name] ?? config.params?.[name];
93244
+ if (value !== void 0) {
93245
+ resolved.push(catchAll ? encodeCatchAll(value) : encodeSegment(value));
93246
+ } else if (!optional) {
93247
+ return null;
93335
93248
  }
93336
- } finally {
93337
- await handle.close();
93338
93249
  }
93339
- }
93340
- var defaultLoader = async (file) => {
93341
- await assertReadableSnapshot(file);
93342
- import_core.config.muteConsole = true;
93343
- return (0, import_heap_analysis.getFullHeapFromFile)(file);
93344
- };
93345
- async function diffSnapshotFiles(baselineFile, afterFile, options = {}, loadHeap = defaultLoader) {
93346
- const summary = summarizeBaseline(await loadHeap(baselineFile), options);
93347
- const after = await loadHeap(afterFile);
93348
- return diffAgainstBaseline(summary, after, options);
93250
+ const joined = resolved.join("/");
93251
+ const path7 = joined === "" ? "/" : joined;
93252
+ const query = config.query?.[routeTemplate];
93253
+ return query === void 0 || query === "" ? path7 : `${path7}?${query}`;
93349
93254
  }
93350
93255
 
93351
- // src/vlq.ts
93352
- var BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
93353
- var CHAR_TO_VALUE = new Map([...BASE64].map((char, index) => [char, index]));
93354
- function decodeVlqSegment(raw) {
93355
- const values = [];
93356
- let value = 0;
93357
- let shift = 0;
93358
- for (const char of raw) {
93359
- const digit = CHAR_TO_VALUE.get(char);
93360
- if (digit === void 0) {
93361
- throw new Error(`invalid VLQ character: ${char}`);
93256
+ // src/route-guidance.ts
93257
+ var PLACEHOLDER = "REPLACE-ME";
93258
+ function sampleValuesFromManifest(manifest, routeTemplate) {
93259
+ const routes = manifest?.routes;
93260
+ if (routes === void 0) {
93261
+ return null;
93262
+ }
93263
+ const concrete = Object.entries(routes).find(([, entry]) => entry.srcRoute === routeTemplate)?.[0];
93264
+ if (concrete === void 0) {
93265
+ return null;
93266
+ }
93267
+ const templateSegments = routeTemplate.split("/");
93268
+ const concreteSegments = concrete.split("/");
93269
+ const values = {};
93270
+ for (const [index, segment] of templateSegments.entries()) {
93271
+ const dynamic = dynamicSegmentsOf(segment);
93272
+ const parameter = dynamic[0];
93273
+ if (parameter === void 0) {
93274
+ continue;
93362
93275
  }
93363
- value += (digit & 31) << shift;
93364
- if ((digit & 32) !== 0) {
93365
- shift += 5;
93366
- } else {
93367
- values.push((value & 1) === 1 ? -(value >>> 1) : value >>> 1);
93368
- value = 0;
93369
- shift = 0;
93276
+ const value = parameter.catchAll ? concreteSegments.slice(index).join("/") : concreteSegments[index];
93277
+ if (value === void 0 || value === "") {
93278
+ return null;
93370
93279
  }
93280
+ values[parameter.name] = value;
93371
93281
  }
93372
- return values;
93282
+ return Object.keys(values).length === 0 ? null : values;
93373
93283
  }
93374
- function decodeVlqLine(line) {
93375
- const segments = [];
93376
- for (const raw of line.split(",")) {
93377
- if (raw !== "") {
93378
- segments.push(decodeVlqSegment(raw));
93284
+ function renderConfigSkeleton(routeTemplates, manifest = void 0) {
93285
+ const routes = {};
93286
+ for (const template of routeTemplates) {
93287
+ const segments = dynamicSegmentsOf(template);
93288
+ if (segments.length === 0) {
93289
+ continue;
93290
+ }
93291
+ const sampled = sampleValuesFromManifest(manifest, template);
93292
+ const values = {};
93293
+ for (const segment of segments) {
93294
+ values[segment.name] = sampled?.[segment.name] ?? PLACEHOLDER;
93379
93295
  }
93296
+ routes[template] = values;
93380
93297
  }
93381
- return segments;
93298
+ return Object.keys(routes).length === 0 ? null : JSON.stringify({ routes }, null, 2);
93382
93299
  }
93383
- function decodeMappings(mappings) {
93384
- const lines = [];
93385
- let sourceIndex = 0;
93386
- for (const line of mappings.split(";")) {
93387
- const entries = [];
93388
- let column = 0;
93389
- for (const segment of decodeVlqLine(line)) {
93390
- const columnDelta = segment[0];
93391
- if (columnDelta === void 0) {
93392
- continue;
93393
- }
93394
- column += columnDelta;
93395
- if (segment.length >= 4) {
93396
- sourceIndex += segment[1] ?? 0;
93397
- entries.push({ column, sourceIndex });
93398
- }
93300
+ function hasPlaceholders(skeleton) {
93301
+ return skeleton.includes(PLACEHOLDER);
93302
+ }
93303
+
93304
+ // src/unreclaimed-retention.ts
93305
+ var MB = 1024 * 1024;
93306
+ var CLASSES = ["heap", "external", "arrayBuffers"];
93307
+ var MIN_GAP_BYTES = 2 * MB;
93308
+ var MIN_GAP_RATIO = 0.35;
93309
+ var MIN_RETAINED_BYTES = MB;
93310
+ var read = (sample, memoryClass) => memoryClass === "heap" ? sample.heapUsed : memoryClass === "external" ? sample.external : sample.arrayBuffers;
93311
+ function largestGap(values) {
93312
+ return values.reduce((highest, value) => Math.max(highest, value), 0);
93313
+ }
93314
+ function gapsFor(input, memoryClass) {
93315
+ const gaps = [];
93316
+ for (const [index, held] of input.unreclaimedSamples.entries()) {
93317
+ const afterGc = input.memorySamples[index + 1];
93318
+ if (afterGc === void 0) {
93319
+ break;
93399
93320
  }
93400
- lines.push({ entries });
93321
+ gaps.push(read(held, memoryClass) - read(afterGc, memoryClass));
93401
93322
  }
93402
- return lines;
93323
+ return gaps;
93403
93324
  }
93404
- function sourceIndexAt(lines, line, column) {
93405
- const mapping = lines[line];
93406
- if (mapping === void 0) {
93407
- return void 0;
93325
+ function assessUnreclaimedRetention(input) {
93326
+ if (input.verdict === "leak" && input.verdictIsWellSupported || input.unreclaimedSamples.length === 0) {
93327
+ return null;
93408
93328
  }
93409
- let found;
93410
- for (const entry of mapping.entries) {
93411
- if (entry.column > column) {
93412
- break;
93329
+ let worst = null;
93330
+ for (const memoryClass of CLASSES) {
93331
+ const gaps = gapsFor(input, memoryClass);
93332
+ if (gaps.length === 0) {
93333
+ continue;
93334
+ }
93335
+ const largestGapBytes = largestGap(gaps);
93336
+ if (largestGapBytes < MIN_GAP_BYTES) {
93337
+ continue;
93338
+ }
93339
+ const retainedBytes = largestGap(
93340
+ input.memorySamples.slice(1).map((sample) => read(sample, memoryClass))
93341
+ );
93342
+ const ratio = largestGapBytes / Math.max(retainedBytes, MIN_RETAINED_BYTES);
93343
+ if (ratio < MIN_GAP_RATIO) {
93344
+ continue;
93345
+ }
93346
+ if (worst === null || ratio >= worst.ratio) {
93347
+ worst = { class: memoryClass, largestGapBytes, retainedBytes, ratio };
93413
93348
  }
93414
- found = entry.sourceIndex;
93415
93349
  }
93416
- return found;
93350
+ return worst;
93351
+ }
93352
+ var mb = (bytes) => `${(bytes / MB).toFixed(2)} MB`;
93353
+ var CLASS_LABEL = {
93354
+ heap: "heap",
93355
+ external: "external memory",
93356
+ arrayBuffers: "arrayBuffers"
93357
+ };
93358
+ function describeUnreclaimedRetention(retention) {
93359
+ return `held ${mb(retention.largestGapBytes)} of ${CLASS_LABEL[retention.class]} between collections that a GC took back (${retention.ratio.toFixed(1)}x what it retains) \u2014 a forced GC reclaims this, a long-running process may not run one often enough to; read seconds after load, so it also includes memory not yet collected`;
93417
93360
  }
93418
93361
 
93419
- // src/module-registry.ts
93420
- import { readdir as readdir2, readFile as readFile4 } from "fs/promises";
93421
- import path4 from "path";
93422
- import { z as z4 } from "zod";
93423
- var flatMapSchema = z4.looseObject({
93424
- version: z4.number(),
93425
- sources: z4.array(z4.string()),
93426
- mappings: z4.string()
93427
- });
93428
- var sectionedMapSchema = z4.looseObject({
93429
- version: z4.number(),
93430
- sections: z4.array(
93431
- z4.looseObject({
93432
- offset: z4.looseObject({ line: z4.number(), column: z4.number() }),
93433
- map: z4.looseObject({ sources: z4.array(z4.string()), mappings: z4.string() })
93434
- })
93435
- )
93436
- });
93437
- function normalizeSourceMap(raw) {
93438
- const flat = flatMapSchema.safeParse(raw);
93439
- if (flat.success) {
93440
- return { sources: flat.data.sources, lines: decodeMappings(flat.data.mappings) };
93362
+ // src/report.ts
93363
+ var MB2 = 1024 * 1024;
93364
+ var formatMb = (bytes) => `${(bytes / MB2).toFixed(1)} MB`;
93365
+ var formatGrowth = (bytes) => {
93366
+ const sign = bytes >= 0 ? "+" : "";
93367
+ return `${sign}${(bytes / MB2).toFixed(2)} MB/1000 req`;
93368
+ };
93369
+ var VERDICT_ICON = { leak: "\u2716", stable: "\u2714", inconclusive: "?" };
93370
+ var RSS_MIN_GROWTH_PER_CYCLE = 16 * MB2;
93371
+ var RSS_MIN_TOTAL_GROWTH = 64 * MB2;
93372
+ function hasSustainedRssGrowth(memorySamples) {
93373
+ const rss = memorySamples.map((sample) => sample.rss);
93374
+ const first = rss[1];
93375
+ const last = rss.at(-1);
93376
+ if (first === void 0 || last === void 0 || rss.length < 4) {
93377
+ return false;
93441
93378
  }
93442
- const sectioned = sectionedMapSchema.safeParse(raw);
93443
- if (!sectioned.success) {
93444
- return null;
93379
+ return classifyTrend(rss, { minGrowthPerCycle: RSS_MIN_GROWTH_PER_CYCLE }).verdict === "leak" && last - first >= RSS_MIN_TOTAL_GROWTH;
93380
+ }
93381
+ function ownerLabel(attribution) {
93382
+ switch (attribution.owner) {
93383
+ case "app":
93384
+ return `${attribution.source ?? "your code"} (your code)`;
93385
+ case "dependency":
93386
+ return `${attribution.packageName ?? "a dependency"} (dependency)`;
93387
+ case "framework":
93388
+ return attribution.packageName === null ? "Next.js/runtime internals" : `${attribution.packageName} (framework)`;
93389
+ case "unattributed":
93390
+ return null;
93445
93391
  }
93446
- const sources = [];
93392
+ }
93393
+ function revalidationLines(route) {
93394
+ return route.revalidatedEverySeconds === void 0 ? [] : [
93395
+ ` driven through ISR revalidation (revalidates every ${route.revalidatedEverySeconds}s; without it the load would serve the cache)`
93396
+ ];
93397
+ }
93398
+ function abandonLines(route) {
93399
+ if (route.abandon === void 0) {
93400
+ return [];
93401
+ }
93402
+ return [
93403
+ route.abandon.from === "request" ? ` cut ${route.abandon.afterMs}ms after the request was sent, so the client is gone before the response starts` : ` cut ${route.abandon.afterMs}ms after the first byte, so the cut lands mid-stream`
93404
+ ];
93405
+ }
93406
+ function confidenceLines(route) {
93447
93407
  const lines = [];
93448
- for (const section of sectioned.data.sections) {
93449
- const sourceBase = sources.length;
93450
- sources.push(...section.map.sources);
93451
- for (const [lineIndex, mapping] of decodeMappings(section.map.mappings).entries()) {
93452
- const targetLine = section.offset.line + lineIndex;
93453
- const columnShift = lineIndex === 0 ? section.offset.column : 0;
93454
- const target = lines[targetLine] ??= { entries: [] };
93455
- for (const entry of mapping.entries) {
93456
- target.entries.push({
93457
- column: entry.column + columnShift,
93458
- sourceIndex: entry.sourceIndex + sourceBase
93459
- });
93460
- }
93461
- }
93408
+ if (route.confidence.supersededVerdict !== void 0) {
93409
+ lines.push(
93410
+ ` measured ${route.trend.verdict}, withdrawn: the run did not observe what that verdict needs`
93411
+ );
93462
93412
  }
93463
- for (const line of lines) {
93464
- line?.entries.sort((a, b) => a.column - b.column);
93413
+ for (const warning of route.confidence.warnings) {
93414
+ lines.push(` \u26A0 low confidence: ${warning.detail}`);
93415
+ }
93416
+ return lines;
93417
+ }
93418
+ function memorySourceLines(route, verdict) {
93419
+ const lines = [];
93420
+ if (route.trend.source === "external" && verdict !== "stable") {
93421
+ const externalCurve = route.memorySamples.map((sample) => formatMb(sample.external)).join(" \u2192 ");
93422
+ lines.push(
93423
+ ` verdict comes from EXTERNAL memory (buffers, streams, fetch bodies), not the JS heap: external ${externalCurve}`
93424
+ );
93465
93425
  }
93466
- for (let i = 0; i < lines.length; i += 1) {
93467
- lines[i] ??= { entries: [] };
93426
+ if (verdict === "stable" && hasSustainedRssGrowth(route.memorySamples)) {
93427
+ const rssCurve = route.memorySamples.map((sample) => formatMb(sample.rss)).join(" \u2192 ");
93428
+ lines.push(
93429
+ ` note: heap is flat but RSS grows ${formatGrowth(route.rssPer1000Requests)} \u2014 not a JS-heap leak (allocator, external buffers or fragmentation): RSS ${rssCurve}`
93430
+ );
93468
93431
  }
93469
- return { sources, lines };
93432
+ return lines;
93470
93433
  }
93471
- var ID_IN_GAP = /[,[](\d{1,9}),/g;
93472
- function lastModuleIdIn(gap) {
93473
- ID_IN_GAP.lastIndex = 0;
93474
- let last = null;
93475
- let match = ID_IN_GAP.exec(gap);
93476
- while (match !== null) {
93477
- last = Number(match[1]);
93478
- match = ID_IN_GAP.exec(gap);
93434
+ function diffFindingLines(route) {
93435
+ if (route.diff === null) {
93436
+ return [];
93479
93437
  }
93480
- return last;
93438
+ const findings = [...route.diff.grownNodes, ...route.diff.newNodes];
93439
+ return findings.slice(0, 3).map((finding, index) => {
93440
+ const size = formatMb(finding.retainedBytes);
93441
+ const attribution = route.attribution?.findings[index];
93442
+ const owner = attribution === void 0 ? null : ownerLabel(attribution);
93443
+ const detail = owner ?? (finding.retainerChain === "" ? "" : finding.retainerChain);
93444
+ return ` \u21B3 ${finding.kind} [${finding.nodeType}] ${finding.name} ${size}${detail === "" ? "" : ` \u2014 ${detail}`}`;
93445
+ });
93481
93446
  }
93482
- function collectFirstArrayIds(codeLines, map, result) {
93483
- for (const [lineIndex, lineText] of codeLines.entries()) {
93484
- const first = /module\.exports ?= ?\[(\d+),/.exec(lineText);
93485
- if (first?.[1] === void 0) {
93486
- continue;
93487
- }
93488
- const idEnd = first.index + first[0].length;
93489
- const nextId = /[,[]\d{1,9},/.exec(lineText.slice(idEnd));
93490
- const spanEnd = nextId === null ? lineText.length : idEnd + nextId.index;
93491
- const entry = map.lines[lineIndex]?.entries.find(
93492
- (candidate) => candidate.column >= idEnd && candidate.column < spanEnd
93493
- );
93494
- const source = entry === void 0 ? void 0 : map.sources[entry.sourceIndex];
93495
- if (source !== void 0) {
93496
- result.set(Number(first[1]), source);
93497
- }
93447
+ function findingLines(route) {
93448
+ const lines = [];
93449
+ const culprit = route.attribution === null ? null : ownerLabel(route.attribution.route);
93450
+ if (culprit !== null) {
93451
+ lines.push(` culprit: ${culprit}`);
93452
+ }
93453
+ lines.push(...diffFindingLines(route));
93454
+ for (const signature of route.signatures) {
93455
+ const flag = signature.historical ? " (historical)" : "";
93456
+ lines.push(` \u26A0 known cause${flag}: ${signature.title} \u2014 ${signature.issue}`);
93498
93457
  }
93458
+ return lines;
93499
93459
  }
93500
- function collectTransitionIds(codeLines, map, result) {
93501
- for (const [lineIndex, mapping] of map.lines.entries()) {
93502
- const lineText = codeLines[lineIndex];
93503
- if (lineText === void 0) {
93504
- continue;
93505
- }
93506
- let previousColumn = 0;
93507
- let previousSource = null;
93508
- for (const entry of mapping.entries) {
93509
- if (entry.sourceIndex !== previousSource) {
93510
- const id = lastModuleIdIn(lineText.slice(previousColumn, entry.column));
93511
- const source = map.sources[entry.sourceIndex];
93512
- if (id !== null && source !== void 0 && !result.has(id)) {
93513
- result.set(id, source);
93514
- }
93515
- previousSource = entry.sourceIndex;
93516
- }
93517
- previousColumn = entry.column;
93518
- }
93460
+ function peakPressureLines(route, parameters) {
93461
+ const retained = route.memorySamples.at(-1)?.heapUsed;
93462
+ if (retained === void 0) {
93463
+ return [];
93519
93464
  }
93465
+ const pressure = assessPeakPressure({
93466
+ peaks: route.peaks,
93467
+ retainedHeapBytes: retained,
93468
+ maxOldSpaceMb: parameters.maxOldSpaceMb
93469
+ });
93470
+ return pressure === null ? [] : [` \u25B2 peak pressure: ${describePeakPressure(pressure)}`];
93520
93471
  }
93521
- function extractChunkModules(code, rawMap) {
93522
- const result = /* @__PURE__ */ new Map();
93523
- if (!code.includes("module.exports=[") && !code.includes("module.exports = [")) {
93524
- return result;
93472
+ function unreclaimedLines(route, verdict) {
93473
+ const retention = assessUnreclaimedRetention({
93474
+ unreclaimedSamples: route.unreclaimedSamples,
93475
+ memorySamples: route.memorySamples,
93476
+ verdict,
93477
+ verdictIsWellSupported: warrantsIssueDraft(route)
93478
+ });
93479
+ return retention === null ? [] : [` \u25B2 unreclaimed: ${describeUnreclaimedRetention(retention)}`];
93480
+ }
93481
+ function routeLines(route, parameters) {
93482
+ if (route.status === "skipped") {
93483
+ return [` \u2013 ${route.route} skipped: ${route.reason}`];
93525
93484
  }
93526
- const map = normalizeSourceMap(rawMap);
93527
- if (map === null) {
93528
- return result;
93485
+ if (route.status === "failed") {
93486
+ return [` \u2716 ${route.route} failed: ${route.reason}`];
93529
93487
  }
93530
- const codeLines = code.split("\n");
93531
- collectFirstArrayIds(codeLines, map, result);
93532
- collectTransitionIds(codeLines, map, result);
93533
- return result;
93488
+ if (route.status === "not-exercised") {
93489
+ return [` \u2013 ${route.route} not exercised: ${route.reason}`];
93490
+ }
93491
+ const verdict = effectiveVerdict(route);
93492
+ const curve = route.samples.map(formatMb).join(" \u2192 ");
93493
+ const resolved = route.resolvedWithCycles === void 0 ? "" : ` (resolved at ${route.resolvedWithCycles} cycles)`;
93494
+ return [
93495
+ ` ${VERDICT_ICON[verdict]} ${route.route} ${verdict} (${formatGrowth(
93496
+ route.growthPer1000Requests
93497
+ )}) heap ${curve}${resolved}`,
93498
+ ...revalidationLines(route),
93499
+ ...abandonLines(route),
93500
+ ...confidenceLines(route),
93501
+ ...memorySourceLines(route, verdict),
93502
+ ...peakPressureLines(route, parameters),
93503
+ ...unreclaimedLines(route, verdict),
93504
+ ...findingLines(route)
93505
+ ];
93534
93506
  }
93535
- async function jsFilesUnder(dir) {
93536
- const out = [];
93537
- let entries;
93538
- try {
93539
- entries = await readdir2(dir, { withFileTypes: true, recursive: true });
93540
- } catch {
93541
- return out;
93507
+ function skippedGuidanceLines(report) {
93508
+ const needConfig = report.routes.filter((route) => route.status === "skipped" && route.reason.includes("sample params")).map((route) => route.route);
93509
+ const skeleton = renderConfigSkeleton(needConfig, report.prerender);
93510
+ if (skeleton === null) {
93511
+ return [];
93542
93512
  }
93543
- for (const entry of entries) {
93544
- if (entry.isFile() && entry.name.endsWith(".js")) {
93545
- out.push(path4.join(entry.parentPath, entry.name));
93546
- }
93513
+ const editing = hasPlaceholders(skeleton) ? ` Replace each ${"REPLACE-ME"} with a value that exists in your app.` : ` The values come from paths your build already prerendered.`;
93514
+ return [
93515
+ "",
93516
+ `${needConfig.length} route(s) need sample params. Write this to next-leak.config.json in the app directory:${editing}`,
93517
+ ...skeleton.split("\n").map((line) => ` ${line}`)
93518
+ ];
93519
+ }
93520
+ function coverageLine(report) {
93521
+ const total = report.routes.length;
93522
+ const measured = report.routes.filter((route) => route.status === "measured").length;
93523
+ if (measured === total) {
93524
+ return `measured all ${total} discovered route(s)`;
93547
93525
  }
93548
- return out;
93526
+ const breakdown = ["skipped", "not-exercised", "failed"].map((status) => ({
93527
+ status,
93528
+ count: report.routes.filter((route) => route.status === status).length
93529
+ })).filter((entry) => entry.count > 0).map((entry) => `${entry.count} ${entry.status.replace("-", " ")}`).join(", ");
93530
+ return `measured ${measured} of ${total} discovered route(s) \u2014 ${breakdown}; the rest is not a verdict about your app`;
93549
93531
  }
93550
- async function extractModuleRegistry(nextServerDir) {
93551
- const registry = /* @__PURE__ */ new Map();
93552
- for (const file of await jsFilesUnder(nextServerDir)) {
93553
- let code;
93554
- let map;
93555
- try {
93556
- code = await readFile4(file, "utf8");
93557
- map = JSON.parse(await readFile4(`${file}.map`, "utf8"));
93558
- } catch {
93559
- continue;
93560
- }
93561
- for (const [id, source] of extractChunkModules(code, map)) {
93562
- if (!registry.has(id)) {
93563
- registry.set(id, source);
93564
- }
93565
- }
93532
+ function formatReport(report) {
93533
+ const lines = [`next-leak \u2014 ${report.appDir}`, ""];
93534
+ for (const route of report.routes) {
93535
+ lines.push(...routeLines(route, report.parameters));
93566
93536
  }
93567
- return registry;
93537
+ lines.push(...skippedGuidanceLines(report));
93538
+ const { minGrowthPerCycle, loadRequests, cycles, maxOldSpaceMb } = report.parameters;
93539
+ const resolvedCycles = [
93540
+ ...new Set(
93541
+ report.routes.flatMap(
93542
+ (route) => route.status === "measured" && route.resolvedWithCycles !== void 0 ? [route.resolvedWithCycles] : []
93543
+ )
93544
+ )
93545
+ ].sort((a, b) => a - b);
93546
+ const cyclesLabel = resolvedCycles.length === 0 ? `${cycles} cycles` : `${cycles} cycles (${resolvedCycles.join(", ")} where resolved)`;
93547
+ lines.push("", coverageLine(report));
93548
+ lines.push(
93549
+ `judged over ${cyclesLabel} \xD7 ${loadRequests} requests, growth gate ${(minGrowthPerCycle / 1024).toFixed(0)} KiB/cycle (${formatGrowth(minGrowthPerCycle / loadRequests * 1e3)}), heap cap ${maxOldSpaceMb} MB`,
93550
+ `snapshots and run.json: ${report.workDir}`,
93551
+ `report: ${report.bundle.htmlReport}`,
93552
+ ...report.bundle.issues.map((issue) => `issue draft (${issue.route}): ${issue.file}`)
93553
+ );
93554
+ const inconclusive = report.routes.filter(
93555
+ (route) => route.status === "measured" && effectiveVerdict(route) === "inconclusive"
93556
+ );
93557
+ if (inconclusive.length > 0) {
93558
+ const routeList = inconclusive.map((route) => route.route).join(",");
93559
+ const moreCycles = resolveCycles(report.parameters.cycles);
93560
+ lines.push(
93561
+ "",
93562
+ "hint: inconclusive means sustained sub-threshold growth \u2014 measure longer to resolve it:",
93563
+ ` next-leak ${report.appDir} --routes ${routeList} --cycles ${moreCycles}`
93564
+ );
93565
+ }
93566
+ return lines.join("\n");
93567
+ }
93568
+
93569
+ // src/environment.ts
93570
+ import { createRequire } from "module";
93571
+ import os from "os";
93572
+ var require2 = createRequire(import.meta.url);
93573
+ var { version: nextLeakVersion } = require2("../package.json");
93574
+ function captureEnvironment(nextVersion) {
93575
+ return {
93576
+ nodeVersion: process.version,
93577
+ platform: os.platform(),
93578
+ arch: os.arch(),
93579
+ cpuModel: os.cpus()[0]?.model ?? null,
93580
+ totalMemoryBytes: os.totalmem(),
93581
+ nextVersion,
93582
+ nextLeakVersion
93583
+ };
93568
93584
  }
93569
93585
 
93570
93586
  // src/load.ts
@@ -94583,6 +94599,20 @@ async function runMeasurement(options, deps = defaultDeps2) {
94583
94599
  }
94584
94600
 
94585
94601
  export {
94602
+ classifySource,
94603
+ attributeFinding,
94604
+ attributeDiff,
94605
+ summarizeBaseline,
94606
+ retainerChain,
94607
+ diffAgainstBaseline,
94608
+ SnapshotError,
94609
+ assertReadableSnapshot,
94610
+ diffSnapshotFiles,
94611
+ decodeVlqLine,
94612
+ decodeMappings,
94613
+ sourceIndexAt,
94614
+ extractChunkModules,
94615
+ extractModuleRegistry,
94586
94616
  appPathsManifestSchema,
94587
94617
  routesManifestSchema,
94588
94618
  discoverPagesRoutes,
@@ -94604,21 +94634,7 @@ export {
94604
94634
  renderConfigSkeleton,
94605
94635
  hasPlaceholders,
94606
94636
  formatReport,
94607
- classifySource,
94608
- attributeFinding,
94609
- attributeDiff,
94610
94637
  captureEnvironment,
94611
- summarizeBaseline,
94612
- retainerChain,
94613
- diffAgainstBaseline,
94614
- SnapshotError,
94615
- assertReadableSnapshot,
94616
- diffSnapshotFiles,
94617
- decodeVlqLine,
94618
- decodeMappings,
94619
- sourceIndexAt,
94620
- extractChunkModules,
94621
- extractModuleRegistry,
94622
94638
  LoadError,
94623
94639
  runLoadPhase,
94624
94640
  runRitual,