| // Copyright (C) 2026 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| // Minimal Vite config. Vite is used here purely as a TS transpiler + bundler |
| // replacement for tsc + rollup. It does NOT serve, watch assets, or process |
| // HTML / SCSS — build.mjs continues to own all of that. |
| // |
| // Inputs are the same entry points the old rollup.config.js used, but read |
| // directly from .ts source instead of from tsc's emit. |
| |
| import {defineConfig} from 'vite'; |
| import path from 'node:path'; |
| import fs from 'node:fs'; |
| import {fileURLToPath} from 'node:url'; |
| import {SourceMapConsumer, SourceMapGenerator} from 'source-map'; |
| import {lezer} from '@lezer/generator/rollup'; |
| |
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| const ROOT_DIR = path.dirname(__dirname); |
| const OUT_SYMLINK = path.join(ROOT_DIR, 'ui/out'); |
| |
| const NO_SOURCE_MAPS = process.env.NO_SOURCE_MAPS === 'true'; |
| const NO_TREESHAKE = process.env.NO_TREESHAKE === 'true'; |
| const MINIFY_JS = process.env.MINIFY_JS || ''; |
| const ENABLE_BIGTRACE = process.env.ENABLE_BIGTRACE === 'true'; |
| const ENABLE_OPEN_PERFETTO_TRACE = |
| process.env.ENABLE_OPEN_PERFETTO_TRACE === 'true'; |
| const IS_MEMORY64_ONLY = process.env.IS_MEMORY64_ONLY === 'true'; |
| |
| // IIFE bundles go to dist_version (the symlink to dist/v1.2.3). The service |
| // worker and chrome extension go elsewhere; for the minimum migration we keep |
| // them on the old rollup path until needed. (build.mjs still runs rollup for |
| // those if we wire it that way, but for now we ship the three main bundles.) |
| const SRC = path.join(ROOT_DIR, 'ui/src'); |
| const GEN_SYMLINK = path.join(SRC, 'gen'); |
| // Canonical (real) path of the gen dir. Vite/Rollup resolves symlinks by |
| // default, so a file imported through ui/src/gen ends up reported with this |
| // path. We use it below to detect "the importer is a generated file". |
| const GEN_REAL = fs.existsSync(GEN_SYMLINK) ? fs.realpathSync(GEN_SYMLINK) : ''; |
| |
| // The generated import barrels (all_plugins.ts, all_core_plugins.ts) live under |
| // ui/src/gen which is a symlink into out/.../tsc/gen. Their relative imports |
| // (e.g. "../core_plugins/foo") only make sense relative to ui/src, not to the |
| // real gen dir. When Rollup canonicalises the symlink, those relative paths |
| // break. This plugin rewrites them to absolute paths under ui/src. |
| // Strips Rollup's full source map down to one mapping per generated line and |
| // embeds it inline into each _bundle.js under self.__SOURCEMAPS[fileName] for |
| // runtime error reporting. Skipped when source maps are disabled. |
| function pluginEmbedMinimalSourceMap() { |
| return { |
| name: 'perfetto:embed-minimal-sourcemap', |
| async generateBundle(_options, bundle) { |
| for (const chunk of Object.values(bundle)) { |
| if (!chunk.fileName || !chunk.fileName.endsWith('.js') || !chunk.map) { |
| continue; |
| } |
| try { |
| const consumer = await new SourceMapConsumer(chunk.map); |
| const generator = new SourceMapGenerator({file: chunk.map.file}); |
| const seenLines = new Set(); |
| const cleanSourcePath = (source) => { |
| let cleaned = source.replace('../../../out/ui/', ''); |
| cleaned = cleaned.replace('../../node_modules/', 'node_modules/'); |
| return cleaned; |
| }; |
| consumer.eachMapping((mapping) => { |
| if (!mapping.source) return; |
| if (seenLines.has(mapping.generatedLine)) return; |
| seenLines.add(mapping.generatedLine); |
| generator.addMapping({ |
| generated: {line: mapping.generatedLine, column: 0}, |
| original: { |
| line: mapping.originalLine, |
| column: mapping.originalColumn, |
| }, |
| source: cleanSourcePath(mapping.source), |
| }); |
| }); |
| consumer.destroy(); |
| const minimalMap = JSON.parse(generator.toString()); |
| delete minimalMap.sourcesContent; |
| delete minimalMap.names; |
| chunk.code += |
| `\n;(self.__SOURCEMAPS=self.__SOURCEMAPS||{})` + |
| `['${chunk.fileName}']=${JSON.stringify(minimalMap)};`; |
| } catch (err) { |
| console.error( |
| `Error creating minimal source map for ${chunk.fileName}:`, |
| err.message, |
| ); |
| } |
| } |
| }, |
| }; |
| } |
| |
| // Generates barrel modules that import every plugin under ui/src/plugins (or |
| // ui/src/core_plugins) and default-export an array of their default exports. |
| // Replaces the on-disk barrels that tools/gen_ui_imports used to produce. |
| // |
| // Exposed as virtual modules so consumers do: |
| // import NON_CORE_PLUGINS from 'virtual:perfetto/all_plugins'; |
| // import CORE_PLUGINS from 'virtual:perfetto/all_core_plugins'; |
| // |
| // Types live in ui/src/types/virtual-modules.d.ts. |
| function pluginAllPluginsBarrel() { |
| const VIRTUALS = { |
| 'virtual:perfetto/all_plugins': path.join(SRC, 'plugins'), |
| 'virtual:perfetto/all_core_plugins': path.join(SRC, 'core_plugins'), |
| }; |
| const toCamelCase = (s) => { |
| const [first, ...rest] = s.split(/[._]/); |
| return ( |
| first + rest.map((x) => x.charAt(0).toUpperCase() + x.slice(1)).join('') |
| ); |
| }; |
| const generate = (dir) => { |
| const entries = fs |
| .readdirSync(dir) |
| .map((name) => ({name, full: path.join(dir, name)})) |
| .filter(({full}) => { |
| try { |
| return ( |
| fs.statSync(full).isDirectory() && |
| fs.existsSync(path.join(full, 'index.ts')) |
| ); |
| } catch (_) { |
| return false; |
| } |
| }) |
| .sort((a, b) => a.name.localeCompare(b.name)); |
| const imports = entries |
| .map(({name, full}) => `import ${toCamelCase(name)} from '${full}';`) |
| .join('\n'); |
| const arr = entries.map(({name}) => ` ${toCamelCase(name)},`).join('\n'); |
| return `${imports}\n\nexport default [\n${arr}\n];\n`; |
| }; |
| let server = null; |
| return { |
| name: 'perfetto:all-plugins-barrel', |
| configureServer(s) { |
| server = s; |
| // Watch the parent dirs so adding/removing a plugin dir invalidates |
| // the barrel even before any file inside it changes. addWatchFile in |
| // load() only covers index.ts files that already exist at load time. |
| for (const dir of Object.values(VIRTUALS)) s.watcher.add(dir); |
| }, |
| resolveId(id) { |
| if (id in VIRTUALS) return '\0' + id; |
| }, |
| load(id) { |
| if (!id.startsWith('\0virtual:perfetto/')) return; |
| const realId = id.slice(1); |
| const dir = VIRTUALS[realId]; |
| if (!dir) return; |
| // Tell Rollup we depend on every index.ts so edits/deletes trigger |
| // a rebuild in `vite build --watch`. |
| for (const name of fs.readdirSync(dir)) { |
| const idx = path.join(dir, name, 'index.ts'); |
| if (fs.existsSync(idx)) this.addWatchFile(idx); |
| } |
| return generate(dir); |
| }, |
| handleHotUpdate(ctx) { |
| // Invalidate the matching barrel when any file under one of the |
| // plugin parent dirs is added/changed/removed (catches new plugin |
| // dirs being dropped in). Edits to existing plugin source don't need |
| // to invalidate the barrel itself — Vite handles those normally. |
| if (!server) return; |
| for (const [realId, dir] of Object.entries(VIRTUALS)) { |
| if (!ctx.file.startsWith(dir + path.sep)) continue; |
| const mod = server.moduleGraph.getModuleById('\0' + realId); |
| if (mod) server.moduleGraph.invalidateModule(mod); |
| } |
| }, |
| }; |
| } |
| |
| function pluginGenRelativeImports() { |
| return { |
| name: 'perfetto:gen-relative-imports', |
| enforce: 'pre', |
| async resolveId(source, importer) { |
| if (!importer || !source.startsWith('.')) return null; |
| if (!GEN_REAL || !importer.startsWith(GEN_REAL + path.sep)) return null; |
| // Re-anchor the relative import to ui/src/gen (the symlinked location) |
| // rather than the canonical out/.../tsc/gen path. |
| const reanchored = path.resolve(GEN_SYMLINK, source); |
| return this.resolve(reanchored, importer, {skipSelf: true}); |
| }, |
| }; |
| } |
| |
| // Dev-mode shim for the UMD wasm glue files in ui/src/gen/*.js. They end with |
| // if (typeof exports === 'object' && typeof module === 'object') { |
| // module.exports = X; module.exports.default = X; |
| // } |
| // which Vite's native ESM serving doesn't recognise — the browser sees no |
| // `export default`. In build mode @rollup/plugin-commonjs handles this; in |
| // dev we transform the source: run the UMD body with a synthesised |
| // `module`/`exports`, then re-export `module.exports.default` as ESM default. |
| function pluginGenWasmGlueEsm() { |
| return { |
| name: 'perfetto:gen-wasm-glue-esm', |
| enforce: 'pre', |
| async load(id) { |
| const file = id.split('?', 1)[0]; |
| if ( |
| !/\/gen\/(proto_utils|trace_processor(_memory64)?|traceconv)\.js$/.test( |
| file, |
| ) |
| ) |
| return null; |
| const src = await fs.promises.readFile(file, 'utf8'); |
| // Wrap the UMD in a CJS-style shim and re-export as ESM. |
| return `const module = {exports: {}};\nconst exports = module.exports;\n${src}\nexport default module.exports.default ?? module.exports;\n`; |
| }, |
| }; |
| } |
| |
| // Per-bundle config: input file, output dir (relative to ui/out), and output |
| // filename. Most bundles follow the standard convention; service_worker and |
| // chrome_extension differ. |
| const BUNDLE_CONFIGS = { |
| frontend: {dir: 'dist_version', entry: 'index.ts'}, |
| engine: {dir: 'dist_version', entry: 'index.ts'}, |
| traceconv: {dir: 'dist_version', entry: 'index.ts'}, |
| bigtrace: {dir: 'dist_version/bigtrace', entry: 'index.ts'}, |
| open_perfetto_trace: {dir: 'dist/open_perfetto_trace', entry: 'index.ts'}, |
| chrome_extension: {dir: 'chrome_extension', entry: 'index.ts'}, |
| service_worker: { |
| dir: 'dist', |
| entry: 'service_worker.ts', |
| fileName: 'service_worker.js', |
| }, |
| }; |
| |
| // When invoked as `vite build`, BUNDLE selects one entry per invocation |
| // (build.mjs spawns one process per bundle). When invoked programmatically |
| // from build.mjs as a dev server (`createServer`), BUNDLE is unset and the |
| // frontend index.html drives the module graph. |
| const BUNDLE = process.env.BUNDLE; |
| |
| export default defineConfig(({command}) => { |
| const isBuild = command === 'build'; |
| if (isBuild && !BUNDLE) { |
| throw new Error('vite.config.mjs requires BUNDLE=<name> env var for build'); |
| } |
| const bundleCfg = isBuild ? BUNDLE_CONFIGS[BUNDLE] : null; |
| if (isBuild && !bundleCfg) { |
| throw new Error(`Unknown BUNDLE: ${BUNDLE}`); |
| } |
| const inputPath = isBuild ? path.join(SRC, BUNDLE, bundleCfg.entry) : null; |
| const entryFileNames = isBuild |
| ? bundleCfg.fileName || '[name]_bundle.js' |
| : undefined; |
| |
| return { |
| root: SRC, |
| // Vite is used purely as a TS transpiler + bundler in build mode, and as |
| // a module-serving dev server in serve mode. build.mjs owns HTML in both |
| // modes (in dev it calls server.transformIndexHtml() and serves at /). |
| appType: 'custom', |
| // We have a real source directory at ui/src/public — disable Vite's |
| // magic "publicDir" handling so it doesn't try to serve it at /. |
| publicDir: false, |
| plugins: [ |
| pluginAllPluginsBarrel(), |
| // Compiles *.grammar files (lezer parser definitions) on import. Replaces |
| // the old "manually run lezer-generator and commit gen/*.js" workflow. |
| lezer(), |
| pluginGenRelativeImports(), |
| ...(isBuild ? [] : [pluginGenWasmGlueEsm()]), |
| ...(NO_SOURCE_MAPS ? [] : [pluginEmbedMinimalSourceMap()]), |
| ], |
| resolve: { |
| // NB: do NOT set preserveSymlinks:true. pnpm puts every dep under |
| // .pnpm/<pkg>@<ver>/node_modules/<pkg> and exposes a symlink at |
| // node_modules/<pkg>. With preserveSymlinks @rollup/plugin-commonjs |
| // mis-resolves intra-package requires (e.g. ajv's require of its own |
| // ./codegen) and emits a stub external `require$$N` that crashes at |
| // runtime. The symlinked ui/src/gen dir is handled below by aliasing |
| // the importer side, not by preserving symlinks globally. |
| alias: [ |
| // The trace_processor_32_stub indirection (see old rollup.config.js). |
| ...(IS_MEMORY64_ONLY |
| ? [] |
| : [ |
| { |
| find: /.*\/trace_processor_32_stub$/, |
| replacement: path.join(SRC, 'gen/trace_processor'), |
| }, |
| ]), |
| ], |
| }, |
| define: { |
| // Immer reads process.env.NODE_ENV; not defined in browser. |
| 'process.env.NODE_ENV': JSON.stringify('production'), |
| }, |
| // The wasm glue files in ui/src/gen (proto_utils.js, trace_processor.js, |
| // traceconv.js) are UMD with a tacked-on `module.exports.default`. Vite's |
| // built-in CJS handling needs help recognising them as CJS so the |
| // `import X from '../gen/foo'` pattern works. |
| optimizeDeps: { |
| esbuildOptions: { |
| // No-op for build, but keeps dev parity if we ever flip to `vite dev`. |
| }, |
| }, |
| build: isBuild |
| ? { |
| commonjsOptions: { |
| transformMixedEsModules: true, |
| // The UMD wasm glue files don't look like CJS to rollup's auto-detect. |
| // Force CJS interpretation for everything under ui/src/gen/*.js. |
| include: [/node_modules/, /\/gen\/.*\.js$/], |
| // ajv's JIT validator codegen emits strings like |
| // `require("ajv/dist/runtime/equal").default` |
| // which @rollup/plugin-commonjs tries to resolve as transitive deps and |
| // then leaves dangling `require$$N` references in the IIFE. Those |
| // require()s are only ever evaluated inside ajv's own codegen layer, |
| // never at runtime in the browser. Tell commonjs not to analyse them. |
| ignoreDynamicRequires: true, |
| }, |
| outDir: path.join(OUT_SYMLINK, bundleCfg.dir), |
| emptyOutDir: false, // build.mjs puts wasm/css/assets here too. |
| // Force a single CSS asset per bundle (named after the entry chunk, e.g. |
| // frontend.css). IIFE builds don't auto-inject <link> tags, so extraction |
| // needs to be explicit. |
| cssCodeSplit: false, |
| sourcemap: !NO_SOURCE_MAPS, |
| minify: MINIFY_JS ? 'terser' : false, |
| terserOptions: |
| MINIFY_JS === 'preserve_comments' |
| ? {format: {comments: 'all'}} |
| : undefined, |
| rollupOptions: { |
| input: {[BUNDLE]: inputPath}, |
| treeshake: NO_TREESHAKE ? false : undefined, |
| output: { |
| format: 'iife', |
| name: BUNDLE, |
| entryFileNames, |
| // With cssCodeSplit:false Vite emits the CSS as "style.css" by |
| // default. Rename it to <bundle>.css so that index.html's preload |
| // and the assetSrc('frontend.css') call match. |
| assetFileNames: (info) => { |
| const name = info.names?.[0] || info.name || ''; |
| if (name.endsWith('.css')) return `${BUNDLE}.css`; |
| return '[name][extname]'; |
| }, |
| inlineDynamicImports: true, |
| }, |
| onwarn(warning, warn) { |
| if (warning.code === 'CIRCULAR_DEPENDENCY') { |
| if ((warning.message || '').includes('node_modules')) return; |
| throw new Error( |
| `Circular dependency: ${warning.importer}\n ${(warning.cycle || []).join('\n ')}`, |
| ); |
| } |
| warn(warning); |
| }, |
| }, |
| } |
| : undefined, |
| }; |
| }); |