| // 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 let self: ServiceWorkerGlobalScope; |
| export {}; |
| |
| const LOG_TAG = `ServiceWorker: `; |
| const CACHE_NAME = 'ui-perfetto-dev'; |
| const OPEN_TRACE_PREFIX = '/_open_trace' |
| |
| // 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; |
| |
| // Files passed to POST /_open_trace/NNNN. |
| let postedFiles = new Map<string, File>(); |
| |
| // 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 we can not access the cache we must give up on the service |
| // worker: |
| let bypass = true; |
| try { |
| bypass = await caches.has('BYPASS_SERVICE_WORKER'); |
| } catch (_) { |
| // TODO(288483453) |
| } |
| if (bypass) { |
| // 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. |
| try { |
| for (const key of await caches.keys()) { |
| if (key.startsWith('dist-')) { |
| await caches.delete(key); |
| } |
| } |
| } catch (_) { |
| // TODO(288483453) |
| // It's desirable to delete the old entries but it's not actually |
| // damaging to keep them around so don't give up on the |
| // installation if this fails. |
| } |
| |
| // 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; |
| if (url.pathname.startsWith(OPEN_TRACE_PREFIX)) return true; |
| |
| 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 attempting the |
| // network fetch. |
| const cachedRes = await caches.match(new Request('/'), cacheOps); |
| if (cachedRes) return cachedRes; |
| } else if (url.pathname.startsWith(OPEN_TRACE_PREFIX)) { |
| return await handleOpenTraceRequest(req); |
| } |
| |
| 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); |
| } |
| |
| // Handles GET and POST requests to /_open_trace/NNNN, where NNNN is typically a |
| // random token generated by the client. |
| // This works as follows: |
| // - The client does a POST request to /_open_trace/NNNN passing the trace blob |
| // as multipart-data, alongside other options like hideSidebar & co that we |
| // support in the usual querystring (see router.ts) |
| // - The SW takes the file and puts it in the global variable `postedFiles`. |
| // - The SW responds to the POST request with a redirect to |
| // ui.perfetto.dev/#!/?url=https://ui.perfetto.dev/_open_trace/NNNN&other_args |
| // - When the new ui.perfetto.dev is reloaded, it will naturally try to fetch |
| // the trace from /_open_trace/NNNN, this time via a GET request. |
| // - The SW intercepts the GET request and returns the file previosly stored in |
| // `postedFiles`. |
| // We use postedFiles here to handle the case of progammatically POST-ing to >1 |
| // instances of ui.perfetto.dev simultaneously, to avoid races. |
| // Note that we should not use a global variable for `postedFiles` but we should |
| // use the CacheAPI because, technically speaking, the SW could be disposed |
| // and respawned in between the POST and the GET request. In practice, however, |
| // SWs are disposed only after 30s seconds of idleness. The POST->GET requests |
| // happen back-to-back.. |
| async function handleOpenTraceRequest(req: Request): Promise<Response> { |
| const url = new URL(req.url); |
| console.assert(url.pathname.startsWith(OPEN_TRACE_PREFIX)); |
| const fileKey = url.pathname.substring(OPEN_TRACE_PREFIX.length); |
| if (req.method === 'POST') { |
| const formData = await req.formData(); |
| const qsParams = new URLSearchParams(); |
| // Iterate over the POST fields and copy them over the querystring in |
| // the hash, with the exception of the trace file. The trace file is |
| // kept in the serviceworker and passed as a url= argument. |
| formData.forEach((value, key) => { |
| if (key === 'trace') { |
| if (value instanceof File) { |
| postedFiles.set(fileKey, value); |
| qsParams.set('url', req.url); |
| } |
| return; |
| } |
| qsParams.set(key, `${value}`); |
| }); // formData.forEach() |
| return Response.redirect(`${url.protocol}//${url.host}/#!/?${qsParams}`); |
| } |
| |
| // else... method == 'GET' |
| const file = postedFiles.get(fileKey); |
| if (file !== undefined) { |
| postedFiles.delete(fileKey); |
| return new Response(file); |
| } |
| |
| // The file /_open_trace/NNNN does not exist. |
| return Response.error(); |
| } |
| |
| 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) { |
| console.error(LOG_TAG + `Installation failed for ${manifestUrl}`, err); |
| await caches.delete(CACHE_NAME); |
| 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(new Error(`Timed out while fetching ${url}`)); |
| }, timeoutMs); |
| fetch(req).then((resp) => { |
| clearTimeout(timerId); |
| if (resp.ok) { |
| resolve(resp); |
| } else { |
| reject(new Error( |
| `Fetch failed for ${url}: ${resp.status} ${resp.statusText}`)); |
| } |
| }, reject); |
| }); |
| } |