// StripStream Service Worker - Version 2 // Architecture: SWR (Stale-While-Revalidate) for all resources const VERSION = "v2.5"; const STATIC_CACHE = `stripstream-static-${VERSION}`; const PAGES_CACHE = `stripstream-pages-${VERSION}`; // Navigation + RSC (client-side navigation) const API_CACHE = `stripstream-api-${VERSION}`; const IMAGES_CACHE = `stripstream-images-${VERSION}`; const BOOKS_CACHE = "stripstream-books"; // Never version this - managed by DownloadManager const OFFLINE_PAGE = "/offline.html"; const PRECACHE_ASSETS = [OFFLINE_PAGE, "/manifest.json"]; // Cache size limits const IMAGES_CACHE_MAX_SIZE = 100 * 1024 * 1024; // 100MB const IMAGES_CACHE_MAX_ENTRIES = 500; // ============================================================================ // Utility Functions - Request Detection // ============================================================================ function isNextStaticResource(url) { return url.includes("/_next/static/"); } function isNextRSCRequest(request) { const url = new URL(request.url); return url.searchParams.has("_rsc") || request.headers.get("RSC") === "1"; } function isApiRequest(url) { return url.includes("/api/komga/") && !url.includes("/api/komga/images/"); } function isImageRequest(url) { return url.includes("/api/komga/images/"); } function isBookPageRequest(url) { // Book pages: /api/komga/images/books/{id}/pages/{num} or /api/komga/books/{id}/pages/{num} // These are handled by manual download (DownloadManager) - don't cache via SWR return ( (url.includes("/api/komga/images/books/") || url.includes("/api/komga/books/")) && url.includes("/pages/") ); } function isBooksManualCache(url) { // Check if this is a request that should be handled by the books manual cache return url.includes("/api/komga/images/books/") && url.includes("/pages"); } // ============================================================================ // Client Communication // ============================================================================ async function notifyClients(message) { const clients = await self.clients.matchAll({ type: "window" }); clients.forEach((client) => { client.postMessage(message); }); } // ============================================================================ // Cache Management // ============================================================================ async function getCacheSize(cacheName) { const cache = await caches.open(cacheName); const keys = await cache.keys(); let totalSize = 0; for (const request of keys) { const response = await cache.match(request); if (response) { const blob = await response.clone().blob(); totalSize += blob.size; } } return totalSize; } async function trimCache(cacheName, maxEntries) { const cache = await caches.open(cacheName); const keys = await cache.keys(); if (keys.length > maxEntries) { // Remove oldest entries (FIFO) const toDelete = keys.slice(0, keys.length - maxEntries); await Promise.all(toDelete.map((key) => cache.delete(key))); // eslint-disable-next-line no-console console.log(`[SW] Trimmed ${toDelete.length} entries from ${cacheName}`); } } // ============================================================================ // Cache Strategies // ============================================================================ /** * Cache-First: Serve from cache, fallback to network * Used for: Next.js static resources (immutable) */ async function cacheFirstStrategy(request, cacheName, options = {}) { const cache = await caches.open(cacheName); const cached = await cache.match(request, options); if (cached) { return cached; } try { const response = await fetch(request); if (response.ok) { cache.put(request, response.clone()); } return response; } catch (error) { // Network failed - try cache without ignoreSearch as fallback if (options.ignoreSearch) { const fallback = await cache.match(request, { ignoreSearch: false }); if (fallback) return fallback; } throw error; } } /** * Stale-While-Revalidate: Serve from cache immediately, update in background * Used for: API calls, images * Respects Cache-Control: no-cache to force network-first (for refresh buttons) */ async function staleWhileRevalidateStrategy(request, cacheName, options = {}) { const cache = await caches.open(cacheName); // Check if client requested no-cache (refresh button, router.refresh(), etc.) // 1. Check Cache-Control header const cacheControl = request.headers.get("Cache-Control"); const noCacheHeader = cacheControl && (cacheControl.includes("no-cache") || cacheControl.includes("no-store")); // 2. Check request.cache mode (used by Next.js router.refresh()) const noCacheMode = request.cache === "no-cache" || request.cache === "no-store" || request.cache === "reload"; const noCache = noCacheHeader || noCacheMode; // If no-cache, skip cached response and go network-first const cached = noCache ? null : await cache.match(request); // Start network request (don't await) const fetchPromise = fetch(request) .then(async (response) => { if (response.ok) { // Clone response for cache const responseToCache = response.clone(); // Check if content changed (for notification) if (cached && options.notifyOnChange) { try { const cachedResponse = await cache.match(request); if (cachedResponse) { // For JSON APIs, compare content if (options.isJson) { const oldText = await cachedResponse.text(); const newText = await response.clone().text(); if (oldText !== newText) { notifyClients({ type: "CACHE_UPDATED", url: request.url, timestamp: Date.now(), }); } } } } catch { // Ignore comparison errors } } // Update cache await cache.put(request, responseToCache); // Trim cache if needed (for images) if (options.maxEntries) { trimCache(cacheName, options.maxEntries); } } return response; }) .catch((error) => { // eslint-disable-next-line no-console console.log("[SW] Network request failed:", request.url, error.message); return null; }); // Return cached version immediately if available if (cached) { return cached; } // Otherwise wait for network const response = await fetchPromise; if (response) { return response; } throw new Error("Network failed and no cache available"); } /** * Navigation SWR: Serve from cache immediately, update in background * Falls back to offline page if nothing cached * Used for: Page navigations */ async function navigationSWRStrategy(request, cacheName) { const cache = await caches.open(cacheName); const cached = await cache.match(request); // Start network request in background const fetchPromise = fetch(request) .then(async (response) => { if (response.ok) { await cache.put(request, response.clone()); } return response; }) .catch(() => null); // Return cached version immediately if available if (cached) { return cached; } // No cache - wait for network const response = await fetchPromise; if (response) { return response; } // Network failed and no cache - try fallbacks // Try to serve root page for SPA client-side routing const rootPage = await cache.match("/"); if (rootPage) { return rootPage; } // Last resort: offline page (in static cache) const staticCache = await caches.open(STATIC_CACHE); const offlinePage = await staticCache.match(OFFLINE_PAGE); if (offlinePage) { return offlinePage; } throw new Error("Offline and no cached page available"); } // ============================================================================ // Service Worker Lifecycle // ============================================================================ self.addEventListener("install", (event) => { // eslint-disable-next-line no-console console.log("[SW] Installing version", VERSION); event.waitUntil( (async () => { const cache = await caches.open(STATIC_CACHE); try { await cache.addAll(PRECACHE_ASSETS); // eslint-disable-next-line no-console console.log("[SW] Precached assets"); } catch (error) { // eslint-disable-next-line no-console console.error("[SW] Precache failed:", error); } await self.skipWaiting(); })() ); }); self.addEventListener("activate", (event) => { // eslint-disable-next-line no-console console.log("[SW] Activating version", VERSION); event.waitUntil( (async () => { // Clean up old caches, but preserve BOOKS_CACHE const cacheNames = await caches.keys(); const currentCaches = [STATIC_CACHE, PAGES_CACHE, API_CACHE, IMAGES_CACHE, BOOKS_CACHE]; const cachesToDelete = cacheNames.filter( (name) => name.startsWith("stripstream-") && !currentCaches.includes(name) ); await Promise.all(cachesToDelete.map((name) => caches.delete(name))); if (cachesToDelete.length > 0) { // eslint-disable-next-line no-console console.log("[SW] Deleted old caches:", cachesToDelete); } await self.clients.claim(); // eslint-disable-next-line no-console console.log("[SW] Activated and claimed clients"); // Notify clients that SW is ready notifyClients({ type: "SW_ACTIVATED", version: VERSION }); })() ); }); // ============================================================================ // Message Handler - Client Communication // ============================================================================ self.addEventListener("message", async (event) => { const { type, payload } = event.data || {}; switch (type) { case "GET_CACHE_STATS": { try { const [staticSize, pagesSize, apiSize, imagesSize, booksSize] = await Promise.all([ getCacheSize(STATIC_CACHE), getCacheSize(PAGES_CACHE), getCacheSize(API_CACHE), getCacheSize(IMAGES_CACHE), getCacheSize(BOOKS_CACHE), ]); const staticCache = await caches.open(STATIC_CACHE); const pagesCache = await caches.open(PAGES_CACHE); const apiCache = await caches.open(API_CACHE); const imagesCache = await caches.open(IMAGES_CACHE); const booksCache = await caches.open(BOOKS_CACHE); const [staticKeys, pagesKeys, apiKeys, imagesKeys, booksKeys] = await Promise.all([ staticCache.keys(), pagesCache.keys(), apiCache.keys(), imagesCache.keys(), booksCache.keys(), ]); event.source.postMessage({ type: "CACHE_STATS", payload: { static: { size: staticSize, entries: staticKeys.length }, pages: { size: pagesSize, entries: pagesKeys.length }, api: { size: apiSize, entries: apiKeys.length }, images: { size: imagesSize, entries: imagesKeys.length }, books: { size: booksSize, entries: booksKeys.length }, total: staticSize + pagesSize + apiSize + imagesSize + booksSize, }, }); } catch (error) { event.source.postMessage({ type: "CACHE_STATS_ERROR", payload: { error: error.message }, }); } break; } case "CLEAR_CACHE": { try { const cacheType = payload?.cacheType || "all"; const cachesToClear = []; if (cacheType === "all" || cacheType === "static") { cachesToClear.push(STATIC_CACHE); } if (cacheType === "all" || cacheType === "pages") { cachesToClear.push(PAGES_CACHE); } if (cacheType === "all" || cacheType === "api") { cachesToClear.push(API_CACHE); } if (cacheType === "all" || cacheType === "images") { cachesToClear.push(IMAGES_CACHE); } // Note: BOOKS_CACHE is not cleared by default, only explicitly await Promise.all( cachesToClear.map(async (cacheName) => { const cache = await caches.open(cacheName); const keys = await cache.keys(); await Promise.all(keys.map((key) => cache.delete(key))); }) ); event.source.postMessage({ type: "CACHE_CLEARED", payload: { caches: cachesToClear }, }); } catch (error) { event.source.postMessage({ type: "CACHE_CLEAR_ERROR", payload: { error: error.message }, }); } break; } case "SKIP_WAITING": { self.skipWaiting(); break; } case "GET_VERSION": { event.source.postMessage({ type: "SW_VERSION", payload: { version: VERSION }, }); break; } case "GET_CACHE_ENTRIES": { try { const cacheType = payload?.cacheType; let cacheName; switch (cacheType) { case "static": cacheName = STATIC_CACHE; break; case "pages": cacheName = PAGES_CACHE; break; case "api": cacheName = API_CACHE; break; case "images": cacheName = IMAGES_CACHE; break; case "books": cacheName = BOOKS_CACHE; break; default: throw new Error("Invalid cache type"); } const cache = await caches.open(cacheName); const keys = await cache.keys(); const entries = await Promise.all( keys.map(async (request) => { const response = await cache.match(request); let size = 0; if (response) { const blob = await response.clone().blob(); size = blob.size; } return { url: request.url, size, }; }) ); // Sort by size descending entries.sort((a, b) => b.size - a.size); event.source.postMessage({ type: "CACHE_ENTRIES", payload: { cacheType, entries }, }); } catch (error) { event.source.postMessage({ type: "CACHE_ENTRIES_ERROR", payload: { error: error.message }, }); } break; } } }); // ============================================================================ // Fetch Handler - Request Routing // ============================================================================ self.addEventListener("fetch", (event) => { const { request } = event; const { method } = request; const url = new URL(request.url); // Only handle GET requests if (method !== "GET") { return; } // Route 1: Book pages (handled by DownloadManager) - Check manual cache only, no SWR if (isBookPageRequest(url.href)) { event.respondWith( (async () => { // Check the manual books cache const booksCache = await caches.open(BOOKS_CACHE); const cached = await booksCache.match(request); if (cached) { return cached; } // Not in cache - fetch from network without caching // Book pages are large and should only be cached via manual download return fetch(request); })() ); return; } // Route 2: Next.js RSC payloads (client-side navigation) → SWR in PAGES_CACHE if (isNextRSCRequest(request)) { event.respondWith( staleWhileRevalidateStrategy(request, PAGES_CACHE, { notifyOnChange: false, }) ); return; } // Route 3: Next.js static resources → Cache-First with ignoreSearch if (isNextStaticResource(url.href)) { event.respondWith(cacheFirstStrategy(request, STATIC_CACHE, { ignoreSearch: true })); return; } // Route 4: API requests (JSON) → SWR with notification if (isApiRequest(url.href)) { event.respondWith( staleWhileRevalidateStrategy(request, API_CACHE, { notifyOnChange: true, isJson: true, }) ); return; } // Route 5: Image requests (thumbnails, covers) → SWR with cache size management // Note: Book pages are excluded (Route 1) and only use manual download cache if (isImageRequest(url.href)) { event.respondWith( staleWhileRevalidateStrategy(request, IMAGES_CACHE, { maxEntries: IMAGES_CACHE_MAX_ENTRIES, }) ); return; } // Route 6: Navigation → SWR (cache first, revalidate in background) if (request.mode === "navigate") { event.respondWith(navigationSWRStrategy(request, PAGES_CACHE)); return; } // Route 7: Everything else → Network only (no caching) });