/// declare const self: ServiceWorkerGlobalScope; const CACHE_VERSION = "rspace-v2"; const STATIC_CACHE = `${CACHE_VERSION}-static`; const HTML_CACHE = `${CACHE_VERSION}-html`; const API_CACHE = `${CACHE_VERSION}-api`; // Vite-hashed assets are immutable (content hash in filename) const IMMUTABLE_PATTERN = /\/assets\/.*\.[a-f0-9]{8}\.(js|css|wasm)$/; // Minimal fallback if manifest fetch fails const FALLBACK_PRECACHE = ["/", "/canvas.html"]; // Max age for cached API GET responses (5 minutes) const API_CACHE_MAX_AGE_MS = 5 * 60 * 1000; interface PrecacheManifest { version: string; core: string[]; // shell assets + HTML pages — cached at install modules: string[]; // module JS/CSS — lazy-cached after activation } self.addEventListener("install", (event) => { event.waitUntil( (async () => { let manifest: PrecacheManifest | null = null; try { const res = await fetch(`/precache-manifest.json?v=${CACHE_VERSION}`); if (res.ok) manifest = await res.json(); } catch { /* manifest unavailable — use fallback */ } const coreUrls = manifest?.core ?? FALLBACK_PRECACHE; // Precache core shell assets (blocking — required for cold offline start) const htmlCache = await caches.open(HTML_CACHE); const staticCache = await caches.open(STATIC_CACHE); const htmlUrls = coreUrls.filter((u) => u.endsWith(".html") || u === "/"); const staticUrls = coreUrls.filter((u) => !u.endsWith(".html") && u !== "/"); await Promise.all([ htmlCache.addAll(htmlUrls), staticCache.addAll(staticUrls), ]); })() ); self.skipWaiting(); }); self.addEventListener("activate", (event) => { event.waitUntil( (async () => { // Clean up old versioned caches const keys = await caches.keys(); await Promise.all( keys .filter((key) => !key.startsWith(CACHE_VERSION)) .map((key) => caches.delete(key)) ); await self.clients.claim(); // Lazy-cache module bundles in background (non-blocking) try { const res = await fetch(`/precache-manifest.json?v=${CACHE_VERSION}`); if (res.ok) { const manifest: PrecacheManifest = await res.json(); if (manifest.modules?.length) { const cache = await caches.open(STATIC_CACHE); // Cache modules one-by-one to avoid overwhelming bandwidth for (const url of manifest.modules) { try { const existing = await cache.match(url); if (!existing) await cache.add(url); } catch { /* skip individual failures */ } } } } } catch { /* manifest unavailable */ } })() ); }); self.addEventListener("fetch", (event) => { const url = new URL(event.request.url); // Skip non-http(s) schemes (chrome-extension://, etc.) — they can't be cached if (!url.protocol.startsWith("http")) return; // Skip WebSocket requests entirely if ( event.request.url.startsWith("ws://") || event.request.url.startsWith("wss://") || url.pathname.startsWith("/ws/") ) { return; } // API GET requests: stale-while-revalidate for offline browsing // Skip POST/PUT/DELETE — those must go to the server if (url.pathname.includes("/api/")) { if (event.request.method !== "GET") return; event.respondWith( caches.open(API_CACHE).then(async (cache) => { const cached = await cache.match(event.request); const fetchPromise = fetch(event.request) .then((response) => { if (response.ok) { // Store with a timestamp header for staleness checking const headers = new Headers(response.headers); headers.set("x-sw-cached-at", String(Date.now())); const timedResponse = new Response(response.clone().body, { status: response.status, statusText: response.statusText, headers, }); cache.put(event.request, timedResponse); } return response; }) .catch(() => { // Offline — return cached if available if (cached) return cached; return new Response( JSON.stringify({ error: "offline", message: "You are offline and this data is not cached." }), { status: 503, headers: { "Content-Type": "application/json" } } ); }); // Return cached immediately if fresh enough, revalidate in background if (cached) { const cachedAt = Number(cached.headers.get("x-sw-cached-at") || 0); if (Date.now() - cachedAt < API_CACHE_MAX_AGE_MS) { // Fresh cache — serve immediately, revalidate in background event.waitUntil(fetchPromise); return cached; } } // No cache or stale — wait for network return fetchPromise; }) ); return; } // Immutable hashed assets: cache-first (they never change) if (IMMUTABLE_PATTERN.test(url.pathname)) { event.respondWith( caches.match(event.request).then((cached) => { if (cached) return cached; return fetch(event.request).then((response) => { if (response.ok) { const clone = response.clone(); caches.open(STATIC_CACHE).then((cache) => cache.put(event.request, clone)); } return response; }); }) ); return; } // HTML pages: network-first with cache fallback + offline page if ( event.request.mode === "navigate" || event.request.headers.get("accept")?.includes("text/html") ) { event.respondWith( fetch(event.request) .then((response) => { if (response.ok) { const clone = response.clone(); caches.open(HTML_CACHE).then((cache) => cache.put(event.request, clone)); } return response; }) .catch(() => { return caches .match(event.request) .then((cached) => cached || caches.match("/") || offlineFallbackPage()) as Promise; }) ); return; } // Other assets (images, fonts, etc.): stale-while-revalidate event.respondWith( caches.match(event.request).then((cached) => { const fetchPromise = fetch(event.request) .then((response) => { if (response.ok) { const clone = response.clone(); caches.open(STATIC_CACHE).then((cache) => cache.put(event.request, clone)); } return response; }) .catch(() => cached || new Response("Offline", { status: 503 })); return cached || fetchPromise; }) ); }); /** Minimal offline fallback page when nothing is cached. */ function offlineFallbackPage(): Response { const html = ` Offline - rSpace You're offline rSpace can't reach the server right now. Previously visited pages and locally cached data are still available. Try Again `; return new Response(html, { status: 503, headers: { "Content-Type": "text/html; charset=utf-8" }, }); }
rSpace can't reach the server right now. Previously visited pages and locally cached data are still available.