198 lines
5.8 KiB
TypeScript
198 lines
5.8 KiB
TypeScript
/// <reference lib="webworker" />
|
|
declare const self: ServiceWorkerGlobalScope;
|
|
|
|
const CACHE_VERSION = "rspace-v1";
|
|
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)$/;
|
|
|
|
// App shell to precache on install
|
|
const PRECACHE_URLS = ["/", "/canvas.html"];
|
|
|
|
// Max age for cached API GET responses (5 minutes)
|
|
const API_CACHE_MAX_AGE_MS = 5 * 60 * 1000;
|
|
|
|
self.addEventListener("install", (event) => {
|
|
event.waitUntil(
|
|
caches.open(HTML_CACHE).then((cache) => cache.addAll(PRECACHE_URLS))
|
|
);
|
|
self.skipWaiting();
|
|
});
|
|
|
|
self.addEventListener("activate", (event) => {
|
|
// Clean up old versioned caches
|
|
event.waitUntil(
|
|
caches
|
|
.keys()
|
|
.then((keys) =>
|
|
Promise.all(
|
|
keys
|
|
.filter((key) => !key.startsWith(CACHE_VERSION))
|
|
.map((key) => caches.delete(key))
|
|
)
|
|
)
|
|
.then(() => self.clients.claim())
|
|
);
|
|
});
|
|
|
|
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" },
|
|
});
|
|
}
|