next-leak 0.10.1 → 0.11.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 CHANGED
@@ -115,6 +115,7 @@ Every report prints the gate it used.
115
115
  |---|---|---|
116
116
  | `--routes <list>` | all | Only measure these routes (comma-separated templates or prefixes) |
117
117
  | `--cycles <n>` | 4 | Load cycles per route (min 3). The first is dropped as warm-up, so the verdict sees `n − 1` deltas — at 3 it sees two |
118
+ | `--repeat <n>` | 1 | Measure each route `n` times, each from a fresh server, and judge it on all of them (max 10). The run takes `n` times as long. Routes whose repetitions disagree are reported `inconclusive`, and the spread of growth rates is printed either way. More cycles watch one process for longer; repetitions watch different ones, which is where the variance lives — three runs of one Next build measured 602, 826 and 875 MB retained, and a fourth measured 39 MB |
118
119
  | `--requests <n>` | 5000 | Requests per cycle. Raises sensitivity as well as duration: the growth gate scales with it, down to a noise floor around 5000 |
119
120
  | `--connections <n>` | 100 | Concurrent connections |
120
121
  | `--idle <seconds>` | 30 | **Maximum** wait before each sample; the run continues as soon as the heap settles |
@@ -31,23 +31,32 @@ function isStepwiseGrowth(samples, deltas, growingCycles, mean, minGrowth) {
31
31
  }
32
32
  return maxDrawdown(samples) <= netGrowth * STEPWISE_MAX_DRAWDOWN_RATIO;
33
33
  }
