// Service worker for the AAC reference app — offline-first. // // AAC must never fail because of network. prerender-static.sh fills in // VERSION (a hash of everything below), PRECACHE_URLS (the app shell and // every file it loads, including wasm-bindgen snippets, the symbol sprites // the first frame draws, and the symbols' LICENSES.md), VENDOR_DIRS (the vendored library directories this // build ships) and SYMBOL_FILES (every symbol sprite this build ships). A new version installs // completely before it replaces the old one, so the shell served is always the // one that matches its files. Content-hashed files already held from the last // version are copied across instead of downloaded again. Only successful, // same-origin GET responses are ever cached. // // The app is served cross-origin isolated (COOP + COEP), which threaded WASM // needs for SharedArrayBuffer. Every file it loads is same-origin, including // the eye-tracking library, which the build vendors under vendor/. const VERSION = '__VERSION__'; const PRECACHE = 'aac-precache-' + VERSION; const RUNTIME = 'aac-runtime'; const RUNTIME_MAX = 150; // Vendored libraries (eye tracking, ~16 MB) are cached on first use rather // than precached, so installing the app stays light. They live in their own // cache so the runtime cache's trimming never evicts part of one. Each // directory's name changes whenever its bytes do (third_party/mediapipe/ // vendor.sh), so the cache holds only this build's directories. const VENDOR = 'aac-vendor'; const VENDOR_PATH = new URL('vendor/', self.registration.scope).pathname; const VENDOR_DIRS = __VENDOR_DIRS__.map((dir) => VENDOR_PATH + dir); const IN_VENDOR_DIRS = (path) => VENDOR_DIRS.some((dir) => path.startsWith(dir)); // Voice and model packs (src/bindings/model_store.js) are published under // models/ and fetched with cache: 'no-store'. The worker leaves both to the // network: the store verifies a pack and keeps it in its own cache, and a // second, unverified copy here would double its size and could be served back // in place of a corrected download. const MODELS_PATH = new URL('models/', self.registration.scope).pathname; const PRECACHE_URLS = __PRECACHE_URLS__; // Symbol sprites (symbols/..svg). Those the prerendered board draws // are precached, so symbols show offline from the first visit; another set is // cached the first time it is drawn. They live in their own cache so the // runtime cache's trimming never evicts one; it keeps this build's sprites and, // until the next update, the previous build's (a tab still open on it may yet // draw one). symbols/LICENSES.md is precached, and opening it (a navigation) // gets the file, never the app shell. const SYMBOLS = 'aac-symbols'; const SYMBOLS_PATH = new URL('symbols/', self.registration.scope).pathname; const SYMBOL_URLS = __SYMBOL_FILES__.map((file) => SYMBOLS_PATH + file); // A name like aac-web.0123456789ab.wasm never changes content. const IMMUTABLE = (path) => /\.[0-9a-f]{12}\.[a-z0-9]+$/.test(path); // Installing never hangs, and never gives up on a network that is only slow. // Every file of this version is fetched and cached, or the install fails: // this worker is discarded, and the app registers it again // (src/bindings/sw_keeper.js). // - A file whose answer has begun and then stops coming (no bytes for // STALL_MS) is given up. A request still waiting for its answer is not timed // on its own: the browser keeps only a few of a worker's requests on the // wire, and the rest wait their turn, however long the others take. // - The install is given up when nothing at all has moved for STALL_MS (no // file's bytes, no cache answering), and every request still open is // cancelled. // - Each file is fetched to its end even when another has failed, so the next // attempt finds every finished content-hashed file in the cache and copies // it instead of downloading it again. // - Two registrations of this worker (the site's, and the app's own when // WebKit lost the site's: sw_keeper.js) never both download a // content-hashed file: one fetches it while the other waits, then copies it. const STALL_MS = 30000; // How the install is going, for the app, which asks (a message // { type: 'aac-sw-progress' } with a port to answer on). WebKit can leave a // worker "installing" without ever giving it its install event (the page that // registered it went away while it started; see sw_keeper.js), and the app // can only tell that worker from a slow one by asking it. const STARTED = Date.now(); const install = { started: 0, done: 0, moved: STARTED, failed: '' }; const moved = () => { install.moved = Date.now(); }; self.addEventListener('message', (event) => { const port = event.ports && event.ports[0]; if (!port || !event.data || event.data.type !== 'aac-sw-progress') return; const now = Date.now(); port.postMessage({ version: VERSION, sinceStart: now - STARTED, installing: install.started > 0, done: install.done, total: PRECACHE_URLS.length, sinceMoved: now - install.moved, failed: install.failed, }); }); // A request from this version's precache first, then from any other cache // (an older copy of a file whose name does not change, in the runtime cache, // must not win over this version's). async function cached(request, options) { return (await caches.match(request, { ...options, cacheName: PRECACHE })) || caches.match(request, options); } // When this version's precache was begun, kept in it: activate removes only // precaches begun before its own, so it never takes the files of a newer // version that another registration of this worker is installing or using. const BEGUN = new URL('.aac-precache-begun', self.registration.scope).pathname; async function begun(cache) { const r = await cache.match(BEGUN); return r ? Number(await r.text()) || 0 : 0; } async function download(cache, url, all) { if (all.signal.aborted) throw all.signal.reason; const own = new AbortController(); const stop = () => own.abort(all.signal.reason); all.signal.addEventListener('abort', stop, { once: true }); let timer = 0; const alive = () => { moved(); clearTimeout(timer); timer = setTimeout(() => own.abort(new Error('precache ' + url + ': nothing arrived for ' + STALL_MS / 1000 + ' s')), STALL_MS); }; let counting = null; try { const res = await fetch(new Request(url, { cache: 'reload', signal: own.signal })); if (!res.ok) throw new Error('precache ' + url + ' ' + res.status); // Timed on its own from its first answer on. alive(); // Its bytes counted as they arrive (from a copy: what is cached is the // response itself), so a slow file that keeps coming is never cut off. counting = res.body && res.clone().body.getReader(); if (counting) (async () => { while (!(await counting.read()).done) alive(); })().catch(() => {}); await cache.put(url, res); } finally { clearTimeout(timer); all.signal.removeEventListener('abort', stop); if (counting) counting.cancel().catch(() => {}); } } async function precache(cache, url, all) { if (!IMMUTABLE(url)) return download(cache, url, all); // Any copy of a content-hashed file is this file. One already in this // version's precache (another registration of this worker, or an earlier // attempt, put it there: a file is only there once it is whole) is left as // it is. const copy = async () => { if (await cache.match(url, { ignoreVary: true })) { moved(); return true; } const held = await caches.match(url, { ignoreVary: true }); moved(); if (!held) return false; await cache.put(url, held); return true; }; if (await copy()) return; const locks = self.navigator && self.navigator.locks; if (!locks) return download(cache, url, all); // While another registration's install holds the file, this one is waiting, // not stalled (that install bounds itself, and its lock goes with it). const waiting = setInterval(moved, 5000); try { await locks.request('aac-precache ' + url, async () => { clearInterval(waiting); if (!(await copy())) await download(cache, url, all); }); } finally { clearInterval(waiting); } } self.addEventListener('install', (event) => { install.started = Date.now(); moved(); const all = new AbortController(); let watchdog = 0; const stalled = new Promise((resolve, reject) => { watchdog = setInterval(() => { if (Date.now() - install.moved <= STALL_MS) return; const e = new Error('install: nothing moved for ' + STALL_MS / 1000 + ' s'); all.abort(e); reject(e); }, 1000); }); const work = (async () => { const cache = await caches.open(PRECACHE); if (!(await begun(cache))) await cache.put(BEGUN, new Response(String(Date.now()))); moved(); const results = await Promise.allSettled(PRECACHE_URLS.map(async (url) => { await precache(cache, url, all); install.done += 1; moved(); })); const failed = results.find((r) => r.status === 'rejected'); if (failed) throw failed.reason; await self.skipWaiting(); })(); event.waitUntil(Promise.race([work, stalled]) .catch((e) => { install.failed = String((e && e.message) || e); throw e; }) .finally(() => clearInterval(watchdog))); }); self.addEventListener('activate', (event) => { event.waitUntil((async () => { const keep = new Set([PRECACHE, RUNTIME, VENDOR, SYMBOLS]); const mine = await begun(await caches.open(PRECACHE)); // A precache begun after this one is a newer version's, which another // registration of this worker is installing or using: it stays. An older // one goes, even while another registration's worker still runs that // version: that worker then answers from the copies that remain (cached() // looks in every cache) until it updates, as it does at the next visit. for (const key of await caches.keys()) { if (key.startsWith('aac-precache-') && key !== PRECACHE && mine && (await begun(await caches.open(key))) > mine) keep.add(key); } // A tab still open on the previous version keeps running its shell, and // may yet draw a sprite it has not drawn (another set, another board): // carry that version's precached sprites over until the next update. const symbols = await caches.open(SYMBOLS); const carried = new Set(); for (const key of await caches.keys()) { if (!key.startsWith('aac-precache-') || keep.has(key)) continue; const old = await caches.open(key); for (const req of await old.keys()) { const path = new URL(req.url).pathname; if (path.startsWith(SYMBOLS_PATH) && path.endsWith('.svg') && !SYMBOL_URLS.includes(path)) { await symbols.put(path, await old.match(req)); carried.add(path); } } } // Also removes the cache of the first release ('aac-v1'). Downloaded voice // and model packs ('aac-models-*') belong to the person, not to a release: // they were fetched on request and must survive every update. for (const key of await caches.keys()) { if (key.startsWith('aac-') && !key.startsWith('aac-models-') && !keep.has(key)) await caches.delete(key); } // Vendored files from earlier builds (their directory has another name). const vendor = await caches.open(VENDOR); for (const req of await vendor.keys()) { if (!IN_VENDOR_DIRS(new URL(req.url).pathname)) await vendor.delete(req); } // Sprites from older builds (their name has another hash). for (const req of await symbols.keys()) { const path = new URL(req.url).pathname; if (!SYMBOL_URLS.includes(path) && !carried.has(path)) await symbols.delete(req); } await self.clients.claim(); })()); }); // Cross-origin isolation for the app shell. The server sends these headers, // but a shell cached before it did would keep the app un-isolated until the // app's content next changed, so the worker states them on every shell it // serves. Redirects and errors pass through untouched. function isolated(res) { if (res.type === 'opaqueredirect' || !res.ok) return res; const headers = new Headers(res.headers); headers.set('Cross-Origin-Opener-Policy', 'same-origin'); headers.set('Cross-Origin-Embedder-Policy', 'require-corp'); return new Response(res.body, { status: res.status, statusText: res.statusText, headers }); } async function trim(cache) { const keys = await cache.keys(); for (let i = 0; i < keys.length - RUNTIME_MAX; i++) await cache.delete(keys[i]); } self.addEventListener('fetch', (event) => { const req = event.request; if (req.method !== 'GET') return; const url = new URL(req.url); // Other origins, any API and model packs are left to the network. if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return; if (req.cache === 'no-store' || url.pathname.startsWith(MODELS_PATH)) return; // Symbols: sprites (drawn by ) and LICENSES.md (opened from Settings, // a navigation, so this comes before the shell below). A current sprite not // yet held is cached the first time it is drawn. if (url.pathname.startsWith(SYMBOLS_PATH)) { event.respondWith((async () => { const hit = await cached(url.pathname); if (hit) return hit; const res = await fetch(req); const html = (res.headers.get('Content-Type') || '').startsWith('text/html'); if (res.ok && res.type === 'basic' && !html && SYMBOL_URLS.includes(url.pathname)) { const copy = res.clone(); event.waitUntil(caches.open(SYMBOLS).then((cache) => cache.put(url.pathname, copy))); } return res; })()); return; } // The app is one screen: every other navigation gets the precached shell. if (req.mode === 'navigate') { event.respondWith(cached('/').then((hit) => hit || fetch(req)).then(isolated)); return; } if (url.pathname.startsWith(VENDOR_PATH)) { event.respondWith((async () => { // A library file is the same whatever asked for it (see below: Vary). const hit = await caches.match(req, { ignoreVary: true }); if (hit) return hit; const res = await fetch(req); // Never an HTML page: a server that answers unknown paths with its // index would otherwise leave that page here in place of a library file. const html = (res.headers.get('Content-Type') || '').startsWith('text/html'); if (res.ok && res.type === 'basic' && !html && IN_VENDOR_DIRS(url.pathname)) { const copy = res.clone(); event.waitUntil(caches.open(VENDOR).then((cache) => cache.put(req, copy))); } return res; })()); return; } event.respondWith((async () => { // Content-hashed and precached files are the same whatever asked for them: // a server's Vary (Vary: Origin; a module import carries an Origin header, // the precache's own request does not) must not make them miss offline. const fixed = IMMUTABLE(url.pathname) || PRECACHE_URLS.includes(url.pathname); const hit = fixed ? await cached(req, { ignoreVary: true }) : await caches.match(req); if (hit) return hit; const res = await fetch(req); if (res.ok && res.type === 'basic') { const cache = await caches.open(RUNTIME); cache.put(req, res.clone()).then(() => trim(cache)); } return res; })()); });