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.
- package/README.md +116 -18
- 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-2FZXZLZW.js → chunk-22PBLCGO.js} +29 -1
- package/dist/{chunk-NNDGXIGQ.js → chunk-6CJU5TVV.js} +1 -1
- package/dist/{chunk-IDSNTUKC.js → chunk-AMSDGT3Y.js} +818 -411
- package/dist/cli-args.d.ts +9 -0
- package/dist/cli.js +508 -6
- package/dist/confidence.d.ts +6 -1
- package/dist/{html-report-X2LUOZRH.js → html-report-2UAOOZQO.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 +22 -0
- package/dist/route-config.d.ts +19 -5
- package/dist/route-guidance.d.ts +21 -0
- package/dist/runner.d.ts +38 -0
- package/dist/target.d.ts +6 -1
- package/dist/unreclaimed-retention.d.ts +64 -0
- package/package.json +1 -1
package/dist/cli-args.d.ts
CHANGED
|
@@ -5,15 +5,24 @@ export type CliRunOptions = {
|
|
|
5
5
|
requests: number | null;
|
|
6
6
|
connections: number | null;
|
|
7
7
|
idleSeconds: number | null;
|
|
8
|
+
warmupRequests: number | null;
|
|
8
9
|
maxOldSpaceMb: number | null;
|
|
9
10
|
quick: boolean;
|
|
10
11
|
noResolve: boolean;
|
|
11
12
|
diffAll: boolean;
|
|
13
|
+
writeConfig: boolean;
|
|
14
|
+
output: string | null;
|
|
15
|
+
};
|
|
16
|
+
export type CliBuildOptions = {
|
|
17
|
+
appDir: string;
|
|
12
18
|
output: string | null;
|
|
13
19
|
};
|
|
14
20
|
export type ParsedCli = {
|
|
15
21
|
kind: "run";
|
|
16
22
|
options: CliRunOptions;
|
|
23
|
+
} | {
|
|
24
|
+
kind: "build";
|
|
25
|
+
options: CliBuildOptions;
|
|
17
26
|
} | {
|
|
18
27
|
kind: "help";
|
|
19
28
|
} | {
|
package/dist/cli.js
CHANGED
|
@@ -4,20 +4,467 @@ import {
|
|
|
4
4
|
RouteConfigError,
|
|
5
5
|
TargetError,
|
|
6
6
|
checkRuntime,
|
|
7
|
+
discoverPagesRoutes,
|
|
8
|
+
discoverRoutes,
|
|
7
9
|
formatReport,
|
|
10
|
+
hasPlaceholders,
|
|
8
11
|
helpText,
|
|
9
12
|
killActiveChildren,
|
|
10
13
|
parseCliArgs,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
registerChild,
|
|
15
|
+
renderConfigSkeleton,
|
|
16
|
+
runMeasurement,
|
|
17
|
+
unregisterChild,
|
|
18
|
+
validateTarget
|
|
19
|
+
} from "./chunk-AMSDGT3Y.js";
|
|
20
|
+
import {
|
|
21
|
+
classifyTrend
|
|
22
|
+
} from "./chunk-22PBLCGO.js";
|
|
14
23
|
import "./chunk-XHPUAMJG.js";
|
|
15
24
|
import "./chunk-6XYFBOL2.js";
|
|
16
25
|
|
|
17
26
|
// src/cli.ts
|
|
18
|
-
import { spawn } from "child_process";
|
|
27
|
+
import { spawn as spawn2 } from "child_process";
|
|
28
|
+
import { writeFile } from "fs/promises";
|
|
19
29
|
import { createRequire } from "module";
|
|
30
|
+
import path3 from "path";
|
|
20
31
|
import { fileURLToPath } from "url";
|
|
32
|
+
|
|
33
|
+
// src/build-report.ts
|
|
34
|
+
var MB = 1024 * 1024;
|
|
35
|
+
var mb = (bytes) => `${(bytes / MB).toFixed(1)} MB`;
|
|
36
|
+
var mbPrecise = (bytes) => `${(bytes / MB).toFixed(2)} MB`;
|
|
37
|
+
var VERDICT_ICON = {
|
|
38
|
+
leak: "\u2716",
|
|
39
|
+
stable: "\u2714",
|
|
40
|
+
inconclusive: "?"
|
|
41
|
+
};
|
|
42
|
+
function curveLine(levels) {
|
|
43
|
+
return levels.map((level) => mb(level)).join(" \u2192 ");
|
|
44
|
+
}
|
|
45
|
+
function growthLine(result) {
|
|
46
|
+
const perPage = result.retentionPerPageBytes === null ? "" : ` (${mbPrecise(result.retentionPerPageBytes)}/page over ${result.pagesGenerated} pages)`;
|
|
47
|
+
return ` grew ${mb(result.netGrowthBytes)} across the generation phase${perPage}`;
|
|
48
|
+
}
|
|
49
|
+
function formatBuildReport(result) {
|
|
50
|
+
const lines = [`next-leak build \xB7 ${result.appDir}`, ""];
|
|
51
|
+
if (result.strippedCapWarning !== null) {
|
|
52
|
+
lines.push(` \u25B2 ${result.strippedCapWarning}`, "");
|
|
53
|
+
}
|
|
54
|
+
if (result.status === "build-failed") {
|
|
55
|
+
lines.push(
|
|
56
|
+
` \u2716 the build failed for a reason that is not memory (exit ${result.exitCode ?? "?"})`,
|
|
57
|
+
` no memory claim is made; the build's own output follows`,
|
|
58
|
+
"",
|
|
59
|
+
...result.output.trimEnd().split("\n").slice(-20).map((line) => ` ${line}`)
|
|
60
|
+
);
|
|
61
|
+
return lines.join("\n");
|
|
62
|
+
}
|
|
63
|
+
if (result.status === "cannot-sample") {
|
|
64
|
+
lines.push(
|
|
65
|
+
` \u2716 could not read the process table, so the build was not measured`,
|
|
66
|
+
` ${result.samplingFailure ?? "unknown reason"}`,
|
|
67
|
+
` this is not a clean run: an unreadable table and a build with no`,
|
|
68
|
+
` workers look identical from here, and only one of them is good news`
|
|
69
|
+
);
|
|
70
|
+
return lines.join("\n");
|
|
71
|
+
}
|
|
72
|
+
if (result.status === "nothing-to-measure") {
|
|
73
|
+
lines.push(
|
|
74
|
+
` \u2013 no static-generation worker ran, so there was nothing to measure`,
|
|
75
|
+
` a build with no prerendered pages does not exercise this path`
|
|
76
|
+
);
|
|
77
|
+
return lines.join("\n");
|
|
78
|
+
}
|
|
79
|
+
const verdict = result.verdict ?? "inconclusive";
|
|
80
|
+
const icon = VERDICT_ICON[verdict] ?? "?";
|
|
81
|
+
lines.push(` ${icon} static-generation worker ${verdict}`);
|
|
82
|
+
if (result.levels.length > 0) {
|
|
83
|
+
lines.push(` worker rss ${curveLine(result.levels)}`);
|
|
84
|
+
}
|
|
85
|
+
if (result.netGrowthBytes > 0) {
|
|
86
|
+
lines.push(growthLine(result));
|
|
87
|
+
}
|
|
88
|
+
if (verdict === "inconclusive" && result.levels.length === 0) {
|
|
89
|
+
lines.push(` the build was too short to judge \u2014 not a sign of health`);
|
|
90
|
+
}
|
|
91
|
+
if (result.heapExhausted) {
|
|
92
|
+
lines.push(
|
|
93
|
+
` the worker ran out of heap and the build died with it after ${result.pagesGenerated ?? "?"} pages, reaching ${mb(result.peakWorkerRssBytes)}`,
|
|
94
|
+
` no per-page figure for a crashed build: the curve is truncated where`,
|
|
95
|
+
` the worker died, so any denominator would be guesswork`,
|
|
96
|
+
` that outcome is the verdict here, whatever shape the curve above has:`,
|
|
97
|
+
` raising the cap moves the wall; it does not remove it`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (result.workers.length > 1) {
|
|
101
|
+
lines.push(` ${result.workers.length} workers ran; the verdict is the worst of them`);
|
|
102
|
+
}
|
|
103
|
+
lines.push(
|
|
104
|
+
"",
|
|
105
|
+
` peak worker rss ${mb(result.peakWorkerRssBytes)} \xB7 sampled from the process tree, so a`,
|
|
106
|
+
` spike shorter than the polling interval is not observed`
|
|
107
|
+
);
|
|
108
|
+
return lines.join("\n");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/build-run.ts
|
|
112
|
+
import { spawn } from "child_process";
|
|
113
|
+
import path2 from "path";
|
|
114
|
+
|
|
115
|
+
// src/build-target.ts
|
|
116
|
+
import { access, readFile, readdir } from "fs/promises";
|
|
117
|
+
import path from "path";
|
|
118
|
+
var CONFIG_NAMES = ["next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs"];
|
|
119
|
+
async function exists(file) {
|
|
120
|
+
try {
|
|
121
|
+
await access(file);
|
|
122
|
+
return true;
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function readPackageJson(appDir) {
|
|
128
|
+
try {
|
|
129
|
+
return JSON.parse(await readFile(path.join(appDir, "package.json"), "utf8"));
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async function usesWorkerThreads(appDir) {
|
|
135
|
+
for (const name of CONFIG_NAMES) {
|
|
136
|
+
const file = path.join(appDir, name);
|
|
137
|
+
if (!await exists(file)) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const source = await readFile(file, "utf8");
|
|
141
|
+
if (/workerThreads\s*:\s*true/.test(source)) {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
async function validateBuildTarget(appDir) {
|
|
148
|
+
const resolved = path.resolve(appDir);
|
|
149
|
+
let entries;
|
|
150
|
+
try {
|
|
151
|
+
entries = await readdir(resolved);
|
|
152
|
+
} catch {
|
|
153
|
+
throw new TargetError("NO_BUILD", `Cannot read ${resolved}.`);
|
|
154
|
+
}
|
|
155
|
+
const packageJson = await readPackageJson(resolved);
|
|
156
|
+
const dependencies = {
|
|
157
|
+
...packageJson?.dependencies ?? {},
|
|
158
|
+
...packageJson?.devDependencies ?? {}
|
|
159
|
+
};
|
|
160
|
+
const hasNext = "next" in dependencies || entries.some((entry) => CONFIG_NAMES.includes(entry));
|
|
161
|
+
if (!hasNext) {
|
|
162
|
+
throw new TargetError(
|
|
163
|
+
"NO_BUILD",
|
|
164
|
+
`${resolved} is not a Next.js project: no "next" dependency in package.json and no next.config.
|
|
165
|
+
next-leak build measures a Next.js build. Point it at the directory holding your package.json.`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (await usesWorkerThreads(resolved)) {
|
|
169
|
+
throw new TargetError(
|
|
170
|
+
"NO_BUILD",
|
|
171
|
+
`This project sets experimental.workerThreads: true.
|
|
172
|
+
next-leak build measures the resident memory of each static-generation worker, and a
|
|
173
|
+
worker thread has none of its own \u2014 it shares the parent's. Remove the flag (Next's
|
|
174
|
+
default) to make the build measurable.`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
const scripts = packageJson?.scripts ?? {};
|
|
178
|
+
const buildScript = "build" in scripts ? "build" : null;
|
|
179
|
+
return { appDir: resolved, buildScript };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// src/build-verdict.ts
|
|
183
|
+
function segmentSamples(samples, segments) {
|
|
184
|
+
if (segments <= 0 || samples.length < segments) {
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
const first = samples[0];
|
|
188
|
+
const last = samples[samples.length - 1];
|
|
189
|
+
if (first === void 0 || last === void 0) {
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
const span = last.atMs - first.atMs;
|
|
193
|
+
if (span <= 0) {
|
|
194
|
+
return [];
|
|
195
|
+
}
|
|
196
|
+
const levels = [];
|
|
197
|
+
for (let segment = 1; segment <= segments; segment += 1) {
|
|
198
|
+
const until = first.atMs + span * segment / segments;
|
|
199
|
+
let level;
|
|
200
|
+
for (const sample of samples) {
|
|
201
|
+
if (sample.atMs <= until) {
|
|
202
|
+
level = sample.rssBytes;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (level !== void 0) {
|
|
206
|
+
levels.push(level);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return levels;
|
|
210
|
+
}
|
|
211
|
+
var BUILD_SEGMENTS = 6;
|
|
212
|
+
var BUILD_GROWTH_GATE_BYTES = 32 * 1024 * 1024;
|
|
213
|
+
function classifyBuildSamples(samples) {
|
|
214
|
+
const levels = segmentSamples(samples, BUILD_SEGMENTS);
|
|
215
|
+
return {
|
|
216
|
+
trend: classifyTrend(levels, { minGrowthPerCycle: BUILD_GROWTH_GATE_BYTES }),
|
|
217
|
+
levels
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function netGrowthOf(levels) {
|
|
221
|
+
const first = levels[1] ?? levels[0];
|
|
222
|
+
const last = levels[levels.length - 1];
|
|
223
|
+
if (first === void 0 || last === void 0) {
|
|
224
|
+
return 0;
|
|
225
|
+
}
|
|
226
|
+
return last - first;
|
|
227
|
+
}
|
|
228
|
+
function strippedHeapCap(nodeOptions) {
|
|
229
|
+
if (nodeOptions === void 0) {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
const match = /--max[-_]old[-_]space[-_]size[= ]\s*(\d+)/.exec(nodeOptions);
|
|
233
|
+
if (match === null) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
const value = match[1] ?? "";
|
|
237
|
+
return `NODE_OPTIONS sets --max-old-space-size=${value}, which Next strips from the static-generation worker's environment \u2014 the worker never sees it. Use --max-heap-size=${value} instead, which survives.`;
|
|
238
|
+
}
|
|
239
|
+
var HEAP_DEATH_MARKERS = [
|
|
240
|
+
"JavaScript heap out of memory",
|
|
241
|
+
"Reached heap limit",
|
|
242
|
+
"Ineffective mark-compacts near heap limit"
|
|
243
|
+
];
|
|
244
|
+
function diedOfHeapExhaustion(output) {
|
|
245
|
+
return HEAP_DEATH_MARKERS.some((marker) => output.includes(marker));
|
|
246
|
+
}
|
|
247
|
+
function pagesGeneratedFrom(output) {
|
|
248
|
+
let furthest = null;
|
|
249
|
+
for (const line of output.split("\n")) {
|
|
250
|
+
if (!line.includes("static pages")) {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
for (const match of line.matchAll(/\((\d+)\/(\d+)\)/g)) {
|
|
254
|
+
const done = Number(match[1]);
|
|
255
|
+
if (Number.isFinite(done) && (furthest === null || done > furthest)) {
|
|
256
|
+
furthest = done;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return furthest;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// src/process-tree.ts
|
|
264
|
+
import { execFile } from "child_process";
|
|
265
|
+
import { promisify } from "util";
|
|
266
|
+
var run = promisify(execFile);
|
|
267
|
+
var STATIC_WORKER_MARKER = "jest-worker/processChild";
|
|
268
|
+
var KB = 1024;
|
|
269
|
+
function parseProcessTable(output) {
|
|
270
|
+
const rows = [];
|
|
271
|
+
for (const line of output.split("\n")) {
|
|
272
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/.exec(line);
|
|
273
|
+
if (match === null) {
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const [, pid, ppid, rssKb, command] = match;
|
|
277
|
+
if (pid === void 0 || ppid === void 0 || rssKb === void 0 || command === void 0) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
rows.push({
|
|
281
|
+
pid: Number(pid),
|
|
282
|
+
ppid: Number(ppid),
|
|
283
|
+
rssBytes: Number(rssKb) * KB,
|
|
284
|
+
command
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return rows;
|
|
288
|
+
}
|
|
289
|
+
function descendantsOf(rows, rootPid) {
|
|
290
|
+
const found = /* @__PURE__ */ new Map();
|
|
291
|
+
let frontier = /* @__PURE__ */ new Set([rootPid]);
|
|
292
|
+
while (frontier.size > 0) {
|
|
293
|
+
const next = /* @__PURE__ */ new Set();
|
|
294
|
+
for (const row of rows) {
|
|
295
|
+
if (!frontier.has(row.ppid) || found.has(row.pid) || row.pid === rootPid) {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
found.set(row.pid, row);
|
|
299
|
+
next.add(row.pid);
|
|
300
|
+
}
|
|
301
|
+
frontier = next;
|
|
302
|
+
}
|
|
303
|
+
return [...found.values()];
|
|
304
|
+
}
|
|
305
|
+
function isStaticGenWorker(row) {
|
|
306
|
+
return row.command.includes(STATIC_WORKER_MARKER);
|
|
307
|
+
}
|
|
308
|
+
async function sampleProcessTable() {
|
|
309
|
+
try {
|
|
310
|
+
const { stdout } = await run("ps", ["-axo", "pid,ppid,rss,command"], {
|
|
311
|
+
maxBuffer: 8 * 1024 * 1024
|
|
312
|
+
});
|
|
313
|
+
return { ok: true, rows: parseProcessTable(stdout) };
|
|
314
|
+
} catch (cause) {
|
|
315
|
+
return { ok: false, reason: cause instanceof Error ? cause.message : String(cause) };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// src/build-run.ts
|
|
320
|
+
var POLL_MS = 500;
|
|
321
|
+
function spawnNextBuild(appDir, env) {
|
|
322
|
+
const nextBin = path2.join(appDir, "node_modules", "next", "dist", "bin", "next");
|
|
323
|
+
return spawn(process.execPath, [nextBin, "build"], {
|
|
324
|
+
cwd: appDir,
|
|
325
|
+
env,
|
|
326
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
var defaultDeps = {
|
|
330
|
+
spawnBuild: spawnNextBuild,
|
|
331
|
+
sampleTable: sampleProcessTable,
|
|
332
|
+
now: () => Date.now(),
|
|
333
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
334
|
+
};
|
|
335
|
+
function recordSample(rows, rootPid, atMs, workers, parentSamples) {
|
|
336
|
+
const root = rows.find((row) => row.pid === rootPid);
|
|
337
|
+
if (root !== void 0) {
|
|
338
|
+
parentSamples.push({ atMs, rssBytes: root.rssBytes });
|
|
339
|
+
}
|
|
340
|
+
for (const row of descendantsOf(rows, rootPid)) {
|
|
341
|
+
if (!isStaticGenWorker(row)) {
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const series = workers.get(row.pid) ?? [];
|
|
345
|
+
series.push({ atMs, rssBytes: row.rssBytes });
|
|
346
|
+
workers.set(row.pid, series);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
function worstWorker(workers) {
|
|
350
|
+
let worst = null;
|
|
351
|
+
let worstGrowth = -Infinity;
|
|
352
|
+
for (const worker of workers) {
|
|
353
|
+
const growth = netGrowthOf(classifyBuildSamples(worker.samples).levels);
|
|
354
|
+
if (growth > worstGrowth) {
|
|
355
|
+
worstGrowth = growth;
|
|
356
|
+
worst = worker;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return worst;
|
|
360
|
+
}
|
|
361
|
+
var peakOf = (samples) => samples.reduce((highest, sample) => Math.max(highest, sample.rssBytes), 0);
|
|
362
|
+
async function runBuildMeasurement(options, deps = defaultDeps) {
|
|
363
|
+
const target = await validateBuildTarget(options.appDir);
|
|
364
|
+
const progress = options.onProgress ?? (() => {
|
|
365
|
+
});
|
|
366
|
+
const env = options.env ?? process.env;
|
|
367
|
+
const strippedCapWarning = strippedHeapCap(env["NODE_OPTIONS"]);
|
|
368
|
+
if (strippedCapWarning !== null) {
|
|
369
|
+
progress(strippedCapWarning);
|
|
370
|
+
}
|
|
371
|
+
progress(`building ${target.appDir}`);
|
|
372
|
+
const child = deps.spawnBuild(target.appDir, env);
|
|
373
|
+
registerChild(child);
|
|
374
|
+
let output = "";
|
|
375
|
+
const capture = (chunk) => {
|
|
376
|
+
output += String(chunk);
|
|
377
|
+
};
|
|
378
|
+
child.stdout?.on("data", capture);
|
|
379
|
+
child.stderr?.on("data", capture);
|
|
380
|
+
const startedAt = deps.now();
|
|
381
|
+
const workerSamples = /* @__PURE__ */ new Map();
|
|
382
|
+
const parentSamples = [];
|
|
383
|
+
let finished = false;
|
|
384
|
+
const exit = new Promise((resolve) => {
|
|
385
|
+
child.once("exit", (code) => {
|
|
386
|
+
finished = true;
|
|
387
|
+
resolve(code);
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
const abort = () => {
|
|
391
|
+
child.kill("SIGTERM");
|
|
392
|
+
};
|
|
393
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
394
|
+
let samplingFailure = null;
|
|
395
|
+
const polling = (async () => {
|
|
396
|
+
while (!finished) {
|
|
397
|
+
await deps.sleep(POLL_MS);
|
|
398
|
+
if (finished || child.pid === void 0) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const sample = await deps.sampleTable();
|
|
402
|
+
if (!sample.ok) {
|
|
403
|
+
samplingFailure = sample.reason;
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
recordSample(sample.rows, child.pid, deps.now() - startedAt, workerSamples, parentSamples);
|
|
407
|
+
}
|
|
408
|
+
})();
|
|
409
|
+
const exitCode = await exit;
|
|
410
|
+
await polling;
|
|
411
|
+
options.signal?.removeEventListener("abort", abort);
|
|
412
|
+
unregisterChild(child);
|
|
413
|
+
const workers = [...workerSamples].map(([pid, samples]) => ({ pid, samples }));
|
|
414
|
+
const heapExhausted = diedOfHeapExhaustion(output);
|
|
415
|
+
const pagesGenerated = pagesGeneratedFrom(output);
|
|
416
|
+
if (workers.length === 0) {
|
|
417
|
+
return {
|
|
418
|
+
appDir: target.appDir,
|
|
419
|
+
// "Could not look" is not "nothing was there". A build that broke before
|
|
420
|
+
// generating anything is a failed build, not a measurement of nothing.
|
|
421
|
+
status: samplingFailure !== null ? "cannot-sample" : exitCode === 0 ? "nothing-to-measure" : "build-failed",
|
|
422
|
+
samplingFailure,
|
|
423
|
+
verdict: null,
|
|
424
|
+
trend: null,
|
|
425
|
+
levels: [],
|
|
426
|
+
workers,
|
|
427
|
+
parentSamples,
|
|
428
|
+
peakWorkerRssBytes: 0,
|
|
429
|
+
netGrowthBytes: 0,
|
|
430
|
+
pagesGenerated,
|
|
431
|
+
retentionPerPageBytes: null,
|
|
432
|
+
heapExhausted,
|
|
433
|
+
strippedCapWarning,
|
|
434
|
+
exitCode,
|
|
435
|
+
output
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
const worst = worstWorker(workers);
|
|
439
|
+
const { trend, levels } = classifyBuildSamples(worst?.samples ?? []);
|
|
440
|
+
const peakWorkerRssBytes = workers.reduce(
|
|
441
|
+
(highest, worker) => Math.max(highest, peakOf(worker.samples)),
|
|
442
|
+
0
|
|
443
|
+
);
|
|
444
|
+
const measured = exitCode === 0 || heapExhausted;
|
|
445
|
+
const verdict = heapExhausted ? "leak" : trend.verdict;
|
|
446
|
+
const growthBytes = heapExhausted ? 0 : netGrowthOf(levels);
|
|
447
|
+
return {
|
|
448
|
+
appDir: target.appDir,
|
|
449
|
+
status: measured ? "measured" : "build-failed",
|
|
450
|
+
samplingFailure,
|
|
451
|
+
verdict: measured ? verdict : null,
|
|
452
|
+
trend: measured ? trend : null,
|
|
453
|
+
levels: measured ? levels : [],
|
|
454
|
+
workers,
|
|
455
|
+
parentSamples,
|
|
456
|
+
peakWorkerRssBytes,
|
|
457
|
+
netGrowthBytes: measured ? growthBytes : 0,
|
|
458
|
+
pagesGenerated,
|
|
459
|
+
retentionPerPageBytes: measured && growthBytes > 0 && pagesGenerated !== null && pagesGenerated > 0 ? growthBytes / pagesGenerated : null,
|
|
460
|
+
heapExhausted,
|
|
461
|
+
strippedCapWarning,
|
|
462
|
+
exitCode,
|
|
463
|
+
output
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// src/cli.ts
|
|
21
468
|
var require2 = createRequire(import.meta.url);
|
|
22
469
|
var { version } = require2("../package.json");
|
|
23
470
|
var HEADROOM_SENTINEL = "NEXT_LEAK_WITH_HEADROOM";
|
|
@@ -26,7 +473,7 @@ function hasHeapHeadroom() {
|
|
|
26
473
|
return process.execArgv.some((argument) => argument.includes("--max-old-space-size")) || (process.env.NODE_OPTIONS ?? "").includes("--max-old-space-size") || process.env[HEADROOM_SENTINEL] === "1";
|
|
27
474
|
}
|
|
28
475
|
function reexecWithHeadroom() {
|
|
29
|
-
const child =
|
|
476
|
+
const child = spawn2(process.execPath, [`--max-old-space-size=${HEADROOM_MB}`, ...process.argv.slice(1)], {
|
|
30
477
|
stdio: "inherit",
|
|
31
478
|
env: { ...process.env, [HEADROOM_SENTINEL]: "1" }
|
|
32
479
|
});
|
|
@@ -76,9 +523,58 @@ function handleNonRunCommand(parsed) {
|
|
|
76
523
|
}
|
|
77
524
|
return false;
|
|
78
525
|
}
|
|
526
|
+
async function runBuildCommand(appDir) {
|
|
527
|
+
const aborter = installInterruptHandlers();
|
|
528
|
+
const result = await runBuildMeasurement({
|
|
529
|
+
appDir,
|
|
530
|
+
signal: aborter.signal,
|
|
531
|
+
onProgress: (message) => console.error(`\xB7 ${message}`)
|
|
532
|
+
});
|
|
533
|
+
console.log(formatBuildReport(result));
|
|
534
|
+
if (aborter.signal.aborted) {
|
|
535
|
+
process.exitCode = 130;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
async function writeConfigSkeleton(appDir) {
|
|
539
|
+
const target = await validateTarget(appDir);
|
|
540
|
+
const routes = [...discoverRoutes(target.appPaths), ...discoverPagesRoutes(target.pages)].filter((route) => route.dynamic && route.unaddressableReason === void 0).map((route) => route.path);
|
|
541
|
+
const skeleton = renderConfigSkeleton(routes, target.prerender);
|
|
542
|
+
if (skeleton === null) {
|
|
543
|
+
console.log("no route needs sample params \u2014 nothing to write");
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const file = path3.join(target.appDir, "next-leak.config.json");
|
|
547
|
+
try {
|
|
548
|
+
await writeFile(file, `${skeleton}
|
|
549
|
+
`, { flag: "wx" });
|
|
550
|
+
} catch {
|
|
551
|
+
console.error(
|
|
552
|
+
`error: ${file} already exists \u2014 not overwriting. The fragment that would measure the skipped routes:
|
|
553
|
+
|
|
554
|
+
${skeleton}`
|
|
555
|
+
);
|
|
556
|
+
process.exitCode = 1;
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
console.log(`wrote ${file}`);
|
|
560
|
+
if (hasPlaceholders(skeleton)) {
|
|
561
|
+
console.log("replace each REPLACE-ME with a value that exists in your app");
|
|
562
|
+
}
|
|
563
|
+
}
|
|
79
564
|
async function main() {
|
|
80
565
|
const parsed = parseCliArgs(process.argv.slice(2));
|
|
81
|
-
if (handleNonRunCommand(parsed)
|
|
566
|
+
if (handleNonRunCommand(parsed)) {
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (parsed.kind === "build") {
|
|
570
|
+
await runBuildCommand(parsed.options.appDir);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (parsed.kind !== "run") {
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (parsed.options.writeConfig) {
|
|
577
|
+
await writeConfigSkeleton(parsed.options.appDir);
|
|
82
578
|
return;
|
|
83
579
|
}
|
|
84
580
|
if (!hasHeapHeadroom()) {
|
|
@@ -98,6 +594,7 @@ async function main() {
|
|
|
98
594
|
...options.requests !== null && { loadRequests: options.requests },
|
|
99
595
|
...options.connections !== null && { connections: options.connections },
|
|
100
596
|
...options.idleSeconds !== null && { idleMs: options.idleSeconds * 1e3 },
|
|
597
|
+
...options.warmupRequests !== null && { warmupRequests: options.warmupRequests },
|
|
101
598
|
...options.maxOldSpaceMb !== null && { maxOldSpaceMb: options.maxOldSpaceMb },
|
|
102
599
|
...options.diffAll && { diffAll: true },
|
|
103
600
|
...options.noResolve && { resolveInconclusive: false },
|
|
@@ -107,6 +604,11 @@ async function main() {
|
|
|
107
604
|
console.log(formatReport(report));
|
|
108
605
|
if (aborter.signal.aborted) {
|
|
109
606
|
process.exitCode = 130;
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (!report.routes.some((route) => route.status === "measured")) {
|
|
610
|
+
console.error("error: no route was measured \u2014 nothing above is a verdict about your app");
|
|
611
|
+
process.exitCode = 1;
|
|
110
612
|
}
|
|
111
613
|
}
|
|
112
614
|
try {
|
package/dist/confidence.d.ts
CHANGED
|
@@ -15,8 +15,11 @@ import { type TrendResult, type TrendVerdict } from "./trend.js";
|
|
|
15
15
|
* `thin-evidence` — a leak called on too few cycles for its size
|
|
16
16
|
* `near-heap-ceiling` — the heap approached the cap the process ran under,
|
|
17
17
|
* so the curve was measured against a ceiling instead of running free
|
|
18
|
+
* `warm-up-baseline` — the baseline sat far above the level the cycles
|
|
19
|
+
* settled at, so it carries warm-up's own retention rather than the app's
|
|
20
|
+
* resting size
|
|
18
21
|
*/
|
|
19
|
-
export type WarningCode = "unsettled" | "settle-unverified" | "load-incomplete" | "abandon-ineffective" | "abandon-before-response" | "spiky-growth" | "near-threshold" | "thin-evidence" | "near-heap-ceiling";
|
|
22
|
+
export type WarningCode = "unsettled" | "settle-unverified" | "load-incomplete" | "abandon-ineffective" | "abandon-before-response" | "spiky-growth" | "near-threshold" | "thin-evidence" | "near-heap-ceiling" | "warm-up-baseline";
|
|
20
23
|
export type MeasurementWarning = {
|
|
21
24
|
code: WarningCode;
|
|
22
25
|
detail: string;
|
|
@@ -44,6 +47,8 @@ export type ConfidenceInput = {
|
|
|
44
47
|
memorySamples?: readonly HeapSample[];
|
|
45
48
|
/** Old-space cap the measured process ran under (MB). */
|
|
46
49
|
maxOldSpaceMb?: number;
|
|
50
|
+
/** Warm-up requests the run sent before the baseline, for the warm-up check. */
|
|
51
|
+
warmupRequests?: number;
|
|
47
52
|
};
|
|
48
53
|
/**
|
|
49
54
|
* Cycles a re-measurement uses when a verdict came back `inconclusive` — the
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createRequire as __nextLeakCreateRequire } from 'node:module';import { fileURLToPath as __nextLeakFileURLToPath } from 'node:url';import { dirname as __nextLeakDirname } from 'node:path';const require = __nextLeakCreateRequire(import.meta.url);const __filename = __nextLeakFileURLToPath(import.meta.url);const __dirname = __nextLeakDirname(__filename);
|
|
2
2
|
import {
|
|
3
3
|
renderHtmlReport
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import "./chunk-
|
|
4
|
+
} from "./chunk-6CJU5TVV.js";
|
|
5
|
+
import "./chunk-22PBLCGO.js";
|
|
6
6
|
import "./chunk-XHPUAMJG.js";
|
|
7
7
|
import "./chunk-6XYFBOL2.js";
|
|
8
8
|
export {
|
package/dist/index.js
CHANGED
|
@@ -39,13 +39,13 @@ import {
|
|
|
39
39
|
sourceIndexAt,
|
|
40
40
|
summarizeBaseline,
|
|
41
41
|
validateTarget
|
|
42
|
-
} from "./chunk-
|
|
42
|
+
} from "./chunk-AMSDGT3Y.js";
|
|
43
43
|
import {
|
|
44
44
|
renderHtmlReport
|
|
45
|
-
} from "./chunk-
|
|
45
|
+
} from "./chunk-6CJU5TVV.js";
|
|
46
46
|
import {
|
|
47
47
|
classifyTrend
|
|
48
|
-
} from "./chunk-
|
|
48
|
+
} from "./chunk-22PBLCGO.js";
|
|
49
49
|
import {
|
|
50
50
|
renderIssueMarkdown
|
|
51
51
|
} from "./chunk-WXAFXVWS.js";
|
package/dist/isr.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { PrerenderManifest } from "./manifests.js";
|
|
2
|
+
/**
|
|
3
|
+
* The header Next accepts as an authentic revalidation request.
|
|
4
|
+
*
|
|
5
|
+
* Sent with the build's own `previewModeId`, it makes the server re-render the
|
|
6
|
+
* page instead of serving the cached one. Without it, load against an ISR route
|
|
7
|
+
* exercises the static cache and nothing else — measured on the
|
|
8
|
+
* vercel/next.js#96533 reproduction, the same app reports `leak` with this
|
|
9
|
+
* header and `stable` without it.
|
|
10
|
+
*/
|
|
11
|
+
export declare const REVALIDATE_HEADER = "x-prerender-revalidate";
|
|
12
|
+
/**
|
|
13
|
+
* The revalidation period in force for a route, or null when it has none.
|
|
14
|
+
*
|
|
15
|
+
* Accepts either a concrete path (`/posts/post-1`) or a dynamic template
|
|
16
|
+
* (`/posts/[slug]`): the period lives on concrete entries, and a template
|
|
17
|
+
* inherits it from any entry whose `srcRoute` points back at it.
|
|
18
|
+
*/
|
|
19
|
+
export declare function revalidateSecondsFor(manifest: PrerenderManifest | undefined, route: string): number | null;
|
|
20
|
+
/** Whether a route is served from the ISR cache and needs driving. */
|
|
21
|
+
export declare function revalidates(manifest: PrerenderManifest | undefined, route: string): boolean;
|
|
22
|
+
export type RevalidationPlan = {
|
|
23
|
+
kind: "not-isr";
|
|
24
|
+
} | {
|
|
25
|
+
kind: "drive";
|
|
26
|
+
headers: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
/** ISR, but the manifest cannot supply what an authentic request needs. */
|
|
29
|
+
| {
|
|
30
|
+
kind: "cannot-drive";
|
|
31
|
+
reason: string;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* How to make a route re-render, given what the build tells us.
|
|
35
|
+
*
|
|
36
|
+
* A header the user set themselves wins untouched: someone driving a bespoke
|
|
37
|
+
* revalidation path knows more about it than the manifest does.
|
|
38
|
+
*/
|
|
39
|
+
export declare function planRevalidation(manifest: PrerenderManifest | undefined, route: string, userHeaders: Record<string, string> | undefined): RevalidationPlan;
|
package/dist/launcher.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ChildProcess } from "node:child_process";
|
|
1
2
|
/**
|
|
2
3
|
* Old-space cap for measured processes, when the caller does not pick one.
|
|
3
4
|
*
|
|
@@ -40,6 +41,13 @@ export declare class LaunchError extends Error {
|
|
|
40
41
|
}
|
|
41
42
|
/** Interrupt safety: no measured-app process may outlive the CLI. */
|
|
42
43
|
export declare function killActiveChildren(): void;
|
|
44
|
+
/**
|
|
45
|
+
* Puts a process this tool spawned under the same interrupt safety as a
|
|
46
|
+
* measured app — a build launched by `next-leak build` must not outlive a
|
|
47
|
+
* Ctrl+C either.
|
|
48
|
+
*/
|
|
49
|
+
export declare function registerChild(child: ChildProcess): void;
|
|
50
|
+
export declare function unregisterChild(child: ChildProcess): void;
|
|
43
51
|
/**
|
|
44
52
|
* Turns a stack dump into a sentence when the cause is recognisable. Seen in
|
|
45
53
|
* the wild: a webpack `output: standalone` build that ships without
|