| // Copyright (C) 2021 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. | |
| // This script takes care of: | |
| // - The build process for the whole UI and the chrome extension. | |
| // - The HTTP dev-server with live-reload capabilities. | |
| // The reason why this is a hand-rolled script rather than a conventional build | |
| // system is keeping incremental build fast and maintaining the set of | |
| // dependencies contained. | |
| // The only way to keep incremental build fast (i.e. O(seconds) for the | |
| // edit-one-line -> reload html cycles) is to run both the TypeScript compiler | |
| // and the rollup bundler in --watch mode. Any other attempt, leads to O(10s) | |
| // incremental-build times. | |
| // This script allows mixing build tools that support --watch mode (tsc and | |
| // rollup) and auto-triggering-on-file-change rules via fs.watch. | |
| // When invoked without any argument (e.g., for production builds), this script | |
| // just runs all the build tasks serially. It doesn't to do any mtime-based | |
| // check, it always re-runs all the tasks. | |
| // When invoked with --watch, it mounts a pipeline of tasks based on fs.watch | |
| // and runs them together with tsc --watch and rollup --watch. | |
| // The output directory structure is carefully crafted so that any change to UI | |
| // sources causes cascading triggers of the next steps. | |
| // The overall build graph looks as follows: | |
| // +----------------+ +-----------------------------+ | |
| // | protos/*.proto |----->| pbjs out/tsc/gen/protos.js |--+ | |
| // +----------------+ +-----------------------------+ | | |
| // +-----------------------------+ | | |
| // | pbts out/tsc/gen/protos.d.ts|<-+ | |
| // +-----------------------------+ | |
| // | | |
| // V +-------------------------+ | |
| // +---------+ +-----+ | out/tsc/frontend/*.js | | |
| // | ui/*.ts |------------->| tsc |-> +-------------------------+ +--------+ | |
| // +---------+ +-----+ | out/tsc/controller/*.js |-->| rollup | | |
| // ^ +-------------------------+ +--------+ | |
| // +------------+ | out/tsc/engine/*.js | | | |
| // +-----------+ |*.wasm.js | +-------------------------+ | | |
| // |ninja *.cc |->|*.wasm.d.ts | | | |
| // +-----------+ |*.wasm |-----------------+ | | |
| // +------------+ | | | |
| // V V | |
| // +-----------+ +------+ +------------------------------------------------+ | |
| // | ui/*.scss |->| scss |--->| Final out/dist/ dir | | |
| // +-----------+ +------+ +------------------------------------------------+ | |
| // +----------------------+ | +----------+ +---------+ +--------------------+| | |
| // | src/assets/*.png | | | assets/ | |*.wasm.js| | frontend_bundle.js || | |
| // +----------------------+ | | *.css | |*.wasm | +--------------------+| | |
| // | buildtools/typefaces |-->| | *.png | +---------+ | engine_bundle.js || | |
| // +----------------------+ | | *.woff2 | +--------------------+| | |
| // | buildtools/legacy_tv | | | tv.html | |traceconv_bundle.js || | |
| // +----------------------+ | +----------+ +--------------------+| | |
| // +------------------------------------------------+ | |
| import argparse from 'argparse'; | |
| import childProcess from 'node:child_process'; | |
| import crypto from 'node:crypto'; | |
| import fs from 'node:fs'; | |
| import http from 'node:http'; | |
| import path from 'node:path'; | |
| import zlib from 'node:zlib'; | |
| import {fileURLToPath} from 'node:url'; | |
| const pjoin = path.join; | |
| const __filename = fileURLToPath(import.meta.url); | |
| const __dirname = path.dirname(__filename); | |
| const ROOT_DIR = path.dirname(__dirname); // The repo root. | |
| const VERSION_SCRIPT = pjoin(ROOT_DIR, 'tools/write_version_header.py'); | |
| const DEFAULT_PORT = 10000; | |
| const cfg = { | |
| minifyJs: '', | |
| noSourceMaps: false, | |
| noTreeshake: false, | |
| watch: false, | |
| verbose: false, | |
| debug: false, | |
| bigtrace: false, | |
| engineBench: false, | |
| startHttpServer: false, | |
| useHmr: false, | |
| httpServerListenHost: '127.0.0.1', | |
| httpServerListenPort: undefined, | |
| onlyWasmMemory64: false, | |
| wasmModules: [], | |
| crossOriginIsolation: false, | |
| allowAllHosts: false, | |
| testFilter: '', | |
| noOverrideGnArgs: false, | |
| // The fields below will be changed by main() after cmdline parsing. | |
| // Directory structure: | |
| // out/xxx/ -> outDir : Root build dir, for both ninja/wasm and UI. | |
| // ui/ -> outUiDir : UI dir. All outputs from this script. | |
| // tsc/ -> outTscDir : Transpiled .ts -> .js. | |
| // gen/ -> outGenDir : Auto-generated .ts/.js (e.g. protos). | |
| // dist/ -> outDistRootDir : Only index.html and service_worker.js | |
| // v1.2/ -> outDistDir : JS bundles and assets | |
| // chrome_extension/ : Chrome extension. | |
| outDir: pjoin(ROOT_DIR, 'out/ui'), | |
| version: '', // v1.2.3, derived from the CHANGELOG + git. | |
| outUiDir: '', | |
| outUiTestArtifactsDir: '', | |
| outDistRootDir: '', | |
| outTscDir: '', | |
| outGenDir: '', | |
| outDistDir: '', | |
| outExtDir: '', | |
| outBigtraceDistDir: '', | |
| outOpenPerfettoTraceDistDir: '', | |
| lockFile: '', | |
| }; | |
| const RULES = [ | |
| {r: /ui\/src\/assets\/index.html/, f: copyIndexHtml}, | |
| {r: /ui\/src\/assets\/bigtrace.html/, f: copyBigtraceHtml}, | |
| {r: /ui\/src\/open_perfetto_trace\/index.html/, f: copyOpenPerfettoTraceHtml}, | |
| // engine_bench page; no-op without --enable-engine-bench. | |
| {r: /ui\/src\/engine_bench\/bench\.html$/, f: copyEngineBenchHtml}, | |
| {r: /ui\/src\/assets\/((.*)[.]png)/, f: copyAssets}, | |
| {r: /ui\/src\/assets\/(data_explorer\/base-page\.json)/, f: copyAssets}, | |
| {r: /ui\/src\/assets\/(data_explorer\/examples\/(.*)[.]json)/, f: copyAssets}, | |
| {r: /ui\/src\/assets\/(data_explorer\/node_info\/(.*)[.]md)/, f: copyAssets}, | |
| {r: /buildtools\/typefaces\/(.+[.]woff2)/, f: copyAssets}, | |
| {r: /buildtools\/catapult_trace_viewer\/(.+(js|html))/, f: copyAssets}, | |
| {r: /ui\/src\/chrome_extension\/.*/, f: copyExtensionAssets}, | |
| {r: /.*\/dist\/.+\/(?!manifest\.json).*/, f: genServiceWorkerManifestJson}, | |
| {r: /.*\/dist\/.*[.](js|html|css|wasm)$/, f: notifyLiveServer}, | |
| ]; | |
| const tasks = []; | |
| let tasksTot = 0; | |
| let tasksRan = 0; | |
| const httpWatches = []; | |
| const tStart = performance.now(); | |
| const subprocesses = []; | |
| const LIVE_SERVER_DEBOUNCE_MS = 250; | |
| let liveServerDebounceTimerId = 0; | |
| const notifyLiveServerPendingFiles = new Set(); | |
| // Loads ~/.config/perfetto/ui-dev-server.env and injects any KEY=VALUE pairs | |
| // into process.env, without overriding variables already set in the environment. | |
| function loadDevServerEnvFile() { | |
| const home = process.env.HOME || process.env.USERPROFILE || ''; | |
| const envFile = path.join(home, '.config', 'perfetto', 'ui-dev-server.env'); | |
| let content; | |
| try { | |
| content = fs.readFileSync(envFile, 'utf8'); | |
| } catch (e) { | |
| return; // File absent or unreadable — not an error. | |
| } | |
| for (const line of content.split('\n')) { | |
| const trimmed = line.trim(); | |
| if (!trimmed || trimmed.startsWith('#')) continue; | |
| const eqIdx = trimmed.indexOf('='); | |
| if (eqIdx < 0) continue; | |
| const key = trimmed.slice(0, eqIdx).trim(); | |
| const value = trimmed.slice(eqIdx + 1).trim(); | |
| if (!(key in process.env)) process.env[key] = value; | |
| } | |
| } | |
| async function main() { | |
| const parser = new argparse.ArgumentParser({ | |
| formatter_class: argparse.RawDescriptionHelpFormatter, | |
| epilog: ` | |
| Env-var overrides: | |
| Any flag can be set via a PERFETTO_UI_<FLAG> environment variable, | |
| where <FLAG> is the flag name uppercased with hyphens replaced by | |
| underscores. Boolean flags are activated by "1" or "true". CLI flags | |
| always take precedence over environment variables. | |
| Examples: | |
| PERFETTO_UI_SERVE_HOST=0.0.0.0 | |
| PERFETTO_UI_SERVE_PORT=10000 | |
| PERFETTO_UI_NO_BUILD=1 | |
| PERFETTO_UI_TITLE=my-instance | |
| Defaults can also be persisted in: | |
| ~/.config/perfetto/ui-dev-server.env | |
| (one KEY=VALUE per line, # comments supported). Shell env vars take | |
| precedence over the file. | |
| `, | |
| }); | |
| parser.add_argument('--out', {help: 'Output directory'}); | |
| parser.add_argument('--minify-js', { | |
| help: 'Minify js files', | |
| choices: ['preserve_comments', 'all'], | |
| }); | |
| parser.add_argument('--no-source-maps', { | |
| action: 'store_true', | |
| help: 'Skip source map generation entirely', | |
| }); | |
| parser.add_argument('--no-treeshake', { | |
| action: 'store_true', | |
| help: 'Disable rollup tree-shaking (much faster incremental rebuilds)', | |
| }); | |
| parser.add_argument('--watch', '-w', {action: 'store_true'}); | |
| parser.add_argument('--serve', '-s', {action: 'store_true'}); | |
| parser.add_argument('--serve-host', {help: '--serve bind host'}); | |
| parser.add_argument('--serve-port', {help: '--serve bind port', type: 'int'}); | |
| parser.add_argument('--verbose', '-v', {action: 'store_true'}); | |
| parser.add_argument('--no-build', '-n', {action: 'store_true'}); | |
| parser.add_argument('--no-wasm', '-W', {action: 'store_true'}); | |
| parser.add_argument('--only-wasm-memory64', {action: 'store_true'}); | |
| parser.add_argument('--run-unittests', '-t', {action: 'store_true'}); | |
| parser.add_argument('--debug', '-d', {action: 'store_true'}); | |
| parser.add_argument('--bigtrace', {action: 'store_true'}); | |
| parser.add_argument('--enable-engine-bench', { | |
| action: 'store_true', | |
| help: 'Build the engine startup benchmark page (engine_bench.html) and ' + | |
| 'its dedicated worker bundle. Off by default.', | |
| }); | |
| parser.add_argument('--open-perfetto-trace', {action: 'store_true'}); | |
| parser.add_argument('--interactive', '-i', {action: 'store_true'}); | |
| parser.add_argument('--rebaseline', '-r', {action: 'store_true'}); | |
| parser.add_argument('--no-depscheck', {action: 'store_true'}); | |
| parser.add_argument('--cross-origin-isolation', {action: 'store_true'}); | |
| parser.add_argument('--allow-all-hosts', { | |
| action: 'store_true', | |
| help: 'Accept requests for any Host header on the Vite dev server, so it ' + | |
| 'can sit behind an arbitrary reverse proxy. Disables Vite\'s ' + | |
| 'DNS-rebind host-check protection.', | |
| }); | |
| parser.add_argument('--test-filter', '-f', { | |
| help: "filter Jest tests by regex, e.g. 'chrome_render'", | |
| }); | |
| parser.add_argument('--no-override-gn-args', {action: 'store_true'}); | |
| parser.add_argument('--typecheck', { | |
| action: 'store_true', | |
| help: 'Only type-check (tsc --noEmit), skip bundling', | |
| }); | |
| parser.add_argument('--bundle', { | |
| action: 'store_true', | |
| help: 'Serve the bundled frontend instead of the Vite HMR server ' + | |
| '(fewer requests, better over a remote/SSH dev server)', | |
| }); | |
| parser.add_argument('--title', { | |
| help: 'Override the page title (useful for distinguishing multiple instances)', | |
| }); | |
| // Load ~/.config/perfetto/ui-dev-server.env defaults, then map any | |
| // PERFETTO_UI_* env vars to synthetic argv entries prepended before the | |
| // real argv so that explicit CLI flags always take precedence. | |
| loadDevServerEnvFile(); | |
| const envPrefix = 'PERFETTO_UI_'; | |
| const syntheticArgv = []; | |
| for (const [key, val] of Object.entries(process.env)) { | |
| if (!key.startsWith(envPrefix)) continue; | |
| const flag = | |
| '--' + key.slice(envPrefix.length).toLowerCase().replace(/_/g, '-'); | |
| const action = parser._actions.find((a) => | |
| (a.option_strings || []).includes(flag), | |
| ); | |
| if (!action) continue; | |
| const isBoolFlag = action.nargs === 0; | |
| if (isBoolFlag) { | |
| if (val === '1' || val.toLowerCase() === 'true') syntheticArgv.push(flag); | |
| } else { | |
| syntheticArgv.push(`${flag}=${val}`); | |
| } | |
| } | |
| const args = parser.parse_args([...syntheticArgv, ...process.argv.slice(2)]); | |
| const clean = !args.no_build; | |
| cfg.outDir = path.resolve(ensureDir(args.out || cfg.outDir)); | |
| cfg.lockFile = pjoin(cfg.outDir, 'watch.lock'); | |
| // Only create the build lock if we are actually going to build If --no-build | |
| // is passed, we can run simultaneoushy without worrying about the build lock, | |
| // since we won't be writing to the output directories. | |
| if (!args.no_build) { | |
| prepareBuildLock(); | |
| } | |
| cfg.outUiDir = ensureDir(pjoin(cfg.outDir, 'ui'), clean); | |
| cfg.outUiTestArtifactsDir = ensureDir(pjoin(cfg.outDir, 'ui-test-artifacts')); | |
| cfg.outExtDir = ensureDir(pjoin(cfg.outUiDir, 'chrome_extension')); | |
| cfg.outDistRootDir = ensureDir(pjoin(cfg.outUiDir, 'dist')); | |
| const proc = exec('python3', [VERSION_SCRIPT, '--stdout'], {stdout: 'pipe'}); | |
| cfg.version = proc.stdout.toString().trim(); | |
| cfg.outDistDir = ensureDir(pjoin(cfg.outDistRootDir, cfg.version)); | |
| cfg.outTscDir = ensureDir(pjoin(cfg.outUiDir, 'tsc')); | |
| cfg.outGenDir = ensureDir(pjoin(cfg.outUiDir, 'tsc/gen')); | |
| cfg.testFilter = args.test_filter || ''; | |
| cfg.watch = !!args.watch; | |
| cfg.verbose = !!args.verbose; | |
| cfg.debug = !!args.debug; | |
| cfg.bigtrace = !!args.bigtrace; | |
| cfg.engineBench = !!args.enable_engine_bench; | |
| cfg.openPerfettoTrace = !!args.open_perfetto_trace; | |
| cfg.startHttpServer = args.serve; | |
| cfg.noOverrideGnArgs = !!args.no_override_gn_args; | |
| if (args.minify_js) { | |
| cfg.minifyJs = args.minify_js; | |
| } | |
| cfg.noSourceMaps = !!args.no_source_maps; | |
| cfg.noTreeshake = !!args.no_treeshake; | |
| if (args.bigtrace) { | |
| cfg.outBigtraceDistDir = ensureDir(pjoin(cfg.outDistDir, 'bigtrace')); | |
| } | |
| if (cfg.openPerfettoTrace) { | |
| cfg.outOpenPerfettoTraceDistDir = ensureDir( | |
| pjoin(cfg.outDistRootDir, 'open_perfetto_trace'), | |
| ); | |
| } | |
| if (args.serve_host) { | |
| cfg.httpServerListenHost = args.serve_host; | |
| } | |
| if (args.serve_port) { | |
| cfg.httpServerListenPort = args.serve_port; | |
| } | |
| if (args.interactive) { | |
| process.env.PERFETTO_UI_TESTS_INTERACTIVE = '1'; | |
| } | |
| if (args.rebaseline) { | |
| process.env.PERFETTO_UI_TESTS_REBASELINE = '1'; | |
| } | |
| if (args.cross_origin_isolation) { | |
| cfg.crossOriginIsolation = true; | |
| } | |
| cfg.allowAllHosts = !!args.allow_all_hosts; | |
| cfg.check = !!args.typecheck; | |
| cfg.useHmr = cfg.watch && cfg.startHttpServer && !!!args.bundle; | |
| cfg.onlyWasmMemory64 = !!args.only_wasm_memory64; | |
| cfg.titleOverride = args.title || ''; | |
| cfg.wasmModules = ['traceconv', 'proto_utils', 'trace_processor_memory64']; | |
| if (!cfg.onlyWasmMemory64) { | |
| cfg.wasmModules.push('trace_processor'); | |
| } | |
| function terminateChildProcesses() { | |
| for (const proc of subprocesses) { | |
| console.log(`Terminating child process with PID ${proc.pid}`); | |
| proc.kill(); // SIGTERM is the default | |
| } | |
| } | |
| // Called whenever the process exits due to: | |
| // 1. The JS loop running out of work - (normal exit or uncaught exception). | |
| // 2. Manually calling process.exit(). | |
| process.on('exit', () => { | |
| terminateChildProcesses(); | |
| }); | |
| // Register signal handlers for the usual termination signals. Only handle | |
| // once per signal so we can then call process.kill(sig) in order for the | |
| // process to exit with the correct exit code. | |
| for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) { | |
| process.once(sig, () => { | |
| terminateChildProcesses(); | |
| process.kill(process.pid, sig); | |
| }); | |
| } | |
| if (!args.no_depscheck) { | |
| // Check that deps are current before starting. | |
| const installBuildDeps = pjoin(ROOT_DIR, 'tools/install-build-deps'); | |
| const checkDepsPath = pjoin(cfg.outDir, '.check_deps'); | |
| let args = [installBuildDeps, `--check-only=${checkDepsPath}`, '--ui']; | |
| if (process.platform === 'darwin') { | |
| const result = childProcess.spawnSync('arch', ['-arm64', 'true']); | |
| const isArm64Capable = result.status === 0; | |
| if (isArm64Capable) { | |
| const archArgs = ['arch', '-arch', 'arm64']; | |
| args = archArgs.concat(args); | |
| } | |
| } | |
| const cmd = args.shift(); | |
| exec(cmd, args); | |
| } | |
| console.log('Entering', cfg.outDir); | |
| process.chdir(cfg.outDir); | |
| // Enqueue empty task. This is needed only for --no-build --serve. The HTTP | |
| // server is started when the task queue reaches quiescence, but it takes at | |
| // least one task for that. | |
| addTask(() => {}); | |
| if (args.no_build && cfg.check) { | |
| // --no-build --typecheck: just run tsc --noEmit assuming out dir exists. | |
| const tsProjects = ['ui', 'ui/src/service_worker']; | |
| if (cfg.bigtrace) tsProjects.push('ui/src/bigtrace'); | |
| if (cfg.openPerfettoTrace) tsProjects.push('ui/src/open_perfetto_trace'); | |
| for (const prj of tsProjects) { | |
| transpileTsProject(prj, {noEmit: true}); | |
| } | |
| } else if (!args.no_build) { | |
| updateSymlinks(); // Links //ui/out -> //out/xxx/ui/ | |
| buildWasm(args.no_wasm); | |
| copySyntaqliteRuntime(); | |
| scanDir('ui/src/assets'); | |
| scanDir('ui/src/chrome_extension'); | |
| scanDir('buildtools/typefaces'); | |
| scanDir('buildtools/catapult_trace_viewer'); | |
| compileProtos(); | |
| generateStdlibDocs(); | |
| const tsProjects = ['ui', 'ui/src/service_worker']; | |
| if (cfg.bigtrace) tsProjects.push('ui/src/bigtrace'); | |
| if (cfg.openPerfettoTrace) { | |
| scanDir('ui/src/open_perfetto_trace'); | |
| tsProjects.push('ui/src/open_perfetto_trace'); | |
| } | |
| if (cfg.engineBench) { | |
| scanDir('ui/src/engine_bench'); | |
| } | |
| if (cfg.check) { | |
| for (const prj of tsProjects) { | |
| transpileTsProject(prj, {noEmit: true}); | |
| } | |
| } else { | |
| // Vite owns TS transpile + bundling. tsc is invoked separately purely | |
| // for type checking. In non-watch builds it runs synchronously and a | |
| // type error fails the build. In watch mode tsc --watch runs async in | |
| // the background and prints errors without killing the build. | |
| for (const prj of tsProjects) { | |
| if (cfg.watch) { | |
| transpileTsProject(prj, { | |
| watch: true, | |
| noEmit: true, | |
| noErrCheck: true, | |
| }); | |
| } else { | |
| transpileTsProject(prj, {noEmit: true}); | |
| } | |
| } | |
| runVite(); | |
| genServiceWorkerManifestJson(); | |
| // Watches the /dist. When changed: | |
| // - Notifies the HTTP live reload clients. | |
| // - Regenerates the ServiceWorker file map. | |
| scanDir(cfg.outDistRootDir); | |
| } | |
| } | |
| // We should enter the loop only in watch mode, where tsc and rollup are | |
| // asynchronous because they run in watch mode. | |
| if (args.no_build && !isDistComplete()) { | |
| console.log('No build was requested, but artifacts are not available.'); | |
| console.log('In case of execution error, re-run without --no-build.'); | |
| } | |
| if (!args.no_build && !cfg.check) { | |
| const tStart = performance.now(); | |
| while (!isDistComplete()) { | |
| const secs = Math.ceil((performance.now() - tStart) / 1000); | |
| process.stdout.write( | |
| `\t\tWaiting for first build to complete... ${secs} s\r`, | |
| ); | |
| await new Promise((r) => setTimeout(r, 500)); | |
| } | |
| } | |
| if (cfg.watch) console.log('\nFirst build completed!'); | |
| if (cfg.startHttpServer) { | |
| if (cfg.useHmr) { | |
| await startViteDevServer(); | |
| } else { | |
| startServer(); | |
| } | |
| } | |
| if (args.run_unittests) { | |
| runTests(); | |
| } | |
| } | |
| // ----------- | |
| // Build rules | |
| // ----------- | |
| function runTests() { | |
| // Vitest reads ui/vitest.config.mjs by default. ts is transpiled on the fly | |
| // by Vite, so there's no tsc-emitted .js layer involved. | |
| const args = [ | |
| cfg.watch ? 'watch' : 'run', | |
| '--config', | |
| pjoin(ROOT_DIR, 'ui/vitest.config.mjs'), | |
| ]; | |
| if (cfg.testFilter.length > 0) { | |
| args.push('-t', cfg.testFilter); | |
| } | |
| if (cfg.watch) { | |
| addTask(execModule, ['vitest', args, {async: true}]); | |
| } else { | |
| addTask(execModule, ['vitest', args]); | |
| } | |
| } | |
| function cpHtml(src, filename) { | |
| let html = fs.readFileSync(src).toString(); | |
| // First copy the html as-is into the dist/v1.2.3/ directory. This is | |
| // only used for archival purporses, so one can open | |
| // ui.perfetto.dev/v1.2.3/ to skip the auto-update and channel logic. | |
| fs.writeFileSync(pjoin(cfg.outDistDir, filename), html); | |
| // Then copy it into the dist/ root by patching the version code. | |
| // TODO(primiano): in next CLs, this script should take a | |
| // --release_map=xxx.json argument, to populate this with multiple channels. | |
| const versionMap = JSON.stringify({stable: cfg.version}); | |
| const bodyRegex = /data-perfetto_version='[^']*'/; | |
| html = html.replace(bodyRegex, `data-perfetto_version='${versionMap}'`); | |
| // If --title was provided, patch the page title. Useful when running | |
| // multiple dev server instances to distinguish browser tabs. | |
| if (cfg.titleOverride) { | |
| html = html.replace( | |
| /<title>[^<]*<\/title>/, | |
| `<title>${cfg.titleOverride}</title>`, | |
| ); | |
| } | |
| fs.writeFileSync(pjoin(cfg.outDistRootDir, filename), html); | |
| } | |
| function copyIndexHtml(src) { | |
| addTask(cpHtml, [src, 'index.html']); | |
| } | |
| function copyBigtraceHtml(src) { | |
| if (cfg.bigtrace) { | |
| addTask(cpHtml, [src, 'bigtrace.html']); | |
| } | |
| } | |
| function copyOpenPerfettoTraceHtml(src) { | |
| if (cfg.openPerfettoTrace) { | |
| addTask(cp, [src, pjoin(cfg.outOpenPerfettoTraceDistDir, 'index.html')]); | |
| } | |
| } | |
| function copyEngineBenchHtml(src) { | |
| if (!cfg.engineBench) return; | |
| // Goes next to engine_bench_bundle.js so its relative <script> resolves. | |
| addTask(cp, [src, pjoin(cfg.outDistDir, 'engine_bench.html')]); | |
| addTask(makeEngineBenchRedirect, []); | |
| } | |
| function makeEngineBenchRedirect() { | |
| const target = `${cfg.version}/engine_bench.html`; | |
| // Redirect via JS so the bench knob query string is preserved. | |
| const html = | |
| '<!DOCTYPE html><meta charset="utf-8">' + | |
| '<title>Perfetto engine bench (redirect)</title>' + | |
| `<script>location.replace(${JSON.stringify(target)} + location.search + ` + | |
| 'location.hash);</script>' + | |
| `<noscript><meta http-equiv="refresh" content="0; url=${target}">` + | |
| `<p>Redirecting to <a href="${target}">${target}</a>.</p></noscript>\n`; | |
| fs.writeFileSync(pjoin(cfg.outDistRootDir, 'engine_bench.html'), html); | |
| } | |
| function copyAssets(src, dst) { | |
| addTask(cp, [src, pjoin(cfg.outDistDir, 'assets', dst)]); | |
| if (cfg.bigtrace) { | |
| addTask(cp, [src, pjoin(cfg.outBigtraceDistDir, 'assets', dst)]); | |
| } | |
| } | |
| function copyUiTestArtifactsAssets(src, dst) { | |
| addTask(cp, [src, pjoin(cfg.outUiTestArtifactsDir, dst)]); | |
| } | |
| function postProcessProtosDts() { | |
| const dstTs = pjoin(cfg.outGenDir, 'protos.d.ts'); | |
| let content = fs.readFileSync(dstTs, 'utf8'); | |
| content = content.replace(/import Long = require\("long"\);\r?\n/g, ''); | |
| fs.writeFileSync(dstTs, content, 'utf8'); | |
| } | |
| function compileProtos() { | |
| const dstJs = pjoin(cfg.outGenDir, 'protos.js'); | |
| const dstTs = pjoin(cfg.outGenDir, 'protos.d.ts'); | |
| const inputs = [ | |
| 'protos/perfetto/ipc/consumer_port.proto', | |
| 'protos/perfetto/ipc/wire_protocol.proto', | |
| 'protos/perfetto/trace/perfetto/perfetto_metatrace.proto', | |
| 'protos/perfetto/perfetto_sql/structured_query.proto', | |
| 'protos/perfetto/trace_processor/trace_processor.proto', | |
| ]; | |
| // Can't put --no-comments here - The comments are load bearing for | |
| // the pbts invocation which follows. | |
| const pbjsArgs = [ | |
| '--no-beautify', | |
| '--force-number', | |
| '--no-delimited', | |
| '--no-verify', | |
| '-t', | |
| 'static-module', | |
| '-w', | |
| 'es6', | |
| '-p', | |
| ROOT_DIR, | |
| '-o', | |
| dstJs, | |
| ].concat(inputs); | |
| addTask(execModule, ['pbjs', pbjsArgs]); | |
| // Note: If you are looking into slowness of pbts it is not pbts | |
| // itself that is slow. It invokes jsdoc to parse the comments out of | |
| // the |dstJs| with https://github.com/hegemonic/catharsis which is | |
| // pinning a CPU core the whole time. | |
| const pbtsArgs = ['--no-comments', '-p', ROOT_DIR, '-o', dstTs, dstJs]; | |
| addTask(execModule, ['pbts', pbtsArgs]); | |
| addTask(postProcessProtosDts, []); | |
| } | |
| function generateStdlibDocs() { | |
| const cmd = pjoin(ROOT_DIR, 'tools/gen_stdlib_docs_json.py'); | |
| const stdlibDir = pjoin(ROOT_DIR, 'src/trace_processor/perfetto_sql/stdlib'); | |
| const stdlibFiles = listFilesRecursive(stdlibDir).filter( | |
| (filePath) => path.extname(filePath) === '.sql', | |
| ); | |
| addTask(exec, [ | |
| cmd, | |
| [ | |
| '--json-out', | |
| pjoin(cfg.outDistDir, 'stdlib_docs.json'), | |
| '--metadata-only', | |
| '--minify', | |
| ...stdlibFiles, | |
| ], | |
| ]); | |
| } | |
| function updateSymlinks() { | |
| // /ui/out -> /out/ui. | |
| mklink(cfg.outUiDir, pjoin(ROOT_DIR, 'ui/out')); | |
| // /ui/src/gen -> /out/ui/ui/tsc/gen) | |
| mklink(cfg.outGenDir, pjoin(ROOT_DIR, 'ui/src/gen')); | |
| // /out/ui/test/data -> /test/data (For UI tests). | |
| mklink( | |
| pjoin(ROOT_DIR, 'test/data'), | |
| pjoin(ensureDir(pjoin(cfg.outDir, 'test')), 'data'), | |
| ); | |
| // Creates a out/dist_version -> out/dist/v1.2.3 symlink, so rollup config | |
| // can point to that without having to know the current version number. | |
| mklink( | |
| path.relative(cfg.outUiDir, cfg.outDistDir), | |
| pjoin(cfg.outUiDir, 'dist_version'), | |
| ); | |
| mklink( | |
| pjoin(ROOT_DIR, 'ui/node_modules'), | |
| pjoin(cfg.outTscDir, 'node_modules'), | |
| ); | |
| } | |
| // Invokes ninja for building the {trace_processor, traceconv} Wasm modules. | |
| // It copies the .wasm directly into the out/dist/ dir, and the .js/.ts into | |
| // out/tsc/, so the typescript compiler and the bundler can pick them up. | |
| function buildWasm(skipWasmBuild) { | |
| if (!skipWasmBuild) { | |
| if (!cfg.noOverrideGnArgs) { | |
| let gnVars = `is_debug=${cfg.debug}`; | |
| if (childProcess.spawnSync('which', ['ccache']).status === 0) { | |
| gnVars += ` cc_wrapper="ccache"`; | |
| } | |
| const gnArgs = ['gen', `--args=${gnVars}`, cfg.outDir]; | |
| addTask(exec, [pjoin(ROOT_DIR, 'tools/gn'), gnArgs]); | |
| } | |
| const ninjaArgs = ['-C', cfg.outDir]; | |
| ninjaArgs.push(...cfg.wasmModules.map((x) => `${x}_wasm`)); | |
| addTask(exec, [pjoin(ROOT_DIR, 'tools/ninja'), ninjaArgs]); | |
| } | |
| for (const wasmMod of cfg.wasmModules) { | |
| const isMem64 = wasmMod.endsWith('_memory64'); | |
| const wasmOutDir = pjoin(cfg.outDir, isMem64 ? 'wasm_memory64' : 'wasm'); | |
| // The .wasm file goes directly into the dist dir (also .map in debug) | |
| for (const ext of ['.wasm'].concat(cfg.debug ? ['.wasm.map'] : [])) { | |
| const src = `${wasmOutDir}/${wasmMod}${ext}`; | |
| addTask(cp, [src, pjoin(cfg.outDistDir, wasmMod + ext)]); | |
| } | |
| // The .js / .ts go into intermediates, they will be bundled by rollup. | |
| for (const ext of ['.js', '.d.ts']) { | |
| const fname = `${wasmMod}${ext}`; | |
| addTask(cp, [pjoin(wasmOutDir, fname), pjoin(cfg.outGenDir, fname)]); | |
| } | |
| } | |
| } | |
| function copySyntaqliteRuntime() { | |
| const srcDir = pjoin(ROOT_DIR, 'ui/node_modules/syntaqlite/wasm'); | |
| const dstDir = pjoin(cfg.outDistDir, 'assets'); | |
| for (const fname of [ | |
| 'syntaqlite-runtime.js', | |
| 'syntaqlite-runtime.wasm', | |
| 'syntaqlite-sqlite.wasm', | |
| ]) { | |
| addTask(cp, [pjoin(srcDir, fname), pjoin(dstDir, fname)]); | |
| // The bigtrace bundle resolves assets against its own serving root. | |
| if (cfg.bigtrace) { | |
| addTask(cp, [ | |
| pjoin(srcDir, fname), | |
| pjoin(cfg.outBigtraceDistDir, 'assets', fname), | |
| ]); | |
| } | |
| } | |
| addTask(buildSyntaqlitePerfettoDialect, []); | |
| if (cfg.bigtrace) { | |
| // Tasks run in queue order, so this copies the freshly-built dialect. | |
| addTask(cp, [ | |
| pjoin(cfg.outDistDir, 'assets', 'syntaqlite-perfetto.wasm'), | |
| pjoin(cfg.outBigtraceDistDir, 'assets', 'syntaqlite-perfetto.wasm'), | |
| ]); | |
| } | |
| } | |
| function getBuildToolsBinDir() { | |
| function getBinDirName() { | |
| switch (process.platform) { | |
| case 'darwin': | |
| return 'mac'; | |
| case 'linux': | |
| return 'linux64'; | |
| default: | |
| throw new Error(`Unsupported platform: ${process.platform}`); | |
| } | |
| } | |
| return pjoin(ROOT_DIR, 'buildtools', getBinDirName()); | |
| } | |
| function buildSyntaqlitePerfettoDialect() { | |
| const buildToolsBinDir = getBuildToolsBinDir(); | |
| const emcc = pjoin(buildToolsBinDir, 'emsdk/emscripten/emcc'); | |
| const src = pjoin( | |
| ROOT_DIR, | |
| 'src/perfetto_sql/syntaqlite/syntaqlite_perfetto.c', | |
| ); | |
| const dst = pjoin(cfg.outDistDir, 'assets', 'syntaqlite-perfetto.wasm'); | |
| try { | |
| const srcMtime = fs.statSync(src).mtimeMs; | |
| const dstMtime = fs.statSync(dst).mtimeMs; | |
| if (dstMtime >= srcMtime) return; | |
| } catch (e) { | |
| /* dst missing → rebuild */ | |
| } | |
| ensureDir(path.dirname(dst)); | |
| const emConfig = pjoin(ROOT_DIR, 'gn/standalone/.emscripten'); | |
| const prevEmConfig = process.env.EM_CONFIG; | |
| process.env.EM_CONFIG = emConfig; | |
| try { | |
| exec(emcc, [ | |
| '-O2', | |
| '-sSIDE_MODULE=2', | |
| '-sEXPORTED_FUNCTIONS=_syntaqlite_perfetto_dialect_template', | |
| '-o', | |
| dst, | |
| src, | |
| ]); | |
| } finally { | |
| if (prevEmConfig === undefined) delete process.env.EM_CONFIG; | |
| else process.env.EM_CONFIG = prevEmConfig; | |
| } | |
| } | |
| // This transpiles all the sources (frontend, controller, engine, extension) in | |
| // one go. The only project that has a dedicated invocation is service_worker. | |
| function transpileTsProject(project, options) { | |
| const args = ['--project', pjoin(ROOT_DIR, project)]; | |
| options = options || {}; | |
| if (options.noEmit) args.push('--noEmit'); | |
| if (options.watch) { | |
| args.push('--watch', '--preserveWatchOutput'); | |
| addTask(execModule, [ | |
| 'tsc', | |
| args, | |
| { | |
| async: true, | |
| noErrCheck: options.noErrCheck, | |
| }, | |
| ]); | |
| } else if (options.noEmit) { | |
| addTask(execModule, ['tsc', args]); | |
| } else { | |
| addTask(execModule, ['tsc', args, {noErrCheck: options.noErrCheck}]); | |
| } | |
| } | |
| // Runs `vite build` (optionally in --watch mode) to transpile TS and produce | |
| // the {frontend, engine, traceconv}_bundle.js files in cfg.outDistDir. All | |
| // configuration lives in ui/vite.config.mjs; flags are passed via env vars to | |
| // keep parity with the old rollup.config.js conventions. | |
| // | |
| // In watch+serve mode the frontend bundle is replaced by an in-process Vite | |
| // dev server (see startViteDevServer) — workers and the service worker still | |
| // go through `vite build --watch` because they're loaded as separate files by | |
| // `new Worker(assetSrc(...))` / SW registration. | |
| function runVite() { | |
| const baseEnv = { | |
| NO_SOURCE_MAPS: cfg.noSourceMaps ? 'true' : '', | |
| NO_TREESHAKE: cfg.noTreeshake ? 'true' : '', | |
| MINIFY_JS: cfg.minifyJs || '', | |
| IS_MEMORY64_ONLY: cfg.onlyWasmMemory64 ? 'true' : '', | |
| }; | |
| const bundles = ['engine', 'traceconv', 'service_worker', 'chrome_extension']; | |
| if (!cfg.useHmr) bundles.unshift('frontend'); | |
| if (cfg.bigtrace) bundles.push('bigtrace'); | |
| if (cfg.engineBench) bundles.push('engine_bench', 'engine_bench_worker'); | |
| if (cfg.openPerfettoTrace) bundles.push('open_perfetto_trace'); | |
| for (const bundle of bundles) { | |
| const args = ['build', '--config', pjoin(ROOT_DIR, 'ui/vite.config.mjs')]; | |
| if (cfg.watch) args.push('--watch'); | |
| if (!cfg.verbose) args.push('--logLevel', 'warn'); | |
| addTask(execModule, [ | |
| 'vite', | |
| args, | |
| { | |
| async: cfg.watch, | |
| env: {...baseEnv, BUNDLE: bundle}, | |
| }, | |
| ]); | |
| } | |
| } | |
| function genServiceWorkerManifestJson() { | |
| function makeManifest() { | |
| const manifest = {resources: {}}; | |
| // When building the subresource manifest skip source maps, the manifest | |
| // itself and the copy of the index.html which is copied under /v1.2.3/. | |
| // The root /index.html will be fetched by service_worker.js separately. | |
| const skipRegex = /(\.map|manifest\.json|index.html)$/; | |
| walk( | |
| cfg.outDistDir, | |
| (absPath) => { | |
| const contents = fs.readFileSync(absPath); | |
| const relPath = path.relative(cfg.outDistDir, absPath); | |
| const b64 = crypto | |
| .createHash('sha256') | |
| .update(contents) | |
| .digest('base64'); | |
| manifest.resources[relPath] = 'sha256-' + b64; | |
| }, | |
| skipRegex, | |
| ); | |
| const manifestJson = JSON.stringify(manifest, null, 2); | |
| fs.writeFileSync(pjoin(cfg.outDistDir, 'manifest.json'), manifestJson); | |
| } | |
| addTask(makeManifest, []); | |
| } | |
| // In dev (--watch --serve), Vite owns the user-facing port. It serves the | |
| // frontend entry as native ESM transformed on the fly; everything else | |
| // (wasm, fonts, css, /test/, /v1.2.3/-relative paths) is layered on as | |
| // middleware. The custom HTTP server (startServer) is bypassed. | |
| async function startViteDevServer() { | |
| // vite.config.mjs reads these at import time; set before createServer(). | |
| if (cfg.onlyWasmMemory64) process.env.IS_MEMORY64_ONLY = 'true'; | |
| if (cfg.noSourceMaps) process.env.NO_SOURCE_MAPS = 'true'; | |
| if (cfg.noTreeshake) process.env.NO_TREESHAKE = 'true'; | |
| if (cfg.minifyJs) process.env.MINIFY_JS = cfg.minifyJs; | |
| const {createServer} = await import('vite'); | |
| const port = cfg.httpServerListenPort ?? DEFAULT_PORT; | |
| const headers = cfg.crossOriginIsolation | |
| ? { | |
| 'Cross-Origin-Opener-Policy': 'same-origin', | |
| 'Cross-Origin-Embedder-Policy': 'require-corp', | |
| } | |
| : undefined; | |
| const server = await createServer({ | |
| configFile: pjoin(ROOT_DIR, 'ui/vite.config.mjs'), | |
| server: { | |
| host: cfg.httpServerListenHost, | |
| port, | |
| strictPort: false, | |
| headers, | |
| // By default Vite only accepts requests whose Host header matches the | |
| // bind host, to guard against DNS-rebind attacks. When --allow-all-hosts | |
| // is passed, accept any Host header so the dev server can sit behind an | |
| // arbitrary reverse proxy. This is safe for us: the app is client-only | |
| // and the source is open, so there's nothing to rebind against. | |
| allowedHosts: cfg.allowAllHosts ? true : undefined, | |
| // Vite needs to read source files outside its root (ui/src/assets, | |
| // ui/src/gen via the symlink to out/, buildtools/, etc.). | |
| fs: {allow: [ROOT_DIR]}, | |
| }, | |
| }); | |
| // Read the source index.html, patch for dev (point the inline bootstrap | |
| // at the frontend source entry), and serve at /. | |
| const indexSrc = pjoin(ROOT_DIR, 'ui/src/assets/index.html'); | |
| const patchHtml = (raw) => { | |
| // Replace the prod-relative bundle path with the dev source entry. | |
| // Vite's transformIndexHtml will then resolve /frontend/index.ts through | |
| // its module graph (with HMR client injected). | |
| let html = raw.replace( | |
| /script\.src\s*=\s*version\s*\+\s*['"]\/frontend_bundle\.js['"];?/, | |
| `script.src = '/frontend/index.ts';`, | |
| ); | |
| // Native ESM entry needs type="module". Vite also needs a global hint at | |
| // where versioned assets live (assetSrc()/getServingRoot() use it). | |
| html = html.replace( | |
| /script\.async\s*=\s*true;?/, | |
| `script.type = 'module'; window.__GLOBAL_ASSET_ROOT__ = version + '/';`, | |
| ); | |
| // Patch the version map (same job as cpHtml in prod). In dev there is | |
| // exactly one channel served from the root, version '.'. | |
| const versionMap = JSON.stringify({stable: cfg.version}); | |
| html = html.replace( | |
| /data-perfetto_version='[^']*'/, | |
| `data-perfetto_version='${versionMap}'`, | |
| ); | |
| if (cfg.titleOverride) { | |
| html = html.replace( | |
| /<title>[^<]*<\/title>/, | |
| `<title>${cfg.titleOverride}</title>`, | |
| ); | |
| } | |
| return html; | |
| }; | |
| // Serve /test/* from the repo (used by some e2e flows). Mirrors the | |
| // equivalent branch in startServer(). | |
| server.middlewares.use((req, res, next) => { | |
| const url = req.url.split('?', 1)[0]; | |
| if (!url.startsWith('/test/')) return next(); | |
| const absPath = pjoin(ROOT_DIR, url); | |
| if (path.relative(ROOT_DIR, absPath).startsWith('..')) { | |
| res.statusCode = 403; | |
| return res.end('403'); | |
| } | |
| fs.readFile(absPath, (err, data) => { | |
| if (err) { | |
| res.statusCode = 404; | |
| return res.end(); | |
| } | |
| res.end(data); | |
| }); | |
| }); | |
| // frontend/index.ts inserts `<link rel="stylesheet" href="frontend.css">` | |
| // at runtime, which is needed in prod but in dev the styles come from the | |
| // SCSS module that Vite transforms inline. Return a 200 empty body so the | |
| // link's onload fires and init can proceed. | |
| server.middlewares.use((req, res, next) => { | |
| const url = req.url.split('?', 1)[0]; | |
| if (url === '/frontend.css' || url.endsWith('/frontend.css')) { | |
| res.setHeader('Content-Type', 'text/css'); | |
| return res.end('/* dev stub: styles injected by Vite */'); | |
| } | |
| next(); | |
| }); | |
| // Fall back to serving files from outDistRootDir / outDistDir for anything | |
| // Vite hasn't claimed (wasm modules, fonts under /assets/, manifest.json, | |
| // etc.). In prod, frontend.css lives inside the versioned dir, so the | |
| // url('assets/Roboto.woff2') paths in typefaces.scss resolve to | |
| // /<version>/assets/...; in dev the stylesheet is injected from /, so the | |
| // browser asks for /assets/Roboto.woff2. Try outDistDir as a second root | |
| // so those font requests succeed. | |
| server.middlewares.use((req, res, next) => { | |
| const url = req.url.split('?', 1)[0]; | |
| // Vite handles JS/TS/CSS modules and HMR endpoints itself. | |
| if (url === '/' || url === '/index.html') return next(); | |
| const roots = [cfg.outDistRootDir, cfg.outDistDir]; | |
| const tryRoot = (i) => { | |
| if (i >= roots.length) return next(); | |
| const root = roots[i]; | |
| const absPath = path.normalize(pjoin(root, url)); | |
| if (path.relative(root, absPath).startsWith('..')) return tryRoot(i + 1); | |
| fs.stat(absPath, (err, stat) => { | |
| if (err || !stat.isFile()) return tryRoot(i + 1); | |
| fs.readFile(absPath, (rerr, data) => { | |
| if (rerr) return tryRoot(i + 1); | |
| const ext = url.split('.').pop(); | |
| const mime = | |
| { | |
| wasm: 'application/wasm', | |
| woff2: 'font/woff2', | |
| png: 'image/png', | |
| json: 'application/json', | |
| css: 'text/css', | |
| js: 'application/javascript', | |
| html: 'text/html', | |
| }[ext] || 'application/octet-stream'; | |
| res.setHeader('Content-Type', mime); | |
| res.end(data); | |
| }); | |
| }); | |
| }; | |
| tryRoot(0); | |
| }); | |
| // Last: serve the patched index.html at / (and any other unmatched route, | |
| // mirroring the SPA convention). | |
| server.middlewares.use(async (req, res, next) => { | |
| const url = req.url.split('?', 1)[0]; | |
| if (url !== '/' && url !== '/index.html') return next(); | |
| try { | |
| const raw = fs.readFileSync(indexSrc, 'utf8'); | |
| const patched = patchHtml(raw); | |
| const transformed = await server.transformIndexHtml(url, patched); | |
| res.setHeader('Content-Type', 'text/html'); | |
| res.end(transformed); | |
| } catch (e) { | |
| next(e); | |
| } | |
| }); | |
| await server.listen(); | |
| server.printUrls(); | |
| // Make sure the dev server is shut down on process exit. | |
| subprocesses.push({pid: 'vite-dev', kill: () => server.close()}); | |
| } | |
| function startServer() { | |
| const server = http.createServer(function (req, res) { | |
| console.debug(req.method, req.url); | |
| let uri = req.url.split('?', 1)[0]; | |
| if (uri.endsWith('/')) { | |
| uri += 'index.html'; | |
| } | |
| if (uri === '/live_reload') { | |
| // Implements the Server-Side-Events protocol. | |
| const head = { | |
| 'Content-Type': 'text/event-stream', | |
| 'Connection': 'keep-alive', | |
| 'Cache-Control': 'no-cache', | |
| }; | |
| res.writeHead(200, head); | |
| const arrayIdx = httpWatches.length; | |
| // We never remove from the array, the delete leaves an undefined item | |
| // around. It makes keeping track of the index easier at the cost of a | |
| // small leak. | |
| httpWatches.push(res); | |
| req.on('close', () => delete httpWatches[arrayIdx]); | |
| return; | |
| } | |
| let absPath = path.normalize(path.join(cfg.outDistRootDir, uri)); | |
| // We want to be able to use the data in '/test/' for e2e tests. | |
| // However, we don't want do create a symlink into the 'dist/' dir, | |
| // because 'dist/' gets shipped on the production server. | |
| if (uri.startsWith('/test/')) { | |
| absPath = pjoin(ROOT_DIR, uri); | |
| } | |
| // Don't serve contents outside of the project root (b/221101533). | |
| if (path.relative(ROOT_DIR, absPath).startsWith('..')) { | |
| res.writeHead(403); | |
| res.end('403 Forbidden - Request path outside of the repo root'); | |
| return; | |
| } | |
| let stat; | |
| try { | |
| stat = fs.statSync(absPath); | |
| } catch (statErr) { | |
| res.writeHead(404); | |
| res.end(JSON.stringify(statErr)); | |
| return; | |
| } | |
| // Truncate to second precision: HTTP dates have 1s resolution, so the | |
| // sub-millisecond part of mtime would cause a permanent mismatch. | |
| const mtimeSec = Math.floor(stat.mtime.getTime() / 1000) * 1000; | |
| const mtimeStr = new Date(mtimeSec).toUTCString(); | |
| // Return 304 if the browser's cached copy is still fresh. | |
| const ifModifiedSince = req.headers['if-modified-since']; | |
| if ( | |
| ifModifiedSince !== undefined && | |
| new Date(ifModifiedSince).getTime() >= mtimeSec | |
| ) { | |
| res.writeHead(304); | |
| res.end(); | |
| return; | |
| } | |
| fs.readFile(absPath, function (err, data) { | |
| if (err) { | |
| res.writeHead(404); | |
| res.end(JSON.stringify(err)); | |
| return; | |
| } | |
| const mimeMap = { | |
| html: 'text/html', | |
| css: 'text/css', | |
| js: 'application/javascript', | |
| wasm: 'application/wasm', | |
| }; | |
| const ext = uri.split('.').pop(); | |
| const cType = mimeMap[ext] || 'octect/stream'; | |
| const acceptsGzip = (req.headers['accept-encoding'] || '').includes( | |
| 'gzip', | |
| ); | |
| const finalize = (body) => { | |
| const head = { | |
| 'Content-Type': cType, | |
| 'Content-Length': body.length, | |
| 'Last-Modified': mtimeStr, | |
| 'Cache-Control': 'no-cache', | |
| }; | |
| if (acceptsGzip) head['Content-Encoding'] = 'gzip'; | |
| if (cfg.crossOriginIsolation) { | |
| head['Cross-Origin-Opener-Policy'] = 'same-origin'; | |
| head['Cross-Origin-Embedder-Policy'] = 'require-corp'; | |
| } | |
| res.writeHead(200, head); | |
| res.write(body); | |
| res.end(); | |
| }; | |
| if (acceptsGzip) { | |
| zlib.gzip(data, (gzErr, compressed) => { | |
| finalize(gzErr ? data : compressed); | |
| }); | |
| } else { | |
| finalize(data); | |
| } | |
| }); | |
| }); | |
| let port = cfg.httpServerListenPort ?? DEFAULT_PORT; | |
| let retryCount = 0; | |
| // Pick the next free port starting at 10000 | |
| server.on('error', (e) => { | |
| if (e.code === 'EADDRINUSE') { | |
| if (cfg.httpServerListenPort === undefined) { | |
| if (retryCount <= 10) { | |
| // Try the next port. | |
| console.log(`Port ${port} is in use, trying ${port + 1}...`); | |
| ++port; | |
| ++retryCount; | |
| server.listen(port, cfg.httpServerListenHost); | |
| } else { | |
| console.error( | |
| `ERROR: Port ${port} is in use, and no free port found after 10 tries. Exiting.`, | |
| ); | |
| process.exit(1); | |
| } | |
| } else { | |
| console.error( | |
| `ERROR: Port ${port} is in use, and --serve-port was explicitly set. Exiting.`, | |
| ); | |
| process.exit(1); | |
| } | |
| } else { | |
| console.error('HTTP SERVER ERROR:', e); | |
| process.exit(1); | |
| } | |
| }); | |
| server.listen(port, cfg.httpServerListenHost); | |
| server.on('listening', () => { | |
| const {address, port} = server.address(); | |
| const host = address == '127.0.0.1' ? 'localhost' : address; | |
| console.log(`HTTP server is listening on http://${host}:${port}`); | |
| }); | |
| } | |
| function isDistComplete() { | |
| // In watch+serve mode the frontend bundle and its CSS are served live by | |
| // the Vite dev server, never materialised on disk. Only require the | |
| // artifacts that genuinely have to exist before the user can load a trace. | |
| const requiredArtifacts = [ | |
| ...(cfg.useHmr ? [] : ['frontend_bundle.js', 'frontend.css']), | |
| 'engine_bundle.js', | |
| 'traceconv_bundle.js', | |
| ...cfg.wasmModules.map((wasmMod) => `${wasmMod}.wasm`), | |
| ]; | |
| const relPaths = new Set(); | |
| walk(cfg.outDistDir, (absPath) => { | |
| relPaths.add(path.relative(cfg.outDistDir, absPath)); | |
| }); | |
| for (const fName of requiredArtifacts) { | |
| if (!relPaths.has(fName)) return false; | |
| } | |
| return true; | |
| } | |
| function notifyLiveServer(changedFile) { | |
| // Add file to pending set | |
| notifyLiveServerPendingFiles.add(changedFile); | |
| // Clear existing timeout if any | |
| if (liveServerDebounceTimerId > 0) { | |
| clearTimeout(liveServerDebounceTimerId); | |
| } | |
| // Set new timeout to batch notifications | |
| liveServerDebounceTimerId = setTimeout(() => { | |
| // Send all pending files in one batch | |
| for (const cli of httpWatches) { | |
| if (cli === undefined) continue; | |
| for (const file of notifyLiveServerPendingFiles) { | |
| cli.write('data: ' + path.relative(cfg.outDistRootDir, file) + '\n\n'); | |
| } | |
| } | |
| notifyLiveServerPendingFiles.clear(); | |
| liveServerDebounceTimerId = 0; | |
| }, LIVE_SERVER_DEBOUNCE_MS); | |
| } | |
| function copyExtensionAssets() { | |
| addTask(cp, [ | |
| pjoin(ROOT_DIR, 'ui/src/assets/logo-128.png'), | |
| pjoin(cfg.outExtDir, 'logo-128.png'), | |
| ]); | |
| addTask(cp, [ | |
| pjoin(ROOT_DIR, 'ui/src/chrome_extension/manifest.json'), | |
| pjoin(cfg.outExtDir, 'manifest.json'), | |
| ]); | |
| } | |
| // ----------------------- | |
| // Task chaining functions | |
| // ----------------------- | |
| function addTask(func, args) { | |
| const task = new Task(func, args); | |
| for (const t of tasks) { | |
| if (t.identity === task.identity) { | |
| return; | |
| } | |
| } | |
| tasks.push(task); | |
| setTimeout(runTasks, 0); | |
| } | |
| function runTasks() { | |
| const snapTasks = tasks.splice(0); // snap = std::move(tasks). | |
| tasksTot += snapTasks.length; | |
| for (const task of snapTasks) { | |
| const DIM = '\u001b[2m'; | |
| const BRT = '\u001b[37m'; | |
| const RST = '\u001b[0m'; | |
| const ms = (performance.now() - tStart) / 1000; | |
| const ts = `[${DIM}${ms.toFixed(3)}${RST}]`; | |
| const descr = task.description.substr(0, 80); | |
| console.log(`${ts} ${BRT}${++tasksRan}/${tasksTot}${RST}\t${descr}`); | |
| task.func.apply(/* this=*/ undefined, task.args); | |
| } | |
| } | |
| // Executes all the RULES that match the given |absPath|. | |
| function scanFile(absPath) { | |
| console.assert(fs.existsSync(absPath)); | |
| console.assert(path.isAbsolute(absPath)); | |
| const normPath = path.relative(ROOT_DIR, absPath); | |
| for (const rule of RULES) { | |
| const match = rule.r.exec(normPath); | |
| if (!match || match[0] !== normPath) continue; | |
| const captureGroup = match.length > 1 ? match[1] : undefined; | |
| rule.f(absPath, captureGroup); | |
| } | |
| } | |
| // Walks the passed |dir| recursively and, for each file, invokes the matching | |
| // RULES. If --watch is used, it also installs a fswatch() and re-triggers the | |
| // matching RULES on each file change. | |
| function scanDir(dir, regex) { | |
| const filterFn = regex ? (absPath) => regex.test(absPath) : () => true; | |
| const absDir = path.isAbsolute(dir) ? dir : pjoin(ROOT_DIR, dir); | |
| // Add a fs watch if in watch mode. | |
| if (cfg.watch) { | |
| fs.watch(absDir, {recursive: true}, (_eventType, relFilePath) => { | |
| const filePath = pjoin(absDir, relFilePath); | |
| if (!filterFn(filePath)) return; | |
| if (cfg.verbose) { | |
| console.log('File change detected', _eventType, filePath); | |
| } | |
| if (fs.existsSync(filePath)) { | |
| scanFile(filePath, filterFn); | |
| } | |
| }); | |
| } | |
| walk(absDir, (f) => { | |
| if (filterFn(f)) scanFile(f); | |
| }); | |
| } | |
| function exec(cmd, args, opts) { | |
| opts = opts || {}; | |
| opts.stdout = opts.stdout || 'inherit'; | |
| if (cfg.verbose) console.log(`${cmd} ${args.join(' ')}\n`); | |
| const spwOpts = {cwd: cfg.outDir, stdio: ['ignore', opts.stdout, 'inherit']}; | |
| if (opts.env) { | |
| spwOpts.env = {...process.env, ...opts.env}; | |
| } | |
| const checkExitCode = (code, signal) => { | |
| if (signal === 'SIGINT' || signal === 'SIGTERM') return; | |
| if (code !== 0 && !opts.noErrCheck) { | |
| console.error(`${cmd} ${args.join(' ')} failed with code ${code}`); | |
| process.exit(1); | |
| } | |
| }; | |
| if (opts.async) { | |
| const proc = childProcess.spawn(cmd, args, spwOpts); | |
| const procIndex = subprocesses.length; | |
| subprocesses.push(proc); | |
| return new Promise((resolve, _reject) => { | |
| proc.on('exit', (code, signal) => { | |
| delete subprocesses[procIndex]; | |
| checkExitCode(code, signal); | |
| resolve(); | |
| }); | |
| }); | |
| } else { | |
| const spawnRes = childProcess.spawnSync(cmd, args, spwOpts); | |
| checkExitCode(spawnRes.status, spawnRes.signal); | |
| return spawnRes; | |
| } | |
| } | |
| function execModule(module, args, opts) { | |
| const modPath = pjoin(ROOT_DIR, 'ui/node_modules/.bin', module); | |
| return exec(modPath, args || [], opts); | |
| } | |
| // ------------------------------------------ | |
| // File system & subprocess utility functions | |
| // ------------------------------------------ | |
| class Task { | |
| constructor(func, args) { | |
| this.func = func; | |
| this.args = args || []; | |
| // |identity| is used to dedupe identical tasks in the queue. | |
| this.identity = JSON.stringify([this.func.name, this.args]); | |
| } | |
| get description() { | |
| const ret = this.func.name.startsWith('exec') ? [] : [this.func.name]; | |
| const flattenedArgs = [].concat(...this.args); | |
| for (const arg of flattenedArgs) { | |
| if (typeof arg === 'object' && arg !== null) { | |
| ret.push(JSON.stringify(arg)); | |
| continue; | |
| } | |
| const argStr = `${arg}`; | |
| if (argStr.startsWith('/')) { | |
| ret.push(path.relative(cfg.outDir, arg)); | |
| } else { | |
| ret.push(argStr); | |
| } | |
| } | |
| return ret.join(' '); | |
| } | |
| } | |
| function walk(dir, callback, skipRegex) { | |
| for (const child of fs.readdirSync(dir)) { | |
| const childPath = pjoin(dir, child); | |
| const stat = fs.lstatSync(childPath); | |
| if (skipRegex !== undefined && skipRegex.test(child)) continue; | |
| if (stat.isDirectory()) { | |
| walk(childPath, callback, skipRegex); | |
| } else if (!stat.isSymbolicLink()) { | |
| callback(childPath); | |
| } | |
| } | |
| } | |
| // Recursively build a list of files in a given directory and return a list of | |
| // file paths, similar to `find -type f`. | |
| function listFilesRecursive(dir) { | |
| const fileList = []; | |
| walk(dir, (filePath) => { | |
| fileList.push(filePath); | |
| }); | |
| return fileList; | |
| } | |
| function ensureDir(dirPath, clean) { | |
| const exists = fs.existsSync(dirPath); | |
| if (exists && clean) { | |
| console.log('rm', dirPath); | |
| fs.rmSync(dirPath, {recursive: true}); | |
| } | |
| if (!exists || clean) fs.mkdirSync(dirPath, {recursive: true}); | |
| return dirPath; | |
| } | |
| function cp(src, dst) { | |
| ensureDir(path.dirname(dst)); | |
| if (cfg.verbose) { | |
| console.log( | |
| 'cp', | |
| path.relative(ROOT_DIR, src), | |
| '->', | |
| path.relative(ROOT_DIR, dst), | |
| ); | |
| } | |
| fs.copyFileSync(src, dst); | |
| } | |
| function mklink(src, dst) { | |
| // If the symlink already points to the right place don't touch it. This is | |
| // to avoid changing the mtime of the ui/ dir when unnecessary. | |
| if (fs.existsSync(dst)) { | |
| if (fs.lstatSync(dst).isSymbolicLink() && fs.readlinkSync(dst) === src) { | |
| return; | |
| } else { | |
| fs.unlinkSync(dst); | |
| } | |
| } | |
| fs.symlinkSync(src, dst); | |
| } | |
| function prepareBuildLock() { | |
| if (fs.existsSync(cfg.lockFile)) { | |
| const oldPid = fs.readFileSync(cfg.lockFile, 'utf8').trim(); | |
| let running = true; | |
| try { | |
| // Check if oldPid exists. | |
| process.kill(parseInt(oldPid), 0); | |
| } catch (e) { | |
| running = false; | |
| } | |
| if (running) { | |
| console.error( | |
| `Error: a build.mjs instance is already running (${cfg.lockFile} PID=${oldPid}).`, | |
| ); | |
| console.error( | |
| 'Hint: use --no-build (-n) to skip the build and avoid the lock.', | |
| ); | |
| process.exit(1); | |
| } else { | |
| console.log(`Removing stale lock file for PID ${oldPid}`); | |
| fs.unlinkSync(cfg.lockFile); | |
| } | |
| } | |
| fs.writeFileSync(cfg.lockFile, process.pid.toString()); | |
| process.on('exit', () => releaseBuildLock()); | |
| } | |
| function releaseBuildLock() { | |
| if (fs.existsSync(cfg.lockFile)) { | |
| const pid = fs.readFileSync(cfg.lockFile, 'utf8').trim(); | |
| if (pid === process.pid.toString()) { | |
| fs.unlinkSync(cfg.lockFile); | |
| } else { | |
| console.warn(`Ignoring stale lock file PID ${pid}`); | |
| } | |
| } | |
| } | |
| main(); |