| // 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. | |
| 'use strict'; | |
| // 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 || | |
| // +----------------------+ | +----------+ +--------------------+| | |
| // +------------------------------------------------+ | |
| const argparse = require('argparse'); | |
| const childProcess = require('child_process'); | |
| const crypto = require('crypto'); | |
| const fs = require('fs'); | |
| const http = require('http'); | |
| const path = require('path'); | |
| const zlib = require('zlib'); | |
| const pjoin = path.join; | |
| const ROOT_DIR = path.dirname(__dirname); // The repo root. | |
| const VERSION_SCRIPT = pjoin(ROOT_DIR, 'tools/write_version_header.py'); | |
| const GEN_IMPORTS_SCRIPT = pjoin(ROOT_DIR, 'tools/gen_ui_imports'); | |
| const DEFAULT_PORT = 10000; | |
| const cfg = { | |
| minifyJs: '', | |
| noSourceMaps: false, | |
| noTreeshake: false, | |
| watch: false, | |
| verbose: false, | |
| debug: false, | |
| bigtrace: false, | |
| startHttpServer: false, | |
| httpServerListenHost: '127.0.0.1', | |
| httpServerListenPort: undefined, | |
| onlyWasmMemory64: false, | |
| wasmModules: [], | |
| crossOriginIsolation: 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}, | |
| {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\/assets\/.+[.]scss|ui\/src\/(?:plugins|core_plugins)\/.+[.]scss/, f: compileScss}, | |
| {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('--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('--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('--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.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.check = !!args.typecheck; | |
| 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); | |
| generateImports('ui/src/core_plugins', 'all_core_plugins'); | |
| generateImports('ui/src/plugins', 'all_plugins'); | |
| scanDir('ui/src/assets'); | |
| scanDir('ui/src/plugins', /[.]scss$/); | |
| scanDir('ui/src/core_plugins', /[.]scss$/); | |
| scanDir('ui/src/chrome_extension'); | |
| scanDir('buildtools/typefaces'); | |
| scanDir('buildtools/catapult_trace_viewer'); | |
| compileProtos(); | |
| genVersion(); | |
| 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.check) { | |
| for (const prj of tsProjects) { | |
| transpileTsProject(prj, {noEmit: true}); | |
| } | |
| } else { | |
| for (const prj of tsProjects) { | |
| // When in watch mode, don't error out if we get typescript errors in the | |
| // initial build. Typescript errors on subsequent incremental builds don't | |
| // error out so the initial build should behave in the same way. | |
| // | |
| // Note: In non-watch mode, we do still break on typescript errors, | |
| // there's no change here. | |
| transpileTsProject(prj, {noErrCheck: cfg.watch}); | |
| } | |
| if (cfg.watch) { | |
| for (const prj of tsProjects) { | |
| transpileTsProject(prj, {watch: cfg.watch}); | |
| } | |
| } | |
| bundleJs('rollup.config.js'); | |
| 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) { | |
| startServer(); | |
| } | |
| if (args.run_unittests) { | |
| runTests('jest.unittest.config.js'); | |
| } | |
| } | |
| // ----------- | |
| // Build rules | |
| // ----------- | |
| function runTests(cfgFile) { | |
| const args = [ | |
| '--rootDir', | |
| cfg.outTscDir, | |
| '--verbose', | |
| '--runInBand', | |
| '--detectOpenHandles', | |
| '--forceExit', | |
| '--projects', | |
| pjoin(ROOT_DIR, 'ui/config', cfgFile), | |
| ]; | |
| if (cfg.testFilter.length > 0) { | |
| args.push('-t', cfg.testFilter); | |
| } | |
| if (cfg.watch) { | |
| args.push('--watchAll'); | |
| addTask(execModule, ['jest', args, {async: true}]); | |
| } else { | |
| addTask(execModule, ['jest', 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 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 compileScss() { | |
| const src = pjoin(ROOT_DIR, 'ui/src/assets/perfetto.scss'); | |
| const dst = pjoin(cfg.outDistDir, 'perfetto.css'); | |
| // In watch mode, don't exit(1) if scss fails. It can easily happen by | |
| // having a typo in the css. It will still print an error. | |
| const noErrCheck = !!cfg.watch; | |
| const args = [src, dst]; | |
| if (!cfg.verbose) { | |
| args.unshift('--quiet'); | |
| } | |
| addTask(execModule, ['sass', args, {noErrCheck}]); | |
| if (cfg.bigtrace) { | |
| addTask(cp, [dst, pjoin(cfg.outBigtraceDistDir, 'perfetto.css')]); | |
| } | |
| } | |
| 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', | |
| 'commonjs', | |
| '-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]); | |
| } | |
| function generateImports(dir, name) { | |
| // We have to use the symlink (ui/src/gen) rather than cfg.outGenDir | |
| // below since we want to generate the correct relative imports. For example: | |
| // ui/src/frontend/foo.ts | |
| // import '../gen/all_plugins.ts'; | |
| // ui/src/gen/all_plugins.ts (aka ui/out/tsc/gen/all_plugins.ts) | |
| // import '../frontend/some_plugin.ts'; | |
| const dstTs = pjoin(ROOT_DIR, 'ui/src/gen', name); | |
| const inputDir = pjoin(ROOT_DIR, dir); | |
| const args = [GEN_IMPORTS_SCRIPT, inputDir, '--out', dstTs]; | |
| addTask(exec, ['python3', args]); | |
| } | |
| // Generates a .ts source that defines the VERSION and SCM_REVISION constants. | |
| function genVersion() { | |
| const cmd = 'python3'; | |
| const args = | |
| [VERSION_SCRIPT, '--ts_out', pjoin(cfg.outGenDir, 'perfetto_version.ts')]; | |
| addTask(exec, [cmd, args]); | |
| } | |
| 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'), | |
| '--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)]); | |
| } | |
| } | |
| } | |
| // 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)]; | |
| if (options !== undefined && options.noEmit) { | |
| args.push('--noEmit'); | |
| addTask(execModule, ['tsc', args]); | |
| } else if (options !== undefined && options.watch) { | |
| args.push('--watch', '--preserveWatchOutput'); | |
| addTask(execModule, ['tsc', args, { | |
| async: true, | |
| noErrCheck: options.noErrCheck, | |
| }]); | |
| } else { | |
| addTask(execModule, ['tsc', args, {noErrCheck: options.noErrCheck}]); | |
| } | |
| } | |
| // Creates the three {frontend, controller, engine}_bundle.js in one invocation. | |
| function bundleJs(cfgName) { | |
| const rcfg = pjoin(ROOT_DIR, 'ui/config', cfgName); | |
| const args = ['-c', rcfg, '--no-indent']; | |
| if (cfg.bigtrace) { | |
| args.push('--environment', 'ENABLE_BIGTRACE:true'); | |
| } | |
| if (cfg.openPerfettoTrace) { | |
| args.push('--environment', 'ENABLE_OPEN_PERFETTO_TRACE:true'); | |
| } | |
| if (cfg.minifyJs) { | |
| args.push('--environment', `MINIFY_JS:${cfg.minifyJs}`); | |
| } | |
| if (cfg.noSourceMaps) { | |
| args.push('--environment', 'NO_SOURCE_MAPS:true'); | |
| } | |
| if (cfg.noTreeshake) { | |
| args.push('--environment', 'NO_TREESHAKE:true'); | |
| } | |
| if (cfg.onlyWasmMemory64) { | |
| args.push('--environment', `IS_MEMORY64_ONLY:${cfg.onlyWasmMemory64}`); | |
| } | |
| args.push(...(cfg.verbose ? [] : ['--silent'])); | |
| if (cfg.watch) { | |
| // --waitForBundleInput is sadly quite busted so it is required ts | |
| // has build at least once before invoking this. | |
| args.push('--watch', '--no-watch.clearScreen'); | |
| addTask(execModule, ['rollup', args, {async: true}]); | |
| } else { | |
| addTask(execModule, ['rollup', args]); | |
| } | |
| } | |
| 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, []); | |
| } | |
| 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() { | |
| const requiredArtifacts = [ | |
| 'frontend_bundle.js', | |
| 'engine_bundle.js', | |
| 'traceconv_bundle.js', | |
| 'perfetto.css', | |
| ...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']}; | |
| 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.js 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(); |