feat: enhance service worker caching strategies and implement offline accessibility checks for books
This commit is contained in:
420
public/sw.js
420
public/sw.js
@@ -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
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user