aboutsummaryrefslogtreecommitdiff
path: root/ui/src/service_worker/service_worker.ts
blob: 328935244e4ebafdc0f2c7ecf4cb67350cf3af99 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
// 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)}]: `;


function shouldHandleHttpRequest(req: Request): boolean {
  // Suppress warning: 'only-if-cached' can be set only with 'same-origin' mode.
  // This seems to be a chromium bug. An internal code search suggests this is a
  // socially acceptable workaround.
  if (req.cache === 'only-if-cached' && req.mode !== 'same-origin') {
    return false;
  }

  const url = new URL(req.url);
  return req.method === 'GET' && url.origin === self.location.origin;
}

async function handleHttpRequest(req: Request): Promise<Response> {
  if (!shouldHandleHttpRequest(req)) {
    throw new Error(LOG_TAG + `${req.url} shouldn't have been handled`);
  }

  // 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.
  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}`);
  } catch (exc) {
    console.error(LOG_TAG + `Cache request failed for ${req.url}`, exc);
  }

  // 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}`);
  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', 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));
      }
    }
    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;
  }

  event.respondWith(handleHttpRequest(event.request));
});