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.
Files changed (43) hide show
  1. package/AGENTS.md +345 -0
  2. package/LICENSE +25 -0
  3. package/README.md +621 -2
  4. package/harness.mjs +4860 -0
  5. package/lib/build.mjs +57 -0
  6. package/lib/cdp.mjs +217 -0
  7. package/lib/chunk-discovery.mjs +226 -0
  8. package/lib/coverage.mjs +498 -0
  9. package/lib/field/analysis.mjs +225 -0
  10. package/lib/field/browser.mjs +232 -0
  11. package/lib/field/build-match.mjs +86 -0
  12. package/lib/field/capture.mjs +501 -0
  13. package/lib/field/config.mjs +84 -0
  14. package/lib/field/run.mjs +612 -0
  15. package/lib/gen-app.mjs +399 -0
  16. package/lib/graph-diff.mjs +136 -0
  17. package/lib/interrupt.mjs +25 -0
  18. package/lib/measure.mjs +540 -0
  19. package/lib/min-cut.mjs +218 -0
  20. package/lib/module-graph.mjs +318 -0
  21. package/lib/network-evidence.mjs +84 -0
  22. package/lib/node/launch.mjs +351 -0
  23. package/lib/node/measure.mjs +289 -0
  24. package/lib/paint-diagnostics.mjs +76 -0
  25. package/lib/parity/anchor.mjs +186 -0
  26. package/lib/parity/capture.mjs +527 -0
  27. package/lib/parity/compare.mjs +813 -0
  28. package/lib/parity/png.mjs +166 -0
  29. package/lib/parity/run.mjs +145 -0
  30. package/lib/profile.mjs +281 -0
  31. package/lib/replay/cert.mjs +45 -0
  32. package/lib/replay/model.mjs +656 -0
  33. package/lib/replay/run.mjs +1140 -0
  34. package/lib/replay/server.mjs +499 -0
  35. package/lib/replay/shaper.mjs +158 -0
  36. package/lib/rolldown-support.mjs +161 -0
  37. package/lib/serve.mjs +142 -0
  38. package/lib/worker/attrib.mjs +125 -0
  39. package/lib/worker/config.mjs +116 -0
  40. package/lib/worker/launch.mjs +337 -0
  41. package/lib/worker/measure.mjs +296 -0
  42. package/package.json +38 -4
  43. package/replay.mjs +79 -0
