next-leak 0.5.1 → 0.7.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-2FZXZLZW.js";
10
+ } from "./chunk-EJ26OBZI.js";
11
11
  import {
12
12
  assessPeakPressure,
13
13
  describePeakPressure
@@ -161,7 +161,7 @@ var require_polyfills = __commonJS({
161
161
  })(fs.rename);
162
162
  }
163
163
  fs.read = typeof fs.read !== "function" ? fs.read : (function(fs$read) {
164
- function read(fd, buffer, offset, length, position, callback_) {
164
+ function read2(fd, buffer, offset, length, position, callback_) {
165
165
  var callback;
166
166
  if (callback_ && typeof callback_ === "function") {
167
167
  var eagCounter = 0;
@@ -175,8 +175,8 @@ var require_polyfills = __commonJS({
175
175
  }
176
176
  return fs$read.call(fs, fd, buffer, offset, length, position, callback);
177
177
  }
178
- if (Object.setPrototypeOf) Object.setPrototypeOf(read, fs$read);
179
- return read;
178
+ if (Object.setPrototypeOf) Object.setPrototypeOf(read2, fs$read);
179
+ return read2;
180
180
  })(fs.read);
181
181
  fs.readSync = typeof fs.readSync !== "function" ? fs.readSync : /* @__PURE__ */ (function(fs$readSync) {
182
182
  return function(fd, buffer, offset, length, position) {
@@ -88693,7 +88693,7 @@ var require_buffer3 = __commonJS({
88693
88693
  byteOffset /= 2;
88694
88694
  }
88695
88695
  }
88696
- function read(buf, i2) {
88696
+ function read2(buf, i2) {
88697
88697
  if (indexSize === 1) {
88698
88698
  return buf[i2];
88699
88699
  } else {
@@ -88704,7 +88704,7 @@ var require_buffer3 = __commonJS({
88704
88704
  if (dir) {
88705
88705
  var foundIndex = -1;
88706
88706
  for (i = byteOffset; i < arrLength; i++) {
88707
- if (read(arr, i) === read(val, foundIndex === -1 ? 0 : i - foundIndex)) {
88707
+ if (read2(arr, i) === read2(val, foundIndex === -1 ? 0 : i - foundIndex)) {
88708
88708
  if (foundIndex === -1) foundIndex = i;
88709
88709
  if (i - foundIndex + 1 === valLength) return foundIndex * indexSize;
88710
88710
  } else {
@@ -88717,7 +88717,7 @@ var require_buffer3 = __commonJS({
88717
88717
  for (i = byteOffset; i >= 0; i--) {
88718
88718
  var found = true;
88719
88719
  for (var j = 0; j < valLength; j++) {
88720
- if (read(arr, i + j) !== read(val, j)) {
88720
+ if (read2(arr, i + j) !== read2(val, j)) {
88721
88721
  found = false;
88722
88722
  break;
88723
88723
  }
@@ -91969,7 +91969,374 @@ 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";
92003
+ }
92004
+ if (STATIC_ASSET_PATHS.has(routePath)) {
92005
+ return "static asset served by a generated handler \u2014 no user code to leak";
92006
+ }
92007
+ return void 0;
92008
+ }
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;
92020
+ }
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
+ }
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)) {
92029
+ continue;
92030
+ }
92031
+ routes.push({
92032
+ path: key,
92033
+ kind: key === "/api" || key.startsWith("/api/") ? "route-handler" : "page",
92034
+ dynamic: key.includes("[")
92035
+ });
92036
+ }
92037
+ return routes.sort((a, b) => a.path.localeCompare(b.path));
92038
+ }
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)) {
92044
+ continue;
92045
+ }
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
+ });
92053
+ }
92054
+ return [...routes.values()].sort((a, b) => a.path.localeCompare(b.path));
92055
+ }
92056
+
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
+ }
92067
+ };
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)}`
92084
+ );
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.`
92092
+ );
92093
+ }
92094
+ }
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.`
92101
+ );
92102
+ }
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".`
92127
+ );
92128
+ }
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;
92133
+ return {
92134
+ appDir: path.resolve(appDir),
92135
+ standaloneServer,
92136
+ appPaths,
92137
+ pages,
92138
+ routes,
92139
+ prerender
92140
+ };
92141
+ }
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 {
92152
+ constructor(message) {
92153
+ super(message);
92154
+ this.name = "LaunchError";
92155
+ }
92156
+ };
92157
+ var activeChildren = /* @__PURE__ */ new Set();
92158
+ var STDERR_WINDOW = 4096;
92159
+ function killActiveChildren() {
92160
+ for (const child of activeChildren) {
92161
+ child.kill("SIGKILL");
92162
+ }
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.`;
92175
+ }
92176
+ if (/EADDRINUSE/.test(stderr)) {
92177
+ return "the port was taken by another process while starting.";
92178
+ }
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.`;
92185
+ }
92186
+ return `the measured process exited mid-run. ${explainStartupFailure(stderr)}`;
92187
+ }
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);
92194
+ }
92195
+ const result = await probe();
92196
+ if (result !== void 0) {
92197
+ return result;
92198
+ }
92199
+ if (Date.now() > deadline) {
92200
+ throw new LaunchError(`timed out waiting for ${what}`);
92201
+ }
92202
+ await sleep(100);
92203
+ }
92204
+ }
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"]
92228
+ }
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
+
91972
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;
92336
+ }
92337
+ }
92338
+ return null;
92339
+ }
91973
92340
  var FLAGS = [
91974
92341
  {
91975
92342
  flag: "--routes",
@@ -91981,6 +92348,12 @@ var FLAGS = [
91981
92348
  { flag: "--requests", value: "int", argName: "<n>", help: "Requests per cycle (default 5000)" },
91982
92349
  { flag: "--connections", value: "int", argName: "<n>", help: "Concurrent connections (default 100)" },
91983
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
+ },
91984
92357
  {
91985
92358
  flag: "--max-old-space",
91986
92359
  value: "int",
@@ -91999,6 +92372,11 @@ var FLAGS = [
91999
92372
  help: "Do not re-measure inconclusive routes with more cycles (default: re-measure once)"
92000
92373
  },
92001
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
+ },
92002
92380
  { flag: "--help", alias: "-h", value: "none", help: "Show this help" },
92003
92381
  { flag: "--version", alias: "-v", value: "none", help: "Print the version" }
92004
92382
  ];
@@ -92015,7 +92393,8 @@ Find out whether your Next.js app actually leaks memory \u2014 how much, on whic
92015
92393
  route, and whose fault it is.
92016
92394
 
92017
92395
  Usage:
92018
- next-leak <app-dir> [options]
92396
+ next-leak <app-dir> [options] measure a built server under load
92397
+ next-leak build <app-dir> measure the memory of "next build"
92019
92398
 
92020
92399
  The app must be built with output: "standalone" (next build). For each
92021
92400
  discovered route, next-leak boots a fresh instrumented process and runs:
@@ -92023,6 +92402,11 @@ warm-up \u2192 GC \u2192 baseline snapshot \u2192 [load \u2192 idle \u2192 GC \u
92023
92402
  snapshot. Evidence (report.html, ISSUE drafts, raw snapshots, run.json) is
92024
92403
  written under the output directory.
92025
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
+
92026
92410
  Options:
92027
92411
  ${rows}
92028
92412
 
@@ -92035,6 +92419,7 @@ var LIMITS = {
92035
92419
  "--requests": 1e6,
92036
92420
  "--connections": 1e4,
92037
92421
  "--idle": 3600,
92422
+ "--warmup": 1e6,
92038
92423
  "--max-old-space": 65536
92039
92424
  };
92040
92425
  var MIN_MAX_OLD_SPACE_MB = 128;
@@ -92074,6 +92459,7 @@ function applyNumericFlag(flag, value, options) {
92074
92459
  if (flag === "--requests") options.requests = parsed;
92075
92460
  if (flag === "--connections") options.connections = parsed;
92076
92461
  if (flag === "--idle") options.idleSeconds = parsed;
92462
+ if (flag === "--warmup") options.warmupRequests = parsed;
92077
92463
  if (flag === "--max-old-space") options.maxOldSpaceMb = parsed;
92078
92464
  return FLAG_OK;
92079
92465
  }
@@ -92085,6 +92471,7 @@ function applyFlag(spec, value, options) {
92085
92471
  case "--requests":
92086
92472
  case "--connections":
92087
92473
  case "--idle":
92474
+ case "--warmup":
92088
92475
  case "--max-old-space":
92089
92476
  return applyNumericFlag(spec.flag, value, options);
92090
92477
  case "--quick":
@@ -92093,6 +92480,9 @@ function applyFlag(spec, value, options) {
92093
92480
  case "--diff-all":
92094
92481
  options.diffAll = true;
92095
92482
  return FLAG_OK;
92483
+ case "--write-config":
92484
+ options.writeConfig = true;
92485
+ return FLAG_OK;
92096
92486
  case "--no-resolve":
92097
92487
  options.noResolve = true;
92098
92488
  return FLAG_OK;
@@ -92126,17 +92516,19 @@ function parseFlagAt(argv, index, options) {
92126
92516
  if (spec.flag === "--version") {
92127
92517
  return { done: { kind: "version" } };
92128
92518
  }
92129
- const read = readFlagValue(spec, argv, index);
92130
- if ("error" in read) {
92131
- return { done: { kind: "error", message: read.error } };
92519
+ const read2 = readFlagValue(spec, argv, index);
92520
+ if ("error" in read2) {
92521
+ return { done: { kind: "error", message: read2.error } };
92132
92522
  }
92133
- const outcome = applyFlag(spec, read.value, options);
92523
+ const outcome = applyFlag(spec, read2.value, options);
92134
92524
  if (outcome.kind === "error") {
92135
92525
  return { done: { kind: "error", message: outcome.message } };
92136
92526
  }
92137
92527
  return { consumed: spec.value === "none" ? 0 : 1 };
92138
92528
  }
92139
92529
  function parseCliArgs(argv) {
92530
+ const isBuild = argv[0] === BUILD_COMMAND;
92531
+ const rest = isBuild ? argv.slice(1) : argv;
92140
92532
  const options = {
92141
92533
  appDir: "",
92142
92534
  routes: null,
@@ -92144,14 +92536,16 @@ function parseCliArgs(argv) {
92144
92536
  requests: null,
92145
92537
  connections: null,
92146
92538
  idleSeconds: null,
92539
+ warmupRequests: null,
92147
92540
  maxOldSpaceMb: null,
92148
92541
  quick: false,
92149
92542
  noResolve: false,
92150
92543
  diffAll: false,
92544
+ writeConfig: false,
92151
92545
  output: null
92152
92546
  };
92153
- for (let index = 0; index < argv.length; index += 1) {
92154
- const argument = argv[index] ?? "";
92547
+ for (let index = 0; index < rest.length; index += 1) {
92548
+ const argument = rest[index] ?? "";
92155
92549
  if (!argument.startsWith("-")) {
92156
92550
  if (options.appDir !== "") {
92157
92551
  return { kind: "error", message: `unexpected extra argument "${argument}" \u2014 one app directory only` };
@@ -92159,7 +92553,7 @@ function parseCliArgs(argv) {
92159
92553
  options.appDir = argument;
92160
92554
  continue;
92161
92555
  }
92162
- const step = parseFlagAt(argv, index, options);
92556
+ const step = parseFlagAt(rest, index, options);
92163
92557
  if ("done" in step) {
92164
92558
  return step.done;
92165
92559
  }
@@ -92168,6 +92562,16 @@ function parseCliArgs(argv) {
92168
92562
  if (options.appDir === "") {
92169
92563
  return { kind: "help" };
92170
92564
  }
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 } };
92574
+ }
92171
92575
  return { kind: "run", options };
92172
92576
  }
92173
92577
 
@@ -92188,182 +92592,254 @@ function checkRuntime(facts = currentFacts()) {
92188
92592
  return null;
92189
92593
  }
92190
92594
 
92191
- // src/launcher.ts
92192
- import { spawn } from "child_process";
92193
- import { readdir, readFile } from "fs/promises";
92194
- import path from "path";
92195
- import { pathToFileURL } from "url";
92196
- import { z } from "zod";
92197
- var controlFileSchema = z.object({ port: z.number(), pid: z.number() });
92198
- var DEFAULT_MAX_OLD_SPACE_MB = 512;
92199
- var LaunchError = class extends Error {
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 {
92200
92650
  constructor(message) {
92201
92651
  super(message);
92202
- this.name = "LaunchError";
92652
+ this.name = "RouteConfigError";
92203
92653
  }
92204
92654
  };
92205
- var activeChildren = /* @__PURE__ */ new Set();
92206
- var STDERR_WINDOW = 4096;
92207
- function killActiveChildren() {
92208
- for (const child of activeChildren) {
92209
- child.kill("SIGKILL");
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;
92659
+ try {
92660
+ raw = await readFile3(file, "utf8");
92661
+ } catch {
92662
+ return {};
92663
+ }
92664
+ try {
92665
+ return routeConfigSchema.parse(JSON.parse(raw));
92666
+ } catch (cause) {
92667
+ throw new RouteConfigError(
92668
+ `${file} is invalid: ${cause instanceof Error ? cause.message : String(cause)}`
92669
+ );
92210
92670
  }
92211
92671
  }
92212
- function explainStartupFailure(stderr) {
92213
- const missingModule = /Cannot find module '([^']+)'/.exec(stderr);
92214
- if (missingModule !== null) {
92215
- 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.`;
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
+ });
92216
92685
  }
92217
- if (/EADDRINUSE/.test(stderr)) {
92218
- return "the port was taken by another process while starting.";
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;
92219
92695
  }
92220
- return `stderr:
92221
- ${stderr}`;
92696
+ return { marker: match[0], bound };
92222
92697
  }
92223
- function explainRuntimeFailure(stderr, maxOldSpaceMb) {
92224
- if (/heap out of memory|Reached heap limit|Ineffective mark-compacts/i.test(stderr)) {
92225
- 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.`;
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
+ }
92226
92720
  }
92227
- return `the measured process exited mid-run. ${explainStartupFailure(stderr)}`;
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}`;
92228
92725
  }
92229
- var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
92230
- async function pollUntil(deadline, what, probe, failed) {
92231
- for (; ; ) {
92232
- const failure = failed();
92233
- if (failure !== void 0) {
92234
- throw new LaunchError(failure);
92726
+
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;
92737
+ }
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;
92235
92746
  }
92236
- const result = await probe();
92237
- if (result !== void 0) {
92238
- return result;
92747
+ const value = parameter.catchAll ? concreteSegments.slice(index).join("/") : concreteSegments[index];
92748
+ if (value === void 0 || value === "") {
92749
+ return null;
92239
92750
  }
92240
- if (Date.now() > deadline) {
92241
- throw new LaunchError(`timed out waiting for ${what}`);
92751
+ values[parameter.name] = value;
92752
+ }
92753
+ return Object.keys(values).length === 0 ? null : values;
92754
+ }
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;
92242
92761
  }
92243
- await sleep(100);
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;
92244
92768
  }
92769
+ return Object.keys(routes).length === 0 ? null : JSON.stringify({ routes }, null, 2);
92245
92770
  }
92246
- async function launchInstrumented(options) {
92247
- const hostname = options.hostname ?? "127.0.0.1";
92248
- const deadline = Date.now() + (options.readyTimeoutMs ?? 15e3);
92249
- const child = spawn(
92250
- process.execPath,
92251
- [
92252
- "--expose-gc",
92253
- `--max-old-space-size=${options.maxOldSpaceMb ?? DEFAULT_MAX_OLD_SPACE_MB}`,
92254
- "--import",
92255
- pathToFileURL(options.bootstrapPath).href,
92256
- options.serverPath
92257
- ],
92258
- {
92259
- cwd: path.dirname(options.serverPath),
92260
- env: {
92261
- ...process.env,
92262
- ...options.env,
92263
- NODE_ENV: "production",
92264
- PORT: String(options.appPort),
92265
- HOSTNAME: hostname,
92266
- NEXT_LEAK_DIR: options.workDir
92267
- },
92268
- stdio: ["ignore", "ignore", "pipe"]
92771
+ function hasPlaceholders(skeleton) {
92772
+ return skeleton.includes(PLACEHOLDER);
92773
+ }
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);
92784
+ }
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;
92269
92791
  }
92270
- );
92271
- let stderrHead = "";
92272
- let stderrTailBuffer = "";
92273
- child.stderr?.on("data", (chunk) => {
92274
- const text = chunk.toString();
92275
- if (stderrHead.length < STDERR_WINDOW) {
92276
- stderrHead = (stderrHead + text).slice(0, STDERR_WINDOW);
92792
+ gaps.push(read(held, memoryClass) - read(afterGc, memoryClass));
92793
+ }
92794
+ return gaps;
92795
+ }
92796
+ function assessUnreclaimedRetention(input) {
92797
+ if (input.verdict === "leak" && input.verdictIsWellSupported || input.unreclaimedSamples.length === 0) {
92798
+ return null;
92799
+ }
92800
+ let worst = null;
92801
+ for (const memoryClass of CLASSES) {
92802
+ const gaps = gapsFor(input, memoryClass);
92803
+ if (gaps.length === 0) {
92804
+ continue;
92277
92805
  }
92278
- stderrTailBuffer = (stderrTailBuffer + text).slice(-STDERR_WINDOW);
92279
- });
92280
- const stderrWindow = () => stderrHead === stderrTailBuffer || stderrTailBuffer === "" ? stderrHead : `${stderrHead}
92281
- [...]
92282
- ${stderrTailBuffer}`;
92283
- let exited = false;
92284
- activeChildren.add(child);
92285
- child.once("exit", () => {
92286
- exited = true;
92287
- activeChildren.delete(child);
92288
- });
92289
- const failed = () => exited ? `server exited before becoming ready. ${explainStartupFailure(stderrWindow())}` : void 0;
92290
- try {
92291
- const controlPort = await pollUntil(
92292
- deadline,
92293
- `control channel in ${options.workDir}`,
92294
- // Several processes may announce a channel (clustered servers); accept
92295
- // the first one that actually answers instead of trusting a filename.
92296
- async () => {
92297
- let entries;
92298
- try {
92299
- entries = (await readdir(options.workDir)).filter(
92300
- (entry) => entry.startsWith("control-") && entry.endsWith(".json")
92301
- );
92302
- } catch {
92303
- return void 0;
92304
- }
92305
- for (const entry of entries) {
92306
- try {
92307
- const parsed = controlFileSchema.parse(
92308
- JSON.parse(await readFile(path.join(options.workDir, entry), "utf8"))
92309
- );
92310
- const response = await fetch(`http://127.0.0.1:${parsed.port}/gc`);
92311
- if (response.ok) {
92312
- return parsed.port;
92313
- }
92314
- } catch {
92315
- continue;
92316
- }
92317
- }
92318
- return void 0;
92319
- },
92320
- failed
92321
- );
92322
- await pollUntil(
92323
- deadline,
92324
- `app on ${hostname}:${options.appPort}`,
92325
- async () => {
92326
- try {
92327
- await fetch(`http://${hostname}:${options.appPort}/`, { method: "HEAD" });
92328
- return true;
92329
- } catch {
92330
- return void 0;
92331
- }
92332
- },
92333
- failed
92806
+ const largestGapBytes = largestGap(gaps);
92807
+ if (largestGapBytes < MIN_GAP_BYTES) {
92808
+ continue;
92809
+ }
92810
+ const retainedBytes = largestGap(
92811
+ input.memorySamples.slice(1).map((sample) => read(sample, memoryClass))
92334
92812
  );
92335
- return {
92336
- pid: child.pid ?? -1,
92337
- appPort: options.appPort,
92338
- controlPort,
92339
- explainExit: () => exited ? explainRuntimeFailure(stderrWindow(), options.maxOldSpaceMb ?? DEFAULT_MAX_OLD_SPACE_MB) : null,
92340
- close: async () => {
92341
- if (exited) {
92342
- return;
92343
- }
92344
- const gone = new Promise((resolve) => child.once("exit", () => resolve()));
92345
- child.kill("SIGTERM");
92346
- const killTimer = setTimeout(() => child.kill("SIGKILL"), 3e3);
92347
- await gone;
92348
- clearTimeout(killTimer);
92349
- }
92350
- };
92351
- } catch (cause) {
92352
- child.kill("SIGKILL");
92353
- throw cause;
92813
+ const ratio = largestGapBytes / Math.max(retainedBytes, MIN_RETAINED_BYTES);
92814
+ if (ratio < MIN_GAP_RATIO) {
92815
+ continue;
92816
+ }
92817
+ if (worst === null || ratio >= worst.ratio) {
92818
+ worst = { class: memoryClass, largestGapBytes, retainedBytes, ratio };
92819
+ }
92354
92820
  }
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`;
92355
92831
  }
92356
92832
 
92357
92833
  // src/report.ts
92358
- var MB = 1024 * 1024;
92359
- var formatMb = (bytes) => `${(bytes / MB).toFixed(1)} MB`;
92834
+ var MB2 = 1024 * 1024;
92835
+ var formatMb = (bytes) => `${(bytes / MB2).toFixed(1)} MB`;
92360
92836
  var formatGrowth = (bytes) => {
92361
92837
  const sign = bytes >= 0 ? "+" : "";
92362
- return `${sign}${(bytes / MB).toFixed(2)} MB/1000 req`;
92838
+ return `${sign}${(bytes / MB2).toFixed(2)} MB/1000 req`;
92363
92839
  };
92364
92840
  var VERDICT_ICON = { leak: "\u2716", stable: "\u2714", inconclusive: "?" };
92365
- var RSS_MIN_GROWTH_PER_CYCLE = 16 * MB;
92366
- var RSS_MIN_TOTAL_GROWTH = 64 * MB;
92841
+ var RSS_MIN_GROWTH_PER_CYCLE = 16 * MB2;
92842
+ var RSS_MIN_TOTAL_GROWTH = 64 * MB2;
92367
92843
  function hasSustainedRssGrowth(memorySamples) {
92368
92844
  const rss = memorySamples.map((sample) => sample.rss);
92369
92845
  const first = rss[1];
@@ -92385,6 +92861,19 @@ function ownerLabel(attribution) {
92385
92861
  return null;
92386
92862
  }
92387
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
+ }
92388
92877
  function confidenceLines(route) {
92389
92878
  const lines = [];
92390
92879
  if (route.confidence.supersededVerdict !== void 0) {
@@ -92451,6 +92940,15 @@ function peakPressureLines(route, parameters) {
92451
92940
  });
92452
92941
  return pressure === null ? [] : [` \u25B2 peak pressure: ${describePeakPressure(pressure)}`];
92453
92942
  }
92943
+ function unreclaimedLines(route, verdict) {
92944
+ const retention = assessUnreclaimedRetention({
92945
+ unreclaimedSamples: route.unreclaimedSamples,
92946
+ memorySamples: route.memorySamples,
92947
+ verdict,
92948
+ verdictIsWellSupported: warrantsIssueDraft(route)
92949
+ });
92950
+ return retention === null ? [] : [` \u25B2 unreclaimed: ${describeUnreclaimedRetention(retention)}`];
92951
+ }
92454
92952
  function routeLines(route, parameters) {
92455
92953
  if (route.status === "skipped") {
92456
92954
  return [` \u2013 ${route.route} skipped: ${route.reason}`];
@@ -92458,6 +92956,9 @@ function routeLines(route, parameters) {
92458
92956
  if (route.status === "failed") {
92459
92957
  return [` \u2716 ${route.route} failed: ${route.reason}`];
92460
92958
  }
92959
+ if (route.status === "not-exercised") {
92960
+ return [` \u2013 ${route.route} not exercised: ${route.reason}`];
92961
+ }
92461
92962
  const verdict = effectiveVerdict(route);
92462
92963
  const curve = route.samples.map(formatMb).join(" \u2192 ");
92463
92964
  const resolved = route.resolvedWithCycles === void 0 ? "" : ` (resolved at ${route.resolvedWithCycles} cycles)`;
@@ -92465,17 +92966,46 @@ function routeLines(route, parameters) {
92465
92966
  ` ${VERDICT_ICON[verdict]} ${route.route} ${verdict} (${formatGrowth(
92466
92967
  route.growthPer1000Requests
92467
92968
  )}) heap ${curve}${resolved}`,
92969
+ ...revalidationLines(route),
92970
+ ...abandonLines(route),
92468
92971
  ...confidenceLines(route),
92469
92972
  ...memorySourceLines(route, verdict),
92470
92973
  ...peakPressureLines(route, parameters),
92974
+ ...unreclaimedLines(route, verdict),
92471
92975
  ...findingLines(route)
92472
92976
  ];
92473
92977
  }
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 [];
92983
+ }
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
+ ];
92990
+ }
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`;
93002
+ }
92474
93003
  function formatReport(report) {
92475
93004
  const lines = [`next-leak \u2014 ${report.appDir}`, ""];
92476
93005
  for (const route of report.routes) {
92477
93006
  lines.push(...routeLines(route, report.parameters));
92478
93007
  }
93008
+ lines.push(...skippedGuidanceLines(report));
92479
93009
  const { minGrowthPerCycle, loadRequests, cycles, maxOldSpaceMb } = report.parameters;
92480
93010
  const resolvedCycles = [
92481
93011
  ...new Set(
@@ -92485,8 +93015,8 @@ function formatReport(report) {
92485
93015
  )
92486
93016
  ].sort((a, b) => a - b);
92487
93017
  const cyclesLabel = resolvedCycles.length === 0 ? `${cycles} cycles` : `${cycles} cycles (${resolvedCycles.join(", ")} where resolved)`;
93018
+ lines.push("", coverageLine(report));
92488
93019
  lines.push(
92489
- "",
92490
93020
  `judged over ${cyclesLabel} \xD7 ${loadRequests} requests, growth gate ${(minGrowthPerCycle / 1024).toFixed(0)} KiB/cycle (${formatGrowth(minGrowthPerCycle / loadRequests * 1e3)}), heap cap ${maxOldSpaceMb} MB`,
92491
93021
  `snapshots and run.json: ${report.workDir}`,
92492
93022
  `report: ${report.bundle.htmlReport}`,
@@ -92507,88 +93037,6 @@ function formatReport(report) {
92507
93037
  return lines.join("\n");
92508
93038
  }
92509
93039
 
92510
- // src/route-config.ts
92511
- import { readFile as readFile2 } from "fs/promises";
92512
- import path2 from "path";
92513
- import { z as z2 } from "zod";
92514
- var routeConfigSchema = z2.object({
92515
- params: z2.record(z2.string(), z2.string()).optional(),
92516
- routes: z2.record(z2.string(), z2.record(z2.string(), z2.string())).optional(),
92517
- /**
92518
- * Headers sent with every request. Real traffic is not header-less:
92519
- * compression (`Accept-Encoding`), sessions and auth change which code
92520
- * paths run, and some leaks only exist on those paths (zlib, per-session
92521
- * caches). Measuring without them measures a different app.
92522
- */
92523
- headers: z2.record(z2.string(), z2.string()).optional(),
92524
- /** Query string appended per route template, e.g. `"weightKb=2048"`. */
92525
- query: z2.record(z2.string(), z2.string()).optional(),
92526
- /**
92527
- * Simulates clients that give up before the response arrives (closed
92528
- * tabs, load-balancer timeouts, bots). Several real leaks only exist on
92529
- * that path — vercel/next.js#89091 traces `ServerResponse` retention to
92530
- * an early disconnect — and a load generator that always waits politely
92531
- * never reaches it.
92532
- *
92533
- * Counted **from the first byte of the response**, not from the request:
92534
- * under load a server's first byte arrives long after any sane fixed
92535
- * window, so a request-relative clock cuts before the stream starts and
92536
- * tests the wrong path entirely.
92537
- */
92538
- abandonAfterMs: z2.number().int().positive().optional()
92539
- }).strict();
92540
- var RouteConfigError = class extends Error {
92541
- constructor(message) {
92542
- super(message);
92543
- this.name = "RouteConfigError";
92544
- }
92545
- };
92546
- var ROUTE_CONFIG_FILE = "next-leak.config.json";
92547
- async function loadRouteConfig(appDir) {
92548
- const file = path2.join(appDir, ROUTE_CONFIG_FILE);
92549
- let raw;
92550
- try {
92551
- raw = await readFile2(file, "utf8");
92552
- } catch {
92553
- return {};
92554
- }
92555
- try {
92556
- return routeConfigSchema.parse(JSON.parse(raw));
92557
- } catch (cause) {
92558
- throw new RouteConfigError(
92559
- `${file} is invalid: ${cause instanceof Error ? cause.message : String(cause)}`
92560
- );
92561
- }
92562
- }
92563
- var SEGMENT_PATTERN = /^\[(\[)?(\.\.\.)?([^\]]+?)\]?\]$/;
92564
- var UNIQUE_MARKER = "{n}";
92565
- var ENCODED_MARKER = encodeURIComponent(UNIQUE_MARKER);
92566
- var encodeSegment = (value) => encodeURIComponent(value).split(ENCODED_MARKER).join(UNIQUE_MARKER);
92567
- var encodeCatchAll = (value) => value.split("/").map(encodeSegment).join("/");
92568
- function resolveRoutePath(routeTemplate, config) {
92569
- const resolved = [];
92570
- for (const segment of routeTemplate.split("/")) {
92571
- const match = SEGMENT_PATTERN.exec(segment);
92572
- if (match === null) {
92573
- resolved.push(encodeSegment(segment));
92574
- continue;
92575
- }
92576
- const optional = match[1] !== void 0;
92577
- const catchAll = match[2] !== void 0;
92578
- const name = match[3] ?? "";
92579
- const value = config.routes?.[routeTemplate]?.[name] ?? config.params?.[name];
92580
- if (value !== void 0) {
92581
- resolved.push(catchAll ? encodeCatchAll(value) : encodeSegment(value));
92582
- } else if (!optional) {
92583
- return null;
92584
- }
92585
- }
92586
- const joined = resolved.join("/");
92587
- const path7 = joined === "" ? "/" : joined;
92588
- const query = config.query?.[routeTemplate];
92589
- return query === void 0 || query === "" ? path7 : `${path7}?${query}`;
92590
- }
92591
-
92592
93040
  // src/attribution.ts
92593
93041
  var UNATTRIBUTED = { owner: "unattributed", source: null, packageName: null };
92594
93042
  function classifySource(rawSource) {
@@ -92854,6 +93302,7 @@ var SNAPSHOT_HEAD = '{"snapshot"';
92854
93302
  var PROBE_BYTES = 512;
92855
93303
  async function assertReadableSnapshot(file) {
92856
93304
  const { open, stat } = await import("fs/promises");
93305
+ const { constants } = await import("buffer");
92857
93306
  let size;
92858
93307
  try {
92859
93308
  size = (await stat(file)).size;
@@ -92863,6 +93312,11 @@ async function assertReadableSnapshot(file) {
92863
93312
  if (size === 0) {
92864
93313
  throw new SnapshotError(`heap snapshot is empty: ${file}`);
92865
93314
  }
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`
93318
+ );
93319
+ }
92866
93320
  const handle = await open(file, "r");
92867
93321
  try {
92868
93322
  const head = Buffer.alloc(Math.min(PROBE_BYTES, size));
@@ -92894,80 +93348,6 @@ async function diffSnapshotFiles(baselineFile, afterFile, options = {}, loadHeap
92894
93348
  return diffAgainstBaseline(summary, after, options);
92895
93349
  }
92896
93350
 
92897
- // src/manifests.ts
92898
- import { z as z3 } from "zod";
92899
- var appPathsManifestSchema = z3.record(z3.string(), z3.string());
92900
- var pagesManifestSchema = z3.record(z3.string(), z3.string());
92901
- var routeEntrySchema = z3.looseObject({
92902
- page: z3.string(),
92903
- regex: z3.string()
92904
- });
92905
- var routesManifestSchema = z3.looseObject({
92906
- version: z3.number(),
92907
- basePath: z3.string(),
92908
- staticRoutes: z3.array(routeEntrySchema),
92909
- dynamicRoutes: z3.array(routeEntrySchema)
92910
- });
92911
- var INTERNAL_PATHS = /* @__PURE__ */ new Set(["/_global-error", "/_not-found"]);
92912
- var INTERCEPTING_SEGMENT = /^(\(\.{1,3}\))+/;
92913
- var STATIC_ASSET_PATHS = /* @__PURE__ */ new Set(["/favicon.ico"]);
92914
- function unaddressableReason(routePath) {
92915
- if (routePath.split("/").some((segment) => INTERCEPTING_SEGMENT.test(segment))) {
92916
- return "intercepting route \u2014 only reachable via client navigation";
92917
- }
92918
- if (STATIC_ASSET_PATHS.has(routePath)) {
92919
- return "static asset served by a generated handler \u2014 no user code to leak";
92920
- }
92921
- return void 0;
92922
- }
92923
- function toRequestPath(manifestKey) {
92924
- let kind;
92925
- let withoutSuffix;
92926
- if (manifestKey.endsWith("/page")) {
92927
- kind = "page";
92928
- withoutSuffix = manifestKey.slice(0, -"/page".length);
92929
- } else if (manifestKey.endsWith("/route")) {
92930
- kind = "route-handler";
92931
- withoutSuffix = manifestKey.slice(0, -"/route".length);
92932
- } else {
92933
- return null;
92934
- }
92935
- const segments = withoutSuffix.split("/").filter((segment) => segment !== "").filter((segment) => !(segment.startsWith("(") && segment.endsWith(")"))).filter((segment) => !segment.startsWith("@"));
92936
- return { path: `/${segments.join("/")}`, kind };
92937
- }
92938
- var PAGES_INTERNAL = /* @__PURE__ */ new Set(["/_app", "/_document", "/_error", "/404", "/500"]);
92939
- function discoverPagesRoutes(pages) {
92940
- const routes = [];
92941
- for (const key of Object.keys(pages)) {
92942
- if (PAGES_INTERNAL.has(key)) {
92943
- continue;
92944
- }
92945
- routes.push({
92946
- path: key,
92947
- kind: key === "/api" || key.startsWith("/api/") ? "route-handler" : "page",
92948
- dynamic: key.includes("[")
92949
- });
92950
- }
92951
- return routes.sort((a, b) => a.path.localeCompare(b.path));
92952
- }
92953
- function discoverRoutes(appPaths) {
92954
- const routes = /* @__PURE__ */ new Map();
92955
- for (const key of Object.keys(appPaths)) {
92956
- const parsed = toRequestPath(key);
92957
- if (parsed === null || INTERNAL_PATHS.has(parsed.path) || routes.has(parsed.path)) {
92958
- continue;
92959
- }
92960
- const reason = unaddressableReason(parsed.path);
92961
- routes.set(parsed.path, {
92962
- path: parsed.path,
92963
- kind: parsed.kind,
92964
- dynamic: parsed.path.includes("["),
92965
- ...reason !== void 0 && { unaddressableReason: reason }
92966
- });
92967
- }
92968
- return [...routes.values()].sort((a, b) => a.path.localeCompare(b.path));
92969
- }
92970
-
92971
93351
  // src/vlq.ts
92972
93352
  var BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
92973
93353
  var CHAR_TO_VALUE = new Map([...BASE64].map((char, index) => [char, index]));
@@ -93037,8 +93417,8 @@ function sourceIndexAt(lines, line, column) {
93037
93417
  }
93038
93418
 
93039
93419
  // src/module-registry.ts
93040
- import { readdir as readdir2, readFile as readFile3 } from "fs/promises";
93041
- import path3 from "path";
93420
+ import { readdir as readdir2, readFile as readFile4 } from "fs/promises";
93421
+ import path4 from "path";
93042
93422
  import { z as z4 } from "zod";
93043
93423
  var flatMapSchema = z4.looseObject({
93044
93424
  version: z4.number(),
@@ -93162,7 +93542,7 @@ async function jsFilesUnder(dir) {
93162
93542
  }
93163
93543
  for (const entry of entries) {
93164
93544
  if (entry.isFile() && entry.name.endsWith(".js")) {
93165
- out.push(path3.join(entry.parentPath, entry.name));
93545
+ out.push(path4.join(entry.parentPath, entry.name));
93166
93546
  }
93167
93547
  }
93168
93548
  return out;
@@ -93173,8 +93553,8 @@ async function extractModuleRegistry(nextServerDir) {
93173
93553
  let code;
93174
93554
  let map;
93175
93555
  try {
93176
- code = await readFile3(file, "utf8");
93177
- map = JSON.parse(await readFile3(`${file}.map`, "utf8"));
93556
+ code = await readFile4(file, "utf8");
93557
+ map = JSON.parse(await readFile4(`${file}.map`, "utf8"));
93178
93558
  } catch {
93179
93559
  continue;
93180
93560
  }
@@ -93209,15 +93589,44 @@ async function describeRedirect(url) {
93209
93589
  return null;
93210
93590
  }
93211
93591
  }
93592
+ function boundedRequestSequence(fullUrl, bounded) {
93593
+ const parsed = new URL(fullUrl);
93594
+ const template = fullUrl.slice(parsed.origin.length);
93595
+ let counter = 0;
93596
+ return {
93597
+ origin: parsed.origin,
93598
+ requests: [
93599
+ {
93600
+ setupRequest: (request2) => {
93601
+ const value = String(counter % bounded.bound);
93602
+ counter += 1;
93603
+ return { ...request2, path: template.split(bounded.marker).join(value) };
93604
+ }
93605
+ }
93606
+ ]
93607
+ };
93608
+ }
93609
+ function diagnoseFailure(result, connections) {
93610
+ if (result.timeouts > result.non2xx) {
93611
+ return ` \u2014 the failures are timeouts rather than error responses, so the load may simply be more than the app or something it calls can serve; lower --connections (currently ${connections}) and re-run before concluding anything about memory`;
93612
+ }
93613
+ if (result.non2xx > 0) {
93614
+ return ` \u2014 the app answered with errors under load, which is a fault in the route rather than too much traffic; fix that before measuring it`;
93615
+ }
93616
+ return "";
93617
+ }
93212
93618
  async function runLoadPhase(options) {
93213
93619
  const unique = options.url.includes(UNIQUE_MARKER);
93214
93620
  const url = unique ? options.url.split(UNIQUE_MARKER).join("[<id>]") : options.url;
93621
+ const bounded = boundedMarkerOf(options.url);
93622
+ const boundedRequests = bounded === null ? void 0 : boundedRequestSequence(options.url, bounded);
93215
93623
  const raw = await (0, import_autocannon.default)({
93216
- url,
93624
+ url: boundedRequests === void 0 ? url : boundedRequests.origin,
93217
93625
  amount: options.amount,
93218
93626
  connections: options.connections,
93219
93627
  ...options.headers !== void 0 && { headers: options.headers },
93220
- ...unique && { idReplacement: true }
93628
+ ...unique && { idReplacement: true },
93629
+ ...boundedRequests !== void 0 && { requests: boundedRequests.requests }
93221
93630
  });
93222
93631
  const result = {
93223
93632
  sent: raw.requests.sent,
@@ -93233,7 +93642,7 @@ async function runLoadPhase(options) {
93233
93642
  const unanswered = failures - result.non2xx - result.errors - result.timeouts;
93234
93643
  const redirect = result.non2xx > 0 ? await describeRedirect(options.url) : null;
93235
93644
  throw new LoadError(
93236
- `${failures} of ${options.amount} requests failed against ${options.url} (${result.non2xx} non-2xx, ${result.errors} errors, ${result.timeouts} timeouts` + (unanswered > 0 ? `, ${unanswered} with no recorded response` : "") + ")" + (redirect === null ? "" : ` \u2014 ${redirect}`),
93645
+ `${failures} of ${options.amount} requests failed against ${options.url} (${result.non2xx} non-2xx, ${result.errors} errors, ${result.timeouts} timeouts` + (unanswered > 0 ? `, ${unanswered} with no recorded response` : "") + ")" + (redirect === null ? diagnoseFailure(result, options.connections) : ` \u2014 ${redirect}`),
93237
93646
  result
93238
93647
  );
93239
93648
  }
@@ -93355,6 +93764,7 @@ var FIRST_BYTE_BUDGET_MS = 5e3;
93355
93764
  async function runAbandonPhase(options) {
93356
93765
  const target = new URL(options.url);
93357
93766
  const port = Number(target.port || 80);
93767
+ const fromRequest = options.abandonFrom === "request";
93358
93768
  const headerLines = Object.entries(options.headers ?? {}).map(([name, value]) => `${name}: ${value}\r
93359
93769
  `).join("");
93360
93770
  const request2 = `GET ${target.pathname}${target.search} HTTP/1.1\r
@@ -93399,7 +93809,10 @@ Host: ${target.host}\r
93399
93809
  socket.once("connect", () => {
93400
93810
  result.sent += 1;
93401
93811
  socket.write(request2);
93402
- timer = setTimeout(giveUp, FIRST_BYTE_BUDGET_MS);
93812
+ timer = setTimeout(
93813
+ giveUp,
93814
+ fromRequest ? options.abandonAfterMs : FIRST_BYTE_BUDGET_MS
93815
+ );
93403
93816
  timer.unref();
93404
93817
  });
93405
93818
  socket.on("data", () => {
@@ -93407,6 +93820,9 @@ Host: ${target.host}\r
93407
93820
  return;
93408
93821
  }
93409
93822
  responseStarted = true;
93823
+ if (fromRequest) {
93824
+ return;
93825
+ }
93410
93826
  clearTimeout(timer);
93411
93827
  timer = setTimeout(giveUp, options.abandonAfterMs);
93412
93828
  timer.unref();
@@ -93441,6 +93857,10 @@ var MIN_POLLS_TO_JUDGE = 2;
93441
93857
  var SETTLE_POLL_MS = 2e3;
93442
93858
  var SETTLE_TOLERANCE = 0.01;
93443
93859
  var PEAK_POLL_MS = 250;
93860
+ var UNRECLAIMED_SETTLE_MS = SETTLE_POLL_MS;
93861
+ function unreclaimedSettleFor(idleMs) {
93862
+ return Math.min(UNRECLAIMED_SETTLE_MS, Math.floor(idleMs / 4));
93863
+ }
93444
93864
  function pollPeak(controlPort, phase, deps) {
93445
93865
  const peak = {
93446
93866
  phase,
@@ -93565,6 +93985,7 @@ async function runRitual(options, deps = defaultDeps) {
93565
93985
  amount,
93566
93986
  connections,
93567
93987
  abandonAfterMs,
93988
+ ...options.abandonFrom !== void 0 && { abandonFrom: options.abandonFrom },
93568
93989
  ...options.headers !== void 0 && { headers: options.headers }
93569
93990
  });
93570
93991
  loadOutcomes.push({
@@ -93601,6 +94022,8 @@ async function runRitual(options, deps = defaultDeps) {
93601
94022
  () => requestSnapshot(app.controlPort, "baseline")
93602
94023
  );
93603
94024
  const memorySamples = [baseline.sample];
94025
+ const unreclaimedSamples = [];
94026
+ let unreclaimedLost = false;
93604
94027
  const peaks = [];
93605
94028
  let afterSnapshot = "";
93606
94029
  for (let cycle = 1; cycle <= cycles; cycle += 1) {
@@ -93612,9 +94035,18 @@ async function runRitual(options, deps = defaultDeps) {
93612
94035
  peaks.push(await poller.stop());
93613
94036
  }
93614
94037
  });
94038
+ const unreclaimedSettleMs = unreclaimedSettleFor(idleMs);
94039
+ await timed(`cycle ${cycle} unreclaimed read`, async () => {
94040
+ await deps.sleep(unreclaimedSettleMs);
94041
+ try {
94042
+ unreclaimedSamples.push(await deps.readMemory(app.controlPort));
94043
+ } catch {
94044
+ unreclaimedLost = true;
94045
+ }
94046
+ });
93615
94047
  const settle = await timed(
93616
94048
  `cycle ${cycle} settle`,
93617
- () => waitUntilSettled(app.controlPort, idleMs, deps)
94049
+ () => waitUntilSettled(app.controlPort, idleMs - unreclaimedSettleMs, deps)
93618
94050
  );
93619
94051
  settleOutcomes.push({ phase: `cycle ${cycle}`, ...settle });
93620
94052
  if (cycle === cycles) {
@@ -93639,6 +94071,14 @@ async function runRitual(options, deps = defaultDeps) {
93639
94071
  samples,
93640
94072
  memorySamples,
93641
94073
  peaks,
94074
+ unreclaimedSamples: unreclaimedLost ? [] : unreclaimedSamples,
94075
+ // The first cycle plays the part the baseline plays for the post-GC
94076
+ // series, so both trends drop a warm-up delta and stay comparable.
94077
+ unreclaimedTrend: unreclaimedLost ? { verdict: "inconclusive", growthPerCycle: 0, deltas: [] } : classifyMemoryTrend(
94078
+ unreclaimedSamples.map((sample) => sample.heapUsed),
94079
+ unreclaimedSamples.map((sample) => sample.external),
94080
+ { minGrowthPerCycle }
94081
+ ),
93642
94082
  baselineSnapshot: baseline.file,
93643
94083
  afterSnapshot,
93644
94084
  trend: classifyMemoryTrend(samples, externalSamples, { minGrowthPerCycle }),
@@ -93657,8 +94097,8 @@ async function runRitual(options, deps = defaultDeps) {
93657
94097
  }
93658
94098
 
93659
94099
  // src/signatures.ts
93660
- import { readFile as readFile4 } from "fs/promises";
93661
- import path4 from "path";
94100
+ import { readFile as readFile5 } from "fs/promises";
94101
+ import path5 from "path";
93662
94102
  import semver from "semver";
93663
94103
  import { z as z6 } from "zod";
93664
94104
  var signatureSchema = z6.object({
@@ -93735,12 +94175,12 @@ function matchSignatures(diff, nextVersion, signatures = loadSignatures()) {
93735
94175
  }
93736
94176
  async function readNextVersion(appDir) {
93737
94177
  const candidates = [
93738
- path4.join(appDir, ".next", "standalone", "node_modules", "next", "package.json"),
93739
- path4.join(appDir, "node_modules", "next", "package.json")
94178
+ path5.join(appDir, ".next", "standalone", "node_modules", "next", "package.json"),
94179
+ path5.join(appDir, "node_modules", "next", "package.json")
93740
94180
  ];
93741
94181
  for (const candidate of candidates) {
93742
94182
  try {
93743
- const parsed = z6.object({ version: z6.string() }).parse(JSON.parse(await readFile4(candidate, "utf8")));
94183
+ const parsed = z6.object({ version: z6.string() }).parse(JSON.parse(await readFile5(candidate, "utf8")));
93744
94184
  return parsed.version;
93745
94185
  } catch {
93746
94186
  continue;
@@ -93749,88 +94189,54 @@ async function readNextVersion(appDir) {
93749
94189
  return null;
93750
94190
  }
93751
94191
 
93752
- // src/target.ts
93753
- import { access, readFile as readFile5 } from "fs/promises";
93754
- import path5 from "path";
93755
- var TargetError = class extends Error {
93756
- code;
93757
- constructor(code, message) {
93758
- super(message);
93759
- this.name = "TargetError";
93760
- this.code = code;
93761
- }
93762
- };
93763
- async function exists(file) {
93764
- try {
93765
- await access(file);
93766
- return true;
93767
- } catch {
93768
- return false;
94192
+ // src/runner.ts
94193
+ import { createHash as createHash3 } from "crypto";
94194
+ import { mkdir as mkdir2, writeFile } from "fs/promises";
94195
+ import { createServer } from "net";
94196
+ import path6 from "path";
94197
+
94198
+ // src/isr.ts
94199
+ var REVALIDATE_HEADER = "x-prerender-revalidate";
94200
+ function revalidateSecondsFor(manifest, route) {
94201
+ const routes = manifest?.routes;
94202
+ if (routes === void 0) {
94203
+ return null;
93769
94204
  }
93770
- }
93771
- async function readManifest(file, parse2) {
93772
- let raw;
93773
- try {
93774
- raw = JSON.parse(await readFile5(file, "utf8"));
93775
- } catch (cause) {
93776
- throw new TargetError(
93777
- "BAD_MANIFEST",
93778
- `Could not read ${file}: ${cause instanceof Error ? cause.message : String(cause)}`
93779
- );
94205
+ const direct = routes[route]?.initialRevalidateSeconds;
94206
+ if (typeof direct === "number") {
94207
+ return direct;
93780
94208
  }
93781
- try {
93782
- return parse2(raw);
93783
- } catch {
93784
- throw new TargetError(
93785
- "BAD_MANIFEST",
93786
- `${file} does not match the expected Next.js manifest shape. This Next.js version may not be supported yet.`
93787
- );
94209
+ for (const entry of Object.values(routes)) {
94210
+ if (entry.srcRoute === route && typeof entry.initialRevalidateSeconds === "number") {
94211
+ return entry.initialRevalidateSeconds;
94212
+ }
93788
94213
  }
94214
+ return null;
93789
94215
  }
93790
- async function validateTarget(appDir) {
93791
- const nextDir = path5.resolve(appDir, ".next");
93792
- if (!await exists(nextDir)) {
93793
- throw new TargetError(
93794
- "NO_BUILD",
93795
- `No .next directory in ${appDir}. Run "next build" first.`
93796
- );
94216
+ function revalidates(manifest, route) {
94217
+ return revalidateSecondsFor(manifest, route) !== null;
94218
+ }
94219
+ function planRevalidation(manifest, route, userHeaders) {
94220
+ const userSupplied = Object.keys(userHeaders ?? {}).some(
94221
+ (name) => name.toLowerCase() === REVALIDATE_HEADER
94222
+ );
94223
+ if (userSupplied) {
94224
+ return { kind: "drive", headers: {} };
93797
94225
  }
93798
- const standaloneServer = path5.join(nextDir, "standalone", "server.js");
93799
- if (!await exists(standaloneServer)) {
93800
- throw new TargetError(
93801
- "NO_STANDALONE",
93802
- `No ${standaloneServer}.
93803
- next-leak measures the standalone server bundle. Enable it once in next.config:
93804
-
93805
- const nextConfig = {
93806
- output: "standalone",
93807
- // ...your existing config
93808
- };
93809
-
93810
- then rebuild: next build
93811
- This only changes how the build is packaged \u2014 not how your app behaves.`
93812
- );
94226
+ if (!revalidates(manifest, route)) {
94227
+ return { kind: "not-isr" };
93813
94228
  }
93814
- const appPathsFile = path5.join(nextDir, "server", "app-paths-manifest.json");
93815
- const pagesFile = path5.join(nextDir, "server", "pages-manifest.json");
93816
- const appPaths = await exists(appPathsFile) ? await readManifest(appPathsFile, (raw) => appPathsManifestSchema.parse(raw)) : {};
93817
- const pages = await exists(pagesFile) ? await readManifest(pagesFile, (raw) => pagesManifestSchema.parse(raw)) : {};
93818
- if (Object.keys(appPaths).length === 0 && Object.keys(pages).length === 0) {
93819
- throw new TargetError(
93820
- "BAD_MANIFEST",
93821
- `No routes found in ${nextDir}/server: neither app-paths-manifest.json nor pages-manifest.json is present. Rebuild the app with "next build".`
93822
- );
94229
+ const previewModeId = manifest?.preview?.previewModeId;
94230
+ if (previewModeId === void 0 || previewModeId === "") {
94231
+ return {
94232
+ kind: "cannot-drive",
94233
+ reason: `revalidates every ${revalidateSecondsFor(manifest, route)}s, but the build's prerender-manifest.json carries no previewModeId \u2014 load would serve the cache and measure nothing. Set "${REVALIDATE_HEADER}" in next-leak.config.json to drive it.`
94234
+ };
93823
94235
  }
93824
- const routesFile = path5.join(nextDir, "routes-manifest.json");
93825
- const routes = await exists(routesFile) ? await readManifest(routesFile, (raw) => routesManifestSchema.parse(raw)) : void 0;
93826
- return { appDir: path5.resolve(appDir), standaloneServer, appPaths, pages, routes };
94236
+ return { kind: "drive", headers: { [REVALIDATE_HEADER]: previewModeId } };
93827
94237
  }
93828
94238
 
93829
94239
  // src/runner.ts
93830
- import { createHash as createHash3 } from "crypto";
93831
- import { mkdir as mkdir2, writeFile } from "fs/promises";
93832
- import { createServer } from "net";
93833
- import path6 from "path";
93834
94240
  var ESTIMATED_RPS = 250;
93835
94241
  var PER_ROUTE_OVERHEAD_SECONDS = 10;
93836
94242
  var MIN_SETTLE_SECONDS = 4;
@@ -93937,6 +94343,12 @@ function skipReason(route, requestPath) {
93937
94343
  }
93938
94344
  async function measureRoute(context, route, requestPath, index, pass = void 0) {
93939
94345
  const { deps, options, target, workDir, routeConfig, registry, nextVersion, progress } = context;
94346
+ const plan = planRevalidation(target.prerender, route.path, routeConfig.headers);
94347
+ const revalidateSeconds = revalidateSecondsFor(target.prerender, route.path);
94348
+ const bounded = boundedMarkerOf(requestPath);
94349
+ const driven = plan.kind === "drive" ? plan.headers : {};
94350
+ const merged = { ...driven, ...routeConfig.headers ?? {} };
94351
+ const headers = Object.keys(merged).length === 0 ? void 0 : merged;
93940
94352
  const result = await deps.ritual({
93941
94353
  serverPath: target.standaloneServer,
93942
94354
  route: requestPath,
@@ -93952,9 +94364,12 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
93952
94364
  ...pass !== void 0 ? { cycles: pass.cycles } : options.cycles !== void 0 && { cycles: options.cycles },
93953
94365
  ...options.idleMs !== void 0 && { idleMs: options.idleMs },
93954
94366
  ...options.maxOldSpaceMb !== void 0 && { maxOldSpaceMb: options.maxOldSpaceMb },
93955
- ...routeConfig.headers !== void 0 && { headers: routeConfig.headers },
94367
+ ...headers !== void 0 && { headers },
93956
94368
  ...routeConfig.abandonAfterMs !== void 0 && {
93957
94369
  abandonAfterMs: routeConfig.abandonAfterMs
94370
+ },
94371
+ ...routeConfig.abandonFrom !== void 0 && {
94372
+ abandonFrom: routeConfig.abandonFrom
93958
94373
  }
93959
94374
  });
93960
94375
  const confidence = assessConfidence({
@@ -93966,8 +94381,12 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
93966
94381
  minGrowthPerCycle: result.minGrowthPerCycle,
93967
94382
  memorySamples: result.memorySamples,
93968
94383
  maxOldSpaceMb: options.maxOldSpaceMb ?? DEFAULT_MAX_OLD_SPACE_MB,
94384
+ warmupRequests: options.warmupRequests ?? RITUAL_DEFAULTS.warmupRequests,
93969
94385
  ...routeConfig.abandonAfterMs !== void 0 && {
93970
94386
  abandonAfterMs: routeConfig.abandonAfterMs
94387
+ },
94388
+ ...routeConfig.abandonFrom !== void 0 && {
94389
+ abandonFrom: routeConfig.abandonFrom
93971
94390
  }
93972
94391
  });
93973
94392
  const verdict = confidence.supersededVerdict ?? result.trend.verdict;
@@ -93977,7 +94396,13 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
93977
94396
  let diff = null;
93978
94397
  if (verdict !== "stable" || options.diffAll === true) {
93979
94398
  progress(`diffing snapshots for ${route.path}`);
93980
- diff = await deps.diff(result.baselineSnapshot, result.afterSnapshot);
94399
+ try {
94400
+ diff = await deps.diff(result.baselineSnapshot, result.afterSnapshot);
94401
+ } catch (cause) {
94402
+ progress(
94403
+ `snapshot diff unavailable for ${route.path}: ${cause instanceof Error ? cause.message : String(cause)}`
94404
+ );
94405
+ }
93981
94406
  }
93982
94407
  return {
93983
94408
  route: route.path,
@@ -93987,6 +94412,17 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
93987
94412
  samples: result.samples,
93988
94413
  memorySamples: result.memorySamples,
93989
94414
  peaks: result.peaks,
94415
+ ...revalidateSeconds !== null && { revalidatedEverySeconds: revalidateSeconds },
94416
+ ...bounded !== null && { keyCardinality: bounded.bound },
94417
+ unreclaimedSamples: result.unreclaimedSamples,
94418
+ unreclaimedTrend: result.unreclaimedTrend,
94419
+ requestsPerCycle: result.requestsPerCycle,
94420
+ ...routeConfig.abandonAfterMs !== void 0 && {
94421
+ abandon: {
94422
+ afterMs: routeConfig.abandonAfterMs,
94423
+ from: routeConfig.abandonFrom ?? "first-byte"
94424
+ }
94425
+ },
93990
94426
  timings: result.timings,
93991
94427
  loadOutcomes: result.loadOutcomes,
93992
94428
  settleOutcomes: result.settleOutcomes,
@@ -94002,7 +94438,7 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
94002
94438
  };
94003
94439
  }
94004
94440
  async function writeEvidenceBundle(report, workDir) {
94005
- const { renderHtmlReport } = await import("./html-report-X2LUOZRH.js");
94441
+ const { renderHtmlReport } = await import("./html-report-TN7PYVU4.js");
94006
94442
  const { renderIssueMarkdown } = await import("./issue-report-HKA26WNV.js");
94007
94443
  for (const route of report.routes) {
94008
94444
  if (route.status === "measured" && warrantsIssueDraft(route)) {
@@ -94022,6 +94458,11 @@ async function routeReportFor(context, route, index, total) {
94022
94458
  progress(`skipping ${label}: ${reason ?? "needs sample params"}`);
94023
94459
  return { route: route.path, status: "skipped", reason: reason ?? "needs sample params" };
94024
94460
  }
94461
+ const plan = planRevalidation(context.target.prerender, route.path, routeConfig.headers);
94462
+ if (plan.kind === "cannot-drive") {
94463
+ progress(`not measuring ${label}: ${plan.reason}`);
94464
+ return { route: route.path, status: "not-exercised", reason: plan.reason };
94465
+ }
94025
94466
  try {
94026
94467
  const asPath = requestPath === route.path ? "" : ` as ${requestPath}`;
94027
94468
  progress(`measuring ${label}${asPath}`);
@@ -94117,6 +94558,7 @@ async function runMeasurement(options, deps = defaultDeps2) {
94117
94558
  appDir: target.appDir,
94118
94559
  startedAt: startedAt.toISOString(),
94119
94560
  workDir,
94561
+ ...target.prerender !== void 0 && { prerender: target.prerender },
94120
94562
  environment: captureEnvironment(nextVersion),
94121
94563
  parameters,
94122
94564
  routes: reports,
@@ -94141,17 +94583,27 @@ async function runMeasurement(options, deps = defaultDeps2) {
94141
94583
  }
94142
94584
 
94143
94585
  export {
94144
- helpText,
94145
- parseCliArgs,
94146
- checkRuntime,
94586
+ appPathsManifestSchema,
94587
+ routesManifestSchema,
94588
+ discoverPagesRoutes,
94589
+ discoverRoutes,
94590
+ TargetError,
94591
+ validateTarget,
94147
94592
  LaunchError,
94148
94593
  killActiveChildren,
94594
+ registerChild,
94595
+ unregisterChild,
94149
94596
  launchInstrumented,
94150
- formatReport,
94597
+ helpText,
94598
+ parseCliArgs,
94599
+ checkRuntime,
94151
94600
  RouteConfigError,
94152
94601
  ROUTE_CONFIG_FILE,
94153
94602
  loadRouteConfig,
94154
94603
  resolveRoutePath,
94604
+ renderConfigSkeleton,
94605
+ hasPlaceholders,
94606
+ formatReport,
94155
94607
  classifySource,
94156
94608
  attributeFinding,
94157
94609
  attributeDiff,
@@ -94162,9 +94614,6 @@ export {
94162
94614
  SnapshotError,
94163
94615
  assertReadableSnapshot,
94164
94616
  diffSnapshotFiles,
94165
- appPathsManifestSchema,
94166
- routesManifestSchema,
94167
- discoverRoutes,
94168
94617
  decodeVlqLine,
94169
94618
  decodeMappings,
94170
94619
  sourceIndexAt,
@@ -94176,8 +94625,6 @@ export {
94176
94625
  loadSignatures,
94177
94626
  matchSignatures,
94178
94627
  readNextVersion,
94179
- TargetError,
94180
- validateTarget,
94181
94628
  freePort,
94182
94629
  routeSlug,
94183
94630
  runMeasurement