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,225 @@
1
+ // Pure analysis for field runs (a deployed site, a real login, a real network):
2
+ // the request the LCP waited for, the script chain that started it, paired
3
+ // statistics across alternating variants, chunk-level coverage that needs no
4
+ // sourcemap, and URL redaction. No browser access here, so all of it is testable.
5
+
6
+ import { coverageBounds, executedIntervals } from '../coverage.mjs';
7
+ import { median, quantile } from '../measure.mjs';
8
+
9
+ // --- redaction -------------------------------------------------------------------
10
+ // Field output comes from a real account. Identifiers in URLs (account, zone and
11
+ // user IDs, emails, long numbers) are replaced before anything is written.
12
+
13
+ const DEFAULT_REDACTIONS = [
14
+ [/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '<uuid>'],
15
+ [/[0-9a-f]{16,}/gi, '<hex>'],
16
+ [/[^\s/?&=@:]+?(?:@|%40)[^\s/?&=]+\.[a-z]{2,}/gi, '<email>'],
17
+ [/(?<![\w.])\d{6,}(?![\w])/g, '<n>'],
18
+ ];
19
+
20
+ export function makeRedactor(extraPatterns = []) {
21
+ const rules = [
22
+ ...DEFAULT_REDACTIONS,
23
+ ...extraPatterns.map((source) => [new RegExp(source, 'g'), '<redacted>']),
24
+ ];
25
+ return (value) => {
26
+ if (typeof value !== 'string') return value;
27
+ let out = value;
28
+ for (const [re, replacement] of rules) out = out.replace(re, replacement);
29
+ return out;
30
+ };
31
+ }
32
+
33
+ // --- critical path ---------------------------------------------------------------
34
+
35
+ const isDataRequest = (r) => r.type === 'Fetch' || r.type === 'XHR';
36
+
37
+ /**
38
+ * The data request the LCP most likely waited for: the fetch/XHR that finished
39
+ * last before LCP (within `windowMs`), or the last one matching `pattern` when the
40
+ * config pins it. The automatic choice is a heuristic - `candidates` lists the
41
+ * runners-up so a wrong pick is visible, and `gate` in the config fixes it.
42
+ */
43
+ export function findGate(requests, lcpMs, { pattern = null, windowMs = 1500 } = {}) {
44
+ if (typeof lcpMs !== 'number') return { gate: null, reason: 'no LCP', candidates: [] };
45
+ let pool = requests.filter(
46
+ (r) => isDataRequest(r) && !r.failed && typeof r.endMs === 'number' && r.endMs <= lcpMs,
47
+ );
48
+ if (pattern) pool = pool.filter((r) => r.url.includes(pattern));
49
+ else pool = pool.filter((r) => lcpMs - r.endMs <= windowMs);
50
+ pool.sort((a, b) => b.endMs - a.endMs);
51
+ const candidates = pool.slice(0, 3).map((r) => ({ url: r.url, endMs: r.endMs }));
52
+ if (!pool.length) {
53
+ return {
54
+ gate: null,
55
+ reason: pattern
56
+ ? `no request matching "${pattern}" finished before LCP`
57
+ : `no fetch/XHR finished within ${windowMs}ms before LCP`,
58
+ candidates,
59
+ };
60
+ }
61
+ return { gate: pool[0], reason: pattern ? 'pinned by config' : 'auto', candidates };
62
+ }
63
+
64
+ /**
65
+ * The script a request had to wait for. A request cannot start before every
66
+ * script on its initiator stack has loaded, so of all the scripts on the stack
67
+ * (sync frames and async parents) the one whose download ended last is the
68
+ * bottleneck. A fetch wrapper on top of the stack is then skipped automatically
69
+ * when the real caller loaded later.
70
+ */
71
+ export function criticalInitiatorUrl(req, requests) {
72
+ const urls = [
73
+ ...new Set([...(req.initiator?.frames ?? []).map((f) => f.url), req.initiator?.url]),
74
+ ].filter(Boolean);
75
+ let best = null;
76
+ for (const url of urls) {
77
+ const loaded = requests.find((r) => r.url === url && !r.failed);
78
+ if (loaded && (!best || (loaded.endMs ?? -1) > (best.endMs ?? -1))) best = loaded;
79
+ }
80
+ return best?.url ?? urls[0] ?? null;
81
+ }
82
+
83
+ /**
84
+ * Walk initiators back from a request: the script it waited for, the script that
85
+ * loaded that script, and so on to the document. Each step keeps its fetch
86
+ * window, which is what turns "the request started late" into the exact
87
+ * waterfall of chunk loads in front of it.
88
+ */
89
+ export function initiatorChain(requests, startUrl, maxDepth = 12) {
90
+ const chain = [];
91
+ const seen = new Set();
92
+ let url = startUrl;
93
+ while (url && !seen.has(url) && chain.length < maxDepth) {
94
+ seen.add(url);
95
+ const req = requests.find((r) => r.url === url && !r.failed);
96
+ if (!req) {
97
+ chain.push({
98
+ url,
99
+ type: 'unknown',
100
+ note: 'no request recorded for this URL (inline or cached)',
101
+ });
102
+ break;
103
+ }
104
+ chain.push({
105
+ url: req.url,
106
+ type: req.type,
107
+ startMs: req.startMs,
108
+ endMs: req.endMs,
109
+ initiatorType: req.initiator?.type ?? null,
110
+ });
111
+ if (req.type === 'Document') break;
112
+ url = criticalInitiatorUrl(req, requests);
113
+ }
114
+ return chain;
115
+ }
116
+
117
+ /**
118
+ * Split one run's LCP at its gate: the client time before the gate request
119
+ * started (also measured from the end of the document, which removes a slower
120
+ * HTML response of one variant's server), the time to first byte, the
121
+ * download, and the client time after it. Only the client parts can move with
122
+ * a front-end change.
123
+ */
124
+ export function criticalPath(requests, lcpMs, options = {}) {
125
+ const found = findGate(requests, lcpMs, options);
126
+ if (!found.gate) return { ...found, breakdown: null, chain: [] };
127
+ const g = found.gate;
128
+ const sendStart = g.sendStartMs ?? g.startMs;
129
+ const headersEnd = g.headersEndMs ?? null;
130
+ const doc =
131
+ requests.find((r) => r.navigation && !r.failed) ??
132
+ requests.find((r) => r.type === 'Document' && !r.failed);
133
+ return {
134
+ gate: { url: g.url, method: g.method, startMs: g.startMs, endMs: g.endMs },
135
+ reason: found.reason,
136
+ candidates: found.candidates,
137
+ breakdown: {
138
+ documentEndMs: round(doc?.endMs ?? null),
139
+ beforeGateMs: round(g.startMs),
140
+ gateAfterDocumentMs: doc?.endMs == null ? null : round(g.startMs - doc.endMs),
141
+ ttfbMs: headersEnd == null ? null : round(headersEnd - sendStart),
142
+ downloadMs: headersEnd == null ? null : round(g.endMs - headersEnd),
143
+ afterGateMs: round(lcpMs - g.endMs),
144
+ },
145
+ chain: initiatorChain(requests, criticalInitiatorUrl(g, requests)),
146
+ };
147
+ }
148
+
149
+ // --- statistics ------------------------------------------------------------------
150
+
151
+ function mulberry32(seed) {
152
+ let a = seed >>> 0;
153
+ return () => {
154
+ a = (a + 0x6d2b79f5) >>> 0;
155
+ let t = a;
156
+ t = Math.imul(t ^ (t >>> 15), t | 1);
157
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
158
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Paired comparison of alternating runs: pair i holds one run of each variant,
164
+ * measured back to back, so slow minutes of the network hit both sides. Reports
165
+ * the median paired delta (candidate - base; negative = candidate faster), a
166
+ * seeded bootstrap 90% interval of that median, and the win count.
167
+ */
168
+ export function pairedDelta(pairs, { resamples = 2000, seed = 1 } = {}) {
169
+ const deltas = pairs
170
+ .filter(([a, b]) => typeof a === 'number' && typeof b === 'number')
171
+ .map(([a, b]) => b - a);
172
+ if (!deltas.length) return null;
173
+ const rand = mulberry32(seed);
174
+ const medians = [];
175
+ for (let i = 0; i < resamples; i++) {
176
+ const draw = deltas.map(() => deltas[Math.floor(rand() * deltas.length)]);
177
+ medians.push(median(draw));
178
+ }
179
+ return {
180
+ pairs: deltas.length,
181
+ medianDeltaMs: round(median(deltas)),
182
+ ci90Ms: [round(quantile(medians, 0.05)), round(quantile(medians, 0.95))],
183
+ candidateFaster: deltas.filter((d) => d < 0).length,
184
+ };
185
+ }
186
+
187
+ /** Values above `factor` x the median, for n >= 3. A slow server minute, not the build. */
188
+ export function outlierIndexes(values, factor = 3) {
189
+ const nums = values.filter((v) => typeof v === 'number');
190
+ if (nums.length < 3) return [];
191
+ const mid = median(nums);
192
+ return values.flatMap((v, i) => (typeof v === 'number' && v > factor * mid ? [i] : []));
193
+ }
194
+
195
+ // --- chunk-level coverage ----------------------------------------------------------
196
+
197
+ function unionLength(a, b) {
198
+ const all = [...a, ...b].sort((x, y) => x.start - y.start);
199
+ let total = 0;
200
+ let current = null;
201
+ for (const interval of all) {
202
+ if (current && interval.start <= current.end) current.end = Math.max(current.end, interval.end);
203
+ else {
204
+ if (current) total += current.end - current.start;
205
+ current = { ...interval };
206
+ }
207
+ }
208
+ if (current) total += current.end - current.start;
209
+ return total;
210
+ }
211
+
212
+ /**
213
+ * Executed bytes of one served script at the cut and by settle, from V8 ranges
214
+ * and the response body alone. Works for every chunk, with or without a matching
215
+ * local build - module attribution is what needs the build.
216
+ */
217
+ export function chunkCoverage({ bytes, atCut, atSettle }) {
218
+ const cut = atCut ? executedIntervals(coverageBounds(atCut)) : [];
219
+ const settle = atSettle ? executedIntervals(coverageBounds(atSettle)) : [];
220
+ const cutBytes = Math.min(bytes, unionLength(cut, []));
221
+ const settleBytes = Math.min(bytes, unionLength(cut, settle));
222
+ return { bytes, cutBytes, settleBytes, cutRatio: bytes ? cutBytes / bytes : 0 };
223
+ }
224
+
225
+ export const round = (v) => (typeof v === 'number' ? Math.round(v) : null);
@@ -0,0 +1,232 @@
1
+ // Browser access for field runs. The lab launches a throwaway headless Chrome;
2
+ // a deployed site behind a login needs the user's own browser instead, because
3
+ // copied cookies rarely survive device-bound sessions and bot checks. So field
4
+ // mode ATTACHES to a running Chrome over CDP and works in the browser context
5
+ // (profile) of a tab that already shows the site.
6
+ //
7
+ // Endpoint, in order: --cdp-url ws://..., --cdp-port N, or the DevToolsActivePort
8
+ // file that Chrome writes when remote debugging is on (chrome://inspect/#remote-debugging).
9
+
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+
14
+ import { connect } from '../cdp.mjs';
15
+
16
+ function activePortFiles() {
17
+ const home = os.homedir();
18
+ const dirs = [
19
+ process.env.CHROME_USER_DATA_DIR,
20
+ path.join(home, 'Library', 'Application Support', 'Google', 'Chrome'),
21
+ path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary'),
22
+ path.join(home, '.config', 'google-chrome'),
23
+ path.join(home, '.config', 'chromium'),
24
+ process.env.LOCALAPPDATA &&
25
+ path.join(process.env.LOCALAPPDATA, 'Google', 'Chrome', 'User Data'),
26
+ ].filter(Boolean);
27
+ return dirs.map((dir) => path.join(dir, 'DevToolsActivePort'));
28
+ }
29
+
30
+ export async function resolveEndpoint({ cdpUrl, cdpPort } = {}) {
31
+ if (cdpUrl) return cdpUrl;
32
+ if (cdpPort) {
33
+ const res = await fetch(`http://127.0.0.1:${cdpPort}/json/version`);
34
+ return (await res.json()).webSocketDebuggerUrl;
35
+ }
36
+ for (const file of activePortFiles()) {
37
+ if (!fs.existsSync(file)) continue;
38
+ const [port, wsPath] = fs.readFileSync(file, 'utf8').trim().split('\n');
39
+ if (port && wsPath) return `ws://127.0.0.1:${port}${wsPath}`;
40
+ }
41
+ throw new Error(
42
+ 'no running Chrome with remote debugging found. Turn it on at chrome://inspect/#remote-debugging,\n' +
43
+ 'or start Chrome with --remote-debugging-port=<n> and pass --cdp-port <n>.',
44
+ );
45
+ }
46
+
47
+ export async function attachBrowser(options) {
48
+ const wsUrl = await resolveEndpoint(options);
49
+ const cdp = await connect(wsUrl);
50
+ return { cdp, close: async () => cdp.close() };
51
+ }
52
+
53
+ /** The browser context of an open tab on `origin`: that profile holds the login. */
54
+ export async function contextForOrigin(cdp, origin) {
55
+ const { targetInfos } = await cdp.send('Target.getTargets');
56
+ const tab = targetInfos.find((t) => t.type === 'page' && t.url.startsWith(origin));
57
+ return tab ? { browserContextId: tab.browserContextId, found: true } : { found: false };
58
+ }
59
+
60
+ // --- cookies -------------------------------------------------------------------------
61
+ // Variant cookies change the whole profile, not only the measuring tab, so the
62
+ // original values are saved first and restored at the end (also on Ctrl-C).
63
+ // Values stay in memory only; nothing here is printed.
64
+
65
+ const domainMatches = (cookieDomain, host) => {
66
+ const d = cookieDomain.replace(/^\./, '');
67
+ return host === d || host.endsWith(`.${d}`);
68
+ };
69
+
70
+ export async function snapshotCookies(cdp, { browserContextId, origin, names }) {
71
+ const host = new URL(origin).hostname;
72
+ const { cookies } = await cdp.send(
73
+ 'Storage.getCookies',
74
+ browserContextId ? { browserContextId } : {},
75
+ );
76
+ const saved = new Map();
77
+ for (const name of names) {
78
+ saved.set(name, cookies.find((c) => c.name === name && domainMatches(c.domain, host)) ?? null);
79
+ }
80
+ return saved;
81
+ }
82
+
83
+ const cookieParam = (c) => ({
84
+ name: c.name,
85
+ value: c.value,
86
+ domain: c.domain,
87
+ path: c.path,
88
+ secure: c.secure,
89
+ httpOnly: c.httpOnly,
90
+ ...(c.sameSite ? { sameSite: c.sameSite } : {}),
91
+ ...(c.session ? {} : { expires: c.expires }),
92
+ });
93
+
94
+ /**
95
+ * Where a variant cookie lands: the config's domain/path, else those of the
96
+ * user's cookie of the same name. A null domain is a host-only cookie on the
97
+ * origin. CDP treats a domain without a leading dot as host-only too.
98
+ */
99
+ const variantScope = (cookie, existing) => ({
100
+ domain: cookie.domain ?? existing?.domain ?? null,
101
+ path: cookie.path ?? existing?.path ?? '/',
102
+ });
103
+
104
+ /**
105
+ * Put back each saved cookie. A variant cookie in another scope than the saved
106
+ * one (or a name the user did not have) is deleted with an expired cookie in the
107
+ * scope the variant used: `url` for host-only cookies, `domain` otherwise.
108
+ */
109
+ export async function restoreCookies(cdp, { browserContextId, origin, saved, configured = [] }) {
110
+ const host = new URL(origin).hostname;
111
+ const cookies = [];
112
+ for (const [name, original] of saved) {
113
+ if (original) cookies.push(cookieParam(original));
114
+ const scopes = new Map();
115
+ for (const c of configured.filter((x) => x.name === name)) {
116
+ const scope = variantScope(c, original);
117
+ scopes.set(`${scope.domain ?? host} ${scope.path}`, scope);
118
+ }
119
+ for (const [key, scope] of scopes) {
120
+ if (original && key === `${original.domain} ${original.path}`) continue;
121
+ cookies.push({
122
+ name,
123
+ value: '',
124
+ path: scope.path,
125
+ ...(scope.domain ? { domain: scope.domain } : { url: origin }),
126
+ expires: 1,
127
+ });
128
+ }
129
+ }
130
+ if (cookies.length) {
131
+ await cdp.send('Storage.setCookies', {
132
+ cookies,
133
+ ...(browserContextId ? { browserContextId } : {}),
134
+ });
135
+ }
136
+ }
137
+
138
+ // --- measuring tab ---------------------------------------------------------------------
139
+
140
+ /**
141
+ * A new window in the chosen profile, configured like the lab tab: cache off,
142
+ * service worker bypassed, blocked URLs, the variant's cookies, init scripts.
143
+ * A variant cookie reuses the domain/path of the user's existing cookie of the
144
+ * same name, so the profile never ends up with two cookies of that name.
145
+ * `configured` holds the cookies of all variants, for the reset to the saved state.
146
+ * `bodies` raises the response buffers so script bodies stay readable for
147
+ * build matching (diagnostic runs only).
148
+ */
149
+ export async function openFieldPage(
150
+ cdp,
151
+ {
152
+ browserContextId,
153
+ origin,
154
+ cookies = [],
155
+ saved = new Map(),
156
+ configured = [],
157
+ block = [],
158
+ scripts = [],
159
+ bodies = false,
160
+ throttle = null,
161
+ },
162
+ ) {
163
+ const { targetId } = await cdp.send('Target.createTarget', {
164
+ url: 'about:blank',
165
+ newWindow: true,
166
+ ...(browserContextId ? { browserContextId } : {}),
167
+ });
168
+ const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
169
+ // A covered or background window is "hidden", and Chrome stops reporting LCP
170
+ // for hidden pages. Bring the window to the front; runs still check visibility.
171
+ await cdp.send('Target.activateTarget', { targetId }).catch(() => {});
172
+ const send = (method, params) => cdp.send(method, params, sessionId);
173
+ await send('Page.enable');
174
+ await send('Runtime.enable');
175
+ await send(
176
+ 'Network.enable',
177
+ bodies ? { maxTotalBufferSize: 400_000_000, maxResourceBufferSize: 64_000_000 } : {},
178
+ );
179
+ await send('Network.setCacheDisabled', { cacheDisabled: true });
180
+ await send('Network.setBypassServiceWorker', { bypass: true }).catch(() => {});
181
+ if (block.length) {
182
+ await send('Network.setBlockedURLs', {
183
+ urls: block.map((p) => (p.includes('*') ? p : `*${p}*`)),
184
+ });
185
+ }
186
+ if (throttle) {
187
+ await send('Network.emulateNetworkConditions', {
188
+ offline: false,
189
+ latency: throttle.latencyMs,
190
+ downloadThroughput: throttle.downloadBps,
191
+ uploadThroughput: throttle.uploadBps,
192
+ });
193
+ await send('Emulation.setCPUThrottlingRate', { rate: throttle.cpuRate });
194
+ }
195
+ // Each run starts from the user's own cookies: a cookie that an earlier variant
196
+ // set must not stay for a variant that does not set it.
197
+ if (saved.size) await restoreCookies(cdp, { browserContextId, origin, saved, configured });
198
+ const secure = origin.startsWith('https:');
199
+ for (const cookie of cookies) {
200
+ const existing = saved.get(cookie.name);
201
+ const scope = variantScope(cookie, existing);
202
+ await send('Network.setCookie', {
203
+ url: origin,
204
+ secure: existing?.secure ?? secure,
205
+ ...cookie,
206
+ path: scope.path,
207
+ ...(scope.domain ? { domain: scope.domain } : {}),
208
+ });
209
+ }
210
+ for (const source of scripts) {
211
+ await send('Page.addScriptToEvaluateOnNewDocument', { source });
212
+ }
213
+ return {
214
+ send,
215
+ on: (method, handler) => cdp.on(method, handler, sessionId),
216
+ navigate: (url) => send('Page.navigate', { url }),
217
+ async evaluate(expression) {
218
+ const result = await send('Runtime.evaluate', {
219
+ expression,
220
+ returnByValue: true,
221
+ awaitPromise: true,
222
+ });
223
+ if (result.exceptionDetails) {
224
+ throw new Error(
225
+ `page evaluate threw: ${result.exceptionDetails.exception?.description ?? result.exceptionDetails.text}`,
226
+ );
227
+ }
228
+ return result.result.value;
229
+ },
230
+ close: () => cdp.send('Target.closeTarget', { targetId }),
231
+ };
232
+ }
@@ -0,0 +1,86 @@
1
+ // Match the scripts a deployed site served to the files of a local build, so a
2
+ // field run can use that build's sourcemaps and module graph. Production rarely
3
+ // serves its maps; a local build of the same commit with the same settings often
4
+ // emits byte-identical chunks. Identity is decided by content, never by name:
5
+ // - exact: same path, same SHA-256
6
+ // - renamed: another path in the build has the same SHA-256
7
+ // - debug-id: the served file names a //# debugId that a local map carries
8
+ // - different-build: the path exists locally with other content (wrong commit,
9
+ // settings or bundler version - the usual cause)
10
+ // - not-in-build: no local file has this path or content
11
+
12
+ import crypto from 'node:crypto';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+
16
+ const sha256 = (text) => crypto.createHash('sha256').update(text).digest('hex');
17
+ // A line of its own, often followed by the //# sourceMappingURL line.
18
+ const DEBUG_ID_RE = /^\/\/# debugId=([0-9a-fA-F-]{36})[ \t]*$/m;
19
+
20
+ function walk(dir, out = []) {
21
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
22
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
23
+ const full = path.join(dir, entry.name);
24
+ if (entry.isDirectory()) walk(full, out);
25
+ else if (/\.[mc]?js$/.test(entry.name)) out.push(full);
26
+ }
27
+ return out;
28
+ }
29
+
30
+ /** Hash every emitted script of a build once. Maps are read lazily, on a match. */
31
+ export function indexBuild(dir) {
32
+ const byPath = new Map();
33
+ const bySha = new Map();
34
+ const byDebugId = new Map();
35
+ for (const file of walk(dir)) {
36
+ const rel = path.relative(dir, file).replaceAll('\\', '/');
37
+ const code = fs.readFileSync(file, 'utf8');
38
+ const sha = sha256(code);
39
+ byPath.set(rel, sha);
40
+ if (!bySha.has(sha)) bySha.set(sha, rel);
41
+ const debugId = code.match(DEBUG_ID_RE)?.[1]?.toLowerCase();
42
+ if (debugId) byDebugId.set(debugId, rel);
43
+ }
44
+ return { dir, byPath, bySha, byDebugId };
45
+ }
46
+
47
+ /** Decide which local file (if any) a served script is. `file` has no leading slash. */
48
+ export function matchScript(index, { file, body }) {
49
+ if (!index) return { status: 'no-build' };
50
+ const sha = sha256(body);
51
+ if (index.byPath.get(file) === sha) return { status: 'exact', local: file };
52
+ const renamed = index.bySha.get(sha);
53
+ if (renamed) return { status: 'renamed', local: renamed };
54
+ const debugId = body.match(DEBUG_ID_RE)?.[1]?.toLowerCase();
55
+ if (debugId && index.byDebugId.has(debugId)) {
56
+ return { status: 'debug-id', local: index.byDebugId.get(debugId) };
57
+ }
58
+ return { status: index.byPath.has(file) ? 'different-build' : 'not-in-build' };
59
+ }
60
+
61
+ /** Code + sourcemap of a matched local file, in the shape attributeChunks expects. */
62
+ export function readMatched(index, match) {
63
+ if (!match?.local) return null;
64
+ const file = path.join(index.dir, match.local);
65
+ const mapFile = `${file}.map`;
66
+ if (!fs.existsSync(mapFile)) return null;
67
+ return {
68
+ code: fs.readFileSync(file, 'utf8'),
69
+ map: JSON.parse(fs.readFileSync(mapFile, 'utf8')),
70
+ };
71
+ }
72
+
73
+ /** Counts and bytes per match status - how much of the page the build explains. */
74
+ export function matchSummary(matches) {
75
+ const summary = { scripts: 0, bytes: 0, matchedScripts: 0, matchedBytes: 0, byStatus: {} };
76
+ for (const { status, bytes } of matches) {
77
+ summary.scripts += 1;
78
+ summary.bytes += bytes;
79
+ summary.byStatus[status] = (summary.byStatus[status] ?? 0) + 1;
80
+ if (status === 'exact' || status === 'renamed' || status === 'debug-id') {
81
+ summary.matchedScripts += 1;
82
+ summary.matchedBytes += bytes;
83
+ }
84
+ }
85
+ return summary;
86
+ }