• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright (C) 2020 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// This script handles the caching of the UI resources, allowing it to work
16// offline (as long as the UI site has been visited at least once).
17// Design doc: http://go/perfetto-offline.
18
19// When a new version of the UI is released (e.g. v1 -> v2), the following
20// happens on the next visit:
21// 1. The v1 (old) service worker is activated (at this point we don't know yet
22//    that v2 is released).
23// 2. /index.html is requested. The SW intercepts the request and serves
24//    v1 from cache.
25// 3. The browser checks if a new version of service_worker.js is available. It
26//    does that by comparing the bytes of the current and new version.
27// 5. service_worker.js v2 will not be byte identical with v1, even if v2 was a
28//    css-only change. This is due to the hashes in UI_DIST_MAP below. For this
29//    reason v2 is installed in the background (it takes several seconds).
30// 6. The 'install' handler is triggered, the new resources are fetched and
31//    populated in the cache.
32// 7. The 'activate' handler is triggered. The old caches are deleted at this
33//    point.
34// 8. frontend/index.ts (in setupServiceWorker()) is notified about the activate
35//    and shows a notification prompting to reload the UI.
36//
37// If the user just closes the tab or hits refresh, v2 will be served anyways
38// on the next load.
39
40// UI_DIST_FILES is map of {file_name -> sha1}.
41// It is really important that this map is bundled directly in the
42// service_worker.js bundle file, as it's used to cause the browser to
43// re-install the service worker and re-fetch resources when anything changes.
44// This is why the map contains the SHA1s even if we don't directly use them in
45// the code (because it makes the final .js file content-dependent).
46
47import {UI_DIST_MAP} from '../gen/dist_file_map';
48
49declare var self: ServiceWorkerGlobalScope;
50
51const CACHE_NAME = 'dist-' + UI_DIST_MAP.hex_digest.substr(0, 16);
52const LOG_TAG = `ServiceWorker[${UI_DIST_MAP.hex_digest.substr(0, 16)}]: `;
53
54
55function shouldHandleHttpRequest(req: Request): boolean {
56  // Suppress warning: 'only-if-cached' can be set only with 'same-origin' mode.
57  // This seems to be a chromium bug. An internal code search suggests this is a
58  // socially acceptable workaround.
59  if (req.cache === 'only-if-cached' && req.mode !== 'same-origin') {
60    return false;
61  }
62
63  const url = new URL(req.url);
64  return req.method === 'GET' && url.origin === self.location.origin;
65}
66
67async function handleHttpRequest(req: Request): Promise<Response> {
68  if (!shouldHandleHttpRequest(req)) {
69    throw new Error(LOG_TAG + `${req.url} shouldn't have been handled`);
70  }
71
72  // We serve from the cache even if req.cache == 'no-cache'. It's a bit
73  // contra-intuitive but it's the most consistent option. If the user hits the
74  // reload button*, the browser requests the "/" index with a 'no-cache' fetch.
75  // However all the other resources (css, js, ...) are requested with a
76  // 'default' fetch (this is just how Chrome works, it's not us). If we bypass
77  // the service worker cache when we get a 'no-cache' request, we can end up in
78  // an inconsistent state where the index.html is more recent than the other
79  // resources, which is undesirable.
80  // * Only Ctrl+R. Ctrl+Shift+R will always bypass service-worker for all the
81  // requests (index.html and the rest) made in that tab.
82  try {
83    const cacheOps = {cacheName: CACHE_NAME} as CacheQueryOptions;
84    const cachedRes = await caches.match(req, cacheOps);
85    if (cachedRes) {
86      console.debug(LOG_TAG + `serving ${req.url} from cache`);
87      return cachedRes;
88    }
89    console.warn(LOG_TAG + `cache miss on ${req.url}`);
90  } catch (exc) {
91    console.error(LOG_TAG + `Cache request failed for ${req.url}`, exc);
92  }
93
94  // In any other case, just propagate the fetch on the network, which is the
95  // safe behavior.
96  console.debug(LOG_TAG + `falling back on network fetch() for ${req.url}`);
97  return fetch(req);
98}
99
100// The install() event is fired:
101// - The very first time the site is visited, after frontend/index.ts has
102//   executed the serviceWorker.register() method.
103// - *After* the site is loaded, if the service_worker.js code
104//   has changed (because of the hashes in UI_DIST_MAP, service_worker.js will
105//   change if anything in the UI has changed).
106self.addEventListener('install', event => {
107  const doInstall = async () => {
108    if (await caches.has('BYPASS_SERVICE_WORKER')) {
109      // Throw will prevent the installation.
110      throw new Error(LOG_TAG + 'skipping installation, bypass enabled');
111    }
112    console.log(LOG_TAG + 'installation started');
113    const cache = await caches.open(CACHE_NAME);
114    const urlsToCache: RequestInfo[] = [];
115    for (const [file, integrity] of Object.entries(UI_DIST_MAP.files)) {
116      const reqOpts:
117          RequestInit = {cache: 'reload', mode: 'same-origin', integrity};
118      urlsToCache.push(new Request(file, reqOpts));
119      if (file === 'index.html' && location.host !== 'storage.googleapis.com') {
120        // Disable cachinig of '/' for cases where the UI is hosted on GCS.
121        // GCS doesn't support auto indexes. GCS returns a 404 page on / that
122        // fails the integrity check.
123        urlsToCache.push(new Request('/', reqOpts));
124      }
125    }
126    await cache.addAll(urlsToCache);
127    console.log(LOG_TAG + 'installation completed');
128
129    // skipWaiting() still waits for the install to be complete. Without this
130    // call, the new version would be activated only when all tabs are closed.
131    // Instead, we ask to activate it immediately. This is safe because each
132    // service worker version uses a different cache named after the SHA256 of
133    // the contents. When the old version is activated, the activate() method
134    // below will evict the cache for the old versions. If there is an old still
135    // opened, any further request from that tab will be a cache-miss and go
136    // through the network (which is inconsitent, but not the end of the world).
137    self.skipWaiting();
138  };
139  event.waitUntil(doInstall());
140});
141
142self.addEventListener('activate', (event) => {
143  console.warn(LOG_TAG + 'activated');
144  const doActivate = async () => {
145    // Clear old caches.
146    for (const key of await caches.keys()) {
147      if (key !== CACHE_NAME) await caches.delete(key);
148    }
149    // This makes a difference only for the very first load, when no service
150    // worker is present. In all the other cases the skipWaiting() will hot-swap
151    // the active service worker anyways.
152    await self.clients.claim();
153  };
154  event.waitUntil(doActivate());
155});
156
157self.addEventListener('fetch', event => {
158  // The early return here will cause the browser to fall back on standard
159  // network-based fetch.
160  if (!shouldHandleHttpRequest(event.request)) {
161    console.debug(LOG_TAG + `serving ${event.request.url} from network`);
162    return;
163  }
164
165  event.respondWith(handleHttpRequest(event.request));
166});
167