Files
stripstream/src/lib/services/server-cache.service.ts

731 lines
22 KiB
TypeScript

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<string, { data: unknown; expiry: number }> = 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<void> {
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<ServerCacheService> {
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<void> {
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<void> {
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<T>(
key: string,
fetcher: () => Promise<T>,
type: keyof typeof ServerCacheService.DEFAULT_TTL = "DEFAULT"
): Promise<T> {
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<T>(
cacheKey: string,
fetcher: () => Promise<T>,
type: keyof typeof ServerCacheService.DEFAULT_TTL,
debugKey: string
): Promise<void> {
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<ServerCacheService>;
export const getServerCacheService = async (): Promise<ServerCacheService> => {
if (!initializedInstance) {
initializedInstance = ServerCacheService.getInstance();
}
return initializedInstance;
};
// Exporter aussi la classe pour les tests
export { ServerCacheService };