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,337 @@
1
+ // Cloudflare Worker startup driver: run a Vite-built Worker under the app's OWN workerd
2
+ // (through its miniflare and wrangler), with the Worker's startup moved inside a request
3
+ // so it can be timed, profiled and covered.
4
+ //
5
+ // workerd evaluates a Worker's main module when it loads the worker, before any request
6
+ // or inspector can observe it. So the worker the lab runs is a generated wrapper entry
7
+ // whose fetch handler does `await import('./<main>')`: the import compiles and evaluates
8
+ // the main module and its whole static closure, which is exactly the work Cloudflare's
9
+ // startup limit counts. `wrangler check startup` uses the same trick.
10
+ //
11
+ // Two instance shapes, deliberately separate:
12
+ // wrapper - startup happens inside the lab route. Durable Object and Workflow classes
13
+ // live in the main module, not in the wrapper, so their namespaces are
14
+ // dropped here and DO-backed routes cannot be served through it.
15
+ // plain - the app's real entry is the worker: startup happens at boot, every export
16
+ // is in place. The shape for first-request numbers and response parity.
17
+
18
+ import { createRequire } from 'node:module';
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import { performance } from 'node:perf_hooks';
22
+
23
+ export const WRAPPER_NAME = '__lab_wrapper.js';
24
+ export const EMPTY_NAME = '__lab_empty.js';
25
+ export const LAB_IMPORT_PATH = '/__lab/import';
26
+ export const LAB_COVERAGE_PATH = '/__lab/coverage';
27
+ export const COVERAGE_FLAGS = ['nodejs_compat', 'enable_nodejs_inspector_local_dev'];
28
+ /** First Wrangler whose workerd (1.20260706.1) carries the in-process inspector session. */
29
+ export const COVERAGE_MIN_WRANGLER = '4.108.0';
30
+
31
+ /**
32
+ * Resolve miniflare, wrangler and the workerd version from the app directory. The lab
33
+ * never ships a runtime of its own: the number must come from the workerd the app pins.
34
+ */
35
+ export function resolveWorkerTools(appDir) {
36
+ const require = createRequire(path.join(appDir, 'package.json'));
37
+ const resolveOrNull = (id) => {
38
+ try {
39
+ return require.resolve(id);
40
+ } catch {
41
+ return null;
42
+ }
43
+ };
44
+ const miniflarePath = resolveOrNull('miniflare');
45
+ const wranglerPath = resolveOrNull('wrangler');
46
+ if (!miniflarePath || !wranglerPath) {
47
+ throw new Error(
48
+ `${appDir} has no ${!miniflarePath ? 'miniflare' : 'wrangler'} - add wrangler as a dev dependency ` +
49
+ "(it brings miniflare and workerd). The lab runs the app's own workerd, never one of its own.",
50
+ );
51
+ }
52
+ const version = (id) => {
53
+ try {
54
+ return require(`${id}/package.json`).version;
55
+ } catch {
56
+ return null;
57
+ }
58
+ };
59
+ return {
60
+ appDir,
61
+ miniflarePath,
62
+ wranglerPath,
63
+ versions: {
64
+ wrangler: version('wrangler'),
65
+ miniflare: version('miniflare'),
66
+ workerd: version('workerd'),
67
+ },
68
+ miniflare: null,
69
+ wrangler: null,
70
+ async load() {
71
+ this.miniflare ??= await import(this.miniflarePath);
72
+ this.wrangler ??= await import(this.wranglerPath);
73
+ return this;
74
+ },
75
+ /** Miniflare 5 takes `workers: [...]`; v4 takes the flat shape wrangler's helper returns. */
76
+ toOptions(flat) {
77
+ const convert = this.miniflare?.convertV4MiniflareOptions;
78
+ return typeof convert === 'function' ? convert(flat) : flat;
79
+ },
80
+ };
81
+ }
82
+
83
+ /** `a.b.c` >= `x.y.z` */
84
+ export function versionAtLeast(version, minimum) {
85
+ if (!version) return false;
86
+ const a = String(version).split(/[.-]/).map(Number);
87
+ const b = String(minimum).split(/[.-]/).map(Number);
88
+ for (let i = 0; i < 3; i++) {
89
+ const x = a[i] || 0;
90
+ const y = b[i] || 0;
91
+ if (x !== y) return x > y;
92
+ }
93
+ return true;
94
+ }
95
+
96
+ /**
97
+ * The output `wrangler.json` the Cloudflare Vite plugin writes next to the worker's
98
+ * `index.js`, read through wrangler's own helper so bindings, flags and the compatibility
99
+ * date mean what `wrangler dev` makes them mean. When the helper cannot express a binding,
100
+ * startup is still measurable: module evaluation rarely touches `env`.
101
+ */
102
+ export function loadWorkerOptions(tools, configPath) {
103
+ const helper = tools.wrangler?.unstable_getMiniflareWorkerOptions;
104
+ if (typeof helper !== 'function') {
105
+ throw new Error(
106
+ 'this wrangler does not export unstable_getMiniflareWorkerOptions - update wrangler',
107
+ );
108
+ }
109
+ try {
110
+ const { workerOptions, main } = helper(configPath, undefined, {
111
+ overrides: { enableContainers: false },
112
+ });
113
+ return { workerOptions, main, fallback: null };
114
+ } catch (error) {
115
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
116
+ return {
117
+ workerOptions: {
118
+ compatibilityDate: config.compatibility_date,
119
+ compatibilityFlags: config.compatibility_flags ?? [],
120
+ modulesRules: (config.rules ?? []).map((r) => ({ type: r.type, include: r.globs })),
121
+ },
122
+ main: config.main,
123
+ fallback: String(error.message).split('\n')[0],
124
+ };
125
+ }
126
+ }
127
+
128
+ export const DEFAULT_RULES = [
129
+ { type: 'ESModule', include: ['**/*.js', '**/*.mjs'] },
130
+ { type: 'CompiledWasm', include: ['**/*.wasm'] },
131
+ { type: 'Text', include: ['**/*.txt', '**/*.html', '**/*.sql'] },
132
+ { type: 'Data', include: ['**/*.bin'] },
133
+ ];
134
+
135
+ /** A wrangler module-rule glob (`**` and `*`, `?`) to a RegExp over a `/`-joined path. */
136
+ export function globToRegExp(glob) {
137
+ let re = '';
138
+ for (let i = 0; i < glob.length; i++) {
139
+ const c = glob[i];
140
+ if (c === '*') {
141
+ if (glob[i + 1] === '*') {
142
+ i++;
143
+ if (glob[i + 1] === '/') {
144
+ i++;
145
+ re += '(?:.*/)?';
146
+ } else re += '.*';
147
+ } else re += '[^/]*';
148
+ } else if (c === '?') re += '[^/]';
149
+ else re += /[.+^${}()|[\]\\]/.test(c) ? `\\${c}` : c;
150
+ }
151
+ return new RegExp(`^${re}$`);
152
+ }
153
+
154
+ /**
155
+ * Every file under the output dir that a module rule classifies, as miniflare module
156
+ * descriptors. The app's own rules come first, the defaults close the gaps (`.wasm` is a
157
+ * CompiledWasm module even when the app lists only ESModule rules, as wrangler does).
158
+ */
159
+ export function classifyModules(outDir, rules = []) {
160
+ const all = [...rules, ...DEFAULT_RULES].map((rule) => ({
161
+ type: rule.type,
162
+ patterns: (rule.include ?? rule.globs ?? []).map(globToRegExp),
163
+ }));
164
+ const typeOf = (rel) => all.find((rule) => rule.patterns.some((p) => p.test(rel)))?.type ?? null;
165
+ const modules = [];
166
+ const skipped = [];
167
+ const walk = (dir, rel) => {
168
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
169
+ if (entry.name.startsWith('.')) continue;
170
+ const r = rel ? `${rel}/${entry.name}` : entry.name;
171
+ if (entry.isDirectory()) {
172
+ walk(path.join(dir, entry.name), r);
173
+ continue;
174
+ }
175
+ if (r.endsWith('.map') || r === 'wrangler.json' || r.startsWith('wrangler.')) continue;
176
+ const type = typeOf(r);
177
+ if (type) modules.push({ type, path: path.join(outDir, r), rel: r });
178
+ else skipped.push(r);
179
+ }
180
+ };
181
+ walk(outDir, '');
182
+ return { modules, skipped };
183
+ }
184
+
185
+ /** The wrapper entry. `coverage: true` is the variant that collects precise coverage. */
186
+ export function wrapperModule(outDir, target, { coverage = false } = {}) {
187
+ const spec = JSON.stringify(`./${target}`);
188
+ const contents = coverage
189
+ ? `import { Session } from 'node:inspector/promises';
190
+ export default {
191
+ async fetch() {
192
+ let s;
193
+ try { s = new Session(); s.connect(); } catch (e) { return Response.json({ unsupported: String((e && e.code) || e) }); }
194
+ await s.post('Profiler.enable');
195
+ await s.post('Profiler.startPreciseCoverage', { callCount: true, detailed: true });
196
+ let error = null;
197
+ try { await import(${spec}); } catch (e) { error = String((e && e.stack) || e); }
198
+ const { result } = await s.post('Profiler.takePreciseCoverage');
199
+ await s.post('Profiler.stopPreciseCoverage');
200
+ s.disconnect();
201
+ return Response.json({ error, result });
202
+ }
203
+ };
204
+ `
205
+ : `let mod = null;
206
+ export default {
207
+ async fetch(request, env, ctx) {
208
+ const url = new URL(request.url);
209
+ if (url.pathname === ${JSON.stringify(LAB_IMPORT_PATH)}) {
210
+ let error = null;
211
+ try { mod = await import(${spec}); } catch (e) { error = String((e && e.stack) || e); }
212
+ return new Response(error ? 'startup threw: ' + error : 'ok', { status: error ? 500 : 200 });
213
+ }
214
+ mod ??= await import(${spec});
215
+ const handler = mod.default && mod.default.fetch ? mod.default : null;
216
+ if (!handler) return new Response('no default export with fetch', { status: 501 });
217
+ return handler.fetch(request, env, ctx);
218
+ }
219
+ };
220
+ `;
221
+ return { type: 'ESModule', path: path.join(outDir, WRAPPER_NAME), contents };
222
+ }
223
+
224
+ export const emptyModule = (outDir) => ({
225
+ type: 'ESModule',
226
+ path: path.join(outDir, EMPTY_NAME),
227
+ contents: 'export {};\n',
228
+ });
229
+
230
+ /**
231
+ * Static assets answer before the worker unless a route is listed as worker-first; with
232
+ * SPA fallback every unknown path becomes `index.html`, lab routes included - and that
233
+ * answer is a 200, so the import silently never runs. Lab routes go worker-first.
234
+ */
235
+ export function labAssetsRouting(assets) {
236
+ if (!assets) return assets;
237
+ const router = { ...assets.routerConfig, has_user_worker: true };
238
+ const routing = { ...router.static_routing };
239
+ routing.user_worker = ['/__lab/*', ...(routing.user_worker ?? [])];
240
+ router.static_routing = routing;
241
+ return { ...assets, routerConfig: router };
242
+ }
243
+
244
+ /** Deduplicated workerd stderr, printed once at the end instead of once per run. */
245
+ export function createStderrCollector() {
246
+ const lines = new Map();
247
+ return {
248
+ lines,
249
+ handleRuntimeStdio(stdout, stderr) {
250
+ stdout.on('data', () => {});
251
+ stderr.on('data', (chunk) => {
252
+ for (const line of String(chunk).split('\n')) {
253
+ const key = line.replace(/\s+/g, ' ').trim().slice(0, 220);
254
+ if (key) lines.set(key, (lines.get(key) ?? 0) + 1);
255
+ }
256
+ });
257
+ },
258
+ };
259
+ }
260
+
261
+ /**
262
+ * One miniflare instance. `ctx` is the resolved worker context (tools, workerOptions,
263
+ * outDir, mainName, modules). Options: `target` (module the wrapper imports), `coverage`
264
+ * (coverage wrapper), `extraFlags` (compatibility flags added for this instance only),
265
+ * `shared` (top-level miniflare options such as `inspectorPort`), `plain` (real entry).
266
+ */
267
+ export async function createInstance(
268
+ ctx,
269
+ { target, coverage = false, extraFlags = [], shared = {}, plain = false, tools = null } = {},
270
+ ) {
271
+ const mod = tools ?? ctx.tools;
272
+ await mod.load();
273
+ const { Log, LogLevel } = mod.miniflare;
274
+ const appFlags = ctx.workerOptions.compatibilityFlags ?? [];
275
+ const flat = {
276
+ ...ctx.workerOptions,
277
+ name: 'lab',
278
+ compatibilityFlags: [...new Set([...appFlags, ...extraFlags])],
279
+ modulesRoot: ctx.outDir,
280
+ handleRuntimeStdio: ctx.stderr.handleRuntimeStdio,
281
+ log: new Log(LogLevel.WARN),
282
+ ...shared,
283
+ };
284
+ if (plain) {
285
+ const mainModule = ctx.modules.find((m) => m.rel === ctx.mainName);
286
+ if (!mainModule) throw new Error(`main module ${ctx.mainName} is not among the output modules`);
287
+ flat.modules = [mainModule, ...ctx.modules.filter((m) => m !== mainModule)];
288
+ } else {
289
+ flat.modules = [
290
+ wrapperModule(ctx.outDir, target ?? ctx.mainName, { coverage }),
291
+ emptyModule(ctx.outDir),
292
+ ...ctx.modules,
293
+ ];
294
+ delete flat.durableObjects;
295
+ delete flat.workflows;
296
+ if (flat.assets) flat.assets = labAssetsRouting(flat.assets);
297
+ }
298
+ const mf = new mod.miniflare.Miniflare(mod.toOptions(flat));
299
+ await mf.ready;
300
+ return mf;
301
+ }
302
+
303
+ export async function fetchText(mf, p, { timeoutMs = 120_000 } = {}) {
304
+ const res = await mf.dispatchFetch(`http://lab${p}`, { signal: AbortSignal.timeout(timeoutMs) });
305
+ return { status: res.status, text: await res.text() };
306
+ }
307
+
308
+ export async function disposeQuietly(mf) {
309
+ try {
310
+ await mf.dispose();
311
+ } catch {
312
+ /* already gone */
313
+ }
314
+ }
315
+
316
+ /**
317
+ * One cold run: a fresh instance, then the time of the lab import request as seen from
318
+ * the host. Timers inside workerd advance only on I/O, so the Worker cannot time itself.
319
+ * `bootMs` (instance creation to ready) is reported but never counted: it is workerd
320
+ * process start, config load and wasm compile, not the Worker's global scope.
321
+ */
322
+ export async function timedImport(ctx, { target } = {}) {
323
+ const b0 = performance.now();
324
+ const mf = await createInstance(ctx, { target });
325
+ const bootMs = performance.now() - b0;
326
+ try {
327
+ const t0 = performance.now();
328
+ const { status, text } = await fetchText(mf, LAB_IMPORT_PATH);
329
+ const ms = performance.now() - t0;
330
+ if (status !== 200 || text !== 'ok') {
331
+ throw new Error(`the lab import route did not run (${status}): ${text.slice(0, 600)}`);
332
+ }
333
+ return { ms, bootMs };
334
+ } finally {
335
+ await disposeQuietly(mf);
336
+ }
337
+ }
@@ -0,0 +1,296 @@
1
+ // Startup measurement for Cloudflare Workers: timed cold runs, first requests on the real
2
+ // entry, a CPU profile over the external inspector, and precise coverage from inside the
3
+ // isolate. Nothing here re-implements analysis the other modes have: the profile and
4
+ // coverage transforms are the shared ones, only the acquisition differs.
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { performance } from 'node:perf_hooks';
9
+ import { pathToFileURL } from 'node:url';
10
+
11
+ import { connect } from '../cdp.mjs';
12
+ import { median, quantile } from '../measure.mjs';
13
+ import { attributeStartupCoverage, profileStartup } from '../node/measure.mjs';
14
+ import {
15
+ COVERAGE_FLAGS,
16
+ COVERAGE_MIN_WRANGLER,
17
+ EMPTY_NAME,
18
+ LAB_COVERAGE_PATH,
19
+ LAB_IMPORT_PATH,
20
+ WRAPPER_NAME,
21
+ createInstance,
22
+ disposeQuietly,
23
+ fetchText,
24
+ timedImport,
25
+ versionAtLeast,
26
+ } from './launch.mjs';
27
+
28
+ // The measured window excludes process boot, so the jitter is the host round trip to
29
+ // workerd: 1-2 ms on the floor runs seen so far. The floor is set above that, never below.
30
+ export const NOISE_FLOOR_MS = 3;
31
+ export const NOISE_FLOOR_PCT = 2;
32
+ export const COLD_MIN_BYTES = 4 * 1024;
33
+
34
+ const round1 = (v) => (v == null ? null : Math.round(v * 10) / 10);
35
+
36
+ /** N cold runs of one target (the main module, or the empty module for the floor). */
37
+ export async function gatherSeries(ctx, { target, runs, warmup = 1, onSample = null }) {
38
+ for (let i = 0; i < warmup; i++) await timedImport(ctx, { target });
39
+ const samples = [];
40
+ for (let i = 0; i < runs; i++) {
41
+ const run = await timedImport(ctx, { target });
42
+ samples.push(run);
43
+ onSample?.(i, run);
44
+ }
45
+ return samples;
46
+ }
47
+
48
+ /**
49
+ * Fold the two series into the flat metric map. The floor is what the wrapper costs
50
+ * when it imports an empty module: the host round trip, the proxy hop and the dynamic
51
+ * import machinery. App startup is what is left, the part a bundle change can move.
52
+ */
53
+ export function summarizeWorkerStartup({ floor, main }) {
54
+ const floorMs = floor.map((s) => s.ms);
55
+ const mainMs = main.map((s) => s.ms);
56
+ const floorMedian = median(floorMs);
57
+ const mainMedian = median(mainMs);
58
+ const spread =
59
+ mainMs.length > 1 && mainMedian > 0
60
+ ? ((Math.max(...mainMs) - Math.min(...mainMs)) / mainMedian) * 100
61
+ : 0;
62
+ return {
63
+ runs: main.length,
64
+ metrics: {
65
+ 'worker.startup_ms': round1(mainMedian),
66
+ 'worker.startup_p75_ms': round1(quantile(mainMs, 0.75)),
67
+ 'worker.boot_floor_ms': round1(floorMedian),
68
+ 'worker.app_startup_ms': round1(Math.max(0, mainMedian - floorMedian)),
69
+ 'worker.instance_boot_ms': round1(median(main.map((s) => s.bootMs))),
70
+ },
71
+ guard: {
72
+ allRunsCompleted: main.length > 0 && main.every((s) => typeof s.ms === 'number'),
73
+ spreadPct: Math.round(spread * 10) / 10,
74
+ },
75
+ samples: { floor: floorMs.map(round1), main: mainMs.map(round1) },
76
+ };
77
+ }
78
+
79
+ /**
80
+ * First requests on a plain instance: the real entry, every export in place, startup
81
+ * already done at boot. The first hit of a route that imports a deferred module pays
82
+ * that load, which is the cost a deferral moved out of startup.
83
+ */
84
+ export async function firstRequests(ctx, paths) {
85
+ const mf = await createInstance(ctx, { plain: true });
86
+ const rows = [];
87
+ try {
88
+ for (const p of paths) {
89
+ const t0 = performance.now();
90
+ const { status, text } = await fetchText(mf, p);
91
+ rows.push({
92
+ path: p,
93
+ status,
94
+ bytes: text.length,
95
+ ms: round1(performance.now() - t0),
96
+ error: status >= 500 ? text.slice(0, 200).replace(/\s+/g, ' ') : null,
97
+ });
98
+ }
99
+ } finally {
100
+ await disposeQuietly(mf);
101
+ }
102
+ return rows;
103
+ }
104
+
105
+ /**
106
+ * CPU profile of the import, over the external inspector. workerd serves `Profiler.start`
107
+ * and `stop` itself with its own CpuProfiler, so this route works on every workerd.
108
+ */
109
+ export async function cpuProfileRun(ctx, { intervalUs = 100 } = {}) {
110
+ const mf = await createInstance(ctx, { shared: { inspectorPort: 0 } });
111
+ let cdp = null;
112
+ try {
113
+ const inspectorUrl = await mf.getInspectorURL();
114
+ cdp = await connect(new URL('/core:user:lab', inspectorUrl).href);
115
+ await cdp.send('Profiler.enable');
116
+ await cdp.send('Profiler.setSamplingInterval', { interval: intervalUs });
117
+ await cdp.send('Profiler.start');
118
+ const imported = await fetchText(mf, LAB_IMPORT_PATH);
119
+ const { profile } = await cdp.send('Profiler.stop');
120
+ return {
121
+ profile,
122
+ importRan: imported.status === 200 && imported.text === 'ok',
123
+ importText: imported.text,
124
+ };
125
+ } finally {
126
+ try {
127
+ cdp?.close();
128
+ } catch {
129
+ /* closed */
130
+ }
131
+ await disposeQuietly(mf);
132
+ }
133
+ }
134
+
135
+ /** The summary `wrangler check startup` prints, so the two tools agree on their terms. */
136
+ export function summarizeProfile(profile) {
137
+ const nodes = new Map(profile.nodes.map((n) => [n.id, n]));
138
+ let sampled = 0;
139
+ let idle = 0;
140
+ let gc = 0;
141
+ let program = 0;
142
+ (profile.timeDeltas ?? []).forEach((d, i) => {
143
+ sampled += d;
144
+ const fn = nodes.get(profile.samples[i])?.callFrame.functionName;
145
+ if (fn === '(idle)') idle += d;
146
+ else if (fn === '(garbage collector)') gc += d;
147
+ else if (fn === '(program)') program += d;
148
+ });
149
+ return {
150
+ windowMs: round1((profile.endTime - profile.startTime) / 1000),
151
+ sampledMs: round1(sampled / 1000),
152
+ activeMs: round1((sampled - idle) / 1000),
153
+ gcMs: round1(gc / 1000),
154
+ programMs: round1(program / 1000),
155
+ idleMs: round1(idle / 1000),
156
+ samples: profile.samples.length,
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Map a script URL onto the output dir. Miniflare reports `file://` paths on one version
162
+ * and bare module names on another, and a profile recorded elsewhere carries another
163
+ * directory: the longest path suffix that exists under the output dir wins.
164
+ */
165
+ export function localFile(url, outDir) {
166
+ if (!url || /^[a-z-]+:(?!\/\/)/.test(url)) return null;
167
+ const parts = (url.startsWith('file://') ? new URL(url).pathname : url)
168
+ .split('/')
169
+ .filter(Boolean);
170
+ for (let i = 0; i < parts.length; i++) {
171
+ const candidate = path.join(outDir, parts.slice(i).join('/'));
172
+ if (fs.existsSync(candidate)) return candidate;
173
+ }
174
+ return null;
175
+ }
176
+
177
+ /** `readChunk` for `profileStartup`: every chunk under the output dir that has a sourcemap. */
178
+ export function chunkResolver(outDir) {
179
+ return (frameFile) => {
180
+ const file = localFile(frameFile, outDir);
181
+ if (!file || !fs.existsSync(`${file}.map`)) return null;
182
+ return {
183
+ code: fs.readFileSync(file, 'utf8'),
184
+ map: JSON.parse(fs.readFileSync(`${file}.map`, 'utf8')),
185
+ };
186
+ };
187
+ }
188
+
189
+ /** Self time by source module through every chunk's sourcemap, plus the wrangler-style summary. */
190
+ export function attributeProfile(profile, { outDir, mainName }) {
191
+ const summary = summarizeProfile(profile);
192
+ const mapFile = path.join(outDir, `${mainName}.map`);
193
+ if (!fs.existsSync(mapFile))
194
+ return { summary, rows: null, totalMs: null, ownership: null, missingSourcemap: true };
195
+ const code = fs.readFileSync(path.join(outDir, mainName), 'utf8');
196
+ const map = JSON.parse(fs.readFileSync(mapFile, 'utf8'));
197
+ const { rows, totalMs, ownership } = profileStartup(profile, {
198
+ code,
199
+ map,
200
+ entryPath: path.join(outDir, mainName),
201
+ readChunk: chunkResolver(outDir),
202
+ baseUrl: pathToFileURL(`${outDir}/`),
203
+ });
204
+ return { summary, rows, totalMs, ownership, missingSourcemap: false };
205
+ }
206
+
207
+ /**
208
+ * Precise coverage of the import, taken from INSIDE the isolate. The external inspector
209
+ * cannot do it: workerd answers `Profiler.enable` itself and never forwards it to V8, so
210
+ * `Profiler.startPreciseCoverage` fails with "Profiler is not enabled". The in-process
211
+ * `node:inspector` session needs two compatibility flags, added for this run only, and a
212
+ * workerd from 1.20260706.1 on. `tools` may point at a newer wrangler than the app's; the
213
+ * timing and the profile keep the app's own workerd regardless.
214
+ */
215
+ export async function coverageRun(ctx, { tools = null } = {}) {
216
+ const used = tools ?? ctx.tools;
217
+ const appFlags = ctx.workerOptions.compatibilityFlags ?? [];
218
+ const addedFlags = COVERAGE_FLAGS.filter((f) => !appFlags.includes(f));
219
+ const base = {
220
+ source: null,
221
+ result: null,
222
+ addedFlags,
223
+ workerd: used.versions.workerd,
224
+ borrowed: Boolean(tools),
225
+ };
226
+ if (!versionAtLeast(used.versions.wrangler, COVERAGE_MIN_WRANGLER)) {
227
+ return {
228
+ ...base,
229
+ unsupported: `wrangler ${used.versions.wrangler ?? '?'} (workerd ${used.versions.workerd ?? '?'}) predates the in-process inspector session; needs wrangler ${COVERAGE_MIN_WRANGLER} or later. Update wrangler, or pass --tools <dir> with a newer one for this run.`,
230
+ };
231
+ }
232
+ const mf = await createInstance(ctx, { coverage: true, extraFlags: COVERAGE_FLAGS, tools: used });
233
+ try {
234
+ const { status, text } = await fetchText(mf, LAB_COVERAGE_PATH);
235
+ if (status !== 200)
236
+ return { ...base, unsupported: `coverage route answered ${status}: ${text.slice(0, 200)}` };
237
+ const body = JSON.parse(text);
238
+ if (body.unsupported)
239
+ return {
240
+ ...base,
241
+ unsupported: `node:inspector session unavailable (${body.unsupported}) on workerd ${used.versions.workerd}`,
242
+ };
243
+ return { ...base, result: body.result, importError: body.error ?? null, unsupported: null };
244
+ } finally {
245
+ await disposeQuietly(mf);
246
+ }
247
+ }
248
+
249
+ /** The package a sourcemap source belongs to, or `(app)`. */
250
+ export function packageOf(source) {
251
+ const m = String(source).match(/node_modules\/((?:@[^/]+\/)?[^/]+)/g);
252
+ return m ? m[m.length - 1].replace('node_modules/', '') : '(app)';
253
+ }
254
+
255
+ /**
256
+ * Coverage attribution plus the views a real app needs: the 2% cold filter finds almost
257
+ * nothing on real libraries, which run 5-30% of themselves at startup, so rank by bytes
258
+ * parsed but not executed, and group by package.
259
+ */
260
+ export function attributeCoverage(result, { outDir }) {
261
+ const runtimeScripts = result
262
+ .filter((s) =>
263
+ /^(node|node-internal|cloudflare|cloudflare-internal|workerd):/.test(s.url ?? ''),
264
+ )
265
+ .map((s) => s.url);
266
+ const local = [];
267
+ for (const script of result) {
268
+ const file = localFile(script.url, outDir);
269
+ if (!file || file.endsWith(WRAPPER_NAME) || file.endsWith(EMPTY_NAME)) continue;
270
+ local.push({ ...script, url: `file://${file}` });
271
+ }
272
+ const attributed = attributeStartupCoverage(local, { root: outDir });
273
+ const modules = attributed.modules.map((m) => ({ ...m, idleBytes: m.totalBytes - m.readyBytes }));
274
+ const totalBytes = modules.reduce((a, m) => a + m.totalBytes, 0);
275
+ const readyBytes = modules.reduce((a, m) => a + m.readyBytes, 0);
276
+ const byPackage = new Map();
277
+ for (const m of modules) {
278
+ const key = packageOf(m.source);
279
+ const row = byPackage.get(key) ?? { pkg: key, totalBytes: 0, readyBytes: 0, modules: 0 };
280
+ row.totalBytes += m.totalBytes;
281
+ row.readyBytes += m.readyBytes;
282
+ row.modules += 1;
283
+ byPackage.set(key, row);
284
+ }
285
+ return {
286
+ scripts: attributed.scripts,
287
+ runtimeScripts,
288
+ modules,
289
+ totals: { totalBytes, readyBytes, moduleCount: modules.length },
290
+ cold: modules
291
+ .filter((m) => m.totalBytes >= COLD_MIN_BYTES && m.readyRatio <= 0.02)
292
+ .sort((a, b) => b.totalBytes - a.totalBytes),
293
+ idle: [...modules].sort((a, b) => b.idleBytes - a.idleBytes),
294
+ packages: [...byPackage.values()].sort((a, b) => b.totalBytes - a.totalBytes),
295
+ };
296
+ }
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "rolldown-perf-kit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Experimental build of rolldown's perf-kit (feat/metrics-lab-field-replay @ 6e578fb57 (with uncommitted changes)). Performance harness for agent loops: throttled browser runs with first-paint coverage and boot profile, node/deno startup runs, static deferral planning on the module graph.",
5
+ "bin": {
6
+ "rolldown-perf-kit": "./harness.mjs",
7
+ "perf-kit": "./harness.mjs",
8
+ "perf-kit-replay": "./replay.mjs"
9
+ },
10
+ "files": [
11
+ "harness.mjs",
12
+ "replay.mjs",
13
+ "lib",
14
+ "AGENTS.md",
15
+ "README.md"
16
+ ],
17
+ "type": "module",
18
+ "engines": {
19
+ "node": ">=22"
20
+ },
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/rolldown/rolldown.git",
25
+ "directory": "packages/perf-kit"
26
+ },
27
+ "homepage": "https://github.com/rolldown/rolldown/tree/main/packages/perf-kit",
28
+ "keywords": [
29
+ "rolldown",
30
+ "vite",
31
+ "performance",
32
+ "startup",
33
+ "lcp",
34
+ "cloudflare-workers",
35
+ "agent"
36
+ ],
37
+ "publishConfig": {
38
+ "access": "public"
39
+ }
40
+ }