blob: 88406eccee7044d0cecec0601ebc716d9adf0554 [file] [edit]
// 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,
};
});