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,499 @@
|
|
|
1
|
+
// The replay server: one HTTPS + HTTP/2 server for EVERY host the page uses.
|
|
2
|
+
// Chrome reaches it through --host-resolver-rules, so the page runs at its real
|
|
3
|
+
// origin (cookies, CORS, absolute URLs all unchanged) and no request leaves the
|
|
4
|
+
// machine. What it serves, in this order:
|
|
5
|
+
// document a navigation to the recorded origin: the local dist's index.html
|
|
6
|
+
// plus the tags only the recorded HTML has (mergeDocument), or the
|
|
7
|
+
// bare local / recorded HTML, after the recorded document TTFB
|
|
8
|
+
// preflight a CORS preflight: the recorded one, or an allow-all answer after one RTT
|
|
9
|
+
// dist a file of the local dist on the recorded origin, after an idle-link
|
|
10
|
+
// time to first byte drawn from the recorded ones, then behind the
|
|
11
|
+
// bytes already on the wire; compressed like the recorded assets
|
|
12
|
+
// recorded a recorded response (see createResponseIndex) after its recorded
|
|
13
|
+
// time to first byte
|
|
14
|
+
// miss anything else: 404 after one RTT, and a line in the run's log
|
|
15
|
+
// Every body goes through the shared bandwidth shaper.
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import http2 from 'node:http2';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import zlib from 'node:zlib';
|
|
21
|
+
|
|
22
|
+
import { createResponseIndex, mergeDocument, sampleQuantiles, seededRandom } from './model.mjs';
|
|
23
|
+
import { createShaper, urgencyOf } from './shaper.mjs';
|
|
24
|
+
|
|
25
|
+
const MIME = {
|
|
26
|
+
'.html': 'text/html; charset=utf-8',
|
|
27
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
28
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
29
|
+
'.css': 'text/css; charset=utf-8',
|
|
30
|
+
'.json': 'application/json; charset=utf-8',
|
|
31
|
+
'.webmanifest': 'application/manifest+json',
|
|
32
|
+
'.map': 'application/json; charset=utf-8',
|
|
33
|
+
'.svg': 'image/svg+xml',
|
|
34
|
+
'.png': 'image/png',
|
|
35
|
+
'.jpg': 'image/jpeg',
|
|
36
|
+
'.webp': 'image/webp',
|
|
37
|
+
'.avif': 'image/avif',
|
|
38
|
+
'.ico': 'image/x-icon',
|
|
39
|
+
'.gif': 'image/gif',
|
|
40
|
+
'.woff2': 'font/woff2',
|
|
41
|
+
'.woff': 'font/woff',
|
|
42
|
+
'.ttf': 'font/ttf',
|
|
43
|
+
'.wasm': 'application/wasm',
|
|
44
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
45
|
+
};
|
|
46
|
+
const COMPRESSIBLE = new Set([
|
|
47
|
+
'.html',
|
|
48
|
+
'.js',
|
|
49
|
+
'.mjs',
|
|
50
|
+
'.css',
|
|
51
|
+
'.json',
|
|
52
|
+
'.webmanifest',
|
|
53
|
+
'.map',
|
|
54
|
+
'.svg',
|
|
55
|
+
'.txt',
|
|
56
|
+
'.wasm',
|
|
57
|
+
'.ico',
|
|
58
|
+
'.ttf',
|
|
59
|
+
]);
|
|
60
|
+
const DEFAULT_LEVEL = { br: 5, gzip: 6, zstd: 3 };
|
|
61
|
+
const NO_BODY = new Set([204, 205, 304]);
|
|
62
|
+
|
|
63
|
+
export function encode(data, { encoding, level }) {
|
|
64
|
+
const lvl = level ?? DEFAULT_LEVEL[encoding];
|
|
65
|
+
if (encoding === 'br') {
|
|
66
|
+
return zlib.brotliCompressSync(data, {
|
|
67
|
+
params: {
|
|
68
|
+
[zlib.constants.BROTLI_PARAM_QUALITY]: lvl,
|
|
69
|
+
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: data.length,
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (encoding === 'gzip') return zlib.gzipSync(data, { level: lvl });
|
|
74
|
+
if (encoding === 'zstd')
|
|
75
|
+
return zlib.zstdCompressSync(data, {
|
|
76
|
+
params: { [zlib.constants.ZSTD_c_compressionLevel]: lvl },
|
|
77
|
+
});
|
|
78
|
+
return data;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const LEVELS = { br: [1, 3, 4, 5, 6, 9, 11], gzip: [1, 4, 6, 9], zstd: [1, 3, 6, 9, 12, 19] };
|
|
82
|
+
|
|
83
|
+
/** The file of `dist` that serves `pathname`, or null (outside `dist`, missing, a folder). */
|
|
84
|
+
export function distFile(dist, pathname) {
|
|
85
|
+
let decoded;
|
|
86
|
+
try {
|
|
87
|
+
decoded = decodeURIComponent(pathname);
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const file = path.normalize(path.join(dist, decoded));
|
|
92
|
+
if (!file.startsWith(path.normalize(dist) + path.sep)) return null;
|
|
93
|
+
try {
|
|
94
|
+
return fs.statSync(file).isFile() ? file : null;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The encoding and level that reproduce the recorded asset sizes best, from up
|
|
102
|
+
* to `sample` of the largest recorded assets that also exist in `dist` (the
|
|
103
|
+
* recorded build, ideally). Header bytes in the recorded sizes are ignored, so
|
|
104
|
+
* only assets of 20 kB or more count.
|
|
105
|
+
*/
|
|
106
|
+
export function chooseCompression(assets, dist, { sample = 8 } = {}) {
|
|
107
|
+
const rows = assets
|
|
108
|
+
.filter(
|
|
109
|
+
(a) =>
|
|
110
|
+
a.encoding &&
|
|
111
|
+
a.encodedBytes >= 20_000 &&
|
|
112
|
+
LEVELS[a.encoding] &&
|
|
113
|
+
COMPRESSIBLE.has(path.extname(a.path)),
|
|
114
|
+
)
|
|
115
|
+
.map((a) => ({ ...a, file: distFile(dist, a.path) }))
|
|
116
|
+
.filter((a) => a.file)
|
|
117
|
+
.sort((a, b) => b.encodedBytes - a.encodedBytes)
|
|
118
|
+
.slice(0, sample);
|
|
119
|
+
if (!rows.length)
|
|
120
|
+
return {
|
|
121
|
+
encoding: null,
|
|
122
|
+
level: null,
|
|
123
|
+
files: 0,
|
|
124
|
+
note: 'no recorded compressed asset exists in the dist',
|
|
125
|
+
};
|
|
126
|
+
const counts = new Map();
|
|
127
|
+
for (const r of rows) counts.set(r.encoding, (counts.get(r.encoding) ?? 0) + 1);
|
|
128
|
+
const [encoding] = [...counts].sort((a, b) => b[1] - a[1])[0];
|
|
129
|
+
const mine = rows.filter((r) => r.encoding === encoding);
|
|
130
|
+
const data = mine.map((r) => fs.readFileSync(r.file));
|
|
131
|
+
let best = null;
|
|
132
|
+
for (const level of LEVELS[encoding]) {
|
|
133
|
+
const ratios = mine
|
|
134
|
+
.map((r, i) => r.encodedBytes / encode(data[i], { encoding, level }).length)
|
|
135
|
+
.sort((a, b) => a - b);
|
|
136
|
+
const mid = ratios[Math.floor(ratios.length / 2)];
|
|
137
|
+
if (!best || Math.abs(Math.log(mid)) < Math.abs(Math.log(best.ratio)))
|
|
138
|
+
best = { level, ratio: mid };
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
encoding,
|
|
142
|
+
level: best.level,
|
|
143
|
+
files: mine.length,
|
|
144
|
+
recordedOverLocal: Math.round(best.ratio * 1000) / 1000,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* HTML split like the recorded document arrived: the early share right after
|
|
150
|
+
* the headers (cut at the end of a tag), the rest when the recorded download
|
|
151
|
+
* ended. Sent uncompressed so that both parts decode at once; on the order of
|
|
152
|
+
* 10 kB of HTML, compression changes nothing measurable.
|
|
153
|
+
*/
|
|
154
|
+
export function documentSegments(html, doc) {
|
|
155
|
+
const ttfb = doc.ttfbMs ?? 0;
|
|
156
|
+
const f = doc.earlyFraction;
|
|
157
|
+
if (typeof f !== 'number' || f >= 1 || !doc.downloadMs) return [{ atMs: ttfb, data: html }];
|
|
158
|
+
let split = 0;
|
|
159
|
+
if (f > 0) {
|
|
160
|
+
const gt = html.indexOf(0x3e, Math.floor(html.length * f));
|
|
161
|
+
split = gt === -1 ? html.length : gt + 1;
|
|
162
|
+
}
|
|
163
|
+
return [
|
|
164
|
+
{ atMs: ttfb, data: html.subarray(0, split) },
|
|
165
|
+
{ atMs: ttfb + doc.downloadMs, data: html.subarray(split) },
|
|
166
|
+
];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const readBody = (req) =>
|
|
170
|
+
new Promise((resolve) => {
|
|
171
|
+
const chunks = [];
|
|
172
|
+
req.on('data', (c) => chunks.push(c));
|
|
173
|
+
req.on('end', () => resolve(chunks.length ? Buffer.concat(chunks).toString('utf8') : null));
|
|
174
|
+
req.on('error', () => resolve(null));
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Http2ServerResponse has no `destroyed` or `closed`; its stream has them.
|
|
178
|
+
const gone = (res) =>
|
|
179
|
+
res.stream ? res.stream.destroyed || res.stream.closed : Boolean(res.destroyed || res.closed);
|
|
180
|
+
|
|
181
|
+
const accepts = (req, encoding) =>
|
|
182
|
+
encoding != null &&
|
|
183
|
+
new RegExp(`(^|[,\\s])${encoding}(;|,|\\s|$)`).test(req.headers['accept-encoding'] ?? '');
|
|
184
|
+
|
|
185
|
+
export async function startReplayServer({
|
|
186
|
+
archive,
|
|
187
|
+
archiveDir,
|
|
188
|
+
tls,
|
|
189
|
+
network,
|
|
190
|
+
compression = null,
|
|
191
|
+
documentSource = 'merged',
|
|
192
|
+
}) {
|
|
193
|
+
const index = createResponseIndex(archive.endpoints, archive.volatile);
|
|
194
|
+
const shaper = createShaper({
|
|
195
|
+
bytesPerSec: network.bandwidthBps,
|
|
196
|
+
wireBytes: network.wireBytes ?? 0,
|
|
197
|
+
});
|
|
198
|
+
const rttMs = network.rttMs ?? 0;
|
|
199
|
+
const bodyCache = new Map();
|
|
200
|
+
const encodedCache = new Map();
|
|
201
|
+
let dist = null;
|
|
202
|
+
let log = [];
|
|
203
|
+
let rand = seededRandom(0);
|
|
204
|
+
|
|
205
|
+
const recordedBody = (ref) => {
|
|
206
|
+
if (!ref) return Buffer.alloc(0);
|
|
207
|
+
if (!bodyCache.has(ref))
|
|
208
|
+
bodyCache.set(ref, fs.readFileSync(path.join(archiveDir, 'bodies', ref)));
|
|
209
|
+
return bodyCache.get(ref);
|
|
210
|
+
};
|
|
211
|
+
const encodedOnce = (cacheKey, data, how) => {
|
|
212
|
+
if (!encodedCache.has(cacheKey)) encodedCache.set(cacheKey, encode(data, how));
|
|
213
|
+
return encodedCache.get(cacheKey);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Headers after `delayMs`, then the body through the shaper. `segments`
|
|
218
|
+
* ([{ atMs, data }], times from the request's arrival) sends the body in
|
|
219
|
+
* parts, like a host that flushes the head of the HTML early. `queueHeaders`
|
|
220
|
+
* puts the headers on the wire's tail with the body behind them (static files).
|
|
221
|
+
*/
|
|
222
|
+
async function respond(
|
|
223
|
+
req,
|
|
224
|
+
res,
|
|
225
|
+
entry,
|
|
226
|
+
{
|
|
227
|
+
status,
|
|
228
|
+
headers = {},
|
|
229
|
+
body,
|
|
230
|
+
segments = null,
|
|
231
|
+
delayMs,
|
|
232
|
+
holdMs = 0,
|
|
233
|
+
queueHeaders = false,
|
|
234
|
+
encodeAs = null,
|
|
235
|
+
cacheKey = null,
|
|
236
|
+
},
|
|
237
|
+
) {
|
|
238
|
+
const write = (chunk) => {
|
|
239
|
+
if (!gone(res)) res.write(chunk);
|
|
240
|
+
};
|
|
241
|
+
// First: a stream the client cancels while it waits leaves the link at once.
|
|
242
|
+
res.on('close', () => shaper.cancel(write));
|
|
243
|
+
const wait = entry.t + delayMs - performance.now();
|
|
244
|
+
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
|
245
|
+
if (gone(res)) {
|
|
246
|
+
entry.cancelled = true;
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const out = { ...headers };
|
|
250
|
+
delete out['content-length'];
|
|
251
|
+
delete out['content-encoding'];
|
|
252
|
+
let parts = segments ?? [{ atMs: 0, data: body ?? Buffer.alloc(0) }];
|
|
253
|
+
if (
|
|
254
|
+
!segments &&
|
|
255
|
+
encodeAs?.encoding &&
|
|
256
|
+
parts[0].data.length &&
|
|
257
|
+
accepts(req, encodeAs.encoding)
|
|
258
|
+
) {
|
|
259
|
+
const data = parts[0].data;
|
|
260
|
+
parts = [
|
|
261
|
+
{
|
|
262
|
+
atMs: 0,
|
|
263
|
+
data: cacheKey
|
|
264
|
+
? encodedOnce(`${cacheKey}|${encodeAs.encoding}|${encodeAs.level}`, data, encodeAs)
|
|
265
|
+
: encode(data, encodeAs),
|
|
266
|
+
},
|
|
267
|
+
];
|
|
268
|
+
out['content-encoding'] = encodeAs.encoding;
|
|
269
|
+
}
|
|
270
|
+
// A late body: everything but the last byte now, the last byte `holdMs` after
|
|
271
|
+
// the headers. A script or JSON response is usable only when complete. The
|
|
272
|
+
// last byte skips the shaper: the recorded download time already holds the
|
|
273
|
+
// time the real link queued it.
|
|
274
|
+
let held = null;
|
|
275
|
+
if (!segments && holdMs > 0 && parts[0].data.length > 1) {
|
|
276
|
+
const data = parts[0].data;
|
|
277
|
+
parts = [{ atMs: 0, data: data.subarray(0, data.length - 1) }];
|
|
278
|
+
held = { atMs: delayMs + holdMs, data: data.subarray(data.length - 1) };
|
|
279
|
+
}
|
|
280
|
+
const total = parts.reduce((s, p) => s + p.data.length, 0) + (held?.data.length ?? 0);
|
|
281
|
+
const noBody = req.method === 'HEAD' || NO_BODY.has(status);
|
|
282
|
+
if (!noBody) out['content-length'] = String(total);
|
|
283
|
+
out['cache-control'] = 'no-store';
|
|
284
|
+
const writeHead = () => {
|
|
285
|
+
if (gone(res)) return;
|
|
286
|
+
res.writeHead(status, out);
|
|
287
|
+
Object.assign(entry, {
|
|
288
|
+
status,
|
|
289
|
+
bytes: noBody ? 0 : total,
|
|
290
|
+
headersMs: Math.round(performance.now() - entry.t),
|
|
291
|
+
});
|
|
292
|
+
};
|
|
293
|
+
const end = () => !gone(res) && res.end();
|
|
294
|
+
if (noBody) {
|
|
295
|
+
// Static headers queue behind the bytes already on the wire (see shaper.mjs).
|
|
296
|
+
if (queueHeaders) await shaper.header();
|
|
297
|
+
writeHead();
|
|
298
|
+
end();
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (queueHeaders) {
|
|
302
|
+
await shaper.send({
|
|
303
|
+
data: parts[0].data,
|
|
304
|
+
urgency: entry.urgency,
|
|
305
|
+
write,
|
|
306
|
+
end,
|
|
307
|
+
head: writeHead,
|
|
308
|
+
});
|
|
309
|
+
entry.endMs = Math.round(performance.now() - entry.t);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
writeHead();
|
|
313
|
+
for (const [i, part] of parts.entries()) {
|
|
314
|
+
const until = entry.t + part.atMs - performance.now();
|
|
315
|
+
if (until > 0) await new Promise((r) => setTimeout(r, until));
|
|
316
|
+
if (gone(res)) return;
|
|
317
|
+
const last = i === parts.length - 1 && !held;
|
|
318
|
+
await shaper.send({
|
|
319
|
+
data: part.data,
|
|
320
|
+
urgency: entry.urgency,
|
|
321
|
+
write,
|
|
322
|
+
end: last ? end : () => {},
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
if (held) {
|
|
326
|
+
const until = entry.t + held.atMs - performance.now();
|
|
327
|
+
if (until > 0) await new Promise((r) => setTimeout(r, until));
|
|
328
|
+
if (gone(res)) return;
|
|
329
|
+
res.end(held.data);
|
|
330
|
+
}
|
|
331
|
+
entry.endMs = Math.round(performance.now() - entry.t);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function handle(req, res) {
|
|
335
|
+
const t = performance.now();
|
|
336
|
+
const body = await readBody(req);
|
|
337
|
+
const authority = req.headers[':authority'] ?? req.headers.host;
|
|
338
|
+
let u;
|
|
339
|
+
try {
|
|
340
|
+
u = new URL(`https://${authority}${req.url}`);
|
|
341
|
+
} catch {
|
|
342
|
+
res.writeHead(400).end();
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
u.hash = '';
|
|
346
|
+
const entry = {
|
|
347
|
+
t,
|
|
348
|
+
method: req.method,
|
|
349
|
+
url: u.href,
|
|
350
|
+
urgency: urgencyOf(req.headers.priority),
|
|
351
|
+
priority: req.headers.priority ?? null,
|
|
352
|
+
};
|
|
353
|
+
log.push(entry);
|
|
354
|
+
const sameOrigin = u.origin === archive.origin;
|
|
355
|
+
|
|
356
|
+
// The page's own navigation; an iframe navigates with another destination.
|
|
357
|
+
if (
|
|
358
|
+
sameOrigin &&
|
|
359
|
+
req.headers['sec-fetch-mode'] === 'navigate' &&
|
|
360
|
+
(req.headers['sec-fetch-dest'] ?? 'document') === 'document' &&
|
|
361
|
+
archive.document
|
|
362
|
+
) {
|
|
363
|
+
const doc = archive.document;
|
|
364
|
+
const local = documentSource !== 'recorded' && dist && distFile(dist, '/index.html');
|
|
365
|
+
const headers = { ...doc.headers };
|
|
366
|
+
// Link headers of the recorded HTML name the recorded build's files.
|
|
367
|
+
if (local) delete headers.link;
|
|
368
|
+
let html;
|
|
369
|
+
if (!local) html = recordedBody(doc.bodyRef);
|
|
370
|
+
else if (documentSource === 'merged' && doc.bodyRef)
|
|
371
|
+
html = Buffer.from(
|
|
372
|
+
mergeDocument(recordedBody(doc.bodyRef).toString('utf8'), fs.readFileSync(local, 'utf8'))
|
|
373
|
+
.html,
|
|
374
|
+
);
|
|
375
|
+
else html = fs.readFileSync(local);
|
|
376
|
+
Object.assign(entry, { source: 'document', level: local ? documentSource : 'recorded' });
|
|
377
|
+
return respond(req, res, entry, {
|
|
378
|
+
status: local ? 200 : doc.status,
|
|
379
|
+
headers: { 'content-type': MIME['.html'], ...headers },
|
|
380
|
+
segments: documentSegments(html, doc),
|
|
381
|
+
delayMs: doc.ttfbMs ?? 0,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (req.method === 'OPTIONS' && req.headers['access-control-request-method']) {
|
|
386
|
+
const m = index.match({ method: 'OPTIONS', url: u.href });
|
|
387
|
+
Object.assign(entry, {
|
|
388
|
+
source: 'preflight',
|
|
389
|
+
level: m.call ? m.level : 'synthesized',
|
|
390
|
+
key: m.key,
|
|
391
|
+
});
|
|
392
|
+
if (m.call)
|
|
393
|
+
return respond(req, res, entry, {
|
|
394
|
+
status: m.call.status,
|
|
395
|
+
headers: m.call.headers,
|
|
396
|
+
body: null,
|
|
397
|
+
delayMs: m.call.ttfbMs ?? rttMs,
|
|
398
|
+
});
|
|
399
|
+
return respond(req, res, entry, {
|
|
400
|
+
status: 204,
|
|
401
|
+
headers: {
|
|
402
|
+
'access-control-allow-origin': req.headers.origin ?? '*',
|
|
403
|
+
'access-control-allow-credentials': 'true',
|
|
404
|
+
'access-control-allow-methods': req.headers['access-control-request-method'],
|
|
405
|
+
'access-control-allow-headers': req.headers['access-control-request-headers'] ?? '*',
|
|
406
|
+
'access-control-max-age': '600',
|
|
407
|
+
},
|
|
408
|
+
body: null,
|
|
409
|
+
delayMs: rttMs,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const file =
|
|
414
|
+
sameOrigin && dist && (req.method === 'GET' || req.method === 'HEAD')
|
|
415
|
+
? distFile(dist, u.pathname)
|
|
416
|
+
: null;
|
|
417
|
+
if (file) {
|
|
418
|
+
const ext = path.extname(file).toLowerCase();
|
|
419
|
+
Object.assign(entry, { source: 'dist', level: 'local' });
|
|
420
|
+
const ttfb = network.staticTtfbPoints
|
|
421
|
+
? sampleQuantiles(network.staticTtfbPoints, rand())
|
|
422
|
+
: (network.staticTtfbMs ?? 0);
|
|
423
|
+
return respond(req, res, entry, {
|
|
424
|
+
status: 200,
|
|
425
|
+
headers: { 'content-type': MIME[ext] ?? 'application/octet-stream' },
|
|
426
|
+
body: fs.readFileSync(file),
|
|
427
|
+
delayMs: ttfb,
|
|
428
|
+
queueHeaders: true,
|
|
429
|
+
encodeAs: COMPRESSIBLE.has(ext) ? compression : null,
|
|
430
|
+
cacheKey: `${file}|${fs.statSync(file).mtimeMs}`,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const m = index.match({ method: req.method, url: u.href, body });
|
|
435
|
+
Object.assign(entry, { source: m.call ? 'recorded' : 'miss', level: m.level, key: m.key });
|
|
436
|
+
if (!m.call) {
|
|
437
|
+
return respond(req, res, entry, {
|
|
438
|
+
status: 404,
|
|
439
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
440
|
+
body: Buffer.from('perf-kit replay: this request was not recorded\n'),
|
|
441
|
+
delayMs: rttMs,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
const enc = m.call.headers['content-encoding'];
|
|
445
|
+
return respond(req, res, entry, {
|
|
446
|
+
status: m.call.status,
|
|
447
|
+
headers: m.call.headers,
|
|
448
|
+
body: recordedBody(m.call.bodyRef),
|
|
449
|
+
delayMs: m.call.ttfbMs ?? rttMs,
|
|
450
|
+
holdMs: m.call.downloadMs ?? 0,
|
|
451
|
+
encodeAs:
|
|
452
|
+
enc && DEFAULT_LEVEL[enc]
|
|
453
|
+
? compression?.encoding === enc
|
|
454
|
+
? compression
|
|
455
|
+
: { encoding: enc }
|
|
456
|
+
: null,
|
|
457
|
+
cacheKey: m.call.bodyRef,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const server = http2.createSecureServer({ ...tls, allowHTTP1: true });
|
|
462
|
+
const sessions = new Set();
|
|
463
|
+
server.on('session', (s) => {
|
|
464
|
+
sessions.add(s);
|
|
465
|
+
s.on('close', () => sessions.delete(s));
|
|
466
|
+
});
|
|
467
|
+
server.on('request', (req, res) => {
|
|
468
|
+
handle(req, res).catch((err) => {
|
|
469
|
+
if (!res.headersSent && !res.destroyed) res.writeHead(500).end(String(err.message));
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
await new Promise((resolve, reject) => {
|
|
473
|
+
server.once('error', reject);
|
|
474
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
475
|
+
});
|
|
476
|
+
return {
|
|
477
|
+
port: server.address().port,
|
|
478
|
+
/**
|
|
479
|
+
* Next page load: serve this dist, start call order over, empty the log.
|
|
480
|
+
* The same `seed` gives the same sampled network times in the same request order.
|
|
481
|
+
*/
|
|
482
|
+
beginRun(distDir, { seed = 0 } = {}) {
|
|
483
|
+
dist = distDir;
|
|
484
|
+
index.reset();
|
|
485
|
+
log = [];
|
|
486
|
+
rand = seededRandom(seed);
|
|
487
|
+
},
|
|
488
|
+
takeLog() {
|
|
489
|
+
const out = log.map(({ t: _t, ...rest }) => rest);
|
|
490
|
+
log = [];
|
|
491
|
+
return out;
|
|
492
|
+
},
|
|
493
|
+
close() {
|
|
494
|
+
shaper.close();
|
|
495
|
+
for (const s of sessions) s.destroy();
|
|
496
|
+
return new Promise((r) => server.close(() => r()));
|
|
497
|
+
},
|
|
498
|
+
};
|
|
499
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Shared bandwidth for the replay server. Every response body goes through ONE
|
|
2
|
+
// link, so parallel downloads share the recorded bandwidth the way they share a
|
|
3
|
+
// real access link. A delay per request cannot do this: it lets every parallel
|
|
4
|
+
// download run at full speed, and a page with 80 chunks loads too fast.
|
|
5
|
+
//
|
|
6
|
+
// Two stages, like a server on a slow link:
|
|
7
|
+
// scheduler - lower urgency first (the RFC 9218 `priority` header that Chrome
|
|
8
|
+
// sends, u=0 is the most urgent), first come first served within
|
|
9
|
+
// one urgency. This is how a CDN picks the next DATA frame.
|
|
10
|
+
// wire - a FIFO of `wireBytes` (send buffer + bytes in flight) that drains
|
|
11
|
+
// at the link rate. Response headers join its tail and wait behind
|
|
12
|
+
// what is already there: on a busy slow link a response's first
|
|
13
|
+
// byte comes late because of the page's OWN downloads. The body
|
|
14
|
+
// follows its headers into the scheduler at once, so a response
|
|
15
|
+
// waits for the wire one time, not once for the headers and again
|
|
16
|
+
// for the body. With wireBytes 0 the wire holds one tick of data.
|
|
17
|
+
|
|
18
|
+
/** `u=1, i` -> 1. Chrome's default for a request without the header is u=3. */
|
|
19
|
+
export function urgencyOf(priorityHeader) {
|
|
20
|
+
const m = /(?:^|[,\s])u=([0-7])/.exec(priorityHeader ?? '');
|
|
21
|
+
return m ? Number(m[1]) : 3;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const SLICE = 16_384;
|
|
25
|
+
|
|
26
|
+
export function createShaper({
|
|
27
|
+
bytesPerSec,
|
|
28
|
+
wireBytes = 0,
|
|
29
|
+
tickMs = 5,
|
|
30
|
+
now = () => performance.now(),
|
|
31
|
+
schedule = (fn, ms) => setTimeout(() => fn(), ms),
|
|
32
|
+
}) {
|
|
33
|
+
const unlimited = !(bytesPerSec > 0) || bytesPerSec === Infinity;
|
|
34
|
+
const queue = [];
|
|
35
|
+
const wire = []; // { bytes, sent, chunk, write } or a marker { bytes: 0, fire }
|
|
36
|
+
let wireLoad = 0;
|
|
37
|
+
let seq = 0;
|
|
38
|
+
let credit = 0;
|
|
39
|
+
let last = now();
|
|
40
|
+
let timer = null;
|
|
41
|
+
let closed = false;
|
|
42
|
+
|
|
43
|
+
const idle = () => !queue.length && !wire.length;
|
|
44
|
+
|
|
45
|
+
// Scheduler -> wire, up to the window (or up to what this tick can send).
|
|
46
|
+
function fill(limit) {
|
|
47
|
+
queue.sort((a, b) => a.urgency - b.urgency || a.seq - b.seq);
|
|
48
|
+
while (queue.length && wireLoad < limit) {
|
|
49
|
+
const item = queue[0];
|
|
50
|
+
const n = Math.min(item.data.length - item.offset, limit - wireLoad, SLICE);
|
|
51
|
+
if (n > 0) {
|
|
52
|
+
wire.push({
|
|
53
|
+
bytes: n,
|
|
54
|
+
sent: 0,
|
|
55
|
+
chunk: item.data.subarray(item.offset, item.offset + n),
|
|
56
|
+
write: item.write,
|
|
57
|
+
});
|
|
58
|
+
wireLoad += n;
|
|
59
|
+
item.offset += n;
|
|
60
|
+
}
|
|
61
|
+
if (item.offset >= item.data.length) {
|
|
62
|
+
queue.shift();
|
|
63
|
+
wire.push({
|
|
64
|
+
bytes: 0,
|
|
65
|
+
fire: () => {
|
|
66
|
+
item.end();
|
|
67
|
+
item.resolve();
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Wire -> sockets, in FIFO order, at the link rate.
|
|
75
|
+
function drain() {
|
|
76
|
+
while (wire.length) {
|
|
77
|
+
const head = wire[0];
|
|
78
|
+
if (head.bytes === 0) {
|
|
79
|
+
wire.shift();
|
|
80
|
+
head.fire();
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (credit < 1) break;
|
|
84
|
+
const n = Math.min(head.bytes - head.sent, Math.floor(credit));
|
|
85
|
+
head.write(head.chunk.subarray(head.sent, head.sent + n));
|
|
86
|
+
head.sent += n;
|
|
87
|
+
credit -= n;
|
|
88
|
+
wireLoad -= n;
|
|
89
|
+
if (head.sent >= head.bytes) wire.shift();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function pump() {
|
|
94
|
+
timer = null;
|
|
95
|
+
if (closed) return;
|
|
96
|
+
const t = now();
|
|
97
|
+
// The link was busy for all of this time, so all of it is usable: a late timer
|
|
98
|
+
// must not lose bandwidth. Idle time never banks a burst: start() drops it.
|
|
99
|
+
credit += ((t - last) * bytesPerSec) / 1000;
|
|
100
|
+
last = t;
|
|
101
|
+
fill(Math.max(wireBytes, Math.floor(credit)));
|
|
102
|
+
drain();
|
|
103
|
+
if (!idle()) timer = schedule(pump, tickMs);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function start() {
|
|
107
|
+
if (idle() && !timer) {
|
|
108
|
+
// Start the clock now: credit from the idle time before is not usable.
|
|
109
|
+
last = now();
|
|
110
|
+
credit = 0;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
/**
|
|
116
|
+
* Sends `data` through the shared link; resolves after `end()` is called.
|
|
117
|
+
* `head()` writes the response headers: they join the wire's tail now, and
|
|
118
|
+
* the body enters the scheduler behind them.
|
|
119
|
+
*/
|
|
120
|
+
send({ data, urgency = 3, write, end, head = null }) {
|
|
121
|
+
if (unlimited || data.length === 0) {
|
|
122
|
+
head?.();
|
|
123
|
+
if (data.length) write(data);
|
|
124
|
+
end();
|
|
125
|
+
return Promise.resolve();
|
|
126
|
+
}
|
|
127
|
+
return new Promise((resolve) => {
|
|
128
|
+
start();
|
|
129
|
+
if (head && wire.length) wire.push({ bytes: 0, fire: head });
|
|
130
|
+
else head?.();
|
|
131
|
+
queue.push({ data, offset: 0, urgency, seq: seq++, write, end, resolve });
|
|
132
|
+
if (!timer) timer = schedule(pump, 0);
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
/** Resolves when response headers sent now would reach the client: behind the wire's content. */
|
|
136
|
+
header() {
|
|
137
|
+
if (unlimited || !wire.length) return Promise.resolve();
|
|
138
|
+
return new Promise((resolve) => wire.push({ bytes: 0, fire: resolve }));
|
|
139
|
+
},
|
|
140
|
+
/** Drops a stream the client cancelled. */
|
|
141
|
+
cancel(write) {
|
|
142
|
+
const i = queue.findIndex((item) => item.write === write);
|
|
143
|
+
if (i !== -1) queue.splice(i, 1)[0].resolve();
|
|
144
|
+
for (let j = wire.length - 1; j >= 0; j--) {
|
|
145
|
+
if (wire[j].write === write) {
|
|
146
|
+
wireLoad -= wire[j].bytes - wire[j].sent;
|
|
147
|
+
wire.splice(j, 1);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
pending: () => queue.length + wire.length,
|
|
152
|
+
close() {
|
|
153
|
+
closed = true;
|
|
154
|
+
for (const item of queue.splice(0)) item.resolve();
|
|
155
|
+
for (const entry of wire.splice(0)) if (entry.bytes === 0) entry.fire();
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|