xref: /aosp_15_r20/external/perfetto/ui/build.js (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
1// Copyright (C) 2021 The Android Open Source Project
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15'use strict';
16
17
18// This script takes care of:
19// - The build process for the whole UI and the chrome extension.
20// - The HTTP dev-server with live-reload capabilities.
21// The reason why this is a hand-rolled script rather than a conventional build
22// system is keeping incremental build fast and maintaining the set of
23// dependencies contained.
24// The only way to keep incremental build fast (i.e. O(seconds) for the
25// edit-one-line -> reload html cycles) is to run both the TypeScript compiler
26// and the rollup bundler in --watch mode. Any other attempt, leads to O(10s)
27// incremental-build times.
28// This script allows mixing build tools that support --watch mode (tsc and
29// rollup) and auto-triggering-on-file-change rules via fs.watch.
30// When invoked without any argument (e.g., for production builds), this script
31// just runs all the build tasks serially. It doesn't to do any mtime-based
32// check, it always re-runs all the tasks.
33// When invoked with --watch, it mounts a pipeline of tasks based on fs.watch
34// and runs them together with tsc --watch and rollup --watch.
35// The output directory structure is carefully crafted so that any change to UI
36// sources causes cascading triggers of the next steps.
37// The overall build graph looks as follows:
38// +----------------+      +-----------------------------+
39// | protos/*.proto |----->| pbjs out/tsc/gen/protos.js  |--+
40// +----------------+      +-----------------------------+  |
41//                         +-----------------------------+  |
42//                         | pbts out/tsc/gen/protos.d.ts|<-+
43//                         +-----------------------------+
44//                             |
45//                             V      +-------------------------+
46// +---------+              +-----+   |  out/tsc/frontend/*.js  |
47// | ui/*.ts |------------->| tsc |-> +-------------------------+   +--------+
48// +---------+              +-----+   | out/tsc/controller/*.js |-->| rollup |
49//                            ^       +-------------------------+   +--------+
50//                +------------+      |   out/tsc/engine/*.js   |       |
51// +-----------+  |*.wasm.js   |      +-------------------------+       |
52// |ninja *.cc |->|*.wasm.d.ts |                                        |
53// +-----------+  |*.wasm      |-----------------+                      |
54//                +------------+                 |                      |
55//                                               V                      V
56// +-----------+  +------+    +------------------------------------------------+
57// | ui/*.scss |->| scss |--->|              Final out/dist/ dir               |
58// +-----------+  +------+    +------------------------------------------------+
59// +----------------------+   | +----------+ +---------+ +--------------------+|
60// | src/assets/*.png     |   | | assets/  | |*.wasm.js| | frontend_bundle.js ||
61// +----------------------+   | |  *.css   | |*.wasm   | +--------------------+|
62// | buildtools/typefaces |-->| |  *.png   | +---------+ |  engine_bundle.js  ||
63// +----------------------+   | |  *.woff2 |             +--------------------+|
64// | buildtools/legacy_tv |   | |  tv.html |             |traceconv_bundle.js ||
65// +----------------------+   | +----------+             +--------------------+|
66//                            +------------------------------------------------+
67
68const argparse = require('argparse');
69const childProcess = require('child_process');
70const crypto = require('crypto');
71const fs = require('fs');
72const http = require('http');
73const path = require('path');
74const pjoin = path.join;
75
76const ROOT_DIR = path.dirname(__dirname);  // The repo root.
77const VERSION_SCRIPT = pjoin(ROOT_DIR, 'tools/write_version_header.py');
78const GEN_IMPORTS_SCRIPT = pjoin(ROOT_DIR, 'tools/gen_ui_imports');
79
80const cfg = {
81  minifyJs: '',
82  watch: false,
83  verbose: false,
84  debug: false,
85  bigtrace: false,
86  startHttpServer: false,
87  httpServerListenHost: '127.0.0.1',
88  httpServerListenPort: 10000,
89  wasmModules: ['trace_processor', 'traceconv', 'trace_config_utils'],
90  crossOriginIsolation: false,
91  testFilter: '',
92  noOverrideGnArgs: false,
93
94  // The fields below will be changed by main() after cmdline parsing.
95  // Directory structure:
96  // out/xxx/    -> outDir         : Root build dir, for both ninja/wasm and UI.
97  //   ui/       -> outUiDir       : UI dir. All outputs from this script.
98  //    tsc/     -> outTscDir      : Transpiled .ts -> .js.
99  //      gen/   -> outGenDir      : Auto-generated .ts/.js (e.g. protos).
100  //    dist/    -> outDistRootDir : Only index.html and service_worker.js
101  //      v1.2/  -> outDistDir     : JS bundles and assets
102  //    chrome_extension/          : Chrome extension.
103  outDir: pjoin(ROOT_DIR, 'out/ui'),
104  version: '',  // v1.2.3, derived from the CHANGELOG + git.
105  outUiDir: '',
106  outUiTestArtifactsDir: '',
107  outDistRootDir: '',
108  outTscDir: '',
109  outGenDir: '',
110  outDistDir: '',
111  outExtDir: '',
112  outBigtraceDistDir: '',
113  outOpenPerfettoTraceDistDir: '',
114};
115
116const RULES = [
117  {r: /ui\/src\/assets\/index.html/, f: copyIndexHtml},
118  {r: /ui\/src\/assets\/bigtrace.html/, f: copyBigtraceHtml},
119  {r: /ui\/src\/open_perfetto_trace\/index.html/, f: copyOpenPerfettoTraceHtml},
120  {r: /ui\/src\/assets\/((.*)[.]png)/, f: copyAssets},
121  {r: /buildtools\/typefaces\/(.+[.]woff2)/, f: copyAssets},
122  {r: /buildtools\/catapult_trace_viewer\/(.+(js|html))/, f: copyAssets},
123  {r: /ui\/src\/assets\/.+[.]scss/, f: compileScss},
124  {r: /ui\/src\/chrome_extension\/.*/, f: copyExtensionAssets},
125  {r: /.*\/dist\/.+\/(?!manifest\.json).*/, f: genServiceWorkerManifestJson},
126  {r: /.*\/dist\/.*[.](js|html|css|wasm)$/, f: notifyLiveServer},
127];
128
129const tasks = [];
130let tasksTot = 0;
131let tasksRan = 0;
132const httpWatches = [];
133const tStart = Date.now();
134const subprocesses = [];
135
136async function main() {
137  const parser = new argparse.ArgumentParser();
138  parser.add_argument('--out', {help: 'Output directory'});
139  parser.add_argument('--minify-js', {
140    help: 'Minify js files',
141    choices: ['preserve_comments', 'all'],
142  });
143  parser.add_argument('--watch', '-w', {action: 'store_true'});
144  parser.add_argument('--serve', '-s', {action: 'store_true'});
145  parser.add_argument('--serve-host', {help: '--serve bind host'});
146  parser.add_argument('--serve-port', {help: '--serve bind port', type: 'int'});
147  parser.add_argument('--verbose', '-v', {action: 'store_true'});
148  parser.add_argument('--no-build', '-n', {action: 'store_true'});
149  parser.add_argument('--no-wasm', '-W', {action: 'store_true'});
150  parser.add_argument('--run-unittests', '-t', {action: 'store_true'});
151  parser.add_argument('--debug', '-d', {action: 'store_true'});
152  parser.add_argument('--bigtrace', {action: 'store_true'});
153  parser.add_argument('--open-perfetto-trace', {action: 'store_true'});
154  parser.add_argument('--interactive', '-i', {action: 'store_true'});
155  parser.add_argument('--rebaseline', '-r', {action: 'store_true'});
156  parser.add_argument('--no-depscheck', {action: 'store_true'});
157  parser.add_argument('--cross-origin-isolation', {action: 'store_true'});
158  parser.add_argument('--test-filter', '-f', {
159    help: 'filter Jest tests by regex, e.g. \'chrome_render\'',
160  });
161  parser.add_argument('--no-override-gn-args', {action: 'store_true'});
162
163  const args = parser.parse_args();
164  const clean = !args.no_build;
165  cfg.outDir = path.resolve(ensureDir(args.out || cfg.outDir));
166  cfg.outUiDir = ensureDir(pjoin(cfg.outDir, 'ui'), clean);
167  cfg.outUiTestArtifactsDir = ensureDir(pjoin(cfg.outDir, 'ui-test-artifacts'));
168  cfg.outExtDir = ensureDir(pjoin(cfg.outUiDir, 'chrome_extension'));
169  cfg.outDistRootDir = ensureDir(pjoin(cfg.outUiDir, 'dist'));
170  const proc = exec('python3', [VERSION_SCRIPT, '--stdout'], {stdout: 'pipe'});
171  cfg.version = proc.stdout.toString().trim();
172  cfg.outDistDir = ensureDir(pjoin(cfg.outDistRootDir, cfg.version));
173  cfg.outTscDir = ensureDir(pjoin(cfg.outUiDir, 'tsc'));
174  cfg.outGenDir = ensureDir(pjoin(cfg.outUiDir, 'tsc/gen'));
175  cfg.testFilter = args.test_filter || '';
176  cfg.watch = !!args.watch;
177  cfg.verbose = !!args.verbose;
178  cfg.debug = !!args.debug;
179  cfg.bigtrace = !!args.bigtrace;
180  cfg.openPerfettoTrace = !!args.open_perfetto_trace;
181  cfg.startHttpServer = args.serve;
182  cfg.noOverrideGnArgs = !!args.no_override_gn_args;
183  if (args.minify_js) {
184    cfg.minifyJs = args.minify_js;
185  }
186  if (args.bigtrace) {
187    cfg.outBigtraceDistDir = ensureDir(pjoin(cfg.outDistDir, 'bigtrace'));
188  }
189  if (cfg.openPerfettoTrace) {
190    cfg.outOpenPerfettoTraceDistDir = ensureDir(pjoin(cfg.outDistRootDir,
191                                                      'open_perfetto_trace'));
192  }
193  if (args.serve_host) {
194    cfg.httpServerListenHost = args.serve_host;
195  }
196  if (args.serve_port) {
197    cfg.httpServerListenPort = args.serve_port;
198  }
199  if (args.interactive) {
200    process.env.PERFETTO_UI_TESTS_INTERACTIVE = '1';
201  }
202  if (args.rebaseline) {
203    process.env.PERFETTO_UI_TESTS_REBASELINE = '1';
204  }
205  if (args.cross_origin_isolation) {
206    cfg.crossOriginIsolation = true;
207  }
208
209  process.on('SIGINT', () => {
210    console.log('\nSIGINT received. Killing all child processes and exiting');
211    for (const proc of subprocesses) {
212      if (proc) proc.kill('SIGINT');
213    }
214    process.exit(130);  // 130 -> Same behavior of bash when killed by SIGINT.
215  });
216
217  if (!args.no_depscheck) {
218    // Check that deps are current before starting.
219    const installBuildDeps = pjoin(ROOT_DIR, 'tools/install-build-deps');
220    const checkDepsPath = pjoin(cfg.outDir, '.check_deps');
221    let args = [installBuildDeps, `--check-only=${checkDepsPath}`, '--ui'];
222
223    if (process.platform === 'darwin') {
224      const result = childProcess.spawnSync('arch', ['-arm64', 'true']);
225      const isArm64Capable = result.status === 0;
226      if (isArm64Capable) {
227        const archArgs = [
228          'arch',
229          '-arch',
230          'arm64',
231        ];
232        args = archArgs.concat(args);
233      }
234    }
235    const cmd = args.shift();
236    exec(cmd, args);
237  }
238
239  console.log('Entering', cfg.outDir);
240  process.chdir(cfg.outDir);
241
242  // Enqueue empty task. This is needed only for --no-build --serve. The HTTP
243  // server is started when the task queue reaches quiescence, but it takes at
244  // least one task for that.
245  addTask(() => {});
246
247  if (!args.no_build) {
248    updateSymlinks();  // Links //ui/out -> //out/xxx/ui/
249
250    buildWasm(args.no_wasm);
251    generateImports('ui/src/core_plugins', 'all_core_plugins');
252    generateImports('ui/src/plugins', 'all_plugins');
253    scanDir('ui/src/assets');
254    scanDir('ui/src/chrome_extension');
255    scanDir('buildtools/typefaces');
256    scanDir('buildtools/catapult_trace_viewer');
257    compileProtos();
258    genVersion();
259    generateStdlibDocs();
260
261    const tsProjects = [
262      'ui',
263      'ui/src/service_worker'
264    ];
265    if (cfg.bigtrace) tsProjects.push('ui/src/bigtrace');
266    if (cfg.openPerfettoTrace) {
267      scanDir('ui/src/open_perfetto_trace');
268      tsProjects.push('ui/src/open_perfetto_trace');
269    }
270
271
272    for (const prj of tsProjects) {
273      transpileTsProject(prj);
274    }
275
276    if (cfg.watch) {
277      for (const prj of tsProjects) {
278        transpileTsProject(prj, {watch: cfg.watch});
279      }
280    }
281
282    bundleJs('rollup.config.js');
283    genServiceWorkerManifestJson();
284
285    // Watches the /dist. When changed:
286    // - Notifies the HTTP live reload clients.
287    // - Regenerates the ServiceWorker file map.
288    scanDir(cfg.outDistRootDir);
289  }
290
291  // We should enter the loop only in watch mode, where tsc and rollup are
292  // asynchronous because they run in watch mode.
293  if (args.no_build && !isDistComplete()) {
294    console.log('No build was requested, but artifacts are not available.');
295    console.log('In case of execution error, re-run without --no-build.');
296  }
297  if (!args.no_build) {
298    const tStart = Date.now();
299    while (!isDistComplete()) {
300      const secs = Math.ceil((Date.now() - tStart) / 1000);
301      process.stdout.write(
302          `\t\tWaiting for first build to complete... ${secs} s\r`);
303      await new Promise((r) => setTimeout(r, 500));
304    }
305  }
306  if (cfg.watch) console.log('\nFirst build completed!');
307
308  if (cfg.startHttpServer) {
309    startServer();
310  }
311  if (args.run_unittests) {
312    runTests('jest.unittest.config.js');
313  }
314}
315
316// -----------
317// Build rules
318// -----------
319
320function runTests(cfgFile) {
321  const args = [
322    '--rootDir',
323    cfg.outTscDir,
324    '--verbose',
325    '--runInBand',
326    '--detectOpenHandles',
327    '--forceExit',
328    '--projects',
329    pjoin(ROOT_DIR, 'ui/config', cfgFile),
330  ];
331  if (cfg.testFilter.length > 0) {
332    args.push('-t', cfg.testFilter);
333  }
334  if (cfg.watch) {
335    args.push('--watchAll');
336    addTask(execModule, ['jest', args, {async: true}]);
337  } else {
338    addTask(execModule, ['jest', args]);
339  }
340}
341
342function cpHtml(src, filename) {
343  let html = fs.readFileSync(src).toString();
344  // First copy the html as-is into the dist/v1.2.3/ directory. This is
345  // only used for archival purporses, so one can open
346  // ui.perfetto.dev/v1.2.3/ to skip the auto-update and channel logic.
347  fs.writeFileSync(pjoin(cfg.outDistDir, filename), html);
348
349  // Then copy it into the dist/ root by patching the version code.
350  // TODO(primiano): in next CLs, this script should take a
351  // --release_map=xxx.json argument, to populate this with multiple channels.
352  const versionMap = JSON.stringify({'stable': cfg.version});
353  const bodyRegex = /data-perfetto_version='[^']*'/;
354  html = html.replace(bodyRegex, `data-perfetto_version='${versionMap}'`);
355  fs.writeFileSync(pjoin(cfg.outDistRootDir, filename), html);
356}
357
358function copyIndexHtml(src) {
359  addTask(cpHtml, [src, 'index.html']);
360}
361
362function copyBigtraceHtml(src) {
363  if (cfg.bigtrace) {
364    addTask(cpHtml, [src, 'bigtrace.html']);
365  }
366}
367
368function copyOpenPerfettoTraceHtml(src) {
369  if (cfg.openPerfettoTrace) {
370    addTask(cp, [src, pjoin(cfg.outOpenPerfettoTraceDistDir, 'index.html')]);
371  }
372}
373
374function copyAssets(src, dst) {
375  addTask(cp, [src, pjoin(cfg.outDistDir, 'assets', dst)]);
376  if (cfg.bigtrace) {
377    addTask(cp, [src, pjoin(cfg.outBigtraceDistDir, 'assets', dst)]);
378  }
379}
380
381function copyUiTestArtifactsAssets(src, dst) {
382  addTask(cp, [src, pjoin(cfg.outUiTestArtifactsDir, dst)]);
383}
384
385function compileScss() {
386  const src = pjoin(ROOT_DIR, 'ui/src/assets/perfetto.scss');
387  const dst = pjoin(cfg.outDistDir, 'perfetto.css');
388  // In watch mode, don't exit(1) if scss fails. It can easily happen by
389  // having a typo in the css. It will still print an error.
390  const noErrCheck = !!cfg.watch;
391  const args = [src, dst];
392  if (!cfg.verbose) {
393    args.unshift('--quiet');
394  }
395  addTask(execModule, ['sass', args, {noErrCheck}]);
396  if (cfg.bigtrace) {
397    addTask(cp, [dst, pjoin(cfg.outBigtraceDistDir, 'perfetto.css')]);
398  }
399}
400
401function compileProtos() {
402  const dstJs = pjoin(cfg.outGenDir, 'protos.js');
403  const dstTs = pjoin(cfg.outGenDir, 'protos.d.ts');
404  const inputs = [
405    'protos/perfetto/ipc/consumer_port.proto',
406    'protos/perfetto/ipc/wire_protocol.proto',
407    'protos/perfetto/trace/perfetto/perfetto_metatrace.proto',
408    'protos/perfetto/trace_processor/trace_processor.proto',
409  ];
410  // Can't put --no-comments here - The comments are load bearing for
411  // the pbts invocation which follows.
412  const pbjsArgs = [
413    '--no-beautify',
414    '--force-number',
415    '--no-delimited',
416    '--no-verify',
417    '-t',
418    'static-module',
419    '-w',
420    'commonjs',
421    '-p',
422    ROOT_DIR,
423    '-o',
424    dstJs,
425  ].concat(inputs);
426  addTask(execModule, ['pbjs', pbjsArgs]);
427
428  // Note: If you are looking into slowness of pbts it is not pbts
429  // itself that is slow. It invokes jsdoc to parse the comments out of
430  // the |dstJs| with https://github.com/hegemonic/catharsis which is
431  // pinning a CPU core the whole time.
432  const pbtsArgs = ['--no-comments', '-p', ROOT_DIR, '-o', dstTs, dstJs];
433  addTask(execModule, ['pbts', pbtsArgs]);
434}
435
436function generateImports(dir, name) {
437  // We have to use the symlink (ui/src/gen) rather than cfg.outGenDir
438  // below since we want to generate the correct relative imports. For example:
439  // ui/src/frontend/foo.ts
440  //    import '../gen/all_plugins.ts';
441  // ui/src/gen/all_plugins.ts (aka ui/out/tsc/gen/all_plugins.ts)
442  //    import '../frontend/some_plugin.ts';
443  const dstTs = pjoin(ROOT_DIR, 'ui/src/gen', name);
444  const inputDir = pjoin(ROOT_DIR, dir);
445  const args = [GEN_IMPORTS_SCRIPT, inputDir, '--out', dstTs];
446  addTask(exec, ['python3', args]);
447}
448
449// Generates a .ts source that defines the VERSION and SCM_REVISION constants.
450function genVersion() {
451  const cmd = 'python3';
452  const args =
453      [VERSION_SCRIPT, '--ts_out', pjoin(cfg.outGenDir, 'perfetto_version.ts')];
454  addTask(exec, [cmd, args]);
455}
456
457function generateStdlibDocs() {
458  const cmd = pjoin(ROOT_DIR, 'tools/gen_stdlib_docs_json.py');
459  const stdlibDir = pjoin(ROOT_DIR, 'src/trace_processor/perfetto_sql/stdlib');
460
461  const stdlibFiles =
462    listFilesRecursive(stdlibDir)
463    .filter((filePath) => path.extname(filePath) === '.sql');
464
465  addTask(exec, [
466    cmd,
467    [
468      '--json-out',
469      pjoin(cfg.outDistDir, 'stdlib_docs.json'),
470      '--minify',
471      ...stdlibFiles,
472    ],
473  ]);
474}
475
476function updateSymlinks() {
477  // /ui/out -> /out/ui.
478  mklink(cfg.outUiDir, pjoin(ROOT_DIR, 'ui/out'));
479
480  // /ui/src/gen -> /out/ui/ui/tsc/gen)
481  mklink(cfg.outGenDir, pjoin(ROOT_DIR, 'ui/src/gen'));
482
483  // /out/ui/test/data -> /test/data (For UI tests).
484  mklink(
485      pjoin(ROOT_DIR, 'test/data'),
486      pjoin(ensureDir(pjoin(cfg.outDir, 'test')), 'data'));
487
488  // Creates a out/dist_version -> out/dist/v1.2.3 symlink, so rollup config
489  // can point to that without having to know the current version number.
490  mklink(
491      path.relative(cfg.outUiDir, cfg.outDistDir),
492      pjoin(cfg.outUiDir, 'dist_version'));
493
494  mklink(
495      pjoin(ROOT_DIR, 'ui/node_modules'), pjoin(cfg.outTscDir, 'node_modules'));
496}
497
498// Invokes ninja for building the {trace_processor, traceconv} Wasm modules.
499// It copies the .wasm directly into the out/dist/ dir, and the .js/.ts into
500// out/tsc/, so the typescript compiler and the bundler can pick them up.
501function buildWasm(skipWasmBuild) {
502  if (!skipWasmBuild) {
503    if (!cfg.noOverrideGnArgs) {
504      let gnVars = `is_debug=${cfg.debug}`;
505      if (childProcess.spawnSync('which', ['ccache']).status === 0) {
506        gnVars += ` cc_wrapper="ccache"`;
507      }
508      const gnArgs = ['gen', `--args=${gnVars}`, cfg.outDir];
509      addTask(exec, [pjoin(ROOT_DIR, 'tools/gn'), gnArgs]);
510    }
511
512    const ninjaArgs = ['-C', cfg.outDir];
513    ninjaArgs.push(...cfg.wasmModules.map((x) => `${x}_wasm`));
514    addTask(exec, [pjoin(ROOT_DIR, 'tools/ninja'), ninjaArgs]);
515  }
516
517  const wasmOutDir = pjoin(cfg.outDir, 'wasm');
518  for (const wasmMod of cfg.wasmModules) {
519    // The .wasm file goes directly into the dist dir (also .map in debug)
520    for (const ext of ['.wasm'].concat(cfg.debug ? ['.wasm.map'] : [])) {
521      const src = `${wasmOutDir}/${wasmMod}${ext}`;
522      addTask(cp, [src, pjoin(cfg.outDistDir, wasmMod + ext)]);
523    }
524    // The .js / .ts go into intermediates, they will be bundled by rollup.
525    for (const ext of ['.js', '.d.ts']) {
526      const fname = `${wasmMod}${ext}`;
527      addTask(cp, [pjoin(wasmOutDir, fname), pjoin(cfg.outGenDir, fname)]);
528    }
529  }
530}
531
532// This transpiles all the sources (frontend, controller, engine, extension) in
533// one go. The only project that has a dedicated invocation is service_worker.
534function transpileTsProject(project, options) {
535  const args = ['--project', pjoin(ROOT_DIR, project)];
536
537  if (options !== undefined && options.watch) {
538    args.push('--watch', '--preserveWatchOutput');
539    addTask(execModule, ['tsc', args, {async: true}]);
540  } else {
541    addTask(execModule, ['tsc', args]);
542  }
543}
544
545// Creates the three {frontend, controller, engine}_bundle.js in one invocation.
546function bundleJs(cfgName) {
547  const rcfg = pjoin(ROOT_DIR, 'ui/config', cfgName);
548  const args = ['-c', rcfg, '--no-indent'];
549  if (cfg.bigtrace) {
550    args.push('--environment', 'ENABLE_BIGTRACE:true');
551  }
552  if (cfg.openPerfettoTrace) {
553    args.push('--environment', 'ENABLE_OPEN_PERFETTO_TRACE:true');
554  }
555  if (cfg.minifyJs) {
556    args.push('--environment', `MINIFY_JS:${cfg.minifyJs}`);
557  }
558  args.push(...(cfg.verbose ? [] : ['--silent']));
559  if (cfg.watch) {
560    // --waitForBundleInput is sadly quite busted so it is required ts
561    // has build at least once before invoking this.
562    args.push('--watch', '--no-watch.clearScreen');
563    addTask(execModule, ['rollup', args, {async: true}]);
564  } else {
565    addTask(execModule, ['rollup', args]);
566  }
567}
568
569function genServiceWorkerManifestJson() {
570  function makeManifest() {
571    const manifest = {resources: {}};
572    // When building the subresource manifest skip source maps, the manifest
573    // itself and the copy of the index.html which is copied under /v1.2.3/.
574    // The root /index.html will be fetched by service_worker.js separately.
575    const skipRegex = /(\.map|manifest\.json|index.html)$/;
576    walk(cfg.outDistDir, (absPath) => {
577      const contents = fs.readFileSync(absPath);
578      const relPath = path.relative(cfg.outDistDir, absPath);
579      const b64 = crypto.createHash('sha256').update(contents).digest('base64');
580      manifest.resources[relPath] = 'sha256-' + b64;
581    }, skipRegex);
582    const manifestJson = JSON.stringify(manifest, null, 2);
583    fs.writeFileSync(pjoin(cfg.outDistDir, 'manifest.json'), manifestJson);
584  }
585  addTask(makeManifest, []);
586}
587
588function startServer() {
589  const host = cfg.httpServerListenHost == '127.0.0.1' ? 'localhost' : cfg.httpServerListenHost;
590  console.log(
591      'Starting HTTP server on',
592      `http://${host}:${cfg.httpServerListenPort}`);
593  http.createServer(function(req, res) {
594        console.debug(req.method, req.url);
595        let uri = req.url.split('?', 1)[0];
596        if (uri.endsWith('/')) {
597          uri += 'index.html';
598        }
599
600        if (uri === '/live_reload') {
601          // Implements the Server-Side-Events protocol.
602          const head = {
603            'Content-Type': 'text/event-stream',
604            'Connection': 'keep-alive',
605            'Cache-Control': 'no-cache',
606          };
607          res.writeHead(200, head);
608          const arrayIdx = httpWatches.length;
609          // We never remove from the array, the delete leaves an undefined item
610          // around. It makes keeping track of the index easier at the cost of a
611          // small leak.
612          httpWatches.push(res);
613          req.on('close', () => delete httpWatches[arrayIdx]);
614          return;
615        }
616
617        let absPath = path.normalize(path.join(cfg.outDistRootDir, uri));
618        // We want to be able to use the data in '/test/' for e2e tests.
619        // However, we don't want do create a symlink into the 'dist/' dir,
620        // because 'dist/' gets shipped on the production server.
621        if (uri.startsWith('/test/')) {
622          absPath = pjoin(ROOT_DIR, uri);
623        }
624
625        // Don't serve contents outside of the project root (b/221101533).
626        if (path.relative(ROOT_DIR, absPath).startsWith('..')) {
627          res.writeHead(403);
628          res.end('403 Forbidden - Request path outside of the repo root');
629          return;
630        }
631
632        fs.readFile(absPath, function(err, data) {
633          if (err) {
634            res.writeHead(404);
635            res.end(JSON.stringify(err));
636            return;
637          }
638
639          const mimeMap = {
640            'html': 'text/html',
641            'css': 'text/css',
642            'js': 'application/javascript',
643            'wasm': 'application/wasm',
644          };
645          const ext = uri.split('.').pop();
646          const cType = mimeMap[ext] || 'octect/stream';
647          const head = {
648            'Content-Type': cType,
649            'Content-Length': data.length,
650            'Last-Modified': fs.statSync(absPath).mtime.toUTCString(),
651            'Cache-Control': 'no-cache',
652          };
653          if (cfg.crossOriginIsolation) {
654            head['Cross-Origin-Opener-Policy'] = 'same-origin';
655            head['Cross-Origin-Embedder-Policy'] = 'require-corp';
656          }
657          res.writeHead(200, head);
658          res.write(data);
659          res.end();
660        });
661      })
662      .listen(cfg.httpServerListenPort, cfg.httpServerListenHost);
663}
664
665function isDistComplete() {
666  const requiredArtifacts = [
667    'frontend_bundle.js',
668    'engine_bundle.js',
669    'traceconv_bundle.js',
670    'trace_processor.wasm',
671    'perfetto.css',
672  ];
673  const relPaths = new Set();
674  walk(cfg.outDistDir, (absPath) => {
675    relPaths.add(path.relative(cfg.outDistDir, absPath));
676  });
677  for (const fName of requiredArtifacts) {
678    if (!relPaths.has(fName)) return false;
679  }
680  return true;
681}
682
683// Called whenever a change in the out/dist directory is detected. It sends a
684// Server-Side-Event to the live_reload.ts script.
685function notifyLiveServer(changedFile) {
686  for (const cli of httpWatches) {
687    if (cli === undefined) continue;
688    cli.write(
689        'data: ' + path.relative(cfg.outDistRootDir, changedFile) + '\n\n');
690  }
691}
692
693function copyExtensionAssets() {
694  addTask(cp, [
695    pjoin(ROOT_DIR, 'ui/src/assets/logo-128.png'),
696    pjoin(cfg.outExtDir, 'logo-128.png'),
697  ]);
698  addTask(cp, [
699    pjoin(ROOT_DIR, 'ui/src/chrome_extension/manifest.json'),
700    pjoin(cfg.outExtDir, 'manifest.json'),
701  ]);
702}
703
704// -----------------------
705// Task chaining functions
706// -----------------------
707
708function addTask(func, args) {
709  const task = new Task(func, args);
710  for (const t of tasks) {
711    if (t.identity === task.identity) {
712      return;
713    }
714  }
715  tasks.push(task);
716  setTimeout(runTasks, 0);
717}
718
719function runTasks() {
720  const snapTasks = tasks.splice(0);  // snap = std::move(tasks).
721  tasksTot += snapTasks.length;
722  for (const task of snapTasks) {
723    const DIM = '\u001b[2m';
724    const BRT = '\u001b[37m';
725    const RST = '\u001b[0m';
726    const ms = (new Date(Date.now() - tStart)).toISOString().slice(17, -1);
727    const ts = `[${DIM}${ms}${RST}]`;
728    const descr = task.description.substr(0, 80);
729    console.log(`${ts} ${BRT}${++tasksRan}/${tasksTot}${RST}\t${descr}`);
730    task.func.apply(/* this=*/ undefined, task.args);
731  }
732}
733
734// Executes all the RULES that match the given |absPath|.
735function scanFile(absPath) {
736  console.assert(fs.existsSync(absPath));
737  console.assert(path.isAbsolute(absPath));
738  const normPath = path.relative(ROOT_DIR, absPath);
739  for (const rule of RULES) {
740    const match = rule.r.exec(normPath);
741    if (!match || match[0] !== normPath) continue;
742    const captureGroup = match.length > 1 ? match[1] : undefined;
743    rule.f(absPath, captureGroup);
744  }
745}
746
747// Walks the passed |dir| recursively and, for each file, invokes the matching
748// RULES. If --watch is used, it also installs a fswatch() and re-triggers the
749// matching RULES on each file change.
750function scanDir(dir, regex) {
751  const filterFn = regex ? (absPath) => regex.test(absPath) : () => true;
752  const absDir = path.isAbsolute(dir) ? dir : pjoin(ROOT_DIR, dir);
753  // Add a fs watch if in watch mode.
754  if (cfg.watch) {
755    fs.watch(absDir, {recursive: true}, (_eventType, relFilePath) => {
756      const filePath = pjoin(absDir, relFilePath);
757      if (!filterFn(filePath)) return;
758      if (cfg.verbose) {
759        console.log('File change detected', _eventType, filePath);
760      }
761      if (fs.existsSync(filePath)) {
762        scanFile(filePath, filterFn);
763      }
764    });
765  }
766  walk(absDir, (f) => {
767    if (filterFn(f)) scanFile(f);
768  });
769}
770
771function exec(cmd, args, opts) {
772  opts = opts || {};
773  opts.stdout = opts.stdout || 'inherit';
774  if (cfg.verbose) console.log(`${cmd} ${args.join(' ')}\n`);
775  const spwOpts = {cwd: cfg.outDir, stdio: ['ignore', opts.stdout, 'inherit']};
776  const checkExitCode = (code, signal) => {
777    if (signal === 'SIGINT' || signal === 'SIGTERM') return;
778    if (code !== 0 && !opts.noErrCheck) {
779      console.error(`${cmd} ${args.join(' ')} failed with code ${code}`);
780      process.exit(1);
781    }
782  };
783  if (opts.async) {
784    const proc = childProcess.spawn(cmd, args, spwOpts);
785    const procIndex = subprocesses.length;
786    subprocesses.push(proc);
787    return new Promise((resolve, _reject) => {
788      proc.on('exit', (code, signal) => {
789        delete subprocesses[procIndex];
790        checkExitCode(code, signal);
791        resolve();
792      });
793    });
794  } else {
795    const spawnRes = childProcess.spawnSync(cmd, args, spwOpts);
796    checkExitCode(spawnRes.status, spawnRes.signal);
797    return spawnRes;
798  }
799}
800
801function execModule(module, args, opts) {
802  const modPath = pjoin(ROOT_DIR, 'ui/node_modules/.bin', module);
803  return exec(modPath, args || [], opts);
804}
805
806// ------------------------------------------
807// File system & subprocess utility functions
808// ------------------------------------------
809
810class Task {
811  constructor(func, args) {
812    this.func = func;
813    this.args = args || [];
814    // |identity| is used to dedupe identical tasks in the queue.
815    this.identity = JSON.stringify([this.func.name, this.args]);
816  }
817
818  get description() {
819    const ret = this.func.name.startsWith('exec') ? [] : [this.func.name];
820    const flattenedArgs = [].concat(...this.args);
821    for (const arg of flattenedArgs) {
822      const argStr = `${arg}`;
823      if (argStr.startsWith('/')) {
824        ret.push(path.relative(cfg.outDir, arg));
825      } else {
826        ret.push(argStr);
827      }
828    }
829    return ret.join(' ');
830  }
831}
832
833function walk(dir, callback, skipRegex) {
834  for (const child of fs.readdirSync(dir)) {
835    const childPath = pjoin(dir, child);
836    const stat = fs.lstatSync(childPath);
837    if (skipRegex !== undefined && skipRegex.test(child)) continue;
838    if (stat.isDirectory()) {
839      walk(childPath, callback, skipRegex);
840    } else if (!stat.isSymbolicLink()) {
841      callback(childPath);
842    }
843  }
844}
845
846// Recursively build a list of files in a given directory and return a list of
847// file paths, similar to `find -type f`.
848function listFilesRecursive(dir) {
849  const fileList = [];
850
851  walk(dir, (filePath) => {
852    fileList.push(filePath);
853  });
854
855  return fileList;
856}
857
858function ensureDir(dirPath, clean) {
859  const exists = fs.existsSync(dirPath);
860  if (exists && clean) {
861    console.log('rm', dirPath);
862    fs.rmSync(dirPath, {recursive: true});
863  }
864  if (!exists || clean) fs.mkdirSync(dirPath, {recursive: true});
865  return dirPath;
866}
867
868function cp(src, dst) {
869  ensureDir(path.dirname(dst));
870  if (cfg.verbose) {
871    console.log(
872        'cp', path.relative(ROOT_DIR, src), '->', path.relative(ROOT_DIR, dst));
873  }
874  fs.copyFileSync(src, dst);
875}
876
877function mklink(src, dst) {
878  // If the symlink already points to the right place don't touch it. This is
879  // to avoid changing the mtime of the ui/ dir when unnecessary.
880  if (fs.existsSync(dst)) {
881    if (fs.lstatSync(dst).isSymbolicLink() && fs.readlinkSync(dst) === src) {
882      return;
883    } else {
884      fs.unlinkSync(dst);
885    }
886  }
887  fs.symlinkSync(src, dst);
888}
889
890main();
891