next-leak 0.6.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.
@@ -7,7 +7,7 @@ import {
7
7
  minGrowthFor,
8
8
  resolveCycles,
9
9
  warrantsIssueDraft
10
- } from "./chunk-22PBLCGO.js";
10
+ } from "./chunk-EJ26OBZI.js";
11
11
  import {
12
12
  assessPeakPressure,
13
13
  describePeakPressure
@@ -91969,30 +91969,543 @@ var require_autocannon = __commonJS({
91969
91969
  }
91970
91970
  });
91971
91971
 
91972
- // src/manifests.ts
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 {
91979
+ }
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 };
91989
+ }
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 };
91998
+ }
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
+ }
92022
+ }
92023
+ return null;
92024
+ }
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) {
92030
+ continue;
92031
+ }
92032
+ const candidate = classifySource(source);
92033
+ if (OWNER_PRIORITY[candidate.owner] < OWNER_PRIORITY[best.owner]) {
92034
+ best = candidate;
92035
+ }
92036
+ }
92037
+ if (best.owner === "unattributed") {
92038
+ return classifyByChain(finding.retainerChain ?? "") ?? UNATTRIBUTED;
92039
+ }
92040
+ return best;
92041
+ }
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") {
92049
+ continue;
92050
+ }
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);
92057
+ }
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 };
92066
+ }
92067
+
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
92078
+ };
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
92094
+ );
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
+ )}]`
92125
+ );
92126
+ }
92127
+ return { parts, nodes };
92128
+ }
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"
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
+ }
92150
+ }
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
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
+ }
92200
+ }
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);
92204
+ return {
92205
+ typeDeltas,
92206
+ grownNodes: grown.slice(0, resolved.maxFindings),
92207
+ newNodes: fresh.slice(0, resolved.maxFindings)
92208
+ };
92209
+ }
92210
+ var SnapshotError = class extends Error {
92211
+ constructor(message) {
92212
+ super(message);
92213
+ this.name = "SnapshotError";
92214
+ }
92215
+ };
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}`);
92226
+ }
92227
+ if (size === 0) {
92228
+ throw new SnapshotError(`heap snapshot is empty: ${file}`);
92229
+ }
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
+ );
92234
+ }
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();
92253
+ }
92254
+ }
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}`);
92277
+ }
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;
92285
+ }
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));
92294
+ }
92295
+ }
92296
+ return segments;
92297
+ }
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
+ }
92314
+ }
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;
92328
+ }
92329
+ found = entry.sourceIndex;
92330
+ }
92331
+ return found;
92332
+ }
92333
+
92334
+ // src/module-registry.ts
92335
+ import { readdir, readFile } from "fs/promises";
92336
+ import path from "path";
91973
92337
  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({
92338
+ var flatMapSchema = z.looseObject({
91981
92339
  version: z.number(),
91982
- basePath: z.string(),
91983
- staticRoutes: z.array(routeEntrySchema),
91984
- dynamicRoutes: z.array(routeEntrySchema)
92340
+ sources: z.array(z.string()),
92341
+ mappings: z.string()
91985
92342
  });
91986
- var prerenderManifestSchema = z.looseObject({
91987
- routes: z.record(
91988
- z.string(),
92343
+ var sectionedMapSchema = z.looseObject({
92344
+ version: z.number(),
92345
+ sections: z.array(
91989
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) };
92356
+ }
92357
+ const sectioned = sectionedMapSchema.safeParse(raw);
92358
+ if (!sectioned.success) {
92359
+ return null;
92360
+ }
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
+ }
92377
+ }
92378
+ for (const line of lines) {
92379
+ line?.entries.sort((a, b) => a.column - b.column);
92380
+ }
92381
+ for (let i = 0; i < lines.length; i += 1) {
92382
+ lines[i] ??= { entries: [] };
92383
+ }
92384
+ return { sources, lines };
92385
+ }
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);
92394
+ }
92395
+ return last;
92396
+ }
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
+ }
92413
+ }
92414
+ }
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
+ }
92434
+ }
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;
92440
+ }
92441
+ const map = normalizeSourceMap(rawMap);
92442
+ if (map === null) {
92443
+ return result;
92444
+ }
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;
92457
+ }
92458
+ for (const entry of entries) {
92459
+ if (entry.isFile() && entry.name.endsWith(".js")) {
92460
+ out.push(path.join(entry.parentPath, entry.name));
92461
+ }
92462
+ }
92463
+ return out;
92464
+ }
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 {
92474
+ continue;
92475
+ }
92476
+ for (const [id, source] of extractChunkModules(code, map)) {
92477
+ if (!registry.has(id)) {
92478
+ registry.set(id, source);
92479
+ }
92480
+ }
92481
+ }
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({
91990
92503
  // `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()
92504
+ initialRevalidateSeconds: z2.union([z2.number(), z2.literal(false)]).optional(),
92505
+ srcRoute: z2.string().nullable().optional()
91993
92506
  })
91994
92507
  ).optional(),
91995
- preview: z.looseObject({ previewModeId: z.string() }).optional()
92508
+ preview: z2.looseObject({ previewModeId: z2.string() }).optional()
91996
92509
  });
91997
92510
  var INTERNAL_PATHS = /* @__PURE__ */ new Set(["/_global-error", "/_not-found"]);
91998
92511
  var INTERCEPTING_SEGMENT = /^(\(\.{1,3}\))+/;
@@ -92055,8 +92568,8 @@ function discoverRoutes(appPaths) {
92055
92568
  }
92056
92569
 
92057
92570
  // src/target.ts
