rspace-online/website/sw.ts

241 lines
7.4 KiB
TypeScript

/// <reference lib="webworker" />
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<Response>;
})
);
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 = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline - rSpace</title>
<style>
body { font-family: system-ui, sans-serif; display: flex; align-items: center;
justify-content: center; min-height: 100vh; margin: 0;
background: #0a0a0f; color: #e0e0e0; }
.offline { text-align: center; max-width: 400px; padding: 2rem; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
p { color: #999; line-height: 1.5; }
button { margin-top: 1rem; padding: 8px 20px; border-radius: 6px;
border: 1px solid #333; background: #1a1a2e; color: #e0e0e0;
cursor: pointer; font-size: 0.9rem; }
button:hover { background: #2a2a3e; }
</style>
</head>
<body>
<div class="offline">
<h1>You're offline</h1>
<p>rSpace can't reach the server right now. Previously visited pages and
locally cached data are still available.</p>
<button onclick="location.reload()">Try Again</button>
</div>
</body>
</html>`;
return new Response(html, {
status: 503,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}