34
- function isSaturating(deltas) {
34
+ function isSaturating(deltas, minGrowth) {
35
35
  if (deltas.length < SATURATION_MIN_CYCLES) {
36
36
  return false;
37
37
  }
38
38
  const first = deltas[0];
39
39
  const last = deltas[deltas.length - 1];
40
- if (first === void 0 || last === void 0 || first <= 0) {
40
+ if (first === void 0 || last === void 0 || first < minGrowth) {
41
41
  return false;
42
42
  }
43
- for (let i = 1; i < deltas.length; i += 1) {
44
- const current = deltas[i];
45
- const previous = deltas[i - 1];
46
- if (current === void 0 || previous === void 0 || current >= previous) {
47
- return false;
48
- }
43
+ if (!deltas.every((delta) => delta > 0)) {
44
+ return false;
49
45
  }
50
- return last <= first * SATURATION_MAX_FINAL_RATIO;
46
+ if (last > first * SATURATION_MAX_FINAL_RATIO) {
47
+ return false;
48
+ }
49
+ return isHeadingDown(deltas);
50
+ }
51
+ function isHeadingDown(deltas) {
52
+ const midpoint = Math.floor(deltas.length / 2);
53
+ const head = deltas.slice(0, midpoint);
54
+ const tail = deltas.slice(midpoint);
55
+ if (head.length === 0 || tail.length === 0) {
56
+ return false;
57
+ }
58
+ const mean = (values) => values.reduce((sum, value) => sum + value, 0) / values.length;
59
+ return mean(tail) < mean(head);
51
60
  }
52
61
  function isTooLargeToAcquit(deltas, mean, minGrowth) {
53
62
  const netGrowth = deltas.reduce((sum, delta) => sum + delta, 0);
@@ -71,10 +80,10 @@ function classifyShape(samples, options) {
71
80
  const allGrow = deltas.every((d) => d >= minGrowth);
72
81
  const anyFlatOrDown = deltas.some((d) => d <= 0);
73
82
  const growingCycles = deltas.filter((d) => d >= minGrowth).length;
83
+ if (isSaturating(deltas, minGrowth)) {
84
+ return { verdict: "saturating", growthPerCycle: mean, deltas, source: "heap" };
85
+ }
74
86
  if (allGrow) {
75
- if (isSaturating(deltas)) {
76
- return { verdict: "saturating", growthPerCycle: mean, deltas, source: "heap" };
77
- }
78
87
  return { verdict: "leak", growthPerCycle: mean, deltas, source: "heap" };
79
88
  }
80
89
  if (isStepwiseGrowth(samples, deltas, growingCycles, mean, minGrowth)) {
@@ -115,7 +124,8 @@ function effectiveVerdict(report) {
115
124
  var VERDICT_WEAKENING = /* @__PURE__ */ new Set([
116
125
  "near-threshold",
117
126
  "spiky-growth",
118
- "thin-evidence"
127
+ "thin-evidence",
128
+ "repetitions-disagree"
119
129
  ]);
120
130
  function warrantsIssueDraft(report) {
121
131
  return effectiveVerdict(report) === "leak" && !report.confidence.warnings.some((warning) => VERDICT_WEAKENING.has(warning.code));
@@ -7,7 +7,7 @@ import {
7
7
  minGrowthFor,
8
8
  resolveCycles,
9
9
  warrantsIssueDraft
10
- } from "./chunk-5ZBIS5WM.js";
10
+ } from "./chunk-4FYSLLSX.js";
11
11
  import {
12
12
  assessPeakPressure,
13
13
  describePeakPressure
@@ -92939,6 +92939,7 @@ var BUILD_COMMAND = "build";
92939
92939
  var RUN_ONLY_FLAGS = [
92940
92940
  ["routes", "--routes"],
92941
92941
  ["cycles", "--cycles"],
92942
+ ["repeat", "--repeat"],
92942
92943
  ["requests", "--requests"],
92943
92944
  ["connections", "--connections"],
92944
92945
  ["idleSeconds", "--idle"],
@@ -92967,6 +92968,12 @@ var FLAGS = [
92967
92968
  help: "Only measure these routes \u2014 comma-separated templates or prefixes (e.g. /api,/dashboard)"
92968
92969
  },
92969
92970
  { flag: "--cycles", value: "int", argName: "<n>", help: "Load cycles per route (default 4, minimum 3)" },
92971
+ {
92972
+ flag: "--repeat",
92973
+ value: "int",
92974
+ argName: "<n>",
92975
+ help: "Measure each route n times from a fresh server (default 1) \u2014 the run takes n times as long, and routes whose repetitions disagree are reported inconclusive"
92976
+ },
92970
92977
  { flag: "--requests", value: "int", argName: "<n>", help: "Requests per cycle (default 5000)" },
92971
92978
  { flag: "--connections", value: "int", argName: "<n>", help: "Concurrent connections (default 100)" },
92972
92979
  { flag: "--idle", value: "int", argName: "<seconds>", help: "Idle seconds before each sample (default 30)" },
@@ -93048,6 +93055,7 @@ interrupted, 1 on errors.
93048
93055
  }
93049
93056
  var LIMITS = {
93050
93057
  "--cycles": 100,
93058
+ "--repeat": 10,
93051
93059
  "--requests": 1e6,
93052
93060
  "--connections": 1e4,
93053
93061
  "--idle": 3600,
@@ -93088,6 +93096,7 @@ function applyNumericFlag(flag, value, options) {
93088
93096
  );
93089
93097
  }
93090
93098
  if (flag === "--cycles") options.cycles = parsed;
93099
+ if (flag === "--repeat") options.repeat = parsed;
93091
93100
  if (flag === "--requests") options.requests = parsed;
93092
93101
  if (flag === "--connections") options.connections = parsed;
93093
93102
  if (flag === "--idle") options.idleSeconds = parsed;
@@ -93100,6 +93109,7 @@ function applyFlag(spec, value, options) {
93100
93109
  case "--routes":
93101
93110
  return applyRoutesFlag(value, options);
93102
93111
  case "--cycles":
93112
+ case "--repeat":
93103
93113
  case "--requests":
93104
93114
  case "--connections":
93105
93115
  case "--idle":
@@ -93171,6 +93181,7 @@ function parseCliArgs(argv) {
93171
93181
  appDir: "",
93172
93182
  routes: null,
93173
93183
  cycles: null,
93184
+ repeat: null,
93174
93185
  requests: null,
93175
93186
  connections: null,
93176
93187
  idleSeconds: null,
@@ -94242,6 +94253,19 @@ function revalidationLines(route) {
94242
94253
  ` driven through ISR revalidation (revalidates every ${route.revalidatedEverySeconds}s; without it the load would serve the cache)`
94243
94254
  ];
94244
94255
  }
94256
+ function repetitionLines(route) {
94257
+ const repetitions = route.repetitions;
94258
+ if (repetitions === void 0 || repetitions.length < 2) {
94259
+ return [];
94260
+ }
94261
+ const rates = repetitions.map((entry) => entry.growthPer1000Requests);
94262
+ const low = Math.min(...rates);
94263
+ const high = Math.max(...rates);
94264
+ const verdicts = [...new Set(repetitions.map((entry) => entry.verdict))];
94265
+ const agreement = verdicts.length === 1 ? `all ${repetitions.length} agreed on ${verdicts[0]}` : `they disagreed (${verdicts.join(", ")}), so no single verdict is reported`;
94266
+ const range = `${low >= 0 ? "+" : ""}${(low / MB2).toFixed(2)} to ${formatGrowth(high)}`;
94267
+ return [` across ${repetitions.length} repetitions: ${range} \u2014 ${agreement}`];
94268
+ }
94245
94269
  function cacheLines(route) {
94246
94270
  const lines = [];
94247
94271
  if (route.trend.verdict === "saturating") {
@@ -94363,6 +94387,7 @@ function routeLines(route, parameters) {
94363
94387
  ` ${VERDICT_ICON[verdict]} ${route.route} ${verdict} (${formatGrowth(
94364
94388
  route.growthPer1000Requests
94365
94389
  )}) heap ${curve}${resolved}`,
94390
+ ...repetitionLines(route),
94366
94391
  ...revalidationLines(route),
94367
94392
  ...cacheLines(route),
94368
94393
  ...abandonLines(route),
@@ -94752,7 +94777,7 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
94752
94777
  ...options.warmupRequests !== void 0 && { warmupRequests: options.warmupRequests },
94753
94778
  ...options.loadRequests !== void 0 && { loadRequests: options.loadRequests },
94754
94779
  ...options.connections !== void 0 && { connections: options.connections },
94755
- ...pass !== void 0 ? { cycles: pass.cycles } : options.cycles !== void 0 && { cycles: options.cycles },
94780
+ ...pass?.cycles !== void 0 ? { cycles: pass.cycles } : options.cycles !== void 0 && { cycles: options.cycles },
94756
94781
  ...options.idleMs !== void 0 && { idleMs: options.idleMs },
94757
94782
  ...options.maxOldSpaceMb !== void 0 && { maxOldSpaceMb: options.maxOldSpaceMb },
94758
94783
  ...headers !== void 0 && { headers },
@@ -94810,7 +94835,7 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
94810
94835
  route: route.path,
94811
94836
  status: "measured",
94812
94837
  requestPath,
94813
- ...pass !== void 0 && { resolvedWithCycles: pass.cycles },
94838
+ ...pass?.cycles !== void 0 && { resolvedWithCycles: pass.cycles },
94814
94839
  samples: result.samples,
94815
94840
  memorySamples: result.memorySamples,
94816
94841
  peaks: result.peaks,
@@ -94841,7 +94866,7 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
94841
94866
  };
94842
94867
  }
94843
94868
  async function writeEvidenceBundle(report, workDir) {
94844
- const { renderHtmlReport } = await import("./html-report-YWIK5EDQ.js");
94869
+ const { renderHtmlReport } = await import("./html-report-O7ILFOF5.js");
94845
94870
  const { renderIssueMarkdown } = await import("./issue-report-FPVOZ6HU.js");
94846
94871
  for (const route of report.routes) {
94847
94872
  if (route.status === "measured" && warrantsIssueDraft(route)) {
@@ -94879,7 +94904,7 @@ async function routeReportFor(context, route, index, total) {
94879
94904
  progress(`not measuring ${label}: ${plan.reason}`);
94880
94905
  return { route: route.path, status: "not-exercised", reason: plan.reason };
94881
94906
  }
94882
- return measureWithResolution(context, route, requestPath, index, label);
94907
+ return measureRepeatedly(context, route, requestPath, index, label);
94883
94908
  }
94884
94909
  function reasonToResolve(verdict) {
94885
94910
  switch (verdict) {
@@ -94892,12 +94917,74 @@ function reasonToResolve(verdict) {
94892
94917
  return null;
94893
94918
  }
94894
94919
  }
94895
- async function measureWithResolution(context, route, requestPath, index, label) {
94920
+ async function measureRepeatedly(context, route, requestPath, index, label) {
94921
+ const repeat = context.options.repeat ?? 1;
94922
+ if (repeat <= 1) {
94923
+ return measureWithResolution(context, route, requestPath, index, label);
94924
+ }
94925
+ const passes = [];
94926
+ for (let attempt = 1; attempt <= repeat; attempt += 1) {
94927
+ context.progress(`repetition ${attempt}/${repeat} of ${label}`);
94928
+ passes.push(
94929
+ await measureWithResolution(context, route, requestPath, index, label, `-rep${attempt}`)
94930
+ );
94931
+ if (context.options.signal?.aborted === true) {
94932
+ break;
94933
+ }
94934
+ }
94935
+ return aggregateRepetitions(passes);
94936
+ }
94937
+ function aggregateRepetitions(passes) {
94938
+ const first = passes[0];
94939
+ if (first === void 0) {
94940
+ return { route: "", status: "failed", reason: "no repetition produced a result" };
94941
+ }
94942
+ const measured = passes.filter((pass) => pass.status === "measured");
94943
+ if (measured.length !== passes.length || measured.length === 0) {
94944
+ return passes.find((pass) => pass.status !== "measured") ?? first;
94945
+ }
94946
+ const repetitions = measured.map((pass) => ({
94947
+ verdict: pass.trend.verdict,
94948
+ growthPer1000Requests: pass.trend.growthPerCycle / pass.requestsPerCycle * 1e3
94949
+ }));
94950
+ const verdicts = [...new Set(repetitions.map((entry) => entry.verdict))];
94951
+ const winner = measured[0];
94952
+ if (winner === void 0) {
94953
+ return first;
94954
+ }
94955
+ if (verdicts.length === 1) {
94956
+ return { ...winner, repetitions };
94957
+ }
94958
+ const observed = verdicts.join(", ");
94959
+ return {
94960
+ ...winner,
94961
+ repetitions,
94962
+ confidence: {
94963
+ ...winner.confidence,
94964
+ level: "low",
94965
+ warnings: [
94966
+ ...winner.confidence.warnings,
94967
+ {
94968
+ code: "repetitions-disagree",
94969
+ detail: `${repetitions.length} repetitions of this route disagreed (${observed}) \u2014 the measurement did not settle, so no single verdict is reported`
94970
+ }
94971
+ ],
94972
+ supersededVerdict: "inconclusive"
94973
+ }
94974
+ };
94975
+ }
94976
+ async function measureWithResolution(context, route, requestPath, index, label, repetitionSuffix = "") {
94896
94977
  const { progress } = context;
94897
94978
  try {
94898
94979
  const asPath = requestPath === route.path ? "" : ` as ${requestPath}`;
94899
94980
  progress(`measuring ${label}${asPath}`);
94900
- const first = await measureRoute(context, route, requestPath, index);
94981
+ const first = await measureRoute(
94982
+ context,
94983
+ route,
94984
+ requestPath,
94985
+ index,
94986
+ repetitionSuffix === "" ? void 0 : { dirSuffix: repetitionSuffix }
94987
+ );
94901
94988
  const reason = first.status === "measured" ? reasonToResolve(effectiveVerdict(first)) : null;
94902
94989
  if (first.status !== "measured" || context.options.resolveInconclusive === false || reason === null) {
94903
94990
  return first;
@@ -94910,7 +94997,7 @@ async function measureWithResolution(context, route, requestPath, index, label)
94910
94997
  try {
94911
94998
  return await measureRoute(context, route, requestPath, index, {
94912
94999
  cycles,
94913
- dirSuffix: "-resolve"
95000
+ dirSuffix: `${repetitionSuffix}-resolve`
94914
95001
  });
94915
95002
  } catch (cause) {
94916
95003
  progress(
@@ -1,7 +1,7 @@
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
  effectiveVerdict
4
- } from "./chunk-5ZBIS5WM.js";
4
+ } from "./chunk-4FYSLLSX.js";
5
5
  import {
6
6
  assessPeakPressure,
7
7
  describePeakPressure
@@ -2,6 +2,15 @@ export type CliRunOptions = {
2
2
  appDir: string;
3
3
  routes: string[] | null;
4
4
  cycles: number | null;
5
+ /**
6
+ * How many times to measure each route, from a fresh server each time.
7
+ *
8
+ * More cycles watch one process for longer; repetitions watch different
9
+ * processes. The spreads that make a verdict unpublishable live between
10
+ * processes: vercel/next.js#84648 gave 602, 826 and 875 MB on three runs of
11
+ * one build and 39 MB on a fourth.
12
+ */
13
+ repeat: number | null;
5
14
  requests: number | null;
6
15
  connections: number | null;
7
16
  idleSeconds: number | null;
package/dist/cli.js CHANGED
@@ -22,10 +22,10 @@ import {
22
22
  runSelfCheck,
23
23
  unregisterChild,
24
24
  validateTarget
25
- } from "./chunk-OIJE43WX.js";
25
+ } from "./chunk-JHOE2RCM.js";
26
26
  import {
27
27
  classifyTrend
28
- } from "./chunk-5ZBIS5WM.js";
28
+ } from "./chunk-4FYSLLSX.js";
29
29
  import "./chunk-XHPUAMJG.js";
30
30
  import "./chunk-6XYFBOL2.js";
31
31
 
@@ -927,6 +927,7 @@ async function main() {
927
927
  ...quickPreset,
928
928
  ...options.routes !== null && { routeFilter: options.routes },
929
929
  ...options.cycles !== null && { cycles: options.cycles },
930
+ ...options.repeat !== null && { repeat: options.repeat },
930
931
  ...options.requests !== null && { loadRequests: options.requests },
931
932
  ...options.connections !== null && { connections: options.connections },
932
933
  ...options.idleSeconds !== null && { idleMs: options.idleSeconds * 1e3 },
@@ -20,7 +20,16 @@ import { type TrendResult, type TrendVerdict } from "./trend.js";
20
20
  * settled at, so it carries warm-up's own retention rather than the app's
21
21
  * resting size
22
22
  */
23
- 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";
23
+ 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"
24
+ /**
25
+ * Repeated measurements of the same route did not agree.
26
+ *
27
+ * More cycles watch one process for longer; repetitions watch different
28
+ * ones, and that is where the spread lives — vercel/next.js#84648 gave 602,
29
+ * 826 and 875 MB on three runs of one build and 39 MB on a fourth. A verdict
30
+ * a second run contradicts is not a verdict.
31
+ */
32
+ | "repetitions-disagree";
24
33
  export type MeasurementWarning = {
25
34
  code: WarningCode;
26
35
  detail: string;
@@ -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-SV47LIER.js";
5
- import "./chunk-5ZBIS5WM.js";
4
+ } from "./chunk-YLHE4N5G.js";
5
+ import "./chunk-4FYSLLSX.js";
6
6
  import "./chunk-XHPUAMJG.js";
7
7
  import "./chunk-6XYFBOL2.js";
8
8
  export {
package/dist/index.js CHANGED
@@ -41,13 +41,13 @@ import {
41
41
  sourceIndexAt,
42
42
  summarizeBaseline,
43
43
  validateTarget
44
- } from "./chunk-OIJE43WX.js";
44
+ } from "./chunk-JHOE2RCM.js";
45
45
  import {
46
46
  renderHtmlReport
47
- } from "./chunk-SV47LIER.js";
47
+ } from "./chunk-YLHE4N5G.js";
48
48
  import {
49
49
  classifyTrend
50
- } from "./chunk-5ZBIS5WM.js";
50
+ } from "./chunk-4FYSLLSX.js";
51
51
  import {
52
52
  renderIssueMarkdown
53
53
  } from "./chunk-AUUMRTZZ.js";
package/dist/runner.d.ts CHANGED
@@ -8,7 +8,13 @@ import { type PrerenderManifest } from "./manifests.js";
8
8
  import { extractModuleRegistry } from "./module-registry.js";
9
9
  import { runRitual, type LoadOutcome, type PeakSample, type PhaseTiming, type SettleOutcome } from "./ritual.js";
10
10
  import { readNextVersion, type MatchedSignature } from "./signatures.js";
11
- import { type TrendResult } from "./trend.js";
11
+ import { type TrendResult, type TrendVerdict } from "./trend.js";
12
+ /** What one repetition of a route concluded, for disclosing the spread. */
13
+ export type RepetitionSummary = {
14
+ verdict: TrendVerdict;
15
+ /** Growth normalized by traffic, so repetitions stay comparable. */
16
+ growthPer1000Requests: number;
17
+ };
12
18
  export type RouteReport = {
13
19
  route: string;
14
20
  status: "skipped";
@@ -79,6 +85,12 @@ export type RouteReport = {
79
85
  unreclaimedTrend: TrendResult;
80
86
  /** Requests each cycle served — what the growth rates normalize by. */
81
87
  requestsPerCycle: number;
88
+ /**
89
+ * One entry per repetition when `repeat` was greater than 1, in the
90
+ * order they ran. Absent for a single measurement, so `run.json` keeps
91
+ * its existing shape unless repetition was asked for.
92
+ */
93
+ repetitions?: RepetitionSummary[];
82
94
  /**
83
95
  * The early-disconnect regime, when the route asked for one. Recorded
84
96
  * because a curve measured with cuts landing mid-stream and one measured
@@ -227,6 +239,13 @@ export type RunOptions = {
227
239
  * call it. Default true: the run should answer the question it was asked.
228
240
  */
229
241
  resolveInconclusive?: boolean;
242
+ /**
243
+ * How many times to measure each route, each from a fresh server. Default 1.
244
+ *
245
+ * Distinct from `cycles`, which watches a single process for longer. The
246
+ * variance that makes a number unpublishable is between processes.
247
+ */
248
+ repeat?: number;
230
249
  /**
231
250
  * Growth the self-check measured on its planted leak, when one ran before
232
251
  * this measurement. Its presence is what lets the report say a `stable`
@@ -265,6 +284,17 @@ export type RunnerDeps = {
265
284
  };
266
285
  export declare function freePort(): Promise<number>;
267
286
  export declare function routeSlug(route: string): string;
287
+ /**
288
+ * Combines repeated measurements of one route into the verdict it earned.
289
+ *
290
+ * Unanimity or nothing. Taking the most severe would let one noisy repetition
291
+ * in five accuse a healthy route, and a false accusation is the expensive
292
+ * error; taking the majority would discard exactly the disagreement the
293
+ * repetitions were run to find. When they disagree the honest report is that
294
+ * the measurement did not settle, which is what `inconclusive` means and what
295
+ * already routes to re-measurement and away from issue drafts.
296
+ */
297
+ export declare function aggregateRepetitions(passes: readonly RouteReport[]): RouteReport;
268
298
  /**
269
299
  * Full measurement run: validate the target, discover routes, run the ritual
270
300
  * per route in a fresh process, diff snapshots for non-stable verdicts, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-leak",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "Find out whether your Next.js app actually leaks memory — how much, on which route, and whose fault it is.",
5
5
  "keywords": [
6
6
  "nextjs",