/** * Cache Plugin * * Caches GET request responses to reduce load on Elasticsearch */ import { defaultLogger } from '../../observability/logger.js'; import type { Plugin, PluginContext, PluginResponse, CachePluginConfig } from '../types.js'; /** * Cache entry */ interface CacheEntry { response: PluginResponse; cachedAt: number; expiresAt: number; hits: number; } /** * Default configuration */ const DEFAULT_CONFIG: Required = { enabled: true, maxEntries: 1000, defaultTTL: 60, // 60 seconds keyGenerator: (context: PluginContext) => { const query = context.request.querystring ? JSON.stringify(context.request.querystring) : ''; const body = context.request.body ? JSON.stringify(context.request.body) : ''; return `${context.request.method}:${context.request.path}:${query}:${body}`; }, methods: ['GET'], }; /** * Create cache plugin */ export function createCachePlugin(config: CachePluginConfig = {}): Plugin { const pluginConfig: Required = { ...DEFAULT_CONFIG, ...config, keyGenerator: config.keyGenerator || DEFAULT_CONFIG.keyGenerator, }; const logger = defaultLogger; const cache = new Map(); let cacheHits = 0; let cacheMisses = 0; /** * Get from cache */ function getFromCache(key: string): PluginResponse | null { const entry = cache.get(key) as CacheEntry | undefined; if (!entry) { cacheMisses++; return null; } // Check expiration const now = Date.now(); if (now >= entry.expiresAt) { cache.delete(key); cacheMisses++; return null; } // Update stats entry.hits++; cacheHits++; logger.debug('Cache hit', { key, age: now - entry.cachedAt, hits: entry.hits, }); return entry.response; } /** * Set in cache */ function setInCache(key: string, response: PluginResponse, ttl: number): void { // Check if cache is full if (cache.size >= pluginConfig.maxEntries && !cache.has(key)) { evictOldest(); } const now = Date.now(); cache.set(key, { response, cachedAt: now, expiresAt: now + ttl * 1000, hits: 0, }); logger.debug('Cache set', { key, ttl }); } /** * Evict oldest entry */ function evictOldest(): void { let oldestKey: string | null = null; let oldestTime = Infinity; for (const [key, entry] of cache) { if (entry.cachedAt < oldestTime) { oldestTime = entry.cachedAt; oldestKey = key; } } if (oldestKey) { cache.delete(oldestKey); logger.debug('Cache evicted', { key: oldestKey }); } } /** * Clear cache */ function clearCache(): void { cache.clear(); cacheHits = 0; cacheMisses = 0; logger.info('Cache cleared'); } /** * Clean expired entries */ function cleanExpired(): void { const now = Date.now(); let cleaned = 0; for (const [key, entry] of cache) { if (now >= entry.expiresAt) { cache.delete(key); cleaned++; } } if (cleaned > 0) { logger.debug('Cache cleaned', { expired: cleaned }); } } // Periodic cleanup let cleanupTimer: NodeJS.Timeout; return { name: 'cache', version: '1.0.0', priority: 50, // Execute in the middle initialize: () => { // Start periodic cleanup cleanupTimer = setInterval(cleanExpired, 60000); // Every minute logger.info('Cache plugin initialized', { maxEntries: pluginConfig.maxEntries, defaultTTL: pluginConfig.defaultTTL, methods: pluginConfig.methods, }); }, beforeRequest: (context: PluginContext): PluginContext | null => { if (!pluginConfig.enabled) { return context; } // Only cache configured methods if (!pluginConfig.methods.includes(context.request.method)) { return context; } // Generate cache key const cacheKey = pluginConfig.keyGenerator(context); // Check cache const cachedResponse = getFromCache(cacheKey); if (cachedResponse) { // Store cached response in shared data for afterResponse to use context.shared.set('cache_hit', true); context.shared.set('cached_response', cachedResponse); context.shared.set('cache_key', cacheKey); } else { context.shared.set('cache_hit', false); context.shared.set('cache_key', cacheKey); } return context; }, afterResponse: (context: PluginContext, response: PluginResponse) => { if (!pluginConfig.enabled) { return response; } const cacheHit = context.shared.get('cache_hit'); // If it was a cache hit, return the cached response if (cacheHit) { return context.shared.get('cached_response') as PluginResponse; } // Otherwise, cache this response const cacheKey = context.shared.get('cache_key') as string; if (cacheKey && pluginConfig.methods.includes(context.request.method)) { // Only cache successful responses if (response.statusCode >= 200 && response.statusCode < 300) { setInCache(cacheKey, response, pluginConfig.defaultTTL); } } return response; }, destroy: () => { if (cleanupTimer) { clearInterval(cleanupTimer); } clearCache(); logger.info('Cache plugin destroyed', { totalHits: cacheHits, totalMisses: cacheMisses, hitRatio: cacheHits / (cacheHits + cacheMisses) || 0, }); }, }; } /** * Get cache statistics */ export function getCacheStats(plugin: Plugin): { size: number; hits: number; misses: number; hitRatio: number; } | null { if (plugin.name !== 'cache') { return null; } // This would require exposing stats from the plugin // For now, return null return null; }