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
package/lib/build.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Builds the demo app with the repo's rolldown (packages/rolldown/dist), devtools
|
|
2
|
+
// report mode on, so every build also refreshes the build-side metrics report
|
|
3
|
+
// (entry bytes, initial-load bytes, delta/baselineDelta) next to the runtime numbers.
|
|
4
|
+
// The report lands in the app's node_modules/.rolldown/report/dist.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
9
|
+
|
|
10
|
+
import { reportDirFor } from './module-graph.mjs';
|
|
11
|
+
|
|
12
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
|
13
|
+
const ROLLDOWN_DIST =
|
|
14
|
+
process.env.ROLLDOWN_DIST ?? path.join(REPO_ROOT, 'packages', 'rolldown', 'dist', 'index.mjs');
|
|
15
|
+
|
|
16
|
+
export async function buildApp({ appDir }) {
|
|
17
|
+
if (!fs.existsSync(ROLLDOWN_DIST)) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`rolldown dist not found at ${ROLLDOWN_DIST} - run \`just build-rolldown\` first (or set ROLLDOWN_DIST).`,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
const { build } = await import(pathToFileURL(ROLLDOWN_DIST).href);
|
|
23
|
+
const distDir = path.join(appDir, 'dist');
|
|
24
|
+
fs.rmSync(distDir, { recursive: true, force: true });
|
|
25
|
+
const started = Date.now();
|
|
26
|
+
await build({
|
|
27
|
+
cwd: appDir,
|
|
28
|
+
input: { main: './src/main.ts' },
|
|
29
|
+
devtools: { mode: 'report' },
|
|
30
|
+
output: {
|
|
31
|
+
dir: 'dist',
|
|
32
|
+
format: 'esm',
|
|
33
|
+
sourcemap: true,
|
|
34
|
+
minify: false, // keep offsets readable; relative deltas match minified builds
|
|
35
|
+
entryFileNames: '[name].js',
|
|
36
|
+
chunkFileNames: 'chunks/[name]-[hash].js',
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
fs.copyFileSync(path.join(appDir, 'index.html'), path.join(distDir, 'index.html'));
|
|
40
|
+
|
|
41
|
+
const entryBytes = fs.statSync(path.join(distDir, 'main.js')).size;
|
|
42
|
+
const chunksDir = path.join(distDir, 'chunks');
|
|
43
|
+
const chunks = fs.existsSync(chunksDir)
|
|
44
|
+
? fs
|
|
45
|
+
.readdirSync(chunksDir)
|
|
46
|
+
.filter((f) => f.endsWith('.js'))
|
|
47
|
+
.map((f) => ({ file: `chunks/${f}`, bytes: fs.statSync(path.join(chunksDir, f)).size }))
|
|
48
|
+
: [];
|
|
49
|
+
let buildMetrics = null;
|
|
50
|
+
try {
|
|
51
|
+
const reportDir = reportDirFor(appDir, 'dist');
|
|
52
|
+
buildMetrics = JSON.parse(fs.readFileSync(path.join(reportDir, 'metrics.json'), 'utf8'));
|
|
53
|
+
} catch {
|
|
54
|
+
// metrics report is best-effort; the harness still works without it
|
|
55
|
+
}
|
|
56
|
+
return { distDir, entryBytes, chunks, buildMetrics, wallMs: Date.now() - started };
|
|
57
|
+
}
|
package/lib/cdp.mjs
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Minimal Chrome DevTools Protocol client over Node's built-in WebSocket (Node >= 22).
|
|
2
|
+
// No dependencies: launches headless Chrome/Edge with a throwaway profile, parses the
|
|
3
|
+
// "DevTools listening on ws://..." line, and exposes flat-protocol page sessions.
|
|
4
|
+
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
const CHROME_CANDIDATES = [
|
|
10
|
+
process.env.CHROME_PATH,
|
|
11
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
12
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
13
|
+
process.env.LOCALAPPDATA &&
|
|
14
|
+
path.join(process.env.LOCALAPPDATA, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
15
|
+
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
16
|
+
'/usr/bin/google-chrome',
|
|
17
|
+
'/usr/bin/chromium',
|
|
18
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
19
|
+
].filter(Boolean);
|
|
20
|
+
|
|
21
|
+
export function findChrome() {
|
|
22
|
+
for (const candidate of CHROME_CANDIDATES) {
|
|
23
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
24
|
+
}
|
|
25
|
+
throw new Error('No Chrome/Edge executable found. Set CHROME_PATH to a Chromium binary.');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** `extraArgs`: more Chrome switches (the replay maps every host to its server). */
|
|
29
|
+
export async function launchBrowser({ profileDir, extraArgs = [] }) {
|
|
30
|
+
if (typeof WebSocket === 'undefined') {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`This harness needs Node >= 22 (global WebSocket); running ${process.version}.`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
const exe = findChrome();
|
|
36
|
+
fs.rmSync(profileDir, { recursive: true, force: true });
|
|
37
|
+
fs.mkdirSync(profileDir, { recursive: true });
|
|
38
|
+
const child = spawn(
|
|
39
|
+
exe,
|
|
40
|
+
[
|
|
41
|
+
'--headless=new',
|
|
42
|
+
'--remote-debugging-port=0',
|
|
43
|
+
`--user-data-dir=${profileDir}`,
|
|
44
|
+
'--no-first-run',
|
|
45
|
+
'--no-default-browser-check',
|
|
46
|
+
'--disable-background-networking',
|
|
47
|
+
'--disable-component-update',
|
|
48
|
+
'--disable-sync',
|
|
49
|
+
'--disable-default-apps',
|
|
50
|
+
'--mute-audio',
|
|
51
|
+
'--window-size=1280,900',
|
|
52
|
+
...extraArgs,
|
|
53
|
+
'about:blank',
|
|
54
|
+
],
|
|
55
|
+
{ stdio: ['ignore', 'ignore', 'pipe'] },
|
|
56
|
+
);
|
|
57
|
+
// Registered before anything else can run, so close() never waits for an exit
|
|
58
|
+
// that already happened.
|
|
59
|
+
const exited = new Promise((resolve) => child.once('exit', resolve));
|
|
60
|
+
|
|
61
|
+
const wsUrl = await new Promise((resolve, reject) => {
|
|
62
|
+
let stderr = '';
|
|
63
|
+
const timer = setTimeout(() => {
|
|
64
|
+
cleanup();
|
|
65
|
+
child.kill();
|
|
66
|
+
reject(new Error(`Chrome did not announce a DevTools endpoint within 20s.\n${stderr}`));
|
|
67
|
+
}, 20_000);
|
|
68
|
+
const onData = (chunk) => {
|
|
69
|
+
stderr += chunk;
|
|
70
|
+
const match = stderr.match(/DevTools listening on (ws:\/\/\S+)/);
|
|
71
|
+
if (match) {
|
|
72
|
+
cleanup();
|
|
73
|
+
resolve(match[1]);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const onExit = (code) => {
|
|
77
|
+
cleanup();
|
|
78
|
+
reject(
|
|
79
|
+
new Error(`Chrome exited before announcing DevTools endpoint (code ${code}).\n${stderr}`),
|
|
80
|
+
);
|
|
81
|
+
};
|
|
82
|
+
const cleanup = () => {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
child.stderr.off('data', onData);
|
|
85
|
+
child.off('exit', onExit);
|
|
86
|
+
};
|
|
87
|
+
child.stderr.setEncoding('utf8');
|
|
88
|
+
child.stderr.on('data', onData);
|
|
89
|
+
child.on('exit', onExit);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const cdp = await connect(wsUrl);
|
|
93
|
+
return {
|
|
94
|
+
cdp,
|
|
95
|
+
pid: child.pid,
|
|
96
|
+
/**
|
|
97
|
+
* Resolves only after the Chrome process has exited. Chrome keeps writing its
|
|
98
|
+
* profile directory while it shuts down; removing that directory before the exit
|
|
99
|
+
* fails with ENOTEMPTY. SIGTERM lets it flush; a browser that is still alive
|
|
100
|
+
* after 5 s gets SIGKILL, and close() still waits for the exit.
|
|
101
|
+
*/
|
|
102
|
+
async close() {
|
|
103
|
+
try {
|
|
104
|
+
cdp.close();
|
|
105
|
+
} catch {
|
|
106
|
+
/* already closed */
|
|
107
|
+
}
|
|
108
|
+
if (child.exitCode === null && child.signalCode === null) child.kill();
|
|
109
|
+
const forceKill = setTimeout(() => child.kill('SIGKILL'), 5_000);
|
|
110
|
+
await exited;
|
|
111
|
+
clearTimeout(forceKill);
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Flat JSON-RPC client for a DevTools endpoint. Exported because Node's inspector
|
|
118
|
+
* speaks the same protocol on the same transport — `lib/node/launch.mjs` attaches
|
|
119
|
+
* to `Debugger listening on ws://…` with this exact client, minus the page domains.
|
|
120
|
+
*/
|
|
121
|
+
export function connect(wsUrl) {
|
|
122
|
+
return new Promise((resolve, reject) => {
|
|
123
|
+
const ws = new WebSocket(wsUrl);
|
|
124
|
+
let nextId = 1;
|
|
125
|
+
const pending = new Map();
|
|
126
|
+
const listeners = new Map(); // `${sessionId}:${method}` -> handlers
|
|
127
|
+
ws.addEventListener('open', () => resolve(api));
|
|
128
|
+
ws.addEventListener('error', () => reject(new Error(`WebSocket connection failed: ${wsUrl}`)));
|
|
129
|
+
ws.addEventListener('message', (event) => {
|
|
130
|
+
const msg = JSON.parse(event.data);
|
|
131
|
+
if (msg.id === undefined) {
|
|
132
|
+
const key = `${msg.sessionId ?? ''}:${msg.method}`;
|
|
133
|
+
for (const handler of listeners.get(key) ?? []) handler(msg.params);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const entry = pending.get(msg.id);
|
|
137
|
+
if (!entry) return;
|
|
138
|
+
pending.delete(msg.id);
|
|
139
|
+
if (msg.error) entry.reject(new Error(`${entry.method}: ${msg.error.message}`));
|
|
140
|
+
else entry.resolve(msg.result);
|
|
141
|
+
});
|
|
142
|
+
const api = {
|
|
143
|
+
send(method, params = {}, sessionId) {
|
|
144
|
+
return new Promise((res, rej) => {
|
|
145
|
+
const id = nextId++;
|
|
146
|
+
pending.set(id, { resolve: res, reject: rej, method });
|
|
147
|
+
ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
|
|
148
|
+
});
|
|
149
|
+
},
|
|
150
|
+
on(method, handler, sessionId = '') {
|
|
151
|
+
const key = `${sessionId}:${method}`;
|
|
152
|
+
if (!listeners.has(key)) listeners.set(key, []);
|
|
153
|
+
const handlers = listeners.get(key);
|
|
154
|
+
handlers.push(handler);
|
|
155
|
+
return () => {
|
|
156
|
+
const index = handlers.indexOf(handler);
|
|
157
|
+
if (index === -1) return;
|
|
158
|
+
handlers.splice(index, 1);
|
|
159
|
+
if (!handlers.length) listeners.delete(key);
|
|
160
|
+
};
|
|
161
|
+
},
|
|
162
|
+
close() {
|
|
163
|
+
ws.close();
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Fresh tab configured for a measurement: cache off, optional throttle, optional init script. */
|
|
170
|
+
export async function openPage(cdp, { throttle, injectScript } = {}) {
|
|
171
|
+
const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' });
|
|
172
|
+
const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
|
|
173
|
+
const send = (method, params) => cdp.send(method, params, sessionId);
|
|
174
|
+
await send('Page.enable');
|
|
175
|
+
await send('Runtime.enable');
|
|
176
|
+
await send('Network.enable');
|
|
177
|
+
await send('Network.setCacheDisabled', { cacheDisabled: true });
|
|
178
|
+
// PWAs install a service worker on the first run; without this, later runs are
|
|
179
|
+
// served from SW cache (setCacheDisabled does not cover it) and measure faster
|
|
180
|
+
// than a cold visitor ever would.
|
|
181
|
+
await send('Network.setBypassServiceWorker', { bypass: true }).catch(() => {});
|
|
182
|
+
if (throttle) {
|
|
183
|
+
await send('Network.emulateNetworkConditions', {
|
|
184
|
+
offline: false,
|
|
185
|
+
latency: throttle.latencyMs,
|
|
186
|
+
downloadThroughput: throttle.downloadBps,
|
|
187
|
+
uploadThroughput: throttle.uploadBps,
|
|
188
|
+
});
|
|
189
|
+
await send('Emulation.setCPUThrottlingRate', { rate: throttle.cpuRate });
|
|
190
|
+
}
|
|
191
|
+
if (injectScript) {
|
|
192
|
+
await send('Page.addScriptToEvaluateOnNewDocument', { source: injectScript });
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
send,
|
|
196
|
+
on: (method, handler) => cdp.on(method, handler, sessionId),
|
|
197
|
+
navigate: (url) => send('Page.navigate', { url }),
|
|
198
|
+
async evaluate(expression) {
|
|
199
|
+
const result = await send('Runtime.evaluate', {
|
|
200
|
+
expression,
|
|
201
|
+
returnByValue: true,
|
|
202
|
+
awaitPromise: true,
|
|
203
|
+
});
|
|
204
|
+
if (result.exceptionDetails) {
|
|
205
|
+
const detail =
|
|
206
|
+
result.exceptionDetails.exception?.description ??
|
|
207
|
+
result.exceptionDetails.text ??
|
|
208
|
+
'unknown error';
|
|
209
|
+
throw new Error(`page evaluate threw: ${detail}`);
|
|
210
|
+
}
|
|
211
|
+
return result.result.value;
|
|
212
|
+
},
|
|
213
|
+
close: () => cdp.send('Target.closeTarget', { targetId }),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// Join emitted static chunk imports to the runtime waterfall. A discovery delay
|
|
2
|
+
// is a lead to test, not a predicted LCP gain or proof that paint awaits a chunk.
|
|
3
|
+
// The waterfall is a lab scan's Resource Timing list, or the CDP requests of field
|
|
4
|
+
// and replay runs (requestSamples).
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { median } from './measure.mjs';
|
|
9
|
+
|
|
10
|
+
/** Prefer a current emitted graph; Vite's manifest supplies the same static edges. */
|
|
11
|
+
export function loadChunkGraph(dist, candidates = []) {
|
|
12
|
+
const manifestFile = path.join(dist, '.vite', 'manifest.json');
|
|
13
|
+
const manifestMtime = fs.existsSync(manifestFile) ? fs.statSync(manifestFile).mtimeMs : 0;
|
|
14
|
+
for (const file of candidates) {
|
|
15
|
+
if (!fs.existsSync(file)) continue;
|
|
16
|
+
const graphMtime = fs.statSync(file).mtimeMs;
|
|
17
|
+
if (graphMtime < manifestMtime) continue;
|
|
18
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
19
|
+
if (data.schemaVersion !== 1 || !Array.isArray(data.chunks) || !data.chunks.length) continue;
|
|
20
|
+
const files = new Map();
|
|
21
|
+
for (const chunk of data.chunks) {
|
|
22
|
+
// Metrics filenames may include the build's relative output directory.
|
|
23
|
+
// Match a path suffix only when it names a real file inside this dist.
|
|
24
|
+
const parts = chunk.fileName.replaceAll('\\', '/').split('/');
|
|
25
|
+
for (let i = 0; i < parts.length; i++) {
|
|
26
|
+
const name = parts.slice(i).join('/');
|
|
27
|
+
const resolved = path.resolve(dist, name);
|
|
28
|
+
if (!resolved.startsWith(`${path.resolve(dist)}${path.sep}`)) continue;
|
|
29
|
+
const stat = fs.existsSync(resolved) ? fs.statSync(resolved) : null;
|
|
30
|
+
if (stat?.isFile() && stat.mtimeMs <= graphMtime) {
|
|
31
|
+
files.set(chunk.fileName, name);
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Rebuilds can reuse filenames. The graph must also follow the emitted files
|
|
37
|
+
// and the manifest so old imports cannot produce a false all-clear.
|
|
38
|
+
if (files.size !== data.chunks.length) continue;
|
|
39
|
+
return {
|
|
40
|
+
file,
|
|
41
|
+
chunks: new Map(
|
|
42
|
+
data.chunks.map((chunk) => [
|
|
43
|
+
files.get(chunk.fileName),
|
|
44
|
+
{
|
|
45
|
+
facadeModuleId: chunk.facadeModuleId ?? null,
|
|
46
|
+
imports: (chunk.imports ?? []).map((name) => files.get(name)).filter(Boolean),
|
|
47
|
+
},
|
|
48
|
+
]),
|
|
49
|
+
),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const file = manifestFile;
|
|
53
|
+
if (!fs.existsSync(file)) return null;
|
|
54
|
+
const manifest = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
55
|
+
const rows = Object.values(manifest).filter((chunk) => /\.[cm]?js$/.test(chunk.file ?? ''));
|
|
56
|
+
if (
|
|
57
|
+
!rows.length ||
|
|
58
|
+
rows.some((chunk) => {
|
|
59
|
+
const file = path.join(dist, chunk.file);
|
|
60
|
+
const stat = fs.existsSync(file) ? fs.statSync(file) : null;
|
|
61
|
+
return !stat?.isFile() || stat.mtimeMs > manifestMtime;
|
|
62
|
+
})
|
|
63
|
+
)
|
|
64
|
+
return null;
|
|
65
|
+
return {
|
|
66
|
+
file,
|
|
67
|
+
chunks: new Map(
|
|
68
|
+
rows.map((chunk) => [
|
|
69
|
+
chunk.file,
|
|
70
|
+
{
|
|
71
|
+
facadeModuleId: chunk.src ?? null,
|
|
72
|
+
imports: (chunk.imports ?? []).map((key) => manifest[key]?.file).filter(Boolean),
|
|
73
|
+
},
|
|
74
|
+
]),
|
|
75
|
+
),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Keep one shortest static import chain per descendant, excluding cycles. */
|
|
80
|
+
function staticDescendants(graph, root) {
|
|
81
|
+
const paths = new Map([[root, [root]]]);
|
|
82
|
+
const queue = [root];
|
|
83
|
+
for (let i = 0; i < queue.length; i++) {
|
|
84
|
+
const parent = queue[i];
|
|
85
|
+
for (const file of graph.chunks.get(parent)?.imports ?? []) {
|
|
86
|
+
if (paths.has(file)) continue;
|
|
87
|
+
paths.set(file, [...paths.get(parent), file]);
|
|
88
|
+
queue.push(file);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
paths.delete(root);
|
|
92
|
+
return paths;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Field and replay runs as discovery samples. Their CDP request rows (page time,
|
|
97
|
+
* every request of the page) stand in for the Resource Timing list, which field
|
|
98
|
+
* runs do not keep. `redact` cleans initiator URLs: a document URL can name an account.
|
|
99
|
+
*/
|
|
100
|
+
export function requestSamples(runs, origin, redact = (value) => value) {
|
|
101
|
+
const local = (url) => (url?.startsWith(`${origin}/`) ? url.slice(origin.length) : url);
|
|
102
|
+
return runs.map((run) => ({
|
|
103
|
+
fcp: run.fcp ?? null,
|
|
104
|
+
lcp: run.lcp ?? null,
|
|
105
|
+
resourceDetailsComplete: true,
|
|
106
|
+
resources: (run.requests ?? []).map((r) => ({
|
|
107
|
+
name: local(r.url),
|
|
108
|
+
type: String(r.type ?? 'other').toLowerCase(),
|
|
109
|
+
start: r.startMs ?? 0,
|
|
110
|
+
// end 0 = unfinished or failed, as in the Resource Timing list.
|
|
111
|
+
end: r.failed || typeof r.endMs !== 'number' ? 0 : r.endMs,
|
|
112
|
+
bytes: r.encodedBytes ?? 0,
|
|
113
|
+
initiator: { type: r.initiator?.type ?? null, url: redact(local(r.initiator?.url ?? null)) },
|
|
114
|
+
requestPriority: { initial: r.priority ?? null, last: r.lastPriority ?? r.priority ?? null },
|
|
115
|
+
})),
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function analyzeChunkDiscovery(graph, samples, latencyMs) {
|
|
120
|
+
return Object.fromEntries(
|
|
121
|
+
['fcp', 'lcp'].map((paint) => [paint, analyzePaintDiscovery(graph, samples, latencyMs, paint)]),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function analyzePaintDiscovery(graph, samples, latencyMs, paint) {
|
|
126
|
+
const complete = samples.length > 0 && samples.every((s) => s.resourceDetailsComplete === true);
|
|
127
|
+
if (!graph) return { state: 'unknown', reason: 'no chunk graph or Vite manifest', groups: [] };
|
|
128
|
+
if (typeof latencyMs !== 'number' || latencyMs <= 0) {
|
|
129
|
+
return {
|
|
130
|
+
state: 'unknown',
|
|
131
|
+
reason: 'no throttled RTT reference',
|
|
132
|
+
graphFile: graph.file,
|
|
133
|
+
groups: [],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const groups = new Map();
|
|
137
|
+
const matchedCounts = [];
|
|
138
|
+
let paintedRuns = 0;
|
|
139
|
+
for (const sample of samples) {
|
|
140
|
+
if (typeof sample[paint] !== 'number') continue;
|
|
141
|
+
paintedRuns++;
|
|
142
|
+
const requests = new Map();
|
|
143
|
+
for (const resource of sample.resources ?? []) {
|
|
144
|
+
// Same-origin names are relative. Cross-origin scripts can have identical
|
|
145
|
+
// basenames but do not belong to this build's graph.
|
|
146
|
+
if (resource.type !== 'script' || !resource.name.startsWith('/') || resource.end <= 0)
|
|
147
|
+
continue;
|
|
148
|
+
let file;
|
|
149
|
+
try {
|
|
150
|
+
file = decodeURIComponent(resource.name.split(/[?#]/)[0].slice(1));
|
|
151
|
+
} catch {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (!graph.chunks.has(file)) continue;
|
|
155
|
+
const previous = requests.get(file);
|
|
156
|
+
if (!previous || resource.start < previous.start) requests.set(file, resource);
|
|
157
|
+
}
|
|
158
|
+
matchedCounts.push(requests.size);
|
|
159
|
+
for (const [parent, request] of requests) {
|
|
160
|
+
if (request.end > sample[paint]) continue;
|
|
161
|
+
const late = [];
|
|
162
|
+
for (const [file, importChain] of staticDescendants(graph, parent)) {
|
|
163
|
+
const child = requests.get(file);
|
|
164
|
+
if (!child || child.end > sample[paint] || child.start - request.end <= latencyMs) continue;
|
|
165
|
+
late.push({
|
|
166
|
+
file,
|
|
167
|
+
facadeModuleId: graph.chunks.get(file)?.facadeModuleId ?? null,
|
|
168
|
+
importChain,
|
|
169
|
+
startMs: child.start,
|
|
170
|
+
endMs: child.end,
|
|
171
|
+
delayMs: child.start - request.end,
|
|
172
|
+
bytes: child.bytes,
|
|
173
|
+
initiator: child.initiator ?? null,
|
|
174
|
+
requestPriority: child.requestPriority ?? null,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
if (!late.length) continue;
|
|
178
|
+
late.sort((a, b) => b.bytes - a.bytes || a.file.localeCompare(b.file));
|
|
179
|
+
if (!groups.has(parent)) groups.set(parent, []);
|
|
180
|
+
groups.get(parent).push({
|
|
181
|
+
paintMs: sample[paint],
|
|
182
|
+
parentEndMs: request.end,
|
|
183
|
+
bytes: late.reduce((sum, r) => sum + r.bytes, 0),
|
|
184
|
+
count: late.length,
|
|
185
|
+
maxDelayMs: Math.max(...late.map((r) => r.delayMs)),
|
|
186
|
+
chunks: late,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const rows = [...groups]
|
|
191
|
+
.map(([parent, runs]) => {
|
|
192
|
+
const sorted = runs.slice().sort((a, b) => a.bytes - b.bytes);
|
|
193
|
+
return {
|
|
194
|
+
parent,
|
|
195
|
+
facadeModuleId: graph.chunks.get(parent)?.facadeModuleId ?? null,
|
|
196
|
+
paintDependency: 'unverified',
|
|
197
|
+
runsAffected: runs.length,
|
|
198
|
+
measuredRuns: paintedRuns,
|
|
199
|
+
bytes: median(runs.map((r) => r.bytes)),
|
|
200
|
+
count: median(runs.map((r) => r.count)),
|
|
201
|
+
maxDelayMs: median(runs.map((r) => r.maxDelayMs)),
|
|
202
|
+
// Keep an actual run's edges and timings; do not invent a median waterfall.
|
|
203
|
+
example: sorted[Math.floor(sorted.length / 2)],
|
|
204
|
+
};
|
|
205
|
+
})
|
|
206
|
+
.sort(
|
|
207
|
+
(a, b) =>
|
|
208
|
+
b.bytes - a.bytes || b.maxDelayMs - a.maxDelayMs || a.parent.localeCompare(b.parent),
|
|
209
|
+
);
|
|
210
|
+
const matchedScripts = median(matchedCounts) ?? 0;
|
|
211
|
+
const reason = !complete
|
|
212
|
+
? 'resource details missing or browser timing buffer overflow'
|
|
213
|
+
: paintedRuns !== samples.length
|
|
214
|
+
? `${paint.toUpperCase()} missing in one or more runs`
|
|
215
|
+
: matchedCounts.some((count) => count === 0)
|
|
216
|
+
? 'no runtime scripts matched the chunk graph in one or more runs'
|
|
217
|
+
: null;
|
|
218
|
+
return {
|
|
219
|
+
state: reason ? 'unknown' : rows.length ? 'open' : 'clear',
|
|
220
|
+
reason,
|
|
221
|
+
graphFile: graph.file,
|
|
222
|
+
latencyMs,
|
|
223
|
+
matchedScripts,
|
|
224
|
+
groups: rows,
|
|
225
|
+
};
|
|
226
|
+
}
|