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,501 @@
1
+ // One field navigation, in three kinds. They stay separate on purpose: precise
2
+ // coverage, the sampling profiler and body buffers slow the page down, so a run
3
+ // that collects them cannot also be a timing run.
4
+ // timingRun - web vitals, every request with CDP timing and initiator stack
5
+ // diagnosticRun - V8 precise coverage at a cut point and at settle, a CPU
6
+ // profile to the cut, and the body of each same-origin script
7
+ // (for build matching)
8
+ // bodyRun - every response body, response headers and request bodies,
9
+ // for a replay archive (record command)
10
+
11
+ import { sleep } from '../cdp.mjs';
12
+ import { COLLECT_JS, OBSERVER_JS } from '../measure.mjs';
13
+ import { openFieldPage } from './browser.mjs';
14
+
15
+ /** Every LCP entry (the lab observer keeps only the last), page visibility. */
16
+ export function fieldObserverJs(ignoreSelector) {
17
+ return `(() => {
18
+ const F = (window.__fieldMetrics = { lcp: [], hidden: document.visibilityState === 'hidden' });
19
+ const sel = ${JSON.stringify(ignoreSelector ?? null)};
20
+ addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') F.hidden = true; }, true);
21
+ try {
22
+ new PerformanceObserver((list) => {
23
+ for (const e of list.getEntries()) {
24
+ let ignored = false;
25
+ try { ignored = Boolean(sel && e.element && e.element.closest(sel)); } catch {}
26
+ F.lcp.push({ t: e.startTime, size: e.size, ignored });
27
+ }
28
+ }).observe({ type: 'largest-contentful-paint', buffered: true });
29
+ } catch {}
30
+ })();`;
31
+ }
32
+
33
+ const STATE_JS = `(() => {
34
+ const nav = performance.getEntriesByType('navigation')[0];
35
+ const F = window.__fieldMetrics || { lcp: [], hidden: false };
36
+ const counted = F.lcp.filter((e) => !e.ignored);
37
+ return {
38
+ load: nav ? nav.loadEventEnd : 0,
39
+ lcp: counted.length ? counted[counted.length - 1].t : null,
40
+ fcp: (window.__perfMetrics || {}).fcp ?? null,
41
+ hidden: F.hidden,
42
+ };
43
+ })()`;
44
+
45
+ const PAGE_INFO_JS = `(() => {
46
+ const nav = performance.getEntriesByType('navigation')[0];
47
+ const F = window.__fieldMetrics || { lcp: [] };
48
+ const entry = [...document.querySelectorAll('script[type="module"][src]')]
49
+ .map((s) => new URL(s.src, location.href))
50
+ .find((u) => u.origin === location.origin);
51
+ const c = navigator.connection;
52
+ return {
53
+ path: location.pathname,
54
+ navUrl: nav ? nav.name.split('#')[0] : null,
55
+ requestStart: nav ? nav.requestStart : null,
56
+ lcpEntries: F.lcp,
57
+ hidden: F.hidden,
58
+ entry: entry ? entry.pathname : null,
59
+ connection: c ? { effectiveType: c.effectiveType, rttMs: c.rtt, downlinkMbps: c.downlink } : null,
60
+ };
61
+ })()`;
62
+
63
+ /**
64
+ * Requests with CDP timing, in CDP seconds until `finish` converts them to page
65
+ * time. Keeps URL, method, type, priority and the initiator's call frames - never
66
+ * headers, cookies or bodies. For the document it also keeps when its bytes
67
+ * arrived (sizes only): a host that flushes the head early starts the page
68
+ * earlier than one that sends the HTML at once.
69
+ */
70
+ export function recordFieldNetwork(page, { maxFrames = 8 } = {}) {
71
+ const byId = new Map();
72
+ const order = [];
73
+ const stops = [
74
+ page.on('Network.requestWillBeSent', (e) => {
75
+ const frames = [];
76
+ for (let st = e.initiator?.stack; st && frames.length < maxFrames; st = st.parent) {
77
+ for (const f of st.callFrames ?? []) {
78
+ if (f.url && frames.length < maxFrames) {
79
+ frames.push({
80
+ url: f.url,
81
+ functionName: f.functionName,
82
+ lineNumber: f.lineNumber,
83
+ columnNumber: f.columnNumber,
84
+ });
85
+ }
86
+ }
87
+ }
88
+ const previous = byId.get(e.requestId);
89
+ const row = {
90
+ id: e.requestId,
91
+ url: e.request.url.split('#')[0],
92
+ method: e.request.method,
93
+ type: e.type ?? null,
94
+ ts: e.timestamp,
95
+ priority: e.request.initialPriority ?? null,
96
+ redirected: Boolean(e.redirectResponse) || Boolean(previous?.redirected),
97
+ initiator: {
98
+ type: e.initiator?.type ?? null,
99
+ url: frames[0]?.url ?? e.initiator?.url ?? null,
100
+ frames,
101
+ },
102
+ };
103
+ if (!previous) order.push(e.requestId);
104
+ byId.set(e.requestId, row);
105
+ }),
106
+ page.on('Network.responseReceived', (e) => {
107
+ const r = byId.get(e.requestId);
108
+ if (!r) return;
109
+ r.status = e.response.status;
110
+ r.protocol = e.response.protocol ?? null;
111
+ r.type = e.type ?? r.type;
112
+ const t = e.response.timing;
113
+ if (t) {
114
+ r.sendStart = t.requestTime + t.sendStart / 1000;
115
+ r.headersEnd = t.requestTime + t.receiveHeadersEnd / 1000;
116
+ }
117
+ }),
118
+ page.on('Network.resourceChangedPriority', (e) => {
119
+ const r = byId.get(e.requestId);
120
+ if (r) r.lastPriority = e.newPriority;
121
+ }),
122
+ page.on('Network.dataReceived', (e) => {
123
+ const r = byId.get(e.requestId);
124
+ if (r?.type === 'Document') (r.chunks ??= []).push({ ts: e.timestamp, bytes: e.dataLength });
125
+ }),
126
+ page.on('Network.loadingFinished', (e) => {
127
+ const r = byId.get(e.requestId);
128
+ if (!r) return;
129
+ r.end = e.timestamp;
130
+ r.encodedBytes = e.encodedDataLength;
131
+ }),
132
+ page.on('Network.loadingFailed', (e) => {
133
+ const r = byId.get(e.requestId);
134
+ if (!r) return;
135
+ r.end = e.timestamp;
136
+ r.failed = e.errorText;
137
+ r.blocked = e.blockedReason ?? null;
138
+ }),
139
+ ];
140
+ return {
141
+ rows: () => order.map((id) => byId.get(id)),
142
+ stop: () => stops.forEach((stop) => stop()),
143
+ /**
144
+ * Page time (ms since timeOrigin, the clock LCP uses) for every request. The
145
+ * offset comes from the document request: its CDP sendStart equals the
146
+ * navigation entry's requestStart. After a client-side redirect the page is
147
+ * the second document, so `navUrl` (the navigation entry's URL) picks it; that
148
+ * row is marked `navigation`.
149
+ */
150
+ finish(requestStartPageMs, redact, navUrl = null) {
151
+ const rows = order.map((id) => byId.get(id));
152
+ const docs = rows.filter((r) => r.type === 'Document' && r.sendStart != null);
153
+ const doc = docs.find((r) => r.url === navUrl) ?? docs[0];
154
+ const offset =
155
+ doc && typeof requestStartPageMs === 'number'
156
+ ? doc.sendStart * 1000 - requestStartPageMs
157
+ : rows[0]
158
+ ? rows[0].ts * 1000
159
+ : 0;
160
+ const pt = (s) => (typeof s === 'number' ? Math.round((s * 1000 - offset) * 10) / 10 : null);
161
+ return rows.map((r) => ({
162
+ url: redact(r.url),
163
+ method: r.method,
164
+ type: r.type,
165
+ status: r.status ?? null,
166
+ protocol: r.protocol ?? null,
167
+ priority: r.priority,
168
+ lastPriority: r.lastPriority ?? r.priority,
169
+ startMs: pt(r.ts),
170
+ sendStartMs: pt(r.sendStart),
171
+ headersEndMs: pt(r.headersEnd),
172
+ endMs: pt(r.end),
173
+ encodedBytes: r.encodedBytes ?? null,
174
+ failed: r.failed ?? null,
175
+ blocked: r.blocked ?? null,
176
+ redirected: r.redirected,
177
+ ...(r === doc ? { navigation: true } : {}),
178
+ ...(r.chunks ? { chunks: r.chunks.map((c) => ({ ms: pt(c.ts), bytes: c.bytes })) } : {}),
179
+ initiator: {
180
+ type: r.initiator.type,
181
+ url: redact(r.initiator.url),
182
+ frames: r.initiator.frames.map((f) => ({ ...f, url: redact(f.url) })),
183
+ },
184
+ }));
185
+ },
186
+ };
187
+ }
188
+
189
+ async function waitFor(page, predicate, timeoutMs) {
190
+ const deadline = Date.now() + timeoutMs;
191
+ for (;;) {
192
+ const state = await page.evaluate(STATE_JS).catch(() => null);
193
+ if (state && (predicate(state) || state.hidden)) return state;
194
+ if (Date.now() > deadline) return state;
195
+ await sleep(25);
196
+ }
197
+ }
198
+
199
+ /** The lab sample shape, without the text an LCP element shows (real account data). */
200
+ function scrubSample(sample, redact) {
201
+ const out = { ...sample };
202
+ out.resources = (sample.resources ?? []).map((r) => ({ ...r, name: redact(r.name) }));
203
+ if (out.lcpElement) {
204
+ const { text, finalText, ...rest } = out.lcpElement;
205
+ out.lcpElement = {
206
+ ...rest,
207
+ url: redact(rest.url),
208
+ finalUrl: redact(rest.finalUrl),
209
+ textLength: (finalText ?? text ?? '').length,
210
+ };
211
+ }
212
+ if (out.blockingGate?.worst) {
213
+ out.blockingGate = {
214
+ ...out.blockingGate,
215
+ worst: out.blockingGate.worst.map((w) => ({ ...w, name: redact(w.name) })),
216
+ };
217
+ }
218
+ delete out.heroTitle;
219
+ delete out.heroSubtitle;
220
+ return out;
221
+ }
222
+
223
+ /** `keepRaw` adds `rawRequests`: the same rows with raw URLs (archives, chunk names). */
224
+ export async function timingRun(
225
+ cdp,
226
+ {
227
+ variant,
228
+ config,
229
+ context,
230
+ saved,
231
+ configured,
232
+ redact,
233
+ settleMs,
234
+ timeoutMs,
235
+ throttle,
236
+ keepRaw = false,
237
+ },
238
+ ) {
239
+ const page = await openFieldPage(cdp, {
240
+ browserContextId: context,
241
+ origin: config.origin,
242
+ cookies: variant.cookies,
243
+ saved,
244
+ configured,
245
+ block: config.block,
246
+ scripts: [OBSERVER_JS, fieldObserverJs(config.ignoreLcp)],
247
+ throttle,
248
+ });
249
+ const net = recordFieldNetwork(page);
250
+ try {
251
+ await page.navigate(variant.url);
252
+ await waitFor(page, (s) => s.load > 0 && s.lcp != null, timeoutMs);
253
+ await sleep(settleMs);
254
+ const raw = await page.evaluate(COLLECT_JS);
255
+ const info = await page.evaluate(PAGE_INFO_JS);
256
+ const counted = info.lcpEntries.filter((e) => !e.ignored);
257
+ const sample = scrubSample(raw, redact);
258
+ // The lab observer keeps the LAST entry, which may sit in an ignored region
259
+ // (a splash/loader). Field LCP is the last entry outside it.
260
+ sample.lcp = counted.length ? counted[counted.length - 1].t : null;
261
+ sample.lcpIgnoredEntries = info.lcpEntries.length - counted.length;
262
+ const requests = net.finish(info.requestStart, redact, info.navUrl);
263
+ let invalid = null;
264
+ if (info.hidden) invalid = 'page was hidden (window covered or in the background)';
265
+ else if (sample.lcp == null) invalid = 'no LCP outside the ignored region';
266
+ return {
267
+ sample,
268
+ requests,
269
+ ...(keepRaw ? { rawRequests: net.finish(info.requestStart, (v) => v, info.navUrl) } : {}),
270
+ path: redact(info.path),
271
+ entry: info.entry,
272
+ connection: info.connection,
273
+ invalid,
274
+ };
275
+ } finally {
276
+ net.stop();
277
+ await page.close().catch(() => {});
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Recording run for a replay archive. Returns every request (page time, raw URL)
283
+ * with its response headers, decoded response body and request body. The caller
284
+ * keeps only safelisted headers and body hashes, and writes the bodies outside
285
+ * the repo. Nothing here is printed. Page state that the replay cannot copy is
286
+ * described by names and counts only: script-visible cookie names and the
287
+ * number of localStorage keys.
288
+ */
289
+ export async function bodyRun(
290
+ cdp,
291
+ { variant, config, context, saved, configured, settleMs, timeoutMs },
292
+ ) {
293
+ const page = await openFieldPage(cdp, {
294
+ browserContextId: context,
295
+ origin: config.origin,
296
+ cookies: variant.cookies,
297
+ saved,
298
+ configured,
299
+ block: config.block,
300
+ scripts: [OBSERVER_JS, fieldObserverJs(config.ignoreLcp)],
301
+ bodies: true,
302
+ });
303
+ const net = recordFieldNetwork(page);
304
+ const extra = new Map();
305
+ const of = (id) => extra.get(id) ?? extra.set(id, {}).get(id);
306
+ const stops = [
307
+ page.on('Network.requestWillBeSent', (e) => {
308
+ const x = of(e.requestId);
309
+ x.postData = e.request.postData ?? null;
310
+ x.hasPostData = Boolean(e.request.hasPostData);
311
+ }),
312
+ page.on('Network.responseReceived', (e) => {
313
+ of(e.requestId).headers = e.response.headers;
314
+ }),
315
+ ];
316
+ try {
317
+ await page.navigate(variant.url);
318
+ await waitFor(page, (s) => s.load > 0 && s.lcp != null, timeoutMs);
319
+ await sleep(settleMs);
320
+ const info = await page.evaluate(PAGE_INFO_JS);
321
+ const state = await page
322
+ .evaluate(
323
+ `({ cookieNames: document.cookie.split(';').map((c) => c.split('=')[0].trim()).filter(Boolean),
324
+ localStorageKeys: (() => { try { return localStorage.length; } catch { return null; } })() })`,
325
+ )
326
+ .catch(() => ({ cookieNames: [], localStorageKeys: null }));
327
+ const raw = net.rows();
328
+ const timed = net.finish(info.requestStart, (v) => v, info.navUrl);
329
+ const rows = [];
330
+ for (let i = 0; i < raw.length; i++) {
331
+ const x = extra.get(raw[i].id) ?? {};
332
+ let body = null;
333
+ if (!raw[i].failed && raw[i].end != null && /^https?:/.test(raw[i].url)) {
334
+ try {
335
+ const res = await page.send('Network.getResponseBody', { requestId: raw[i].id });
336
+ body = Buffer.from(res.body, res.base64Encoded ? 'base64' : 'utf8');
337
+ } catch {
338
+ /* no body (204, redirect) or evicted from the buffer */
339
+ }
340
+ }
341
+ let postData = x.postData ?? null;
342
+ if (postData == null && x.hasPostData) {
343
+ postData = await page
344
+ .send('Network.getRequestPostData', { requestId: raw[i].id })
345
+ .then((r) => r.postData)
346
+ .catch(() => null);
347
+ }
348
+ rows.push({ ...timed[i], headers: x.headers ?? {}, body, postData });
349
+ }
350
+ let invalid = null;
351
+ if (info.hidden) invalid = 'page was hidden (window covered or in the background)';
352
+ return { rows, page: { path: info.path, entry: info.entry, ...state }, invalid };
353
+ } finally {
354
+ stops.forEach((stop) => stop());
355
+ net.stop();
356
+ await page.close().catch(() => {});
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Coverage + CPU profile run. Cut points:
362
+ * fcp - first contentful paint (the lab's cut)
363
+ * lcp - the first LCP entry outside the ignored region
364
+ * gate - the moment the config's gate request is sent: "what ran before the
365
+ * request the LCP waits for". The snapshot is async, so a few ms of
366
+ * later execution can land in it.
367
+ * Coverage is armed on about:blank and survives the navigation; if the entry
368
+ * script is missing from the result, the caller reports it.
369
+ */
370
+ export async function diagnosticRun(
371
+ cdp,
372
+ { variant, config, context, saved, configured, redact, cut, settleMs, timeoutMs, throttle },
373
+ ) {
374
+ const page = await openFieldPage(cdp, {
375
+ browserContextId: context,
376
+ origin: config.origin,
377
+ cookies: variant.cookies,
378
+ saved,
379
+ configured,
380
+ block: config.block,
381
+ scripts: [OBSERVER_JS, fieldObserverJs(config.ignoreLcp)],
382
+ bodies: true,
383
+ throttle,
384
+ });
385
+ const net = recordFieldNetwork(page, { maxFrames: 64 });
386
+ let gateSeen = false;
387
+ // The timing runs find the gate in redacted URLs, so the cut matches the same way.
388
+ const gate = redact(config.gate);
389
+ const stopGate =
390
+ cut === 'gate' && gate
391
+ ? page.on('Network.requestWillBeSent', (e) => {
392
+ if (redact(e.request.url).includes(gate)) gateSeen = true;
393
+ })
394
+ : () => {};
395
+ // With the debugger on, a `debugger` statement or a breakpoint stops the page
396
+ // until a client resumes it. Resume at once: Debugger.setSkipAllPauses does
397
+ // not survive the navigation.
398
+ const stopPauses = page.on('Debugger.paused', () => {
399
+ page.send('Debugger.resume').catch(() => {});
400
+ });
401
+ try {
402
+ // Async parent stacks make the initiator of a fetch name its real caller,
403
+ // not only the wrapper or microtask that issued it. They cost CPU, which is
404
+ // one more reason this run never feeds timing numbers.
405
+ await page.send('Debugger.enable');
406
+ await page.send('Debugger.setAsyncCallStackDepth', { maxDepth: 32 });
407
+ await page.send('Profiler.enable');
408
+ await page.send('Profiler.setSamplingInterval', { interval: 200 });
409
+ await page.send('Profiler.startPreciseCoverage', { callCount: false, detailed: true });
410
+ await page.send('Profiler.start');
411
+ await page.navigate(variant.url);
412
+ const deadline = Date.now() + timeoutMs;
413
+ let state = null;
414
+ let reached = false;
415
+ for (;;) {
416
+ state = await page.evaluate(STATE_JS).catch(() => null);
417
+ reached = cut === 'gate' ? gateSeen : cut === 'fcp' ? state?.fcp != null : state?.lcp != null;
418
+ if (reached || state?.hidden || Date.now() > deadline) break;
419
+ await sleep(cut === 'gate' ? 5 : 25);
420
+ }
421
+ const atCut = await page.send('Profiler.takePreciseCoverage');
422
+ const cutMs = await page.evaluate('performance.now()');
423
+ const { profile } = await page.send('Profiler.stop');
424
+ await waitFor(page, (s) => s.load > 0 && s.lcp != null, Math.max(0, deadline - Date.now()));
425
+ await sleep(settleMs);
426
+ const atSettle = await page.send('Profiler.takePreciseCoverage');
427
+ await page.send('Profiler.stopPreciseCoverage').catch(() => {});
428
+ const info = await page.evaluate(PAGE_INFO_JS);
429
+ const requests = net.finish(info.requestStart, redact, info.navUrl);
430
+
431
+ // V8 omits a script from a snapshot when it ran nothing new in that window.
432
+ const byUrl = new Map();
433
+ const collect = (take, key) => {
434
+ for (const s of take.result) {
435
+ if (!s.url || !s.url.startsWith(config.origin)) continue;
436
+ const rec = byUrl.get(s.url) ?? {
437
+ url: s.url,
438
+ pathname: new URL(s.url).pathname,
439
+ atPaint: null,
440
+ atSettle: null,
441
+ };
442
+ rec[key] = (rec[key] ?? []).concat(s.functions);
443
+ byUrl.set(s.url, rec);
444
+ }
445
+ };
446
+ collect(atCut, 'atPaint');
447
+ collect(atSettle, 'atSettle');
448
+
449
+ // Bodies for build matching: only same-origin scripts that executed.
450
+ const rows = net.rows();
451
+ const bodies = new Map();
452
+ for (const url of byUrl.keys()) {
453
+ if (!/\.[mc]?js$/.test(new URL(url).pathname)) continue;
454
+ const row = rows.find((r) => r.url === url && !r.failed);
455
+ if (!row) continue;
456
+ try {
457
+ const { body, base64Encoded } = await page.send('Network.getResponseBody', {
458
+ requestId: row.id,
459
+ });
460
+ bodies.set(url, base64Encoded ? Buffer.from(body, 'base64').toString('utf8') : body);
461
+ } catch {
462
+ /* evicted from the buffer: chunk-level coverage still works, matching does not */
463
+ }
464
+ }
465
+ // Raw URLs: a redacted chunk name (a long hex hash) would not match its script.
466
+ const scriptFetches = net
467
+ .finish(info.requestStart, (v) => v, info.navUrl)
468
+ .filter((r) => r.url.startsWith(config.origin) && /\.[mc]?js$/.test(new URL(r.url).pathname))
469
+ .map((r) => ({
470
+ pathname: new URL(r.url).pathname,
471
+ startMs: r.startMs,
472
+ endMs: r.endMs,
473
+ bytes: r.encodedBytes ?? 0,
474
+ prePaint: typeof r.startMs === 'number' && r.startMs < cutMs,
475
+ }));
476
+ let invalid = null;
477
+ if (info.hidden || state?.hidden)
478
+ invalid = 'page was hidden (window covered or in the background)';
479
+ else if (cut === 'gate' && !reached)
480
+ invalid = `gate request "${gate}" was not sent within ${timeoutMs}ms`;
481
+ else if (!reached) invalid = `no ${cut.toUpperCase()} within ${timeoutMs}ms`;
482
+ return {
483
+ scripts: [...byUrl.values()],
484
+ bodies,
485
+ requests,
486
+ scriptFetches,
487
+ profile: { ...profile, origin: config.origin },
488
+ cut,
489
+ cutMs,
490
+ entry: info.entry,
491
+ path: redact(info.path),
492
+ invalid,
493
+ };
494
+ } finally {
495
+ await page.send('Debugger.disable').catch(() => {});
496
+ stopGate();
497
+ stopPauses();
498
+ net.stop();
499
+ await page.close().catch(() => {});
500
+ }
501
+ }
@@ -0,0 +1,84 @@
1
+ // Field config: which deployed URL to load, how to ask the site for each build
2
+ // (variant), what to block, and which local build explains each variant.
3
+ //
4
+ // {
5
+ // "url": "https://app.example.com/",
6
+ // "variants": {
7
+ // "base": { "cookies": [{ "name": "version", "value": "stable" }], "build": "/path/to/dist" },
8
+ // "candidate": { "query": { "preview": "pr-123" } }
9
+ // },
10
+ // "block": ["analytics.example.com"], // URL substrings; the page cannot send these
11
+ // "ignoreLcp": "#splash", // LCP entries inside this selector do not count
12
+ // "gate": "/api/items", // optional: the request the LCP waits for
13
+ // "redact": ["tenant-[a-z0-9]+"] // extra regexes removed from written URLs
14
+ // }
15
+ //
16
+ // A variant may set cookies, query parameters or both. Variant cookie values are
17
+ // treated as secrets: they are never printed or written to the report.
18
+
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+
22
+ export function loadFieldConfig(file) {
23
+ const resolved = path.resolve(file);
24
+ if (!fs.existsSync(resolved)) throw new Error(`no field config at ${resolved}`);
25
+ const config = JSON.parse(fs.readFileSync(resolved, 'utf8'));
26
+ return validateFieldConfig(config, path.dirname(resolved));
27
+ }
28
+
29
+ export function validateFieldConfig(config, baseDir = process.cwd()) {
30
+ let url;
31
+ try {
32
+ url = new URL(config.url);
33
+ } catch {
34
+ throw new Error('field config: "url" must be an absolute URL');
35
+ }
36
+ const names = Object.keys(config.variants ?? {});
37
+ if (names.length < 1) throw new Error('field config: "variants" needs at least one entry');
38
+ const variants = {};
39
+ for (const name of names) {
40
+ const v = config.variants[name] ?? {};
41
+ for (const cookie of v.cookies ?? []) {
42
+ if (!cookie?.name || typeof cookie.value !== 'string') {
43
+ throw new Error(`field config: variant "${name}" has a cookie without name/value`);
44
+ }
45
+ }
46
+ const target = new URL(url);
47
+ for (const [key, value] of Object.entries(v.query ?? {})) target.searchParams.set(key, value);
48
+ variants[name] = {
49
+ name,
50
+ cookies: v.cookies ?? [],
51
+ url: target.toString(),
52
+ build: v.build ? path.resolve(baseDir, v.build) : null,
53
+ };
54
+ }
55
+ return {
56
+ url: url.toString(),
57
+ origin: url.origin,
58
+ variants,
59
+ block: config.block ?? [],
60
+ ignoreLcp: config.ignoreLcp ?? null,
61
+ gate: config.gate ?? null,
62
+ redact: config.redact ?? [],
63
+ };
64
+ }
65
+
66
+ /** What the report may say about the config: cookie names, never values. */
67
+ export function describeConfig(config, redact) {
68
+ return {
69
+ url: redact(config.url),
70
+ variants: Object.fromEntries(
71
+ Object.values(config.variants).map((v) => [
72
+ v.name,
73
+ {
74
+ url: redact(v.url),
75
+ cookies: v.cookies.map((c) => c.name),
76
+ build: v.build,
77
+ },
78
+ ]),
79
+ ),
80
+ block: config.block,
81
+ ignoreLcp: config.ignoreLcp,
82
+ gate: config.gate,
83
+ };
84
+ }