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,166 @@
1
+ // Minimal PNG codec for screenshot comparison. The lab has no dependencies, and the
2
+ // only PNG files it ever reads are its own CDP screenshots (8-bit, non-interlaced),
3
+ // so a full decoder is ~100 lines of zlib + scanline unfiltering. Everything decodes
4
+ // to RGBA so the comparison loop never branches on color type.
5
+
6
+ import zlib from 'node:zlib';
7
+
8
+ const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
9
+ const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
10
+
11
+ /** @returns {{ width: number, height: number, data: Uint8Array }} RGBA, 4 bytes per pixel */
12
+ export function decodePng(buf) {
13
+ if (buf.length < 8 || !buf.subarray(0, 8).equals(SIGNATURE)) throw new Error('not a PNG');
14
+ let offset = 8;
15
+ let header = null;
16
+ let palette = null;
17
+ let transparency = null;
18
+ const idat = [];
19
+ while (offset + 8 <= buf.length) {
20
+ const length = buf.readUInt32BE(offset);
21
+ const type = buf.toString('latin1', offset + 4, offset + 8);
22
+ const data = buf.subarray(offset + 8, offset + 8 + length);
23
+ offset += 12 + length;
24
+ if (type === 'IHDR') {
25
+ header = {
26
+ width: data.readUInt32BE(0),
27
+ height: data.readUInt32BE(4),
28
+ bitDepth: data[8],
29
+ colorType: data[9],
30
+ interlace: data[12],
31
+ };
32
+ } else if (type === 'PLTE') palette = data;
33
+ else if (type === 'tRNS') transparency = data;
34
+ else if (type === 'IDAT') idat.push(data);
35
+ else if (type === 'IEND') break;
36
+ }
37
+ if (!header) throw new Error('PNG has no IHDR chunk');
38
+ const { width, height, bitDepth, colorType, interlace } = header;
39
+ const channels = CHANNELS[colorType];
40
+ if (bitDepth !== 8 || interlace !== 0 || !channels) {
41
+ throw new Error(
42
+ `unsupported PNG (bit depth ${bitDepth}, color type ${colorType}, interlace ${interlace}) - only 8-bit non-interlaced screenshots are read`,
43
+ );
44
+ }
45
+ const raw = zlib.inflateSync(Buffer.concat(idat));
46
+ const stride = width * channels;
47
+ if (raw.length < (stride + 1) * height) throw new Error('PNG image data is truncated');
48
+ const out = new Uint8Array(width * height * 4);
49
+ let prev = new Uint8Array(stride);
50
+ let cur = new Uint8Array(stride);
51
+ for (let y = 0; y < height; y++) {
52
+ const rowStart = y * (stride + 1);
53
+ unfilter(raw[rowStart], raw.subarray(rowStart + 1, rowStart + 1 + stride), prev, cur, channels);
54
+ expandRow(cur, out, y * width * 4, width, colorType, palette, transparency);
55
+ [prev, cur] = [cur, prev];
56
+ }
57
+ return { width, height, data: out };
58
+ }
59
+
60
+ function paeth(a, b, c) {
61
+ const p = a + b - c;
62
+ const pa = Math.abs(p - a);
63
+ const pb = Math.abs(p - b);
64
+ const pc = Math.abs(p - c);
65
+ if (pa <= pb && pa <= pc) return a;
66
+ return pb <= pc ? b : c;
67
+ }
68
+
69
+ function unfilter(filter, line, prev, cur, bpp) {
70
+ const n = line.length;
71
+ switch (filter) {
72
+ case 0:
73
+ cur.set(line);
74
+ return;
75
+ case 1:
76
+ for (let i = 0; i < n; i++) cur[i] = line[i] + (i >= bpp ? cur[i - bpp] : 0);
77
+ return;
78
+ case 2:
79
+ for (let i = 0; i < n; i++) cur[i] = line[i] + prev[i];
80
+ return;
81
+ case 3:
82
+ for (let i = 0; i < n; i++)
83
+ cur[i] = line[i] + (((i >= bpp ? cur[i - bpp] : 0) + prev[i]) >> 1);
84
+ return;
85
+ case 4:
86
+ for (let i = 0; i < n; i++) {
87
+ cur[i] =
88
+ line[i] + paeth(i >= bpp ? cur[i - bpp] : 0, prev[i], i >= bpp ? prev[i - bpp] : 0);
89
+ }
90
+ return;
91
+ default:
92
+ throw new Error(`PNG scanline has unknown filter type ${filter}`);
93
+ }
94
+ }
95
+
96
+ function expandRow(cur, out, base, width, colorType, palette, transparency) {
97
+ if (colorType === 6) {
98
+ out.set(cur, base);
99
+ return;
100
+ }
101
+ for (let x = 0, o = base; x < width; x++, o += 4) {
102
+ if (colorType === 2) {
103
+ out[o] = cur[x * 3];
104
+ out[o + 1] = cur[x * 3 + 1];
105
+ out[o + 2] = cur[x * 3 + 2];
106
+ out[o + 3] = 255;
107
+ } else if (colorType === 3) {
108
+ const i = cur[x];
109
+ out[o] = palette?.[i * 3] ?? 0;
110
+ out[o + 1] = palette?.[i * 3 + 1] ?? 0;
111
+ out[o + 2] = palette?.[i * 3 + 2] ?? 0;
112
+ out[o + 3] = transparency?.[i] ?? 255;
113
+ } else {
114
+ const gray = colorType === 4 ? cur[x * 2] : cur[x];
115
+ out[o] = gray;
116
+ out[o + 1] = gray;
117
+ out[o + 2] = gray;
118
+ out[o + 3] = colorType === 4 ? cur[x * 2 + 1] : 255;
119
+ }
120
+ }
121
+ }
122
+
123
+ const CRC_TABLE = (() => {
124
+ const table = new Uint32Array(256);
125
+ for (let n = 0; n < 256; n++) {
126
+ let c = n;
127
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
128
+ table[n] = c >>> 0;
129
+ }
130
+ return table;
131
+ })();
132
+
133
+ function crc32(bytes) {
134
+ let c = 0xffffffff;
135
+ for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
136
+ return (c ^ 0xffffffff) >>> 0;
137
+ }
138
+
139
+ function chunk(type, data) {
140
+ const head = Buffer.alloc(8);
141
+ head.writeUInt32BE(data.length, 0);
142
+ head.write(type, 4, 'latin1');
143
+ const crc = Buffer.alloc(4);
144
+ crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0);
145
+ return Buffer.concat([head, data, crc]);
146
+ }
147
+
148
+ /** RGBA -> PNG (filter 0 on every row; these are diagnostic images, not assets). */
149
+ export function encodePng({ width, height, data }) {
150
+ const stride = width * 4;
151
+ const raw = Buffer.alloc((stride + 1) * height);
152
+ for (let y = 0; y < height; y++) {
153
+ raw.set(data.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
154
+ }
155
+ const ihdr = Buffer.alloc(13);
156
+ ihdr.writeUInt32BE(width, 0);
157
+ ihdr.writeUInt32BE(height, 4);
158
+ ihdr[8] = 8; // bit depth
159
+ ihdr[9] = 6; // RGBA
160
+ return Buffer.concat([
161
+ SIGNATURE,
162
+ chunk('IHDR', ihdr),
163
+ chunk('IDAT', zlib.deflateSync(raw, { level: 4 })),
164
+ chunk('IEND', Buffer.alloc(0)),
165
+ ]);
166
+ }
@@ -0,0 +1,145 @@
1
+ // Parity runner: for each pinned page, render the original build and the candidate
2
+ // back to back in ONE browser session, from ONE origin (the server's root swaps
3
+ // between captures), compare, and write the evidence images.
4
+ //
5
+ // Capture order per page: original (A), candidate (A), original (B). Interleaving
6
+ // keeps drift within the session (a slow third-party font early on) from landing on
7
+ // one side only; the second original, under a moved clock and another random seed,
8
+ // maps what the original itself does not render the same twice.
9
+
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+
13
+ import { startServer } from '../serve.mjs';
14
+ import { CONDITION_A, CONDITION_B, capturePage } from './capture.mjs';
15
+ import { MIN_CHANGED_PIXELS, comparePage, pageSignature, renderDiffImage } from './compare.mjs';
16
+ import { decodePng, encodePng } from './png.mjs';
17
+
18
+ export function pageSlug(pagePath) {
19
+ return pagePath.replace(/[^a-z0-9.]+/gi, '-').replace(/^-+|-+$/g, '') || 'root';
20
+ }
21
+
22
+ const decode = (capture) => ({
23
+ ...capture,
24
+ images: {
25
+ viewport: decodePng(capture.viewport),
26
+ full: capture.full ? decodePng(capture.full) : null,
27
+ },
28
+ });
29
+
30
+ function failedPage(pagePath, state, reason, lcp) {
31
+ return {
32
+ page: pagePath,
33
+ state,
34
+ reasons: state === 'open' ? [reason] : [],
35
+ notes: state === 'open' ? [] : [reason],
36
+ views: {},
37
+ text: { missing: [], added: [] },
38
+ a11y: { missing: [], added: [] },
39
+ runtime: { added: [] },
40
+ location: null,
41
+ lcp,
42
+ fonts: [],
43
+ images: { missing: [], added: [] },
44
+ faces: { missing: [], added: [] },
45
+ styles: null,
46
+ };
47
+ }
48
+
49
+ function writeEvidence(dir, { a1, c1, result }) {
50
+ fs.rmSync(dir, { recursive: true, force: true });
51
+ fs.mkdirSync(dir, { recursive: true });
52
+ const files = {};
53
+ const put = (name, bytes) => {
54
+ const file = path.join(dir, `${name}.png`);
55
+ fs.writeFileSync(file, bytes);
56
+ files[name] = file;
57
+ };
58
+ put('anchor', a1.viewport);
59
+ if (a1.full) put('anchor-full', a1.full);
60
+ put('current', c1.viewport);
61
+ if (c1.full) put('current-full', c1.full);
62
+ // A diff image for every view that changed, and for every view too unstable to
63
+ // judge - its blue areas are where masks belong.
64
+ for (const [name, view] of Object.entries(result.views)) {
65
+ if (!view.masks || (view.changedPixels < MIN_CHANGED_PIXELS && view.state !== 'unknown')) {
66
+ continue;
67
+ }
68
+ const img = name === 'viewport' ? c1.images.viewport : c1.images.full;
69
+ put(name === 'viewport' ? 'diff' : 'diff-full', encodePng(renderDiffImage(img, view)));
70
+ }
71
+ return files;
72
+ }
73
+
74
+ /** Compare every given page of the candidate build with the original. */
75
+ export async function runParity({
76
+ cdp,
77
+ anchorDist,
78
+ candidateDist,
79
+ pages,
80
+ masks,
81
+ outDir,
82
+ lcpByPage = {},
83
+ log = () => {},
84
+ }) {
85
+ let root = anchorDist;
86
+ const server = await startServer(() => root);
87
+ const capture = async (dist, pagePath, condition) => {
88
+ root = dist;
89
+ try {
90
+ return decode(await capturePage(cdp, { origin: server.origin, pagePath, condition, masks }));
91
+ } catch (err) {
92
+ return { error: err.message };
93
+ }
94
+ };
95
+ const results = [];
96
+ const fail = (pagePath, state, reason, lcp) => {
97
+ const result = failedPage(pagePath, state, reason, lcp);
98
+ result.signature = pageSignature(result, {});
99
+ results.push(result);
100
+ };
101
+ try {
102
+ for (const pagePath of pages) {
103
+ const lcp = lcpByPage[pagePath] ?? null;
104
+ log(`parity ${pagePath}: original build...`);
105
+ const a1 = await capture(anchorDist, pagePath, CONDITION_A);
106
+ log(`parity ${pagePath}: this build...`);
107
+ const c1 = await capture(candidateDist, pagePath, CONDITION_A);
108
+ log(`parity ${pagePath}: original build again (moved clock, new seed)...`);
109
+ const a2 = await capture(anchorDist, pagePath, CONDITION_B);
110
+ if (a1.error || a2.error) {
111
+ fail(
112
+ pagePath,
113
+ 'unknown',
114
+ `the original build did not render this page: ${a1.error ?? a2.error}`,
115
+ lcp,
116
+ );
117
+ continue;
118
+ }
119
+ if (c1.error) {
120
+ fail(pagePath, 'open', `this build did not render the page: ${c1.error}`, lcp);
121
+ continue;
122
+ }
123
+ let result = comparePage({ a1, a2, c1, lcp });
124
+ // A finding must reproduce: a second capture of this build has to disagree
125
+ // with the original in the same pixels / lines / errors before it is reported.
126
+ if (result.state === 'open') {
127
+ log(`parity ${pagePath}: confirming with a second capture of this build...`);
128
+ const c2 = await capture(candidateDist, pagePath, CONDITION_A);
129
+ if (!c2.error) result = comparePage({ a1, a2, c1, c2, lcp });
130
+ }
131
+ result.page = pagePath;
132
+ result.signature = pageSignature(result, c1.images);
133
+ result.files = writeEvidence(path.join(outDir, pageSlug(pagePath)), { a1, c1, result });
134
+ for (const view of Object.values(result.views)) {
135
+ view.changedCells = view.cells.length;
136
+ delete view.masks;
137
+ delete view.cells;
138
+ }
139
+ results.push(result);
140
+ }
141
+ } finally {
142
+ await server.close();
143
+ }
144
+ return results;
145
+ }
@@ -0,0 +1,281 @@
1
+ // Boot CPU profile: sample from navigation until FCP is observed, then
2
+ // attribute self-time through every sampled same-origin chunk's sourcemap.
3
+ // Answers the question coverage can't: "what RAN before paint, and for how long?"
4
+ // — which is exactly the evidence needed to judge whether eager boot work
5
+ // (telemetry, warm-up caches, data-module evaluation) belongs on the critical path.
6
+
7
+ import { openPage, sleep } from './cdp.mjs';
8
+ import { generatedSpans } from './coverage.mjs';
9
+ import { OBSERVER_JS } from './measure.mjs';
10
+
11
+ /**
12
+ * One profiled navigation. Arms the sampling profiler on the same-origin blank
13
+ * page, navigates, and stops when FCP is observed. The polling tail can include
14
+ * post-paint work; these samples are leads rather than exact paint costs.
15
+ */
16
+ export async function profileRun(cdp, { origin, throttle, timeoutMs = 60_000 }) {
17
+ const page = await openPage(cdp, { throttle, injectScript: OBSERVER_JS });
18
+ try {
19
+ await page.navigate(`${origin}/blank.html`);
20
+ await sleep(150);
21
+ await page.send('Profiler.enable');
22
+ await page.send('Profiler.setSamplingInterval', { interval: 200 }); // microseconds
23
+ await page.send('Profiler.start');
24
+ // '/' not '/index.html': router-strict SPAs 404 on the literal file path.
25
+ await page.navigate(`${origin}/`);
26
+
27
+ const deadline = Date.now() + timeoutMs;
28
+ for (;;) {
29
+ const fcp = await page.evaluate(
30
+ "(location.pathname !== '/blank.html' && window.__perfMetrics) ? window.__perfMetrics.fcp : null",
31
+ );
32
+ if (fcp != null) break;
33
+ if (Date.now() > deadline) throw new Error('profile run: FCP never fired');
34
+ await sleep(25);
35
+ }
36
+ const { profile } = await page.send('Profiler.stop');
37
+ return { ...profile, origin };
38
+ } finally {
39
+ await page.close().catch(() => {});
40
+ }
41
+ }
42
+
43
+ /** Locate generated (line, column) positions back to source modules. */
44
+ function sourceLocator(code, map) {
45
+ const lineStarts = [0];
46
+ for (let i = 0; i < code.length; i++) {
47
+ if (code.charCodeAt(i) === 10) lineStarts.push(i + 1);
48
+ }
49
+ const spans = generatedSpans(code, map); // sorted [{start, end, srcIdx}]
50
+ const sourceName = (idx) => String(map.sources[idx] ?? `#${idx}`).replaceAll('\\', '/');
51
+ const locate = (line, column) => {
52
+ if (!Number.isInteger(line) || !Number.isInteger(column) || line < 0 || column < 0) return null;
53
+ const lineStart = lineStarts[line];
54
+ if (lineStart === undefined) return null;
55
+ const offset = lineStart + column;
56
+ let lo = 0;
57
+ let hi = spans.length - 1;
58
+ while (lo <= hi) {
59
+ const mid = (lo + hi) >> 1;
60
+ const span = spans[mid];
61
+ if (offset < span.start) hi = mid - 1;
62
+ else if (offset >= span.end) lo = mid + 1;
63
+ else return span.srcIdx >= 0 ? sourceName(span.srcIdx) : '(unmapped)';
64
+ }
65
+ return '(unmapped)';
66
+ };
67
+ /** The one source that a generated line holds, or null when the line mixes sources. */
68
+ const lineSource = (line) => {
69
+ const start = lineStarts[line];
70
+ if (start === undefined) return null;
71
+ const end = lineStarts[line + 1] ?? code.length;
72
+ let lo = 0;
73
+ let hi = spans.length;
74
+ while (lo < hi) {
75
+ const mid = (lo + hi) >> 1;
76
+ if (spans[mid].end <= start) lo = mid + 1;
77
+ else hi = mid;
78
+ }
79
+ let found = null;
80
+ for (let i = lo; i < spans.length && spans[i].start < end; i++) {
81
+ const idx = spans[i].srcIdx;
82
+ if (idx < 0) continue;
83
+ if (found !== null && found !== idx) return null;
84
+ found = idx;
85
+ }
86
+ return found === null ? null : sourceName(found);
87
+ };
88
+ return { locate, lineSource };
89
+ }
90
+
91
+ /**
92
+ * Aggregate sampled self-time across all chunks, including dynamic imports.
93
+ * Missing maps and unmapped frames remain explicit; a filename is not evidence
94
+ * of engine work. readChunk(file) reads a local emitted file and its sourcemap.
95
+ * Profiling stops when FCP is observed, so the polling tail can include some
96
+ * post-paint work. Use these rows as leads, not exact paint-blocking costs.
97
+ * `isLocal(url)` says which script URLs are local chunks (default: same origin).
98
+ * `baseUrl` resolves relative script URLs (Miniflare reports bare module names).
99
+ */
100
+ export function aggregateProfile(
101
+ profile,
102
+ { origin = profile.origin, isLocal = (url) => url.origin === origin, readChunk, baseUrl },
103
+ ) {
104
+ const nodesById = new Map(profile.nodes.map((node) => [node.id, node]));
105
+ const parents = new Map();
106
+ for (const node of profile.nodes)
107
+ for (const child of node.children ?? []) parents.set(child, node);
108
+ const selfMicros = new Map();
109
+ const samples = profile.samples ?? [];
110
+ const deltas = profile.timeDeltas ?? [];
111
+ for (let i = 0; i < samples.length; i++) {
112
+ const delta = deltas[i] ?? 0;
113
+ if (!Number.isFinite(delta) || delta <= 0) continue;
114
+ selfMicros.set(samples[i], (selfMicros.get(samples[i]) ?? 0) + delta);
115
+ }
116
+
117
+ const locators = new Map();
118
+ const locateChunk = (file) => {
119
+ if (!locators.has(file)) {
120
+ let result;
121
+ try {
122
+ const chunk = readChunk(file);
123
+ if (!chunk) {
124
+ result = { reason: 'no-sourcemap' };
125
+ } else if (
126
+ typeof chunk.code !== 'string' ||
127
+ !Array.isArray(chunk.map?.sources) ||
128
+ typeof chunk.map?.mappings !== 'string'
129
+ ) {
130
+ result = { reason: 'invalid-sourcemap' };
131
+ } else {
132
+ result = sourceLocator(chunk.code, chunk.map);
133
+ }
134
+ } catch {
135
+ result = { reason: 'invalid-sourcemap' };
136
+ }
137
+ locators.set(file, result);
138
+ }
139
+ return locators.get(file);
140
+ };
141
+
142
+ const buckets = new Map();
143
+ const add = (kind, bucket, file, micros) => {
144
+ const key = JSON.stringify([kind, bucket]);
145
+ const row = buckets.get(key) ?? { bucket, kind, micros: 0, chunks: new Set() };
146
+ row.micros += micros;
147
+ if (file) row.chunks.add(file);
148
+ buckets.set(key, row);
149
+ };
150
+ for (const [nodeId, micros] of selfMicros) {
151
+ let node = nodesById.get(nodeId);
152
+ const fn = node?.callFrame?.functionName ?? '';
153
+ if (fn === '(idle)' || fn === '(program)') continue;
154
+ if (!node?.callFrame?.url && fn === '(garbage collector)') {
155
+ add('engine', '(engine: gc)', null, micros);
156
+ continue;
157
+ }
158
+ // A frame without a URL (a builtin or a host call: performance.now, JSON.parse,
159
+ // a DOM method) does the work of the script that called it.
160
+ while (node && !node.callFrame?.url) node = parents.get(node.id);
161
+ if (!node) {
162
+ add('engine', '(engine)', null, micros);
163
+ continue;
164
+ }
165
+ const frame = node.callFrame;
166
+ let url;
167
+ try {
168
+ url = new URL(frame.url, baseUrl);
169
+ } catch {
170
+ /* Unresolved eval/sourceURL frames stay unattributed. */
171
+ }
172
+ if (!url) {
173
+ add('unmapped', frame.url, null, micros);
174
+ continue;
175
+ }
176
+ if (!isLocal(url)) {
177
+ add(
178
+ 'external',
179
+ url.origin === 'null' ? frame.url : `${url.origin}${url.pathname}`,
180
+ null,
181
+ micros,
182
+ );
183
+ continue;
184
+ }
185
+ let file = null;
186
+ try {
187
+ const decoded = decodeURIComponent(url.pathname).replace(/^\/+/, '');
188
+ // Never resolve encoded traversal or separators.
189
+ if (
190
+ !decoded.includes('\\') &&
191
+ !decoded.split('/').some((part) => part === '..' || part === '.') &&
192
+ /\.[cm]?js$/.test(decoded)
193
+ )
194
+ file = decoded;
195
+ } catch {
196
+ /* Invalid URL escapes stay unattributed. */
197
+ }
198
+ if (!file) {
199
+ add('inline', url.pathname, null, micros);
200
+ continue;
201
+ }
202
+ const locator = locateChunk(file);
203
+ if (locator.reason) {
204
+ add(locator.reason, file, file, micros);
205
+ } else if (
206
+ frame.functionName === '' &&
207
+ ((frame.lineNumber === 0 && frame.columnNumber === 0) ||
208
+ (frame.lineNumber === -1 && frame.columnNumber === -1))
209
+ ) {
210
+ // Top-level code (0:0 in Chrome, -1:-1 in workerd). A bundled chunk runs the
211
+ // top-level code of many modules in one frame, so its start position names only
212
+ // the first module. Split the time by the sampled lines; a line that holds more
213
+ // than one source stays unattributed.
214
+ const ticks = (node.positionTicks ?? []).filter((t) => t.ticks > 0);
215
+ const total = ticks.reduce((sum, t) => sum + t.ticks, 0);
216
+ if (!total) add('top-level', file, file, micros);
217
+ for (const t of total ? ticks : []) {
218
+ const source = locator.lineSource(t.line - 1);
219
+ add(source ? 'source' : 'top-level', source ?? file, file, (micros * t.ticks) / total);
220
+ }
221
+ } else {
222
+ const source = locator.locate(frame.lineNumber, frame.columnNumber);
223
+ if (source && source !== '(unmapped)') add('source', source, file, micros);
224
+ else add('unmapped', file, file, micros);
225
+ }
226
+ }
227
+
228
+ const rows = [...buckets.values()]
229
+ .map(({ bucket, kind, micros, chunks }) => ({
230
+ bucket,
231
+ kind,
232
+ ms: Math.round(micros / 100) / 10,
233
+ chunks: [...chunks].sort((a, b) => a.localeCompare(b)),
234
+ }))
235
+ .sort((a, b) => b.ms - a.ms || a.bucket.localeCompare(b.bucket));
236
+ const sumMs = (predicate) =>
237
+ Math.round(
238
+ [...buckets.values()].filter(predicate).reduce((sum, row) => sum + row.micros, 0) / 100,
239
+ ) / 10;
240
+ return {
241
+ scope: 'all-chunks',
242
+ rows,
243
+ totalMs: sumMs(() => true),
244
+ attribution: {
245
+ sourceMs: sumMs((r) => r.kind === 'source'),
246
+ engineMs: sumMs((r) => r.kind === 'engine'),
247
+ unattributedMs: sumMs((r) => r.kind !== 'source' && r.kind !== 'engine'),
248
+ },
249
+ };
250
+ }
251
+
252
+ /** A sampled module is a source lead, never proof that its work is optional. */
253
+ export function profileEvidence(report) {
254
+ if (report?.scope !== 'all-chunks') {
255
+ return {
256
+ state: 'unknown',
257
+ reason: 'profile does not attribute all sampled chunks',
258
+ candidates: [],
259
+ unattributed: [],
260
+ };
261
+ }
262
+ if (!report.rows?.length) {
263
+ return {
264
+ state: 'unknown',
265
+ reason: 'profile has no sampled work',
266
+ candidates: [],
267
+ unattributed: [],
268
+ };
269
+ }
270
+ const candidates = report.rows.filter((row) => row.kind === 'source' && row.ms >= 15);
271
+ // A row under the candidate threshold cannot hide a source module that reaches it:
272
+ // unattributed rows are kept per chunk file, so a module in one has at most its time.
273
+ const unattributed = report.rows.filter(
274
+ (row) => row.kind !== 'source' && row.kind !== 'engine' && row.ms >= 15,
275
+ );
276
+ return {
277
+ state: candidates.length ? 'open' : unattributed.length ? 'unknown' : 'clear',
278
+ candidates,
279
+ unattributed,
280
+ };
281
+ }
@@ -0,0 +1,45 @@
1
+ // A self-signed certificate for the replay server. Chrome runs with
2
+ // --ignore-certificate-errors (a throwaway profile, the replay hosts only), so the
3
+ // names in it do not matter; TLS is needed because Chrome speaks HTTP/2 only
4
+ // over TLS, and the recorded site used HTTP/2.
5
+
6
+ import { execFileSync } from 'node:child_process';
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+
11
+ export function replayCertificate(dir = path.join(os.tmpdir(), 'perf-kit-replay', 'cert')) {
12
+ const keyFile = path.join(dir, 'key.pem');
13
+ const certFile = path.join(dir, 'cert.pem');
14
+ if (!fs.existsSync(keyFile) || !fs.existsSync(certFile)) {
15
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
16
+ try {
17
+ execFileSync(
18
+ 'openssl',
19
+ [
20
+ 'req',
21
+ '-x509',
22
+ '-newkey',
23
+ 'ec',
24
+ '-pkeyopt',
25
+ 'ec_paramgen_curve:prime256v1',
26
+ '-nodes',
27
+ '-days',
28
+ '365',
29
+ '-subj',
30
+ '/CN=perf-kit-replay',
31
+ '-addext',
32
+ 'subjectAltName=DNS:localhost,IP:127.0.0.1',
33
+ '-keyout',
34
+ keyFile,
35
+ '-out',
36
+ certFile,
37
+ ],
38
+ { stdio: ['ignore', 'ignore', 'pipe'] },
39
+ );
40
+ } catch (err) {
41
+ throw new Error(`replay needs openssl to make a local TLS certificate: ${err.message}`);
42
+ }
43
+ }
44
+ return { key: fs.readFileSync(keyFile), cert: fs.readFileSync(certFile) };
45
+ }