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