From 0d33462349dd71fdf7f52981ea122de5071b8a45 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Sun, 4 Jan 2026 07:39:07 +0100 Subject: [PATCH] feat: update service worker to version 2.4, enhance caching strategies for pages, and add service worker reinstallation functionality in CacheSettings component --- public/sw.js | 98 ++++++----- src/components/settings/CacheSettings.tsx | 118 ++++++++++--- src/contexts/ServiceWorkerContext.tsx | 196 +++++++++++++++------- src/i18n/messages/en/common.json | 4 + src/i18n/messages/fr/common.json | 4 + 5 files changed, 294 insertions(+), 126 deletions(-) diff --git a/public/sw.js b/public/sw.js index 8e13fe2..a12f2a7 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,11 +1,11 @@ // StripStream Service Worker - Version 2 // Architecture: SWR (Stale-While-Revalidate) for all resources -const VERSION = "v2"; +const VERSION = "v2.4"; 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 RSC_CACHE = `stripstream-rsc-${VERSION}`; const BOOKS_CACHE = "stripstream-books"; // Never version this - managed by DownloadManager const OFFLINE_PAGE = "/offline.html"; @@ -195,39 +195,50 @@ async function staleWhileRevalidateStrategy(request, cacheName, options = {}) { } /** - * Network-First: Try network, fallback to cache + * Navigation SWR: Serve from cache immediately, update in background + * Falls back to offline page if nothing cached * Used for: Page navigations */ -async function networkFirstStrategy(request, cacheName) { +async function navigationSWRStrategy(request, cacheName) { const cache = await caches.open(cacheName); + const cached = await cache.match(request); - try { - 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; - } + // 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); - // 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; + // 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"); } // ============================================================================ @@ -262,7 +273,7 @@ self.addEventListener("activate", (event) => { (async () => { // Clean up old caches, but preserve BOOKS_CACHE const cacheNames = await caches.keys(); - const currentCaches = [STATIC_CACHE, API_CACHE, IMAGES_CACHE, RSC_CACHE, BOOKS_CACHE]; + const currentCaches = [STATIC_CACHE, PAGES_CACHE, API_CACHE, IMAGES_CACHE, BOOKS_CACHE]; const cachesToDelete = cacheNames.filter( (name) => name.startsWith("stripstream-") && !currentCaches.includes(name) @@ -295,20 +306,23 @@ self.addEventListener("message", async (event) => { switch (type) { case "GET_CACHE_STATS": { try { - const [staticSize, apiSize, imagesSize, booksSize] = await Promise.all([ + 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, apiKeys, imagesKeys, booksKeys] = await Promise.all([ + const [staticKeys, pagesKeys, apiKeys, imagesKeys, booksKeys] = await Promise.all([ staticCache.keys(), + pagesCache.keys(), apiCache.keys(), imagesCache.keys(), booksCache.keys(), @@ -318,10 +332,11 @@ self.addEventListener("message", async (event) => { 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 + apiSize + imagesSize + booksSize, + total: staticSize + pagesSize + apiSize + imagesSize + booksSize, }, }); } catch (error) { @@ -341,15 +356,15 @@ self.addEventListener("message", async (event) => { 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); } - if (cacheType === "all" || cacheType === "rsc") { - cachesToClear.push(RSC_CACHE); - } // Note: BOOKS_CACHE is not cleared by default, only explicitly await Promise.all( @@ -395,6 +410,9 @@ self.addEventListener("message", async (event) => { case "static": cacheName = STATIC_CACHE; break; + case "pages": + cacheName = PAGES_CACHE; + break; case "api": cacheName = API_CACHE; break; @@ -477,10 +495,10 @@ self.addEventListener("fetch", (event) => { return; } - // Route 2: Next.js RSC payloads → Stale-While-Revalidate + // Route 2: Next.js RSC payloads (client-side navigation) → SWR in PAGES_CACHE if (isNextRSCRequest(request)) { event.respondWith( - staleWhileRevalidateStrategy(request, RSC_CACHE, { + staleWhileRevalidateStrategy(request, PAGES_CACHE, { notifyOnChange: false, }) ); @@ -515,9 +533,9 @@ self.addEventListener("fetch", (event) => { return; } - // Route 6: Navigation → Network-First with SPA fallback + // Route 6: Navigation → SWR (cache first, revalidate in background) if (request.mode === "navigate") { - event.respondWith(networkFirstStrategy(request, STATIC_CACHE)); + event.respondWith(navigationSWRStrategy(request, PAGES_CACHE)); return; } diff --git a/src/components/settings/CacheSettings.tsx b/src/components/settings/CacheSettings.tsx index 0bdecba..f8680ed 100644 --- a/src/components/settings/CacheSettings.tsx +++ b/src/components/settings/CacheSettings.tsx @@ -8,11 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; import { Badge } from "@/components/ui/badge"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Database, @@ -27,10 +23,13 @@ import { Loader2, ChevronDown, ChevronRight, + LayoutGrid, + RotateCcw, } from "lucide-react"; interface CacheStats { static: { size: number; entries: number }; + pages: { size: number; entries: number }; api: { size: number; entries: number }; images: { size: number; entries: number }; books: { size: number; entries: number }; @@ -42,7 +41,7 @@ interface CacheEntry { size: number; } -type CacheType = "static" | "api" | "images" | "books"; +type CacheType = "static" | "pages" | "api" | "images" | "books"; function formatBytes(bytes: number): string { if (bytes === 0) return "0 B"; @@ -195,12 +194,20 @@ function CacheItem({ export function CacheSettings() { const { t } = useTranslate(); const { toast } = useToast(); - const { isSupported, isReady, version, getCacheStats, getCacheEntries, clearCache } = - useServiceWorker(); + const { + isSupported, + isReady, + version, + getCacheStats, + getCacheEntries, + clearCache, + reinstallServiceWorker, + } = useServiceWorker(); const [stats, setStats] = useState(null); const [isLoading, setIsLoading] = useState(false); const [clearingCache, setClearingCache] = useState(null); + const [isReinstalling, setIsReinstalling] = useState(false); const loadStats = useCallback(async () => { if (!isReady) return; @@ -218,7 +225,7 @@ export function CacheSettings() { loadStats(); }, [loadStats]); - const handleClearCache = async (cacheType: "all" | "static" | "api" | "images") => { + const handleClearCache = async (cacheType: "all" | "static" | "pages" | "api" | "images") => { setClearingCache(cacheType); try { const success = await clearCache(cacheType); @@ -247,6 +254,28 @@ export function CacheSettings() { [getCacheEntries] ); + const handleReinstall = async () => { + setIsReinstalling(true); + try { + const success = await reinstallServiceWorker(); + if (!success) { + toast({ + variant: "destructive", + title: t("settings.error.title"), + description: t("settings.cache.reinstallError"), + }); + } + // If success, the page will reload automatically + } catch { + toast({ + variant: "destructive", + title: t("settings.error.title"), + description: t("settings.cache.reinstallError"), + }); + setIsReinstalling(false); + } + }; + // Calculer le pourcentage du cache utilisé (basé sur 100MB limite images) const maxCacheSize = 100 * 1024 * 1024; // 100MB const usagePercent = stats ? Math.min((stats.images.size / maxCacheSize) * 100, 100) : 0; @@ -328,6 +357,17 @@ export function CacheSettings() { isClearing={clearingCache === "static"} onLoadEntries={handleLoadEntries} /> + } + label={t("settings.cache.pages")} + size={stats.pages.size} + entries={stats.pages.entries} + cacheType="pages" + description={t("settings.cache.pagesDesc")} + onClear={() => handleClearCache("pages")} + isClearing={clearingCache === "pages"} + onLoadEntries={handleLoadEntries} + /> } label={t("settings.cache.api")} @@ -365,27 +405,55 @@ export function CacheSettings() { ) : ( -

