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