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/gn/standalone/write_ui_dist_file_map.py b/gn/standalone/write_ui_dist_file_map.py
deleted file mode 100644
index 20ba344..0000000
--- a/gn/standalone/write_ui_dist_file_map.py
+++ /dev/null
@@ -1,89 +0,0 @@
-#!/usr/bin/env python
-# Copyright (C) 2020 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.
-""" Writes a TypeScript dict that contains SHA256s of the passed files.
-
-The output looks like this:
-{
- hex_digest: '6c761701c5840483833ffb0bd70ae155b2b3c70e8f667c7bd1f6abc98095930',
- files: {
- 'frontend_bundle.js': 'sha256-2IVKK/3mEMlDdXNADyK03L1cANKbBpU+xue+vnLOcyo=',
- 'index.html': 'sha256-ZRS1+Xh/dFZeWZi/dz8QMWg/8PYQHNdazsNX2oX8s70=',
- ...
- }
-}
-"""
-
-from __future__ import print_function
-
-import argparse
-import base64
-import hashlib
-import os
-import sys
-
-from base64 import b64encode
-
-
-def hash_file(file_path):
- hasher = hashlib.sha256()
- with open(file_path, 'rb') as f:
- for chunk in iter(lambda: f.read(32768), b''):
- hasher.update(chunk)
- return file_path, hasher.digest()
-
-
-def hash_list_hex(args):
- hasher = hashlib.sha256()
- for arg in args:
- hasher.update(arg)
- return hasher.hexdigest()
-
-
-def main():
- parser = argparse.ArgumentParser()
- parser.add_argument('--out', help='Path of the output file')
- parser.add_argument(
- '--strip', help='Strips the leading path in the generated file list')
- parser.add_argument('file_list', nargs=argparse.REMAINDER)
- args = parser.parse_args()
-
- # Compute the hash of each file.
- digests = dict(map(hash_file, args.file_list))
-
- contents = '// __generated_by %s\n' % __file__
- contents += 'export const UI_DIST_MAP = {\n'
- contents += ' files: {\n'
- strip = args.strip + ('' if args.strip[-1] == os.path.sep else os.path.sep)
- for fname, digest in digests.items():
- if not fname.startswith(strip):
- raise Exception('%s must start with %s (--strip arg)' % (fname, strip))
- fname = fname[len(strip):]
- # We use b64 instead of hexdigest() because it's handy for handling fetch()
- # subresource integrity.
- contents += ' \'%s\': \'sha256-%s\',\n' % (
- fname, b64encode(digest).decode("ascii"))
- contents += ' },\n'
-
- # Compute the hash of the all resources' hashes.
- contents += ' hex_digest: \'%s\',\n' % hash_list_hex(digests.values())
- contents += '};\n'
-
- with open(args.out + '.tmp', 'w') as fout:
- fout.write(contents)
- os.rename(args.out + '.tmp', args.out)
-
-
-if __name__ == '__main__':
- sys.exit(main())
diff --git a/tools/write_version_header.py b/tools/write_version_header.py
index 88dcb30..028ad2a 100755
--- a/tools/write_version_header.py
+++ b/tools/write_version_header.py
@@ -101,6 +101,7 @@
help='Skips running git rev-parse, emits only the version from CHANGELOG')
parser.add_argument('--cpp_out', help='Path of the generated .h file.')
parser.add_argument('--ts_out', help='Path of the generated .ts file.')
+ parser.add_argument('--stdout', help='Write to stdout', action='store_true')
parser.add_argument('--changelog', help='Path to CHANGELOG.')
args = parser.parse_args()
@@ -142,6 +143,9 @@
content = '\n'.join(lines)
write_if_unchanged(args.ts_out, content)
+ if args.stdout:
+ print(version)
+
if __name__ == '__main__':
sys.exit(main())
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);
diff --git a/ui/config/rollup.config.js b/ui/config/rollup.config.js
index 0055444..380aa91 100644
--- a/ui/config/rollup.config.js
+++ b/ui/config/rollup.config.js
@@ -60,9 +60,32 @@
};
}
+function defServiceWorkerBundle() {
+ return {
+ input: `${OUT_SYMLINK}/tsc/service_worker/service_worker.js`,
+ output: {
+ name: 'service_worker',
+ format: 'iife',
+ esModule: false,
+ file: `${OUT_SYMLINK}/dist/service_worker.js`,
+ sourcemap: true,
+ },
+ plugins: [
+ nodeResolve({
+ mainFields: ['browser'],
+ browser: true,
+ preferBuiltins: false,
+ }),
+ commonjs(),
+ sourcemaps(),
+ ],
+ };
+}
+
export default [
- defBundle('frontend', 'dist'),
- defBundle('controller', 'dist'),
- defBundle('engine', 'dist'),
+ defBundle('frontend', 'dist_version'),
+ defBundle('controller', 'dist_version'),
+ defBundle('engine', 'dist_version'),
defBundle('chrome_extension', 'chrome_extension'),
+ defServiceWorkerBundle(),
]
diff --git a/ui/src/assets/index.html b/ui/src/assets/index.html
index b1e57fa..7ad3b21 100644
--- a/ui/src/assets/index.html
+++ b/ui/src/assets/index.html
@@ -4,9 +4,9 @@
<meta charset="utf-8">
<title>Perfetto UI</title>
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" name="viewport" />
- <link rel="shortcut icon" id="favicon" type="image/png" href="assets/favicon.png">
+ <link rel="shortcut icon" id="favicon" type="image/png" href="data:image/png;base64,iVBORw0KGgo=">
</head>
-<body>
+<body data-perfetto_version='{"filled_by_build_js":"."}'>
<!--
Don't add any content here. The whole <body> is replaced by
frontend/index.ts when bootstrapping. This is only used for very early
@@ -55,12 +55,17 @@
let errTimerId = undefined;
function errHandler(err) {
- console.error(err);
+ // Note: we deliberately don't clearTimeout(), which means that this
+ // handler is called also in the happy case when the UI loads. In that
+ // case, though, the onCssLoaded() in frontend/index.ts will empty the
+ // <body>, so |div| below will be null and this function becomes a
+ // no-op.
const div = document.getElementById('app_load_failure');
if (!div) return;
div.style.opacity ='1';
const errDom = document.getElementById('app_load_failure_err');
if (!errDom) return;
+ console.error(err);
errDom.innerText += `${err}\n`;
const storageJson = JSON.stringify(window.localStorage);
const dbg = document.getElementById('app_load_failure_dbg');
@@ -91,9 +96,19 @@
window.onerror = errHandler;
window.onunhandledrejection = errHandler;
+ const versionStr = document.body.dataset['perfetto_version'] || '{}';
+ const versionMap = JSON.parse(versionStr);
+ const channel = localStorage.getItem('perfettoUiChannel') || 'stable';
+
+ // The '.' below is a fallback for the case of opening a pinned version
+ // (e.g., ui.perfetto.dev/v1.2.3./). In that case, the index.html has no
+ // valid version map; we want to load the frontend from the same
+ // sub-directory directory, hence ./frontend_bundle.js.
+ const version = versionMap[channel] || versionMap['stable'] || '.';
+
const script = document.createElement('script');
script.async = true;
- script.src = 'frontend_bundle.js';
+ script.src = version + '/frontend_bundle.js';
script.onerror = () => errHandler(`Failed to load ${script.src}`);
document.head.append(script);
diff --git a/ui/src/frontend/analytics.ts b/ui/src/frontend/analytics.ts
index 5abf7ed..c652d6a 100644
--- a/ui/src/frontend/analytics.ts
+++ b/ui/src/frontend/analytics.ts
@@ -13,6 +13,7 @@
// limitations under the License.
import {globals} from '../frontend/globals';
+import * as version from '../gen/perfetto_version';
type TraceCategories = 'Trace Actions'|'Record Trace'|'User Actions';
const ANALYTICS_ID = 'UA-137828855-1';
@@ -92,6 +93,7 @@
referrer: document.referrer.split('?')[0],
send_page_view: false,
dimension1: globals.isInternalUser ? '1' : '0',
+ dimension2: version.VERSION,
});
this.updatePath(route);
}
diff --git a/ui/src/frontend/globals.ts b/ui/src/frontend/globals.ts
index 38bd7a9..2d8d66e 100644
--- a/ui/src/frontend/globals.ts
+++ b/ui/src/frontend/globals.ts
@@ -125,10 +125,20 @@
}
type ThreadMap = Map<number, ThreadDesc>;
+function getRoot() {
+ // Works out the root directory where the content should be served from
+ // e.g. `http://origin/v1.2.3/`.
+ let root = (document.currentScript as HTMLScriptElement).src;
+ root = root.substr(0, root.lastIndexOf('/') + 1);
+ return root;
+}
+
/**
* Global accessors for state/dispatch in the frontend.
*/
class Globals {
+ readonly root = getRoot();
+
private _dispatch?: Dispatch = undefined;
private _controllerWorker?: Worker = undefined;
private _state?: State = undefined;
diff --git a/ui/src/frontend/home_page.ts b/ui/src/frontend/home_page.ts
index 5d37cbb..6662e8f 100644
--- a/ui/src/frontend/home_page.ts
+++ b/ui/src/frontend/home_page.ts
@@ -13,7 +13,7 @@
// limitations under the License.
import * as m from 'mithril';
-
+import {globals} from './globals';
import {createPage} from './pages';
export const HomePage = createPage({
@@ -21,7 +21,7 @@
return m(
'.page.home-page',
m('.home-page-title', 'Perfetto'),
- m('img.logo[src=assets/logo-3d.png]'),
+ m(`img.logo[src=${globals.root}assets/logo-3d.png]`),
m('a.privacy',
{href: 'https://policies.google.com/privacy', target: '_blank'},
'Privacy policy'));
diff --git a/ui/src/frontend/index.ts b/ui/src/frontend/index.ts
index 7645a8f..1b6584e 100644
--- a/ui/src/frontend/index.ts
+++ b/ui/src/frontend/index.ts
@@ -316,9 +316,11 @@
const cssLoadPromise = defer<void>();
const css = document.createElement('link');
css.rel = 'stylesheet';
- css.href = 'perfetto.css';
+ css.href = globals.root + 'perfetto.css';
css.onload = () => cssLoadPromise.resolve();
css.onerror = (err) => cssLoadPromise.reject(err);
+ const favicon = document.head.querySelector('#favicon') as HTMLLinkElement;
+ if (favicon) favicon.href = globals.root + 'assets/favicon.png';
// Load the script to detect if this is a Googler (see comments on globals.ts)
// and initialize GA after that (or after a timeout if something goes wrong).
@@ -337,7 +339,7 @@
window.addEventListener('error', e => reportError(e));
window.addEventListener('unhandledrejection', e => reportError(e));
- const controller = new Worker('controller_bundle.js');
+ const controller = new Worker(globals.root + 'controller_bundle.js');
const frontendChannel = new MessageChannel();
const controllerChannel = new MessageChannel();
const extensionLocalChannel = new MessageChannel();
diff --git a/ui/src/frontend/legacy_trace_viewer.ts b/ui/src/frontend/legacy_trace_viewer.ts
index a200ea9..122a618 100644
--- a/ui/src/frontend/legacy_trace_viewer.ts
+++ b/ui/src/frontend/legacy_trace_viewer.ts
@@ -15,6 +15,7 @@
import * as m from 'mithril';
import {inflate} from 'pako';
import {assertTrue} from '../base/logging';
+import {globals} from './globals';
import {showModal} from './modal';
const CTRACE_HEADER = 'TRACE:\n';
@@ -126,9 +127,7 @@
// The location.pathname mangling is to make this code work also when hosted
// in a non-root sub-directory, for the case of CI artifacts.
- const urlParts = location.pathname.split('/');
- urlParts[urlParts.length - 1] = 'assets/catapult_trace_viewer.html';
- const catapultUrl = urlParts.join('/');
+ const catapultUrl = globals.root + 'assets/catapult_trace_viewer.html';
const newWin = window.open(catapultUrl) as Window;
if (newWin) {
// Popup succeedeed.
diff --git a/ui/src/frontend/record_page.ts b/ui/src/frontend/record_page.ts
index 3f425f8..4de254a 100644
--- a/ui/src/frontend/record_page.ts
+++ b/ui/src/frontend/record_page.ts
@@ -137,7 +137,7 @@
return m(
`label${cfg.mode === mode ? '.selected' : ''}`,
m(`input[type=radio][name=rec_mode]`, checkboxArgs),
- m(`img[src=assets/${img}]`),
+ m(`img[src=${globals.root}assets/${img}]`),
m('span', title));
};
diff --git a/ui/src/frontend/record_widgets.ts b/ui/src/frontend/record_widgets.ts
index 2e6db1f..fd919a8 100644
--- a/ui/src/frontend/record_widgets.ts
+++ b/ui/src/frontend/record_widgets.ts
@@ -70,7 +70,7 @@
return m(
`.probe${enabled ? '.enabled' : ''}`,
attrs.img && m('img', {
- src: `assets/${attrs.img}`,
+ src: `${globals.root}assets/${attrs.img}`,
onclick: () => onToggle(!enabled),
}),
m('label',
diff --git a/ui/src/frontend/service_worker_controller.ts b/ui/src/frontend/service_worker_controller.ts
index 55bcfdc..bc11ee3 100644
--- a/ui/src/frontend/service_worker_controller.ts
+++ b/ui/src/frontend/service_worker_controller.ts
@@ -74,12 +74,24 @@
async install() {
if (!('serviceWorker' in navigator)) return; // Not supported.
+ if (location.pathname !== '/') {
+ // Disable the service worker when the UI is loaded from a non-root URL
+ // (e.g. from the CI artifacts GCS bucket). Supporting the case of a
+ // nested index.html is too cumbersome and has no benefits.
+ return;
+ }
+
if (await caches.has(BYPASS_ID)) {
this._bypassed = true;
console.log('Skipping service worker registration, disabled by the user');
return;
}
- navigator.serviceWorker.register('service_worker.js').then(registration => {
+ // In production cases versionDir == VERSION. We use this here for ease of
+ // testing (so we can have /v1.0.0a/ /v1.0.0b/ even if they have the same
+ // version code).
+ const versionDir = globals.root.split('/').slice(-2)[0];
+ const swUri = `/service_worker.js?v=${versionDir}`;
+ navigator.serviceWorker.register(swUri).then(registration => {
this._initialWorker = registration.active;
// At this point there are two options:
@@ -90,8 +102,7 @@
this.monitorWorker(registration.installing);
this.monitorWorker(registration.active);
- // Setup the event that shows the "A new release is available"
- // notification.
+ // Setup the event that shows the "Updated to v1.2.3" notification.
registration.addEventListener('updatefound', () => {
this.monitorWorker(registration.installing);
});
diff --git a/ui/src/frontend/sidebar.ts b/ui/src/frontend/sidebar.ts
index a2d0a8d..51c48b5 100644
--- a/ui/src/frontend/sidebar.ts
+++ b/ui/src/frontend/sidebar.ts
@@ -813,8 +813,8 @@
ontransitionend: () => this._redrawWhileAnimating.stop(),
},
m(
- 'header',
- m('img[src=assets/brand.png].brand'),
+ `header`,
+ m(`img[src=${globals.root}assets/brand.png].brand`),
m('button.sidebar-button',
{
onclick: () => {
diff --git a/ui/src/frontend/topbar.ts b/ui/src/frontend/topbar.ts
index dfeb056..0af8781 100644
--- a/ui/src/frontend/topbar.ts
+++ b/ui/src/frontend/topbar.ts
@@ -16,6 +16,7 @@
import {Actions} from '../common/actions';
import {EngineConfig} from '../common/state';
+import * as version from '../gen/perfetto_version';
import {globals} from './globals';
import {executeSearch} from './search_handler';
@@ -198,17 +199,10 @@
}
return m(
'.new-version-toast',
- 'A new version of the UI is available!',
+ `Updated to ${version.VERSION} and ready for offline use!`,
m('button.notification-btn.preferred',
{
onclick: () => {
- location.reload();
- }
- },
- 'Reload'),
- m('button.notification-btn',
- {
- onclick: () => {
globals.frontendLocalState.newVersionAvailable = false;
globals.rafScheduler.scheduleFullRedraw();
}
diff --git a/ui/src/service_worker/service_worker.ts b/ui/src/service_worker/service_worker.ts
index 668f840..fbe5551 100644
--- a/ui/src/service_worker/service_worker.ts
+++ b/ui/src/service_worker/service_worker.ts
@@ -18,38 +18,110 @@
// When a new version of the UI is released (e.g. v1 -> v2), the following
// happens on the next visit:
-// 1. The v1 (old) service worker is activated (at this point we don't know yet
-// that v2 is released).
-// 2. /index.html is requested. The SW intercepts the request and serves
-// v1 from cache.
-// 3. The browser checks if a new version of service_worker.js is available. It
-// does that by comparing the bytes of the current and new version.
-// 5. service_worker.js v2 will not be byte identical with v1, even if v2 was a
-// css-only change. This is due to the hashes in UI_DIST_MAP below. For this
-// reason v2 is installed in the background (it takes several seconds).
-// 6. The 'install' handler is triggered, the new resources are fetched and
-// populated in the cache.
-// 7. The 'activate' handler is triggered. The old caches are deleted at this
+// 1. The v1 (old) service worker is activated. At this point we don't know yet
+// that v2 is released.
+// 2. /index.html is requested. The SW intercepts the request and serves it from
+// the network.
+// 3a If the request fails (offline / server unreachable) or times out, the old
+// v1 is served.
+// 3b If the request succeeds, the browser receives the index.html for v2. That
+// will try to fetch resources from /v2/frontend_bundle.ts.
+// 4. When the SW sees the /v2/ request, will have a cache miss and will issue
+// a network fetch(), returning the fresh /v2/ content.
+// 4. The v2 site will call serviceWorker.register('service_worker.js?v=v2').
+// This (i.e. the different querystring) will cause a re-installation of the
+// service worker (even if the service_worker.js script itself is unchanged).
+// 5. In the "install" step, the service_worker.js script will fetch the newer
+// version (v2).
+// Note: the v2 will be fetched twice, once upon the first request that
+// causes causes a cache-miss, the second time while re-installing the SW.
+// The latter though will hit a HTTP 304 (Not Changed) and will be served
+// from the browser cache after the revalidation request.
+// 6. The 'activate' handler is triggered. The old v1 cache is deleted at this
// point.
-// 8. frontend/index.ts (in setupServiceWorker()) is notified about the activate
-// and shows a notification prompting to reload the UI.
-//
-// If the user just closes the tab or hits refresh, v2 will be served anyways
-// on the next load.
-
-// UI_DIST_FILES is map of {file_name -> sha1}.
-// It is really important that this map is bundled directly in the
-// service_worker.js bundle file, as it's used to cause the browser to
-// re-install the service worker and re-fetch resources when anything changes.
-// This is why the map contains the SHA1s even if we don't directly use them in
-// the code (because it makes the final .js file content-dependent).
-
-import {UI_DIST_MAP} from '../gen/dist_file_map';
declare var self: ServiceWorkerGlobalScope;
+export {};
-const CACHE_NAME = 'dist-' + UI_DIST_MAP.hex_digest.substr(0, 16);
-const LOG_TAG = `ServiceWorker[${UI_DIST_MAP.hex_digest.substr(0, 16)}]: `;
+const LOG_TAG = `ServiceWorker: `;
+const CACHE_NAME = 'ui-perfetto-dev';
+
+// If the fetch() for the / doesn't respond within 3s, return a cached version.
+// This is to avoid that a user waits too much if on a flaky network.
+const INDEX_TIMEOUT_MS = 3000;
+
+// Use more relaxed timeouts when caching the subresources for the new version
+// in the background.
+const INSTALL_TIMEOUT_MS = 30000;
+
+// The install() event is fired:
+// 1. On the first visit, when there is no SW installed.
+// 2. Every time the user opens the site and the version has been updated (they
+// will get the newer version regardless, unless we hit INDEX_TIMEOUT_MS).
+// The latter happens because:
+// - / (index.html) is always served from the network (% timeout) and it pulls
+// /v1.2.3/frontend_bundle.js.
+// - /v1.2.3/frontend_bundle.js will register /service_worker.js?v=v1.2.3 .
+// The service_worker.js script itself never changes, but the browser
+// re-installs it because the version in the V? query-string argument changes.
+// The reinstallation will cache the new files from the v.1.2.3/manifest.json.
+self.addEventListener('install', event => {
+ const doInstall = async () => {
+ if (await caches.has('BYPASS_SERVICE_WORKER')) {
+ // Throw will prevent the installation.
+ throw new Error(LOG_TAG + 'skipping installation, bypass enabled');
+ }
+
+ // Delete old cache entries from the pre-feb-2021 service worker.
+ for (const key of await caches.keys()) {
+ if (key.startsWith('dist-')) {
+ await caches.delete(key);
+ }
+ }
+
+ // The UI should register this as service_worker.js?v=v1.2.3. Extract the
+ // version number and pre-fetch all the contents for the version.
+ const match = /\bv=([\w.]*)/.exec(location.search);
+ if (!match) {
+ throw new Error(
+ 'Failed to install. Was epecting a query string like ' +
+ `?v=v1.2.3 query string, got "${location.search}" instead`);
+ }
+ await installAppVersionIntoCache(match[1]);
+
+ // skipWaiting() still waits for the install to be complete. Without this
+ // call, the new version would be activated only when all tabs are closed.
+ // Instead, we ask to activate it immediately. This is safe because the
+ // subresources are versioned (e.g. /v1.2.3/frontend_bundle.js). Even if
+ // there is an old UI tab opened while we activate() a newer version, the
+ // activate() would just cause cache-misses, hence fetch from the network,
+ // for the old tab.
+ self.skipWaiting();
+ };
+ event.waitUntil(doInstall());
+});
+
+self.addEventListener('activate', (event) => {
+ console.info(LOG_TAG + 'activated');
+ const doActivate = async () => {
+ // This makes a difference only for the very first load, when no service
+ // worker is present. In all the other cases the skipWaiting() will hot-swap
+ // the active service worker anyways.
+ await self.clients.claim();
+ };
+ event.waitUntil(doActivate());
+});
+
+self.addEventListener('fetch', event => {
+ // The early return here will cause the browser to fall back on standard
+ // network-based fetch.
+ if (!shouldHandleHttpRequest(event.request)) {
+ console.debug(LOG_TAG + `serving ${event.request.url} from network`);
+ return;
+ }
+
+ event.respondWith(handleHttpRequest(event.request));
+});
function shouldHandleHttpRequest(req: Request): boolean {
@@ -80,88 +152,90 @@
// resources, which is undesirable.
// * Only Ctrl+R. Ctrl+Shift+R will always bypass service-worker for all the
// requests (index.html and the rest) made in that tab.
- try {
- const cacheOps = {cacheName: CACHE_NAME} as CacheQueryOptions;
- const cachedRes = await caches.match(req, cacheOps);
- if (cachedRes) {
- console.debug(LOG_TAG + `serving ${req.url} from cache`);
- return cachedRes;
+
+ const cacheOps = {cacheName: CACHE_NAME} as CacheQueryOptions;
+ const url = new URL(req.url);
+ if (url.pathname === '/') {
+ try {
+ console.debug(LOG_TAG + `Fetching live ${req.url}`);
+ // The await bleow is needed to fall through in case of an exception.
+ return await fetchWithTimeout(req, INDEX_TIMEOUT_MS);
+ } catch (err) {
+ console.warn(LOG_TAG + `Failed to fetch ${req.url}, using cache.`, err);
+ // Fall through the code below.
}
- console.warn(LOG_TAG + `cache miss on ${req.url}`);
- } catch (exc) {
- console.error(LOG_TAG + `Cache request failed for ${req.url}`, exc);
+ } else if (url.pathname === '/offline') {
+ // Escape hatch to force serving the offline version without attemping the
+ // network fetch.
+ const cachedRes = await caches.match(new Request('/'), cacheOps);
+ if (cachedRes) return cachedRes;
+ }
+
+ const cachedRes = await caches.match(req, cacheOps);
+ if (cachedRes) {
+ console.debug(LOG_TAG + `serving ${req.url} from cache`);
+ return cachedRes;
}
// In any other case, just propagate the fetch on the network, which is the
// safe behavior.
- console.debug(LOG_TAG + `falling back on network fetch() for ${req.url}`);
+ console.warn(LOG_TAG + `cache miss on ${req.url}, using live network`);
return fetch(req);
}
-// The install() event is fired:
-// - The very first time the site is visited, after frontend/index.ts has
-// executed the serviceWorker.register() method.
-// - *After* the site is loaded, if the service_worker.js code
-// has changed (because of the hashes in UI_DIST_MAP, service_worker.js will
-// change if anything in the UI has changed).
-self.addEventListener('install', event => {
- const doInstall = async () => {
- if (await caches.has('BYPASS_SERVICE_WORKER')) {
- // Throw will prevent the installation.
- throw new Error(LOG_TAG + 'skipping installation, bypass enabled');
+async function installAppVersionIntoCache(version: string) {
+ const manifestUrl = `${version}/manifest.json`;
+ try {
+ console.log(LOG_TAG + `Starting installation of ${manifestUrl}`);
+ await caches.delete(CACHE_NAME);
+ const resp = await fetchWithTimeout(manifestUrl, INSTALL_TIMEOUT_MS);
+ const manifest = await resp.json();
+ const manifestResources = manifest['resources'];
+ if (!manifestResources || !(manifestResources instanceof Object)) {
+ throw new Error(`Invalid manifest ${manifestUrl} : ${manifest}`);
}
- console.log(LOG_TAG + 'installation started');
+
const cache = await caches.open(CACHE_NAME);
const urlsToCache: RequestInfo[] = [];
- for (const [file, integrity] of Object.entries(UI_DIST_MAP.files)) {
- const reqOpts:
- RequestInit = {cache: 'reload', mode: 'same-origin', integrity};
- urlsToCache.push(new Request(file, reqOpts));
- if (file === 'index.html' && location.host !== 'storage.googleapis.com') {
- // Disable cachinig of '/' for cases where the UI is hosted on GCS.
- // GCS doesn't support auto indexes. GCS returns a 404 page on / that
- // fails the integrity check.
- urlsToCache.push(new Request('/', reqOpts));
- }
+
+ // We use cache:reload to make sure that the index is always current and we
+ // don't end up in some cycle where we keep re-caching the index coming from
+ // the service worker itself.
+ urlsToCache.push(new Request('/', {cache: 'reload', mode: 'same-origin'}));
+
+ for (const [resource, integrity] of Object.entries(manifestResources)) {
+ // We use cache: no-cache rather then reload here because the versioned
+ // sub-resources are expected to be immutable and should never be
+ // ambiguous. A revalidation request is enough.
+ const reqOpts: RequestInit = {
+ cache: 'no-cache',
+ mode: 'same-origin',
+ integrity: `${integrity}`
+ };
+ urlsToCache.push(new Request(`${version}/${resource}`, reqOpts));
}
await cache.addAll(urlsToCache);
- console.log(LOG_TAG + 'installation completed');
-
- // skipWaiting() still waits for the install to be complete. Without this
- // call, the new version would be activated only when all tabs are closed.
- // Instead, we ask to activate it immediately. This is safe because each
- // service worker version uses a different cache named after the SHA256 of
- // the contents. When the old version is activated, the activate() method
- // below will evict the cache for the old versions. If there is an old still
- // opened, any further request from that tab will be a cache-miss and go
- // through the network (which is inconsitent, but not the end of the world).
- self.skipWaiting();
- };
- event.waitUntil(doInstall());
-});
-
-self.addEventListener('activate', (event) => {
- console.warn(LOG_TAG + 'activated');
- const doActivate = async () => {
- // Clear old caches.
- for (const key of await caches.keys()) {
- if (key !== CACHE_NAME) await caches.delete(key);
- }
- // This makes a difference only for the very first load, when no service
- // worker is present. In all the other cases the skipWaiting() will hot-swap
- // the active service worker anyways.
- await self.clients.claim();
- };
- event.waitUntil(doActivate());
-});
-
-self.addEventListener('fetch', event => {
- // The early return here will cause the browser to fall back on standard
- // network-based fetch.
- if (!shouldHandleHttpRequest(event.request)) {
- console.debug(LOG_TAG + `serving ${event.request.url} from network`);
- return;
+ console.log(LOG_TAG + 'installation completed for ' + version);
+ } catch (err) {
+ await caches.delete(CACHE_NAME);
+ console.error(LOG_TAG + `Installation failed for ${manifestUrl}`, err);
+ throw err;
}
+}
- event.respondWith(handleHttpRequest(event.request));
-});
+function fetchWithTimeout(req: Request|string, timeoutMs: number) {
+ const url = (req as {url?: string}).url || `${req}`;
+ return new Promise<Response>((resolve, reject) => {
+ const timerId = setTimeout(() => {
+ reject(`Timed out while fetching ${url}`);
+ }, timeoutMs);
+ fetch(req).then(resp => {
+ clearTimeout(timerId);
+ if (resp.ok) {
+ resolve(resp);
+ } else {
+ reject(`Fetch failed for ${url}: ${resp.status} ${resp.statusText}`);
+ }
+ }, reject);
+ });
+}
diff --git a/ui/src/service_worker/tsconfig.json b/ui/src/service_worker/tsconfig.json
index 7b58182..35ff5b6 100644
--- a/ui/src/service_worker/tsconfig.json
+++ b/ui/src/service_worker/tsconfig.json
@@ -5,6 +5,7 @@
"../gen/"
],
"compilerOptions": {
+ "outDir": "../../out/tsc/service_worker",
"lib": [
"webworker",
"es2018",
diff --git a/ui/tsconfig.base.json b/ui/tsconfig.base.json
index f8cdfc4..075b1b3 100644
--- a/ui/tsconfig.base.json
+++ b/ui/tsconfig.base.json
@@ -8,7 +8,6 @@
"allowJs": true,
"declaration": false, // Generates corresponding '.d.ts' file.
"sourceMap": true, // Generates corresponding '.map' file.
- "outDir": "./out/tsc", // Redirect output structure to the directory.
"removeComments": false, // Do not emit comments to output.
"importHelpers": true, // Import emit helpers from 'tslib'.
"downlevelIteration": true, // Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'.
diff --git a/ui/tsconfig.json b/ui/tsconfig.json
index ada3c9b..5867aea 100644
--- a/ui/tsconfig.json
+++ b/ui/tsconfig.json
@@ -8,6 +8,7 @@
"./out"
],
"compilerOptions": {
+ "outDir": "./out/tsc",
"lib": [
"dom", // Need to be explicitly mentioned now since we're overriding default included libs.
"es2018", // Need this to use Object.values.