- {t("settings.cache.unavailable")} -

+
+

{t("settings.cache.unavailable")}

+ +
)} {/* Bouton vider tout */} {stats && stats.total > 0 && ( - +
+ + +
)} diff --git a/src/contexts/ServiceWorkerContext.tsx b/src/contexts/ServiceWorkerContext.tsx index faba1db..7b3c302 100644 --- a/src/contexts/ServiceWorkerContext.tsx +++ b/src/contexts/ServiceWorkerContext.tsx @@ -2,11 +2,12 @@ import { createContext, useContext, useEffect, useState, useCallback, useRef } from "react"; import type { ReactNode } from "react"; -import { registerServiceWorker } from "@/lib/registerSW"; +import { registerServiceWorker, unregisterServiceWorker } from "@/lib/registerSW"; import logger from "@/lib/logger"; interface CacheStats { static: { size: number; entries: number }; + pages: { size: number; entries: number }; api: { size: number; entries: number }; images: { size: number; entries: number }; books: { size: number; entries: number }; @@ -23,7 +24,7 @@ interface CacheUpdate { timestamp: number; } -type CacheType = "all" | "static" | "api" | "images" | "rsc" | "books"; +type CacheType = "all" | "static" | "pages" | "api" | "images" | "books"; interface ServiceWorkerContextValue { isSupported: boolean; @@ -38,6 +39,7 @@ interface ServiceWorkerContextValue { clearCache: (cacheType?: CacheType) => Promise; skipWaiting: () => void; reloadForUpdate: () => void; + reinstallServiceWorker: () => Promise; } const ServiceWorkerContext = createContext(null); @@ -53,76 +55,113 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) { // Handle messages from service worker const handleMessage = useCallback((event: MessageEvent) => { - const { type, payload } = event.data || {}; + try { + // Ignore messages without proper data structure + if (!event.data || typeof event.data !== "object") return; - switch (type) { - case "SW_ACTIVATED": - setIsReady(true); - setVersion(payload?.version || null); - break; + // Only handle messages from our service worker (check for known message types) + const knownTypes = [ + "SW_ACTIVATED", + "SW_VERSION", + "CACHE_UPDATED", + "CACHE_STATS", + "CACHE_STATS_ERROR", + "CACHE_CLEARED", + "CACHE_CLEAR_ERROR", + "CACHE_ENTRIES", + "CACHE_ENTRIES_ERROR", + ]; - case "SW_VERSION": - setVersion(payload?.version || null); - break; + const type = event.data.type; + if (typeof type !== "string" || !knownTypes.includes(type)) return; - case "CACHE_UPDATED": - setCacheUpdates((prev) => { - // Avoid duplicates for the same URL within 1 second - const existing = prev.find( - (u) => u.url === payload.url && Date.now() - u.timestamp < 1000 - ); - if (existing) return prev; - return [...prev, { url: payload.url, timestamp: payload.timestamp }]; - }); - break; + const payload = event.data.payload; - case "CACHE_STATS": - const statsResolver = pendingRequests.current.get("CACHE_STATS"); - if (statsResolver) { - statsResolver(payload); - pendingRequests.current.delete("CACHE_STATS"); + switch (type) { + case "SW_ACTIVATED": + setIsReady(true); + setVersion(payload?.version || null); + break; + + case "SW_VERSION": + setVersion(payload?.version || null); + break; + + case "CACHE_UPDATED": { + const url = typeof payload?.url === "string" ? payload.url : null; + const timestamp = typeof payload?.timestamp === "number" ? payload.timestamp : Date.now(); + if (url) { + setCacheUpdates((prev) => { + // Avoid duplicates for the same URL within 1 second + const existing = prev.find((u) => u.url === url && Date.now() - u.timestamp < 1000); + if (existing) return prev; + return [...prev, { url, timestamp }]; + }); + } + break; } - break; - case "CACHE_STATS_ERROR": - const statsErrorResolver = pendingRequests.current.get("CACHE_STATS"); - if (statsErrorResolver) { - statsErrorResolver(null); - pendingRequests.current.delete("CACHE_STATS"); - } - break; + case "CACHE_STATS": + const statsResolver = pendingRequests.current.get("CACHE_STATS"); + if (statsResolver) { + statsResolver(payload); + pendingRequests.current.delete("CACHE_STATS"); + } + break; - case "CACHE_CLEARED": - const clearResolver = pendingRequests.current.get("CACHE_CLEARED"); - if (clearResolver) { - clearResolver(true); - pendingRequests.current.delete("CACHE_CLEARED"); - } - break; + case "CACHE_STATS_ERROR": + const statsErrorResolver = pendingRequests.current.get("CACHE_STATS"); + if (statsErrorResolver) { + statsErrorResolver(null); + pendingRequests.current.delete("CACHE_STATS"); + } + break; - case "CACHE_CLEAR_ERROR": - const clearErrorResolver = pendingRequests.current.get("CACHE_CLEARED"); - if (clearErrorResolver) { - clearErrorResolver(false); - pendingRequests.current.delete("CACHE_CLEARED"); - } - break; + case "CACHE_CLEARED": + const clearResolver = pendingRequests.current.get("CACHE_CLEARED"); + if (clearResolver) { + clearResolver(true); + pendingRequests.current.delete("CACHE_CLEARED"); + } + break; - case "CACHE_ENTRIES": - const entriesResolver = pendingRequests.current.get("CACHE_ENTRIES"); - if (entriesResolver) { - entriesResolver(payload.entries); - pendingRequests.current.delete("CACHE_ENTRIES"); - } - break; + case "CACHE_CLEAR_ERROR": + const clearErrorResolver = pendingRequests.current.get("CACHE_CLEARED"); + if (clearErrorResolver) { + clearErrorResolver(false); + pendingRequests.current.delete("CACHE_CLEARED"); + } + break; - case "CACHE_ENTRIES_ERROR": - const entriesErrorResolver = pendingRequests.current.get("CACHE_ENTRIES"); - if (entriesErrorResolver) { - entriesErrorResolver(null); - pendingRequests.current.delete("CACHE_ENTRIES"); + case "CACHE_ENTRIES": { + const entriesResolver = pendingRequests.current.get("CACHE_ENTRIES"); + if (entriesResolver) { + entriesResolver(payload?.entries || null); + pendingRequests.current.delete("CACHE_ENTRIES"); + } + break; } - break; + + case "CACHE_ENTRIES_ERROR": { + const entriesErrorResolver = pendingRequests.current.get("CACHE_ENTRIES"); + if (entriesErrorResolver) { + entriesErrorResolver(null); + pendingRequests.current.delete("CACHE_ENTRIES"); + } + break; + } + + default: + // Ignore unknown message types + break; + } + } catch (error) { + // Silently ignore message handling errors to prevent app crashes + // This can happen with malformed messages or during SW reinstall + if (process.env.NODE_ENV === "development") { + // eslint-disable-next-line no-console + console.warn("[SW Context] Error handling message:", error, event.data); + } } }, []); @@ -263,6 +302,40 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) { } }, []); + const reinstallServiceWorker = useCallback(async (): Promise => { + try { + // Unregister all service workers + await unregisterServiceWorker(); + setIsReady(false); + setVersion(null); + + // Re-register + const registration = await registerServiceWorker({ + onSuccess: () => { + setIsReady(true); + if (navigator.serviceWorker.controller) { + navigator.serviceWorker.controller.postMessage({ type: "GET_VERSION" }); + } + }, + onError: (error) => { + logger.error({ err: error }, "Service worker re-registration failed"); + }, + }); + + if (registration) { + // Force update check + await registration.update(); + // Reload page to ensure new SW takes control + window.location.reload(); + return true; + } + return false; + } catch (error) { + logger.error({ err: error }, "Failed to reinstall service worker"); + return false; + } + }, []); + return ( {children} diff --git a/src/i18n/messages/en/common.json b/src/i18n/messages/en/common.json index e4d324b..6594394 100644 --- a/src/i18n/messages/en/common.json +++ b/src/i18n/messages/en/common.json @@ -146,6 +146,8 @@ "imagesQuota": "{used}% of images quota used", "static": "Static resources", "staticDesc": "Next.js scripts, styles and assets", + "pages": "Visited pages", + "pagesDesc": "Home, libraries, series and details", "api": "API data", "apiDesc": "Series, books and library metadata", "images": "Images", @@ -157,6 +159,8 @@ "clearedDesc": "Cache has been cleared successfully", "clearError": "Error clearing cache", "unavailable": "Cache statistics unavailable", + "reinstall": "Reinstall Service Worker", + "reinstallError": "Error reinstalling Service Worker", "entry": "entry", "entries": "entries", "loadingEntries": "Loading entries...", diff --git a/src/i18n/messages/fr/common.json b/src/i18n/messages/fr/common.json index 06404fa..6aa21b7 100644 --- a/src/i18n/messages/fr/common.json +++ b/src/i18n/messages/fr/common.json @@ -146,6 +146,8 @@ "imagesQuota": "{used}% du quota images utilisé", "static": "Ressources statiques", "staticDesc": "Scripts, styles et assets Next.js", + "pages": "Pages visitées", + "pagesDesc": "Home, bibliothèques, séries et détails", "api": "Données API", "apiDesc": "Métadonnées des séries, livres et bibliothèques", "images": "Images", @@ -157,6 +159,8 @@ "clearedDesc": "Le cache a été vidé avec succès", "clearError": "Erreur lors du vidage du cache", "unavailable": "Statistiques du cache non disponibles", + "reinstall": "Réinstaller le Service Worker", + "reinstallError": "Erreur lors de la réinstallation du Service Worker", "entry": "entrée", "entries": "entrées", "loadingEntries": "Chargement des entrées...",