blob: 328935244e4ebafdc0f2c7ecf4cb67350cf3af99 [file] [log] [blame]
// 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<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.
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));
});