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,161 @@
1
+ // The app's rolldown must carry the devtools report mode, or no build writes the report
2
+ // and graph / what-if / cut / why / graph-diff have nothing to read. The npm release does
3
+ // not carry it yet; this checkout's packages/rolldown does. Two things live here: the probe
4
+ // that names the app's rolldown and whether it has the mode, and the install that copies
5
+ // the checkout's build over the copy the app's vite resolves, whatever the package manager
6
+ // laid out (bun and npm hoist a real directory, pnpm symlinks into its store).
7
+
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { createRequire } from 'node:module';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ const LAB_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
14
+ // The sibling package is this checkout's packages/rolldown only when the lab runs from the
15
+ // repo. An installed copy sits under node_modules, where the sibling is the app's own release.
16
+ export const CHECKOUT_ROLLDOWN = /[\\/]node_modules[\\/]/.test(LAB_DIR)
17
+ ? null
18
+ : path.resolve(LAB_DIR, '..', 'rolldown');
19
+ // What a built package carries (the npm tarball leaves dist/*.node out; the install keeps it,
20
+ // because the loader tries dist/rolldown-binding.<platform>.node before the binding package).
21
+ const PACKAGE_PARTS = ['dist', 'bin', 'cli', 'package.json', 'LICENSE'];
22
+
23
+ const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
24
+ const REPORT_MODE = /mode\?:\s*['"]full['"]\s*\|\s*['"]report['"]/;
25
+
26
+ /** True when a package's type declarations offer `mode: 'report'` for devtools. */
27
+ export function hasReportMode(pkgDir) {
28
+ const dist = path.join(pkgDir, 'dist');
29
+ if (!fs.existsSync(dist)) return false;
30
+ const stack = [dist];
31
+ while (stack.length) {
32
+ const dir = stack.pop();
33
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
34
+ const file = path.join(dir, entry.name);
35
+ if (entry.isDirectory()) stack.push(file);
36
+ else if (/\.d\.[cm]?ts$/.test(entry.name) && REPORT_MODE.test(fs.readFileSync(file, 'utf8')))
37
+ return true;
38
+ }
39
+ }
40
+ return false;
41
+ }
42
+
43
+ /**
44
+ * The rolldown package the app's vite resolves (the app's own when vite is absent):
45
+ * `{ dir, version, viteVersion, reportMode }`; `dir` is null when none resolves.
46
+ */
47
+ export function appRolldown(appDir) {
48
+ const fromApp = createRequire(path.join(appDir, 'package.json'));
49
+ let viteDir = null;
50
+ let viteVersion = null;
51
+ try {
52
+ const vitePkg = fromApp.resolve('vite/package.json');
53
+ viteDir = path.dirname(fs.realpathSync(vitePkg));
54
+ viteVersion = readJson(vitePkg).version;
55
+ } catch {
56
+ /* no vite: resolve from the app itself */
57
+ }
58
+ let pkgFile = null;
59
+ for (const from of [viteDir, appDir]) {
60
+ if (!from) continue;
61
+ try {
62
+ pkgFile = createRequire(path.join(from, 'package.json')).resolve('rolldown/package.json');
63
+ break;
64
+ } catch {
65
+ /* try the next base */
66
+ }
67
+ }
68
+ if (!pkgFile) return { dir: null, version: null, viteVersion, reportMode: false };
69
+ const dir = path.dirname(fs.realpathSync(pkgFile));
70
+ return { dir, version: readJson(pkgFile).version, viteVersion, reportMode: hasReportMode(dir) };
71
+ }
72
+
73
+ /** This checkout's packages/rolldown: `{ dir, version, built, binding, reportMode }`, or null when none (or `dir` is null). */
74
+ export function checkoutRolldown(dir = CHECKOUT_ROLLDOWN) {
75
+ if (!dir) return null;
76
+ const pkgFile = path.join(dir, 'package.json');
77
+ if (!fs.existsSync(pkgFile)) return null;
78
+ const dist = path.join(dir, 'dist');
79
+ const built = fs.existsSync(path.join(dist, 'index.mjs'));
80
+ const binding = built
81
+ ? (fs.readdirSync(dist).find((f) => /^rolldown-binding\..+\.node$/.test(f)) ?? null)
82
+ : null;
83
+ return {
84
+ dir,
85
+ version: readJson(pkgFile).version,
86
+ built,
87
+ binding,
88
+ reportMode: built && hasReportMode(dir),
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Why no build of this app writes the report, with the fix, or null when its rolldown has
94
+ * the report mode. `cli` is how to spell the harness in the hint.
95
+ */
96
+ export function reportSupportNote(appDir, cli, { checkout = CHECKOUT_ROLLDOWN } = {}) {
97
+ const app = appRolldown(appDir);
98
+ if (app.reportMode) return null;
99
+ const rel = path.relative(process.cwd(), appDir);
100
+ const appRel = !rel ? '.' : rel.startsWith('..') ? appDir : rel;
101
+ if (!app.dir) {
102
+ return `no rolldown package resolves from ${appDir} - install the app's dependencies first (vite >= 8 brings rolldown).`;
103
+ }
104
+ const src = checkoutRolldown(checkout);
105
+ const lines = [
106
+ `rolldown ${app.version} at ${app.dir} has no devtools report mode, so no build of this app writes the report`,
107
+ '(the npm release does not carry it yet; graph / what-if / cut / why / graph-diff need it).',
108
+ ];
109
+ if (src?.built && src.binding) {
110
+ lines.push(
111
+ `Install the build next to the lab (rolldown ${src.version}) over it, then rebuild:`,
112
+ ` ${cli} rolldown-install --app ${appRel}`,
113
+ '(`<package manager> install --force` in the app restores the release later.)',
114
+ );
115
+ } else if (src) {
116
+ lines.push(
117
+ `The rolldown package next to the lab, ${src.dir}, is not built: run \`just build-rolldown-release\` there,`,
118
+ `then \`${cli} rolldown-install --app ${appRel}\` copies it over the app's copy.`,
119
+ );
120
+ } else {
121
+ lines.push(
122
+ 'No rolldown package sits next to the lab. Build one in a rolldown checkout (`just build-rolldown-release`',
123
+ `writes packages/rolldown), then \`${cli} rolldown-install --app ${appRel} --from <that packages/rolldown>\`.`,
124
+ );
125
+ }
126
+ return lines.join('\n');
127
+ }
128
+
129
+ /**
130
+ * Copy the checkout's built package over the copy the app's vite resolves. Each part is
131
+ * removed before the copy: pnpm hard-links its store, and a write in place would change
132
+ * every project that shares the file. Returns `{ from, dir, before, after }`.
133
+ */
134
+ export function installCheckoutRolldown(appDir, { checkout = CHECKOUT_ROLLDOWN } = {}) {
135
+ const app = appRolldown(appDir);
136
+ if (!app.dir)
137
+ throw new Error(
138
+ `no rolldown package resolves from ${appDir} - install the app's dependencies first.`,
139
+ );
140
+ const src = checkoutRolldown(checkout);
141
+ if (!src) {
142
+ throw new Error(
143
+ checkout
144
+ ? `no rolldown package at ${checkout}`
145
+ : 'no rolldown package sits next to the lab: pass --from <a built packages/rolldown>.',
146
+ );
147
+ }
148
+ if (!src.built || !src.binding) {
149
+ throw new Error(
150
+ `${src.dir} is not built (dist/index.mjs and dist/rolldown-binding.*.node): run \`just build-rolldown-release\` in the checkout first.`,
151
+ );
152
+ }
153
+ for (const part of PACKAGE_PARTS) {
154
+ const from = path.join(src.dir, part);
155
+ if (!fs.existsSync(from)) continue;
156
+ const to = path.join(app.dir, part);
157
+ fs.rmSync(to, { recursive: true, force: true });
158
+ fs.cpSync(from, to, { recursive: true, dereference: true });
159
+ }
160
+ return { from: src, dir: app.dir, before: app.version, after: appRolldown(appDir) };
161
+ }
package/lib/serve.mjs ADDED
@@ -0,0 +1,142 @@
1
+ // Static file server for the built app. Port 0 = ephemeral (measure/coverage runs);
2
+ // `/blank.html` is synthesized so coverage can arm the profiler on a same-origin page
3
+ // before index.html loads (keeps the renderer process, and thus V8 coverage, alive).
4
+
5
+ import http from 'node:http';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import zlib from 'node:zlib';
9
+
10
+ const MIME = {
11
+ '.html': 'text/html; charset=utf-8',
12
+ '.js': 'text/javascript; charset=utf-8',
13
+ '.mjs': 'text/javascript; charset=utf-8',
14
+ '.map': 'application/json; charset=utf-8',
15
+ '.json': 'application/json; charset=utf-8',
16
+ '.css': 'text/css; charset=utf-8',
17
+ '.svg': 'image/svg+xml',
18
+ };
19
+
20
+ // Cross-origin isolation: wasm-threaded apps (SQLite wasm, ffmpeg.wasm) need
21
+ // SharedArrayBuffer, which browsers only enable under COOP+COEP — without these
22
+ // headers such apps show a fatal error instead of a first paint (found via
23
+ // Actual Budget). COEP 'credentialless' (not 'require-corp') keeps cross-origin
24
+ // subresources like third-party fonts loading, so non-wasm apps measure
25
+ // identically to before.
26
+ const ISOLATION_HEADERS = {
27
+ 'cross-origin-opener-policy': 'same-origin',
28
+ 'cross-origin-embedder-policy': 'credentialless',
29
+ 'cross-origin-resource-policy': 'cross-origin',
30
+ };
31
+
32
+ // Real deployments compress text assets; serving a 16MB bundle raw would
33
+ // overcharge the throttled lab by 3-4x versus what any user's wire sees
34
+ // (found via drawDB). transfer_bytes reads encodedBodySize, so metrics report
35
+ // compressed bytes - same as production.
36
+ const COMPRESSIBLE = new Set([
37
+ '.html',
38
+ '.js',
39
+ '.mjs',
40
+ '.css',
41
+ '.json',
42
+ '.map',
43
+ '.svg',
44
+ '.txt',
45
+ '.webmanifest',
46
+ '.wasm',
47
+ ]);
48
+
49
+ function wantsGzip(req, ext) {
50
+ return COMPRESSIBLE.has(ext) && /\bgzip\b/.test(req.headers['accept-encoding'] ?? '');
51
+ }
52
+
53
+ // Deliberately contentless: the warm-up page must never fire FCP/LCP, or a
54
+ // coverage/measure poll racing the next navigation could read the blank page's
55
+ // paint entries as the app's.
56
+ const BLANK_HTML = '<!doctype html><title>blank</title>';
57
+
58
+ // Navigations get what a real static host gives them: a directory's index.html, a
59
+ // sibling .html for an extensionless path (multi-page entries), and finally the
60
+ // root index.html (SPA history routes, which parity loads by path). Subresource
61
+ // misses stay 404 - an API fetch must never start receiving HTML it didn't get
62
+ // before, or a measurement would change under the app.
63
+ function resolveFile(rootDir, pathname, navigation) {
64
+ const inRoot = (file) =>
65
+ (file === rootDir || file.startsWith(rootDir + path.sep)) &&
66
+ fs.existsSync(file) &&
67
+ fs.statSync(file).isFile();
68
+ const direct = path.normalize(path.join(rootDir, pathname === '/' ? '/index.html' : pathname));
69
+ if (inRoot(direct)) return direct;
70
+ if (!navigation) return null;
71
+ const fallbacks = [path.join(direct, 'index.html')];
72
+ if (!path.extname(pathname)) fallbacks.push(`${direct}.html`, path.join(rootDir, 'index.html'));
73
+ return fallbacks.map((file) => path.normalize(file)).find(inRoot) ?? null;
74
+ }
75
+
76
+ /**
77
+ * `root` may be a function: parity serves the original build and the candidate
78
+ * from ONE origin (neither may render a different `location`), swapping the root
79
+ * between captures.
80
+ */
81
+ export function startServer(root, port = 0) {
82
+ const rootOf = typeof root === 'function' ? root : () => root;
83
+ const server = http.createServer((req, res) => {
84
+ let pathname;
85
+ try {
86
+ pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
87
+ } catch {
88
+ res.writeHead(400).end('bad request');
89
+ return;
90
+ }
91
+ if (pathname === '/blank.html') {
92
+ res.writeHead(200, {
93
+ 'content-type': MIME['.html'],
94
+ 'cache-control': 'no-store',
95
+ ...ISOLATION_HEADERS,
96
+ });
97
+ res.end(BLANK_HTML);
98
+ return;
99
+ }
100
+ const file = resolveFile(
101
+ path.normalize(rootOf()),
102
+ pathname,
103
+ req.headers['sec-fetch-mode'] === 'navigate',
104
+ );
105
+ if (!file) {
106
+ res.writeHead(404).end('not found');
107
+ return;
108
+ }
109
+ const ext = path.extname(file).toLowerCase();
110
+ const headers = {
111
+ 'content-type': MIME[ext] ?? 'application/octet-stream',
112
+ 'cache-control': 'no-store',
113
+ ...ISOLATION_HEADERS,
114
+ };
115
+ if (wantsGzip(req, ext)) {
116
+ res.writeHead(200, { ...headers, 'content-encoding': 'gzip' });
117
+ fs.createReadStream(file)
118
+ .pipe(zlib.createGzip({ level: 6 }))
119
+ .pipe(res);
120
+ } else {
121
+ res.writeHead(200, headers);
122
+ fs.createReadStream(file).pipe(res);
123
+ }
124
+ });
125
+ return new Promise((resolve, reject) => {
126
+ server.on('error', reject);
127
+ server.listen(port, '127.0.0.1', () => {
128
+ const actualPort = server.address().port;
129
+ resolve({
130
+ port: actualPort,
131
+ origin: `http://127.0.0.1:${actualPort}`,
132
+ // Chrome can keep a socket open with no finished request; server.close()
133
+ // alone waits for it until requestTimeout (300 s).
134
+ close: () =>
135
+ new Promise((r) => {
136
+ server.close(r);
137
+ server.closeAllConnections();
138
+ }),
139
+ });
140
+ });
141
+ });
142
+ }
@@ -0,0 +1,125 @@
1
+ // Startup CPU by EVALUATING module, from a profile taken on an attribution build (the
2
+ // same environment built with `preserveModules`, every module its own file).
3
+ //
4
+ // Two things make this build necessary. V8 attaches line ticks to a frame only when
5
+ // source positions exist, which in practice means optimized code; the top-level frame
6
+ // of a bundled chunk usually has none, so its self time cannot be split by module. And
7
+ // ESM evaluation is not a call stack: a library's top-level work has no importer frame
8
+ // above it. With one file per module, the root-most frame under the output dir IS the
9
+ // module whose evaluation is running, and the build report's importer chain says where
10
+ // the import to cut lives.
11
+
12
+ import path from 'node:path';
13
+
14
+ import { localFile } from './measure.mjs';
15
+
16
+ const isLab = (rel) => !rel || rel.startsWith('__lab') || rel.startsWith('_virtual/');
17
+
18
+ /**
19
+ * `graph` (optional) is the loaded module graph of the attribution build; with it every
20
+ * row carries the chain from the entry. Returns rows sorted by time: `{ module, ms, chain }`,
21
+ * `pairs` (evaluating module -> library file the time is spent in) and `self` by file.
22
+ */
23
+ export function evaluatingModules(profile, { outDir, graph = null, top = 20 }) {
24
+ const nodes = new Map(profile.nodes.map((n) => [n.id, n]));
25
+ const parent = new Map();
26
+ for (const n of profile.nodes) for (const c of n.children ?? []) parent.set(c, n.id);
27
+ const rel = (url) => {
28
+ const file = localFile(url, outDir);
29
+ return file ? path.relative(outDir, file).replaceAll('\\', '/') : null;
30
+ };
31
+ const evaluating = new Map();
32
+ const pairs = new Map();
33
+ const self = new Map();
34
+ const add = (m, k, v) => m.set(k, (m.get(k) ?? 0) + v);
35
+ profile.samples.forEach((id, i) => {
36
+ const delta = profile.timeDeltas[i] ?? 0;
37
+ if (delta <= 0) return;
38
+ const leaf = nodes.get(id);
39
+ const fn = leaf?.callFrame.functionName;
40
+ if (fn === '(idle)' || fn === '(program)') return;
41
+ const leafFile = rel(leaf?.callFrame.url);
42
+ add(
43
+ self,
44
+ leafFile ?? (fn === '(garbage collector)' ? '(gc)' : leaf?.callFrame.url || '(engine)'),
45
+ delta,
46
+ );
47
+ let cur = id;
48
+ let root = null;
49
+ while (cur != null) {
50
+ const f = rel(nodes.get(cur)?.callFrame.url);
51
+ if (f && !isLab(f)) root = f;
52
+ cur = parent.get(cur);
53
+ }
54
+ add(evaluating, root ?? (fn === '(garbage collector)' ? '(gc)' : '(no module frame)'), delta);
55
+ if (root && leafFile && leafFile !== root && !isLab(leafFile)) {
56
+ add(pairs, `${root} -> ${leafFile.replace(/^node_modules\//, '')}`, delta);
57
+ }
58
+ });
59
+ const ms = (v) => Math.round(v / 100) / 10;
60
+ const sorted = (m) => [...m.entries()].sort((a, b) => b[1] - a[1]);
61
+ const chainOf = graph ? graphChainResolver(graph) : () => null;
62
+ return {
63
+ rows: sorted(evaluating)
64
+ .slice(0, top)
65
+ .map(([module, us]) => ({ module, ms: ms(us), chain: chainOf(module) })),
66
+ pairs: sorted(pairs)
67
+ .slice(0, top)
68
+ .map(([pair, us]) => ({ pair, ms: ms(us) })),
69
+ self: sorted(self)
70
+ .slice(0, top)
71
+ .map(([file, us]) => ({ file, ms: ms(us) })),
72
+ };
73
+ }
74
+
75
+ /** Match an output file of the attribution build to a graph id and walk up to the entry. */
76
+ export function graphChainResolver(graph) {
77
+ const norm = (id) =>
78
+ String(id)
79
+ .replace(/^(\.\.\/)+/, '')
80
+ .replace(/^node_modules\//, '')
81
+ .replace(/\.[cm]?[jt]sx?$/, '');
82
+ const byKey = new Map(graph.modules.map((m, i) => [norm(m.id), i]));
83
+ return (file) => {
84
+ const i = byKey.get(norm(file));
85
+ if (i == null) return null;
86
+ const chain = [];
87
+ let cur = i;
88
+ const seen = new Set();
89
+ while (cur != null && !seen.has(cur)) {
90
+ seen.add(cur);
91
+ const m = graph.modules[cur];
92
+ const up = m.idom != null && m.idom >= 0 ? m.idom : (graph.staticPreds[cur] ?? [])[0];
93
+ if (up == null || up === cur) break;
94
+ chain.push(up);
95
+ cur = up;
96
+ }
97
+ return chain
98
+ .reverse()
99
+ .map((j) => graph.modules[j].id)
100
+ .filter((id) => !/^(\\0|\0)/.test(id));
101
+ };
102
+ }
103
+
104
+ /** Direct static importers of a module and one chain from the entry: the "why" view. */
105
+ export function whyModule(graph, index) {
106
+ const name = (i) => graph.modules[i].id;
107
+ const preds = graph.staticPreds[index] ?? [];
108
+ const chain = [];
109
+ let cur = index;
110
+ const seen = new Set();
111
+ while (cur != null && !seen.has(cur)) {
112
+ seen.add(cur);
113
+ const m = graph.modules[cur];
114
+ const up = m.idom != null && m.idom >= 0 ? m.idom : (graph.staticPreds[cur] ?? [])[0];
115
+ if (up == null || up === cur) break;
116
+ chain.push(up);
117
+ cur = up;
118
+ }
119
+ return {
120
+ module: name(index),
121
+ bytes: graph.modules[index].bytes ?? 0,
122
+ importers: preds.map((p) => ({ module: name(p), bytes: graph.modules[p].bytes ?? 0 })),
123
+ chain: chain.reverse().map(name),
124
+ };
125
+ }
@@ -0,0 +1,116 @@
1
+ // The Vite config shim: builds the app with the rolldown metrics report on EVERY
2
+ // environment (one directory per output dir), sourcemaps on the worker environment, and two
3
+ // variants behind environment variables - a worker-only rebuild for the loop and the
4
+ // preserveModules attribution build. It wraps the app's own config without editing it.
5
+ //
6
+ // Every environment must carry the report: the devtools tracer is process-global, and an
7
+ // environment built without it in the same process writes a full JSON-lines log under
8
+ // node_modules/.rolldown/unknown-session (hundreds of MB on a real app).
9
+
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+
13
+ export const SHIM_FILE = 'perf-kit.vite.config.ts';
14
+ export const ATTRIB_DIR = 'dist-attrib';
15
+
16
+ export function viteConfigShim(baseConfigFile) {
17
+ return `// Written by perf-kit (worker-config). Wraps ${baseConfigFile} without editing it.
18
+ // npx vite build --config ${SHIM_FILE} full build, report per environment
19
+ // ROLLDOWN_PERF_KIT_WORKER_ONLY=1 npx vite build --config ${SHIM_FILE} rebuild only the worker environment
20
+ // ROLLDOWN_PERF_KIT_ATTRIB=1 npx vite build --config ${SHIM_FILE} attribution build into ${ATTRIB_DIR}/ (preserveModules)
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { createRequire } from 'node:module';
24
+ import { mergeConfig } from 'vite';
25
+ import base from './${baseConfigFile.replace(/\.[cm]?[jt]s$/, '')}';
26
+
27
+ // A rolldown without the report mode takes \`devtools\` as the plain tracer and writes the
28
+ // full event log (hundreds of MB per build) instead of the report. Refuse to build on one.
29
+ function assertReportMode() {
30
+ const here = createRequire(import.meta.url);
31
+ let from = here;
32
+ try { from = createRequire(path.dirname(fs.realpathSync(here.resolve('vite/package.json')))); } catch {}
33
+ const pkg = JSON.parse(fs.readFileSync(from.resolve('rolldown/package.json'), 'utf8'));
34
+ const dist = path.join(path.dirname(fs.realpathSync(from.resolve('rolldown/package.json'))), 'dist');
35
+ const stack = [dist];
36
+ while (stack.length) {
37
+ const dir = stack.pop()!;
38
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
39
+ const f = path.join(dir, e.name);
40
+ if (e.isDirectory()) stack.push(f);
41
+ else if (/\\.d\\.[cm]?ts$/.test(e.name) && /mode\\?:\\s*['"]full['"]\\s*\\|\\s*['"]report['"]/.test(fs.readFileSync(f, 'utf8'))) return;
42
+ }
43
+ }
44
+ throw new Error(
45
+ \`perf-kit: rolldown \${pkg.version} at \${dist} has no devtools report mode; a build with this config would write the full event log instead of the report. Run \\\`rolldown-install --app <appDir>\\\` first (\\\`--from <built packages/rolldown>\\\` when no checkout sits next to the lab).\`,
46
+ );
47
+ }
48
+
49
+ export default async (env: any) => {
50
+ assertReportMode();
51
+ const resolved = typeof base === 'function' ? await (base as any)(env) : await base;
52
+ const attrib = process.env.ROLLDOWN_PERF_KIT_ATTRIB === '1';
53
+ const workerOnly = attrib || process.env.ROLLDOWN_PERF_KIT_WORKER_ONLY === '1';
54
+ return mergeConfig(resolved, {
55
+ ...(workerOnly
56
+ ? {
57
+ builder: {
58
+ buildApp: async (builder: any) => {
59
+ for (const name of Object.keys(builder.environments)) {
60
+ if (name !== 'client') await builder.build(builder.environments[name]);
61
+ }
62
+ },
63
+ },
64
+ }
65
+ : {}),
66
+ plugins: [
67
+ {
68
+ name: 'perf-kit',
69
+ generateBundle(_options: any, bundle: Record<string, any>) {
70
+ // rolldown's preserveModules output ends every CommonJS file with an eager
71
+ // \`export default require_x();\`. In a require cycle that call reaches a wrapper whose
72
+ // file has not run yet ("require_x is not a function"), and the attribution build
73
+ // throws at import (rolldown#11348). Its importers use the named wrapper, so this build drops the eager
74
+ // line; the cycle then resolves at the require site, as in node. The line is the last
75
+ // one of the file, so the sourcemap of the lines above stays right. The entry keeps
76
+ // its default export: that is the Worker's interface, and the lab imports it.
77
+ if (!attrib) return;
78
+ for (const chunk of Object.values(bundle)) {
79
+ if (chunk.type !== 'chunk' || chunk.isEntry) continue;
80
+ const code = chunk.code.replace(/^export default require_[\\w$]+\\(\\);\\n?/m, '');
81
+ if (code !== chunk.code) chunk.code = code;
82
+ }
83
+ },
84
+ configEnvironment(name: string) {
85
+ const worker = name !== 'client';
86
+ return {
87
+ build: {
88
+ ...(attrib && worker ? { outDir: \`${ATTRIB_DIR}/\${name}\`, minify: false } : {}),
89
+ sourcemap: worker,
90
+ rolldownOptions: {
91
+ ...(attrib && worker
92
+ ? { output: { preserveModules: true, preserveModulesRoot: '.', entryFileNames: '[name].js', chunkFileNames: '[name].js' } }
93
+ : {}),
94
+ devtools: { mode: 'report' },
95
+ },
96
+ },
97
+ };
98
+ },
99
+ },
100
+ ],
101
+ });
102
+ };
103
+ `;
104
+ }
105
+
106
+ /** Write the shim next to the app's vite config. Returns `{ file, baseConfigFile, existed }`. */
107
+ export function writeViteConfigShim(appDir, { force = false } = {}) {
108
+ const base = ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs'].find(
109
+ (f) => fs.existsSync(path.join(appDir, f)),
110
+ );
111
+ if (!base) throw new Error(`no vite.config.{ts,mts,js,mjs} in ${appDir}`);
112
+ const file = path.join(appDir, SHIM_FILE);
113
+ const existed = fs.existsSync(file);
114
+ if (!existed || force) fs.writeFileSync(file, viteConfigShim(base));
115
+ return { file, baseConfigFile: base, existed };
116
+ }