UI: move build artifacts into /v1.2.3/

This CL moves all the build artifacts into a
subdirectory named after the version number.
This is done for two reasons:
1. Cache-busting, to avoid pulling subresources
   at different versions.
2. Prepare for multiple channels (see
   go/perfetto-ui-channels).

This CL also changes the service worker logic
as follows (See go/perfetto-offline for full
details):
- Network-first fetch of the / index, to
  guarantee that the index is always current.
- The subresource map with hashes is not bundled
  in the service_worker.js script but is fetched
  separately from /v.1.2.3/manifest.json.

Bug: 179864115
Change-Id: Iece22b770f7e2bd958cdcfe30df046832f070641
diff --git a/ui/build.js b/ui/build.js
index 3363d53..3760b71 100644
--- a/ui/build.js
+++ b/ui/build.js
@@ -66,6 +66,7 @@
 
 const argparse = require('argparse');
 const child_process = require('child_process');
+var crypto = require('crypto');
 const fs = require('fs');
 const http = require('http');
 const path = require('path');
@@ -85,14 +86,17 @@
 
   // 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/      -> outDistDir : Final artifacts (JS bundles, assets).
-  //    chrome_extension/        : Chrome extension.
+  // 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: '',
+  outDistRootDir: '',
   outTscDir: '',
   outGenDir: '',
   outDistRootDir: '',
@@ -101,14 +105,14 @@
 };
 
 const RULES = [
-  {r: /ui\/src\/assets\/(index.html)/, f: copyIntoDistRoot},
+  {r: /ui\/src\/assets\/index.html/, f: copyIndexHtml},
   {r: /ui\/src\/assets\/((.*)[.]png)/, f: copyAssets},
   {r: /buildtools\/typefaces\/(.+[.]woff2)/, f: copyAssets},
   {r: /buildtools\/catapult_trace_viewer\/(.+(js|html))/, f: copyAssets},
   {r: /ui\/src\/assets\/.+[.]scss/, f: compileScss},
   {r: /ui\/src\/assets\/.+[.]scss/, f: compileScss},
   {r: /ui\/src\/chrome_extension\/.*/, f: copyExtensionAssets},
-  {r: /.*\/dist\/(?!service_worker).*/, f: genServiceWorkerDistHashes},
+  {r: /.*\/dist\/.+\/(?!manifest\.json).*/, f: genServiceWorkerManifestJson},
   {r: /.*\/dist\/.*/, f: notifyLiveServer},
 ];
 
@@ -136,9 +140,9 @@
   cfg.outUiDir = ensureDir(pjoin(cfg.outDir, 'ui'), clean);
   cfg.outExtDir = ensureDir(pjoin(cfg.outUiDir, 'chrome_extension'));
   cfg.outDistRootDir = ensureDir(pjoin(cfg.outUiDir, 'dist'));
-  // TODO(primiano): for now distDir == distRootDir. In next CLs distDir will
-  // become dist/v1.2.3/.
-  cfg.outDistDir = cfg.outDistRootDir;
+  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.watch = !!args.watch;
@@ -162,7 +166,7 @@
   console.log('Entering', cfg.outDir);
   process.chdir(cfg.outDir);
 
-  updateSymilnks();  // Links //ui/out -> //out/xxx/ui/
+  updateSymlinks();  // Links //ui/out -> //out/xxx/ui/
 
   // 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
@@ -178,12 +182,9 @@
     compileProtos();
     genVersion();
     transpileTsProject('ui');
-    bundleJs('rollup.config.js');
-
-    // ServiceWorker.
-    genServiceWorkerDistHashes();
     transpileTsProject('ui/src/service_worker');
-    bundleJs('rollup-serviceworker.config.js');
+    bundleJs('rollup.config.js');
+    genServiceWorkerManifestJson();
 
     // Watches the /dist. When changed:
     // - Notifies the HTTP live reload clients.
@@ -214,8 +215,23 @@
   }
 }
 
-function copyIntoDistRoot(src, dst) {
-  addTask(cp, [src, pjoin(cfg.outDistRootDir, dst)]);
+function copyIndexHtml(src) {
+  const index_html = () => {
+    let html = fs.readFileSync(src).toString();
+    // First copy the index.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, 'index.html'), 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}'`);
+    fs.writeFileSync(pjoin(cfg.outDistRootDir, 'index.html'), html);
+  };
+  addTask(index_html);
 }
 
 function copyAssets(src, dst) {
@@ -267,8 +283,15 @@
   addTask(exec, [cmd, args]);
 }
 
-function updateSymilnks() {
+function updateSymlinks() {
   mklink(cfg.outUiDir, pjoin(ROOT_DIR, 'ui/out'));
+
+  // 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'))
 }
@@ -304,12 +327,7 @@
 // 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) {
-  const args = [
-    '--project',
-    pjoin(ROOT_DIR, project),
-    '--outDir',
-    cfg.outTscDir,
-  ];
+  const args = ['--project', pjoin(ROOT_DIR, project)];
   if (cfg.watch) {
     args.push('--watch', '--preserveWatchOutput');
     addTask(execNode, ['tsc', args, {async: true}]);
@@ -333,24 +351,23 @@
   }
 }
 
-// Generates a map of {"dist_file_name" -> "sha256-01234"} used by the SW.
-function genServiceWorkerDistHashes() {
-  function write_ui_dist_file_map() {
-    const distFiles = [];
-    const skipRegex = /(service_worker\.js)|(\.map$)/;
-    walk(cfg.outDistDir, f => distFiles.push(f), skipRegex);
-    const dst = pjoin(cfg.outGenDir, 'dist_file_map.ts');
-    const cmd = 'python3';
-    const args = [
-      pjoin(ROOT_DIR, 'gn/standalone/write_ui_dist_file_map.py'),
-      '--out',
-      dst,
-      '--strip',
-      cfg.outDistDir,
-    ].concat(distFiles);
-    exec(cmd, args);
+function genServiceWorkerManifestJson() {
+  function make_manifest() {
+    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(write_ui_dist_file_map, []);
+  addTask(make_manifest, []);
 }
 
 function startServer() {
@@ -403,7 +420,8 @@
             'Cache-Control': 'no-cache',
           };
           res.writeHead(200, head);
-          res.end(data);
+          res.write(data);
+          res.end();
         });
       })
       .listen(port);