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,656 @@
1
+ // Pure parts of record-and-replay: request keys, volatile query parameters, the
2
+ // archive built from recorded runs, the response index the replay server matches
3
+ // against, and the network model fitted from recorded downloads. No I/O here, so
4
+ // all of it is testable.
5
+ //
6
+ // Why a model and not per-URL timing: a changed build has other chunk names and
7
+ // sizes, so recorded asset timings do not apply to it. The replay serves the
8
+ // local dist through a fitted network (round trip, CDN time to first byte, ONE
9
+ // shared bandwidth) and serves recorded API responses after their recorded
10
+ // server time.
11
+
12
+ import crypto from 'node:crypto';
13
+
14
+ import { median, quantile } from '../measure.mjs';
15
+
16
+ export const sha256 = (data) => crypto.createHash('sha256').update(data).digest('hex');
17
+
18
+ /** The archive format version. `replay` refuses any other: record again. */
19
+ export const ARCHIVE_SCHEMA = 1;
20
+
21
+ const round = (v) => (typeof v === 'number' ? Math.round(v) : null);
22
+
23
+ // --- keys ------------------------------------------------------------------------
24
+
25
+ const routeKeyOf = (method, u) => `${method} ${u.host}${u.pathname}`;
26
+
27
+ /**
28
+ * method + host + path + sorted query, without the volatile parameters of that
29
+ * route. Two loads of the same page give the same key for the same request.
30
+ */
31
+ export function requestKey(method, url, volatile = {}) {
32
+ const u = new URL(url);
33
+ const drop = new Set(volatile[routeKeyOf(method, u)] ?? []);
34
+ const params = [...u.searchParams]
35
+ .filter(([name]) => !drop.has(name))
36
+ .sort(([a, av], [b, bv]) => (a === b ? (av < bv ? -1 : av > bv ? 1 : 0) : a < b ? -1 : 1));
37
+ const query = new URLSearchParams(params).toString();
38
+ return `${routeKeyOf(method, u)}${query ? `?${query}` : ''}`;
39
+ }
40
+
41
+ const isHttp = (url) => /^https?:\/\//.test(url ?? '');
42
+
43
+ /**
44
+ * Query parameters whose values change between loads of the same page (time
45
+ * stamps, cache busters, random IDs). A parameter is volatile for a route when
46
+ * the sorted list of its values differs between two runs. A parameter with
47
+ * several values in ONE run but the same list in every run (page=1, page=2) is
48
+ * stable. A route seen in one run only has no volatile parameters.
49
+ */
50
+ export function findVolatileParams(runs) {
51
+ const routes = new Map(); // routeKey -> Map(runIndex -> Map(param -> values[]))
52
+ runs.forEach((requests, runIndex) => {
53
+ for (const r of requests) {
54
+ if (!isHttp(r.url)) continue;
55
+ const u = new URL(r.url);
56
+ const route = routeKeyOf(r.method, u);
57
+ const byRun = routes.get(route) ?? new Map();
58
+ routes.set(route, byRun);
59
+ const params = byRun.get(runIndex) ?? new Map();
60
+ byRun.set(runIndex, params);
61
+ for (const [name, value] of u.searchParams) {
62
+ params.set(name, [...(params.get(name) ?? []), value]);
63
+ }
64
+ }
65
+ });
66
+ const volatile = {};
67
+ for (const [route, byRun] of routes) {
68
+ if (byRun.size < 2) continue;
69
+ const names = new Set([...byRun.values()].flatMap((p) => [...p.keys()]));
70
+ const changing = [...names].filter((name) => {
71
+ const lists = [...byRun.values()].map((p) =>
72
+ JSON.stringify([...(p.get(name) ?? [])].sort((a, b) => a.localeCompare(b))),
73
+ );
74
+ return new Set(lists).size > 1;
75
+ });
76
+ if (changing.length) volatile[route] = changing.sort((a, b) => a.localeCompare(b));
77
+ }
78
+ return volatile;
79
+ }
80
+
81
+ // Request bodies often carry a time stamp or a random ID; the normalized hash
82
+ // still matches them.
83
+ const VOLATILE_TEXT = [
84
+ [/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?/g, '<time>'],
85
+ [/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '<uuid>'],
86
+ [/(?<![\w.])\d{10,}(?:\.\d+)?(?![\w])/g, '<n>'],
87
+ ];
88
+
89
+ export function normalizeBody(text) {
90
+ let out = String(text);
91
+ for (const [re, replacement] of VOLATILE_TEXT) out = out.replace(re, replacement);
92
+ return out;
93
+ }
94
+
95
+ export const bodyHashes = (body) =>
96
+ body == null || body === ''
97
+ ? { bodyHash: null, bodyNormHash: null }
98
+ : { bodyHash: sha256(body), bodyNormHash: sha256(normalizeBody(body)) };
99
+
100
+ // --- response headers ------------------------------------------------------------
101
+ // Only headers that change how the browser treats a response are kept. Never
102
+ // cookies, never auth, never server or tracing identifiers.
103
+
104
+ const KEEP_HEADERS = new Set([
105
+ 'content-type',
106
+ 'content-encoding',
107
+ 'content-language',
108
+ 'vary',
109
+ 'location',
110
+ 'link',
111
+ 'access-control-allow-origin',
112
+ 'access-control-allow-credentials',
113
+ 'access-control-allow-headers',
114
+ 'access-control-allow-methods',
115
+ 'access-control-expose-headers',
116
+ 'access-control-max-age',
117
+ 'timing-allow-origin',
118
+ 'cross-origin-opener-policy',
119
+ 'cross-origin-embedder-policy',
120
+ 'cross-origin-resource-policy',
121
+ 'x-content-type-options',
122
+ ]);
123
+
124
+ export function safeHeaders(headers = {}) {
125
+ const out = {};
126
+ for (const [name, value] of Object.entries(headers)) {
127
+ const key = name.toLowerCase();
128
+ if (KEEP_HEADERS.has(key)) out[key] = String(value);
129
+ }
130
+ return out;
131
+ }
132
+
133
+ // --- network model ---------------------------------------------------------------
134
+
135
+ const STATIC_TYPES = new Set(['Script', 'Stylesheet', 'Font', 'Image', 'Media', 'Manifest']);
136
+
137
+ export const isStaticRequest = (r, origin) =>
138
+ r.method === 'GET' &&
139
+ STATIC_TYPES.has(r.type) &&
140
+ typeof r.url === 'string' &&
141
+ r.url.startsWith(`${origin}/`);
142
+
143
+ const done = (r) =>
144
+ !r.failed &&
145
+ typeof r.sendStartMs === 'number' &&
146
+ typeof r.headersEndMs === 'number' &&
147
+ typeof r.endMs === 'number';
148
+
149
+ /**
150
+ * Busiest window of received bytes in one run. Each response's bytes are spread
151
+ * evenly over its download (headers end -> end). This is a lower bound of the
152
+ * access link: it reaches the link rate only when downloads saturated it.
153
+ */
154
+ export function peakThroughputBps(requests, windowMs = 250) {
155
+ const bins = new Map();
156
+ for (const r of requests) {
157
+ if (!done(r) || !(r.encodedBytes > 0)) continue;
158
+ const start = r.headersEndMs;
159
+ const end = Math.max(r.endMs, start + 0.001);
160
+ const perMs = r.encodedBytes / (end - start);
161
+ for (let b = Math.floor(start / windowMs); b * windowMs < end; b++) {
162
+ const overlap = Math.min(end, (b + 1) * windowMs) - Math.max(start, b * windowMs);
163
+ if (overlap > 0) bins.set(b, (bins.get(b) ?? 0) + overlap * perMs);
164
+ }
165
+ }
166
+ const peak = Math.max(0, ...bins.values());
167
+ return (peak / windowMs) * 1000;
168
+ }
169
+
170
+ /** 21 points (p0, p5, ..., p100): a distribution small enough to keep, rich enough to sample. */
171
+ export function quantilePoints(values) {
172
+ if (!values.length) return null;
173
+ return Array.from({ length: 21 }, (_, i) => Math.round(quantile(values, i / 20)));
174
+ }
175
+
176
+ /** Inverse CDF: u in [0, 1) -> a value, linear between the points. */
177
+ export function sampleQuantiles(points, u) {
178
+ const pos = Math.min(Math.max(u, 0), 1) * (points.length - 1);
179
+ const lo = Math.floor(pos);
180
+ return lo >= points.length - 1
181
+ ? points[points.length - 1]
182
+ : points[lo] + (points[lo + 1] - points[lo]) * (pos - lo);
183
+ }
184
+
185
+ /** Seeded PRNG: a pair of runs (one per build) gets the same network luck. */
186
+ export function seededRandom(seed) {
187
+ let a = seed >>> 0;
188
+ return () => {
189
+ a = (a + 0x6d2b79f5) >>> 0;
190
+ let t = a;
191
+ t = Math.imul(t ^ (t >>> 15), t | 1);
192
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
193
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Bytes that OTHER responses received while `r` waited for its first byte.
199
+ * Each response's bytes are spread evenly over its download.
200
+ */
201
+ function otherBytesDuringWait(r, requests) {
202
+ let bytes = 0;
203
+ for (const o of requests) {
204
+ if (o === r || !done(o) || !(o.encodedBytes > 0)) continue;
205
+ const end = Math.max(o.endMs, o.headersEndMs + 0.001);
206
+ const overlap = Math.min(end, r.headersEndMs) - Math.max(o.headersEndMs, r.sendStartMs);
207
+ if (overlap > 0) bytes += (o.encodedBytes * overlap) / (end - o.headersEndMs);
208
+ }
209
+ return bytes;
210
+ }
211
+
212
+ const IDLE_LINK_BYTES = 50_000;
213
+
214
+ /**
215
+ * Network model from recorded runs (page-ms request rows, see recordFieldNetwork):
216
+ * staticTtfbMs / staticTtfbPoints - time to first byte of same-origin static
217
+ * files while the link was idle (other responses received under 50 kB
218
+ * during the wait): RTT + CDN time. The replay samples the distribution:
219
+ * a startup step waits for the SLOWEST of its parallel chunks, so a
220
+ * constant median makes every step too fast.
221
+ * wireBytes - how far behind other bytes a response's headers queue on a
222
+ * busy link (send buffer + bytes in flight): the median extra TTFB of the
223
+ * loaded requests x bandwidth. On a slow link most of a long TTFB is this
224
+ * queue, caused by the page's own downloads, so the replay must queue the
225
+ * headers (see shaper.mjs) and not draw the long TTFBs a second time.
226
+ * rttMs - 10th percentile of all static TTFBs: the closest thing to a bare round trip
227
+ * bandwidthBps - median over runs of the busiest 250 ms window. A lower bound
228
+ * of the link; the fastest single download over-reads (a body that arrives
229
+ * in one burst after its headers looks infinitely fast)
230
+ * The replay applies bandwidthBps to ALL streams together: a per-request delay
231
+ * would let parallel downloads all run at full speed.
232
+ */
233
+ export function fitNetworkModel(runs, origin) {
234
+ const ttfbs = [];
235
+ const waits = [];
236
+ const perRun = [];
237
+ for (const requests of runs) {
238
+ const statics = requests.filter((r) => isStaticRequest(r, origin) && done(r));
239
+ for (const r of statics) {
240
+ const ttfb = r.headersEndMs - r.sendStartMs;
241
+ ttfbs.push(ttfb);
242
+ waits.push({ ttfb, otherBytes: otherBytesDuringWait(r, requests) });
243
+ }
244
+ const single = Math.max(
245
+ 0,
246
+ ...requests
247
+ .filter((r) => done(r) && r.encodedBytes >= 100_000 && r.endMs > r.headersEndMs)
248
+ .map((r) => (r.encodedBytes / (r.endMs - r.headersEndMs)) * 1000),
249
+ );
250
+ perRun.push({ window: peakThroughputBps(requests), single });
251
+ }
252
+ const bandwidth = median(perRun.map((x) => x.window).filter((v) => v > 0));
253
+ const idle = waits.filter((w) => w.otherBytes < IDLE_LINK_BYTES).map((w) => w.ttfb);
254
+ const base = idle.length >= 20 ? idle : ttfbs;
255
+ const baseMedian = median(base);
256
+ const extras = waits
257
+ .filter((w) => w.otherBytes >= IDLE_LINK_BYTES)
258
+ .map((w) => w.ttfb - baseMedian);
259
+ const extraMs = median(extras);
260
+ const wireBytes =
261
+ bandwidth && extraMs > 0
262
+ ? Math.round(Math.min(8_000_000, Math.max(16_384, (extraMs * bandwidth) / 1000)))
263
+ : 0;
264
+ return {
265
+ staticTtfbMs: round(baseMedian),
266
+ staticTtfbPoints: quantilePoints(base),
267
+ wireBytes,
268
+ rttMs: round(quantile(ttfbs, 0.1)),
269
+ bandwidthBps: bandwidth == null ? null : Math.round(bandwidth),
270
+ estimates: {
271
+ staticSamples: ttfbs.length,
272
+ idleLinkSamples: idle.length,
273
+ staticTtfbAllMedianMs: round(median(ttfbs)),
274
+ staticTtfbP90Ms: round(quantile(ttfbs, 0.9)),
275
+ loadedExtraTtfbMs: round(extraMs),
276
+ peakWindowBps: round(median(perRun.map((x) => x.window))),
277
+ fastestSingleBps: round(median(perRun.map((x) => x.single))),
278
+ },
279
+ };
280
+ }
281
+
282
+ // --- archive ---------------------------------------------------------------------
283
+
284
+ /**
285
+ * Share of the HTML (decoded bytes) that arrived within `windowMs` of its
286
+ * headers. A host that flushes the head early lets the page start fetching
287
+ * before the rest of the HTML arrives.
288
+ */
289
+ export function earlyFraction(doc, windowMs = 50) {
290
+ if (!doc?.chunks?.length || typeof doc.headersEndMs !== 'number') return null;
291
+ const total = doc.chunks.reduce((s, c) => s + c.bytes, 0);
292
+ if (!total) return null;
293
+ return (
294
+ doc.chunks.filter((c) => c.ms <= doc.headersEndMs + windowMs).reduce((s, c) => s + c.bytes, 0) /
295
+ total
296
+ );
297
+ }
298
+
299
+ function mostCommon(values) {
300
+ const counts = new Map();
301
+ for (const v of values.filter((x) => x != null)) counts.set(v, (counts.get(v) ?? 0) + 1);
302
+ return [...counts].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
303
+ }
304
+
305
+ const ttfbOf = (r) => (done(r) ? r.headersEndMs - r.sendStartMs : null);
306
+ const downloadOf = (r) => (done(r) ? r.endMs - r.headersEndMs : null);
307
+ const replayable = (r) => isHttp(r.url) && !r.failed && !r.blocked && typeof r.status === 'number';
308
+
309
+ /**
310
+ * The page's own document: the row of the navigation entry, else the first
311
+ * document. Other documents (iframes) are recorded like any other request.
312
+ */
313
+ const mainDocument = (rows) =>
314
+ rows.find((r) => r.navigation) ?? rows.find((r) => r.type === 'Document');
315
+
316
+ /** Requests grouped by key, in call order. */
317
+ function callsByKey(requests, volatile) {
318
+ const map = new Map();
319
+ const main = mainDocument(requests);
320
+ for (const r of requests) {
321
+ if (!replayable(r) || r === main) continue;
322
+ const key = requestKey(r.method, r.url, volatile);
323
+ map.set(key, [...(map.get(key) ?? []), r]);
324
+ }
325
+ return map;
326
+ }
327
+
328
+ const medianOf = (values) => round(median(values.filter((v) => typeof v === 'number')));
329
+
330
+ /**
331
+ * The replay archive. `timing` = timing runs ({requests, lcp, criticalPath, path},
332
+ * requests with raw URLs). `bodyRun` = one more load whose rows also carry
333
+ * safelisted response headers, a body reference and request body hashes.
334
+ * Responses come from the body run; times are medians over the timing runs,
335
+ * matched by key and call order.
336
+ */
337
+ export function buildArchive({ config, variant, timing, bodyRun, recordedAtMs = Date.now() }) {
338
+ const volatile = findVolatileParams([...timing.map((t) => t.requests), bodyRun.rows]);
339
+ const timingCalls = timing.map((t) => callsByKey(t.requests, volatile));
340
+ const endpoints = {};
341
+ for (const [key, calls] of callsByKey(bodyRun.rows, volatile)) {
342
+ const [method] = key.split(' ', 1);
343
+ endpoints[key] = {
344
+ method,
345
+ calls: calls.map((r, i) => {
346
+ const samples = timingCalls.map((m) => m.get(key)?.[i]).filter(Boolean);
347
+ return {
348
+ url: r.url,
349
+ type: r.type,
350
+ status: r.status,
351
+ headers: r.headers ?? {},
352
+ bodyRef: r.bodyRef ?? null,
353
+ bodyBytes: r.bodyBytes ?? 0,
354
+ encodedBytes: r.encodedBytes ?? null,
355
+ reqBodyHash: r.reqBodyHash ?? null,
356
+ reqBodyNormHash: r.reqBodyNormHash ?? null,
357
+ ttfbMs: medianOf(samples.length ? samples.map(ttfbOf) : [ttfbOf(r)]),
358
+ downloadMs: medianOf(samples.length ? samples.map(downloadOf) : [downloadOf(r)]),
359
+ samples: samples.length,
360
+ };
361
+ }),
362
+ };
363
+ }
364
+ const mainRow = mainDocument(bodyRun.rows);
365
+ const docRow = mainRow && replayable(mainRow) ? mainRow : null;
366
+ const docTimes = timing.map((t) => mainDocument(t.requests)).filter((r) => r && !r.failed);
367
+ const assetSizes = new Map();
368
+ for (const t of timing) {
369
+ for (const r of t.requests) {
370
+ if (!isStaticRequest(r, config.origin) || !done(r)) continue;
371
+ const p = new URL(r.url).pathname;
372
+ assetSizes.set(p, [...(assetSizes.get(p) ?? []), r.encodedBytes]);
373
+ }
374
+ }
375
+ const encodingOf = new Map(
376
+ bodyRun.rows
377
+ .filter((r) => isStaticRequest(r, config.origin))
378
+ .map((r) => [new URL(r.url).pathname, r.headers?.['content-encoding'] ?? null]),
379
+ );
380
+ const hosts = new Map();
381
+ for (const r of bodyRun.rows) {
382
+ if (!isHttp(r.url)) continue;
383
+ const h = hosts.get(new URL(r.url).host) ?? {
384
+ host: new URL(r.url).host,
385
+ requests: 0,
386
+ blocked: 0,
387
+ };
388
+ h.requests++;
389
+ if (r.blocked) h.blocked++;
390
+ hosts.set(h.host, h);
391
+ }
392
+ const breakdowns = timing.map((t) => t.criticalPath?.breakdown).filter(Boolean);
393
+ const pick = (k) => medianOf(breakdowns.map((b) => b[k]));
394
+ return {
395
+ schemaVersion: ARCHIVE_SCHEMA,
396
+ kind: 'perf-kit-replay',
397
+ recordedAtMs,
398
+ url: variant.url,
399
+ origin: config.origin,
400
+ variant: variant.name,
401
+ build: variant.build ?? null,
402
+ gate: config.gate,
403
+ block: config.block,
404
+ ignoreLcp: config.ignoreLcp,
405
+ redact: config.redact,
406
+ volatile,
407
+ document: docRow
408
+ ? {
409
+ url: docRow.url,
410
+ status: docRow.status,
411
+ headers: docRow.headers ?? {},
412
+ bodyRef: docRow.bodyRef ?? null,
413
+ ttfbMs: medianOf(docTimes.map(ttfbOf)),
414
+ downloadMs: medianOf(docTimes.map(downloadOf)),
415
+ earlyFraction: (() => {
416
+ const f = median(
417
+ docTimes.map((d) => earlyFraction(d)).filter((v) => typeof v === 'number'),
418
+ );
419
+ return f == null ? null : Math.round(f * 1000) / 1000;
420
+ })(),
421
+ }
422
+ : null,
423
+ endpoints,
424
+ assets: [...assetSizes].map(([p, sizes]) => ({
425
+ path: p,
426
+ encodedBytes: round(median(sizes)),
427
+ encoding: encodingOf.get(p) ?? null,
428
+ })),
429
+ network: {
430
+ ...fitNetworkModel(
431
+ timing.map((t) => t.requests),
432
+ config.origin,
433
+ ),
434
+ // Chrome's own rating of the network. At 3g and slower it holds back
435
+ // low-priority requests even on HTTP/2; the replay forces the same rating.
436
+ effectiveType: mostCommon(timing.map((t) => t.connection?.effectiveType)),
437
+ connectionRttMs: medianOf(timing.map((t) => t.connection?.rttMs)),
438
+ },
439
+ field: {
440
+ runs: timing.length,
441
+ paths: [...new Set(timing.map((t) => t.path))],
442
+ lcpMs: medianOf(timing.map((t) => t.lcp)),
443
+ documentEndMs: pick('documentEndMs'),
444
+ beforeGateMs: pick('beforeGateMs'),
445
+ gateAfterDocumentMs: pick('gateAfterDocumentMs'),
446
+ ttfbMs: pick('ttfbMs'),
447
+ downloadMs: pick('downloadMs'),
448
+ afterGateMs: pick('afterGateMs'),
449
+ },
450
+ hosts: [...hosts.values()].sort((a, b) => b.requests - a.requests),
451
+ page: bodyRun.page ?? null,
452
+ };
453
+ }
454
+
455
+ // --- matching --------------------------------------------------------------------
456
+
457
+ /**
458
+ * Recorded responses by key, served in call order within one page load
459
+ * (`reset()` starts a new one). Levels, best first:
460
+ * exact same URL as the recorded call
461
+ * volatile only volatile parameters differ
462
+ * body a request body: same bytes as a recorded call
463
+ * body-normalized same bytes after time stamps and IDs are replaced
464
+ * order a request body that matches no recorded body: next unused call
465
+ * repeat more calls than recorded: the last (or the same-body) response again
466
+ * miss no recorded request with this key - the server answers 404
467
+ */
468
+ export function createResponseIndex(endpoints, volatile = {}) {
469
+ let used = new Map();
470
+ return {
471
+ reset() {
472
+ used = new Map();
473
+ },
474
+ match({ method, url, body = null }) {
475
+ const key = requestKey(method, url, volatile);
476
+ const calls = endpoints[key]?.calls;
477
+ if (!calls?.length) return { key, call: null, level: 'miss' };
478
+ const taken = used.get(key) ?? new Set();
479
+ used.set(key, taken);
480
+ const free = calls.map((_, i) => i).filter((i) => !taken.has(i));
481
+ let pick = null;
482
+ let level = null;
483
+ const { bodyHash, bodyNormHash } = bodyHashes(body);
484
+ if (bodyHash) {
485
+ const tries = [
486
+ [free.find((i) => calls[i].reqBodyHash === bodyHash), 'body'],
487
+ [free.find((i) => calls[i].reqBodyNormHash === bodyNormHash), 'body-normalized'],
488
+ [calls.findLastIndex((c) => c.reqBodyHash === bodyHash), 'repeat'],
489
+ [free[0], 'order'],
490
+ ];
491
+ [pick, level] = tries.find(([i]) => i != null && i !== -1) ?? [null, null];
492
+ } else if (free.length) {
493
+ pick = free[0];
494
+ level = calls[pick].url === url ? 'exact' : 'volatile';
495
+ }
496
+ if (pick == null) {
497
+ pick = calls.length - 1;
498
+ level = 'repeat';
499
+ }
500
+ taken.add(pick);
501
+ return { key, call: calls[pick], level };
502
+ },
503
+ };
504
+ }
505
+
506
+ // --- document --------------------------------------------------------------------
507
+ // A host often changes the HTML it serves: a CSP nonce on every script, an
508
+ // analytics snippet, a service worker or bot-check loader. Those tags load and
509
+ // run during startup, so a replay of the bare dist index.html is too fast.
510
+
511
+ const ELEMENT_RE = /<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>|<(?:link|meta|base)\b[^>]*>/gi;
512
+ const stripNonce = (html) => html.replace(/\snonce\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
513
+
514
+ function attrOf(tag, name) {
515
+ const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i').exec(tag);
516
+ return m ? (m[1] ?? m[2] ?? m[3]) : null;
517
+ }
518
+
519
+ // Content hashes in file names change with every build: /assets/app.Ab12Cd34.js -> /assets/app.<hash>.js
520
+ const refShape = (ref) => ref.replace(/[.-][A-Za-z0-9_-]{8,}(?=\.[a-z0-9]+$)/, '.<hash>');
521
+
522
+ function documentElements(html) {
523
+ return [...html.matchAll(ELEMENT_RE)].map((m) => {
524
+ const open = m[0].slice(0, m[0].indexOf('>') + 1);
525
+ const id = attrOf(open, 'id');
526
+ const ref = attrOf(open, 'src') ?? attrOf(open, 'href');
527
+ const tag = /^<([a-z]+)/i.exec(open)[1].toLowerCase();
528
+ return {
529
+ text: m[0],
530
+ end: m.index + m[0].length,
531
+ tag,
532
+ ref,
533
+ identity: id ? `id:${id}` : ref ? `ref:${refShape(ref)}` : `inline:${sha256(m[0])}`,
534
+ };
535
+ });
536
+ }
537
+
538
+ /**
539
+ * The local index.html plus every tag that only the recorded HTML has, each put
540
+ * after the tag it followed in the recorded HTML. Never added: tags that point
541
+ * into a folder the local index.html loads its own files from (/assets/...):
542
+ * those are files of the recorded build. A tag with the same id in both files
543
+ * keeps the local version (build metadata, the entry script).
544
+ */
545
+ export function mergeDocument(recordedHtml, localHtml) {
546
+ const recorded = documentElements(stripNonce(recordedHtml));
547
+ const local = documentElements(localHtml);
548
+ const localIds = new Set(local.map((e) => e.identity));
549
+ const dirOf = (ref) => ref.slice(0, ref.lastIndexOf('/') + 1);
550
+ const assetDirs = new Set(local.filter((e) => e.ref?.startsWith('/')).map((e) => dirOf(e.ref)));
551
+ const after = new Map(); // local identity (or null = top of <head>) -> tags to add after it
552
+ const added = [];
553
+ let anchor = null;
554
+ for (const e of recorded) {
555
+ if (localIds.has(e.identity)) {
556
+ anchor = e.identity;
557
+ continue;
558
+ }
559
+ if (e.ref?.startsWith('/') && assetDirs.has(dirOf(e.ref))) continue;
560
+ after.set(anchor, [...(after.get(anchor) ?? []), e.text]);
561
+ added.push({
562
+ tag: e.tag,
563
+ ref: e.ref ? refShape(e.ref) : null,
564
+ inlineChars: e.ref ? 0 : e.text.length,
565
+ });
566
+ }
567
+ if (!added.length) return { html: localHtml, added };
568
+ let html = '';
569
+ let pos = 0;
570
+ const head = /<head\b[^>]*>/i.exec(localHtml);
571
+ const top = head ? head.index + head[0].length : 0;
572
+ const cuts = [{ at: top, texts: after.get(null) ?? [] }];
573
+ const placed = new Set([null]);
574
+ for (const e of local) {
575
+ if (placed.has(e.identity) || !after.has(e.identity)) continue;
576
+ placed.add(e.identity);
577
+ cuts.push({ at: e.end, texts: after.get(e.identity) });
578
+ }
579
+ for (const cut of cuts.sort((a, b) => a.at - b.at)) {
580
+ html += localHtml.slice(pos, cut.at) + cut.texts.join('');
581
+ pos = cut.at;
582
+ }
583
+ return { html: html + localHtml.slice(pos), added };
584
+ }
585
+
586
+ // --- calibration -----------------------------------------------------------------
587
+
588
+ /**
589
+ * Replay of the recorded build against the field medians. A metric passes when
590
+ * the replay median is within max(relative x field, floorMs) of the field median.
591
+ */
592
+ export function calibrate(field, replay, { relative = 0.15, floorMs = 150 } = {}) {
593
+ const rows = [
594
+ ['lcpMs', 'LCP'],
595
+ ['documentEndMs', 'document end'],
596
+ ['beforeGateMs', 'gate start'],
597
+ ['gateAfterDocumentMs', 'gate - doc'],
598
+ ['ttfbMs', 'gate ttfb'],
599
+ ['afterGateMs', 'after gate'],
600
+ ].map(([key, label]) => {
601
+ const f = field[key];
602
+ const r = replay[key];
603
+ if (typeof f !== 'number') return { key, label, field: null, replay: r ?? null, pass: null };
604
+ // The field has the value and the replay does not (no gate, no LCP): a model error.
605
+ if (typeof r !== 'number') return { key, label, field: f, replay: null, pass: false };
606
+ const toleranceMs = Math.round(Math.max(relative * Math.abs(f), floorMs));
607
+ return {
608
+ key,
609
+ label,
610
+ field: f,
611
+ replay: r,
612
+ deltaMs: Math.round(r - f),
613
+ toleranceMs,
614
+ pass: Math.abs(r - f) <= toleranceMs,
615
+ };
616
+ });
617
+ const judged = rows.filter((r) => r.pass !== null);
618
+ return { rows, pass: judged.length > 0 && judged.every((r) => r.pass) };
619
+ }
620
+
621
+ /**
622
+ * Requests that at least half of the timing runs made but that the archive has no
623
+ * response for: the replay answers them with a 404. Files the local build serves
624
+ * (`inBuild`) are not gaps. A request with a new URL on every load (a bot check)
625
+ * is in one run only, so it is not a gap either.
626
+ */
627
+ export function archiveGaps(archive, runs, { inBuild = () => false } = {}) {
628
+ const counts = new Map();
629
+ for (const run of runs) {
630
+ const keys = new Set();
631
+ const main = mainDocument(run.requests ?? []);
632
+ for (const r of run.requests ?? []) {
633
+ if (!replayable(r) || r === main || inBuild(r)) continue;
634
+ keys.add(requestKey(r.method, r.url, archive.volatile));
635
+ }
636
+ for (const k of keys) counts.set(k, (counts.get(k) ?? 0) + 1);
637
+ }
638
+ const need = Math.ceil(runs.length / 2);
639
+ return [...counts]
640
+ .filter(([key, n]) => n >= need && !archive.endpoints[key])
641
+ .map(([key, n]) => ({ key, runs: n, of: runs.length }))
642
+ .sort((a, b) => b.runs - a.runs || a.key.localeCompare(b.key));
643
+ }
644
+
645
+ /**
646
+ * What a calibration asks for next:
647
+ * record-again - the archive misses responses that most field runs got; a new
648
+ * recording (with a longer --settle) can fix it
649
+ * model-error - the archive is complete but a row is off; a new recording
650
+ * does not fix it, the replay model must change
651
+ * pass - every row is within tolerance and the archive is complete
652
+ */
653
+ export function calibrationVerdict(calibration, gaps = []) {
654
+ if (gaps.length) return 'record-again';
655
+ return calibration.pass ? 'pass' : 'model-error';
656
+ }