cache.ts•4 kB
import { CacheEntry, FeedResult } from '../types.js';
import { config } from '../config.js';
import { logger } from '../logger.js';
export class FeedCache {
  private cache: Map<string, CacheEntry> = new Map();
  private accessOrder: string[] = [];
  private cleanupInterval?: NodeJS.Timeout;
  
  constructor() {
    // Start cleanup interval
    this.cleanupInterval = setInterval(() => {
      this.cleanup();
    }, config.rssCacheCleanupInterval);
  }
  
  /**
   * Get a cached feed if available and not expired
   */
  get(url: string): FeedResult | null {
    const entry = this.cache.get(url);
    if (!entry) return null;
    
    if (Date.now() > entry.expiresAt) {
      this.cache.delete(url);
      this.removeFromAccessOrder(url);
      return null;
    }
    
    // Update access order (LRU)
    this.updateAccessOrder(url);
    
    return entry.data;
  }
  
  /**
   * Get cache metadata without the full data
   */
  getMetadata(url: string): { etag?: string; lastModified?: string } | null {
    const entry = this.cache.get(url);
    if (!entry) return null;
    
    return {
      etag: entry.etag,
      lastModified: entry.lastModified,
    };
  }
  
  /**
   * Store a feed in cache
   */
  set(url: string, data: FeedResult, ttl?: number): void {
    const expiresAt = Date.now() + (ttl || config.rssCacheTTL);
    
    this.cache.set(url, {
      data,
      expiresAt,
      etag: data.etag,
      lastModified: data.lastModified,
    });
    
    this.updateAccessOrder(url);
    
    // Enforce max cache size
    if (this.cache.size > config.rssCacheMaxSize) {
      this.evictLRU();
    }
    
    logger.debug(`Cached feed: ${url}, expires at: ${new Date(expiresAt).toISOString()}`);
  }
  
  /**
   * Update existing cache entry with new data
   */
  update(url: string, data: FeedResult): void {
    const existing = this.cache.get(url);
    if (existing) {
      existing.data = data;
      existing.etag = data.etag;
      existing.lastModified = data.lastModified;
      logger.debug(`Updated cached feed: ${url}`);
    }
  }
  
  /**
   * Check if a feed is cached (regardless of expiration)
   */
  has(url: string): boolean {
    return this.cache.has(url);
  }
  
  /**
   * Remove a feed from cache
   */
  delete(url: string): void {
    this.cache.delete(url);
    this.removeFromAccessOrder(url);
  }
  
  /**
   * Clear all cached feeds
   */
  clear(): void {
    this.cache.clear();
    this.accessOrder = [];
    logger.info('Feed cache cleared');
  }
  
  /**
   * Get cache statistics
   */
  getStats(): { size: number; urls: string[] } {
    return {
      size: this.cache.size,
      urls: Array.from(this.cache.keys()),
    };
  }
  
  /**
   * Clean up expired entries
   */
  private cleanup(): void {
    const now = Date.now();
    let removed = 0;
    
    for (const [url, entry] of this.cache.entries()) {
      if (now > entry.expiresAt) {
        this.cache.delete(url);
        this.removeFromAccessOrder(url);
        removed++;
      }
    }
    
    if (removed > 0) {
      logger.debug(`Cleaned up ${removed} expired cache entries`);
    }
  }
  
  /**
   * Update LRU access order
   */
  private updateAccessOrder(url: string): void {
    this.removeFromAccessOrder(url);
    this.accessOrder.push(url);
  }
  
  /**
   * Remove from access order array
   */
  private removeFromAccessOrder(url: string): void {
    const index = this.accessOrder.indexOf(url);
    if (index > -1) {
      this.accessOrder.splice(index, 1);
    }
  }
  
  /**
   * Evict least recently used entry
   */
  private evictLRU(): void {
    if (this.accessOrder.length > 0) {
      const url = this.accessOrder[0];
      this.delete(url);
      logger.debug(`Evicted LRU cache entry: ${url}`);
    }
  }
  
  /**
   * Destroy cache and clear intervals
   */
  destroy(): void {
    if (this.cleanupInterval) {
      clearInterval(this.cleanupInterval);
    }
    this.clear();
  }
}
// Singleton instance
export const feedCache = new FeedCache();