import fs from "fs"; import path from "path"; import { PreferencesService } from "./preferences.service"; import { getCurrentUser } from "../auth-utils"; import logger from "@/lib/logger"; export type CacheMode = "file" | "memory"; interface CacheConfig { mode: CacheMode; } class ServerCacheService { private static instance: ServerCacheService; private cacheDir: string; private memoryCache: Map = new Map(); private config: CacheConfig = { mode: "memory", }; // Configuration des temps de cache en millisecondes private static readonly oneMinute = 1 * 60 * 1000; private static readonly twoMinutes = 2 * 60 * 1000; private static readonly fiveMinutes = 5 * 60 * 1000; private static readonly tenMinutes = 10 * 60 * 1000; private static readonly twentyFourHours = 24 * 60 * 60 * 1000; private static readonly oneWeek = 7 * 24 * 60 * 60 * 1000; private static readonly noCache = 0; // Configuration des temps de cache // Optimisé pour la pagination native Komga : // - Listes paginées (SERIES, BOOKS) : TTL court (2 min) car données fraîches + progression utilisateur // - Données agrégées (HOME) : TTL moyen (10 min) car plusieurs sources // - Données statiques (LIBRARIES) : TTL long (24h) car changent rarement // - Images : TTL très long (7 jours) car immuables private static readonly DEFAULT_TTL = { DEFAULT: ServerCacheService.fiveMinutes, HOME: ServerCacheService.tenMinutes, LIBRARIES: ServerCacheService.twentyFourHours, SERIES: ServerCacheService.twoMinutes, // Listes paginées avec progression BOOKS: ServerCacheService.twoMinutes, // Listes paginées avec progression IMAGES: ServerCacheService.oneWeek, }; private constructor() { this.cacheDir = path.join(process.cwd(), ".cache"); this.ensureCacheDirectory(); this.cleanExpiredCache(); this.initializeCacheMode(); } private async initializeCacheMode(): Promise { try { const user = await getCurrentUser(); if (!user) { this.setCacheMode("memory"); return; } const preferences = await PreferencesService.getPreferences(); this.setCacheMode(preferences.cacheMode); } catch (error) { logger.error({ err: error }, "Error initializing cache mode from preferences"); // Keep default memory mode if preferences can't be loaded } } private ensureCacheDirectory(): void { if (!fs.existsSync(this.cacheDir)) { fs.mkdirSync(this.cacheDir, { recursive: true }); } } private getCacheFilePath(key: string): string { // Nettoyer la clé des caractères spéciaux et des doubles slashes const sanitizedKey = key.replace(/[<>:"|?*]/g, "_").replace(/\/+/g, "/"); const filePath = path.join(this.cacheDir, `${sanitizedKey}.json`); return filePath; } private cleanExpiredCache(): void { if (!fs.existsSync(this.cacheDir)) return; const cleanDirectory = (dirPath: string): boolean => { if (!fs.existsSync(dirPath)) return true; const items = fs.readdirSync(dirPath); let isEmpty = true; for (const item of items) { const itemPath = path.join(dirPath, item); try { const stats = fs.statSync(itemPath); if (stats.isDirectory()) { const isSubDirEmpty = cleanDirectory(itemPath); if (isSubDirEmpty) { try { fs.rmdirSync(itemPath); } catch (error) { logger.error( { err: error, path: itemPath }, `Could not remove directory ${itemPath}` ); isEmpty = false; } } else { isEmpty = false; } } else if (stats.isFile() && item.endsWith(".json")) { try { const content = fs.readFileSync(itemPath, "utf-8"); const cached = JSON.parse(content); if (cached.expiry < Date.now()) { fs.unlinkSync(itemPath); } else { isEmpty = false; } } catch (error) { logger.error({ err: error, path: itemPath }, `Could not parse file ${itemPath}`); // Si le fichier est corrompu, on le supprime try { fs.unlinkSync(itemPath); } catch (error) { logger.error({ err: error, path: itemPath }, `Could not remove file ${itemPath}`); isEmpty = false; } } } else { isEmpty = false; } } catch (error) { logger.error({ err: error, path: itemPath }, `Could not access ${itemPath}`); // En cas d'erreur sur le fichier/dossier, on continue isEmpty = false; continue; } } return isEmpty; }; cleanDirectory(this.cacheDir); } public static async getInstance(): Promise { if (!ServerCacheService.instance) { ServerCacheService.instance = new ServerCacheService(); await ServerCacheService.instance.initializeCacheMode(); } return ServerCacheService.instance; } /** * Retourne le TTL pour un type de données spécifique */ public getTTL(type: keyof typeof ServerCacheService.DEFAULT_TTL): number { // Utiliser directement la valeur par défaut return ServerCacheService.DEFAULT_TTL[type]; } public setCacheMode(mode: CacheMode): void { if (this.config.mode === mode) return; // Si on passe de mémoire à fichier, on sauvegarde le cache en mémoire if (mode === "file" && this.config.mode === "memory") { this.memoryCache.forEach((value, key) => { if (value.expiry > Date.now()) { this.saveToFile(key, value); } }); this.memoryCache.clear(); } // Si on passe de fichier à mémoire, on charge le cache fichier en mémoire else if (mode === "memory" && this.config.mode === "file") { this.loadFileCacheToMemory(); } this.config.mode = mode; } public getCacheMode(): CacheMode { return this.config.mode; } private loadFileCacheToMemory(): void { if (!fs.existsSync(this.cacheDir)) return; const loadDirectory = (dirPath: string) => { const items = fs.readdirSync(dirPath); for (const item of items) { const itemPath = path.join(dirPath, item); try { const stats = fs.statSync(itemPath); if (stats.isDirectory()) { loadDirectory(itemPath); } else if (stats.isFile() && item.endsWith(".json")) { try { const content = fs.readFileSync(itemPath, "utf-8"); const cached = JSON.parse(content); if (cached.expiry > Date.now()) { const key = path.relative(this.cacheDir, itemPath).slice(0, -5); // Remove .json this.memoryCache.set(key, cached); } } catch (error) { logger.error({ err: error, path: itemPath }, `Could not parse file ${itemPath}`); // Ignore les fichiers corrompus } } } catch (error) { logger.error({ err: error, path: itemPath }, `Could not access ${itemPath}`); // Ignore les erreurs d'accès } } }; loadDirectory(this.cacheDir); } private saveToFile(key: string, value: { data: unknown; expiry: number }): void { const filePath = this.getCacheFilePath(key); const dirPath = path.dirname(filePath); try { if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); } fs.writeFileSync(filePath, JSON.stringify(value), "utf-8"); } catch (error) { logger.error({ err: error, path: filePath }, `Could not write cache file ${filePath}`); } } /** * Met en cache des données avec une durée de vie */ set(key: string, data: any, type: keyof typeof ServerCacheService.DEFAULT_TTL = "DEFAULT"): void { const cacheData = { data, expiry: Date.now() + this.getTTL(type), }; if (this.config.mode === "memory") { this.memoryCache.set(key, cacheData); } else { const filePath = this.getCacheFilePath(key); const dirPath = path.dirname(filePath); try { if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); } fs.writeFileSync(filePath, JSON.stringify(cacheData), "utf-8"); } catch (error) { logger.error({ err: error, path: filePath }, `Error writing cache file ${filePath}`); } } } /** * Récupère des données du cache si elles sont valides */ get(key: string): any | null { if (this.config.mode === "memory") { const cached = this.memoryCache.get(key); if (!cached) return null; if (cached.expiry > Date.now()) { return cached.data; } this.memoryCache.delete(key); return null; } const filePath = this.getCacheFilePath(key); if (!fs.existsSync(filePath)) { return null; } try { const content = fs.readFileSync(filePath, "utf-8"); const cached = JSON.parse(content); if (cached.expiry > Date.now()) { return cached.data; } fs.unlinkSync(filePath); return null; } catch (error) { logger.error({ err: error, path: filePath }, `Error reading cache file ${filePath}`); return null; } } /** * Récupère des données du cache même si elles sont expirées (stale) * Retourne { data, isStale } ou null si pas de cache */ private getStale(key: string): { data: any; isStale: boolean } | null { if (this.config.mode === "memory") { const cached = this.memoryCache.get(key); if (!cached) return null; return { data: cached.data, isStale: cached.expiry <= Date.now(), }; } const filePath = this.getCacheFilePath(key); if (!fs.existsSync(filePath)) { return null; } try { const content = fs.readFileSync(filePath, "utf-8"); const cached = JSON.parse(content); return { data: cached.data, isStale: cached.expiry <= Date.now(), }; } catch (error) { logger.error({ err: error, path: filePath }, `Error reading cache file ${filePath}`); return null; } } /** * Supprime une entrée du cache */ async delete(key: string): Promise { const user = await getCurrentUser(); if (!user) { throw new Error("Utilisateur non authentifié"); } const cacheKey = `${user.id}-${key}`; if (this.config.mode === "memory") { this.memoryCache.delete(cacheKey); } else { const filePath = this.getCacheFilePath(cacheKey); if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } } } /** * Supprime toutes les entrées du cache qui commencent par un préfixe */ async deleteAll(prefix: string): Promise { const user = await getCurrentUser(); if (!user) { throw new Error("Utilisateur non authentifié"); } const prefixKey = `${user.id}-${prefix}`; if (this.config.mode === "memory") { this.memoryCache.forEach((value, key) => { if (key.startsWith(prefixKey)) { this.memoryCache.delete(key); } }); } else { // En mode fichier, parcourir récursivement tous les fichiers et supprimer ceux qui correspondent if (!fs.existsSync(this.cacheDir)) return; const deleteMatchingFiles = (dirPath: string): void => { const items = fs.readdirSync(dirPath); for (const item of items) { const itemPath = path.join(dirPath, item); try { const stats = fs.statSync(itemPath); if (stats.isDirectory()) { deleteMatchingFiles(itemPath); // Supprimer le répertoire s'il est vide après suppression des fichiers try { const remainingItems = fs.readdirSync(itemPath); if (remainingItems.length === 0) { fs.rmdirSync(itemPath); } } catch { // Ignore les erreurs de suppression de répertoire } } else if (stats.isFile() && item.endsWith(".json")) { // Extraire la clé du chemin relatif (sans l'extension .json) const relativePath = path.relative(this.cacheDir, itemPath); const key = relativePath.slice(0, -5).replace(/\\/g, "/"); // Remove .json and normalize slashes if (key.startsWith(prefixKey)) { fs.unlinkSync(itemPath); if (process.env.CACHE_DEBUG === "true") { logger.debug(`🗑️ [CACHE DELETE] ${key}`); } } } } catch (error) { logger.error({ err: error, path: itemPath }, `Could not delete cache file ${itemPath}`); } } }; deleteMatchingFiles(this.cacheDir); } } /** * Vide le cache */ clear(): void { if (this.config.mode === "memory") { this.memoryCache.clear(); return; } if (!fs.existsSync(this.cacheDir)) return; const removeDirectory = (dirPath: string) => { if (!fs.existsSync(dirPath)) return; const items = fs.readdirSync(dirPath); for (const item of items) { const itemPath = path.join(dirPath, item); try { const stats = fs.statSync(itemPath); if (stats.isDirectory()) { removeDirectory(itemPath); try { fs.rmdirSync(itemPath); } catch (error) { logger.error( { err: error, path: itemPath }, `Could not remove directory ${itemPath}` ); } } else { try { fs.unlinkSync(itemPath); } catch (error) { logger.error({ err: error, path: itemPath }, `Could not remove file ${itemPath}`); } } } catch (error) { logger.error({ err: error, path: itemPath }, `Error accessing ${itemPath}`); } } }; try { removeDirectory(this.cacheDir); } catch (error) { logger.error({ err: error }, "Error clearing cache"); } } /** * Récupère des données du cache ou exécute la fonction si nécessaire * Stratégie stale-while-revalidate: * - Cache valide → retourne immédiatement * - Cache expiré → retourne le cache expiré ET revalide en background * - Pas de cache → fetch normalement */ async getOrSet( key: string, fetcher: () => Promise, type: keyof typeof ServerCacheService.DEFAULT_TTL = "DEFAULT" ): Promise { const startTime = performance.now(); const user = await getCurrentUser(); if (!user) { throw new Error("Utilisateur non authentifié"); } const cacheKey = `${user.id}-${key}`; const cachedResult = this.getStale(cacheKey); if (cachedResult !== null) { const { data, isStale } = cachedResult; const endTime = performance.now(); // Debug logging if (process.env.CACHE_DEBUG === "true") { const icon = isStale ? "⚠️" : "✅"; const status = isStale ? "STALE" : "HIT"; logger.debug( `${icon} [CACHE ${status}] ${key} | ${type} | ${(endTime - startTime).toFixed(2)}ms` ); } // Si le cache est expiré, revalider en background sans bloquer la réponse if (isStale) { // Fire and forget - revalidate en background this.revalidateInBackground(cacheKey, fetcher, type, key); } return data as T; } // Pas de cache du tout, fetch normalement if (process.env.CACHE_DEBUG === "true") { logger.debug(`❌ [CACHE MISS] ${key} | ${type}`); } try { const data = await fetcher(); this.set(cacheKey, data, type); const endTime = performance.now(); if (process.env.CACHE_DEBUG === "true") { logger.debug(`💾 [CACHE SET] ${key} | ${type} | ${(endTime - startTime).toFixed(2)}ms`); } return data; } catch (error) { throw error; } } /** * Revalide le cache en background */ private async revalidateInBackground( cacheKey: string, fetcher: () => Promise, type: keyof typeof ServerCacheService.DEFAULT_TTL, debugKey: string ): Promise { try { const startTime = performance.now(); const data = await fetcher(); this.set(cacheKey, data, type); if (process.env.CACHE_DEBUG === "true") { const endTime = performance.now(); logger.debug( `🔄 [CACHE REVALIDATE] ${debugKey} | ${type} | ${(endTime - startTime).toFixed(2)}ms` ); } } catch (error) { logger.error({ err: error, key: debugKey }, `🔴 [CACHE REVALIDATE ERROR] ${debugKey}`); // Ne pas relancer l'erreur car c'est en background } } invalidate(key: string): void { this.delete(key); } /** * Calcule la taille approximative d'un objet en mémoire */ private calculateObjectSize(obj: unknown): number { if (obj === null || obj === undefined) return 0; // Si c'est un Buffer, utiliser sa taille réelle if (Buffer.isBuffer(obj)) { return obj.length; } // Si c'est un objet avec une propriété buffer (comme ImageResponse) if (typeof obj === "object" && obj !== null) { const objAny = obj as any; if (objAny.buffer && Buffer.isBuffer(objAny.buffer)) { // Taille du buffer + taille approximative des autres propriétés let size = objAny.buffer.length; // Ajouter la taille du contentType si présent if (objAny.contentType && typeof objAny.contentType === "string") { size += objAny.contentType.length * 2; // UTF-16 } return size; } } // Pour les autres types, utiliser JSON.stringify comme approximation try { return JSON.stringify(obj).length * 2; // x2 pour UTF-16 } catch { // Si l'objet n'est pas sérialisable, retourner une estimation return 1000; // 1KB par défaut } } /** * Calcule la taille du cache */ async getCacheSize(): Promise<{ sizeInBytes: number; itemCount: number }> { if (this.config.mode === "memory") { // Calculer la taille approximative en mémoire let sizeInBytes = 0; let itemCount = 0; this.memoryCache.forEach((value) => { if (value.expiry > Date.now()) { itemCount++; // Calculer la taille du data + expiry (8 bytes pour le timestamp) sizeInBytes += this.calculateObjectSize(value.data) + 8; } }); return { sizeInBytes, itemCount }; } // Calculer la taille du cache sur disque let sizeInBytes = 0; let itemCount = 0; const calculateDirectorySize = (dirPath: string): void => { if (!fs.existsSync(dirPath)) return; const items = fs.readdirSync(dirPath); for (const item of items) { const itemPath = path.join(dirPath, item); try { const stats = fs.statSync(itemPath); if (stats.isDirectory()) { calculateDirectorySize(itemPath); } else if (stats.isFile() && item.endsWith(".json")) { sizeInBytes += stats.size; itemCount++; } } catch (error) { logger.error({ err: error, path: itemPath }, `Could not access ${itemPath}`); } } }; if (fs.existsSync(this.cacheDir)) { calculateDirectorySize(this.cacheDir); } return { sizeInBytes, itemCount }; } /** * Liste les entrées du cache avec leurs détails */ async getCacheEntries(): Promise< Array<{ key: string; size: number; expiry: number; isExpired: boolean; }> > { const entries: Array<{ key: string; size: number; expiry: number; isExpired: boolean; }> = []; if (this.config.mode === "memory") { this.memoryCache.forEach((value, key) => { const size = this.calculateObjectSize(value.data) + 8; entries.push({ key, size, expiry: value.expiry, isExpired: value.expiry <= Date.now(), }); }); } else { const collectEntries = (dirPath: string): void => { if (!fs.existsSync(dirPath)) return; const items = fs.readdirSync(dirPath); for (const item of items) { const itemPath = path.join(dirPath, item); try { const stats = fs.statSync(itemPath); if (stats.isDirectory()) { collectEntries(itemPath); } else if (stats.isFile() && item.endsWith(".json")) { try { const content = fs.readFileSync(itemPath, "utf-8"); const cached = JSON.parse(content); const key = path.relative(this.cacheDir, itemPath).slice(0, -5); entries.push({ key, size: stats.size, expiry: cached.expiry, isExpired: cached.expiry <= Date.now(), }); } catch (error) { logger.error({ err: error, path: itemPath }, `Could not parse file ${itemPath}`); } } } catch (error) { logger.error({ err: error, path: itemPath }, `Could not access ${itemPath}`); } } }; if (fs.existsSync(this.cacheDir)) { collectEntries(this.cacheDir); } } return entries.sort((a, b) => b.expiry - a.expiry); } } // Créer une instance initialisée du service let initializedInstance: Promise; export const getServerCacheService = async (): Promise => { if (!initializedInstance) { initializedInstance = ServerCacheService.getInstance(); } return initializedInstance; }; // Exporter aussi la classe pour les tests export { ServerCacheService };