@@ -0,0 +1,351 @@
1
+ // Server-runtime startup driver: spawn the runtime, wait for a DECLARED readiness
2
+ // signal, and — for instrumented runs — attach the V8 inspector before any user
3
+ // code evaluates.
4
+ //
5
+ // The browser hands us a readiness moment for free (FCP/LCP are observable from
6
+ // outside the page). A server process has no such universal signal: "up" means
7
+ // accepting connections for one app, a line on stdout for another, and clean exit
8
+ // for a CLI or a lambda-shaped entry. So readiness is part of the TARGET, declared
9
+ // once and pinned with it — the same role `expectedFeatures` plays for the demo app.
10
+ //
11
+ // Two run shapes, deliberately separate:
12
+ // plain — no inspector, no profiler. This is the number that gets reported;
13
+ // `--inspect-brk` alone shifts startup by tens of ms and would make
14
+ // every measurement a measurement of the harness.
15
+ // instrumented — `--inspect-brk=0`, profiler/coverage armed while the process is
16
+ // still paused at entry, then released. Used for ATTRIBUTION only
17
+ // (which module cost what), never for the reported timing.
18
+
19
+ import { spawn } from 'node:child_process';
20
+ import net from 'node:net';
21
+ import fs from 'node:fs';
22
+ import os from 'node:os';
23
+ import path from 'node:path';
24
+
25
+ import { connect } from '../cdp.mjs';
26
+
27
+ // Port/http readiness is discovered by polling, so a sample carries up to one interval
28
+ // of quantization. A local TCP connect attempt costs ~0.1ms, so the practical floor is
29
+ // setTimeout granularity rather than probe cost — poll as tightly as the timer allows.
30
+ // This matters more than it looks: on a target whose addressable startup is ~10ms, a
31
+ // 4ms quantum was 40% of the signal. `summarizeStartup` flags measurements that get
32
+ // close to this floor rather than reporting a precise-looking number it cannot support.
33
+ export const POLL_MS = 1;
34
+
35
+ const READY_MENU =
36
+ ' --ready port:3000 a TCP listener is accepting (servers)\n' +
37
+ " --ready 'stdout:ready on' a line matching this regex is printed\n" +
38
+ ' --ready http://127.0.0.1:3000/health an HTTP probe returns 2xx\n' +
39
+ ' --ready exit the process exits 0 (CLIs, lambda-shaped entries)';
40
+
41
+ /**
42
+ * Parse a `--ready` spec into a probe descriptor. Throws with the full menu — an
43
+ * unparsable spec is the one error where guessing a default would silently
44
+ * measure the wrong moment.
45
+ */
46
+ export function parseReadySpec(spec) {
47
+ const raw = String(spec ?? '').trim();
48
+ if (raw === '' || raw === 'exit') return { kind: 'exit' };
49
+ const port = raw.match(/^port:(\d{1,5})$/);
50
+ if (port) {
51
+ const value = Number(port[1]);
52
+ if (value < 1 || value > 65535) throw new Error(`--ready port:${port[1]} is not a valid port.`);
53
+ return { kind: 'port', port: value };
54
+ }
55
+ const stdout = raw.match(/^stdout:([\s\S]+)$/);
56
+ if (stdout) {
57
+ let pattern;
58
+ try {
59
+ pattern = new RegExp(stdout[1]);
60
+ } catch (err) {
61
+ throw new Error(
62
+ `--ready stdout:<regex> — ${stdout[1]} is not a valid regex (${err.message}).`,
63
+ );
64
+ }
65
+ return { kind: 'stdout', source: stdout[1], pattern };
66
+ }
67
+ if (/^https?:\/\//.test(raw)) return { kind: 'http', url: raw };
68
+ throw new Error(`unrecognized --ready spec: ${raw}\nSupported forms:\n${READY_MENU}`);
69
+ }
70
+
71
+ export function describeReady(spec) {
72
+ switch (spec.kind) {
73
+ case 'port':
74
+ return `port ${spec.port} accepting`;
75
+ case 'stdout':
76
+ return `stdout matches /${spec.source}/`;
77
+ case 'http':
78
+ return `${spec.url} returns 2xx`;
79
+ default:
80
+ return 'process exits 0';
81
+ }
82
+ }
83
+
84
+ /** True once `port` accepts a connection. Never throws — a refused connect is "not yet". */
85
+ export function portAccepting(port, { host = '127.0.0.1', timeoutMs = 250 } = {}) {
86
+ return new Promise((resolve) => {
87
+ const socket = net.connect({ port, host });
88
+ const done = (value) => {
89
+ socket.destroy();
90
+ resolve(value);
91
+ };
92
+ socket.setTimeout(timeoutMs, () => done(false));
93
+ socket.once('connect', () => done(true));
94
+ socket.once('error', () => done(false));
95
+ });
96
+ }
97
+
98
+ /**
99
+ * A port probe on an already-occupied port reports ready instantly and every run
100
+ * measures nothing. Refuse before spawning rather than emitting a fast, wrong number.
101
+ */
102
+ export async function assertReadyPortFree(spec) {
103
+ if (spec.kind !== 'port') return;
104
+ if (await portAccepting(spec.port, { timeoutMs: 150 })) {
105
+ throw new Error(
106
+ `port ${spec.port} is already accepting connections before the target started.\n` +
107
+ 'Every run would report ready immediately and measure nothing. Stop whatever holds\n' +
108
+ `the port (lsof -i :${spec.port}), or point --ready at the port this build actually uses.`,
109
+ );
110
+ }
111
+ }
112
+
113
+ /** Has the declared readiness moment happened yet? `state` is owned by the runner. */
114
+ async function isReady(spec, state) {
115
+ switch (spec.kind) {
116
+ case 'exit':
117
+ return state.exitCode === 0;
118
+ case 'stdout':
119
+ return spec.pattern.test(state.output);
120
+ case 'port':
121
+ return await portAccepting(spec.port);
122
+ case 'http':
123
+ try {
124
+ const res = await fetch(spec.url, { signal: AbortSignal.timeout(1000) });
125
+ return res.ok;
126
+ } catch {
127
+ return false;
128
+ }
129
+ default:
130
+ return false;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Environment for one run. Two inherited variables would silently corrupt a
136
+ * measurement and are always neutralized:
137
+ * NODE_V8_COVERAGE — makes V8 instrument every script, inflating startup.
138
+ * NODE_OPTIONS=--inspect* — a second inspector fights ours for the port and
139
+ * breaks the "Debugger listening on" scrape.
140
+ *
141
+ * The compile cache is the server-side equivalent of the browser's service-worker
142
+ * trap: with a warm cache, runs 2..N measure the cache rather than the bundle.
143
+ * cold (default) — a fresh cache dir per run: every run pays parse+compile, which
144
+ * is what a cold start (new container, new lambda) actually pays.
145
+ * warm — one shared dir across runs, primed by the warmup run.
146
+ * off — leave the ambient environment alone, whatever the app does.
147
+ */
148
+ export function runEnv({ cacheMode = 'cold', cacheDir = null, extraEnv = null } = {}) {
149
+ const env = { ...process.env, ...extraEnv };
150
+ delete env.NODE_V8_COVERAGE;
151
+ if (env.NODE_OPTIONS) {
152
+ const kept = env.NODE_OPTIONS.split(/\s+/).filter(
153
+ (flag) => !/^--inspect(-brk|-port)?(=|$)/.test(flag),
154
+ );
155
+ if (kept.length) env.NODE_OPTIONS = kept.join(' ');
156
+ else delete env.NODE_OPTIONS;
157
+ }
158
+ if (cacheMode === 'off') delete env.NODE_COMPILE_CACHE;
159
+ else if (cacheDir) env.NODE_COMPILE_CACHE = cacheDir;
160
+ return env;
161
+ }
162
+
163
+ export function freshCacheDir(stateDir, tag) {
164
+ const dir = path.join(stateDir, 'compile-cache', `${tag}`);
165
+ fs.rmSync(dir, { recursive: true, force: true });
166
+ fs.mkdirSync(dir, { recursive: true });
167
+ return dir;
168
+ }
169
+
170
+ function killTree(child) {
171
+ if (child.exitCode != null || child.signalCode != null) return Promise.resolve();
172
+ return new Promise((resolve) => {
173
+ const hard = setTimeout(() => {
174
+ try {
175
+ child.kill('SIGKILL');
176
+ } catch {
177
+ /* already gone */
178
+ }
179
+ resolve();
180
+ }, 2000);
181
+ child.once('exit', () => {
182
+ clearTimeout(hard);
183
+ resolve();
184
+ });
185
+ try {
186
+ child.kill('SIGTERM');
187
+ } catch {
188
+ clearTimeout(hard);
189
+ resolve();
190
+ }
191
+ });
192
+ }
193
+
194
+ /**
195
+ * One spawn→ready run.
196
+ *
197
+ * `elapsedMs` is wall time from immediately-before-spawn to the first observation
198
+ * of the readiness signal, so it includes runtime boot, module evaluation and
199
+ * whatever the app does before declaring itself up — the whole cold path a
200
+ * deployment pays. `inspector` (when requested) is handed to `arm` while the
201
+ * process sits paused at entry, before any user code has evaluated.
202
+ */
203
+ export async function startupRun({
204
+ execPath = process.execPath,
205
+ entry,
206
+ args = [],
207
+ execArgv = [],
208
+ cwd,
209
+ ready,
210
+ env,
211
+ timeoutMs = 60_000,
212
+ inspect = false,
213
+ arm = null,
214
+ atReady = null,
215
+ }) {
216
+ const state = { output: '', exitCode: null, signal: null };
217
+ const argv = [...execArgv, ...(inspect ? ['--inspect-brk=0'] : []), entry, ...args];
218
+
219
+ const started = process.hrtime.bigint();
220
+ const child = spawn(execPath, argv, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
221
+ const sinceStart = () => Number(process.hrtime.bigint() - started) / 1e6;
222
+
223
+ let inspectorUrl = null;
224
+ const collect = (chunk) => {
225
+ state.output += chunk;
226
+ if (state.output.length > 1_000_000) state.output = state.output.slice(-500_000);
227
+ if (!inspectorUrl) {
228
+ const match = state.output.match(/Debugger listening on (ws:\/\/\S+)/);
229
+ if (match) inspectorUrl = match[1];
230
+ }
231
+ };
232
+ child.stdout.setEncoding('utf8');
233
+ child.stderr.setEncoding('utf8');
234
+ child.stdout.on('data', collect);
235
+ child.stderr.on('data', collect);
236
+ child.once('exit', (code, signal) => {
237
+ state.exitCode = code;
238
+ state.signal = signal;
239
+ });
240
+
241
+ let cdp = null;
242
+ try {
243
+ if (inspect) {
244
+ const deadline = Date.now() + 20_000;
245
+ while (!inspectorUrl) {
246
+ if (state.exitCode != null) {
247
+ throw new Error(
248
+ `the target exited (code ${state.exitCode}) before the inspector attached.\n${tail(state.output)}`,
249
+ );
250
+ }
251
+ if (Date.now() > deadline) throw new Error('no "Debugger listening on" line within 20s.');
252
+ await sleep(POLL_MS);
253
+ }
254
+ cdp = await connect(inspectorUrl);
255
+ await cdp.send('Runtime.enable');
256
+ if (arm) await arm(cdp);
257
+ // Everything above happened while the process was paused at entry, so the
258
+ // profiler sees module evaluation from its very first frame.
259
+ await cdp.send('Runtime.runIfWaitingForDebugger');
260
+ }
261
+
262
+ const deadline = Date.now() + timeoutMs;
263
+ let readyMs = null;
264
+ for (;;) {
265
+ if (await isReady(ready, state)) {
266
+ readyMs = sinceStart();
267
+ break;
268
+ }
269
+ // A non-zero exit can never become ready, whatever the probe is.
270
+ if (state.exitCode != null && state.exitCode !== 0) {
271
+ throw new Error(
272
+ `the target exited with code ${state.exitCode} before becoming ready (${describeReady(ready)}).\n${tail(state.output)}`,
273
+ );
274
+ }
275
+ if (state.exitCode != null && ready.kind !== 'exit') {
276
+ throw new Error(
277
+ `the target exited cleanly but never signalled ${describeReady(ready)}.\n` +
278
+ `If this entry is meant to finish rather than stay up, measure it with --ready exit.\n${tail(state.output)}`,
279
+ );
280
+ }
281
+ if (Date.now() > deadline) {
282
+ throw new Error(readyTimeoutMessage(ready, timeoutMs, state));
283
+ }
284
+ await sleep(POLL_MS);
285
+ }
286
+
287
+ const collected = atReady && cdp ? await atReady(cdp) : null;
288
+ return { elapsedMs: readyMs, output: state.output, exitCode: state.exitCode, collected };
289
+ } finally {
290
+ try {
291
+ cdp?.close();
292
+ } catch {
293
+ /* already closed */
294
+ }
295
+ await killTree(child);
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Every readiness timeout is a target-configuration mistake, so the message is the
301
+ * menu of correct specs rather than a bare "timed out" — an agent that reads only
302
+ * the last line still learns the fix.
303
+ */
304
+ function readyTimeoutMessage(ready, timeoutMs, state) {
305
+ const stillRunning = state.exitCode == null;
306
+ if (ready.kind === 'exit' && stillRunning) {
307
+ return (
308
+ `the target was still running after ${Math.round(timeoutMs / 1000)}s and --ready defaulted to "exit".\n` +
309
+ 'This looks like a long-lived server. Declare how it signals readiness:\n' +
310
+ `${READY_MENU}\n${tail(state.output)}`
311
+ );
312
+ }
313
+ return (
314
+ `the target never signalled ${describeReady(ready)} within ${Math.round(timeoutMs / 1000)}s.\n` +
315
+ `Check the spec matches what this build prints or listens on:\n${READY_MENU}\n${tail(state.output)}`
316
+ );
317
+ }
318
+
319
+ function tail(output, lines = 12) {
320
+ const text = output.trimEnd();
321
+ if (!text) return '(the target produced no output)';
322
+ return `--- last output ---\n${text.split('\n').slice(-lines).join('\n')}`;
323
+ }
324
+
325
+ /**
326
+ * The floor: what an empty script costs on this machine with this runtime. Startup
327
+ * work the bundle can never remove (binary load, V8 init, bootstrap) — without it,
328
+ * "340ms" reads as 340ms of addressable cost when 30ms of it is physics.
329
+ */
330
+ export async function measureBootFloor({ execPath = process.execPath, runs = 5, env } = {}) {
331
+ const probe = path.join(os.tmpdir(), `perf-kit-boot-${process.pid}.mjs`);
332
+ fs.writeFileSync(probe, 'process.exit(0)\n');
333
+ try {
334
+ const samples = [];
335
+ for (let i = 0; i < runs; i++) {
336
+ const run = await startupRun({
337
+ execPath,
338
+ entry: probe,
339
+ ready: { kind: 'exit' },
340
+ env: env ?? runEnv({ cacheMode: 'off' }),
341
+ timeoutMs: 30_000,
342
+ });
343
+ samples.push(run.elapsedMs);
344
+ }
345
+ return samples;
346
+ } finally {
347
+ fs.rmSync(probe, { force: true });
348
+ }
349
+ }
350
+
351
+ export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -0,0 +1,289 @@
1
+ // Startup measurement for server runtimes: timed spawn→ready runs, plus the two
2
+ // instrumented runs that say WHERE the time went (V8 precise coverage at the ready
3
+ // moment, and a CPU profile covering everything before it).
4
+ //
5
+ // Nothing here re-implements analysis that already exists. `coverageBySource` and
6
+ // `aggregateProfile` are the same pure V8-format transforms the browser side uses —
7
+ // Node emits the identical formats, so only the ACQUISITION differs.
8
+ //
9
+ // What deliberately does NOT carry over from the browser is the throttle model.
10
+ // `DEFAULT_THROTTLE` / net-scale calibration exist because page load is transfer
11
+ // bound; server startup does no network I/O and is disk+CPU bound. Carrying a
12
+ // throttle over (even pinned at x1) would invent a dimension the measurement does
13
+ // not have, so node mode has no throttle at all — and therefore no cross-scale
14
+ // comparability problem either.
15
+
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath, pathToFileURL } from 'node:url';
19
+
20
+ import { median, quantile } from '../measure.mjs';
21
+ import { coverageBySource } from '../coverage.mjs';
22
+ import { aggregateProfile } from '../profile.mjs';
23
+ import { measureBootFloor, startupRun } from './launch.mjs';
24
+
25
+ // A module this big that never executes before ready is worth a deferral look. Below
26
+ // it, the win is inside the noise of a single startup and the edit is not worth it.
27
+ export const COLD_MIN_BYTES = 4 * 1024;
28
+ // Startup is far quieter than a throttled page load (no network, no compositor), so
29
+ // the floor is tighter than the browser's 30ms — but never below timer/scheduler jitter.
30
+ export const NOISE_FLOOR_MS = 8;
31
+ export const NOISE_FLOOR_PCT = 2;
32
+
33
+ const round1 = (v) => (v == null ? null : Math.round(v * 10) / 10);
34
+
35
+ /**
36
+ * N timed runs, no inspector — the numbers that get reported. `runOptsFor(index)` is
37
+ * a factory rather than a fixed object so cold-cache mode can hand every run its own
38
+ * empty compile-cache dir; warmup runs get index -1..-n so they never share one.
39
+ */
40
+ export async function gatherStartupSamples(runOptsFor, { runs, warmup, onSample = null }) {
41
+ for (let i = 0; i < warmup; i++) {
42
+ process.stderr.write(`warmup ${i + 1}/${warmup}...\n`);
43
+ await startupRun(runOptsFor(-1 - i));
44
+ }
45
+ const samples = [];
46
+ for (let i = 0; i < runs; i++) {
47
+ const run = await startupRun(runOptsFor(i));
48
+ samples.push({ startupMs: run.elapsedMs, exitCode: run.exitCode });
49
+ onSample?.(i, run);
50
+ }
51
+ return samples;
52
+ }
53
+
54
+ /**
55
+ * Fold runs into the flat metric-id map. `bootFloorMs` is what an empty script costs
56
+ * on this machine: subtracting it separates the cost the bundle owns from the runtime
57
+ * boot it can never remove. Without that split a 340ms startup reads as 340ms of
58
+ * addressable work when 30ms of it is physics.
59
+ */
60
+ export function summarizeStartup(
61
+ samples,
62
+ { bootFloorMs = null, ready = null, resolutionMs = 0 } = {},
63
+ ) {
64
+ const values = samples.map((s) => s.startupMs).filter((v) => typeof v === 'number');
65
+ const startup = median(values);
66
+ const appMs =
67
+ typeof startup === 'number' && typeof bootFloorMs === 'number'
68
+ ? Math.max(0, startup - bootFloorMs)
69
+ : null;
70
+ return {
71
+ runs: samples.length,
72
+ metrics: {
73
+ 'runtime.startup_ms': round1(startup),
74
+ 'runtime.startup_p75_ms': round1(quantile(values, 0.75)),
75
+ 'runtime.boot_floor_ms': round1(bootFloorMs),
76
+ // The addressable part: total minus the runtime's own boot.
77
+ 'runtime.app_startup_ms': round1(appMs),
78
+ },
79
+ guard: {
80
+ allRunsCompleted: samples.length > 0 && samples.every((s) => typeof s.startupMs === 'number'),
81
+ readySignal: ready,
82
+ // A spread this wide means something other than the bundle moves run to run
83
+ // (disk cache, a port retry, a background daemon) and deltas are not trustworthy.
84
+ spreadPct:
85
+ values.length > 1 && startup > 0
86
+ ? Math.round(((Math.max(...values) - Math.min(...values)) / startup) * 1000) / 10
87
+ : 0,
88
+ // When the addressable time is only a few probe intervals wide, the digits after
89
+ // the decimal point are quantization, not signal. Say so rather than letting an
90
+ // agent chase a "+2ms regression" that is one poll tick.
91
+ resolutionLimited:
92
+ resolutionMs > 0 && typeof appMs === 'number' && appMs < 5 * resolutionMs
93
+ ? { appMs: round1(appMs), resolutionMs }
94
+ : null,
95
+ },
96
+ samples,
97
+ };
98
+ }
99
+
100
+ // --- attribution -----------------------------------------------------------------
101
+
102
+ /** file:// script URLs that live under `root` and carry a sourcemap next to them. */
103
+ function localScript(url, root) {
104
+ if (!url?.startsWith('file://')) return null;
105
+ let file;
106
+ try {
107
+ file = fileURLToPath(url);
108
+ } catch {
109
+ return null;
110
+ }
111
+ const rel = path.relative(root, file);
112
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return null;
113
+ const mapFile = `${file}.map`;
114
+ if (!fs.existsSync(file) || !fs.existsSync(mapFile)) return null;
115
+ return { file, rel: rel.replaceAll('\\', '/'), mapFile };
116
+ }
117
+
118
+ /**
119
+ * One instrumented run: V8 precise coverage armed while the process is paused at
120
+ * entry, snapshotted the moment it becomes ready. Every byte counted as executed
121
+ * therefore HAD to run to get the server up.
122
+ *
123
+ * The known V8 blind spot applies here exactly as it does in the browser: a module's
124
+ * top level counts as executed when it evaluates, so weight parked in top-level
125
+ * literals looks "used". Function bodies are where the signal is clean.
126
+ */
127
+ export async function startupCoverage(runOpts, { root }) {
128
+ const run = await startupRun({
129
+ ...runOpts,
130
+ inspect: true,
131
+ arm: async (cdp) => {
132
+ await cdp.send('Profiler.enable');
133
+ await cdp.send('Profiler.startPreciseCoverage', { callCount: false, detailed: true });
134
+ },
135
+ atReady: async (cdp) => cdp.send('Profiler.takePreciseCoverage'),
136
+ });
137
+ return attributeStartupCoverage(run.collected?.result ?? [], { root });
138
+ }
139
+
140
+ /**
141
+ * Attribute V8 script coverage to source modules through each script's sourcemap.
142
+ * Scripts outside `root` (node internals, native deps) are counted but not attributed —
143
+ * they are real startup cost the bundler cannot address, and hiding them would make
144
+ * the module list look like the whole story.
145
+ */
146
+ export function attributeStartupCoverage(scriptCoverage, { root, readFile = null }) {
147
+ const read = readFile ?? ((file) => fs.readFileSync(file, 'utf8'));
148
+ const modules = [];
149
+ const scripts = [];
150
+ let external = 0;
151
+ for (const script of scriptCoverage) {
152
+ const local = localScript(script.url, root);
153
+ if (!local) {
154
+ if (script.url && !script.url.startsWith('node:')) external += 1;
155
+ continue;
156
+ }
157
+ let code;
158
+ let map;
159
+ try {
160
+ code = read(local.file);
161
+ map = JSON.parse(read(local.mapFile));
162
+ } catch {
163
+ scripts.push({ file: local.rel, reason: 'unreadable-sourcemap' });
164
+ continue;
165
+ }
166
+ const rows = coverageBySource({
167
+ code,
168
+ map,
169
+ atPaint: script.functions ?? [],
170
+ atSettle: [],
171
+ });
172
+ let totalBytes = 0;
173
+ let evalBytes = 0;
174
+ for (const [source, row] of rows.entries()) {
175
+ modules.push({
176
+ source,
177
+ script: local.rel,
178
+ totalBytes: row.totalBytes,
179
+ // "paint" in the shared helper is just "the first snapshot"; here that
180
+ // snapshot is taken at the ready moment.
181
+ readyBytes: row.paintBytes,
182
+ readyRatio: row.totalBytes ? row.paintBytes / row.totalBytes : 0,
183
+ });
184
+ totalBytes += row.totalBytes;
185
+ evalBytes += row.paintBytes;
186
+ }
187
+ scripts.push({ file: local.rel, totalBytes, readyBytes: evalBytes });
188
+ }
189
+ modules.sort((a, b) => b.totalBytes - a.totalBytes);
190
+ scripts.sort((a, b) => (b.totalBytes ?? 0) - (a.totalBytes ?? 0));
191
+ return { modules, scripts, externalScriptCount: external };
192
+ }
193
+
194
+ /**
195
+ * The names a coverage module might go by in the build's module graph, most specific
196
+ * first. Coverage sources come from the sourcemap and are relative to the OUTPUT dir
197
+ * ('../src/reporting.js'); graph ids are project-relative ('src/reporting.js'). Walking
198
+ * the leading './' and '../' segments off is what lets the measured half and the static
199
+ * half talk about the same module — without it every cold module prices as "no match"
200
+ * and the scan quietly stops suggesting deferrals.
201
+ */
202
+ export function graphIdCandidates(source) {
203
+ const forms = [];
204
+ let candidate = String(source).replaceAll('\\', '/');
205
+ forms.push(candidate);
206
+ while (/^\.\.?\//.test(candidate)) {
207
+ candidate = candidate.replace(/^\.\.?\//, '');
208
+ forms.push(candidate);
209
+ }
210
+ return forms;
211
+ }
212
+
213
+ /** Shipped into the startup path but never executed to get there — deferral candidates. */
214
+ export function coldModules(modules, { minBytes = COLD_MIN_BYTES } = {}) {
215
+ return modules
216
+ .filter((m) => m.totalBytes >= minBytes && m.readyRatio <= 0.02)
217
+ .sort((a, b) => b.totalBytes - a.totalBytes);
218
+ }
219
+
220
+ /**
221
+ * One instrumented run sampling from entry until ready: what RAN, and for how long.
222
+ * This is the signal that can say "the bundle is not your problem" — if the self-time
223
+ * sits in native addon loading or a TLS/DB handshake rather than in module evaluation,
224
+ * no amount of deferral moves the number.
225
+ */
226
+ export async function startupProfile(runOpts, { intervalUs = 200 } = {}) {
227
+ const run = await startupRun({
228
+ ...runOpts,
229
+ inspect: true,
230
+ arm: async (cdp) => {
231
+ await cdp.send('Profiler.enable');
232
+ await cdp.send('Profiler.setSamplingInterval', { interval: intervalUs });
233
+ await cdp.send('Profiler.start');
234
+ },
235
+ atReady: async (cdp) => cdp.send('Profiler.stop'),
236
+ });
237
+ return run.collected?.profile ?? null;
238
+ }
239
+
240
+ /**
241
+ * Split profile self-time into what the bundle owns and what it never will.
242
+ * `appSources` is the entry sourcemap's source list — a bucket naming one of those
243
+ * is app code; `(engine*)` is V8 itself; anything else is runtime/native frames.
244
+ */
245
+ export function splitProfileOwnership(rows, appSources) {
246
+ const app = new Set(appSources.map((s) => String(s).replaceAll('\\', '/')));
247
+ let appMs = 0;
248
+ let engineMs = 0;
249
+ let runtimeMs = 0;
250
+ for (const row of rows) {
251
+ // A `source` row came through some chunk's sourcemap, so it is app code even when the
252
+ // entry chunk's own sourcemap does not list it (multi-chunk builds).
253
+ if (app.has(row.bucket) || row.kind === 'source') appMs += row.ms;
254
+ else if (row.bucket.startsWith('(engine')) engineMs += row.ms;
255
+ else runtimeMs += row.ms;
256
+ }
257
+ const total = appMs + engineMs + runtimeMs;
258
+ return {
259
+ appMs: round1(appMs),
260
+ engineMs: round1(engineMs),
261
+ runtimeMs: round1(runtimeMs),
262
+ // The three parts always sum to this. `aggregateProfile` rounds each row, so its
263
+ // `totalMs` can differ slightly — reporting that as the headline next to a split
264
+ // derived from the rows makes the numbers look like they fail to add up.
265
+ accountedMs: round1(total),
266
+ appShare: total > 0 ? Math.round((appMs / total) * 1000) / 10 : null,
267
+ };
268
+ }
269
+
270
+ /**
271
+ * `entryPath`: the profiled entry file; by default only it has a sourcemap here.
272
+ * `readChunk(file)` reads any other chunk with its sourcemap, and `baseUrl` resolves bare
273
+ * script names (Worker builds).
274
+ */
275
+ export function profileStartup(profile, { code, map, entryPath, readChunk = () => null, baseUrl }) {
276
+ // Node and Deno frames are file: URLs (plus node:/ext: internals, never local).
277
+ const entryFile = decodeURIComponent(pathToFileURL(path.resolve(entryPath)).pathname).replace(
278
+ /^\/+/,
279
+ '',
280
+ );
281
+ const { rows, totalMs } = aggregateProfile(profile, {
282
+ baseUrl,
283
+ isLocal: (url) => url.protocol === 'file:',
284
+ readChunk: (file) => (file === entryFile ? { code, map } : readChunk(file)),
285
+ });
286
+ return { rows, totalMs, ownership: splitProfileOwnership(rows, map.sources ?? []) };
287
+ }
288
+
289
+ export { measureBootFloor };