import fs from "fs"; import path from "path"; import { PreferencesService } from "./preferences.service"; import { getCurrentUser } from "../auth-utils"; 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 fiveMinutes = 5 * 60 * 1000; private static readonly tenMinutes = 10 * 60 * 1000; private static readonly twentyFourHours = 24 * 60 * 60 * 1000; private static readonly oneMinute = 1 * 60 * 1000; private static readonly oneWeek = 7 * 24 * 60 * 60 * 1000; private static readonly noCache = 0; // Configuration des temps de cache private static readonly DEFAULT_TTL = { DEFAULT: ServerCacheService.fiveMinutes, HOME: ServerCacheService.tenMinutes, LIBRARIES: ServerCacheService.twentyFourHours, SERIES: ServerCacheService.fiveMinutes, BOOKS: ServerCacheService.fiveMinutes, 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) { console.error("Error initializing cache mode from preferences:", error); // 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) { console.error(`Could not remove directory ${itemPath}:`, error); 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) { console.error(`Could not parse file ${itemPath}:`, error); // Si le fichier est corrompu, on le supprime try { fs.unlinkSync(itemPath); } catch (error) { console.error(`Could not remove file ${itemPath}:`, error); isEmpty = false; } } } else { isEmpty = false; } } catch (error) { console.error(`Could not access ${itemPath}:`, error); // 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) { console.error(`Could not parse file ${itemPath}:`, error); // Ignore les fichiers corrompus } } } catch (error) { console.error(`Could not access ${itemPath}:`, error); // 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) { console.error(`Could not write cache file ${filePath}:`, error); } } /** * 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) { console.error(`Error writing cache file ${filePath}:`, error); } } } /** * 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) { console.error(`Error reading cache file ${filePath}:`, error); 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) { console.error(`Error reading cache file ${filePath}:`, error); 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 { const cacheDir = path.join(this.cacheDir, prefixKey); if (fs.existsSync(cacheDir)) { fs.rmdirSync(cacheDir, { recursive: true }); } } } /** * 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) { console.error(`Could not remove directory ${itemPath}:`, error); } } else { try { fs.unlinkSync(itemPath); } catch (error) { console.error(`Could not remove file ${itemPath}:`, error); } } } catch (error) { console.error(`Error accessing ${itemPath}:`, error); } } }; try { removeDirectory(this.cacheDir); } catch (error) { console.error("Error clearing cache:", error); } } /** * 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'; // eslint-disable-next-line no-console console.log(`${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') { // eslint-disable-next-line no-console console.log(`❌ [CACHE MISS] ${key} | ${type}`); } try { const data = await fetcher(); this.set(cacheKey, data, type); const endTime = performance.now(); if (process.env.CACHE_DEBUG === 'true') { // eslint-disable-next-line no-console console.log(`💾 [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(); // eslint-disable-next-line no-console console.log(`🔄 [CACHE REVALIDATE] ${debugKey} | ${type} | ${(endTime - startTime).toFixed(2)}ms`); } } catch (error) { console.error(`🔴 [CACHE REVALIDATE ERROR] ${debugKey}:`, error); // 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) { console.error(`Could not access ${itemPath}:`, error); } } }; if (fs.existsSync(this.cacheDir)) { calculateDirectorySize(this.cacheDir); } return { sizeInBytes, itemCount }; } } // 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 };