Review of 3d242dea (MEDIUM 3, LOW 4-6):
- A file is timed on its own only from its first answer on: Chromium keeps
three to five of a worker's requests on the wire, and the rest waited past
the 30 s stall limit before they were sent, so a slow network with many
files never installed. Before an answer, the whole-install watchdog bounds
it; when nothing at all has moved for 30 s it now cancels every request
still open.
- Every file is fetched to its end even when another failed
(Promise.allSettled), so a retry copies the finished content-hashed files
(a part-downloaded wasm is no longer thrown away because another file
stalled). A file already in this version's precache is left as it is.
- Two registrations of the worker (the site's, and the app's own after WebKit
lost the site's install) never both download a content-hashed file: Web
Locks let one fetch it while the other waits (alive, not stalled), then
copies it.
- Each precache records when it was begun; activate removes only precaches
begun before its own, so it never takes a newer version's files from
another registration's install. The older-version case is documented.
- Lookups of precached files ask this version's precache first, then every
cache: an older runtime copy of a file whose name does not change no
longer wins.
- prerender-static.sh never lists a .br or .gz copy in the precache.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
325 lines
15 KiB
JavaScript
325 lines
15 KiB
JavaScript
// 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/<set>.<hash>.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 <use>) 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;
|
|
})());
|
|
});
|