blob: 3c3951f90ddb9ea24a5a9d05d0e6781540aec9c7 [file] [view] [edit]
# UI build migration to Vite
**Authors:** @primiano
**Status:** Proposal
**POC branch:** [dev/primiano/vite](https://github.com/google/perfetto/tree/dev/primiano/vite)
## Problem
The Perfetto UI build is driven by a hand-rolled `ui/build.js` (1177 LoC)
that orchestrates `rollup`, `tsc`, `sass`, `pbjs/pbts`, ninja, and a custom
HTTP server.
Pain points:
1. **Slow incremental dev rebuilds.** Editing `ui/src/frontend/sidebar.ts`
triggers a `tsc` recompile (incremental, OK) followed by a rollup
re-bundle of the whole frontend, ~35s per cycle once warm.
2. **A lot of bespoke code.** Live-reload SSE, port handling, asset
copying, watch debouncing, build-lock, signal handling, all hand-rolled
on top of node `http`, `fs.watch`, and `child_process`.
## Opportunities and risks
Opportunities:
- Move over to industry standard. Vite seems to have caught a lot of traction
lately. It solves the problems we care about and has a large ecosystem.
- The speed up on dev cycle is incredible, it retains tsc debug output for
type-related issues (altough does it asynchronously).
- More importantly works better with AI agents because there is no need to
"wait for build", as the synchronization as transpilation happens on-demand
when requesting a module from the HTTP server.
- It improves massively the dev workflow experience, as it doesn't require any
bundling at all for dev.
Risks:
- There is a lot of complexity behind Vite, as it uses a miture of rollup
bundles for production but for dev server, it pushes ES modules down to the
browser and transpiles them on demand. Although this complexity is supposed to
be dealt with by Vite.
- Realistically we are going to spend probably a few weeks shaking bugs here and
there that will unavoidably come up.
## Proposal
Replace the entire `ui/build.js` + rollup orchestration with **Vite**:
* **Production builds** (`ui/build`): one `vite.build()` per IIFE bundle.
Same output layout as before; same `manifest.json` + duplicated
`index.html` post-build steps. Still bundles with rollup.
No behavioral change at the user/serving layer.
* **Dev mode** (`ui/run-dev-server`): use Vite's **dev server** for the
frontend (native ES modules, esbuild per-file transforms, browser
refresh in ~2s), and `vite.build()` for the worker / SW / chrome
extension bundles (one-shot for the rarely-edited ones, `watch:{}` for
the small ones we sometimes touch).
Hand-rolled bits we retire entirely:
* Custom HTTP server, `/live_reload` SSE, mtime 304s, gzip middleware
(Vite dev server replaces them).
* `tsc --watch` as a JS emitter Vite/esbuild transform `.ts` directly.
* Several rollup plugins (`re`, `sourcemaps2`, our own
`embedMinimalSourceMap`).
We **keep**:
* `pbjs`/`pbts` invocations (with one option flip: `-w es6` so the dev
server can serve the generated `protos.js` as a native module).
* `tools/gen_ui_imports`, `tools/write_version_header.py`,
`tools/gen_stdlib_docs_json.py` invoked by the new orchestrator.
* `tools/gn` + `tools/ninja` for wasm. Output staging unchanged.
* The `manifest.json` + duplicated `index.html` mechanism (required by
the offline service worker).
* The single source-of-truth production `ui/src/assets/index.html` a
tiny middleware in dev rewrites *one line* of it on the wire to load
the frontend as a module.
## Design
### Repository layout
```text
ui/
├── build # bash wrapper → scripts/build.mjs
├── run-dev-server # bash wrapper → scripts/dev.mjs
├── scripts/
│ ├── common.mjs # layout, exec helpers, env-file loading
│ ├── codegen.mjs # protos, version, plugin index, stdlib docs
│ ├── build_wasm.mjs # gn + ninja + stage outputs
│ ├── assets.mjs # static asset + chrome-ext copy rules
│ ├── postbuild.mjs # SW manifest.json + root index.html
│ ├── build.mjs # production orchestrator
│ └── dev.mjs # dev orchestrator
└── vite/
├── config.mjs # InlineConfig factory used by both modes
└── plugins.mjs # custom Vite plugins
```
`ui/src/gen/` is now a real directory (no longer a symlink) and receives
all generated files (codegen + Emscripten glue) directly. This sidesteps
a class of `pnpm` symlink interactions with `@rollup/plugin-commonjs`
that broke ajv resolution under `preserveSymlinks: true`.
### Production pipeline (`scripts/build.mjs`)
1. Parse args + env (`PERFETTO_UI_*`, `~/.config/perfetto/ui-dev-server.env`).
CLI flags always win; precedence is documented in the parser.
2. Wipe `<outDir>/ui/` for a deterministic build; recreate the layout.
3. Codegen: pbjs+pbts `ui/src/gen/protos.{js,d.ts}`; version header;
plugin import index; stdlib docs JSON.
4. Wasm: optional `gn gen` + `ninja <mod>_wasm` for each module, then
stage `.wasm` into `dist/<ver>/` and `.{js,d.ts}` glue into
`ui/src/gen/`.
5. Copy fonts, pngs, catapult, chrome extension files; copy
`ui/src/assets/index.html` into `dist/<ver>/index.html` verbatim.
6. Compile `ui/src/assets/perfetto.scss` `dist/<ver>/perfetto.css`
via the `sass` CLI.
7. **One `vite.build()` call per bundle** sequentially, sharing the
output dir via `emptyOutDir: false`. Output is forced to
`<name>_bundle.js` (no hashing) so the existing service-worker
subresource-integrity scheme keeps working.
8. Postbuild: walk `dist/<ver>/`, sha256 each non-`.map`/non-`index.html`
file, write `manifest.json`. Duplicate `dist/<ver>/index.html` to
`dist/index.html` with `data-perfetto_version` patched to a single-
channel JSON map (`{"stable":"<ver>"}`) and optional `<title>`
override.
The output tree is byte-equivalent to what the old rollup pipeline
produced (modulo bundle minifier output differences); the service worker
needs no changes.
### Dev pipeline (`scripts/dev.mjs`)
```text
ui/run-dev-server
├── codegen (once)
├── stage wasm (once; gn+ninja unless --no-wasm)
├── copy static assets (once)
├── sass --watch → dist/<ver>/perfetto.css
├── vite.build one-shot → traceconv, chrome_extension
├── vite.build watch:{} → engine, service_worker
└── vite.createServer → http://<host>:<port>/
├── publicDir = dist/ (serves /v<ver>/*, /service_worker.js,
│ /v<ver>/*.wasm, /v<ver>/perfetto.css)
├── middleware: read ui/src/assets/index.html and serve
│ a transformed copy at / (see "HTML transform" below)
├── middleware: /live_reload no-op SSE stub
├── middleware: /test/** passthrough (Playwright fixtures)
└── plugin: vite-plugin-checker (project-wide tsc --noEmit
in a worker; errors land in terminal + browser overlay)
```
When a worker bundle changes on disk a Vite `ws.send({type:'full-reload'})`
is pushed so the browser refreshes automatically.
Edit-to-refresh budget on a warm cache (Linux, Node 20):
| Action | Time |
|---------------------------------------|----------|
| Cold start (one-shot bundles + dev) | ~56 s |
| Edit frontend `.ts`, hit reload | **~2 s** |
| Reload, no edits | ~1.4 s |
| Edit `engine/index.ts`, watcher signals | ~0.5 s |
| Reload after engine rebuild | ~2 s |
### HTML transform
The production bootstrap in `ui/src/assets/index.html` ends with
roughly:
```js
const script = document.createElement('script');
script.async = true;
script.src = version + '/frontend_bundle.js';
script.onerror = () => errHandler(...);
document.head.append(script);
```
In dev, the dev server's middleware reads this same file and rewrites
*just* that line:
```js
script.type = 'module';
script.src = '/src/frontend/index.ts';
```
The error handler, the timeout, the CSS/font preloads, the
`data-perfetto_version` channel map — all run unchanged. The middleware
also injects a single inline script in `<head>` setting
`window.__PERFETTO_ASSET_ROOT__ = '/v<ver>/'` so that `assetSrc()`
(which historically derived the asset root from
`document.currentScript.src`) still works when the frontend is loaded
as a module (where `document.currentScript` is `null`). The
production HTML is **not** modified on disk.
The only source-side change required is in
`ui/src/base/http_utils.ts::getServingRoot()` — five lines that consult
the global before falling back to the existing `currentScript`-based
logic.
### Custom Vite plugins (`ui/vite/plugins.mjs`)
* **`pluginProtoFixup`** (build-only safety net): replaces
`eval(...moduleName);` with `undefined;` in any `.js` we transform,
and the `process.env.NODE_ENV` references that immer expects. This
used to be `rollup-plugin-re`; same intent, more focused.
* **`pluginTraceProcessor32Alias`**: when the 32-bit `trace_processor`
glue exists in `ui/src/gen/`, redirect `./trace_processor_32_stub`
imports to it; otherwise leave the stub in place (which throws at
runtime if the user's browser doesn't support memory64).
* **`pluginLezerGrammarAlias`**: `import {parser} from './foo.grammar';`
is rewritten to `./foo.grammar.js`. Necessary because Vite resolves
`./foo.grammar` to the literal text-format grammar file, then chokes
parsing it as JS.
* **`pluginEmscriptenGlueToEsm`** (dev-server only): the
`MODULARIZE=1` Emscripten glue ends with a CommonJS/AMD export
trailer (`module.exports = X`). In `vite build` the rollup
commonjs plugin handles it; in dev-serve there is no such plugin in
the hot path, so we append `export default <name>_wasm;` to the
served file. The trailing CJS/AMD branch becomes dead code.
### tsconfig change
`tsconfig.base.json` switched:
* `module: commonjs` → `module: esnext`
* `moduleResolution: node` → `moduleResolution: bundler`
`tsc` is no longer used for emit (Vite/esbuild do the transforms);
these settings only affect type-check resolution, which is now aligned
with what the bundlers actually do. Two minor source fixups in
`com.google.PerfettoMcp/{tracetools,uitools}.ts` add explicit `.js`
suffixes to the MCP SDK imports as the `bundler` resolver requires.
### Why `vite-plugin-checker`
In dev mode Vite only transforms files that the browser actually
imports. A type error in an unreached file (a disabled plugin, a new
file not yet wired up) would silently sit there. The plugin runs
`tsc --noEmit` over the whole project in a worker, surfaces errors
both in the terminal and as a click-through browser overlay. Verified
end-to-end by adding a synthetic `_test_checker_error.ts` containing
`const x: number = "string";` — the dev server logged the TS2322 even
though no file imported it.
## Alternatives considered
### A. Stay on rollup, retrofit watch-mode
Pros: smallest change.
Cons: doesn't address the "edit a frontend file → ~5s rebuild" pain.
Modern alternatives (Vite, rspack, esbuild) were designed exactly to
fix this; sticking with rollup leaves us with the slow path forever.
### B. `vite build --watch` for everything
This was the **original** attempt. It failed: editing the frontend
`.ts` triggered `[commonjs] Cannot read properties of undefined
(reading 'resolved')` from `@rollup/plugin-commonjs@28`'s rebuild-cache
path on the second build, leaving the watcher wedged. We tried two
escalations:
1. Spawn `vite build --watch` as a subprocess per bundle and respawn on
that specific error code (worked but every frontend edit took ~21 s
for a full cold rebuild — **unusable**).
2. Patch `meta.commonjs.resolved` access with a `?.` guard inline in
`node_modules/.vite/.../dep-XXXX.js` and `@rollup/plugin-commonjs`,
reapplied via a `postinstall` hook (worked, no more crashes, but
the rebuild stayed at ~15 s because rollup was still re-bundling
the whole frontend on every edit).
Both approaches were rejected for the chosen design (dev-server +
selective watch). The patches and the per-bundle subprocess scaffolding
have been removed.
### C. Webpack / rspack / turbopack
Pros: also have fast dev modes.
Cons: bigger config surfaces, less idiomatic for SPA + classic-script
workers.
Also the ecosystem seems to be shifting in favor of Vite.
### D. ESBuild directly (no bundler framework on top)
Pros: fastest possible builds.
Cons:
- we'd be writing our own dev server + asset pipeline + CSS
extractor + dep-pre-bundling all over again. That's exactly what we
left behind.
- Rollup is still a better bundler (which vite uses in full buildS).
## Implementation notes / non-goals
* **Workers are still classic IIFE scripts.** `frontend` does
`new Worker(assetSrc('engine_bundle.js'))` (no `{type: 'module'}`).
The engine bundle is built as IIFE in both prod and dev. Switching
to module workers would simplify some things but require source
changes and has its own caveats (some Emscripten module-worker bugs
in the past).
* **Source maps in dev are off by default.** Vite serves the original
`.ts` source unmodified to the browser, so devtools can map line/col
natively without us emitting `.map` files. This kept dev rebuild
times tight; can be re-enabled per-bundle if desired.
* **Production source maps** are still emitted per Vite's defaults but
the old `embedMinimalSourceMap` mechanism (a compact map registry on
`self.__SOURCEMAPS[<bundleName>]`, consumed by
`ui/src/base/source_map_utils.ts` for stack-trace decoration on
error reports) was **not** ported. The runtime tolerates the
registry being absent. Re-adding it as a Vite `generateBundle`
plugin is left as a TODO that should be addressed.
* **Worker rebuild auto-reload** uses Vite's HMR websocket
(`server.ws.send({type:'full-reload'})`) rather than the old SSE
channel. The frontend's `core/live_reload.ts` was deleted along
with its callers; the dev server still serves a no-op SSE stub at
`/live_reload` for any out-of-tree consumer that pings it.
## Open questions / follow-ups
* **`isolatedModules: true`** in `tsconfig.base.json`. Would surface
the handful of mixed value/type re-exports left in the codebase
(`histogram.ts`, `track_helper.ts`). Currently both prod and dev
build clean without it; it's a tightening choice, not a correctness
one.
* **Per-bundle source-map strategy.** Re-enable for dev only? Match
prod sizes? Bring back the embedded minimal-map registry?
* **`bigtrace` and `open_perfetto_trace`** smoke tests in a real
browser. Plumbed through both pipelines but not visually verified
post-migration.