import { createHash } from 'node:crypto'; import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, unlinkSync } from 'node:fs'; import { join } from 'node:path'; export interface ResponseCacheEntry { url: string; etag?: string; lastModified?: string; cacheControl?: string; body: string; contentType: string; statusCode: number; cachedAt: number; hitCount: number; } export class ResponseCache { private readonly dir: string; private totalHits = 0; private totalRequests = 0; constructor(cacheDir: string) { this.dir = cacheDir; if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true }); } private key(url: string): string { return createHash('sha256').update(url).digest('hex').slice(0, 16); } private path(url: string): string { return join(this.dir, `${this.key(url)}.json`); } get(url: string): ResponseCacheEntry | null { this.totalRequests++; try { const p = this.path(url); if (!existsSync(p)) return null; const entry: ResponseCacheEntry = JSON.parse(readFileSync(p, 'utf-8')); this.totalHits++; return entry; } catch { return null; } } private read(url: string): ResponseCacheEntry | null { try { const p = this.path(url); if (!existsSync(p)) return null; return JSON.parse(readFileSync(p, 'utf-8')) as ResponseCacheEntry; } catch { return null; } } isFresh(url: string): boolean { const entry = this.read(url); if (!entry?.cacheControl) return false; const m = /max-age=(\d+)/i.exec(entry.cacheControl); if (!m) return false; const maxAgeMs = parseInt(m[1], 10) * 1000; return entry.cachedAt + maxAgeMs > Date.now(); } staleIfError(url: string): boolean { const entry = this.read(url); if (!entry?.cacheControl) return false; const m = /stale-if-error=(\d+)/i.exec(entry.cacheControl); if (!m) return false; const staleIfErrorMs = parseInt(m[1], 10) * 1000; return entry.cachedAt + staleIfErrorMs >= Date.now(); } set(url: string, entry: Omit): void { try { const full: ResponseCacheEntry = { ...entry, cachedAt: Date.now(), hitCount: 0 }; writeFileSync(this.path(url), JSON.stringify(full), 'utf-8'); } catch { /* non-fatal */ } } getValidationHeaders(url: string): { 'If-None-Match'?: string; 'If-Modified-Since'?: string } { const entry = this.get(url); if (!entry) return {}; const headers: { 'If-None-Match'?: string; 'If-Modified-Since'?: string } = {}; if (entry.etag) headers['If-None-Match'] = entry.etag; if (entry.lastModified) headers['If-Modified-Since'] = entry.lastModified; return headers; } markHit(url: string): void { try { const p = this.path(url); if (!existsSync(p)) return; const entry: ResponseCacheEntry = JSON.parse(readFileSync(p, 'utf-8')); entry.hitCount++; writeFileSync(p, JSON.stringify(entry), 'utf-8'); } catch { /* non-fatal */ } } invalidate(url: string): void { try { const p = this.path(url); if (existsSync(p)) unlinkSync(p); } catch { /* non-fatal */ } } clear(): void { try { for (const f of readdirSync(this.dir)) { if (f.endsWith('.json')) unlinkSync(join(this.dir, f)); } } catch { /* non-fatal */ } } stats(): { entries: number; totalSizeBytes: number; hitRate: number; totalHits: number } { let entries = 0; let totalSizeBytes = 0; try { for (const f of readdirSync(this.dir)) { if (!f.endsWith('.json')) continue; entries++; try { totalSizeBytes += statSync(join(this.dir, f)).size; } catch { /* skip */ } } } catch { /* non-fatal */ } return { entries, totalSizeBytes, hitRate: this.totalRequests > 0 ? this.totalHits / this.totalRequests : 0, totalHits: this.totalHits, }; } prune(maxAgeMs: number): number { let deleted = 0; const cutoff = Date.now() - maxAgeMs; try { for (const f of readdirSync(this.dir)) { if (!f.endsWith('.json')) continue; try { const p = join(this.dir, f); const entry: ResponseCacheEntry = JSON.parse(readFileSync(p, 'utf-8')); if (entry.cachedAt < cutoff) { unlinkSync(p); deleted++; } } catch { /* skip */ } } } catch { /* non-fatal */ } return deleted; } }