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.
- package/README.md +129 -22
- package/dist/abandon-load.d.ts +12 -2
- package/dist/build-report.d.ts +11 -0
- package/dist/build-run.d.ts +58 -0
- package/dist/build-target.d.ts +13 -0
- package/dist/build-verdict.d.ts +80 -0
- package/dist/{chunk-KJFYTJCF.js → chunk-BXDUPN6Z.js} +867 -420
- package/dist/{chunk-2FZXZLZW.js → chunk-EJ26OBZI.js} +36 -5
- package/dist/{chunk-NNDGXIGQ.js → chunk-RS3AO7HB.js} +1 -1
- package/dist/cli-args.d.ts +9 -0
- package/dist/cli.js +508 -6
- package/dist/confidence.d.ts +9 -1
- package/dist/{html-report-X2LUOZRH.js → html-report-TN7PYVU4.js} +2 -2
- package/dist/index.js +3 -3
- package/dist/isr.d.ts +39 -0
- package/dist/launcher.d.ts +8 -0
- package/dist/manifests.d.ts +20 -0
- package/dist/process-tree.d.ts +44 -0
- package/dist/report.d.ts +0 -1
- package/dist/ritual.d.ts +25 -1
- package/dist/route-config.d.ts +23 -5
- package/dist/route-guidance.d.ts +21 -0
- package/dist/runner.d.ts +49 -0
- package/dist/target.d.ts +6 -1
- package/dist/unreclaimed-retention.d.ts +64 -0
- package/package.json +6 -2
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
minGrowthFor,
|
|
8
8
|
resolveCycles,
|
|
9
9
|
warrantsIssueDraft
|
|
10
|
-
} from "./chunk-
|
|
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
|
|
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(
|
|
179
|
-
return
|
|
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
|
|
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 (
|
|
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 (
|
|
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
|
|
92130
|
-
if ("error" in
|
|
92131
|
-
return { done: { kind: "error", message:
|
|
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,
|
|
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 <
|
|
92154
|
-
const argument =
|
|
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(
|
|
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/
|
|
92192
|
-
import {
|
|
92193
|
-
import
|
|
92194
|
-
import
|
|
92195
|
-
|
|
92196
|
-
|
|
92197
|
-
|
|
92198
|
-
|
|
92199
|
-
var
|
|
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 = "
|
|
92652
|
+
this.name = "RouteConfigError";
|
|
92203
92653
|
}
|
|
92204
92654
|
};
|
|
92205
|
-
var
|
|
92206
|
-
|
|
92207
|
-
|
|
92208
|
-
|
|
92209
|
-
|
|
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
|
-
|
|
92213
|
-
|
|
92214
|
-
|
|
92215
|
-
|
|
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
|
-
|
|
92218
|
-
|
|
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
|
|
92221
|
-
${stderr}`;
|
|
92696
|
+
return { marker: match[0], bound };
|
|
92222
92697
|
}
|
|
92223
|
-
|
|
92224
|
-
|
|
92225
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92230
|
-
|
|
92231
|
-
|
|
92232
|
-
|
|
92233
|
-
|
|
92234
|
-
|
|
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
|
|
92237
|
-
if (
|
|
92238
|
-
return
|
|
92747
|
+
const value = parameter.catchAll ? concreteSegments.slice(index).join("/") : concreteSegments[index];
|
|
92748
|
+
if (value === void 0 || value === "") {
|
|
92749
|
+
return null;
|
|
92239
92750
|
}
|
|
92240
|
-
|
|
92241
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92247
|
-
|
|
92248
|
-
|
|
92249
|
-
|
|
92250
|
-
|
|
92251
|
-
|
|
92252
|
-
|
|
92253
|
-
|
|
92254
|
-
|
|
92255
|
-
|
|
92256
|
-
|
|
92257
|
-
|
|
92258
|
-
|
|
92259
|
-
|
|
92260
|
-
|
|
92261
|
-
|
|
92262
|
-
|
|
92263
|
-
|
|
92264
|
-
|
|
92265
|
-
|
|
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
|
-
|
|
92272
|
-
|
|
92273
|
-
|
|
92274
|
-
|
|
92275
|
-
|
|
92276
|
-
|
|
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
|
-
|
|
92279
|
-
|
|
92280
|
-
|
|
92281
|
-
|
|
92282
|
-
|
|
92283
|
-
|
|
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
|
-
|
|
92336
|
-
|
|
92337
|
-
|
|
92338
|
-
|
|
92339
|
-
|
|
92340
|
-
|
|
92341
|
-
|
|
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
|
|
92359
|
-
var formatMb = (bytes) => `${(bytes /
|
|
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 /
|
|
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 *
|
|
92366
|
-
var RSS_MIN_TOTAL_GROWTH = 64 *
|
|
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
|
|
93041
|
-
import
|
|
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(
|
|
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
|
|
93177
|
-
map = JSON.parse(await
|
|
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 ?
|
|
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(
|
|
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
|
|
93661
|
-
import
|
|
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
|
-
|
|
93739
|
-
|
|
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
|
|
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/
|
|
93753
|
-
import {
|
|
93754
|
-
import
|
|
93755
|
-
|
|
93756
|
-
|
|
93757
|
-
|
|
93758
|
-
|
|
93759
|
-
|
|
93760
|
-
|
|
93761
|
-
|
|
93762
|
-
|
|
93763
|
-
|
|
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
|
-
|
|
93772
|
-
|
|
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
|
-
|
|
93782
|
-
|
|
93783
|
-
|
|
93784
|
-
|
|
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
|
-
|
|
93791
|
-
|
|
93792
|
-
|
|
93793
|
-
|
|
93794
|
-
|
|
93795
|
-
|
|
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
|
-
|
|
93799
|
-
|
|
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
|
|
93815
|
-
|
|
93816
|
-
|
|
93817
|
-
|
|
93818
|
-
|
|
93819
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
94145
|
-
|
|
94146
|
-
|
|
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
|
-
|
|
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
|