rolldown-perf-kit 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +345 -0
- package/LICENSE +25 -0
- package/README.md +621 -2
- package/harness.mjs +4860 -0
- package/lib/build.mjs +57 -0
- package/lib/cdp.mjs +217 -0
- package/lib/chunk-discovery.mjs +226 -0
- package/lib/coverage.mjs +498 -0
- package/lib/field/analysis.mjs +225 -0
- package/lib/field/browser.mjs +232 -0
- package/lib/field/build-match.mjs +86 -0
- package/lib/field/capture.mjs +501 -0
- package/lib/field/config.mjs +84 -0
- package/lib/field/run.mjs +612 -0
- package/lib/gen-app.mjs +399 -0
- package/lib/graph-diff.mjs +136 -0
- package/lib/interrupt.mjs +25 -0
- package/lib/measure.mjs +540 -0
- package/lib/min-cut.mjs +218 -0
- package/lib/module-graph.mjs +318 -0
- package/lib/network-evidence.mjs +84 -0
- package/lib/node/launch.mjs +351 -0
- package/lib/node/measure.mjs +289 -0
- package/lib/paint-diagnostics.mjs +76 -0
- package/lib/parity/anchor.mjs +186 -0
- package/lib/parity/capture.mjs +527 -0
- package/lib/parity/compare.mjs +813 -0
- package/lib/parity/png.mjs +166 -0
- package/lib/parity/run.mjs +145 -0
- package/lib/profile.mjs +281 -0
- package/lib/replay/cert.mjs +45 -0
- package/lib/replay/model.mjs +656 -0
- package/lib/replay/run.mjs +1140 -0
- package/lib/replay/server.mjs +499 -0
- package/lib/replay/shaper.mjs +158 -0
- package/lib/rolldown-support.mjs +161 -0
- package/lib/serve.mjs +142 -0
- package/lib/worker/attrib.mjs +125 -0
- package/lib/worker/config.mjs +116 -0
- package/lib/worker/launch.mjs +337 -0
- package/lib/worker/measure.mjs +296 -0
- package/package.json +38 -4
- package/replay.mjs +79 -0
|
@@ -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
|
+
}
|