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