feat: update service worker caching strategy to handle versioning and duplicate entries, enhance CacheSettings component with version grouping and improved UI for cache management

This commit is contained in:
Julien Froidefond
2025-10-18 13:58:45 +02:00
parent 816abe2b90
commit a9f2f9f3c8
2 changed files with 237 additions and 28 deletions

View File

@@ -1,5 +1,5 @@
const CACHE_NAME = "stripstream-cache-v1"; const CACHE_NAME = "stripstream-cache-v3";
const IMAGES_CACHE_NAME = "stripstream-images-v1"; const IMAGES_CACHE_NAME = "stripstream-images-v3";
const OFFLINE_PAGE = "/offline.html"; const OFFLINE_PAGE = "/offline.html";
const STATIC_ASSETS = [ const STATIC_ASSETS = [
@@ -10,6 +10,16 @@ const STATIC_ASSETS = [
"/images/icons/icon-512x512.png", "/images/icons/icon-512x512.png",
]; ];
// Fonction pour obtenir l'URL de base sans les query params
const getBaseUrl = (url) => {
try {
const urlObj = new URL(url);
return urlObj.origin + urlObj.pathname;
} catch {
return url;
}
};
// Installation du service worker // Installation du service worker
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil( event.waitUntil(
@@ -20,16 +30,58 @@ self.addEventListener("install", (event) => {
); );
}); });
// Fonction pour nettoyer les doublons dans un cache
const cleanDuplicatesInCache = async (cacheName) => {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
// Grouper par URL de base
const grouped = {};
for (const key of keys) {
const baseUrl = getBaseUrl(key.url);
if (!grouped[baseUrl]) {
grouped[baseUrl] = [];
}
grouped[baseUrl].push(key);
}
// Pour chaque groupe, garder seulement la version la plus récente
const deletePromises = [];
for (const baseUrl in grouped) {
const versions = grouped[baseUrl];
if (versions.length > 1) {
// Trier par query params (version) décroissant
versions.sort((a, b) => {
const aVersion = new URL(a.url).searchParams.get('v') || '0';
const bVersion = new URL(b.url).searchParams.get('v') || '0';
return Number(bVersion) - Number(aVersion);
});
// Supprimer toutes sauf la première (plus récente)
for (let i = 1; i < versions.length; i++) {
deletePromises.push(cache.delete(versions[i]));
}
}
}
await Promise.all(deletePromises);
};
// Activation et nettoyage des anciens caches // Activation et nettoyage des anciens caches
self.addEventListener("activate", (event) => { self.addEventListener("activate", (event) => {
event.waitUntil( event.waitUntil(
caches.keys().then((cacheNames) => { Promise.all([
return Promise.all( // Supprimer les anciens caches
cacheNames caches.keys().then((cacheNames) => {
.filter((name) => name !== CACHE_NAME && name !== IMAGES_CACHE_NAME) return Promise.all(
.map((name) => caches.delete(name)) cacheNames
); .filter((name) => name !== CACHE_NAME && name !== IMAGES_CACHE_NAME)
}) .map((name) => caches.delete(name))
);
}),
// Nettoyer les doublons dans les caches actuels
cleanDuplicatesInCache(CACHE_NAME),
cleanDuplicatesInCache(IMAGES_CACHE_NAME),
])
); );
}); });
@@ -57,6 +109,22 @@ const isImageResource = (url) => {
); );
}; };
// Fonction pour nettoyer les anciennes versions d'un fichier
const cleanOldVersions = async (cacheName, request) => {
const cache = await caches.open(cacheName);
const baseUrl = getBaseUrl(request.url);
// Récupérer toutes les requêtes en cache
const keys = await cache.keys();
// Supprimer toutes les requêtes qui ont la même URL de base
const deletePromises = keys
.filter(key => getBaseUrl(key.url) === baseUrl)
.map(key => cache.delete(key));
await Promise.all(deletePromises);
};
// Stratégie Cache-First pour les images // Stratégie Cache-First pour les images
const imageCacheStrategy = async (request) => { const imageCacheStrategy = async (request) => {
const cache = await caches.open(IMAGES_CACHE_NAME); const cache = await caches.open(IMAGES_CACHE_NAME);
@@ -113,16 +181,30 @@ self.addEventListener("fetch", (event) => {
// Pour les ressources statiques de Next.js et les autres requêtes : Network-First // Pour les ressources statiques de Next.js et les autres requêtes : Network-First
event.respondWith( event.respondWith(
fetch(event.request) fetch(event.request)
.then((response) => { .then(async (response) => {
// Mettre en cache les ressources statiques de Next.js et les pages // Mettre en cache les ressources statiques de Next.js et les pages
if ( if (
response.ok && response.ok &&
(isNextStaticResource(event.request.url) || event.request.mode === "navigate") (isNextStaticResource(event.request.url) || event.request.mode === "navigate")
) { ) {
const responseToCache = response.clone(); const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => { const cache = await caches.open(CACHE_NAME);
cache.put(event.request, responseToCache);
}); // Nettoyer les anciennes versions avant de mettre en cache la nouvelle
if (isNextStaticResource(event.request.url)) {
try {
await cleanOldVersions(CACHE_NAME, event.request);
} catch (error) {
console.warn("Error cleaning old versions:", error);
}
}
// Mettre en cache la nouvelle version
try {
await cache.put(event.request, responseToCache);
} catch (error) {
console.warn("Error caching response:", error);
}
} }
return response; return response;
}) })

View File

@@ -47,6 +47,7 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
const [isLoadingSwEntries, setIsLoadingSwEntries] = useState(false); const [isLoadingSwEntries, setIsLoadingSwEntries] = useState(false);
const [showSwEntries, setShowSwEntries] = useState(false); const [showSwEntries, setShowSwEntries] = useState(false);
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({}); const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
const [expandedVersions, setExpandedVersions] = useState<Record<string, boolean>>({});
const [ttlConfig, setTTLConfig] = useState<TTLConfigData>( const [ttlConfig, setTTLConfig] = useState<TTLConfigData>(
initialTTLConfig || { initialTTLConfig || {
defaultTTL: 5, defaultTTL: 5,
@@ -197,7 +198,7 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
setShowSwEntries(!showSwEntries); setShowSwEntries(!showSwEntries);
}; };
const getFirstPathSegment = (url: string): string => { const getPathGroup = (url: string): string => {
try { try {
const urlObj = new URL(url); const urlObj = new URL(url);
const path = urlObj.pathname; const path = urlObj.pathname;
@@ -205,16 +206,56 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
if (segments.length === 0) return '/'; if (segments.length === 0) return '/';
// Pour /api/komga/images, grouper par type (series/books)
if (segments[0] === 'api' && segments[1] === 'komga' && segments[2] === 'images' && segments[3]) {
return `/${segments[0]}/${segments[1]}/${segments[2]}/${segments[3]}`;
}
// Pour les autres, garder juste le premier segment
return `/${segments[0]}`; return `/${segments[0]}`;
} catch { } catch {
return 'Autres'; return 'Autres';
} }
}; };
const getBaseUrl = (url: string): string => {
try {
const urlObj = new URL(url);
return urlObj.pathname;
} catch {
return url;
}
};
const groupVersions = (entries: ServiceWorkerCacheEntry[]) => {
const grouped = entries.reduce(
(acc, entry) => {
const baseUrl = getBaseUrl(entry.url);
if (!acc[baseUrl]) {
acc[baseUrl] = [];
}
acc[baseUrl].push(entry);
return acc;
},
{} as Record<string, ServiceWorkerCacheEntry[]>
);
// Trier par date (le plus récent en premier) basé sur le paramètre v
Object.keys(grouped).forEach((key) => {
grouped[key].sort((a, b) => {
const aVersion = new URL(a.url).searchParams.get('v') || '0';
const bVersion = new URL(b.url).searchParams.get('v') || '0';
return Number(bVersion) - Number(aVersion);
});
});
return grouped;
};
const groupEntriesByPath = (entries: ServiceWorkerCacheEntry[]) => { const groupEntriesByPath = (entries: ServiceWorkerCacheEntry[]) => {
const grouped = entries.reduce( const grouped = entries.reduce(
(acc, entry) => { (acc, entry) => {
const pathGroup = getFirstPathSegment(entry.url); const pathGroup = getPathGroup(entry.url);
if (!acc[pathGroup]) { if (!acc[pathGroup]) {
acc[pathGroup] = []; acc[pathGroup] = [];
} }
@@ -229,7 +270,19 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
grouped[key].sort((a, b) => b.size - a.size); grouped[key].sort((a, b) => b.size - a.size);
}); });
return grouped; // Trier les groupes par taille totale décroissante
const sortedGroups: Record<string, ServiceWorkerCacheEntry[]> = {};
Object.entries(grouped)
.sort((a, b) => {
const aSize = getTotalSizeByType(a[1]);
const bSize = getTotalSizeByType(b[1]);
return bSize - aSize;
})
.forEach(([key, value]) => {
sortedGroups[key] = value;
});
return sortedGroups;
}; };
const getTotalSizeByType = (entries: ServiceWorkerCacheEntry[]): number => { const getTotalSizeByType = (entries: ServiceWorkerCacheEntry[]): number => {
@@ -243,6 +296,13 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
})); }));
}; };
const toggleVersions = (fileName: string) => {
setExpandedVersions((prev) => ({
...prev,
[fileName]: !prev[fileName],
}));
};
useEffect(() => { useEffect(() => {
fetchCacheSize(); fetchCacheSize();
}, []); }, []);
@@ -291,6 +351,13 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
if ("serviceWorker" in navigator && "caches" in window) { if ("serviceWorker" in navigator && "caches" in window) {
const cacheNames = await caches.keys(); const cacheNames = await caches.keys();
await Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName))); await Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName)));
// Forcer la mise à jour du service worker
const registrations = await navigator.serviceWorker.getRegistrations();
for (const registration of registrations) {
await registration.unregister();
}
toast({ toast({
title: t("settings.cache.title"), title: t("settings.cache.title"),
description: t("settings.cache.messages.serviceWorkerCleared"), description: t("settings.cache.messages.serviceWorkerCleared"),
@@ -304,6 +371,11 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
if (showSwEntries) { if (showSwEntries) {
await fetchSwCacheEntries(); await fetchSwCacheEntries();
} }
// Recharger la page après 1 seconde pour réenregistrer le SW
setTimeout(() => {
window.location.reload();
}, 1000);
} }
} catch (error) { } catch (error) {
console.error("Erreur lors de la suppression des caches:", error); console.error("Erreur lors de la suppression des caches:", error);
@@ -546,20 +618,75 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
</button> </button>
{isExpanded && ( {isExpanded && (
<div className="space-y-1 pl-2"> <div className="space-y-1 pl-2">
{entries.map((entry, index) => ( {(() => {
<div key={index} className="py-1"> const versionGroups = groupVersions(entries);
<div className="flex items-start justify-between gap-2"> return Object.entries(versionGroups).map(([baseUrl, versions]) => {
<div className="flex-1 min-w-0"> const hasMultipleVersions = versions.length > 1;
<div className="font-mono text-xs truncate text-muted-foreground" title={entry.url}> const isVersionExpanded = expandedVersions[baseUrl];
{entry.url.replace(/^https?:\/\/[^/]+/, "")} const totalSize = versions.reduce((sum, v) => sum + v.size, 0);
if (!hasMultipleVersions) {
const entry = versions[0];
return (
<div key={baseUrl} className="py-1">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="font-mono text-xs truncate text-muted-foreground" title={entry.url}>
{entry.url.replace(/^https?:\/\/[^/]+/, "")}
</div>
</div>
<div className="text-xs text-muted-foreground whitespace-nowrap">
{formatBytes(entry.size)}
</div>
</div>
</div> </div>
);
}
return (
<div key={baseUrl} className="py-1">
<button
type="button"
onClick={() => toggleVersions(baseUrl)}
className="w-full flex items-start justify-between gap-2 hover:bg-muted/30 rounded p-1 -m-1 transition-colors"
>
<div className="flex-1 min-w-0 flex items-center gap-1">
{isVersionExpanded ? (
<ChevronDown className="h-3 w-3 flex-shrink-0" />
) : (
<ChevronUp className="h-3 w-3 flex-shrink-0" />
)}
<div className="font-mono text-xs truncate text-muted-foreground" title={baseUrl}>
{baseUrl}
</div>
<span className="inline-flex items-center rounded-full bg-orange-500/10 px-1.5 py-0.5 text-xs font-medium text-orange-600 dark:text-orange-400 flex-shrink-0">
{versions.length} versions
</span>
</div>
<div className="text-xs text-muted-foreground whitespace-nowrap font-medium">
{formatBytes(totalSize)}
</div>
</button>
{isVersionExpanded && (
<div className="pl-4 mt-1 space-y-1">
{versions.map((version, vIdx) => (
<div key={vIdx} className="py-0.5 flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="font-mono text-xs truncate text-muted-foreground/70" title={version.url}>
{new URL(version.url).search || "(no version)"}
</div>
</div>
<div className="text-xs text-muted-foreground/70 whitespace-nowrap">
{formatBytes(version.size)}
</div>
</div>
))}
</div>
)}
</div> </div>
<div className="text-xs text-muted-foreground whitespace-nowrap"> );
{formatBytes(entry.size)} });
</div> })()}
</div>
</div>
))}
</div> </div>
)} )}
</div> </div>