Files
stripstream/public/sw.js

239 lines
6.8 KiB
JavaScript

// StripStream Service Worker - Version 1
// Architecture: Cache-as-you-go with Stale-While-Revalidate for data
const VERSION = "v1";
const STATIC_CACHE = `stripstream-static-${VERSION}`;
const IMAGES_CACHE = `stripstream-images-${VERSION}`;
const DATA_CACHE = `stripstream-data-${VERSION}`;
const RSC_CACHE = `stripstream-rsc-${VERSION}`;
const BOOKS_CACHE = "stripstream-books"; // Never version this - managed by DownloadManager
const OFFLINE_PAGE = "/offline.html";
const PRECACHE_ASSETS = [OFFLINE_PAGE, "/manifest.json"];
// ============================================================================
// Utility Functions - Request Detection
// ============================================================================
function isNextStaticResource(url) {
return url.includes("/_next/static/");
}
function isImageRequest(url) {
return url.includes("/api/komga/images/");
}
function isApiDataRequest(url) {
return url.includes("/api/komga/") && !isImageRequest(url);
}
function isNextRSCRequest(request) {
const url = new URL(request.url);
return url.searchParams.has("_rsc") || request.headers.get("RSC") === "1";
}
function shouldCacheApiData(url) {
// Exclude dynamic/auth endpoints that should always be fresh
return !url.includes("/api/auth/session") && !url.includes("/api/preferences");
}
// ============================================================================
// Cache Strategies
// ============================================================================
/**
* Cache-First: Serve from cache, fallback to network
* Used for: Images, Next.js static resources
*/
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 data, RSC payloads
*/
async function staleWhileRevalidateStrategy(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
// Start network request (don't await)
const fetchPromise = fetch(request)
.then((response) => {
if (response.ok) {
cache.put(request, response.clone());
}
return response;
})
.catch(() => 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 Strategy: Network-First with SPA fallback
* Used for: Page navigations
*/
async function navigationStrategy(request) {
const cache = await caches.open(STATIC_CACHE);
try {
// Try network first
const response = await fetch(request);
if (response.ok) {
cache.put(request, response.clone());
}
return response;
} catch (error) {
// Network failed - try cache
const cached = await cache.match(request);
if (cached) {
return cached;
}
// Try to serve root page for SPA client-side routing
const rootPage = await cache.match("/");
if (rootPage) {
return rootPage;
}
// Last resort: offline page
const offlinePage = await cache.match(OFFLINE_PAGE);
if (offlinePage) {
return offlinePage;
}
throw error;
}
}
// ============================================================================
// 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 cachesToDelete = cacheNames.filter(
(name) =>
name.startsWith("stripstream-") && name !== BOOKS_CACHE && !name.endsWith(`-${VERSION}`)
);
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");
})()
);
});
// ============================================================================
// 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: Images → Cache-First with ignoreSearch
if (isImageRequest(url.href)) {
event.respondWith(cacheFirstStrategy(request, IMAGES_CACHE, { ignoreSearch: true }));
return;
}
// Route 2: Next.js RSC payloads → Stale-While-Revalidate
if (isNextRSCRequest(request)) {
event.respondWith(staleWhileRevalidateStrategy(request, RSC_CACHE));
return;
}
// Route 3: API data → Stale-While-Revalidate (if cacheable)
if (isApiDataRequest(url.href) && shouldCacheApiData(url.href)) {
event.respondWith(staleWhileRevalidateStrategy(request, DATA_CACHE));
return;
}
// Route 4: Next.js static resources → Cache-First with ignoreSearch
if (isNextStaticResource(url.href)) {
event.respondWith(cacheFirstStrategy(request, STATIC_CACHE, { ignoreSearch: true }));
return;
}
// Route 5: Navigation → Network-First with SPA fallback
if (request.mode === "navigate") {
event.respondWith(navigationStrategy(request));
return;
}
// Route 6: Everything else → Network only (no caching)
// This includes: API auth, preferences, and other dynamic content
});