open-standards/aac/aac-rs/crates/aac-web/static/sw.js
Transaction Science dda1b8af20 aac-web sw: an install waits its turn, keeps what it fetched, and downloads a file once
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]>
2026-10-18 12:14:00 -04:00

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;
})());
});