Prototype commit
Some checks are pending
check / frontend (push) Waiting to run
check / server (push) Waiting to run

This commit is contained in:
Pen Anderson 2026-05-05 09:33:51 -05:00
commit b2ffbe865e
29 changed files with 3438 additions and 0 deletions

159
web/src/service-worker.ts Normal file
View file

@ -0,0 +1,159 @@
/// <reference types="@sveltejs/kit" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
// Production service worker for Docent.
//
// Behavior:
// - Install: precache the SvelteKit shell (build + small static files).
// - Activate: drop caches that don't match the current version.
// - Fetch:
// /api/* → network only (auth state must not be cached)
// navigation → cached index.html as offline fallback
// /audio /img → stale-while-revalidate from ASSET_CACHE
// everything → cache-first from SHELL_CACHE
// - Messages:
// { type: 'precache-stops', urls: string[] }
// Bulk-cache a list of URLs into ASSET_CACHE, posting progress back to
// the sender via { type: 'precache-progress', done, total } and a final
// { type: 'precache-done' } when finished.
//
// In dev (`serviceWorker.register: false` + the layout's dev-mode unregister),
// this file is bundled but never registered.
import { build, files, version } from '$service-worker';
const sw = self as unknown as ServiceWorkerGlobalScope;
const SHELL_CACHE = `docent-shell-${version}`;
const ASSET_CACHE = `docent-assets-v1`; // version-stable so audio survives shell upgrades
const shellAssets = [
...build,
...files.filter((f) => !f.startsWith('/audio/') && !f.startsWith('/images/'))
];
sw.addEventListener('install', (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(SHELL_CACHE);
await cache.addAll(shellAssets);
await sw.skipWaiting();
})()
);
});
sw.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(
keys
.filter((k) => k !== SHELL_CACHE && k !== ASSET_CACHE)
.map((k) => caches.delete(k))
);
await sw.clients.claim();
})()
);
});
sw.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (url.origin !== location.origin) return;
// Never cache API responses — auth state must always be authoritative.
if (url.pathname.startsWith('/api/')) return;
const isContent = url.pathname.startsWith('/audio/') || url.pathname.startsWith('/images/');
event.respondWith(
(async () => {
// Navigation: prefer cached shell; fall back to network.
if (request.mode === 'navigate') {
const shell = await caches.open(SHELL_CACHE);
const cached = (await shell.match('/')) || (await shell.match('/index.html'));
if (cached) return cached;
try {
return await fetch(request);
} catch (err) {
return new Response('offline', { status: 503, statusText: 'offline' });
}
}
const cacheName = isContent ? ASSET_CACHE : SHELL_CACHE;
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
if (cached) {
if (isContent) {
// Stale-while-revalidate: return cached, refresh in background.
event.waitUntil(refresh(cache, request));
}
return cached;
}
try {
const res = await fetch(request);
if (res.ok && res.type === 'basic') {
cache.put(request, res.clone());
}
return res;
} catch (err) {
return new Response('offline', { status: 503, statusText: 'offline' });
}
})()
);
});
async function refresh(cache: Cache, request: Request) {
try {
const res = await fetch(request, { credentials: 'same-origin' });
if (res.ok && res.type === 'basic') await cache.put(request, res);
} catch {
// Offline; keep the cached version.
}
}
sw.addEventListener('message', (event) => {
const data = event.data;
if (data?.type !== 'precache-stops' || !Array.isArray(data.urls)) return;
const urls: string[] = data.urls;
const source = event.source;
event.waitUntil(precacheStops(urls, source));
});
async function precacheStops(urls: string[], source: Client | ServiceWorker | MessagePort | null) {
const cache = await caches.open(ASSET_CACHE);
let done = 0;
const total = urls.length;
for (const url of urls) {
try {
const cached = await cache.match(url);
if (!cached) {
const res = await fetch(url, { credentials: 'same-origin' });
if (res.ok && res.type === 'basic') await cache.put(url, res);
}
} catch {
// Network error — skip and continue. UI shows the count.
}
done++;
try {
(source as Client | null)?.postMessage?.({
type: 'precache-progress',
done,
total
});
} catch {
// Sender went away.
}
}
try {
(source as Client | null)?.postMessage?.({ type: 'precache-done', done, total });
} catch {
// Sender went away.
}
}