aboutsummaryrefslogtreecommitdiff
path: root/ui/src/service_worker/service_worker.ts
blob: b3b34762d8be0237247d5d66ac315100af8f65d4 (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
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
// 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 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.

declare var self: ServiceWorkerGlobalScope;
export {};

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-sha/frontend_bundle.js.
// - /v1.2-sha/frontend_bundle.js will register /service_worker.js?v=v1.2-sha.
// 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-sha/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-sha. 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-sha 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-sha/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 {
  // 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);
  if (url.pathname === '/live_reload') return false;
  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.

  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.
    }
  } 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.warn(LOG_TAG + `cache miss on ${req.url}, using live network`);
  return fetch(req);
}

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

    const cache = await caches.open(CACHE_NAME);
    const urlsToCache: RequestInfo[] = [];

    // 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 for ' + version);
  } catch (err) {
    await caches.delete(CACHE_NAME);
    console.error(LOG_TAG + `Installation failed for ${manifestUrl}`, err);
    throw err;
  }
}

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);
  });
}