Merge "ui: add offline support via ServiceWorker"
diff --git a/gn/standalone/write_ui_dist_file_map.py b/gn/standalone/write_ui_dist_file_map.py
new file mode 100644
index 0000000..8ea5aff
--- /dev/null
+++ b/gn/standalone/write_ui_dist_file_map.py
@@ -0,0 +1,90 @@
+#!/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 multiprocessing
+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 (in parallel).
+ pool = multiprocessing.Pool(multiprocessing.cpu_count() * 2)
+ digests = dict(pool.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.iteritems():
+ 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))
+ 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/ui/BUILD.gn b/ui/BUILD.gn
index b27e380..d7d67a8 100644
--- a/ui/BUILD.gn
+++ b/ui/BUILD.gn
@@ -28,23 +28,44 @@
# +----------------------------------------------------------------------------+
# | The outer "ui" target to just ninja -C out/xxx ui |
# +----------------------------------------------------------------------------+
+
group("ui") {
deps = [
- ":assets_dist",
- ":catapult_dist",
":chrome_extension_assets_dist",
":chrome_extension_bundle_dist",
- ":controller_bundle_dist",
- ":engine_bundle_dist",
- ":frontend_bundle_dist",
- ":index_dist",
- ":scss",
+ ":dist",
+ ":gen_dist_file_map",
+ ":service_worker_bundle_dist",
":test_scripts",
- ":typefaces_dist",
- ":wasm_dist",
+
+ # IMPORTANT: Only add deps here if they are NOT part of the production UI
+ # (e.g., tests, extensions, ...). Any UI dep should go in the
+ # |ui_dist_targets| list below. The only exception is the service worker
+ # target, that depends on that list.
]
}
+# The list of targets that produces dist/ files for the UI. This list is used
+# also by the gen_dist_file_map to generate the map of hashes of all UI files,
+# which is turn used by the service worker code for the offline caching.
+ui_dist_targets = [
+ ":assets_dist",
+ ":catapult_dist",
+ ":controller_bundle_dist",
+ ":engine_bundle_dist",
+ ":frontend_bundle_dist",
+ ":index_dist",
+ ":scss",
+ ":typefaces_dist",
+ ":wasm_dist",
+]
+
+# Buils the ui, but not service worker, tests and extensions.
+group("dist") {
+ deps = ui_dist_targets
+}
+
+# A minimal page to profile the WASM engine without the all UI.
group("query") {
deps = [
":query_bundle_dist",
@@ -182,6 +203,14 @@
output = "$target_out_dir/engine_bundle.js"
}
+bundle("service_worker_bundle") {
+ deps = [
+ ":transpile_service_worker_ts",
+ ]
+ input = "$target_out_dir/service_worker/service_worker.js"
+ output = "$target_out_dir/service_worker.js"
+}
+
bundle("query_bundle") {
deps = [
":transpile_all_ts",
@@ -270,6 +299,7 @@
"--filter=*.ts",
"--exclude=node_modules",
"--exclude=dist",
+ "--exclude=service_worker",
"--deps=obj/ui/frontend/index.js",
"--output=" + rebase_path(depfile),
],
@@ -284,6 +314,28 @@
]
}
+node_bin("transpile_service_worker_ts") {
+ deps = [
+ ":dist_symlink",
+ ":gen_dist_file_map",
+ ]
+ inputs = [
+ "tsconfig.json",
+ "src/service_worker/service_worker.ts",
+ ]
+ outputs = [
+ "$target_out_dir/service_worker/service_worker.js",
+ ]
+
+ node_cmd = "tsc"
+ args = [
+ "--project",
+ rebase_path("src/service_worker", root_build_dir),
+ "--outDir",
+ rebase_path(target_out_dir, root_build_dir),
+ ]
+}
+
# +----------------------------------------------------------------------------+
# | Build css. |
# +----------------------------------------------------------------------------+
@@ -424,6 +476,14 @@
output = "$ui_dir/engine_bundle.js"
}
+sorcery("service_worker_bundle_dist") {
+ deps = [
+ ":service_worker_bundle",
+ ]
+ input = "$target_out_dir/service_worker.js"
+ output = "$ui_dir/service_worker.js"
+}
+
sorcery("query_bundle_dist") {
deps = [
":query_bundle",
@@ -585,3 +645,32 @@
"$root_build_dir/ui_tests",
]
}
+
+# This target generates an map containing all the UI subresources and their
+# hashes. This is used by the service worker code for offline caching.
+# This taarget needs to be kept at the end of the BUILD.gn file, because of the
+# get_target_outputs() call (fails otherwise due to GN's evaluation order).
+action("gen_dist_file_map") {
+ out_file_path = "$ui_gen_dir/dist_file_map.ts"
+
+ dist_files = []
+ foreach(target, ui_dist_targets) {
+ foreach(dist_file, get_target_outputs(target)) {
+ dist_files += [ rebase_path(dist_file, root_build_dir) ]
+ }
+ }
+ deps = [
+ ":dist",
+ ]
+ script = "../gn/standalone/write_ui_dist_file_map.py"
+ inputs = []
+ outputs = [
+ out_file_path,
+ ]
+ args = [
+ "--out",
+ rebase_path(out_file_path, root_build_dir),
+ "--strip",
+ rebase_path(ui_dir, root_build_dir),
+ ] + dist_files
+}
diff --git a/ui/index.html b/ui/index.html
index 0e1833a..fae544d 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -8,9 +8,6 @@
<meta http-equiv="origin-trial" content="AtzsILqIzNPGftktQTEYxI9GpnqFBuse5uB5n4JQO3Wa1ky4TCKmnXZli0A9g9p7Es7Il9pqarELntnfm0HriwkAAABreyJvcmlnaW4iOiJodHRwczovL3VpLnBlcmZldHRvLmRldjo0NDMiLCJmZWF0dXJlIjoiV2ViQ29tcG9uZW50c1YwIiwiZXhwaXJ5IjoxNjA4MjI2NDQzLCJpc1N1YmRvbWFpbiI6dHJ1ZX0=">
<link href="perfetto.css" rel="stylesheet">
<link rel="icon" type="image/png" href="assets/logo.png">
- <link rel="preload" href="controller_bundle.js" as="script">
- <link rel="preload" href="engine_bundle.js" as="script">
- <link rel="preload" href="trace_processor.wasm" as="fetch">
</head>
<body>
<main>
diff --git a/ui/src/assets/sidebar.scss b/ui/src/assets/sidebar.scss
index 7cb1ce2..7868f27 100644
--- a/ui/src/assets/sidebar.scss
+++ b/ui/src/assets/sidebar.scss
@@ -184,7 +184,7 @@
}
}
- > .num-queued-queries {
+ > .dbg-info-square {
width: 24px;
height: 22px;
line-height: 22px;
@@ -194,11 +194,15 @@
border-radius: 5px;
font-size: 12px;
text-align: center;
- &.rpc {
+ &.green {
background: #7aca75;
color: #12161b;
}
- &.failed {
+ &.amber {
+ background: #FFC107;
+ color: #333;
+ }
+ &.red {
background: #d32f2f;
color: #fff;
}
diff --git a/ui/src/assets/topbar.scss b/ui/src/assets/topbar.scss
index 49c06df..f5ce2f5 100644
--- a/ui/src/assets/topbar.scss
+++ b/ui/src/assets/topbar.scss
@@ -201,4 +201,24 @@
right: -90%;
}
}
+
+ .notification-btn {
+ @include transition(0.25s);
+ font-size: 16px;
+ padding: 8px 10px;
+ margin: 0 10px;
+ border-radius: 2px;
+ background: hsl(210, 10%, 73%);
+ &:hover {
+ background: hsl(210, 10%, 83%);
+ }
+
+ &.preferred {
+ background: hsl(210, 98%, 53%);
+ color: #fff;
+ &:hover {
+ background: hsl(210, 98%, 63%);
+ }
+ }
+ }
}
diff --git a/ui/src/frontend/frontend_local_state.ts b/ui/src/frontend/frontend_local_state.ts
index b38fe94..0f4a605 100644
--- a/ui/src/frontend/frontend_local_state.ts
+++ b/ui/src/frontend/frontend_local_state.ts
@@ -107,6 +107,7 @@
currentTab?: Tab;
scrollToTrackId?: string|number;
httpRpcState: HttpRpcState = {connected: false};
+ newVersionAvailable = false;
private scrollBarWidth?: number;
private _omniboxState: OmniboxState = {
diff --git a/ui/src/frontend/globals.ts b/ui/src/frontend/globals.ts
index 0fb518d..7c384ba 100644
--- a/ui/src/frontend/globals.ts
+++ b/ui/src/frontend/globals.ts
@@ -20,6 +20,7 @@
import {FrontendLocalState} from './frontend_local_state';
import {RafScheduler} from './raf_scheduler';
+import {ServiceWorkerController} from './service_worker_controller';
type Dispatch = (action: DeferredAction) => void;
type TrackDataStore = Map<string, {}>;
@@ -85,6 +86,7 @@
private _state?: State = undefined;
private _frontendLocalState?: FrontendLocalState = undefined;
private _rafScheduler?: RafScheduler = undefined;
+ private _serviceWorkerController?: ServiceWorkerController = undefined;
// TODO(hjd): Unify trackDataStore, queryResults, overviewStore, threads.
private _trackDataStore?: TrackDataStore = undefined;
@@ -126,6 +128,7 @@
this._state = createEmptyState();
this._frontendLocalState = new FrontendLocalState();
this._rafScheduler = new RafScheduler();
+ this._serviceWorkerController = new ServiceWorkerController();
// TODO(hjd): Unify trackDataStore, queryResults, overviewStore, threads.
this._trackDataStore = new Map<string, {}>();
@@ -157,6 +160,10 @@
return assertExists(this._rafScheduler);
}
+ get serviceWorkerController() {
+ return assertExists(this._serviceWorkerController);
+ }
+
// TODO(hjd): Unify trackDataStore, queryResults, overviewStore, threads.
get overviewStore(): OverviewStore {
return assertExists(this._overviewStore);
@@ -262,6 +269,7 @@
this._state = undefined;
this._frontendLocalState = undefined;
this._rafScheduler = undefined;
+ this._serviceWorkerController = undefined;
// TODO(hjd): Unify trackDataStore, queryResults, overviewStore, threads.
this._trackDataStore = undefined;
diff --git a/ui/src/frontend/index.ts b/ui/src/frontend/index.ts
index c9f75bc..e1d5c64 100644
--- a/ui/src/frontend/index.ts
+++ b/ui/src/frontend/index.ts
@@ -78,7 +78,7 @@
// want to keep in the global state. Figure out a more generic and type-safe
// mechanism to achieve this.
- publishOverviewData(data: {[key: string]: QuantizedLoad | QuantizedLoad[]}) {
+ publishOverviewData(data: {[key: string]: QuantizedLoad|QuantizedLoad[]}) {
for (const [key, value] of Object.entries(data)) {
if (!globals.overviewStore.has(key)) {
globals.overviewStore.set(key, []);
@@ -251,6 +251,7 @@
dispatch);
forwardRemoteCalls(frontendChannel.port2, new FrontendApi(router));
globals.initialize(dispatch, controller);
+ globals.serviceWorkerController.install();
// We proxy messages between the extension and the controller because the
// controller's worker can't access chrome.runtime.
diff --git a/ui/src/frontend/service_worker_controller.ts b/ui/src/frontend/service_worker_controller.ts
new file mode 100644
index 0000000..54c5fe4
--- /dev/null
+++ b/ui/src/frontend/service_worker_controller.ts
@@ -0,0 +1,119 @@
+// 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.
+
+// Handles registration, unregistration and lifecycle of the service worker.
+// This class contains only the controlling logic, all the code in here runs in
+// the main thread, not in the service worker thread.
+// The actual service worker code is in src/service_worker.
+// Design doc: http://go/perfetto-offline.
+
+import {reportError} from '../base/logging';
+import {globals} from './globals';
+
+// We use a dedicated |caches| object to share a global boolean beween the main
+// thread and the SW. SW cannot use local-storage or anything else other than
+// IndexedDB (which would be overkill).
+const BYPASS_ID = 'BYPASS_SERVICE_WORKER';
+
+export class ServiceWorkerController {
+ private _initialWorker: ServiceWorker|null = null;
+ private _bypassed = false;
+ private _installing = false;
+
+ // Caller should reload().
+ async setBypass(bypass: boolean) {
+ this._bypassed = bypass;
+ if (bypass) {
+ await caches.open(BYPASS_ID); // Create the entry.
+ for (const reg of await navigator.serviceWorker.getRegistrations()) {
+ await reg.unregister();
+ }
+ } else {
+ await caches.delete(BYPASS_ID);
+ this.install();
+ }
+ globals.rafScheduler.scheduleFullRedraw();
+ }
+
+ onStateChange(sw: ServiceWorker) {
+ globals.rafScheduler.scheduleFullRedraw();
+ if (sw.state === 'installing') {
+ this._installing = true;
+ } else if (sw.state === 'activated') {
+ this._installing = false;
+ // Don't show the notification if the site was served straight
+ // from the network (e.g., on the very first visit or after
+ // Ctrl+Shift+R). In these cases, we are already at the last
+ // version.
+ if (sw !== this._initialWorker && this._initialWorker) {
+ globals.frontendLocalState.newVersionAvailable = true;
+ }
+ } else if (
+ sw.state === 'redundant' && sw !== this._initialWorker &&
+ !this._bypassed) {
+ // Note that upon updates, the initial SW will hit the 'redundant'
+ // state by design once the new one is activated. That's why the
+ // != _initialWorker above.
+
+ // In the other cases, the 'redundant' state signals a failure in the
+ // SW installation. This can happen, for instance, if the subresource
+ // integrity check fails. In that case there doesn't seem to be any easy
+ // way to get the failure output from the service worker.
+ reportError(
+ 'Service Worker installation failed.\n' +
+ 'Please attach the JavaScript console output to the bug.');
+ }
+ }
+
+ monitorWorker(sw: ServiceWorker|null) {
+ if (!sw) return;
+ sw.addEventListener('error', (e) => reportError(e));
+ sw.addEventListener('statechange', () => this.onStateChange(sw));
+ this.onStateChange(sw); // Trigger updates for the current state.
+ }
+
+ async install() {
+ if (!('serviceWorker' in navigator)) return; // Not supported.
+
+ 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 => {
+ this._initialWorker = registration.active;
+
+ // At this point there are two options:
+ // 1. This is the first time we visit the site (or cache was cleared) and
+ // no SW is installed yet. In this case |installing| will be set.
+ // 2. A SW is already installed (though it might be obsolete). In this
+ // case |active| will be set.
+ this.monitorWorker(registration.installing);
+ this.monitorWorker(registration.active);
+
+ // Setup the event that shows the "A new release is available"
+ // notification.
+ registration.addEventListener('updatefound', () => {
+ this.monitorWorker(registration.installing);
+ });
+ });
+ }
+
+ get bypassed() {
+ return this._bypassed;
+ }
+ get installing() {
+ return this._installing;
+ }
+}
\ No newline at end of file
diff --git a/ui/src/frontend/sidebar.ts b/ui/src/frontend/sidebar.ts
index 4e7c3aa..f4305ad 100644
--- a/ui/src/frontend/sidebar.ts
+++ b/ui/src/frontend/sidebar.ts
@@ -464,7 +464,7 @@
}
-const SidebarFooter: m.Component = {
+const EngineRPCWidget: m.Component = {
view() {
let cssClass = '';
let title = 'Number of pending SQL queries';
@@ -478,7 +478,7 @@
for (const engine of engines) {
mode = engine.mode;
if (engine.failed !== undefined) {
- cssClass += '.failed';
+ cssClass += '.red';
title = 'Query engine crashed\n' + engine.failed;
failed = true;
}
@@ -499,7 +499,7 @@
}
if (mode === 'HTTP_RPC') {
- cssClass += '.rpc';
+ cssClass += '.green';
label = 'RPC';
title += '\n(Query engine: native accelerator over HTTP+RPC)';
} else {
@@ -508,6 +508,85 @@
}
return m(
+ `.dbg-info-square${cssClass}`,
+ {title},
+ m('div', label),
+ m('div', `${failed ? 'FAIL' : globals.numQueuedQueries}`));
+ }
+};
+
+const ServiceWorkerWidget: m.Component = {
+ view() {
+ let cssClass = '';
+ let title = 'Service Worker: ';
+ let label = 'N/A';
+ const ctl = globals.serviceWorkerController;
+ if (ctl.bypassed) {
+ label = 'OFF';
+ cssClass = '.red';
+ title += 'Bypassed, using live network. Double-click to re-enable';
+ } else if (ctl.installing) {
+ label = 'UPD';
+ cssClass = '.amber';
+ title += 'Installing / updating ...';
+ } else if (!navigator.serviceWorker.controller) {
+ label = 'N/A';
+ title += 'Not available, using network';
+ } else {
+ label = 'ON';
+ cssClass = '.green';
+ title += 'Serving from cache. Ready for offline use';
+ }
+
+ const toggle = async () => {
+ if (globals.serviceWorkerController.bypassed) {
+ globals.serviceWorkerController.setBypass(false);
+ return;
+ }
+ showModal({
+ title: 'Disable service worker?',
+ content: m(
+ 'div',
+ m('p', `If you continue the service worker will be disabled until
+ manually re-enabled.`),
+ m('p', `All future requests will be served from the network and the
+ UI won't be available offline.`),
+ m('p', `You should do this only if you are debugging the UI
+ or if you are experiencing caching-related problems.`),
+ m('p', `Disabling will cause a refresh of the UI, the current state
+ will be lost.`),
+ ),
+ buttons: [
+ {
+ text: 'Disable and reload',
+ primary: true,
+ id: 'sw-bypass-enable',
+ action: () => {
+ globals.serviceWorkerController.setBypass(true).then(
+ () => location.reload());
+ }
+ },
+ {
+ text: 'Cancel',
+ primary: false,
+ id: 'sw-bypass-cancel',
+ action: () => {}
+ }
+ ]
+ });
+ };
+
+ return m(
+ `.dbg-info-square${cssClass}`,
+ {title, ondblclick: toggle},
+ m('div', 'SW'),
+ m('div', label));
+ }
+};
+
+const SidebarFooter: m.Component = {
+ view() {
+ return m(
'.sidebar-footer',
m('button',
{
@@ -516,10 +595,8 @@
m('i.material-icons',
{title: 'Toggle Perf Debug Mode'},
'assessment')),
- m(`.num-queued-queries${cssClass}`,
- {title},
- m('div', label),
- m('div', `${failed ? 'FAIL' : globals.numQueuedQueries}`)),
+ m(EngineRPCWidget),
+ m(ServiceWorkerWidget),
);
}
};
diff --git a/ui/src/frontend/topbar.ts b/ui/src/frontend/topbar.ts
index 2163ace..4fd3c66 100644
--- a/ui/src/frontend/topbar.ts
+++ b/ui/src/frontend/topbar.ts
@@ -229,8 +229,39 @@
}
}
+
+class NewVersionNotification implements m.ClassComponent {
+ view() {
+ if (!globals.frontendLocalState.newVersionAvailable) return;
+ return m(
+ '.new-version-toast',
+ 'A new version of the UI is available!',
+ m('button.notification-btn.preferred',
+ {
+ onclick: () => {
+ location.reload();
+ }
+ },
+ 'Reload'),
+ m('button.notification-btn',
+ {
+ onclick: () => {
+ globals.frontendLocalState.newVersionAvailable = false;
+ globals.rafScheduler.scheduleFullRedraw();
+ }
+ },
+ 'Dismiss'),
+ );
+ }
+}
+
export class Topbar implements m.ClassComponent {
view() {
- return m('.topbar', m(Omnibox), m(Progress));
+ return m(
+ '.topbar',
+ globals.frontendLocalState.newVersionAvailable ?
+ m(NewVersionNotification) :
+ m(Omnibox),
+ m(Progress));
}
}
diff --git a/ui/src/service_worker/service_worker.ts b/ui/src/service_worker/service_worker.ts
new file mode 100644
index 0000000..4226a8a
--- /dev/null
+++ b/ui/src/service_worker/service_worker.ts
@@ -0,0 +1,154 @@
+// 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.
+
+// This script handles the caching of the UI resources, allowing it to work
+// offline (as long as the UI site has been visited at least once).
+// Design doc: http://go/perfetto-offline.
+
+// 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
+// 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;
+
+const CACHE_NAME = 'dist-' + UI_DIST_MAP.hex_digest.substr(0, 16);
+const LOG_TAG = `ServiceWorker[${UI_DIST_MAP.hex_digest.substr(0, 16)}]: `;
+
+async function handleHttpRequest(req: Request): Promise<Response> {
+ let fetchReason = 'N/A';
+ const url = new URL(req.url);
+
+ // We serve from the cache even if req.cache == 'no-cache'. It's a bit
+ // contra-intuitive but it's the most consistent option. If the user hits the
+ // reload button*, the browser requests the "/" index with a 'no-cache' fetch.
+ // However all the other resources (css, js, ...) are requested with a
+ // 'default' fetch (this is just how Chrome works, it's not us). If we bypass
+ // the service worker cache when we get a 'no-cache' request, we can end up in
+ // an inconsistent state where the index.html is more recent than the other
+ // 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.
+ const cacheable = req.method === 'GET' && url.origin === self.location.origin;
+ if (cacheable) {
+ 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;
+ }
+ console.warn(LOG_TAG + `cache miss on ${req.url}`);
+ fetchReason = 'cache miss';
+ } catch (exc) {
+ console.error(LOG_TAG + `Fetch failed for ${req.url}`, exc);
+ fetchReason = 'fetch failed';
+ }
+ } else {
+ fetchReason = `not cacheable (${req.method}, ${req.cache}, ${url.origin})`;
+ }
+
+ // In any other case, just propagate the fetch on the network, which is the
+ // safe behavior.
+ console.debug(LOG_TAG + `serving ${req.url} from network: ${fetchReason}`);
+ 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');
+ }
+ 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', integrity};
+ urlsToCache.push(new Request(file, reqOpts));
+ if (file === 'index.html') {
+ const indexPage = location.href.split('service_worker.js')[0];
+ // Disable cachinig of '/' for cases where the UI is hosted in a
+ // subdirectory, because the ci-artifacts GCS bucket doesn't support
+ // auto indexes (it has a fallback 404 page that fails the check).
+ if (indexPage === '/') {
+ urlsToCache.push(new Request(indexPage, 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 => {
+ event.respondWith(handleHttpRequest(event.request));
+});
diff --git a/ui/src/service_worker/tsconfig.json b/ui/src/service_worker/tsconfig.json
new file mode 100644
index 0000000..7b58182
--- /dev/null
+++ b/ui/src/service_worker/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": [ "." ],
+ "exclude": [
+ "../gen/"
+ ],
+ "compilerOptions": {
+ "lib": [
+ "webworker",
+ "es2018",
+ ],
+ "types" : []
+ }
+}
diff --git a/ui/tsconfig.base.json b/ui/tsconfig.base.json
new file mode 100644
index 0000000..e240301
--- /dev/null
+++ b/ui/tsconfig.base.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "baseUrl": ".",
+ "target": "es6",
+ "module": "commonjs",
+ "moduleResolution": "node",
+ // Lints and checks.
+ "allowJs": true,
+ "declaration": false, // Generates corresponding '.d.ts' file.
+ "sourceMap": true, // Generates corresponding '.map' file.
+ "outDir": "./dist", // 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'.
+ "strict": true, // Enable all strict type-checking options.
+ "noImplicitAny": true, // Raise error on expressions and declarations with an implied 'any' type.
+ "strictNullChecks": true, // Enable strict null checks.
+ "strictFunctionTypes": true, // Enable strict checking of function types.
+ "strictPropertyInitialization": true, // Enable strict checking of property initialization in classes.
+ "noImplicitThis": true, // Raise error on 'this' expressions with an implied 'any' type.
+ "alwaysStrict": true, // Parse in strict mode and emit "use strict" for each source file.
+ "noUnusedLocals": true, // Report errors on unused locals.
+ "noUnusedParameters": true, // Report errors on unused parameters.
+ "noImplicitReturns": true, // Report error when not all code paths in function return a value.
+ "noFallthroughCasesInSwitch": true, // Report errors for fallthrough cases in switch statement.
+ }
+}
diff --git a/ui/tsconfig.json b/ui/tsconfig.json
index e0564a8..8f2c266 100644
--- a/ui/tsconfig.json
+++ b/ui/tsconfig.json
@@ -1,7 +1,9 @@
{
+ "extends": "./tsconfig.base.json",
"include": [ "src/" ],
"exclude": [
"./node_modules/",
+ "./src/service_worker/",
"./src/gen/"
],
"compilerOptions": {
@@ -9,31 +11,8 @@
"dom", // Need to be explicitly mentioned now since we're overriding default included libs.
"es2018", // Need this to use Object.values.
],
- "baseUrl": ".",
"paths": {
"*" : ["*", "./node_modules/@tsundoku/micromodal_types/*"]
},
- "target": "es6",
- "module": "commonjs",
- "moduleResolution": "node",
- // Lints and checks.
- "allowJs": true,
- "declaration": false, // Generates corresponding '.d.ts' file.
- "sourceMap": true, // Generates corresponding '.map' file.
- "outDir": "./dist", // 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'.
- "strict": true, // Enable all strict type-checking options.
- "noImplicitAny": true, // Raise error on expressions and declarations with an implied 'any' type.
- "strictNullChecks": true, // Enable strict null checks.
- "strictFunctionTypes": true, // Enable strict checking of function types.
- "strictPropertyInitialization": true, // Enable strict checking of property initialization in classes.
- "noImplicitThis": true, // Raise error on 'this' expressions with an implied 'any' type.
- "alwaysStrict": true, // Parse in strict mode and emit "use strict" for each source file.
- "noUnusedLocals": true, // Report errors on unused locals.
- "noUnusedParameters": true, // Report errors on unused parameters.
- "noImplicitReturns": true, // Report error when not all code paths in function return a value.
- "noFallthroughCasesInSwitch": true, // Report errors for fallthrough cases in switch statement.
}
}