92058
- import { access, readFile } from "fs/promises";
92059
- import path from "path";
92571
+ import { access, readFile as readFile2 } from "fs/promises";
92572
+ import path2 from "path";
92060
92573
  var TargetError = class extends Error {
92061
92574
  code;
92062
92575
  constructor(code, message) {
@@ -92076,7 +92589,7 @@ async function exists(file) {
92076
92589
  async function readManifest(file, parse2) {
92077
92590
  let raw;
92078
92591
  try {
92079
- raw = JSON.parse(await readFile(file, "utf8"));
92592
+ raw = JSON.parse(await readFile2(file, "utf8"));
92080
92593
  } catch (cause) {
92081
92594
  throw new TargetError(
92082
92595
  "BAD_MANIFEST",
@@ -92093,14 +92606,14 @@ async function readManifest(file, parse2) {
92093
92606
  }
92094
92607
  }
92095
92608
  async function validateTarget(appDir) {
92096
- const nextDir = path.resolve(appDir, ".next");
92609
+ const nextDir = path2.resolve(appDir, ".next");
92097
92610
  if (!await exists(nextDir)) {
92098
92611
  throw new TargetError(
92099
92612
  "NO_BUILD",
92100
92613
  `No .next directory in ${appDir}. Run "next build" first.`
92101
92614
  );
92102
92615
  }
92103
- const standaloneServer = path.join(nextDir, "standalone", "server.js");
92616
+ const standaloneServer = path2.join(nextDir, "standalone", "server.js");
92104
92617
  if (!await exists(standaloneServer)) {
92105
92618
  throw new TargetError(
92106
92619
  "NO_STANDALONE",
@@ -92116,8 +92629,8 @@ then rebuild: next build
92116
92629
  This only changes how the build is packaged \u2014 not how your app behaves.`
92117
92630
  );
92118
92631
  }
92119
- const appPathsFile = path.join(nextDir, "server", "app-paths-manifest.json");
92120
- const pagesFile = path.join(nextDir, "server", "pages-manifest.json");
92632
+ const appPathsFile = path2.join(nextDir, "server", "app-paths-manifest.json");
92633
+ const pagesFile = path2.join(nextDir, "server", "pages-manifest.json");
92121
92634
  const appPaths = await exists(appPathsFile) ? await readManifest(appPathsFile, (raw) => appPathsManifestSchema.parse(raw)) : {};
92122
92635
  const pages = await exists(pagesFile) ? await readManifest(pagesFile, (raw) => pagesManifestSchema.parse(raw)) : {};
92123
92636
  if (Object.keys(appPaths).length === 0 && Object.keys(pages).length === 0) {
@@ -92126,12 +92639,12 @@ This only changes how the build is packaged \u2014 not how your app behaves.`
92126
92639
  `No routes found in ${nextDir}/server: neither app-paths-manifest.json nor pages-manifest.json is present. Rebuild the app with "next build".`
92127
92640
  );
92128
92641
  }
92129
- const routesFile = path.join(nextDir, "routes-manifest.json");
92642
+ const routesFile = path2.join(nextDir, "routes-manifest.json");
92130
92643
  const routes = await exists(routesFile) ? await readManifest(routesFile, (raw) => routesManifestSchema.parse(raw)) : void 0;
92131
- const prerenderFile = path.join(nextDir, "prerender-manifest.json");
92644
+ const prerenderFile = path2.join(nextDir, "prerender-manifest.json");
92132
92645
  const prerender = await exists(prerenderFile) ? await readManifest(prerenderFile, (raw) => prerenderManifestSchema.parse(raw)) : void 0;
92133
92646
  return {
92134
- appDir: path.resolve(appDir),
92647
+ appDir: path2.resolve(appDir),
92135
92648
  standaloneServer,
92136
92649
  appPaths,
92137
92650
  pages,
@@ -92142,11 +92655,11 @@ This only changes how the build is packaged \u2014 not how your app behaves.`
92142
92655
 
92143
92656
  // src/launcher.ts
92144
92657
  import { spawn } from "child_process";
92145
- import { readdir, readFile as readFile2 } from "fs/promises";
92146
- import path2 from "path";
92658
+ import { readdir as readdir2, readFile as readFile3 } from "fs/promises";
92659
+ import path3 from "path";
92147
92660
  import { pathToFileURL } from "url";
92148
- import { z as z2 } from "zod";
92149
- var controlFileSchema = z2.object({ port: z2.number(), pid: z2.number() });
92661
+ import { z as z3 } from "zod";
92662
+ var controlFileSchema = z3.object({ port: z3.number(), pid: z3.number() });
92150
92663
  var DEFAULT_MAX_OLD_SPACE_MB = 512;
92151
92664
  var LaunchError = class extends Error {
92152
92665
  constructor(message) {
@@ -92215,7 +92728,7 @@ async function launchInstrumented(options) {
92215
92728
  options.serverPath
92216
92729
  ],
92217
92730
  {
92218
- cwd: path2.dirname(options.serverPath),
92731
+ cwd: path3.dirname(options.serverPath),
92219
92732
  env: {
92220
92733
  ...process.env,
92221
92734
  ...options.env,
@@ -92255,7 +92768,7 @@ ${stderrTailBuffer}`;
92255
92768
  async () => {
92256
92769
  let entries;
92257
92770
  try {
92258
- entries = (await readdir(options.workDir)).filter(
92771
+ entries = (await readdir2(options.workDir)).filter(
92259
92772
  (entry) => entry.startsWith("control-") && entry.endsWith(".json")
92260
92773
  );
92261
92774
  } catch {
@@ -92264,7 +92777,7 @@ ${stderrTailBuffer}`;
92264
92777
  for (const entry of entries) {
92265
92778
  try {
92266
92779
  const parsed = controlFileSchema.parse(
92267
- JSON.parse(await readFile2(path2.join(options.workDir, entry), "utf8"))
92780
+ JSON.parse(await readFile3(path3.join(options.workDir, entry), "utf8"))
92268
92781
  );
92269
92782
  const response = await fetch(`http://127.0.0.1:${parsed.port}/gc`);
92270
92783
  if (response.ok) {
@@ -92366,6 +92879,11 @@ var FLAGS = [
92366
92879
  help: "Fast preset: 2000 requests x 4 cycles, 8s idle \u2014 same cycle count as the default, less traffic per cycle"
92367
92880
  },
92368
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
+ },
92369
92887
  {
92370
92888
  flag: "--no-resolve",
92371
92889
  value: "none",
@@ -92480,6 +92998,9 @@ function applyFlag(spec, value, options) {
92480
92998
  case "--diff-all":
92481
92999
  options.diffAll = true;
92482
93000
  return FLAG_OK;
93001
+ case "--attribute":
93002
+ options.attributeBuild = true;
93003
+ return FLAG_OK;
92483
93004
  case "--write-config":
92484
93005
  options.writeConfig = true;
92485
93006
  return FLAG_OK;
@@ -92541,6 +93062,7 @@ function parseCliArgs(argv) {
92541
93062
  quick: false,
92542
93063
  noResolve: false,
92543
93064
  diffAll: false,
93065
+ attributeBuild: false,
92544
93066
  writeConfig: false,
92545
93067
  output: null
92546
93068
  };
@@ -92570,8 +93092,15 @@ function parseCliArgs(argv) {
92570
93092
  message: `option "${misplaced}" shapes HTTP load and does not apply to "next-leak build" \u2014 see --help`
92571
93093
  };
92572
93094
  }
92573
- return { kind: "build", options: { appDir: options.appDir, output: options.output } };
92574
- }
93095
+ return {
93096
+ kind: "build",
93097
+ options: {
93098
+ appDir: options.appDir,
93099
+ output: options.output,
93100
+ attributeBuild: options.attributeBuild
93101
+ }
93102
+ };
93103
+ }
92575
93104
  return { kind: "run", options };
92576
93105
  }
92577
93106
 
@@ -92593,25 +93122,25 @@ function checkRuntime(facts = currentFacts()) {
92593
93122
  }
92594
93123
 
92595
93124
  // 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(
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(
92600
93129
  (value) => !(value.includes("{n}") && /\{n%\d+\}/.test(value)),
92601
93130
  { message: 'a sample value cannot carry both "{n}" and "{n%N}" \u2014 pick one' }
92602
93131
  );
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(),
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(),
92606
93135
  /**
92607
93136
  * Headers sent with every request. Real traffic is not header-less:
92608
93137
  * compression (`Accept-Encoding`), sessions and auth change which code
92609
93138
  * paths run, and some leaks only exist on those paths (zlib, per-session
92610
93139
  * caches). Measuring without them measures a different app.
92611
93140
  */
92612
- headers: z3.record(z3.string(), z3.string()).optional(),
93141
+ headers: z4.record(z4.string(), z4.string()).optional(),
92613
93142
  /** Query string appended per route template, e.g. `"weightKb=2048"`. */
92614
- query: z3.record(z3.string(), z3.string()).optional(),
93143
+ query: z4.record(z4.string(), z4.string()).optional(),
92615
93144
  /**
92616
93145
  * Simulates clients that give up before the response arrives (closed
92617
93146
  * tabs, load-balancer timeouts, bots). Several real leaks only exist on
@@ -92624,8 +93153,28 @@ var routeConfigSchema = z3.object({
92624
93153
  * window, so a request-relative clock cuts before the stream starts and
92625
93154
  * tests the wrong path entirely.
92626
93155
  */
92627
- abandonAfterMs: z3.number().int().positive().optional()
92628
- }).strict();
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
+ });
92629
93178
  var RouteConfigError = class extends Error {
92630
93179
  constructor(message) {
92631
93180
  super(message);
@@ -92634,10 +93183,10 @@ var RouteConfigError = class extends Error {
92634
93183
  };
92635
93184
  var ROUTE_CONFIG_FILE = "next-leak.config.json";
92636
93185
  async function loadRouteConfig(appDir) {
92637
- const file = path3.join(appDir, ROUTE_CONFIG_FILE);
93186
+ const file = path4.join(appDir, ROUTE_CONFIG_FILE);
92638
93187
  let raw;
92639
93188
  try {
92640
- raw = await readFile3(file, "utf8");
93189
+ raw = await readFile4(file, "utf8");
92641
93190
  } catch {
92642
93191
  return {};
92643
93192
  }
@@ -92737,805 +93286,301 @@ function renderConfigSkeleton(routeTemplates, manifest = void 0) {
92737
93286
  for (const template of routeTemplates) {
92738
93287
  const segments = dynamicSegmentsOf(template);
92739
93288
  if (segments.length === 0) {
92740
- continue;
92741
- }
92742
- const sampled = sampleValuesFromManifest(manifest, template);
92743
- const values = {};
92744
- for (const segment of segments) {
92745
- values[segment.name] = sampled?.[segment.name] ?? PLACEHOLDER;
92746
- }
92747
- routes[template] = values;
92748
- }
92749
- return Object.keys(routes).length === 0 ? null : JSON.stringify({ routes }, null, 2);
92750
- }
92751
- function hasPlaceholders(skeleton) {
92752
- return skeleton.includes(PLACEHOLDER);
92753
- }
92754
-
92755
- // src/unreclaimed-retention.ts
92756
- var MB = 1024 * 1024;
92757
- var CLASSES = ["heap", "external", "arrayBuffers"];
92758
- var MIN_GAP_BYTES = 2 * MB;
92759
- var MIN_GAP_RATIO = 0.35;
92760
- var MIN_RETAINED_BYTES = MB;
92761
- var read = (sample, memoryClass) => memoryClass === "heap" ? sample.heapUsed : memoryClass === "external" ? sample.external : sample.arrayBuffers;
92762
- function largestGap(values) {
92763
- return values.reduce((highest, value) => Math.max(highest, value), 0);
92764
- }
92765
- function gapsFor(input, memoryClass) {
92766
- const gaps = [];
92767
- for (const [index, held] of input.unreclaimedSamples.entries()) {
92768
- const afterGc = input.memorySamples[index + 1];
92769
- if (afterGc === void 0) {
92770
- break;
92771
- }
92772
- gaps.push(read(held, memoryClass) - read(afterGc, memoryClass));
92773
- }
92774
- return gaps;
92775
- }
92776
- function assessUnreclaimedRetention(input) {
92777
- if (input.verdict === "leak" && input.verdictIsWellSupported || input.unreclaimedSamples.length === 0) {
92778
- return null;
92779
- }
92780
- let worst = null;
92781
- for (const memoryClass of CLASSES) {
92782
- const gaps = gapsFor(input, memoryClass);
92783
- if (gaps.length === 0) {
92784
- continue;
92785
- }
92786
- const largestGapBytes = largestGap(gaps);
92787
- if (largestGapBytes < MIN_GAP_BYTES) {
92788
- continue;
92789
- }
92790
- const retainedBytes = largestGap(
92791
- input.memorySamples.slice(1).map((sample) => read(sample, memoryClass))
92792
- );
92793
- const ratio = largestGapBytes / Math.max(retainedBytes, MIN_RETAINED_BYTES);
92794
- if (ratio < MIN_GAP_RATIO) {
92795
- continue;
92796
- }
92797
- if (worst === null || ratio >= worst.ratio) {
92798
- worst = { class: memoryClass, largestGapBytes, retainedBytes, ratio };
92799
- }
92800
- }
92801
- return worst;
92802
- }
92803
- var mb = (bytes) => `${(bytes / MB).toFixed(2)} MB`;
92804
- var CLASS_LABEL = {
92805
- heap: "heap",
92806
- external: "external memory",
92807
- arrayBuffers: "arrayBuffers"
92808
- };
92809
- function describeUnreclaimedRetention(retention) {
92810
- 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`;
92811
- }
92812
-
92813
- // src/report.ts
92814
- var MB2 = 1024 * 1024;
92815
- var formatMb = (bytes) => `${(bytes / MB2).toFixed(1)} MB`;
92816
- var formatGrowth = (bytes) => {
92817
- const sign = bytes >= 0 ? "+" : "";
92818
- return `${sign}${(bytes / MB2).toFixed(2)} MB/1000 req`;
92819
- };
92820
- var VERDICT_ICON = { leak: "\u2716", stable: "\u2714", inconclusive: "?" };
92821
- var RSS_MIN_GROWTH_PER_CYCLE = 16 * MB2;
92822
- var RSS_MIN_TOTAL_GROWTH = 64 * MB2;
92823
- function hasSustainedRssGrowth(memorySamples) {
92824
- const rss = memorySamples.map((sample) => sample.rss);
92825
- const first = rss[1];
92826
- const last = rss.at(-1);
92827
- if (first === void 0 || last === void 0 || rss.length < 4) {
92828
- return false;
92829
- }
92830
- return classifyTrend(rss, { minGrowthPerCycle: RSS_MIN_GROWTH_PER_CYCLE }).verdict === "leak" && last - first >= RSS_MIN_TOTAL_GROWTH;
92831
- }
92832
- function ownerLabel(attribution) {
92833
- switch (attribution.owner) {
92834
- case "app":
92835
- return `${attribution.source ?? "your code"} (your code)`;
92836
- case "dependency":
92837
- return `${attribution.packageName ?? "a dependency"} (dependency)`;
92838
- case "framework":
92839
- return attribution.packageName === null ? "Next.js/runtime internals" : `${attribution.packageName} (framework)`;
92840
- case "unattributed":
92841
- return null;
92842
- }
92843
- }
92844
- function revalidationLines(route) {
92845
- return route.revalidatedEverySeconds === void 0 ? [] : [
92846
- ` driven through ISR revalidation (revalidates every ${route.revalidatedEverySeconds}s; without it the load would serve the cache)`
92847
- ];
92848
- }
92849
- function confidenceLines(route) {
92850
- const lines = [];
92851
- if (route.confidence.supersededVerdict !== void 0) {
92852
- lines.push(
92853
- ` measured ${route.trend.verdict}, withdrawn: the run did not observe what that verdict needs`
92854
- );
92855
- }
92856
- for (const warning of route.confidence.warnings) {
92857
- lines.push(` \u26A0 low confidence: ${warning.detail}`);
92858
- }
92859
- return lines;
92860
- }
92861
- function memorySourceLines(route, verdict) {
92862
- const lines = [];
92863
- if (route.trend.source === "external" && verdict !== "stable") {
92864
- const externalCurve = route.memorySamples.map((sample) => formatMb(sample.external)).join(" \u2192 ");
92865
- lines.push(
92866
- ` verdict comes from EXTERNAL memory (buffers, streams, fetch bodies), not the JS heap: external ${externalCurve}`
92867
- );
92868
- }
92869
- if (verdict === "stable" && hasSustainedRssGrowth(route.memorySamples)) {
92870
- const rssCurve = route.memorySamples.map((sample) => formatMb(sample.rss)).join(" \u2192 ");
92871
- lines.push(
92872
- ` note: heap is flat but RSS grows ${formatGrowth(route.rssPer1000Requests)} \u2014 not a JS-heap leak (allocator, external buffers or fragmentation): RSS ${rssCurve}`
92873
- );
92874
- }
92875
- return lines;
92876
- }
92877
- function diffFindingLines(route) {
92878
- if (route.diff === null) {
92879
- return [];
92880
- }
92881
- const findings = [...route.diff.grownNodes, ...route.diff.newNodes];
92882
- return findings.slice(0, 3).map((finding, index) => {
92883
- const size = formatMb(finding.retainedBytes);
92884
- const attribution = route.attribution?.findings[index];
92885
- const owner = attribution === void 0 ? null : ownerLabel(attribution);
92886
- const detail = owner ?? (finding.retainerChain === "" ? "" : finding.retainerChain);
92887
- return ` \u21B3 ${finding.kind} [${finding.nodeType}] ${finding.name} ${size}${detail === "" ? "" : ` \u2014 ${detail}`}`;
92888
- });
92889
- }
92890
- function findingLines(route) {
92891
- const lines = [];
92892
- const culprit = route.attribution === null ? null : ownerLabel(route.attribution.route);
92893
- if (culprit !== null) {
92894
- lines.push(` culprit: ${culprit}`);
92895
- }
92896
- lines.push(...diffFindingLines(route));
92897
- for (const signature of route.signatures) {
92898
- const flag = signature.historical ? " (historical)" : "";
92899
- lines.push(` \u26A0 known cause${flag}: ${signature.title} \u2014 ${signature.issue}`);
92900
- }
92901
- return lines;
92902
- }
92903
- function peakPressureLines(route, parameters) {
92904
- const retained = route.memorySamples.at(-1)?.heapUsed;
92905
- if (retained === void 0) {
92906
- return [];
92907
- }
92908
- const pressure = assessPeakPressure({
92909
- peaks: route.peaks,
92910
- retainedHeapBytes: retained,
92911
- maxOldSpaceMb: parameters.maxOldSpaceMb
92912
- });
92913
- return pressure === null ? [] : [` \u25B2 peak pressure: ${describePeakPressure(pressure)}`];
92914
- }
92915
- function unreclaimedLines(route, verdict) {
92916
- const retention = assessUnreclaimedRetention({
92917
- unreclaimedSamples: route.unreclaimedSamples,
92918
- memorySamples: route.memorySamples,
92919
- verdict,
92920
- verdictIsWellSupported: warrantsIssueDraft(route)
92921
- });
92922
- return retention === null ? [] : [` \u25B2 unreclaimed: ${describeUnreclaimedRetention(retention)}`];
92923
- }
92924
- function routeLines(route, parameters) {
92925
- if (route.status === "skipped") {
92926
- return [` \u2013 ${route.route} skipped: ${route.reason}`];
92927
- }
92928
- if (route.status === "failed") {
92929
- return [` \u2716 ${route.route} failed: ${route.reason}`];
92930
- }
92931
- if (route.status === "not-exercised") {
92932
- return [` \u2013 ${route.route} not exercised: ${route.reason}`];
92933
- }
92934
- const verdict = effectiveVerdict(route);
92935
- const curve = route.samples.map(formatMb).join(" \u2192 ");
92936
- const resolved = route.resolvedWithCycles === void 0 ? "" : ` (resolved at ${route.resolvedWithCycles} cycles)`;
92937
- return [
92938
- ` ${VERDICT_ICON[verdict]} ${route.route} ${verdict} (${formatGrowth(
92939
- route.growthPer1000Requests
92940
- )}) heap ${curve}${resolved}`,
92941
- ...revalidationLines(route),
92942
- ...confidenceLines(route),
92943
- ...memorySourceLines(route, verdict),
92944
- ...peakPressureLines(route, parameters),
92945
- ...unreclaimedLines(route, verdict),
92946
- ...findingLines(route)
92947
- ];
92948
- }
92949
- function skippedGuidanceLines(report) {
92950
- const needConfig = report.routes.filter((route) => route.status === "skipped" && route.reason.includes("sample params")).map((route) => route.route);
92951
- const skeleton = renderConfigSkeleton(needConfig, report.prerender);
92952
- if (skeleton === null) {
92953
- return [];
92954
- }
92955
- 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.`;
92956
- return [
92957
- "",
92958
- `${needConfig.length} route(s) need sample params. Write this to next-leak.config.json in the app directory:${editing}`,
92959
- ...skeleton.split("\n").map((line) => ` ${line}`)
92960
- ];
92961
- }
92962
- function coverageLine(report) {
92963
- const total = report.routes.length;
92964
- const measured = report.routes.filter((route) => route.status === "measured").length;
92965
- if (measured === total) {
92966
- return `measured all ${total} discovered route(s)`;
92967
- }
92968
- const breakdown = ["skipped", "not-exercised", "failed"].map((status) => ({
92969
- status,
92970
- count: report.routes.filter((route) => route.status === status).length
92971
- })).filter((entry) => entry.count > 0).map((entry) => `${entry.count} ${entry.status.replace("-", " ")}`).join(", ");
92972
- return `measured ${measured} of ${total} discovered route(s) \u2014 ${breakdown}; the rest is not a verdict about your app`;
92973
- }
92974
- function formatReport(report) {
92975
- const lines = [`next-leak \u2014 ${report.appDir}`, ""];
92976
- for (const route of report.routes) {
92977
- lines.push(...routeLines(route, report.parameters));
92978
- }
92979
- lines.push(...skippedGuidanceLines(report));
92980
- const { minGrowthPerCycle, loadRequests, cycles, maxOldSpaceMb } = report.parameters;
92981
- const resolvedCycles = [
92982
- ...new Set(
92983
- report.routes.flatMap(
92984
- (route) => route.status === "measured" && route.resolvedWithCycles !== void 0 ? [route.resolvedWithCycles] : []
92985
- )
92986
- )
92987
- ].sort((a, b) => a - b);
92988
- const cyclesLabel = resolvedCycles.length === 0 ? `${cycles} cycles` : `${cycles} cycles (${resolvedCycles.join(", ")} where resolved)`;
92989
- lines.push("", coverageLine(report));
92990
- lines.push(
92991
- `judged over ${cyclesLabel} \xD7 ${loadRequests} requests, growth gate ${(minGrowthPerCycle / 1024).toFixed(0)} KiB/cycle (${formatGrowth(minGrowthPerCycle / loadRequests * 1e3)}), heap cap ${maxOldSpaceMb} MB`,
92992
- `snapshots and run.json: ${report.workDir}`,
92993
- `report: ${report.bundle.htmlReport}`,
92994
- ...report.bundle.issues.map((issue) => `issue draft (${issue.route}): ${issue.file}`)
92995
- );
92996
- const inconclusive = report.routes.filter(
92997
- (route) => route.status === "measured" && effectiveVerdict(route) === "inconclusive"
92998
- );
92999
- if (inconclusive.length > 0) {
93000
- const routeList = inconclusive.map((route) => route.route).join(",");
93001
- const moreCycles = resolveCycles(report.parameters.cycles);
93002
- lines.push(
93003
- "",
93004
- "hint: inconclusive means sustained sub-threshold growth \u2014 measure longer to resolve it:",
93005
- ` next-leak ${report.appDir} --routes ${routeList} --cycles ${moreCycles}`
93006
- );
93007
- }
93008
- return lines.join("\n");
93009
- }
93010
-
93011
- // src/attribution.ts
93012
- var UNATTRIBUTED = { owner: "unattributed", source: null, packageName: null };
93013
- function classifySource(rawSource) {
93014
- let source = rawSource.replace(/^turbopack:\/+(?=\[)/, "");
93015
- try {
93016
- source = decodeURIComponent(source);
93017
- } catch {
93018
- }
93019
- const nodeModulesSplit = source.split("/node_modules/");
93020
- if (nodeModulesSplit.length > 1) {
93021
- const tail = nodeModulesSplit.at(-1) ?? "";
93022
- const segments = tail.split("/");
93023
- const packageName = (segments[0]?.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0]) ?? null;
93024
- if (packageName === "next" || packageName === "react" || packageName === "react-dom") {
93025
- return { owner: "framework", source: null, packageName };
93026
- }
93027
- return { owner: "dependency", source: null, packageName };
93028
- }
93029
- if (source.startsWith("[project]/")) {
93030
- return { owner: "app", source: source.slice("[project]/".length), packageName: null };
93031
- }
93032
- const relative = source.replace(/^(\.\.\/)+/, "");
93033
- if (relative !== source && !relative.startsWith("[")) {
93034
- return { owner: "app", source: relative, packageName: null };
93035
- }
93036
- return { owner: "framework", source: null, packageName: null };
93037
- }
93038
- var OWNER_PRIORITY = {
93039
- app: 0,
93040
- dependency: 1,
93041
- framework: 2,
93042
- unattributed: 3
93043
- };
93044
- var FRAMEWORK_CHAIN_MARKERS = [
93045
- ["fsChecker", "route filesystem checker"],
93046
- ["getDynamicRoutes", "dynamic route matcher"],
93047
- ["NextNodeServer", "Next server"],
93048
- ["AppPageRouteModule", "App Router page module"],
93049
- ["AppRouteRouteModule", "App Router route handler module"],
93050
- ["incrementalCache", "incremental cache"],
93051
- ["IncrementalCache", "incremental cache"],
93052
- ["ReactServer", "React Server Components renderer"],
93053
- ["loadManifests", "manifest loader"],
93054
- ["renderToHTML", "HTML renderer"]
93055
- ];
93056
- function classifyByChain(retainerChain2) {
93057
- for (const [marker, component] of FRAMEWORK_CHAIN_MARKERS) {
93058
- if (retainerChain2.includes(marker)) {
93059
- return { owner: "framework", source: null, packageName: `next (${component})` };
93060
- }
93061
- }
93062
- return null;
93063
- }
93064
- function attributeFinding(finding, registry) {
93065
- let best = UNATTRIBUTED;
93066
- for (const id of finding.moduleIds) {
93067
- const source = registry.get(id);
93068
- if (source === void 0) {
93069
- continue;
93070
- }
93071
- const candidate = classifySource(source);
93072
- if (OWNER_PRIORITY[candidate.owner] < OWNER_PRIORITY[best.owner]) {
93073
- best = candidate;
93074
- }
93075
- }
93076
- if (best.owner === "unattributed") {
93077
- return classifyByChain(finding.retainerChain ?? "") ?? UNATTRIBUTED;
93078
- }
93079
- return best;
93080
- }
93081
- function attributeDiff(diff, registry) {
93082
- const all = [...diff.grownNodes, ...diff.newNodes];
93083
- const findings = all.map((finding) => attributeFinding(finding, registry));
93084
- const byGroup = /* @__PURE__ */ new Map();
93085
- let attributedBytes = 0;
93086
- for (const [index, attribution] of findings.entries()) {
93087
- if (attribution.owner === "unattributed") {
93088
- continue;
93089
- }
93090
- const bytes = all[index]?.retainedBytes ?? 0;
93091
- attributedBytes += bytes;
93092
- const key = `${attribution.owner}|${attribution.source ?? ""}|${attribution.packageName ?? ""}`;
93093
- const group = byGroup.get(key) ?? { attribution, bytes: 0 };
93094
- group.bytes += bytes;
93095
- byGroup.set(key, group);
93096
- }
93097
- let winner = null;
93098
- for (const group of byGroup.values()) {
93099
- if (winner === null || group.bytes > winner.bytes) {
93100
- winner = group;
93101
- }
93102
- }
93103
- const route = winner === null || attributedBytes === 0 ? { ...UNATTRIBUTED, dominance: 0 } : { ...winner.attribution, dominance: winner.bytes / attributedBytes };
93104
- return { findings, route };
93105
- }
93106
-
93107
- // src/environment.ts
93108
- import { createRequire } from "module";
93109
- import os from "os";
93110
- var require2 = createRequire(import.meta.url);
93111
- var { version: nextLeakVersion } = require2("../package.json");
93112
- function captureEnvironment(nextVersion) {
93113
- return {
93114
- nodeVersion: process.version,
93115
- platform: os.platform(),
93116
- arch: os.arch(),
93117
- cpuModel: os.cpus()[0]?.model ?? null,
93118
- totalMemoryBytes: os.totalmem(),
93119
- nextVersion,
93120
- nextLeakVersion
93121
- };
93122
- }
93123
-
93124
- // src/heap-diff.ts
93125
- var import_core = __toESM(require_dist(), 1);
93126
- var import_heap_analysis = __toESM(require_dist4(), 1);
93127
- var DEFAULTS = {
93128
- minTypeDeltaBytes: 20 * 1024,
93129
- grownThresholdBytes: 100 * 1024,
93130
- newThresholdBytes: 2 * 1024,
93131
- bigRetainedFloorBytes: 50 * 1024,
93132
- maxFindings: 10,
93133
- chainDepth: 7
93134
- };
93135
- function summarizeBaseline(heap, options = {}) {
93136
- const resolved = { ...DEFAULTS, ...options };
93137
- const summary = {
93138
- nodeIds: /* @__PURE__ */ new Set(),
93139
- bigRetained: /* @__PURE__ */ new Map(),
93140
- typeSelfSizes: /* @__PURE__ */ new Map()
93141
- };
93142
- heap.nodes.forEach((node) => {
93143
- summary.nodeIds.add(node.id);
93144
- if (node.retainedSize >= resolved.bigRetainedFloorBytes) {
93145
- summary.bigRetained.set(node.id, node.retainedSize);
93146
- }
93147
- summary.typeSelfSizes.set(
93148
- node.type,
93149
- (summary.typeSelfSizes.get(node.type) ?? 0) + node.self_size
93150
- );
93151
- });
93152
- return summary;
93153
- }
93154
- function truncateLabel(value, max2 = 60) {
93155
- return value.replaceAll("\n", " ").slice(0, max2);
93156
- }
93157
- function walkChain(node, depth) {
93158
- const parts = [];
93159
- const nodes = [node];
93160
- let current = node;
93161
- const seen = /* @__PURE__ */ new Set([node.id]);
93162
- for (let i = 0; i < depth; i += 1) {
93163
- const referrers = current.referrers;
93164
- if (referrers.length === 0) {
93165
- break;
93166
- }
93167
- const edge = referrers.find(
93168
- (candidate) => !seen.has(candidate.fromNode.id) && candidate.fromNode.type !== "synthetic" && candidate.type !== "weak"
93169
- ) ?? referrers[0];
93170
- if (edge === void 0 || seen.has(edge.fromNode.id)) {
93171
- break;
93172
- }
93173
- current = edge.fromNode;
93174
- seen.add(current.id);
93175
- nodes.push(current);
93176
- parts.push(
93177
- `${truncateLabel(current.name)}#${current.type}[.${truncateLabel(
93178
- String(edge.name_or_index),
93179
- 40
93180
- )}]`
93181
- );
93182
- }
93183
- return { parts, nodes };
93184
- }
93185
- function retainerChain(node, depth) {
93186
- return walkChain(node, depth).parts.join(" <- ");
93187
- }
93188
- function collectModuleIds(chainNodes) {
93189
- const ids = [];
93190
- for (const node of chainNodes) {
93191
- const isModuleInstance = (node.references ?? []).some(
93192
- (edge) => edge.name_or_index === "namespaceObject" || edge.name_or_index === "exports"
93193
- );
93194
- if (!isModuleInstance) {
93195
- continue;
93196
- }
93197
- for (const edge of node.referrers) {
93198
- if (edge.type !== "element") {
93199
- continue;
93200
- }
93201
- const id = Number(edge.name_or_index);
93202
- if (Number.isInteger(id) && !ids.includes(id)) {
93203
- ids.push(id);
93204
- }
93205
- }
93206
- }
93207
- return ids;
93208
- }
93209
- function diffAgainstBaseline(baseline, after, options = {}) {
93210
- const resolved = { ...DEFAULTS, ...options };
93211
- const afterTypeSelfSizes = /* @__PURE__ */ new Map();
93212
- const grown = [];
93213
- const fresh = [];
93214
- after.nodes.forEach((node) => {
93215
- afterTypeSelfSizes.set(
93216
- node.type,
93217
- (afterTypeSelfSizes.get(node.type) ?? 0) + node.self_size
93218
- );
93219
- if (node.type === "synthetic") {
93220
- return;
93221
- }
93222
- if (!baseline.nodeIds.has(node.id)) {
93223
- if (node.retainedSize >= resolved.newThresholdBytes) {
93224
- const chain = walkChain(node, resolved.chainDepth);
93225
- fresh.push({
93226
- kind: "new",
93227
- nodeType: node.type,
93228
- name: truncateLabel(node.name),
93229
- retainedBytes: node.retainedSize,
93230
- retainerChain: chain.parts.join(" <- "),
93231
- moduleIds: collectModuleIds(chain.nodes)
93232
- });
93233
- }
93234
- return;
93235
- }
93236
- const before = baseline.bigRetained.get(node.id);
93237
- if (before !== void 0 && node.retainedSize - before >= resolved.grownThresholdBytes) {
93238
- const chain = walkChain(node, resolved.chainDepth);
93239
- grown.push({
93240
- kind: "grown",
93241
- nodeType: node.type,
93242
- name: truncateLabel(node.name),
93243
- retainedBytes: node.retainedSize - before,
93244
- retainerChain: chain.parts.join(" <- "),
93245
- moduleIds: collectModuleIds(chain.nodes)
93246
- });
93247
- }
93248
- });
93249
- const typeDeltas = [];
93250
- const allTypes = /* @__PURE__ */ new Set([...baseline.typeSelfSizes.keys(), ...afterTypeSelfSizes.keys()]);
93251
- for (const type of allTypes) {
93252
- const delta = (afterTypeSelfSizes.get(type) ?? 0) - (baseline.typeSelfSizes.get(type) ?? 0);
93253
- if (Math.abs(delta) >= resolved.minTypeDeltaBytes) {
93254
- typeDeltas.push({ type, deltaBytes: delta });
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;
93255
93295
  }
93296
+ routes[template] = values;
93256
93297
  }
93257
- typeDeltas.sort((a, b) => b.deltaBytes - a.deltaBytes);
93258
- grown.sort((a, b) => b.retainedBytes - a.retainedBytes);
93259
- fresh.sort((a, b) => b.retainedBytes - a.retainedBytes);
93260
- return {
93261
- typeDeltas,
93262
- grownNodes: grown.slice(0, resolved.maxFindings),
93263
- newNodes: fresh.slice(0, resolved.maxFindings)
93264
- };
93298
+ return Object.keys(routes).length === 0 ? null : JSON.stringify({ routes }, null, 2);
93265
93299
  }
93266
- var SnapshotError = class extends Error {
93267
- constructor(message) {
93268
- super(message);
93269
- this.name = "SnapshotError";
93270
- }
93271
- };
93272
- var SNAPSHOT_HEAD = '{"snapshot"';
93273
- var PROBE_BYTES = 512;
93274
- async function assertReadableSnapshot(file) {
93275
- const { open, stat } = await import("fs/promises");
93276
- const { constants } = await import("buffer");
93277
- let size;
93278
- try {
93279
- size = (await stat(file)).size;
93280
- } catch {
93281
- throw new SnapshotError(`heap snapshot is missing: ${file}`);
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;
93320
+ }
93321
+ gaps.push(read(held, memoryClass) - read(afterGc, memoryClass));
93282
93322
  }
93283
- if (size === 0) {
93284
- throw new SnapshotError(`heap snapshot is empty: ${file}`);
93323
+ return gaps;
93324
+ }
93325
+ function assessUnreclaimedRetention(input) {
93326
+ if (input.verdict === "leak" && input.verdictIsWellSupported || input.unreclaimedSamples.length === 0) {
93327
+ return null;
93285
93328
  }
93286
- if (size > constants.MAX_STRING_LENGTH) {
93287
- throw new SnapshotError(
93288
- `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`
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))
93289
93341
  );
93290
- }
93291
- const handle = await open(file, "r");
93292
- try {
93293
- const head = Buffer.alloc(Math.min(PROBE_BYTES, size));
93294
- await handle.read(head, 0, head.length, 0);
93295
- if (!head.toString("utf8").trimStart().startsWith(SNAPSHOT_HEAD)) {
93296
- throw new SnapshotError(
93297
- `not a V8 heap snapshot (missing "snapshot" header): ${file}`
93298
- );
93342
+ const ratio = largestGapBytes / Math.max(retainedBytes, MIN_RETAINED_BYTES);
93343
+ if (ratio < MIN_GAP_RATIO) {
93344
+ continue;
93299
93345
  }
93300
- const tail = Buffer.alloc(Math.min(PROBE_BYTES, size));
93301
- await handle.read(tail, 0, tail.length, size - tail.length);
93302
- if (!tail.toString("utf8").trimEnd().endsWith("}")) {
93303
- throw new SnapshotError(
93304
- `heap snapshot looks truncated (JSON does not close): ${file} \u2014 the measured process may have been killed while writing it`
93305
- );
93346
+ if (worst === null || ratio >= worst.ratio) {
93347
+ worst = { class: memoryClass, largestGapBytes, retainedBytes, ratio };
93306
93348
  }
93307
- } finally {
93308
- await handle.close();
93309
93349
  }
93350
+ return worst;
93310
93351
  }
93311
- var defaultLoader = async (file) => {
93312
- await assertReadableSnapshot(file);
93313
- import_core.config.muteConsole = true;
93314
- return (0, import_heap_analysis.getFullHeapFromFile)(file);
93352
+ var mb = (bytes) => `${(bytes / MB).toFixed(2)} MB`;
93353
+ var CLASS_LABEL = {
93354
+ heap: "heap",
93355
+ external: "external memory",
93356
+ arrayBuffers: "arrayBuffers"
93315
93357
  };
93316
- async function diffSnapshotFiles(baselineFile, afterFile, options = {}, loadHeap = defaultLoader) {
93317
- const summary = summarizeBaseline(await loadHeap(baselineFile), options);
93318
- const after = await loadHeap(afterFile);
93319
- return diffAgainstBaseline(summary, after, options);
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`;
93320
93360
  }
93321
93361
 
93322
- // src/vlq.ts
93323
- var BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
93324
- var CHAR_TO_VALUE = new Map([...BASE64].map((char, index) => [char, index]));
93325
- function decodeVlqSegment(raw) {
93326
- const values = [];
93327
- let value = 0;
93328
- let shift = 0;
93329
- for (const char of raw) {
93330
- const digit = CHAR_TO_VALUE.get(char);
93331
- if (digit === void 0) {
93332
- throw new Error(`invalid VLQ character: ${char}`);
93333
- }
93334
- value += (digit & 31) << shift;
93335
- if ((digit & 32) !== 0) {
93336
- shift += 5;
93337
- } else {
93338
- values.push((value & 1) === 1 ? -(value >>> 1) : value >>> 1);
93339
- value = 0;
93340
- shift = 0;
93341
- }
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;
93342
93378
  }
93343
- return values;
93379
+ return classifyTrend(rss, { minGrowthPerCycle: RSS_MIN_GROWTH_PER_CYCLE }).verdict === "leak" && last - first >= RSS_MIN_TOTAL_GROWTH;
93344
93380
  }
93345
- function decodeVlqLine(line) {
93346
- const segments = [];
93347
- for (const raw of line.split(",")) {
93348
- if (raw !== "") {
93349
- segments.push(decodeVlqSegment(raw));
93350
- }
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;
93351
93391
  }
93352
- return segments;
93353
93392
  }
93354
- function decodeMappings(mappings) {
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) {
93355
93407
  const lines = [];
93356
- let sourceIndex = 0;
93357
- for (const line of mappings.split(";")) {
93358
- const entries = [];
93359
- let column = 0;
93360
- for (const segment of decodeVlqLine(line)) {
93361
- const columnDelta = segment[0];
93362
- if (columnDelta === void 0) {
93363
- continue;
93364
- }
93365
- column += columnDelta;
93366
- if (segment.length >= 4) {
93367
- sourceIndex += segment[1] ?? 0;
93368
- entries.push({ column, sourceIndex });
93369
- }
93370
- }
93371
- lines.push({ entries });
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
+ );
93412
+ }
93413
+ for (const warning of route.confidence.warnings) {
93414
+ lines.push(` \u26A0 low confidence: ${warning.detail}`);
93372
93415
  }
93373
93416
  return lines;
93374
93417
  }
93375
- function sourceIndexAt(lines, line, column) {
93376
- const mapping = lines[line];
93377
- if (mapping === void 0) {
93378
- return void 0;
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
+ );
93379
93425
  }
93380
- let found;
93381
- for (const entry of mapping.entries) {
93382
- if (entry.column > column) {
93383
- break;
93384
- }
93385
- found = entry.sourceIndex;
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
+ );
93386
93431
  }
93387
- return found;
93432
+ return lines;
93388
93433
  }
93389
-
93390
- // src/module-registry.ts
93391
- import { readdir as readdir2, readFile as readFile4 } from "fs/promises";
93392
- import path4 from "path";
93393
- import { z as z4 } from "zod";
93394
- var flatMapSchema = z4.looseObject({
93395
- version: z4.number(),
93396
- sources: z4.array(z4.string()),
93397
- mappings: z4.string()
93398
- });
93399
- var sectionedMapSchema = z4.looseObject({
93400
- version: z4.number(),
93401
- sections: z4.array(
93402
- z4.looseObject({
93403
- offset: z4.looseObject({ line: z4.number(), column: z4.number() }),
93404
- map: z4.looseObject({ sources: z4.array(z4.string()), mappings: z4.string() })
93405
- })
93406
- )
93407
- });
93408
- function normalizeSourceMap(raw) {
93409
- const flat = flatMapSchema.safeParse(raw);
93410
- if (flat.success) {
93411
- return { sources: flat.data.sources, lines: decodeMappings(flat.data.mappings) };
93412
- }
93413
- const sectioned = sectionedMapSchema.safeParse(raw);
93414
- if (!sectioned.success) {
93415
- return null;
93434
+ function diffFindingLines(route) {
93435
+ if (route.diff === null) {
93436
+ return [];
93416
93437
  }
93417
- const sources = [];
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
+ });
93446
+ }
93447
+ function findingLines(route) {
93418
93448
  const lines = [];
93419
- for (const section of sectioned.data.sections) {
93420
- const sourceBase = sources.length;
93421
- sources.push(...section.map.sources);
93422
- for (const [lineIndex, mapping] of decodeMappings(section.map.mappings).entries()) {
93423
- const targetLine = section.offset.line + lineIndex;
93424
- const columnShift = lineIndex === 0 ? section.offset.column : 0;
93425
- const target = lines[targetLine] ??= { entries: [] };
93426
- for (const entry of mapping.entries) {
93427
- target.entries.push({
93428
- column: entry.column + columnShift,
93429
- sourceIndex: entry.sourceIndex + sourceBase
93430
- });
93431
- }
93432
- }
93433
- }
93434
- for (const line of lines) {
93435
- line?.entries.sort((a, b) => a.column - b.column);
93449
+ const culprit = route.attribution === null ? null : ownerLabel(route.attribution.route);
93450
+ if (culprit !== null) {
93451
+ lines.push(` culprit: ${culprit}`);
93436
93452
  }
93437
- for (let i = 0; i < lines.length; i += 1) {
93438
- lines[i] ??= { entries: [] };
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}`);
93439
93457
  }
93440
- return { sources, lines };
93458
+ return lines;
93441
93459
  }
93442
- var ID_IN_GAP = /[,[](\d{1,9}),/g;
93443
- function lastModuleIdIn(gap) {
93444
- ID_IN_GAP.lastIndex = 0;
93445
- let last = null;
93446
- let match = ID_IN_GAP.exec(gap);
93447
- while (match !== null) {
93448
- last = Number(match[1]);
93449
- match = ID_IN_GAP.exec(gap);
93460
+ function peakPressureLines(route, parameters) {
93461
+ const retained = route.memorySamples.at(-1)?.heapUsed;
93462
+ if (retained === void 0) {
93463
+ return [];
93450
93464
  }
93451
- return last;
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)}`];
93452
93471
  }
93453
- function collectFirstArrayIds(codeLines, map, result) {
93454
- for (const [lineIndex, lineText] of codeLines.entries()) {
93455
- const first = /module\.exports ?= ?\[(\d+),/.exec(lineText);
93456
- if (first?.[1] === void 0) {
93457
- continue;
93458
- }
93459
- const idEnd = first.index + first[0].length;
93460
- const nextId = /[,[]\d{1,9},/.exec(lineText.slice(idEnd));
93461
- const spanEnd = nextId === null ? lineText.length : idEnd + nextId.index;
93462
- const entry = map.lines[lineIndex]?.entries.find(
93463
- (candidate) => candidate.column >= idEnd && candidate.column < spanEnd
93464
- );
93465
- const source = entry === void 0 ? void 0 : map.sources[entry.sourceIndex];
93466
- if (source !== void 0) {
93467
- result.set(Number(first[1]), source);
93468
- }
93469
- }
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)}`];
93470
93480
  }
93471
- function collectTransitionIds(codeLines, map, result) {
93472
- for (const [lineIndex, mapping] of map.lines.entries()) {
93473
- const lineText = codeLines[lineIndex];
93474
- if (lineText === void 0) {
93475
- continue;
93476
- }
93477
- let previousColumn = 0;
93478
- let previousSource = null;
93479
- for (const entry of mapping.entries) {
93480
- if (entry.sourceIndex !== previousSource) {
93481
- const id = lastModuleIdIn(lineText.slice(previousColumn, entry.column));
93482
- const source = map.sources[entry.sourceIndex];
93483
- if (id !== null && source !== void 0 && !result.has(id)) {
93484
- result.set(id, source);
93485
- }
93486
- previousSource = entry.sourceIndex;
93487
- }
93488
- previousColumn = entry.column;
93489
- }
93481
+ function routeLines(route, parameters) {
93482
+ if (route.status === "skipped") {
93483
+ return [` \u2013 ${route.route} skipped: ${route.reason}`];
93490
93484
  }
93491
- }
93492
- function extractChunkModules(code, rawMap) {
93493
- const result = /* @__PURE__ */ new Map();
93494
- if (!code.includes("module.exports=[") && !code.includes("module.exports = [")) {
93495
- return result;
93485
+ if (route.status === "failed") {
93486
+ return [` \u2716 ${route.route} failed: ${route.reason}`];
93496
93487
  }
93497
- const map = normalizeSourceMap(rawMap);
93498
- if (map === null) {
93499
- return result;
93488
+ if (route.status === "not-exercised") {
93489
+ return [` \u2013 ${route.route} not exercised: ${route.reason}`];
93500
93490
  }
93501
- const codeLines = code.split("\n");
93502
- collectFirstArrayIds(codeLines, map, result);
93503
- collectTransitionIds(codeLines, map, result);
93504
- return result;
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
+ ];
93505
93506
  }
93506
- async function jsFilesUnder(dir) {
93507
- const out = [];
93508
- let entries;
93509
- try {
93510
- entries = await readdir2(dir, { withFileTypes: true, recursive: true });
93511
- } catch {
93512
- 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 [];
93513
93512
  }
93514
- for (const entry of entries) {
93515
- if (entry.isFile() && entry.name.endsWith(".js")) {
93516
- out.push(path4.join(entry.parentPath, entry.name));
93517
- }
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)`;
93518
93525
  }
93519
- 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`;
93520
93531
  }
93521
- async function extractModuleRegistry(nextServerDir) {
93522
- const registry = /* @__PURE__ */ new Map();
93523
- for (const file of await jsFilesUnder(nextServerDir)) {
93524
- let code;
93525
- let map;
93526
- try {
93527
- code = await readFile4(file, "utf8");
93528
- map = JSON.parse(await readFile4(`${file}.map`, "utf8"));
93529
- } catch {
93530
- continue;
93531
- }
93532
- for (const [id, source] of extractChunkModules(code, map)) {
93533
- if (!registry.has(id)) {
93534
- registry.set(id, source);
93535
- }
93536
- }
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));
93537
93536
  }
93538
- 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
+ };
93539
93584
  }
93540
93585
 
93541
93586
  // src/load.ts
@@ -93735,6 +93780,7 @@ var FIRST_BYTE_BUDGET_MS = 5e3;
93735
93780
  async function runAbandonPhase(options) {
93736
93781
  const target = new URL(options.url);
93737
93782
  const port = Number(target.port || 80);
93783
+ const fromRequest = options.abandonFrom === "request";
93738
93784
  const headerLines = Object.entries(options.headers ?? {}).map(([name, value]) => `${name}: ${value}\r
93739
93785
  `).join("");
93740
93786
  const request2 = `GET ${target.pathname}${target.search} HTTP/1.1\r
@@ -93779,7 +93825,10 @@ Host: ${target.host}\r
93779
93825
  socket.once("connect", () => {
93780
93826
  result.sent += 1;
93781
93827
  socket.write(request2);
93782
- timer = setTimeout(giveUp, FIRST_BYTE_BUDGET_MS);
93828
+ timer = setTimeout(
93829
+ giveUp,
93830
+ fromRequest ? options.abandonAfterMs : FIRST_BYTE_BUDGET_MS
93831
+ );
93783
93832
  timer.unref();
93784
93833
  });
93785
93834
  socket.on("data", () => {
@@ -93787,6 +93836,9 @@ Host: ${target.host}\r
93787
93836
  return;
93788
93837
  }
93789
93838
  responseStarted = true;
93839
+ if (fromRequest) {
93840
+ return;
93841
+ }
93790
93842
  clearTimeout(timer);
93791
93843
  timer = setTimeout(giveUp, options.abandonAfterMs);
93792
93844
  timer.unref();
@@ -93949,6 +94001,7 @@ async function runRitual(options, deps = defaultDeps) {
93949
94001
  amount,
93950
94002
  connections,
93951
94003
  abandonAfterMs,
94004
+ ...options.abandonFrom !== void 0 && { abandonFrom: options.abandonFrom },
93952
94005
  ...options.headers !== void 0 && { headers: options.headers }
93953
94006
  });
93954
94007
  loadOutcomes.push({
@@ -94330,6 +94383,9 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
94330
94383
  ...headers !== void 0 && { headers },
94331
94384
  ...routeConfig.abandonAfterMs !== void 0 && {
94332
94385
  abandonAfterMs: routeConfig.abandonAfterMs
94386
+ },
94387
+ ...routeConfig.abandonFrom !== void 0 && {
94388
+ abandonFrom: routeConfig.abandonFrom
94333
94389
  }
94334
94390
  });
94335
94391
  const confidence = assessConfidence({
@@ -94344,6 +94400,9 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
94344
94400
  warmupRequests: options.warmupRequests ?? RITUAL_DEFAULTS.warmupRequests,
94345
94401
  ...routeConfig.abandonAfterMs !== void 0 && {
94346
94402
  abandonAfterMs: routeConfig.abandonAfterMs
94403
+ },
94404
+ ...routeConfig.abandonFrom !== void 0 && {
94405
+ abandonFrom: routeConfig.abandonFrom
94347
94406
  }
94348
94407
  });
94349
94408
  const verdict = confidence.supersededVerdict ?? result.trend.verdict;
@@ -94374,6 +94433,12 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
94374
94433
  unreclaimedSamples: result.unreclaimedSamples,
94375
94434
  unreclaimedTrend: result.unreclaimedTrend,
94376
94435
  requestsPerCycle: result.requestsPerCycle,
94436
+ ...routeConfig.abandonAfterMs !== void 0 && {
94437
+ abandon: {
94438
+ afterMs: routeConfig.abandonAfterMs,
94439
+ from: routeConfig.abandonFrom ?? "first-byte"
94440
+ }
94441
+ },
94377
94442
  timings: result.timings,
94378
94443
  loadOutcomes: result.loadOutcomes,
94379
94444
  settleOutcomes: result.settleOutcomes,
@@ -94389,7 +94454,7 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
94389
94454
  };
94390
94455
  }
94391
94456
  async function writeEvidenceBundle(report, workDir) {
94392
- const { renderHtmlReport } = await import("./html-report-2UAOOZQO.js");
94457
+ const { renderHtmlReport } = await import("./html-report-TN7PYVU4.js");
94393
94458
  const { renderIssueMarkdown } = await import("./issue-report-HKA26WNV.js");
94394
94459
  for (const route of report.routes) {
94395
94460
  if (route.status === "measured" && warrantsIssueDraft(route)) {
@@ -94534,6 +94599,20 @@ async function runMeasurement(options, deps = defaultDeps2) {
94534
94599
  }
94535
94600
 
94536
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,
94537
94616
  appPathsManifestSchema,
94538
94617
  routesManifestSchema,
94539
94618
  discoverPagesRoutes,
@@ -94555,21 +94634,7 @@ export {
94555
94634
  renderConfigSkeleton,
94556
94635
  hasPlaceholders,
94557
94636
  formatReport,
94558
- classifySource,
94559
- attributeFinding,
94560
- attributeDiff,
94561
94637
  captureEnvironment,
94562
- summarizeBaseline,
94563
- retainerChain,
94564
- diffAgainstBaseline,
94565
- SnapshotError,
94566
- assertReadableSnapshot,
94567
- diffSnapshotFiles,
94568
- decodeVlqLine,
94569
- decodeMappings,
94570
- sourceIndexAt,
94571
- extractChunkModules,
94572
- extractModuleRegistry,
94573
94638
  LoadError,
94574
94639
  runLoadPhase,
94575
94640
  runRitual,