/* * Service worker: makes the app installable and usable with no network. * * Offline matters more here than for most web apps - the camera is on the end * of a USB cable, so the app is fully functional in a field with no signal, as * long as it can load at all. * * Two caches with deliberately different strategies: * * - Our own files: network-first. They change whenever the app is redeployed, * and a stale module paired with fresh HTML is a miserable bug to chase. * Cache is the fallback, which is what makes offline work. * - The unpkg dependencies (preact, web-gphoto2, the ~2MB WASM): cache-first. * Those URLs are pinned to exact versions and are immutable, so re-checking * them over the network buys nothing. */ const VERSION = 'v1'; const SHELL_CACHE = `intervalometer-shell-${VERSION}`; const DEPS_CACHE = `intervalometer-deps-${VERSION}`; const CACHES = [SHELL_CACHE, DEPS_CACHE]; const SHELL = [ './', './index.html', './manifest.webmanifest', './index.js', './index-fallback.js', './home.js', './settings.js', './intervalometer.js', './storage.js', './config-utils.js', './preview.js', './voice.js', './widget.js', './icon-192.png', './icon-512.png' ]; /** Cross-origin hosts whose responses are safe to keep indefinitely. */ const IMMUTABLE_HOSTS = ['unpkg.com']; /* * These have to be fetched at install time, not left to the runtime cache. * On a first visit the worker isn't controlling the page yet, so the app's own * imports go straight to the network and never reach the fetch handler - which * means without this the app looks cached but dies offline on its imports. * * Every URL is version-pinned, so they're safe to hold forever. */ const DEPS = [ 'https://unpkg.com/web-gphoto2@0.4.1/build/camera.js', 'https://unpkg.com/web-gphoto2@0.4.1/build/libapi.mjs', 'https://unpkg.com/web-gphoto2@0.4.1/build/libapi.wasm', 'https://unpkg.com/preact@10.6.4/dist/preact.module.js', 'https://unpkg.com/purecss@2.0.6/build/pure-min.css' ]; self.addEventListener('install', event => { event.waitUntil( Promise.all([ caches.open(SHELL_CACHE).then(cache => cache.addAll(SHELL)), precacheDeps() ]).then(() => self.skipWaiting()) ); }); /** * Fetched individually and tolerantly: a flaky CDN shouldn't fail the whole * install and leave the app with no worker at all. Anything that misses here * gets picked up by the runtime cache on a later online visit. */ async function precacheDeps() { let cache = await caches.open(DEPS_CACHE); await Promise.allSettled( DEPS.map(async url => { let response = await fetch(url, { mode: 'cors', credentials: 'omit' }); if (!response.ok) throw new Error(`${response.status} for ${url}`); await cache.put(url, response); }) ); } self.addEventListener('activate', event => { event.waitUntil( caches .keys() .then(keys => Promise.all(keys.filter(k => !CACHES.includes(k)).map(k => caches.delete(k))) ) .then(() => self.clients.claim()) ); }); self.addEventListener('fetch', event => { let { request } = event; if (request.method !== 'GET') return; let url = new URL(request.url); if (IMMUTABLE_HOSTS.includes(url.hostname)) { event.respondWith(cacheFirst(request)); return; } if (url.origin === self.location.origin) { event.respondWith(networkFirst(request)); } }); /** * @param {Request} request */ async function cacheFirst(request) { let cache = await caches.open(DEPS_CACHE); let hit = await cache.match(request); if (hit) return hit; let response = await fetch(request); // An opaque response would break the page's cross-origin isolation on // replay, so only keep ones that actually passed CORS. if (response.ok && response.type !== 'opaque') { cache.put(request, response.clone()); } return response; } /** * @param {Request} request */ async function networkFirst(request) { let cache = await caches.open(SHELL_CACHE); try { let response = await fetch(request); if (response.ok) cache.put(request, response.clone()); return response; } catch (err) { let hit = await cache.match(request); if (hit) return hit; // A navigation with nothing cached for this exact URL still wants the shell. if (request.mode === 'navigate') { let shell = await cache.match('./'); if (shell) return shell; } throw err; } }