From e6fe5ac27fd077cdc94a3b8d70623f86e92cb645 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Sun, 1 Mar 2026 18:33:11 +0100 Subject: [PATCH] fix: harden offline fallback and track visitable pages --- public/offline.html | 407 +++++++++++++++++++--- public/sw.js | 212 +++++++---- src/components/settings/CacheSettings.tsx | 4 + src/contexts/ServiceWorkerContext.tsx | 8 +- src/i18n/messages/en/common.json | 3 +- src/i18n/messages/fr/common.json | 3 +- 6 files changed, 518 insertions(+), 119 deletions(-) diff --git a/public/offline.html b/public/offline.html index a99860f..a551926 100644 --- a/public/offline.html +++ b/public/offline.html @@ -5,75 +5,392 @@ Hors ligne - StripStream -
-

Vous êtes hors ligne

-

- Il semble que vous n'ayez pas de connexion internet. Certaines fonctionnalités de - StripStream peuvent ne pas être disponibles en mode hors ligne. -

-
- - +
+
+
+ +
+
STRIPSTREAM
+
comic reader
+
+
+ Mode hors ligne
+
+ +
+ + + +
+
+
● Hors ligne
+

Cette page n'est pas encore disponible hors ligne.

+

+ Tu peux continuer a naviguer sur les pages deja consultees. Cette route sera + disponible hors ligne apres une visite en ligne. +

+
+ + +
+
+ Astuce: visite d'abord Accueil, Bibliotheques, Series et pages de lecture quand tu es + en ligne. +
+ +
+
+ + diff --git a/public/sw.js b/public/sw.js index 8ed45c2..a5ebbc7 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,15 +1,15 @@ // StripStream Service Worker - Version 2 -// Architecture: SWR (Stale-While-Revalidate) for all resources +// Architecture: static + image caching, resilient offline navigation fallback -const VERSION = "v2.5"; +const VERSION = "v2.16"; const STATIC_CACHE = `stripstream-static-${VERSION}`; -const PAGES_CACHE = `stripstream-pages-${VERSION}`; // Navigation + RSC (client-side navigation) +const PAGES_CACHE = `stripstream-pages-${VERSION}`; // Navigation documents + RSC payloads 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"]; +const OPTIONAL_PRECACHE_ASSETS = ["/manifest.json"]; // Cache size limits const IMAGES_CACHE_MAX_ENTRIES = 500; @@ -27,10 +27,6 @@ function isNextRSCRequest(request) { 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/"); } @@ -44,28 +40,99 @@ function isBookPageRequest(url) { ); } -function shouldCacheResponse(response) { +function shouldCacheResponse(response, options = {}) { if (!response || !response.ok) return false; + + if (options.allowPrivateNoStore) { + return true; + } + const cacheControl = response.headers.get("Cache-Control") || ""; return !/no-store|private/i.test(cacheControl); } async function getOfflineFallbackResponse() { - // Try root page first for app-shell style recovery - const pagesCache = await caches.open(PAGES_CACHE); - const rootPage = await pagesCache.match("/"); - if (rootPage) { - return rootPage; - } - - // Last resort: static offline page + // Prefer dedicated offline page to avoid route mismatches const staticCache = await caches.open(STATIC_CACHE); const offlinePage = await staticCache.match(OFFLINE_PAGE); if (offlinePage) { return offlinePage; } - return null; + // If offline page is unavailable, fallback to root app shell + const pagesCache = await caches.open(PAGES_CACHE); + const rootPage = await pagesCache.match("/"); + if (rootPage && isHtmlResponse(rootPage)) { + return rootPage; + } + + // Fallback to any cached HTML app shell page before static offline page + const keys = await pagesCache.keys(); + const pageKey = [...keys].reverse().find((request) => { + const key = getVisitablePageKey(request.url); + return key !== null; + }); + + if (pageKey) { + const appShellPage = await pagesCache.match(pageKey); + if (appShellPage && isHtmlResponse(appShellPage)) { + return appShellPage; + } + } + + return createInlineOfflineResponse(); +} + +function getVisitablePageKey(rawUrl) { + try { + const url = new URL(rawUrl); + + if (url.origin !== self.location.origin) { + return null; + } + + if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/_next/")) { + return null; + } + + url.searchParams.delete("_rsc"); + url.searchParams.delete("__sw_rsc"); + const search = url.searchParams.toString(); + return `${url.pathname}${search ? `?${search}` : ""}`; + } catch { + return null; + } +} + +function isHtmlResponse(response) { + if (!response) return false; + const contentType = response.headers.get("content-type") || ""; + return contentType.includes("text/html"); +} + +function createInlineOfflineResponse() { + return new Response( + `Hors ligne - StripStream
STRIPSTREAM
comic reader
Mode hors ligne
● Hors ligne

Cette page n'est pas encore disponible hors ligne.

