rolldown-perf-kit 0.0.0-stage → 0.1.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/AGENTS.md +345 -0
- package/LICENSE +25 -0
- package/README.md +621 -2
- package/harness.mjs +4860 -0
- package/lib/build.mjs +57 -0
- package/lib/cdp.mjs +217 -0
- package/lib/chunk-discovery.mjs +226 -0
- package/lib/coverage.mjs +498 -0
- package/lib/field/analysis.mjs +225 -0
- package/lib/field/browser.mjs +232 -0
- package/lib/field/build-match.mjs +86 -0
- package/lib/field/capture.mjs +501 -0
- package/lib/field/config.mjs +84 -0
- package/lib/field/run.mjs +612 -0
- package/lib/gen-app.mjs +399 -0
- package/lib/graph-diff.mjs +136 -0
- package/lib/interrupt.mjs +25 -0
- package/lib/measure.mjs +540 -0
- package/lib/min-cut.mjs +218 -0
- package/lib/module-graph.mjs +318 -0
- package/lib/network-evidence.mjs +84 -0
- package/lib/node/launch.mjs +351 -0
- package/lib/node/measure.mjs +289 -0
- package/lib/paint-diagnostics.mjs +76 -0
- package/lib/parity/anchor.mjs +186 -0
- package/lib/parity/capture.mjs +527 -0
- package/lib/parity/compare.mjs +813 -0
- package/lib/parity/png.mjs +166 -0
- package/lib/parity/run.mjs +145 -0
- package/lib/profile.mjs +281 -0
- package/lib/replay/cert.mjs +45 -0
- package/lib/replay/model.mjs +656 -0
- package/lib/replay/run.mjs +1140 -0
- package/lib/replay/server.mjs +499 -0
- package/lib/replay/shaper.mjs +158 -0
- package/lib/rolldown-support.mjs +161 -0
- package/lib/serve.mjs +142 -0
- package/lib/worker/attrib.mjs +125 -0
- package/lib/worker/config.mjs +116 -0
- package/lib/worker/launch.mjs +337 -0
- package/lib/worker/measure.mjs +296 -0
- package/package.json +38 -4
- package/replay.mjs +79 -0
package/lib/min-cut.mjs
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// Minimum edge cut over the static module graph: the smallest set of import statements
|
|
2
|
+
// to make dynamic so a target module leaves the initial load. Dominator retained-size
|
|
3
|
+
// can't answer this once a module is reachable by several independent paths — a cut can.
|
|
4
|
+
//
|
|
5
|
+
// Unit-capacity max-flow / min-cut (Menger): S --INF--> each entry, every static edge
|
|
6
|
+
// --1--> (so max flow = min number of import edges to cut), target --INF--> T. Protected
|
|
7
|
+
// edges get INF so the cut routes around them. We return the cut NEAREST the sink (the
|
|
8
|
+
// last-hop imports into the feature — the exact files to edit), computed from the sink
|
|
9
|
+
// side of the residual graph. Reimplemented fresh from unigraph's min_cut.rs semantics;
|
|
10
|
+
// fully iterative (explicit stacks/queues) — dependency graphs reach 50k modules and
|
|
11
|
+
// tens of thousands of levels deep, which would overflow a recursive DFS.
|
|
12
|
+
|
|
13
|
+
// Sentinel capacity for the artificial/protected edges. Real max flow is bounded by the
|
|
14
|
+
// number of unit-capacity edges, so this is never a bottleneck on a cuttable path.
|
|
15
|
+
const INF = 1e12;
|
|
16
|
+
|
|
17
|
+
/** Entry module indices (the flow sources). */
|
|
18
|
+
export function entryIdx(graph) {
|
|
19
|
+
const idOf = new Map(graph.modules.map((m, i) => [m.id, i]));
|
|
20
|
+
return (graph.entryModules ?? []).map((id) => idOf.get(id)).filter((i) => i != null);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Minimum edge cut separating `target` from the entries over static edges, never cutting
|
|
25
|
+
* an edge in `protectedEdges` (`{from,to}` pairs — e.g. every import into a `--keep`
|
|
26
|
+
* sentry). Returns `{ cutEdges:[{from,to}], flow, hasUncuttableSink, blockedByProtected }`.
|
|
27
|
+
* `flow === cutEdges.length` is asserted — the built-in min-cut sanity check.
|
|
28
|
+
*/
|
|
29
|
+
export function minCut(graph, target, protectedEdges = [], sourcesArg = null) {
|
|
30
|
+
const mods = graph.modules;
|
|
31
|
+
const n = mods.length;
|
|
32
|
+
const sources = sourcesArg ?? entryIdx(graph);
|
|
33
|
+
|
|
34
|
+
// A target that is itself an entry can't be detached by cutting imports — you'd have
|
|
35
|
+
// to delete the module. Flag it, don't cut.
|
|
36
|
+
if (sources.includes(target)) {
|
|
37
|
+
return { cutEdges: [], flow: 0, hasUncuttableSink: true, blockedByProtected: false };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const source = n;
|
|
41
|
+
const sink = n + 1;
|
|
42
|
+
const total = n + 2;
|
|
43
|
+
|
|
44
|
+
// Flow network: forward/backward edges stored as consecutive pairs, so reverse(e) = e^1.
|
|
45
|
+
const edgeTo = [];
|
|
46
|
+
const edgeCap = [];
|
|
47
|
+
const adjacency = Array.from({ length: total }, () => []);
|
|
48
|
+
const realEdges = []; // cuttable graph edges we added, in insertion order
|
|
49
|
+
const addEdge = (from, to, cap) => {
|
|
50
|
+
adjacency[from].push(edgeTo.length);
|
|
51
|
+
edgeTo.push(to);
|
|
52
|
+
edgeCap.push(cap);
|
|
53
|
+
adjacency[to].push(edgeTo.length);
|
|
54
|
+
edgeTo.push(from);
|
|
55
|
+
edgeCap.push(0);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
for (const s of sources) addEdge(source, s, INF);
|
|
59
|
+
const targetSinkFwd = edgeTo.length;
|
|
60
|
+
addEdge(target, sink, INF);
|
|
61
|
+
|
|
62
|
+
const protectedSet = new Set(protectedEdges.map((e) => e.from * n + e.to));
|
|
63
|
+
// Duplicate parallel records (the same from->to listed twice in a sidecar) collapse to
|
|
64
|
+
// ONE unit edge: the cut is counted in import statements at edge-pair granularity, and
|
|
65
|
+
// two parallel unit edges would push flow past the deduped cut list (invariant throw).
|
|
66
|
+
const seenEdge = new Set();
|
|
67
|
+
for (let from = 0; from < n; from++) {
|
|
68
|
+
if (!mods[from].staticReachable) continue; // unreachable nodes carry no flow
|
|
69
|
+
for (const [to, isDynamic] of mods[from].imports ?? []) {
|
|
70
|
+
if (isDynamic) continue;
|
|
71
|
+
const key = from * n + to;
|
|
72
|
+
if (seenEdge.has(key)) continue;
|
|
73
|
+
seenEdge.add(key);
|
|
74
|
+
if (protectedSet.has(key)) {
|
|
75
|
+
addEdge(from, to, INF); // uncuttable, not recorded as a real edge
|
|
76
|
+
} else {
|
|
77
|
+
realEdges.push({ from, to });
|
|
78
|
+
addEdge(from, to, 1);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// If the sink is reachable from the source through INF-capacity edges alone, every cut
|
|
84
|
+
// must include a protected edge — there is no valid cut. Check while caps are pristine.
|
|
85
|
+
const visited = Array.from({ length: total }, () => false);
|
|
86
|
+
{
|
|
87
|
+
const queue = [source];
|
|
88
|
+
let head = 0;
|
|
89
|
+
visited[source] = true;
|
|
90
|
+
while (head < queue.length) {
|
|
91
|
+
const node = queue[head++];
|
|
92
|
+
for (const e of adjacency[node]) {
|
|
93
|
+
const next = edgeTo[e];
|
|
94
|
+
if (edgeCap[e] >= INF && !visited[next]) {
|
|
95
|
+
visited[next] = true;
|
|
96
|
+
queue.push(next);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (visited[sink]) {
|
|
102
|
+
return { cutEdges: [], flow: 0, hasUncuttableSink: false, blockedByProtected: true };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- Dinic max-flow ---
|
|
106
|
+
const level = Array.from({ length: total }, () => -1);
|
|
107
|
+
const iterPtr = Array.from({ length: total }, () => 0);
|
|
108
|
+
|
|
109
|
+
const buildLevels = () => {
|
|
110
|
+
level.fill(-1);
|
|
111
|
+
const queue = [source];
|
|
112
|
+
let head = 0;
|
|
113
|
+
level[source] = 0;
|
|
114
|
+
while (head < queue.length) {
|
|
115
|
+
const node = queue[head++];
|
|
116
|
+
for (const e of adjacency[node]) {
|
|
117
|
+
const next = edgeTo[e];
|
|
118
|
+
if (edgeCap[e] > 0 && level[next] < 0) {
|
|
119
|
+
level[next] = level[node] + 1;
|
|
120
|
+
queue.push(next);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return level[sink] >= 0;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const nextAdmissibleEdge = (node) => {
|
|
128
|
+
while (iterPtr[node] < adjacency[node].length) {
|
|
129
|
+
const e = adjacency[node][iterPtr[node]];
|
|
130
|
+
const next = edgeTo[e];
|
|
131
|
+
if (edgeCap[e] > 0 && level[next] === level[node] + 1) return e;
|
|
132
|
+
iterPtr[node] += 1;
|
|
133
|
+
}
|
|
134
|
+
return -1;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const pushBlockingFlow = () => {
|
|
138
|
+
const nodes = [source]; // current DFS path
|
|
139
|
+
const edges = []; // edges[i] = edge taken from nodes[i] to nodes[i+1]
|
|
140
|
+
while (nodes.length) {
|
|
141
|
+
const node = nodes[nodes.length - 1];
|
|
142
|
+
if (node === sink) {
|
|
143
|
+
// Augment by the path bottleneck, then retreat to just before the first edge
|
|
144
|
+
// that saturated (earlier edges keep residual and can carry more flow).
|
|
145
|
+
let bottleneck = Infinity;
|
|
146
|
+
for (const e of edges) if (edgeCap[e] < bottleneck) bottleneck = edgeCap[e];
|
|
147
|
+
let firstSaturated = edges.length;
|
|
148
|
+
for (let i = 0; i < edges.length; i++) {
|
|
149
|
+
const e = edges[i];
|
|
150
|
+
edgeCap[e] -= bottleneck;
|
|
151
|
+
edgeCap[e ^ 1] += bottleneck;
|
|
152
|
+
if (firstSaturated === edges.length && edgeCap[e] === 0) firstSaturated = i;
|
|
153
|
+
}
|
|
154
|
+
nodes.length = firstSaturated + 1;
|
|
155
|
+
edges.length = firstSaturated;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const e = nextAdmissibleEdge(node);
|
|
159
|
+
if (e !== -1) {
|
|
160
|
+
nodes.push(edgeTo[e]);
|
|
161
|
+
edges.push(e);
|
|
162
|
+
} else {
|
|
163
|
+
// Dead end: drop it from the level graph, back up, step the parent past this edge.
|
|
164
|
+
level[node] = -1;
|
|
165
|
+
nodes.pop();
|
|
166
|
+
if (edges.length) {
|
|
167
|
+
const back = edges.pop();
|
|
168
|
+
iterPtr[edgeTo[back ^ 1]] += 1;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
while (buildLevels()) {
|
|
175
|
+
iterPtr.fill(0);
|
|
176
|
+
pushBlockingFlow();
|
|
177
|
+
}
|
|
178
|
+
const flow = edgeCap[targetSinkFwd ^ 1]; // backward residual on target->sink = flow pushed
|
|
179
|
+
|
|
180
|
+
// --- extract the cut nearest the sink ---
|
|
181
|
+
// T = nodes that can still reach the sink in the residual graph (reverse BFS). Real
|
|
182
|
+
// edges crossing from the source side (∉T) into T are the last-hop imports, and the
|
|
183
|
+
// min-cut theorem guarantees they are saturated.
|
|
184
|
+
const canReach = Array.from({ length: total }, () => false);
|
|
185
|
+
{
|
|
186
|
+
const queue = [sink];
|
|
187
|
+
let head = 0;
|
|
188
|
+
canReach[sink] = true;
|
|
189
|
+
while (head < queue.length) {
|
|
190
|
+
const node = queue[head++];
|
|
191
|
+
for (const e of adjacency[node]) {
|
|
192
|
+
const neighbor = edgeTo[e];
|
|
193
|
+
if (edgeCap[e ^ 1] > 0 && !canReach[neighbor]) {
|
|
194
|
+
canReach[neighbor] = true;
|
|
195
|
+
queue.push(neighbor);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const seen = new Set();
|
|
201
|
+
const cutEdges = [];
|
|
202
|
+
for (const { from, to } of realEdges) {
|
|
203
|
+
if (!canReach[from] && canReach[to]) {
|
|
204
|
+
const key = from * n + to;
|
|
205
|
+
if (!seen.has(key)) {
|
|
206
|
+
seen.add(key);
|
|
207
|
+
cutEdges.push({ from, to });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
cutEdges.sort((a, b) => a.from - b.from || a.to - b.to);
|
|
212
|
+
|
|
213
|
+
// Built-in sanity check: the number of saturated last-hop edges must equal the flow.
|
|
214
|
+
if (cutEdges.length !== flow) {
|
|
215
|
+
throw new Error(`min-cut invariant violated: flow ${flow} != |cut| ${cutEdges.length}`);
|
|
216
|
+
}
|
|
217
|
+
return { cutEdges, flow, hasUncuttableSink: false, blockedByProtected: false };
|
|
218
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// Reads rolldown's `module-graph.json` (emitted by devtools report mode) and answers
|
|
2
|
+
// deferral questions on it. The single engine is a read-only edge-override overlay
|
|
3
|
+
// (unigraph's EdgeOverrides): the initial load is forward reachability from the entries
|
|
4
|
+
// over static edges, and every "what leaves if ...?" recomputes it with the affected
|
|
5
|
+
// edges skipped. With no sentries a single module's closure equals its dominator
|
|
6
|
+
// subtree, so `removedBytes` matches the report's `retainedBytes`.
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The report dir of one build output: `<cwd>/node_modules/.rolldown/report/<output dir>`.
|
|
13
|
+
* The same rule as `report_dir_for` in crates/rolldown_devtools_metrics/src/lib.rs: the
|
|
14
|
+
* output dir relative to cwd, each `..` written as `__`, every `:` removed.
|
|
15
|
+
*/
|
|
16
|
+
export function reportDirFor(cwd, outDir) {
|
|
17
|
+
const segments = path
|
|
18
|
+
.relative(cwd, path.resolve(cwd, outDir))
|
|
19
|
+
.split(/[\\/]/)
|
|
20
|
+
.filter((segment) => segment !== '' && segment !== '.')
|
|
21
|
+
.map((segment) => (segment === '..' ? '__' : segment.replaceAll(':', '')));
|
|
22
|
+
return path.join(cwd, 'node_modules', '.rolldown', 'report', ...segments);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Candidate report dirs, most specific first. Each is checked for module-graph.json. */
|
|
26
|
+
export function moduleGraphCandidates({ reportDir, demoMetricsDir, dist }) {
|
|
27
|
+
const candidates = [];
|
|
28
|
+
if (reportDir) {
|
|
29
|
+
candidates.push(
|
|
30
|
+
reportDir.endsWith('.json') ? reportDir : path.join(reportDir, 'module-graph.json'),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
if (demoMetricsDir) candidates.push(path.join(demoMetricsDir, 'module-graph.json'));
|
|
34
|
+
if (dist) {
|
|
35
|
+
// The report lands relative to the BUILD's cwd, which may sit one or more
|
|
36
|
+
// levels above the dist dir (nested outDirs like build/dist, monorepo app
|
|
37
|
+
// dirs) - walk up a few levels rather than assuming dist's direct parent.
|
|
38
|
+
let dir = path.dirname(dist);
|
|
39
|
+
for (let i = 0; i < 3; i++) {
|
|
40
|
+
candidates.push(path.join(reportDirFor(dir, dist), 'module-graph.json'));
|
|
41
|
+
const parent = path.dirname(dir);
|
|
42
|
+
if (parent === dir) break;
|
|
43
|
+
dir = parent;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return candidates;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Static predecessor lists (only importers that are themselves part of the initial
|
|
50
|
+
// load can pin a module into it). Factored out so hand-built test graphs get the
|
|
51
|
+
// exact same predecessor construction the loader uses.
|
|
52
|
+
export function computeStaticPreds(modules) {
|
|
53
|
+
const staticPreds = modules.map(() => []);
|
|
54
|
+
modules.forEach((mod, from) => {
|
|
55
|
+
if (!mod.staticReachable) return;
|
|
56
|
+
for (const [to, isDynamic] of mod.imports ?? []) {
|
|
57
|
+
if (!isDynamic) staticPreds[to].push(from);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
return staticPreds;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function loadModuleGraph(candidates) {
|
|
64
|
+
const file = candidates.find((candidate) => fs.existsSync(candidate));
|
|
65
|
+
if (!file) return null;
|
|
66
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
67
|
+
return {
|
|
68
|
+
file,
|
|
69
|
+
...data,
|
|
70
|
+
staticPreds: computeStaticPreds(data.modules),
|
|
71
|
+
buildReport: readBuildReport(file),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The build report (`metrics.json`) next to a report-directory `module-graph.json`:
|
|
77
|
+
* the platform of the build and the output file of each entry. Null for a saved
|
|
78
|
+
* sidecar (a baseline snapshot has no sibling) or when the report is unreadable.
|
|
79
|
+
*/
|
|
80
|
+
function readBuildReport(graphFile) {
|
|
81
|
+
if (path.basename(graphFile) !== 'module-graph.json') return null;
|
|
82
|
+
const file = path.join(path.dirname(graphFile), 'metrics.json');
|
|
83
|
+
if (!fs.existsSync(file)) return null;
|
|
84
|
+
let report;
|
|
85
|
+
try {
|
|
86
|
+
report = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
file,
|
|
92
|
+
platform: report.session?.platform ?? null,
|
|
93
|
+
format: report.session?.format ?? null,
|
|
94
|
+
entryChunks: (report.entries ?? [])
|
|
95
|
+
.map((entry) => entry?.chunk)
|
|
96
|
+
.filter((chunk) => typeof chunk === 'string' && chunk !== ''),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Why a report discovered for `dist` does not describe the build in `dist`, or null
|
|
102
|
+
* when it does (or when nothing can be checked). Report mode writes one directory per
|
|
103
|
+
* output dir, so the report is the last report-mode build into that dir: a rebuild
|
|
104
|
+
* without the report leaves the old report behind, and a build that shares the outDir
|
|
105
|
+
* replaces it. The entry chunks decide: when none of them exists in `dist`, the report
|
|
106
|
+
* is another build's.
|
|
107
|
+
*/
|
|
108
|
+
export function reportDistMismatch(graph, dist) {
|
|
109
|
+
const report = graph?.buildReport;
|
|
110
|
+
if (!report || !dist || report.entryChunks.length === 0) return null;
|
|
111
|
+
if (report.entryChunks.some((chunk) => fs.existsSync(path.join(dist, chunk)))) return null;
|
|
112
|
+
const shown =
|
|
113
|
+
report.entryChunks.slice(0, 3).join(', ') + (report.entryChunks.length > 3 ? ', ...' : '');
|
|
114
|
+
let why = `the report at ${report.file} is not the build in ${dist}: none of its entry chunks (${shown}) exists there.`;
|
|
115
|
+
if (report.platform === 'node') {
|
|
116
|
+
// A client build (SSR frameworks write no index.html) is the usual victim, so the
|
|
117
|
+
// hint does not depend on the dist's layout.
|
|
118
|
+
why +=
|
|
119
|
+
'\nThe report comes from a node-platform build (a server), so the last report-mode build ' +
|
|
120
|
+
'into this output dir was a server build.';
|
|
121
|
+
}
|
|
122
|
+
return why;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Find a module by query: exact id, then unique suffix, then unique substring
|
|
127
|
+
* (case-insensitive). Returns `{ index }`, `{ ambiguous: [...] }`, or `null`.
|
|
128
|
+
*/
|
|
129
|
+
export function resolveModule(graph, query) {
|
|
130
|
+
const q = query.replaceAll('\\', '/');
|
|
131
|
+
const ids = graph.modules.map((m) => m.id);
|
|
132
|
+
const exact = ids.findIndex((id) => id === q);
|
|
133
|
+
if (exact >= 0) return { index: exact };
|
|
134
|
+
const lower = q.toLowerCase();
|
|
135
|
+
for (const match of [
|
|
136
|
+
(id) => id.toLowerCase().endsWith(lower),
|
|
137
|
+
(id) => id.toLowerCase().includes(lower),
|
|
138
|
+
]) {
|
|
139
|
+
const hits = [];
|
|
140
|
+
ids.forEach((id, i) => {
|
|
141
|
+
if (match(id)) hits.push(i);
|
|
142
|
+
});
|
|
143
|
+
if (hits.length === 1) return { index: hits[0] };
|
|
144
|
+
if (hits.length > 1) {
|
|
145
|
+
const rows = hits
|
|
146
|
+
.map((i) => graph.modules[i])
|
|
147
|
+
.sort((a, b) => b.retainedBytes - a.retainedBytes || a.id.localeCompare(b.id))
|
|
148
|
+
.slice(0, 8)
|
|
149
|
+
.map((m) => m.id);
|
|
150
|
+
return { ambiguous: rows };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// --- edge-override overlay engine ------------------------------------------------
|
|
157
|
+
// The initial load = everything reachable from the entries over STATIC edges. Every
|
|
158
|
+
// deferral question ("what leaves if these import edges become dynamic / vanish?") is
|
|
159
|
+
// answered by recomputing that forward reachability with the affected edges skipped —
|
|
160
|
+
// no graph mutation. This is the read-only overlay unigraph's EdgeOverrides provides,
|
|
161
|
+
// and the substrate `cut` and `graph-diff` price their answers with.
|
|
162
|
+
|
|
163
|
+
/** Indices of the entry modules (cached on the graph). Ids not present are skipped. */
|
|
164
|
+
function entryIndices(graph) {
|
|
165
|
+
if (graph.__entryIdx) return graph.__entryIdx;
|
|
166
|
+
const idOf = new Map(graph.modules.map((m, i) => [m.id, i]));
|
|
167
|
+
const idx = (graph.entryModules ?? []).map((id) => idOf.get(id)).filter((i) => i != null);
|
|
168
|
+
graph.__entryIdx = idx;
|
|
169
|
+
return idx;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The eager set: module indices reachable from the roots over static edges, with the
|
|
174
|
+
* `overrides` edges skipped. `overrides` is an array of `{ from, to, kind }` (both
|
|
175
|
+
* 'defer' and 'remove' stop the edge pinning `to` eager). `extraRoots` seeds extra
|
|
176
|
+
* always-eager roots (used to model `--keep` sentries: a kept module and everything
|
|
177
|
+
* only it needs stay eager even after the cut). The bare call (entries only, no
|
|
178
|
+
* overrides) is the base eager set and is cached on the graph object — do not mutate
|
|
179
|
+
* the returned Set.
|
|
180
|
+
*/
|
|
181
|
+
export function eagerSet(graph, overrides = [], extraRoots = null) {
|
|
182
|
+
const n = graph.modules.length;
|
|
183
|
+
const bare = overrides.length === 0 && extraRoots == null;
|
|
184
|
+
if (bare && graph.__baseEager) return graph.__baseEager;
|
|
185
|
+
// Key edges as from*n + to in a Set — no string allocation on 50k-module graphs.
|
|
186
|
+
const blocked = overrides.length ? new Set(overrides.map((o) => o.from * n + o.to)) : null;
|
|
187
|
+
const roots = extraRoots == null ? entryIndices(graph) : [...entryIndices(graph), ...extraRoots];
|
|
188
|
+
const seen = new Set();
|
|
189
|
+
const stack = [];
|
|
190
|
+
for (const r of roots) {
|
|
191
|
+
if (r != null && !seen.has(r)) {
|
|
192
|
+
seen.add(r);
|
|
193
|
+
stack.push(r);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
while (stack.length > 0) {
|
|
197
|
+
const v = stack.pop();
|
|
198
|
+
for (const [to, isDynamic] of graph.modules[v].imports ?? []) {
|
|
199
|
+
if (isDynamic) continue;
|
|
200
|
+
if (blocked && blocked.has(v * n + to)) continue;
|
|
201
|
+
if (!seen.has(to)) {
|
|
202
|
+
seen.add(to);
|
|
203
|
+
stack.push(to);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (bare) graph.__baseEager = seen;
|
|
208
|
+
return seen;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The graph as the runtime loads it when `roots` (module indices) always load at
|
|
213
|
+
* startup: a Durable Object class the runtime loads by name, or a dynamic import the
|
|
214
|
+
* app runs on every boot (`import('./initReact')`). The roots join the entries, so the
|
|
215
|
+
* initial load becomes the static closure of entries + roots, and `staticReachable`,
|
|
216
|
+
* `dynamicOnly` and the static predecessor lists are recomputed for it. Every query
|
|
217
|
+
* (`whatIf`, `evalOverrides`, `minCut`) then answers for that startup load, also for a
|
|
218
|
+
* module that only dynamic imports reach in the build. Module objects are copied, never
|
|
219
|
+
* mutated; `retainedBytes` / `idom` keep the build's entry-only values. Returns the
|
|
220
|
+
* graph itself when no root adds anything.
|
|
221
|
+
*/
|
|
222
|
+
export function withRoots(graph, roots) {
|
|
223
|
+
const entries = new Set(entryIndices(graph));
|
|
224
|
+
const extra = [...new Set(roots ?? [])].filter((r) => !entries.has(r));
|
|
225
|
+
if (!extra.length) return graph;
|
|
226
|
+
const eager = eagerSet(graph, [], extra);
|
|
227
|
+
const modules = graph.modules.map((mod, i) => ({
|
|
228
|
+
...mod,
|
|
229
|
+
staticReachable: eager.has(i),
|
|
230
|
+
// Reachable at all = statically reachable or dynamic-only in the build; the roots
|
|
231
|
+
// are reachable themselves, so nothing new becomes reachable, only eager.
|
|
232
|
+
dynamicOnly: !eager.has(i) && (mod.staticReachable === true || mod.dynamicOnly === true),
|
|
233
|
+
}));
|
|
234
|
+
const { __entryIdx, __baseEager, ...rest } = graph;
|
|
235
|
+
return {
|
|
236
|
+
...rest,
|
|
237
|
+
entryModules: [...(graph.entryModules ?? []), ...extra.map((r) => graph.modules[r].id)],
|
|
238
|
+
rootModules: extra.map((r) => graph.modules[r].id),
|
|
239
|
+
modules,
|
|
240
|
+
staticPreds: computeStaticPreds(modules),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Price a batch of edge overrides against the base (current) eager set. Returns the
|
|
246
|
+
* resulting eager set and its bytes, plus what LEFT the initial load: `removed` is the
|
|
247
|
+
* module objects sorted `bytes desc, id asc` (matching the display order), with total
|
|
248
|
+
* bytes and count. `extraRoots` models `--keep` sentries (see `eagerSet`).
|
|
249
|
+
*/
|
|
250
|
+
export function evalOverrides(graph, overrides, extraRoots = null) {
|
|
251
|
+
const mods = graph.modules;
|
|
252
|
+
const base = eagerSet(graph);
|
|
253
|
+
const next = eagerSet(graph, overrides, extraRoots);
|
|
254
|
+
let eagerBytes = 0;
|
|
255
|
+
for (const v of next) eagerBytes += mods[v].bytes;
|
|
256
|
+
const removed = [];
|
|
257
|
+
for (const v of base) if (!next.has(v)) removed.push(mods[v]);
|
|
258
|
+
removed.sort((a, b) => b.bytes - a.bytes || a.id.localeCompare(b.id));
|
|
259
|
+
const removedBytes = removed.reduce((sum, m) => sum + m.bytes, 0);
|
|
260
|
+
return { eager: next, eagerBytes, removed, removedBytes, removedCount: removed.length };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Module-level shorthand: override every static import edge into `target` to 'defer'.
|
|
265
|
+
* These are exactly the edges the what-if module query cuts. (`keep` is accepted for a
|
|
266
|
+
* symmetric signature but is applied by the caller as `extraRoots`, not here — the cut
|
|
267
|
+
* itself is the same set of edges regardless of sentries.)
|
|
268
|
+
*/
|
|
269
|
+
export function deferAllInto(graph, target) {
|
|
270
|
+
return graph.staticPreds[target].map((p) => ({ from: p, to: target, kind: 'defer' }));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The what-if-deferred closure: every module that would leave the initial load if all
|
|
275
|
+
* static import edges into `target` became dynamic. `keep` modules are sentries — they
|
|
276
|
+
* (and everything only they need) stay eager. Thin wrapper over the overlay: deferring
|
|
277
|
+
* `target`'s in-edges and recomputing forward reachability (with sentries re-seeded as
|
|
278
|
+
* roots) IS the unique-reachable set.
|
|
279
|
+
*
|
|
280
|
+
* An entry target returns `isEntry: true` with an empty result: an entry has no eager
|
|
281
|
+
* in-edge to cut — it IS the initial load. (Consolidated 2026-07-19: the pre-overlay
|
|
282
|
+
* grow/prune closure answered this case with the entry's whole forward subtree and
|
|
283
|
+
* zero cut edges, which read as "removable" but described deleting the entry, not
|
|
284
|
+
* deferring it. The old implementation survives only as the test oracle.)
|
|
285
|
+
*/
|
|
286
|
+
export function whatIf(graph, target, keep = []) {
|
|
287
|
+
const mods = graph.modules;
|
|
288
|
+
const base = {
|
|
289
|
+
target: mods[target],
|
|
290
|
+
removed: [],
|
|
291
|
+
removedBytes: 0,
|
|
292
|
+
removedCount: 0,
|
|
293
|
+
cutEdges: [],
|
|
294
|
+
alreadyLazy: mods[target].dynamicOnly === true,
|
|
295
|
+
notStaticallyReachable: mods[target].staticReachable === false,
|
|
296
|
+
isEntry: entryIndices(graph).includes(target),
|
|
297
|
+
};
|
|
298
|
+
// Targets with no eager in-edge to cut: entries (they are roots), and modules that
|
|
299
|
+
// cost the initial load nothing (dynamic-only or unreachable). The caller reports the
|
|
300
|
+
// flag and never shows a removed set.
|
|
301
|
+
if (base.isEntry || base.notStaticallyReachable) return base;
|
|
302
|
+
// Sentries stay eager via extraRoots; the target itself is never its own sentry.
|
|
303
|
+
const extraRoots = keep.filter((k) => k !== target);
|
|
304
|
+
const {
|
|
305
|
+
eager: next,
|
|
306
|
+
removed,
|
|
307
|
+
removedBytes,
|
|
308
|
+
removedCount,
|
|
309
|
+
} = evalOverrides(graph, deferAllInto(graph, target), extraRoots.length ? extraRoots : null);
|
|
310
|
+
// Cut edges are the target's static importers that STAY eager (all static preds are
|
|
311
|
+
// themselves eager, so "survives" = "still in the next eager set"); an importer that
|
|
312
|
+
// is itself removed needs no edit — it is gone from the initial load anyway.
|
|
313
|
+
const cutEdges = graph.staticPreds[target]
|
|
314
|
+
.filter((p) => next.has(p))
|
|
315
|
+
.map((p) => mods[p].id)
|
|
316
|
+
.sort();
|
|
317
|
+
return { ...base, removed, removedBytes, removedCount, cutEdges };
|
|
318
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Request source locations and browser priorities only. Never retain headers,
|
|
2
|
+
// cookies, or request bodies from CDP events.
|
|
3
|
+
export function recordNetworkEvidence(page) {
|
|
4
|
+
const byUrl = new Map();
|
|
5
|
+
const byId = new Map();
|
|
6
|
+
const withoutFragment = (url) => url.split('#')[0];
|
|
7
|
+
const stopRequests = page.on('Network.requestWillBeSent', (event) => {
|
|
8
|
+
const initiator = event.initiator ?? {};
|
|
9
|
+
let stack = initiator.stack;
|
|
10
|
+
let frame;
|
|
11
|
+
while (stack && !frame) {
|
|
12
|
+
frame = stack.callFrames?.find((f) => f.url && f.url !== 'about:client');
|
|
13
|
+
stack = stack.parent;
|
|
14
|
+
}
|
|
15
|
+
const row = {
|
|
16
|
+
start: event.timestamp,
|
|
17
|
+
redirected: Boolean(event.redirectResponse),
|
|
18
|
+
initiator: {
|
|
19
|
+
type: initiator.type,
|
|
20
|
+
url: frame?.url ?? initiator.url ?? null,
|
|
21
|
+
lineNumber: frame?.lineNumber ?? initiator.lineNumber ?? null,
|
|
22
|
+
columnNumber: frame?.columnNumber ?? initiator.columnNumber ?? null,
|
|
23
|
+
},
|
|
24
|
+
requestPriority: {
|
|
25
|
+
initial: event.request.initialPriority ?? null,
|
|
26
|
+
last: event.request.initialPriority ?? null,
|
|
27
|
+
changes: [],
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
if (event.redirectResponse && byId.has(event.requestId)) {
|
|
31
|
+
byId.get(event.requestId).redirected = true;
|
|
32
|
+
}
|
|
33
|
+
byId.set(event.requestId, row);
|
|
34
|
+
const key = withoutFragment(event.request.url);
|
|
35
|
+
const rows = byUrl.get(key) ?? [];
|
|
36
|
+
rows.push(row);
|
|
37
|
+
byUrl.set(key, rows);
|
|
38
|
+
});
|
|
39
|
+
const stopPriorities = page.on('Network.resourceChangedPriority', (event) => {
|
|
40
|
+
const row = byId.get(event.requestId);
|
|
41
|
+
if (!row) return;
|
|
42
|
+
row.requestPriority.last = event.newPriority;
|
|
43
|
+
row.requestPriority.changes.push({
|
|
44
|
+
priority: event.newPriority,
|
|
45
|
+
afterStartMs: Math.round((event.timestamp - row.start) * 1000),
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
attach(resources, origin) {
|
|
50
|
+
const keyOf = (resource) =>
|
|
51
|
+
withoutFragment(
|
|
52
|
+
resource.name.startsWith('/') ? `${origin}${resource.name}` : resource.name,
|
|
53
|
+
);
|
|
54
|
+
const counts = new Map();
|
|
55
|
+
for (const resource of resources) {
|
|
56
|
+
const key = keyOf(resource);
|
|
57
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
58
|
+
}
|
|
59
|
+
for (const resource of resources) {
|
|
60
|
+
const key = keyOf(resource);
|
|
61
|
+
const rows = byUrl.get(key) ?? [];
|
|
62
|
+
// URL alone cannot distinguish repeated requests or redirect hops.
|
|
63
|
+
// Omit ambiguous evidence instead of attaching the last request to all.
|
|
64
|
+
if (rows.length !== 1 || counts.get(key) !== 1 || rows[0].redirected) {
|
|
65
|
+
resource.networkEvidence = rows.length ? 'ambiguous' : 'missing';
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const row = rows[0];
|
|
69
|
+
resource.networkEvidence = 'matched';
|
|
70
|
+
resource.initiator = {
|
|
71
|
+
...row.initiator,
|
|
72
|
+
url: row.initiator.url?.startsWith(`${origin}/`)
|
|
73
|
+
? row.initiator.url.slice(origin.length)
|
|
74
|
+
: row.initiator.url,
|
|
75
|
+
};
|
|
76
|
+
resource.requestPriority = row.requestPriority;
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
stop() {
|
|
80
|
+
stopRequests();
|
|
81
|
+
stopPriorities();
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|