xref: /aosp_15_r20/external/perfetto/ui/src/service_worker/service_worker.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
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 it from
24//    the network.
25// 3a If the request fails (offline / server unreachable) or times out, the old
26//    v1 is served.
27// 3b If the request succeeds, the browser receives the index.html for v2. That
28//    will try to fetch resources from /v2/frontend_bundle.ts.
29// 4. When the SW sees the /v2/ request, will have a cache miss and will issue
30//    a network fetch(), returning the fresh /v2/ content.
31// 4. The v2 site will call serviceWorker.register('service_worker.js?v=v2').
32//    This (i.e. the different querystring) will cause a re-installation of the
33//    service worker (even if the service_worker.js script itself is unchanged).
34// 5. In the "install" step, the service_worker.js script will fetch the newer
35//    version (v2).
36//    Note: the v2 will be fetched twice, once upon the first request that
37//    causes causes a cache-miss, the second time while re-installing the SW.
38//    The  latter though will hit a HTTP 304 (Not Changed) and will be served
39//    from the browser cache after the revalidation request.
40// 6. The 'activate' handler is triggered. The old v1 cache is deleted at this
41//    point.
42
43declare let self: ServiceWorkerGlobalScope;
44export {};
45
46const LOG_TAG = `ServiceWorker: `;
47const CACHE_NAME = 'ui-perfetto-dev';
48const OPEN_TRACE_PREFIX = '/_open_trace'
49
50// If the fetch() for the / doesn't respond within 3s, return a cached version.
51// This is to avoid that a user waits too much if on a flaky network.
52const INDEX_TIMEOUT_MS = 3000;
53
54// Use more relaxed timeouts when caching the subresources for the new version
55// in the background.
56const INSTALL_TIMEOUT_MS = 30000;
57
58// Files passed to POST /_open_trace/NNNN.
59let postedFiles = new Map<string, File>();
60
61// The install() event is fired:
62// 1. On the first visit, when there is no SW installed.
63// 2. Every time the user opens the site and the version has been updated (they
64//    will get the newer version regardless, unless we hit INDEX_TIMEOUT_MS).
65// The latter happens because:
66// - / (index.html) is always served from the network (% timeout) and it pulls
67//   /v1.2-sha/frontend_bundle.js.
68// - /v1.2-sha/frontend_bundle.js will register /service_worker.js?v=v1.2-sha.
69// The service_worker.js script itself never changes, but the browser
70// re-installs it because the version in the V? query-string argument changes.
71// The reinstallation will cache the new files from the v.1.2-sha/manifest.json.
72self.addEventListener('install', (event) => {
73  const doInstall = async () => {
74    // If we can not access the cache we must give up on the service
75    // worker:
76    let bypass = true;
77    try {
78      bypass = await caches.has('BYPASS_SERVICE_WORKER');
79    } catch (_) {
80      // TODO(288483453)
81    }
82    if (bypass) {
83      // Throw will prevent the installation.
84      throw new Error(LOG_TAG + 'skipping installation, bypass enabled');
85    }
86
87    // Delete old cache entries from the pre-feb-2021 service worker.
88    try {
89      for (const key of await caches.keys()) {
90        if (key.startsWith('dist-')) {
91          await caches.delete(key);
92        }
93      }
94    } catch (_) {
95      // TODO(288483453)
96      // It's desirable to delete the old entries but it's not actually
97      // damaging to keep them around so don't give up on the
98      // installation if this fails.
99    }
100
101    // The UI should register this as service_worker.js?v=v1.2-sha. Extract the
102    // version number and pre-fetch all the contents for the version.
103    const match = /\bv=([\w.-]*)/.exec(location.search);
104    if (!match) {
105      throw new Error(
106          'Failed to install. Was epecting a query string like ' +
107          `?v=v1.2-sha query string, got "${location.search}" instead`);
108    }
109    await installAppVersionIntoCache(match[1]);
110
111    // skipWaiting() still waits for the install to be complete. Without this
112    // call, the new version would be activated only when all tabs are closed.
113    // Instead, we ask to activate it immediately. This is safe because the
114    // subresources are versioned (e.g. /v1.2-sha/frontend_bundle.js). Even if
115    // there is an old UI tab opened while we activate() a newer version, the
116    // activate() would just cause cache-misses, hence fetch from the network,
117    // for the old tab.
118    self.skipWaiting();
119  };
120  event.waitUntil(doInstall());
121});
122
123self.addEventListener('activate', (event) => {
124  console.info(LOG_TAG + 'activated');
125  const doActivate = async () => {
126    // This makes a difference only for the very first load, when no service
127    // worker is present. In all the other cases the skipWaiting() will hot-swap
128    // the active service worker anyways.
129    await self.clients.claim();
130  };
131  event.waitUntil(doActivate());
132});
133
134self.addEventListener('fetch', (event) => {
135
136  // The early return here will cause the browser to fall back on standard
137  // network-based fetch.
138  if (!shouldHandleHttpRequest(event.request)) {
139    console.debug(LOG_TAG + `serving ${event.request.url} from network`);
140    return;
141  }
142
143  event.respondWith(handleHttpRequest(event.request));
144});
145
146
147function shouldHandleHttpRequest(req: Request): boolean {
148  // Suppress warning: 'only-if-cached' can be set only with 'same-origin' mode.
149  // This seems to be a chromium bug. An internal code search suggests this is a
150  // socially acceptable workaround.
151  if (req.cache === 'only-if-cached' && req.mode !== 'same-origin') {
152    return false;
153  }
154
155  const url = new URL(req.url);
156  if (url.pathname === '/live_reload') return false;
157  if (url.pathname.startsWith(OPEN_TRACE_PREFIX)) return true;
158
159  return req.method === 'GET' && url.origin === self.location.origin;
160}
161
162async function handleHttpRequest(req: Request): Promise<Response> {
163  if (!shouldHandleHttpRequest(req)) {
164    throw new Error(LOG_TAG + `${req.url} shouldn't have been handled`);
165  }
166
167  // We serve from the cache even if req.cache == 'no-cache'. It's a bit
168  // contra-intuitive but it's the most consistent option. If the user hits the
169  // reload button*, the browser requests the "/" index with a 'no-cache' fetch.
170  // However all the other resources (css, js, ...) are requested with a
171  // 'default' fetch (this is just how Chrome works, it's not us). If we bypass
172  // the service worker cache when we get a 'no-cache' request, we can end up in
173  // an inconsistent state where the index.html is more recent than the other
174  // resources, which is undesirable.
175  // * Only Ctrl+R. Ctrl+Shift+R will always bypass service-worker for all the
176  // requests (index.html and the rest) made in that tab.
177
178  const cacheOps = {cacheName: CACHE_NAME} as CacheQueryOptions;
179  const url = new URL(req.url);
180  if (url.pathname === '/') {
181    try {
182      console.debug(LOG_TAG + `Fetching live ${req.url}`);
183      // The await bleow is needed to fall through in case of an exception.
184      return await fetchWithTimeout(req, INDEX_TIMEOUT_MS);
185    } catch (err) {
186      console.warn(LOG_TAG + `Failed to fetch ${req.url}, using cache.`, err);
187      // Fall through the code below.
188    }
189  } else if (url.pathname === '/offline') {
190    // Escape hatch to force serving the offline version without attempting the
191    // network fetch.
192    const cachedRes = await caches.match(new Request('/'), cacheOps);
193    if (cachedRes) return cachedRes;
194  } else if (url.pathname.startsWith(OPEN_TRACE_PREFIX)) {
195    return await handleOpenTraceRequest(req);
196  }
197
198  const cachedRes = await caches.match(req, cacheOps);
199  if (cachedRes) {
200    console.debug(LOG_TAG + `serving ${req.url} from cache`);
201    return cachedRes;
202  }
203
204  // In any other case, just propagate the fetch on the network, which is the
205  // safe behavior.
206  console.warn(LOG_TAG + `cache miss on ${req.url}, using live network`);
207  return fetch(req);
208}
209
210// Handles GET and POST requests to /_open_trace/NNNN, where NNNN is typically a
211// random token generated by the client.
212// This works as follows:
213// - The client does a POST request to /_open_trace/NNNN passing the trace blob
214//   as multipart-data, alongside other options like hideSidebar & co that we
215//   support in the usual querystring (see router.ts)
216// - The SW takes the file and puts it in the global variable `postedFiles`.
217// - The SW responds to the POST request with a redirect to
218//   ui.perfetto.dev/#!/?url=https://ui.perfetto.dev/_open_trace/NNNN&other_args
219// - When the new ui.perfetto.dev is reloaded, it will naturally try to fetch
220//   the trace from /_open_trace/NNNN, this time via a GET request.
221// - The SW intercepts the GET request and returns the file previosly stored in
222//   `postedFiles`.
223// We use postedFiles here to handle the case of progammatically POST-ing to >1
224// instances of ui.perfetto.dev simultaneously, to avoid races.
225// Note that we should not use a global variable for `postedFiles` but we should
226// use the CacheAPI because, technically speaking, the SW could be disposed
227// and respawned in between the POST and the GET request. In practice, however,
228// SWs are disposed only after 30s seconds of idleness. The POST->GET requests
229// happen back-to-back..
230async function handleOpenTraceRequest(req: Request): Promise<Response> {
231  const url = new URL(req.url);
232  console.assert(url.pathname.startsWith(OPEN_TRACE_PREFIX));
233  const fileKey = url.pathname.substring(OPEN_TRACE_PREFIX.length);
234  if (req.method === 'POST') {
235    const formData = await req.formData();
236    const qsParams = new URLSearchParams();
237    // Iterate over the POST fields and copy them over the querystring in
238    // the hash, with the exception of the trace file. The trace file is
239    // kept in the serviceworker and passed as a url= argument.
240    formData.forEach((value, key) => {
241      if (key === 'trace') {
242        if (value instanceof File) {
243          postedFiles.set(fileKey, value);
244          qsParams.set('url', req.url);
245        }
246        return;
247      }
248      qsParams.set(key, `${value}`);
249    });  // formData.forEach()
250    return Response.redirect(`${url.protocol}//${url.host}/#!/?${qsParams}`);
251  }
252
253  // else... method == 'GET'
254  const file = postedFiles.get(fileKey);
255  if (file !== undefined) {
256    postedFiles.delete(fileKey);
257    return new Response(file);
258  }
259
260  // The file /_open_trace/NNNN does not exist.
261  return Response.error();
262}
263
264async function installAppVersionIntoCache(version: string) {
265  const manifestUrl = `${version}/manifest.json`;
266  try {
267    console.log(LOG_TAG + `Starting installation of ${manifestUrl}`);
268    await caches.delete(CACHE_NAME);
269    const resp = await fetchWithTimeout(manifestUrl, INSTALL_TIMEOUT_MS);
270    const manifest = await resp.json();
271    const manifestResources = manifest['resources'];
272    if (!manifestResources || !(manifestResources instanceof Object)) {
273      throw new Error(`Invalid manifest ${manifestUrl} : ${manifest}`);
274    }
275
276    const cache = await caches.open(CACHE_NAME);
277    const urlsToCache: RequestInfo[] = [];
278
279    // We use cache:reload to make sure that the index is always current and we
280    // don't end up in some cycle where we keep re-caching the index coming from
281    // the service worker itself.
282    urlsToCache.push(new Request('/', {cache: 'reload', mode: 'same-origin'}));
283
284    for (const [resource, integrity] of Object.entries(manifestResources)) {
285      // We use cache: no-cache rather then reload here because the versioned
286      // sub-resources are expected to be immutable and should never be
287      // ambiguous. A revalidation request is enough.
288      const reqOpts: RequestInit = {
289        cache: 'no-cache',
290        mode: 'same-origin',
291        integrity: `${integrity}`,
292      };
293      urlsToCache.push(new Request(`${version}/${resource}`, reqOpts));
294    }
295    await cache.addAll(urlsToCache);
296    console.log(LOG_TAG + 'installation completed for ' + version);
297  } catch (err) {
298    console.error(LOG_TAG + `Installation failed for ${manifestUrl}`, err);
299    await caches.delete(CACHE_NAME);
300    throw err;
301  }
302}
303
304function fetchWithTimeout(req: Request|string, timeoutMs: number) {
305  const url = (req as {url?: string}).url || `${req}`;
306  return new Promise<Response>((resolve, reject) => {
307    const timerId = setTimeout(() => {
308      reject(new Error(`Timed out while fetching ${url}`));
309    }, timeoutMs);
310    fetch(req).then((resp) => {
311      clearTimeout(timerId);
312      if (resp.ok) {
313        resolve(resp);
314      } else {
315        reject(new Error(
316            `Fetch failed for ${url}: ${resp.status} ${resp.statusText}`));
317      }
318    }, reject);
319  });
320}
321