Tu peux continuer a naviguer sur les pages deja consultees. Cette route sera disponible hors ligne apres une visite en ligne.

Astuce: visite d'abord Accueil, Bibliotheques, Series et pages de lecture quand tu es en ligne.
`, + { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + }, + } + ); +} + +function countVisitablePages(requests) { + const uniquePages = new Set(); + + requests.forEach((request) => { + const key = getVisitablePageKey(request.url); + if (key) { + uniquePages.add(key); + } + }); + + return uniquePages.size; } // ============================================================================ @@ -130,7 +197,7 @@ async function cacheFirstStrategy(request, cacheName, options = {}) { try { const response = await fetch(request); - if (shouldCacheResponse(response)) { + if (shouldCacheResponse(response, options)) { cache.put(request, response.clone()); } return response; @@ -146,11 +213,12 @@ async function cacheFirstStrategy(request, cacheName, options = {}) { /** * Stale-While-Revalidate: Serve from cache immediately, update in background - * Used for: API calls, images + * Used for: RSC payloads, 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); + const cacheKey = options.cacheKey ? options.cacheKey(request) : request; // Check if client requested no-cache (refresh button, router.refresh(), etc.) // 1. Check Cache-Control header @@ -163,19 +231,19 @@ async function staleWhileRevalidateStrategy(request, cacheName, options = {}) { const noCache = noCacheHeader || noCacheMode; // If no-cache, skip cached response and go network-first - const cached = noCache ? null : await cache.match(request); + const cached = noCache ? null : await cache.match(cacheKey); // Start network request (don't await) const fetchPromise = fetch(request) .then(async (response) => { - if (shouldCacheResponse(response)) { + if (shouldCacheResponse(response, options)) { // 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); + const cachedResponse = await cache.match(cacheKey); if (cachedResponse) { // For JSON APIs, compare content if (options.isJson) { @@ -196,7 +264,7 @@ async function staleWhileRevalidateStrategy(request, cacheName, options = {}) { } // Update cache - await cache.put(request, responseToCache); + await cache.put(cacheKey, responseToCache); // Trim cache if needed (for images) if (options.maxEntries) { @@ -234,42 +302,32 @@ async function staleWhileRevalidateStrategy(request, cacheName, options = {}) { } /** - * Navigation SWR: Serve from cache immediately, update in background - * Falls back to offline page if nothing cached + * Navigation Network-First: prefer fresh content from network + * Falls back to cached page, then offline fallback when network fails * Used for: Page navigations */ -async function navigationSWRStrategy(request, cacheName) { +async function navigationNetworkFirstStrategy(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 (shouldCacheResponse(response)) { - await cache.put(request, response.clone()); - } - return response; - }) - .catch(() => null); + try { + const networkResponse = await fetch(request); + if (shouldCacheResponse(networkResponse, { allowPrivateNoStore: true })) { + await cache.put(request, networkResponse.clone()); + } + return networkResponse; + } catch { + const cached = await cache.match(request); + if (cached && isHtmlResponse(cached)) { + return cached; + } - // Return cached version immediately if available - if (cached) { - return cached; + const fallbackResponse = await getOfflineFallbackResponse(); + if (fallbackResponse) { + return fallbackResponse; + } + + throw new Error("Offline and no cached page available"); } - - // No cache - wait for network - const response = await fetchPromise; - if (response) { - return response; - } - - // Network failed and no cache - try shared fallbacks - const fallbackResponse = await getOfflineFallbackResponse(); - if (fallbackResponse) { - return fallbackResponse; - } - - throw new Error("Offline and no cached page available"); } // ============================================================================ @@ -284,7 +342,8 @@ self.addEventListener("install", (event) => { (async () => { const cache = await caches.open(STATIC_CACHE); try { - await cache.addAll(PRECACHE_ASSETS); + await cache.add(OFFLINE_PAGE); + await Promise.allSettled(OPTIONAL_PRECACHE_ASSETS.map((asset) => cache.add(asset))); // eslint-disable-next-line no-console console.log("[SW] Precached assets"); } catch (error) { @@ -358,6 +417,8 @@ self.addEventListener("message", async (event) => { booksCache.keys(), ]); + const visitablePages = countVisitablePages(pagesKeys); + event.source.postMessage({ type: "CACHE_STATS", payload: { @@ -367,6 +428,7 @@ self.addEventListener("message", async (event) => { images: { size: imagesSize, entries: imagesKeys.length }, books: { size: booksSize, entries: booksKeys.length }, total: staticSize + pagesSize + apiSize + imagesSize + booksSize, + visitablePages, }, }); } catch (error) { @@ -506,6 +568,15 @@ self.addEventListener("fetch", (event) => { return; } + // Only handle same-origin HTTP(S) requests + if (url.origin !== self.location.origin) { + return; + } + + if (url.protocol !== "http:" && url.protocol !== "https:") { + return; + } + // Route 1: Book pages (handled by DownloadManager) - Check manual cache only, no SWR if (isBookPageRequest(url.href)) { event.respondWith( @@ -525,12 +596,17 @@ self.addEventListener("fetch", (event) => { return; } - // Route 2: Next.js RSC payloads (client-side navigation) → SWR in PAGES_CACHE + // Route 2: Next.js RSC payloads → SWR in PAGES_CACHE if (isNextRSCRequest(request)) { event.respondWith( staleWhileRevalidateStrategy(request, PAGES_CACHE, { - notifyOnChange: false, - fallbackToOffline: true, + allowPrivateNoStore: true, + cacheKey: (incomingRequest) => { + const normalized = new URL(incomingRequest.url); + normalized.searchParams.delete("_rsc"); + normalized.searchParams.set("__sw_rsc", "1"); + return normalized.toString(); + }, }) ); return; @@ -538,22 +614,16 @@ self.addEventListener("fetch", (event) => { // 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, + cacheFirstStrategy(request, STATIC_CACHE, { + ignoreSearch: true, + allowPrivateNoStore: true, }) ); return; } - // Route 5: Image requests (thumbnails, covers) → SWR with cache size management + // Route 4: 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( @@ -564,11 +634,11 @@ self.addEventListener("fetch", (event) => { return; } - // Route 6: Navigation → SWR (cache first, revalidate in background) + // Route 5: Navigation → Network-First with cache/offline fallback if (request.mode === "navigate") { - event.respondWith(navigationSWRStrategy(request, PAGES_CACHE)); + event.respondWith(navigationNetworkFirstStrategy(request, PAGES_CACHE)); return; } - // Route 7: Everything else → Network only (no caching) + // Route 6: Everything else → Network only (no caching) }); diff --git a/src/components/settings/CacheSettings.tsx b/src/components/settings/CacheSettings.tsx index 4b8ae53..466bdba 100644 --- a/src/components/settings/CacheSettings.tsx +++ b/src/components/settings/CacheSettings.tsx @@ -36,6 +36,7 @@ interface CacheStats { images: { size: number; entries: number }; books: { size: number; entries: number }; total: number; + visitablePages: number; } interface CacheEntry { @@ -376,6 +377,9 @@ export function CacheSettings() {

{t("settings.cache.imagesQuota", { used: Math.round(usagePercent) })}

+

+ {t("settings.cache.visitablePages", { count: stats.visitablePages })} +

)} diff --git a/src/contexts/ServiceWorkerContext.tsx b/src/contexts/ServiceWorkerContext.tsx index 05310f3..0a9cf5f 100644 --- a/src/contexts/ServiceWorkerContext.tsx +++ b/src/contexts/ServiceWorkerContext.tsx @@ -17,6 +17,7 @@ interface CacheStats { images: { size: number; entries: number }; books: { size: number; entries: number }; total: number; + visitablePages: number; } interface CacheEntry { @@ -112,7 +113,12 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) { case "CACHE_STATS": const statsResolver = pendingRequests.current.get("CACHE_STATS"); if (statsResolver) { - statsResolver(payload); + const normalizedPayload = { + ...payload, + visitablePages: + typeof payload?.visitablePages === "number" ? payload.visitablePages : 0, + }; + statsResolver(normalizedPayload); pendingRequests.current.delete("CACHE_STATS"); } break; diff --git a/src/i18n/messages/en/common.json b/src/i18n/messages/en/common.json index d73a701..95c84b5 100644 --- a/src/i18n/messages/en/common.json +++ b/src/i18n/messages/en/common.json @@ -144,10 +144,11 @@ "initializing": "Initializing...", "totalStorage": "Total storage", "imagesQuota": "{used}% of images quota used", + "visitablePages": "{count} pages available offline", "static": "Static resources", "staticDesc": "Next.js scripts, styles and assets", "pages": "Visited pages", - "pagesDesc": "Home, libraries, series and details", + "pagesDesc": "Pages and navigation data available offline", "api": "API data", "apiDesc": "Series, books and library metadata", "images": "Images", diff --git a/src/i18n/messages/fr/common.json b/src/i18n/messages/fr/common.json index 5adc14c..16b6479 100644 --- a/src/i18n/messages/fr/common.json +++ b/src/i18n/messages/fr/common.json @@ -144,10 +144,11 @@ "initializing": "Initialisation...", "totalStorage": "Stockage total", "imagesQuota": "{used}% du quota images utilisé", + "visitablePages": "{count} pages visitables hors ligne", "static": "Ressources statiques", "staticDesc": "Scripts, styles et assets Next.js", "pages": "Pages visitées", - "pagesDesc": "Home, bibliothèques, séries et détails", + "pagesDesc": "Pages et données de navigation disponibles hors ligne", "api": "Données API", "apiDesc": "Métadonnées des séries, livres et bibliothèques", "images": "Images",