feat: enhance service worker caching strategies and implement offline accessibility checks for books

This commit is contained in:
Julien Froidefond
2025-10-19 20:23:37 +02:00
parent d3860ce7cc
commit bc3da12fbb
8 changed files with 417 additions and 240 deletions

View File

@@ -1,230 +1,240 @@
const CACHE_NAME = "stripstream-cache-v3";
const IMAGES_CACHE_NAME = "stripstream-images-v3";
// 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"];
const STATIC_ASSETS = [
"/offline.html",
"/manifest.json",
"/favicon.svg",
"/images/icons/icon-192x192.png",
"/images/icons/icon-512x512.png",
];
// ============================================================================
// Utility Functions - Request Detection
// ============================================================================
// 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;
}
};
function isNextStaticResource(url) {
return url.includes("/_next/static/");
}
// Installation du service worker
self.addEventListener("install", (event) => {
event.waitUntil(
Promise.all([
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)),
caches.open(IMAGES_CACHE_NAME),
])
);
});
function isImageRequest(url) {
return url.includes("/api/komga/images/");
}
// Fonction pour nettoyer les doublons dans un cache
const cleanDuplicatesInCache = async (cacheName) => {
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 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);
};
const cached = await cache.match(request, options);
// Activation et nettoyage des anciens caches
self.addEventListener("activate", (event) => {
event.waitUntil(
Promise.all([
// Supprimer les anciens caches
caches.keys().then((cacheNames) => {
return Promise.all(
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),
])
);
});
// Fonction pour vérifier si c'est une ressource webpack
const isWebpackResource = (url) => {
return (
url.includes("/_next/webpack") ||
url.includes("webpack-hmr") ||
url.includes("webpack.js") ||
url.includes("webpack-runtime") ||
url.includes("hot-update")
);
};
// Fonction pour vérifier si c'est une ressource statique de Next.js
const isNextStaticResource = (url) => {
return url.includes("/_next/static") && !isWebpackResource(url);
};
// Fonction pour vérifier si c'est une image (couvertures ou pages de livres)
const isImageResource = (url) => {
return (
(url.includes("/api/v1/books/") && (url.includes("/pages") || url.includes("/thumbnail") || url.includes("/cover"))) ||
(url.includes("/api/komga/images/") && (url.includes("/series/") || url.includes("/books/")) && url.includes("/thumbnail"))
);
};
// 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
const imageCacheStrategy = async (request) => {
const cache = await caches.open(IMAGES_CACHE_NAME);
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
if (cached) {
return cached;
}
try {
const response = await fetch(request);
if (response.ok) {
await cache.put(request, response.clone());
return response;
cache.put(request, response.clone());
}
// Si 404, retourner une réponse vide sans throw (pas d'erreur console)
if (response.status === 404) {
return new Response("", {
status: 404,
statusText: "Not Found",
headers: {
"Content-Type": "text/plain",
},
});
}
// Pour les autres erreurs, throw
throw new Error(`Network response error: ${response.status}`);
return response;
} catch (error) {
// Erreurs réseau (offline, timeout, etc.)
console.warn("Image fetch failed:", error);
return new Response("", {
status: 503,
statusText: "Service Unavailable",
headers: {
"Content-Type": "text/plain",
},
});
// 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) => {
// Ignorer les requêtes non GET
if (event.request.method !== "GET") return;
const { request } = event;
const { method } = request;
const url = new URL(request.url);
// Ignorer les ressources webpack
if (isWebpackResource(event.request.url)) return;
// Gérer les images avec Cache-First
if (isImageResource(event.request.url)) {
event.respondWith(imageCacheStrategy(event.request));
// Only handle GET requests
if (method !== "GET") {
return;
}
// Pour les ressources statiques de Next.js et les autres requêtes : Network-First
event.respondWith(
fetch(event.request)
.then(async (response) => {
// Mettre en cache les ressources statiques de Next.js et les pages
if (
response.ok &&
(isNextStaticResource(event.request.url) || event.request.mode === "navigate")
) {
const responseToCache = response.clone();
const cache = await caches.open(CACHE_NAME);
// 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;
})
.catch(async () => {
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(event.request);
// Route 1: Images → Cache-First with ignoreSearch
if (isImageRequest(url.href)) {
event.respondWith(cacheFirstStrategy(request, IMAGES_CACHE, { ignoreSearch: true }));
return;
}
if (cachedResponse) {
return cachedResponse;
}
// Route 2: Next.js RSC payloads → Stale-While-Revalidate
if (isNextRSCRequest(request)) {
event.respondWith(staleWhileRevalidateStrategy(request, RSC_CACHE));
return;
}
// Si c'est une navigation, renvoyer la page hors ligne
if (event.request.mode === "navigate") {
return cache.match(OFFLINE_PAGE);
}
// Route 3: API data → Stale-While-Revalidate (if cacheable)
if (isApiDataRequest(url.href) && shouldCacheApiData(url.href)) {
event.respondWith(staleWhileRevalidateStrategy(request, DATA_CACHE));
return;
}
return new Response(JSON.stringify({ error: "Hors ligne" }), {
status: 503,
headers: { "Content-Type": "application/json" },
});
})
);
// 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
});