239 lines
6.8 KiB
JavaScript
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
|
|
});
|