feat: update service worker to version 2.4, enhance caching strategies for pages, and add service worker reinstallation functionality in CacheSettings component
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m57s
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m57s
This commit is contained in:
98
public/sw.js
98
public/sw.js
@@ -1,11 +1,11 @@
|
|||||||
// StripStream Service Worker - Version 2
|
// StripStream Service Worker - Version 2
|
||||||
// Architecture: SWR (Stale-While-Revalidate) for all resources
|
// Architecture: SWR (Stale-While-Revalidate) for all resources
|
||||||
|
|
||||||
const VERSION = "v2";
|
const VERSION = "v2.4";
|
||||||
const STATIC_CACHE = `stripstream-static-${VERSION}`;
|
const STATIC_CACHE = `stripstream-static-${VERSION}`;
|
||||||
|
const PAGES_CACHE = `stripstream-pages-${VERSION}`; // Navigation + RSC (client-side navigation)
|
||||||
const API_CACHE = `stripstream-api-${VERSION}`;
|
const API_CACHE = `stripstream-api-${VERSION}`;
|
||||||
const IMAGES_CACHE = `stripstream-images-${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 BOOKS_CACHE = "stripstream-books"; // Never version this - managed by DownloadManager
|
||||||
|
|
||||||
const OFFLINE_PAGE = "/offline.html";
|
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
|
* Used for: Page navigations
|
||||||
*/
|
*/
|
||||||
async function networkFirstStrategy(request, cacheName) {
|
async function navigationSWRStrategy(request, cacheName) {
|
||||||
const cache = await caches.open(cacheName);
|
const cache = await caches.open(cacheName);
|
||||||
|
const cached = await cache.match(request);
|
||||||
|
|
||||||
try {
|
// Start network request in background
|
||||||
const response = await fetch(request);
|
const fetchPromise = fetch(request)
|
||||||
if (response.ok) {
|
.then(async (response) => {
|
||||||
cache.put(request, response.clone());
|
if (response.ok) {
|
||||||
}
|
await cache.put(request, response.clone());
|
||||||
return response;
|
}
|
||||||
} catch (error) {
|
return response;
|
||||||
// Network failed - try cache
|
})
|
||||||
const cached = await cache.match(request);
|
.catch(() => null);
|
||||||
if (cached) {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to serve root page for SPA client-side routing
|
// Return cached version immediately if available
|
||||||
const rootPage = await cache.match("/");
|
if (cached) {
|
||||||
if (rootPage) {
|
return cached;
|
||||||
return rootPage;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Last resort: offline page
|
|
||||||
const offlinePage = await cache.match(OFFLINE_PAGE);
|
|
||||||
if (offlinePage) {
|
|
||||||
return offlinePage;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 () => {
|
(async () => {
|
||||||
// Clean up old caches, but preserve BOOKS_CACHE
|
// Clean up old caches, but preserve BOOKS_CACHE
|
||||||
const cacheNames = await caches.keys();
|
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(
|
const cachesToDelete = cacheNames.filter(
|
||||||
(name) => name.startsWith("stripstream-") && !currentCaches.includes(name)
|
(name) => name.startsWith("stripstream-") && !currentCaches.includes(name)
|
||||||
@@ -295,20 +306,23 @@ self.addEventListener("message", async (event) => {
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case "GET_CACHE_STATS": {
|
case "GET_CACHE_STATS": {
|
||||||
try {
|
try {
|
||||||
const [staticSize, apiSize, imagesSize, booksSize] = await Promise.all([
|
const [staticSize, pagesSize, apiSize, imagesSize, booksSize] = await Promise.all([
|
||||||
getCacheSize(STATIC_CACHE),
|
getCacheSize(STATIC_CACHE),
|
||||||
|
getCacheSize(PAGES_CACHE),
|
||||||
getCacheSize(API_CACHE),
|
getCacheSize(API_CACHE),
|
||||||
getCacheSize(IMAGES_CACHE),
|
getCacheSize(IMAGES_CACHE),
|
||||||
getCacheSize(BOOKS_CACHE),
|
getCacheSize(BOOKS_CACHE),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const staticCache = await caches.open(STATIC_CACHE);
|
const staticCache = await caches.open(STATIC_CACHE);
|
||||||
|
const pagesCache = await caches.open(PAGES_CACHE);
|
||||||
const apiCache = await caches.open(API_CACHE);
|
const apiCache = await caches.open(API_CACHE);
|
||||||
const imagesCache = await caches.open(IMAGES_CACHE);
|
const imagesCache = await caches.open(IMAGES_CACHE);
|
||||||
const booksCache = await caches.open(BOOKS_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(),
|
staticCache.keys(),
|
||||||
|
pagesCache.keys(),
|
||||||
apiCache.keys(),
|
apiCache.keys(),
|
||||||
imagesCache.keys(),
|
imagesCache.keys(),
|
||||||
booksCache.keys(),
|
booksCache.keys(),
|
||||||
@@ -318,10 +332,11 @@ self.addEventListener("message", async (event) => {
|
|||||||
type: "CACHE_STATS",
|
type: "CACHE_STATS",
|
||||||
payload: {
|
payload: {
|
||||||
static: { size: staticSize, entries: staticKeys.length },
|
static: { size: staticSize, entries: staticKeys.length },
|
||||||
|
pages: { size: pagesSize, entries: pagesKeys.length },
|
||||||
api: { size: apiSize, entries: apiKeys.length },
|
api: { size: apiSize, entries: apiKeys.length },
|
||||||
images: { size: imagesSize, entries: imagesKeys.length },
|
images: { size: imagesSize, entries: imagesKeys.length },
|
||||||
books: { size: booksSize, entries: booksKeys.length },
|
books: { size: booksSize, entries: booksKeys.length },
|
||||||
total: staticSize + apiSize + imagesSize + booksSize,
|
total: staticSize + pagesSize + apiSize + imagesSize + booksSize,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -341,15 +356,15 @@ self.addEventListener("message", async (event) => {
|
|||||||
if (cacheType === "all" || cacheType === "static") {
|
if (cacheType === "all" || cacheType === "static") {
|
||||||
cachesToClear.push(STATIC_CACHE);
|
cachesToClear.push(STATIC_CACHE);
|
||||||
}
|
}
|
||||||
|
if (cacheType === "all" || cacheType === "pages") {
|
||||||
|
cachesToClear.push(PAGES_CACHE);
|
||||||
|
}
|
||||||
if (cacheType === "all" || cacheType === "api") {
|
if (cacheType === "all" || cacheType === "api") {
|
||||||
cachesToClear.push(API_CACHE);
|
cachesToClear.push(API_CACHE);
|
||||||
}
|
}
|
||||||
if (cacheType === "all" || cacheType === "images") {
|
if (cacheType === "all" || cacheType === "images") {
|
||||||
cachesToClear.push(IMAGES_CACHE);
|
cachesToClear.push(IMAGES_CACHE);
|
||||||
}
|
}
|
||||||
if (cacheType === "all" || cacheType === "rsc") {
|
|
||||||
cachesToClear.push(RSC_CACHE);
|
|
||||||
}
|
|
||||||
// Note: BOOKS_CACHE is not cleared by default, only explicitly
|
// Note: BOOKS_CACHE is not cleared by default, only explicitly
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@@ -395,6 +410,9 @@ self.addEventListener("message", async (event) => {
|
|||||||
case "static":
|
case "static":
|
||||||
cacheName = STATIC_CACHE;
|
cacheName = STATIC_CACHE;
|
||||||
break;
|
break;
|
||||||
|
case "pages":
|
||||||
|
cacheName = PAGES_CACHE;
|
||||||
|
break;
|
||||||
case "api":
|
case "api":
|
||||||
cacheName = API_CACHE;
|
cacheName = API_CACHE;
|
||||||
break;
|
break;
|
||||||
@@ -477,10 +495,10 @@ self.addEventListener("fetch", (event) => {
|
|||||||
return;
|
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)) {
|
if (isNextRSCRequest(request)) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
staleWhileRevalidateStrategy(request, RSC_CACHE, {
|
staleWhileRevalidateStrategy(request, PAGES_CACHE, {
|
||||||
notifyOnChange: false,
|
notifyOnChange: false,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -515,9 +533,9 @@ self.addEventListener("fetch", (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route 6: Navigation → Network-First with SPA fallback
|
// Route 6: Navigation → SWR (cache first, revalidate in background)
|
||||||
if (request.mode === "navigate") {
|
if (request.mode === "navigate") {
|
||||||
event.respondWith(networkFirstStrategy(request, STATIC_CACHE));
|
event.respondWith(navigationSWRStrategy(request, PAGES_CACHE));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from "@/components/ui/collapsible";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import {
|
import {
|
||||||
Database,
|
Database,
|
||||||
@@ -27,10 +23,13 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
LayoutGrid,
|
||||||
|
RotateCcw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
interface CacheStats {
|
interface CacheStats {
|
||||||
static: { size: number; entries: number };
|
static: { size: number; entries: number };
|
||||||
|
pages: { size: number; entries: number };
|
||||||
api: { size: number; entries: number };
|
api: { size: number; entries: number };
|
||||||
images: { size: number; entries: number };
|
images: { size: number; entries: number };
|
||||||
books: { size: number; entries: number };
|
books: { size: number; entries: number };
|
||||||
@@ -42,7 +41,7 @@ interface CacheEntry {
|
|||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CacheType = "static" | "api" | "images" | "books";
|
type CacheType = "static" | "pages" | "api" | "images" | "books";
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
if (bytes === 0) return "0 B";
|
if (bytes === 0) return "0 B";
|
||||||
@@ -195,12 +194,20 @@ function CacheItem({
|
|||||||
export function CacheSettings() {
|
export function CacheSettings() {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { isSupported, isReady, version, getCacheStats, getCacheEntries, clearCache } =
|
const {
|
||||||
useServiceWorker();
|
isSupported,
|
||||||
|
isReady,
|
||||||
|
version,
|
||||||
|
getCacheStats,
|
||||||
|
getCacheEntries,
|
||||||
|
clearCache,
|
||||||
|
reinstallServiceWorker,
|
||||||
|
} = useServiceWorker();
|
||||||
|
|
||||||
const [stats, setStats] = useState<CacheStats | null>(null);
|
const [stats, setStats] = useState<CacheStats | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [clearingCache, setClearingCache] = useState<string | null>(null);
|
const [clearingCache, setClearingCache] = useState<string | null>(null);
|
||||||
|
const [isReinstalling, setIsReinstalling] = useState(false);
|
||||||
|
|
||||||
const loadStats = useCallback(async () => {
|
const loadStats = useCallback(async () => {
|
||||||
if (!isReady) return;
|
if (!isReady) return;
|
||||||
@@ -218,7 +225,7 @@ export function CacheSettings() {
|
|||||||
loadStats();
|
loadStats();
|
||||||
}, [loadStats]);
|
}, [loadStats]);
|
||||||
|
|
||||||
const handleClearCache = async (cacheType: "all" | "static" | "api" | "images") => {
|
const handleClearCache = async (cacheType: "all" | "static" | "pages" | "api" | "images") => {
|
||||||
setClearingCache(cacheType);
|
setClearingCache(cacheType);
|
||||||
try {
|
try {
|
||||||
const success = await clearCache(cacheType);
|
const success = await clearCache(cacheType);
|
||||||
@@ -247,6 +254,28 @@ export function CacheSettings() {
|
|||||||
[getCacheEntries]
|
[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)
|
// Calculer le pourcentage du cache utilisé (basé sur 100MB limite images)
|
||||||
const maxCacheSize = 100 * 1024 * 1024; // 100MB
|
const maxCacheSize = 100 * 1024 * 1024; // 100MB
|
||||||
const usagePercent = stats ? Math.min((stats.images.size / maxCacheSize) * 100, 100) : 0;
|
const usagePercent = stats ? Math.min((stats.images.size / maxCacheSize) * 100, 100) : 0;
|
||||||
@@ -328,6 +357,17 @@ export function CacheSettings() {
|
|||||||
isClearing={clearingCache === "static"}
|
isClearing={clearingCache === "static"}
|
||||||
onLoadEntries={handleLoadEntries}
|
onLoadEntries={handleLoadEntries}
|
||||||
/>
|
/>
|
||||||
|
<CacheItem
|
||||||
|
icon={<LayoutGrid className="h-4 w-4" />}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
<CacheItem
|
<CacheItem
|
||||||
icon={<FileJson className="h-4 w-4" />}
|
icon={<FileJson className="h-4 w-4" />}
|
||||||
label={t("settings.cache.api")}
|
label={t("settings.cache.api")}
|
||||||
@@ -365,27 +405,55 @@ export function CacheSettings() {
|
|||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-center text-muted-foreground py-8">
|
<div className="text-center py-8 space-y-4">
|
||||||
{t("settings.cache.unavailable")}
|
<p className="text-muted-foreground">{t("settings.cache.unavailable")}</p>
|
||||||
</p>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleReinstall}
|
||||||
|
disabled={isReinstalling}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{isReinstalling ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{t("settings.cache.reinstall")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bouton vider tout */}
|
{/* Bouton vider tout */}
|
||||||
{stats && stats.total > 0 && (
|
{stats && stats.total > 0 && (
|
||||||
<Button
|
<div className="space-y-2">
|
||||||
variant="destructive"
|
<Button
|
||||||
className="w-full gap-2"
|
variant="destructive"
|
||||||
onClick={() => handleClearCache("all")}
|
className="w-full gap-2"
|
||||||
disabled={clearingCache !== null}
|
onClick={() => handleClearCache("all")}
|
||||||
>
|
disabled={clearingCache !== null}
|
||||||
{clearingCache === "all" ? (
|
>
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
{clearingCache === "all" ? (
|
||||||
) : (
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
<Trash2 className="h-4 w-4" />
|
) : (
|
||||||
)}
|
<Trash2 className="h-4 w-4" />
|
||||||
{t("settings.cache.clearAll")}
|
)}
|
||||||
</Button>
|
{t("settings.cache.clearAll")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full gap-2"
|
||||||
|
onClick={handleReinstall}
|
||||||
|
disabled={isReinstalling}
|
||||||
|
>
|
||||||
|
{isReinstalling ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{t("settings.cache.reinstall")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
import { createContext, useContext, useEffect, useState, useCallback, useRef } from "react";
|
import { createContext, useContext, useEffect, useState, useCallback, useRef } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { registerServiceWorker } from "@/lib/registerSW";
|
import { registerServiceWorker, unregisterServiceWorker } from "@/lib/registerSW";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
interface CacheStats {
|
interface CacheStats {
|
||||||
static: { size: number; entries: number };
|
static: { size: number; entries: number };
|
||||||
|
pages: { size: number; entries: number };
|
||||||
api: { size: number; entries: number };
|
api: { size: number; entries: number };
|
||||||
images: { size: number; entries: number };
|
images: { size: number; entries: number };
|
||||||
books: { size: number; entries: number };
|
books: { size: number; entries: number };
|
||||||
@@ -23,7 +24,7 @@ interface CacheUpdate {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CacheType = "all" | "static" | "api" | "images" | "rsc" | "books";
|
type CacheType = "all" | "static" | "pages" | "api" | "images" | "books";
|
||||||
|
|
||||||
interface ServiceWorkerContextValue {
|
interface ServiceWorkerContextValue {
|
||||||
isSupported: boolean;
|
isSupported: boolean;
|
||||||
@@ -38,6 +39,7 @@ interface ServiceWorkerContextValue {
|
|||||||
clearCache: (cacheType?: CacheType) => Promise<boolean>;
|
clearCache: (cacheType?: CacheType) => Promise<boolean>;
|
||||||
skipWaiting: () => void;
|
skipWaiting: () => void;
|
||||||
reloadForUpdate: () => void;
|
reloadForUpdate: () => void;
|
||||||
|
reinstallServiceWorker: () => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ServiceWorkerContext = createContext<ServiceWorkerContextValue | null>(null);
|
const ServiceWorkerContext = createContext<ServiceWorkerContextValue | null>(null);
|
||||||
@@ -53,76 +55,113 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
// Handle messages from service worker
|
// Handle messages from service worker
|
||||||
const handleMessage = useCallback((event: MessageEvent) => {
|
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) {
|
// Only handle messages from our service worker (check for known message types)
|
||||||
case "SW_ACTIVATED":
|
const knownTypes = [
|
||||||
setIsReady(true);
|
"SW_ACTIVATED",
|
||||||
setVersion(payload?.version || null);
|
"SW_VERSION",
|
||||||
break;
|
"CACHE_UPDATED",
|
||||||
|
"CACHE_STATS",
|
||||||
|
"CACHE_STATS_ERROR",
|
||||||
|
"CACHE_CLEARED",
|
||||||
|
"CACHE_CLEAR_ERROR",
|
||||||
|
"CACHE_ENTRIES",
|
||||||
|
"CACHE_ENTRIES_ERROR",
|
||||||
|
];
|
||||||
|
|
||||||
case "SW_VERSION":
|
const type = event.data.type;
|
||||||
setVersion(payload?.version || null);
|
if (typeof type !== "string" || !knownTypes.includes(type)) return;
|
||||||
break;
|
|
||||||
|
|
||||||
case "CACHE_UPDATED":
|
const payload = event.data.payload;
|
||||||
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;
|
|
||||||
|
|
||||||
case "CACHE_STATS":
|
switch (type) {
|
||||||
const statsResolver = pendingRequests.current.get("CACHE_STATS");
|
case "SW_ACTIVATED":
|
||||||
if (statsResolver) {
|
setIsReady(true);
|
||||||
statsResolver(payload);
|
setVersion(payload?.version || null);
|
||||||
pendingRequests.current.delete("CACHE_STATS");
|
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":
|
case "CACHE_STATS":
|
||||||
const statsErrorResolver = pendingRequests.current.get("CACHE_STATS");
|
const statsResolver = pendingRequests.current.get("CACHE_STATS");
|
||||||
if (statsErrorResolver) {
|
if (statsResolver) {
|
||||||
statsErrorResolver(null);
|
statsResolver(payload);
|
||||||
pendingRequests.current.delete("CACHE_STATS");
|
pendingRequests.current.delete("CACHE_STATS");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "CACHE_CLEARED":
|
case "CACHE_STATS_ERROR":
|
||||||
const clearResolver = pendingRequests.current.get("CACHE_CLEARED");
|
const statsErrorResolver = pendingRequests.current.get("CACHE_STATS");
|
||||||
if (clearResolver) {
|
if (statsErrorResolver) {
|
||||||
clearResolver(true);
|
statsErrorResolver(null);
|
||||||
pendingRequests.current.delete("CACHE_CLEARED");
|
pendingRequests.current.delete("CACHE_STATS");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "CACHE_CLEAR_ERROR":
|
case "CACHE_CLEARED":
|
||||||
const clearErrorResolver = pendingRequests.current.get("CACHE_CLEARED");
|
const clearResolver = pendingRequests.current.get("CACHE_CLEARED");
|
||||||
if (clearErrorResolver) {
|
if (clearResolver) {
|
||||||
clearErrorResolver(false);
|
clearResolver(true);
|
||||||
pendingRequests.current.delete("CACHE_CLEARED");
|
pendingRequests.current.delete("CACHE_CLEARED");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "CACHE_ENTRIES":
|
case "CACHE_CLEAR_ERROR":
|
||||||
const entriesResolver = pendingRequests.current.get("CACHE_ENTRIES");
|
const clearErrorResolver = pendingRequests.current.get("CACHE_CLEARED");
|
||||||
if (entriesResolver) {
|
if (clearErrorResolver) {
|
||||||
entriesResolver(payload.entries);
|
clearErrorResolver(false);
|
||||||
pendingRequests.current.delete("CACHE_ENTRIES");
|
pendingRequests.current.delete("CACHE_CLEARED");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "CACHE_ENTRIES_ERROR":
|
case "CACHE_ENTRIES": {
|
||||||
const entriesErrorResolver = pendingRequests.current.get("CACHE_ENTRIES");
|
const entriesResolver = pendingRequests.current.get("CACHE_ENTRIES");
|
||||||
if (entriesErrorResolver) {
|
if (entriesResolver) {
|
||||||
entriesErrorResolver(null);
|
entriesResolver(payload?.entries || null);
|
||||||
pendingRequests.current.delete("CACHE_ENTRIES");
|
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<boolean> => {
|
||||||
|
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 (
|
return (
|
||||||
<ServiceWorkerContext.Provider
|
<ServiceWorkerContext.Provider
|
||||||
value={{
|
value={{
|
||||||
@@ -278,6 +351,7 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
|
|||||||
clearCache,
|
clearCache,
|
||||||
skipWaiting,
|
skipWaiting,
|
||||||
reloadForUpdate,
|
reloadForUpdate,
|
||||||
|
reinstallServiceWorker,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -146,6 +146,8 @@
|
|||||||
"imagesQuota": "{used}% of images quota used",
|
"imagesQuota": "{used}% of images quota used",
|
||||||
"static": "Static resources",
|
"static": "Static resources",
|
||||||
"staticDesc": "Next.js scripts, styles and assets",
|
"staticDesc": "Next.js scripts, styles and assets",
|
||||||
|
"pages": "Visited pages",
|
||||||
|
"pagesDesc": "Home, libraries, series and details",
|
||||||
"api": "API data",
|
"api": "API data",
|
||||||
"apiDesc": "Series, books and library metadata",
|
"apiDesc": "Series, books and library metadata",
|
||||||
"images": "Images",
|
"images": "Images",
|
||||||
@@ -157,6 +159,8 @@
|
|||||||
"clearedDesc": "Cache has been cleared successfully",
|
"clearedDesc": "Cache has been cleared successfully",
|
||||||
"clearError": "Error clearing cache",
|
"clearError": "Error clearing cache",
|
||||||
"unavailable": "Cache statistics unavailable",
|
"unavailable": "Cache statistics unavailable",
|
||||||
|
"reinstall": "Reinstall Service Worker",
|
||||||
|
"reinstallError": "Error reinstalling Service Worker",
|
||||||
"entry": "entry",
|
"entry": "entry",
|
||||||
"entries": "entries",
|
"entries": "entries",
|
||||||
"loadingEntries": "Loading entries...",
|
"loadingEntries": "Loading entries...",
|
||||||
|
|||||||
@@ -146,6 +146,8 @@
|
|||||||
"imagesQuota": "{used}% du quota images utilisé",
|
"imagesQuota": "{used}% du quota images utilisé",
|
||||||
"static": "Ressources statiques",
|
"static": "Ressources statiques",
|
||||||
"staticDesc": "Scripts, styles et assets Next.js",
|
"staticDesc": "Scripts, styles et assets Next.js",
|
||||||
|
"pages": "Pages visitées",
|
||||||
|
"pagesDesc": "Home, bibliothèques, séries et détails",
|
||||||
"api": "Données API",
|
"api": "Données API",
|
||||||
"apiDesc": "Métadonnées des séries, livres et bibliothèques",
|
"apiDesc": "Métadonnées des séries, livres et bibliothèques",
|
||||||
"images": "Images",
|
"images": "Images",
|
||||||
@@ -157,6 +159,8 @@
|
|||||||
"clearedDesc": "Le cache a été vidé avec succès",
|
"clearedDesc": "Le cache a été vidé avec succès",
|
||||||
"clearError": "Erreur lors du vidage du cache",
|
"clearError": "Erreur lors du vidage du cache",
|
||||||
"unavailable": "Statistiques du cache non disponibles",
|
"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",
|
"entry": "entrée",
|
||||||
"entries": "entrées",
|
"entries": "entrées",
|
||||||
"loadingEntries": "Chargement des entrées...",
|
"loadingEntries": "Chargement des entrées...",
|
||||||
|
|||||||
Reference in New Issue
Block a user