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