rspace-online/website/sw.ts

342 lines
11 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`;
const ECOSYSTEM_CACHE = `${CACHE_VERSION}-ecosystem`;
// 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 (with ?v=hash)
modules: string[]; // module JS/CSS — lazy-cached after activation (with ?v=hash)
hashes?: Record<string, string>; // content hashes for cache-busting
}
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) && key !== ECOSYSTEM_CACHE)
.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 */ }
}
// Clean stale versioned entries (old ?v=oldhash URLs)
const currentUrls = new Set([...manifest.core, ...manifest.modules]);
const cachedRequests = await cache.keys();
for (const req of cachedRequests) {
const u = new URL(req.url);
const isVersioned = u.searchParams.has("v") && (
u.pathname.startsWith("/modules/") ||
u.pathname === "/shell.js" ||
u.pathname === "/shell.css" ||
u.pathname === "/theme.css"
);
if (isVersioned && !currentUrls.has(u.pathname + u.search)) {
await cache.delete(req);
}
}
}
}
} 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).catch(() => {});
}
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).catch(() => {}));
}
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).catch(() => {}));
}
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).catch(() => {}));
}
return response;
})
.catch(() => cached || new Response("Offline", { status: 503 }));
return cached || fetchPromise;
})
);
});
// ============================================================================
// ECOSYSTEM MODULE CACHING (AC#8)
// ============================================================================
// EcosystemBridge sends postMessage to cache external ecosystem module JS
// for offline access. The module URL is fetched via our server proxy and
// stored in the ecosystem cache.
self.addEventListener("message", (event) => {
const msg = event.data;
if (!msg || typeof msg !== "object") return;
if (msg.type === "cache-ecosystem-module" && msg.moduleUrl) {
event.waitUntil(
(async () => {
try {
const cache = await caches.open(ECOSYSTEM_CACHE);
const existing = await cache.match(msg.moduleUrl);
if (existing) return; // Already cached
// Fetch via server proxy to avoid CORS
// Only cache under the canonical proxy URL — never under
// client-supplied msg.moduleUrl (cache poisoning risk)
const proxyUrl = `/api/ecosystem/${encodeURIComponent(msg.appId)}/module`;
const res = await fetch(proxyUrl);
if (res.ok) {
await cache.put(proxyUrl, res.clone());
}
} catch {
// Non-critical — module will be fetched on demand
}
})()
);
}
// Allow client to request ecosystem cache cleanup
if (msg.type === "clear-ecosystem-cache") {
event.waitUntil(caches.delete(ECOSYSTEM_CACHE));
}
});
// ============================================================================
// WEB PUSH HANDLERS
// ============================================================================
self.addEventListener("push", (event) => {
if (!event.data) return;
let payload: { title: string; body?: string; icon?: string; badge?: string; tag?: string; data?: any };
try {
payload = event.data.json();
} catch {
payload = { title: event.data.text() || "rSpace" };
}
event.waitUntil(
self.registration.showNotification(payload.title, {
body: payload.body,
icon: payload.icon || "/icons/icon-192.png",
badge: payload.badge || "/icons/icon-192.png",
tag: payload.tag,
data: payload.data,
}),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification.data?.url || "/";
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
// Focus existing tab if found
for (const client of clients) {
if (new URL(client.url).pathname === url && "focus" in client) {
return client.focus();
}
}
// Otherwise open new window
return self.clients.openWindow(url);
}),
);
});
/** 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" },
});
}