next-leak 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@ import {
7
7
  minGrowthFor,
8
8
  resolveCycles,
9
9
  warrantsIssueDraft
10
- } from "./chunk-2FZXZLZW.js";
10
+ } from "./chunk-22PBLCGO.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,173 +92592,234 @@ 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
+ }).strict();
92629
+ var RouteConfigError = class extends Error {
92200
92630
  constructor(message) {
92201
92631
  super(message);
92202
- this.name = "LaunchError";
92632
+ this.name = "RouteConfigError";
92203
92633
  }
92204
92634
  };
92205
- var activeChildren = /* @__PURE__ */ new Set();
92206
- function killActiveChildren() {
92207
- for (const child of activeChildren) {
92208
- child.kill("SIGKILL");
92635
+ var ROUTE_CONFIG_FILE = "next-leak.config.json";
92636
+ async function loadRouteConfig(appDir) {
92637
+ const file = path3.join(appDir, ROUTE_CONFIG_FILE);
92638
+ let raw;
92639
+ try {
92640
+ raw = await readFile3(file, "utf8");
92641
+ } catch {
92642
+ return {};
92643
+ }
92644
+ try {
92645
+ return routeConfigSchema.parse(JSON.parse(raw));
92646
+ } catch (cause) {
92647
+ throw new RouteConfigError(
92648
+ `${file} is invalid: ${cause instanceof Error ? cause.message : String(cause)}`
92649
+ );
92209
92650
  }
92210
92651
  }
92211
- function explainStartupFailure(stderr) {
92212
- const missingModule = /Cannot find module '([^']+)'/.exec(stderr);
92213
- if (missingModule !== null) {
92214
- 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.`;
92652
+ var SEGMENT_PATTERN = /^\[(\[)?(\.\.\.)?([^\]]+?)\]?\]$/;
92653
+ function dynamicSegmentsOf(routeTemplate) {
92654
+ const segments = [];
92655
+ for (const segment of routeTemplate.split("/")) {
92656
+ const match = SEGMENT_PATTERN.exec(segment);
92657
+ if (match === null) {
92658
+ continue;
92659
+ }
92660
+ segments.push({
92661
+ name: match[3] ?? "",
92662
+ catchAll: match[2] !== void 0,
92663
+ optional: match[1] !== void 0
92664
+ });
92215
92665
  }
92216
- if (/EADDRINUSE/.test(stderr)) {
92217
- return "the port was taken by another process while starting.";
92666
+ return segments;
92667
+ }
92668
+ var UNIQUE_MARKER = "{n}";
92669
+ var BOUNDED_MARKER = /\{n%(\d+)\}/;
92670
+ function boundedMarkerOf(value) {
92671
+ const match = BOUNDED_MARKER.exec(value);
92672
+ const bound = Number(match?.[1]);
92673
+ if (match === null || !Number.isInteger(bound) || bound <= 0) {
92674
+ return null;
92218
92675
  }
92219
- return `stderr:
92220
- ${stderr}`;
92676
+ return { marker: match[0], bound };
92221
92677
  }
92222
- function explainRuntimeFailure(stderr, maxOldSpaceMb) {
92223
- if (/heap out of memory|Reached heap limit|Ineffective mark-compacts/i.test(stderr)) {
92224
- 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.`;
92678
+ var ENCODED_MARKER = encodeURIComponent(UNIQUE_MARKER);
92679
+ var encodeSegment = (value) => restoreMarkers(encodeURIComponent(value).split(ENCODED_MARKER).join(UNIQUE_MARKER));
92680
+ var ENCODED_BOUNDED = /%7Bn%25(\d+)%7D/gi;
92681
+ var restoreMarkers = (value) => value.replace(ENCODED_BOUNDED, (_match, bound) => `{n%${bound}}`);
92682
+ var encodeCatchAll = (value) => value.split("/").map(encodeSegment).join("/");
92683
+ function resolveRoutePath(routeTemplate, config) {
92684
+ const resolved = [];
92685
+ for (const segment of routeTemplate.split("/")) {
92686
+ const match = SEGMENT_PATTERN.exec(segment);
92687
+ if (match === null) {
92688
+ resolved.push(encodeSegment(segment));
92689
+ continue;
92690
+ }
92691
+ const optional = match[1] !== void 0;
92692
+ const catchAll = match[2] !== void 0;
92693
+ const name = match[3] ?? "";
92694
+ const value = config.routes?.[routeTemplate]?.[name] ?? config.params?.[name];
92695
+ if (value !== void 0) {
92696
+ resolved.push(catchAll ? encodeCatchAll(value) : encodeSegment(value));
92697
+ } else if (!optional) {
92698
+ return null;
92699
+ }
92225
92700
  }
92226
- return `the measured process exited mid-run. ${explainStartupFailure(stderr)}`;
92701
+ const joined = resolved.join("/");
92702
+ const path7 = joined === "" ? "/" : joined;
92703
+ const query = config.query?.[routeTemplate];
92704
+ return query === void 0 || query === "" ? path7 : `${path7}?${query}`;
92227
92705
  }
92228
- var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
92229
- async function pollUntil(deadline, what, probe, failed) {
92230
- for (; ; ) {
92231
- const failure = failed();
92232
- if (failure !== void 0) {
92233
- throw new LaunchError(failure);
92706
+
92707
+ // src/route-guidance.ts
92708
+ var PLACEHOLDER = "REPLACE-ME";
92709
+ function sampleValuesFromManifest(manifest, routeTemplate) {
92710
+ const routes = manifest?.routes;
92711
+ if (routes === void 0) {
92712
+ return null;
92713
+ }
92714
+ const concrete = Object.entries(routes).find(([, entry]) => entry.srcRoute === routeTemplate)?.[0];
92715
+ if (concrete === void 0) {
92716
+ return null;
92717
+ }
92718
+ const templateSegments = routeTemplate.split("/");
92719
+ const concreteSegments = concrete.split("/");
92720
+ const values = {};
92721
+ for (const [index, segment] of templateSegments.entries()) {
92722
+ const dynamic = dynamicSegmentsOf(segment);
92723
+ const parameter = dynamic[0];
92724
+ if (parameter === void 0) {
92725
+ continue;
92234
92726
  }
92235
- const result = await probe();
92236
- if (result !== void 0) {
92237
- return result;
92727
+ const value = parameter.catchAll ? concreteSegments.slice(index).join("/") : concreteSegments[index];
92728
+ if (value === void 0 || value === "") {
92729
+ return null;
92238
92730
  }
92239
- if (Date.now() > deadline) {
92240
- throw new LaunchError(`timed out waiting for ${what}`);
92731
+ values[parameter.name] = value;
92732
+ }
92733
+ return Object.keys(values).length === 0 ? null : values;
92734
+ }
92735
+ function renderConfigSkeleton(routeTemplates, manifest = void 0) {
92736
+ const routes = {};
92737
+ for (const template of routeTemplates) {
92738
+ const segments = dynamicSegmentsOf(template);
92739
+ if (segments.length === 0) {
92740
+ continue;
92241
92741
  }
92242
- await sleep(100);
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;
92243
92748
  }
92749
+ return Object.keys(routes).length === 0 ? null : JSON.stringify({ routes }, null, 2);
92244
92750
  }
92245
- async function launchInstrumented(options) {
92246
- const hostname = options.hostname ?? "127.0.0.1";
92247
- const deadline = Date.now() + (options.readyTimeoutMs ?? 15e3);
92248
- const child = spawn(
92249
- process.execPath,
92250
- [
92251
- "--expose-gc",
92252
- `--max-old-space-size=${options.maxOldSpaceMb ?? DEFAULT_MAX_OLD_SPACE_MB}`,
92253
- "--import",
92254
- pathToFileURL(options.bootstrapPath).href,
92255
- options.serverPath
92256
- ],
92257
- {
92258
- cwd: path.dirname(options.serverPath),
92259
- env: {
92260
- ...process.env,
92261
- ...options.env,
92262
- NODE_ENV: "production",
92263
- PORT: String(options.appPort),
92264
- HOSTNAME: hostname,
92265
- NEXT_LEAK_DIR: options.workDir
92266
- },
92267
- stdio: ["ignore", "ignore", "pipe"]
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;
92268
92771
  }
92269
- );
92270
- let stderrTail = "";
92271
- child.stderr?.on("data", (chunk) => {
92272
- stderrTail = (stderrTail + chunk.toString()).slice(-2e3);
92273
- });
92274
- let exited = false;
92275
- activeChildren.add(child);
92276
- child.once("exit", () => {
92277
- exited = true;
92278
- activeChildren.delete(child);
92279
- });
92280
- const failed = () => exited ? `server exited before becoming ready. ${explainStartupFailure(stderrTail)}` : void 0;
92281
- try {
92282
- const controlPort = await pollUntil(
92283
- deadline,
92284
- `control channel in ${options.workDir}`,
92285
- // Several processes may announce a channel (clustered servers); accept
92286
- // the first one that actually answers instead of trusting a filename.
92287
- async () => {
92288
- let entries;
92289
- try {
92290
- entries = (await readdir(options.workDir)).filter(
92291
- (entry) => entry.startsWith("control-") && entry.endsWith(".json")
92292
- );
92293
- } catch {
92294
- return void 0;
92295
- }
92296
- for (const entry of entries) {
92297
- try {
92298
- const parsed = controlFileSchema.parse(
92299
- JSON.parse(await readFile(path.join(options.workDir, entry), "utf8"))
92300
- );
92301
- const response = await fetch(`http://127.0.0.1:${parsed.port}/gc`);
92302
- if (response.ok) {
92303
- return parsed.port;
92304
- }
92305
- } catch {
92306
- continue;
92307
- }
92308
- }
92309
- return void 0;
92310
- },
92311
- failed
92312
- );
92313
- await pollUntil(
92314
- deadline,
92315
- `app on ${hostname}:${options.appPort}`,
92316
- async () => {
92317
- try {
92318
- await fetch(`http://${hostname}:${options.appPort}/`, { method: "HEAD" });
92319
- return true;
92320
- } catch {
92321
- return void 0;
92322
- }
92323
- },
92324
- failed
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))
92325
92792
  );
92326
- return {
92327
- pid: child.pid ?? -1,
92328
- appPort: options.appPort,
92329
- controlPort,
92330
- explainExit: () => exited ? explainRuntimeFailure(stderrTail, options.maxOldSpaceMb ?? DEFAULT_MAX_OLD_SPACE_MB) : null,
92331
- close: async () => {
92332
- if (exited) {
92333
- return;
92334
- }
92335
- const gone = new Promise((resolve) => child.once("exit", () => resolve()));
92336
- child.kill("SIGTERM");
92337
- const killTimer = setTimeout(() => child.kill("SIGKILL"), 3e3);
92338
- await gone;
92339
- clearTimeout(killTimer);
92340
- }
92341
- };
92342
- } catch (cause) {
92343
- child.kill("SIGKILL");
92344
- throw cause;
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
+ }
92345
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`;
92346
92811
  }
92347
92812
 
92348
92813
  // src/report.ts
92349
- var MB = 1024 * 1024;
92350
- var formatMb = (bytes) => `${(bytes / MB).toFixed(1)} MB`;
92814
+ var MB2 = 1024 * 1024;
92815
+ var formatMb = (bytes) => `${(bytes / MB2).toFixed(1)} MB`;
92351
92816
  var formatGrowth = (bytes) => {
92352
92817
  const sign = bytes >= 0 ? "+" : "";
92353
- return `${sign}${(bytes / MB).toFixed(2)} MB/1000 req`;
92818
+ return `${sign}${(bytes / MB2).toFixed(2)} MB/1000 req`;
92354
92819
  };
92355
92820
  var VERDICT_ICON = { leak: "\u2716", stable: "\u2714", inconclusive: "?" };
92356
- var RSS_MIN_GROWTH_PER_CYCLE = 16 * MB;
92357
- var RSS_MIN_TOTAL_GROWTH = 64 * MB;
92821
+ var RSS_MIN_GROWTH_PER_CYCLE = 16 * MB2;
92822
+ var RSS_MIN_TOTAL_GROWTH = 64 * MB2;
92358
92823
  function hasSustainedRssGrowth(memorySamples) {
92359
92824
  const rss = memorySamples.map((sample) => sample.rss);
92360
92825
  const first = rss[1];
@@ -92376,6 +92841,11 @@ function ownerLabel(attribution) {
92376
92841
  return null;
92377
92842
  }
92378
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
+ }
92379
92849
  function confidenceLines(route) {
92380
92850
  const lines = [];
92381
92851
  if (route.confidence.supersededVerdict !== void 0) {
@@ -92442,6 +92912,15 @@ function peakPressureLines(route, parameters) {
92442
92912
  });
92443
92913
  return pressure === null ? [] : [` \u25B2 peak pressure: ${describePeakPressure(pressure)}`];
92444
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
+ }
92445
92924
  function routeLines(route, parameters) {
92446
92925
  if (route.status === "skipped") {
92447
92926
  return [` \u2013 ${route.route} skipped: ${route.reason}`];
@@ -92449,6 +92928,9 @@ function routeLines(route, parameters) {
92449
92928
  if (route.status === "failed") {
92450
92929
  return [` \u2716 ${route.route} failed: ${route.reason}`];
92451
92930
  }
92931
+ if (route.status === "not-exercised") {
92932
+ return [` \u2013 ${route.route} not exercised: ${route.reason}`];
92933
+ }
92452
92934
  const verdict = effectiveVerdict(route);
92453
92935
  const curve = route.samples.map(formatMb).join(" \u2192 ");
92454
92936
  const resolved = route.resolvedWithCycles === void 0 ? "" : ` (resolved at ${route.resolvedWithCycles} cycles)`;
@@ -92456,17 +92938,45 @@ function routeLines(route, parameters) {
92456
92938
  ` ${VERDICT_ICON[verdict]} ${route.route} ${verdict} (${formatGrowth(
92457
92939
  route.growthPer1000Requests
92458
92940
  )}) heap ${curve}${resolved}`,
92941
+ ...revalidationLines(route),
92459
92942
  ...confidenceLines(route),
92460
92943
  ...memorySourceLines(route, verdict),
92461
92944
  ...peakPressureLines(route, parameters),
92945
+ ...unreclaimedLines(route, verdict),
92462
92946
  ...findingLines(route)
92463
92947
  ];
92464
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
+ }
92465
92974
  function formatReport(report) {
92466
92975
  const lines = [`next-leak \u2014 ${report.appDir}`, ""];
92467
92976
  for (const route of report.routes) {
92468
92977
  lines.push(...routeLines(route, report.parameters));
92469
92978
  }
92979
+ lines.push(...skippedGuidanceLines(report));
92470
92980
  const { minGrowthPerCycle, loadRequests, cycles, maxOldSpaceMb } = report.parameters;
92471
92981
  const resolvedCycles = [
92472
92982
  ...new Set(
@@ -92476,8 +92986,8 @@ function formatReport(report) {
92476
92986
  )
92477
92987
  ].sort((a, b) => a - b);
92478
92988
  const cyclesLabel = resolvedCycles.length === 0 ? `${cycles} cycles` : `${cycles} cycles (${resolvedCycles.join(", ")} where resolved)`;
92989
+ lines.push("", coverageLine(report));
92479
92990
  lines.push(
92480
- "",
92481
92991
  `judged over ${cyclesLabel} \xD7 ${loadRequests} requests, growth gate ${(minGrowthPerCycle / 1024).toFixed(0)} KiB/cycle (${formatGrowth(minGrowthPerCycle / loadRequests * 1e3)}), heap cap ${maxOldSpaceMb} MB`,
92482
92992
  `snapshots and run.json: ${report.workDir}`,
92483
92993
  `report: ${report.bundle.htmlReport}`,
@@ -92498,88 +93008,6 @@ function formatReport(report) {
92498
93008
  return lines.join("\n");
92499
93009
  }
92500
93010
 
92501
- // src/route-config.ts
92502
- import { readFile as readFile2 } from "fs/promises";
92503
- import path2 from "path";
92504
- import { z as z2 } from "zod";
92505
- var routeConfigSchema = z2.object({
92506
- params: z2.record(z2.string(), z2.string()).optional(),
92507
- routes: z2.record(z2.string(), z2.record(z2.string(), z2.string())).optional(),
92508
- /**
92509
- * Headers sent with every request. Real traffic is not header-less:
92510
- * compression (`Accept-Encoding`), sessions and auth change which code
92511
- * paths run, and some leaks only exist on those paths (zlib, per-session
92512
- * caches). Measuring without them measures a different app.
92513
- */
92514
- headers: z2.record(z2.string(), z2.string()).optional(),
92515
- /** Query string appended per route template, e.g. `"weightKb=2048"`. */
92516
- query: z2.record(z2.string(), z2.string()).optional(),
92517
- /**
92518
- * Simulates clients that give up before the response arrives (closed
92519
- * tabs, load-balancer timeouts, bots). Several real leaks only exist on
92520
- * that path — vercel/next.js#89091 traces `ServerResponse` retention to
92521
- * an early disconnect — and a load generator that always waits politely
92522
- * never reaches it.
92523
- *
92524
- * Counted **from the first byte of the response**, not from the request:
92525
- * under load a server's first byte arrives long after any sane fixed
92526
- * window, so a request-relative clock cuts before the stream starts and
92527
- * tests the wrong path entirely.
92528
- */
92529
- abandonAfterMs: z2.number().int().positive().optional()
92530
- }).strict();
92531
- var RouteConfigError = class extends Error {
92532
- constructor(message) {
92533
- super(message);
92534
- this.name = "RouteConfigError";
92535
- }
92536
- };
92537
- var ROUTE_CONFIG_FILE = "next-leak.config.json";
92538
- async function loadRouteConfig(appDir) {
92539
- const file = path2.join(appDir, ROUTE_CONFIG_FILE);
92540
- let raw;
92541
- try {
92542
- raw = await readFile2(file, "utf8");
92543
- } catch {
92544
- return {};
92545
- }
92546
- try {
92547
- return routeConfigSchema.parse(JSON.parse(raw));
92548
- } catch (cause) {
92549
- throw new RouteConfigError(
92550
- `${file} is invalid: ${cause instanceof Error ? cause.message : String(cause)}`
92551
- );
92552
- }
92553
- }
92554
- var SEGMENT_PATTERN = /^\[(\[)?(\.\.\.)?([^\]]+?)\]?\]$/;
92555
- var UNIQUE_MARKER = "{n}";
92556
- var ENCODED_MARKER = encodeURIComponent(UNIQUE_MARKER);
92557
- var encodeSegment = (value) => encodeURIComponent(value).split(ENCODED_MARKER).join(UNIQUE_MARKER);
92558
- var encodeCatchAll = (value) => value.split("/").map(encodeSegment).join("/");
92559
- function resolveRoutePath(routeTemplate, config) {
92560
- const resolved = [];
92561
- for (const segment of routeTemplate.split("/")) {
92562
- const match = SEGMENT_PATTERN.exec(segment);
92563
- if (match === null) {
92564
- resolved.push(encodeSegment(segment));
92565
- continue;
92566
- }
92567
- const optional = match[1] !== void 0;
92568
- const catchAll = match[2] !== void 0;
92569
- const name = match[3] ?? "";
92570
- const value = config.routes?.[routeTemplate]?.[name] ?? config.params?.[name];
92571
- if (value !== void 0) {
92572
- resolved.push(catchAll ? encodeCatchAll(value) : encodeSegment(value));
92573
- } else if (!optional) {
92574
- return null;
92575
- }
92576
- }
92577
- const joined = resolved.join("/");
92578
- const path7 = joined === "" ? "/" : joined;
92579
- const query = config.query?.[routeTemplate];
92580
- return query === void 0 || query === "" ? path7 : `${path7}?${query}`;
92581
- }
92582
-
92583
93011
  // src/attribution.ts
92584
93012
  var UNATTRIBUTED = { owner: "unattributed", source: null, packageName: null };
92585
93013
  function classifySource(rawSource) {
@@ -92845,6 +93273,7 @@ var SNAPSHOT_HEAD = '{"snapshot"';
92845
93273
  var PROBE_BYTES = 512;
92846
93274
  async function assertReadableSnapshot(file) {
92847
93275
  const { open, stat } = await import("fs/promises");
93276
+ const { constants } = await import("buffer");
92848
93277
  let size;
92849
93278
  try {
92850
93279
  size = (await stat(file)).size;
@@ -92854,6 +93283,11 @@ async function assertReadableSnapshot(file) {
92854
93283
  if (size === 0) {
92855
93284
  throw new SnapshotError(`heap snapshot is empty: ${file}`);
92856
93285
  }
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`
93289
+ );
93290
+ }
92857
93291
  const handle = await open(file, "r");
92858
93292
  try {
92859
93293
  const head = Buffer.alloc(Math.min(PROBE_BYTES, size));
@@ -92885,80 +93319,6 @@ async function diffSnapshotFiles(baselineFile, afterFile, options = {}, loadHeap
92885
93319
  return diffAgainstBaseline(summary, after, options);
92886
93320
  }
92887
93321
 
92888
- // src/manifests.ts
92889
- import { z as z3 } from "zod";
92890
- var appPathsManifestSchema = z3.record(z3.string(), z3.string());
92891
- var pagesManifestSchema = z3.record(z3.string(), z3.string());
92892
- var routeEntrySchema = z3.looseObject({
92893
- page: z3.string(),
92894
- regex: z3.string()
92895
- });
92896
- var routesManifestSchema = z3.looseObject({
92897
- version: z3.number(),
92898
- basePath: z3.string(),
92899
- staticRoutes: z3.array(routeEntrySchema),
92900
- dynamicRoutes: z3.array(routeEntrySchema)
92901
- });
92902
- var INTERNAL_PATHS = /* @__PURE__ */ new Set(["/_global-error", "/_not-found"]);
92903
- var INTERCEPTING_SEGMENT = /^(\(\.{1,3}\))+/;
92904
- var STATIC_ASSET_PATHS = /* @__PURE__ */ new Set(["/favicon.ico"]);
92905
- function unaddressableReason(routePath) {
92906
- if (routePath.split("/").some((segment) => INTERCEPTING_SEGMENT.test(segment))) {
92907
- return "intercepting route \u2014 only reachable via client navigation";
92908
- }
92909
- if (STATIC_ASSET_PATHS.has(routePath)) {
92910
- return "static asset served by a generated handler \u2014 no user code to leak";
92911
- }
92912
- return void 0;
92913
- }
92914
- function toRequestPath(manifestKey) {
92915
- let kind;
92916
- let withoutSuffix;
92917
- if (manifestKey.endsWith("/page")) {
92918
- kind = "page";
92919
- withoutSuffix = manifestKey.slice(0, -"/page".length);
92920
- } else if (manifestKey.endsWith("/route")) {
92921
- kind = "route-handler";
92922
- withoutSuffix = manifestKey.slice(0, -"/route".length);
92923
- } else {
92924
- return null;
92925
- }
92926
- const segments = withoutSuffix.split("/").filter((segment) => segment !== "").filter((segment) => !(segment.startsWith("(") && segment.endsWith(")"))).filter((segment) => !segment.startsWith("@"));
92927
- return { path: `/${segments.join("/")}`, kind };
92928
- }
92929
- var PAGES_INTERNAL = /* @__PURE__ */ new Set(["/_app", "/_document", "/_error", "/404", "/500"]);
92930
- function discoverPagesRoutes(pages) {
92931
- const routes = [];
92932
- for (const key of Object.keys(pages)) {
92933
- if (PAGES_INTERNAL.has(key)) {
92934
- continue;
92935
- }
92936
- routes.push({
92937
- path: key,
92938
- kind: key === "/api" || key.startsWith("/api/") ? "route-handler" : "page",
92939
- dynamic: key.includes("[")
92940
- });
92941
- }
92942
- return routes.sort((a, b) => a.path.localeCompare(b.path));
92943
- }
92944
- function discoverRoutes(appPaths) {
92945
- const routes = /* @__PURE__ */ new Map();
92946
- for (const key of Object.keys(appPaths)) {
92947
- const parsed = toRequestPath(key);
92948
- if (parsed === null || INTERNAL_PATHS.has(parsed.path) || routes.has(parsed.path)) {
92949
- continue;
92950
- }
92951
- const reason = unaddressableReason(parsed.path);
92952
- routes.set(parsed.path, {
92953
- path: parsed.path,
92954
- kind: parsed.kind,
92955
- dynamic: parsed.path.includes("["),
92956
- ...reason !== void 0 && { unaddressableReason: reason }
92957
- });
92958
- }
92959
- return [...routes.values()].sort((a, b) => a.path.localeCompare(b.path));
92960
- }
92961
-
92962
93322
  // src/vlq.ts
92963
93323
  var BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
92964
93324
  var CHAR_TO_VALUE = new Map([...BASE64].map((char, index) => [char, index]));
@@ -93028,8 +93388,8 @@ function sourceIndexAt(lines, line, column) {
93028
93388
  }
93029
93389
 
93030
93390
  // src/module-registry.ts
93031
- import { readdir as readdir2, readFile as readFile3 } from "fs/promises";
93032
- import path3 from "path";
93391
+ import { readdir as readdir2, readFile as readFile4 } from "fs/promises";
93392
+ import path4 from "path";
93033
93393
  import { z as z4 } from "zod";
93034
93394
  var flatMapSchema = z4.looseObject({
93035
93395
  version: z4.number(),
@@ -93153,7 +93513,7 @@ async function jsFilesUnder(dir) {
93153
93513
  }
93154
93514
  for (const entry of entries) {
93155
93515
  if (entry.isFile() && entry.name.endsWith(".js")) {
93156
- out.push(path3.join(entry.parentPath, entry.name));
93516
+ out.push(path4.join(entry.parentPath, entry.name));
93157
93517
  }
93158
93518
  }
93159
93519
  return out;
@@ -93164,8 +93524,8 @@ async function extractModuleRegistry(nextServerDir) {
93164
93524
  let code;
93165
93525
  let map;
93166
93526
  try {
93167
- code = await readFile3(file, "utf8");
93168
- map = JSON.parse(await readFile3(`${file}.map`, "utf8"));
93527
+ code = await readFile4(file, "utf8");
93528
+ map = JSON.parse(await readFile4(`${file}.map`, "utf8"));
93169
93529
  } catch {
93170
93530
  continue;
93171
93531
  }
@@ -93200,15 +93560,44 @@ async function describeRedirect(url) {
93200
93560
  return null;
93201
93561
  }
93202
93562
  }
93563
+ function boundedRequestSequence(fullUrl, bounded) {
93564
+ const parsed = new URL(fullUrl);
93565
+ const template = fullUrl.slice(parsed.origin.length);
93566
+ let counter = 0;
93567
+ return {
93568
+ origin: parsed.origin,
93569
+ requests: [
93570
+ {
93571
+ setupRequest: (request2) => {
93572
+ const value = String(counter % bounded.bound);
93573
+ counter += 1;
93574
+ return { ...request2, path: template.split(bounded.marker).join(value) };
93575
+ }
93576
+ }
93577
+ ]
93578
+ };
93579
+ }
93580
+ function diagnoseFailure(result, connections) {
93581
+ if (result.timeouts > result.non2xx) {
93582
+ 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`;
93583
+ }
93584
+ if (result.non2xx > 0) {
93585
+ 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`;
93586
+ }
93587
+ return "";
93588
+ }
93203
93589
  async function runLoadPhase(options) {
93204
93590
  const unique = options.url.includes(UNIQUE_MARKER);
93205
93591
  const url = unique ? options.url.split(UNIQUE_MARKER).join("[<id>]") : options.url;
93592
+ const bounded = boundedMarkerOf(options.url);
93593
+ const boundedRequests = bounded === null ? void 0 : boundedRequestSequence(options.url, bounded);
93206
93594
  const raw = await (0, import_autocannon.default)({
93207
- url,
93595
+ url: boundedRequests === void 0 ? url : boundedRequests.origin,
93208
93596
  amount: options.amount,
93209
93597
  connections: options.connections,
93210
93598
  ...options.headers !== void 0 && { headers: options.headers },
93211
- ...unique && { idReplacement: true }
93599
+ ...unique && { idReplacement: true },
93600
+ ...boundedRequests !== void 0 && { requests: boundedRequests.requests }
93212
93601
  });
93213
93602
  const result = {
93214
93603
  sent: raw.requests.sent,
@@ -93224,7 +93613,7 @@ async function runLoadPhase(options) {
93224
93613
  const unanswered = failures - result.non2xx - result.errors - result.timeouts;
93225
93614
  const redirect = result.non2xx > 0 ? await describeRedirect(options.url) : null;
93226
93615
  throw new LoadError(
93227
- `${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}`),
93616
+ `${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}`),
93228
93617
  result
93229
93618
  );
93230
93619
  }
@@ -93432,6 +93821,10 @@ var MIN_POLLS_TO_JUDGE = 2;
93432
93821
  var SETTLE_POLL_MS = 2e3;
93433
93822
  var SETTLE_TOLERANCE = 0.01;
93434
93823
  var PEAK_POLL_MS = 250;
93824
+ var UNRECLAIMED_SETTLE_MS = SETTLE_POLL_MS;
93825
+ function unreclaimedSettleFor(idleMs) {
93826
+ return Math.min(UNRECLAIMED_SETTLE_MS, Math.floor(idleMs / 4));
93827
+ }
93435
93828
  function pollPeak(controlPort, phase, deps) {
93436
93829
  const peak = {
93437
93830
  phase,
@@ -93592,6 +93985,8 @@ async function runRitual(options, deps = defaultDeps) {
93592
93985
  () => requestSnapshot(app.controlPort, "baseline")
93593
93986
  );
93594
93987
  const memorySamples = [baseline.sample];
93988
+ const unreclaimedSamples = [];
93989
+ let unreclaimedLost = false;
93595
93990
  const peaks = [];
93596
93991
  let afterSnapshot = "";
93597
93992
  for (let cycle = 1; cycle <= cycles; cycle += 1) {
@@ -93603,9 +93998,18 @@ async function runRitual(options, deps = defaultDeps) {
93603
93998
  peaks.push(await poller.stop());
93604
93999
  }
93605
94000
  });
94001
+ const unreclaimedSettleMs = unreclaimedSettleFor(idleMs);
94002
+ await timed(`cycle ${cycle} unreclaimed read`, async () => {
94003
+ await deps.sleep(unreclaimedSettleMs);
94004
+ try {
94005
+ unreclaimedSamples.push(await deps.readMemory(app.controlPort));
94006
+ } catch {
94007
+ unreclaimedLost = true;
94008
+ }
94009
+ });
93606
94010
  const settle = await timed(
93607
94011
  `cycle ${cycle} settle`,
93608
- () => waitUntilSettled(app.controlPort, idleMs, deps)
94012
+ () => waitUntilSettled(app.controlPort, idleMs - unreclaimedSettleMs, deps)
93609
94013
  );
93610
94014
  settleOutcomes.push({ phase: `cycle ${cycle}`, ...settle });
93611
94015
  if (cycle === cycles) {
@@ -93630,6 +94034,14 @@ async function runRitual(options, deps = defaultDeps) {
93630
94034
  samples,
93631
94035
  memorySamples,
93632
94036
  peaks,
94037
+ unreclaimedSamples: unreclaimedLost ? [] : unreclaimedSamples,
94038
+ // The first cycle plays the part the baseline plays for the post-GC
94039
+ // series, so both trends drop a warm-up delta and stay comparable.
94040
+ unreclaimedTrend: unreclaimedLost ? { verdict: "inconclusive", growthPerCycle: 0, deltas: [] } : classifyMemoryTrend(
94041
+ unreclaimedSamples.map((sample) => sample.heapUsed),
94042
+ unreclaimedSamples.map((sample) => sample.external),
94043
+ { minGrowthPerCycle }
94044
+ ),
93633
94045
  baselineSnapshot: baseline.file,
93634
94046
  afterSnapshot,
93635
94047
  trend: classifyMemoryTrend(samples, externalSamples, { minGrowthPerCycle }),
@@ -93648,8 +94060,8 @@ async function runRitual(options, deps = defaultDeps) {
93648
94060
  }
93649
94061
 
93650
94062
  // src/signatures.ts
93651
- import { readFile as readFile4 } from "fs/promises";
93652
- import path4 from "path";
94063
+ import { readFile as readFile5 } from "fs/promises";
94064
+ import path5 from "path";
93653
94065
  import semver from "semver";
93654
94066
  import { z as z6 } from "zod";
93655
94067
  var signatureSchema = z6.object({
@@ -93726,12 +94138,12 @@ function matchSignatures(diff, nextVersion, signatures = loadSignatures()) {
93726
94138
  }
93727
94139
  async function readNextVersion(appDir) {
93728
94140
  const candidates = [
93729
- path4.join(appDir, ".next", "standalone", "node_modules", "next", "package.json"),
93730
- path4.join(appDir, "node_modules", "next", "package.json")
94141
+ path5.join(appDir, ".next", "standalone", "node_modules", "next", "package.json"),
94142
+ path5.join(appDir, "node_modules", "next", "package.json")
93731
94143
  ];
93732
94144
  for (const candidate of candidates) {
93733
94145
  try {
93734
- const parsed = z6.object({ version: z6.string() }).parse(JSON.parse(await readFile4(candidate, "utf8")));
94146
+ const parsed = z6.object({ version: z6.string() }).parse(JSON.parse(await readFile5(candidate, "utf8")));
93735
94147
  return parsed.version;
93736
94148
  } catch {
93737
94149
  continue;
@@ -93740,88 +94152,54 @@ async function readNextVersion(appDir) {
93740
94152
  return null;
93741
94153
  }
93742
94154
 
93743
- // src/target.ts
93744
- import { access, readFile as readFile5 } from "fs/promises";
93745
- import path5 from "path";
93746
- var TargetError = class extends Error {
93747
- code;
93748
- constructor(code, message) {
93749
- super(message);
93750
- this.name = "TargetError";
93751
- this.code = code;
93752
- }
93753
- };
93754
- async function exists(file) {
93755
- try {
93756
- await access(file);
93757
- return true;
93758
- } catch {
93759
- return false;
94155
+ // src/runner.ts
94156
+ import { createHash as createHash3 } from "crypto";
94157
+ import { mkdir as mkdir2, writeFile } from "fs/promises";
94158
+ import { createServer } from "net";
94159
+ import path6 from "path";
94160
+
94161
+ // src/isr.ts
94162
+ var REVALIDATE_HEADER = "x-prerender-revalidate";
94163
+ function revalidateSecondsFor(manifest, route) {
94164
+ const routes = manifest?.routes;
94165
+ if (routes === void 0) {
94166
+ return null;
93760
94167
  }
93761
- }
93762
- async function readManifest(file, parse2) {
93763
- let raw;
93764
- try {
93765
- raw = JSON.parse(await readFile5(file, "utf8"));
93766
- } catch (cause) {
93767
- throw new TargetError(
93768
- "BAD_MANIFEST",
93769
- `Could not read ${file}: ${cause instanceof Error ? cause.message : String(cause)}`
93770
- );
94168
+ const direct = routes[route]?.initialRevalidateSeconds;
94169
+ if (typeof direct === "number") {
94170
+ return direct;
93771
94171
  }
93772
- try {
93773
- return parse2(raw);
93774
- } catch {
93775
- throw new TargetError(
93776
- "BAD_MANIFEST",
93777
- `${file} does not match the expected Next.js manifest shape. This Next.js version may not be supported yet.`
93778
- );
94172
+ for (const entry of Object.values(routes)) {
94173
+ if (entry.srcRoute === route && typeof entry.initialRevalidateSeconds === "number") {
94174
+ return entry.initialRevalidateSeconds;
94175
+ }
93779
94176
  }
94177
+ return null;
93780
94178
  }
93781
- async function validateTarget(appDir) {
93782
- const nextDir = path5.resolve(appDir, ".next");
93783
- if (!await exists(nextDir)) {
93784
- throw new TargetError(
93785
- "NO_BUILD",
93786
- `No .next directory in ${appDir}. Run "next build" first.`
93787
- );
94179
+ function revalidates(manifest, route) {
94180
+ return revalidateSecondsFor(manifest, route) !== null;
94181
+ }
94182
+ function planRevalidation(manifest, route, userHeaders) {
94183
+ const userSupplied = Object.keys(userHeaders ?? {}).some(
94184
+ (name) => name.toLowerCase() === REVALIDATE_HEADER
94185
+ );
94186
+ if (userSupplied) {
94187
+ return { kind: "drive", headers: {} };
93788
94188
  }
93789
- const standaloneServer = path5.join(nextDir, "standalone", "server.js");
93790
- if (!await exists(standaloneServer)) {
93791
- throw new TargetError(
93792
- "NO_STANDALONE",
93793
- `No ${standaloneServer}.
93794
- next-leak measures the standalone server bundle. Enable it once in next.config:
93795
-
93796
- const nextConfig = {
93797
- output: "standalone",
93798
- // ...your existing config
93799
- };
93800
-
93801
- then rebuild: next build
93802
- This only changes how the build is packaged \u2014 not how your app behaves.`
93803
- );
94189
+ if (!revalidates(manifest, route)) {
94190
+ return { kind: "not-isr" };
93804
94191
  }
93805
- const appPathsFile = path5.join(nextDir, "server", "app-paths-manifest.json");
93806
- const pagesFile = path5.join(nextDir, "server", "pages-manifest.json");
93807
- const appPaths = await exists(appPathsFile) ? await readManifest(appPathsFile, (raw) => appPathsManifestSchema.parse(raw)) : {};
93808
- const pages = await exists(pagesFile) ? await readManifest(pagesFile, (raw) => pagesManifestSchema.parse(raw)) : {};
93809
- if (Object.keys(appPaths).length === 0 && Object.keys(pages).length === 0) {
93810
- throw new TargetError(
93811
- "BAD_MANIFEST",
93812
- `No routes found in ${nextDir}/server: neither app-paths-manifest.json nor pages-manifest.json is present. Rebuild the app with "next build".`
93813
- );
94192
+ const previewModeId = manifest?.preview?.previewModeId;
94193
+ if (previewModeId === void 0 || previewModeId === "") {
94194
+ return {
94195
+ kind: "cannot-drive",
94196
+ 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.`
94197
+ };
93814
94198
  }
93815
- const routesFile = path5.join(nextDir, "routes-manifest.json");
93816
- const routes = await exists(routesFile) ? await readManifest(routesFile, (raw) => routesManifestSchema.parse(raw)) : void 0;
93817
- return { appDir: path5.resolve(appDir), standaloneServer, appPaths, pages, routes };
94199
+ return { kind: "drive", headers: { [REVALIDATE_HEADER]: previewModeId } };
93818
94200
  }
93819
94201
 
93820
94202
  // src/runner.ts
93821
- import { createHash as createHash3 } from "crypto";
93822
- import { mkdir as mkdir2, writeFile } from "fs/promises";
93823
- import { createServer } from "net";
93824
- import path6 from "path";
93825
94203
  var ESTIMATED_RPS = 250;
93826
94204
  var PER_ROUTE_OVERHEAD_SECONDS = 10;
93827
94205
  var MIN_SETTLE_SECONDS = 4;
@@ -93928,6 +94306,12 @@ function skipReason(route, requestPath) {
93928
94306
  }
93929
94307
  async function measureRoute(context, route, requestPath, index, pass = void 0) {
93930
94308
  const { deps, options, target, workDir, routeConfig, registry, nextVersion, progress } = context;
94309
+ const plan = planRevalidation(target.prerender, route.path, routeConfig.headers);
94310
+ const revalidateSeconds = revalidateSecondsFor(target.prerender, route.path);
94311
+ const bounded = boundedMarkerOf(requestPath);
94312
+ const driven = plan.kind === "drive" ? plan.headers : {};
94313
+ const merged = { ...driven, ...routeConfig.headers ?? {} };
94314
+ const headers = Object.keys(merged).length === 0 ? void 0 : merged;
93931
94315
  const result = await deps.ritual({
93932
94316
  serverPath: target.standaloneServer,
93933
94317
  route: requestPath,
@@ -93943,7 +94327,7 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
93943
94327
  ...pass !== void 0 ? { cycles: pass.cycles } : options.cycles !== void 0 && { cycles: options.cycles },
93944
94328
  ...options.idleMs !== void 0 && { idleMs: options.idleMs },
93945
94329
  ...options.maxOldSpaceMb !== void 0 && { maxOldSpaceMb: options.maxOldSpaceMb },
93946
- ...routeConfig.headers !== void 0 && { headers: routeConfig.headers },
94330
+ ...headers !== void 0 && { headers },
93947
94331
  ...routeConfig.abandonAfterMs !== void 0 && {
93948
94332
  abandonAfterMs: routeConfig.abandonAfterMs
93949
94333
  }
@@ -93957,6 +94341,7 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
93957
94341
  minGrowthPerCycle: result.minGrowthPerCycle,
93958
94342
  memorySamples: result.memorySamples,
93959
94343
  maxOldSpaceMb: options.maxOldSpaceMb ?? DEFAULT_MAX_OLD_SPACE_MB,
94344
+ warmupRequests: options.warmupRequests ?? RITUAL_DEFAULTS.warmupRequests,
93960
94345
  ...routeConfig.abandonAfterMs !== void 0 && {
93961
94346
  abandonAfterMs: routeConfig.abandonAfterMs
93962
94347
  }
@@ -93968,7 +94353,13 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
93968
94353
  let diff = null;
93969
94354
  if (verdict !== "stable" || options.diffAll === true) {
93970
94355
  progress(`diffing snapshots for ${route.path}`);
93971
- diff = await deps.diff(result.baselineSnapshot, result.afterSnapshot);
94356
+ try {
94357
+ diff = await deps.diff(result.baselineSnapshot, result.afterSnapshot);
94358
+ } catch (cause) {
94359
+ progress(
94360
+ `snapshot diff unavailable for ${route.path}: ${cause instanceof Error ? cause.message : String(cause)}`
94361
+ );
94362
+ }
93972
94363
  }
93973
94364
  return {
93974
94365
  route: route.path,
@@ -93978,6 +94369,11 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
93978
94369
  samples: result.samples,
93979
94370
  memorySamples: result.memorySamples,
93980
94371
  peaks: result.peaks,
94372
+ ...revalidateSeconds !== null && { revalidatedEverySeconds: revalidateSeconds },
94373
+ ...bounded !== null && { keyCardinality: bounded.bound },
94374
+ unreclaimedSamples: result.unreclaimedSamples,
94375
+ unreclaimedTrend: result.unreclaimedTrend,
94376
+ requestsPerCycle: result.requestsPerCycle,
93981
94377
  timings: result.timings,
93982
94378
  loadOutcomes: result.loadOutcomes,
93983
94379
  settleOutcomes: result.settleOutcomes,
@@ -93993,7 +94389,7 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
93993
94389
  };
93994
94390
  }
93995
94391
  async function writeEvidenceBundle(report, workDir) {
93996
- const { renderHtmlReport } = await import("./html-report-X2LUOZRH.js");
94392
+ const { renderHtmlReport } = await import("./html-report-2UAOOZQO.js");
93997
94393
  const { renderIssueMarkdown } = await import("./issue-report-HKA26WNV.js");
93998
94394
  for (const route of report.routes) {
93999
94395
  if (route.status === "measured" && warrantsIssueDraft(route)) {
@@ -94013,6 +94409,11 @@ async function routeReportFor(context, route, index, total) {
94013
94409
  progress(`skipping ${label}: ${reason ?? "needs sample params"}`);
94014
94410
  return { route: route.path, status: "skipped", reason: reason ?? "needs sample params" };
94015
94411
  }
94412
+ const plan = planRevalidation(context.target.prerender, route.path, routeConfig.headers);
94413
+ if (plan.kind === "cannot-drive") {
94414
+ progress(`not measuring ${label}: ${plan.reason}`);
94415
+ return { route: route.path, status: "not-exercised", reason: plan.reason };
94416
+ }
94016
94417
  try {
94017
94418
  const asPath = requestPath === route.path ? "" : ` as ${requestPath}`;
94018
94419
  progress(`measuring ${label}${asPath}`);
@@ -94108,6 +94509,7 @@ async function runMeasurement(options, deps = defaultDeps2) {
94108
94509
  appDir: target.appDir,
94109
94510
  startedAt: startedAt.toISOString(),
94110
94511
  workDir,
94512
+ ...target.prerender !== void 0 && { prerender: target.prerender },
94111
94513
  environment: captureEnvironment(nextVersion),
94112
94514
  parameters,
94113
94515
  routes: reports,
@@ -94132,17 +94534,27 @@ async function runMeasurement(options, deps = defaultDeps2) {
94132
94534
  }
94133
94535
 
94134
94536
  export {
94135
- helpText,
94136
- parseCliArgs,
94137
- checkRuntime,
94537
+ appPathsManifestSchema,
94538
+ routesManifestSchema,
94539
+ discoverPagesRoutes,
94540
+ discoverRoutes,
94541
+ TargetError,
94542
+ validateTarget,
94138
94543
  LaunchError,
94139
94544
  killActiveChildren,
94545
+ registerChild,
94546
+ unregisterChild,
94140
94547
  launchInstrumented,
94141
- formatReport,
94548
+ helpText,
94549
+ parseCliArgs,
94550
+ checkRuntime,
94142
94551
  RouteConfigError,
94143
94552
  ROUTE_CONFIG_FILE,
94144
94553
  loadRouteConfig,
94145
94554
  resolveRoutePath,
94555
+ renderConfigSkeleton,
94556
+ hasPlaceholders,
94557
+ formatReport,
94146
94558
  classifySource,
94147
94559
  attributeFinding,
94148
94560
  attributeDiff,
@@ -94153,9 +94565,6 @@ export {
94153
94565
  SnapshotError,
94154
94566
  assertReadableSnapshot,
94155
94567
  diffSnapshotFiles,
94156
- appPathsManifestSchema,
94157
- routesManifestSchema,
94158
- discoverRoutes,
94159
94568
  decodeVlqLine,
94160
94569
  decodeMappings,
94161
94570
  sourceIndexAt,
@@ -94167,8 +94576,6 @@ export {
94167
94576
  loadSignatures,
94168
94577
  matchSignatures,
94169
94578
  readNextVersion,
94170
- TargetError,
94171
- validateTarget,
94172
94579
  freePort,
94173
94580
  routeSlug,
94174
94581
  runMeasurement