2026-05-05 09:33:51 -05:00
|
|
|
/// <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:
|
2026-05-26 11:52:32 -05:00
|
|
|
// { type: 'precache-artists', urls: string[] }
|
2026-05-05 09:33:51 -05:00
|
|
|
// 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 () => {
|
|
|
|
|
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) {
|
|
|
|
|
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;
|
2026-05-26 11:52:32 -05:00
|
|
|
if (data?.type !== 'precache-artists' || !Array.isArray(data.urls)) return;
|
2026-05-05 09:33:51 -05:00
|
|
|
|
|
|
|
|
const urls: string[] = data.urls;
|
|
|
|
|
const source = event.source;
|
2026-05-26 11:52:32 -05:00
|
|
|
event.waitUntil(precacheArtists(urls, source));
|
2026-05-05 09:33:51 -05:00
|
|
|
});
|
|
|
|
|
|
2026-05-26 11:52:32 -05:00
|
|
|
async function precacheArtists(urls: string[], source: Client | ServiceWorker | MessagePort | null) {
|
2026-05-05 09:33:51 -05:00
|
|
|
const cache = await caches.open(ASSET_CACHE);
|
|
|
|
|
const total = urls.length;
|
2026-05-26 11:52:32 -05:00
|
|
|
let done = 0;
|
|
|
|
|
let failed = 0;
|
|
|
|
|
|
|
|
|
|
const queue = urls.slice();
|
|
|
|
|
const CONCURRENCY = 3;
|
|
|
|
|
const MAX_ATTEMPTS = 3;
|
|
|
|
|
const BACKOFF_MS = 800;
|
|
|
|
|
|
|
|
|
|
function postProgress() {
|
2026-05-05 09:33:51 -05:00
|
|
|
try {
|
|
|
|
|
(source as Client | null)?.postMessage?.({
|
|
|
|
|
type: 'precache-progress',
|
|
|
|
|
done,
|
2026-05-26 11:52:32 -05:00
|
|
|
total,
|
|
|
|
|
failed
|
2026-05-05 09:33:51 -05:00
|
|
|
});
|
2026-05-26 11:52:32 -05:00
|
|
|
} catch {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fetchWithRetry(url: string): Promise<boolean> {
|
|
|
|
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(url, { credentials: 'same-origin', cache: 'no-store' });
|
|
|
|
|
if (res.ok && res.type === 'basic') {
|
|
|
|
|
await cache.put(url, res);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (res.status >= 400 && res.status < 500) return false; // permanent
|
|
|
|
|
} catch {}
|
|
|
|
|
if (attempt < MAX_ATTEMPTS) {
|
|
|
|
|
await new Promise((r) => setTimeout(r, BACKOFF_MS * attempt));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function worker() {
|
|
|
|
|
while (queue.length > 0) {
|
|
|
|
|
const url = queue.shift();
|
|
|
|
|
if (!url) break;
|
|
|
|
|
try {
|
|
|
|
|
const cached = await cache.match(url);
|
|
|
|
|
if (!cached) {
|
|
|
|
|
const ok = await fetchWithRetry(url);
|
|
|
|
|
if (!ok) failed++;
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
failed++;
|
|
|
|
|
}
|
|
|
|
|
done++;
|
|
|
|
|
postProgress();
|
2026-05-05 09:33:51 -05:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-26 11:52:32 -05:00
|
|
|
|
|
|
|
|
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
|
|
|
|
|
|
2026-05-05 09:33:51 -05:00
|
|
|
try {
|
2026-05-26 11:52:32 -05:00
|
|
|
(source as Client | null)?.postMessage?.({
|
|
|
|
|
type: 'precache-done',
|
|
|
|
|
done,
|
|
|
|
|
total,
|
|
|
|
|
failed
|
|
|
|
|
});
|
2026-05-05 09:33:51 -05:00
|
|
|
} catch {
|
|
|
|
|
// Sender went away.
|
|
|
|
|
}
|
|
|
|
|
}
|