import type { ICacheEntry, IUpstreamCacheConfig, IUpstreamFetchContext, } from './interfaces.upstream.js'; import { DEFAULT_CACHE_CONFIG } from './interfaces.upstream.js'; /** * In-memory cache for upstream responses. * * Features: * - TTL-based expiration * - Stale-while-revalidate support * - Negative caching (404s) * - Content-type aware caching * - ETag support for conditional requests * * Note: This is an in-memory implementation. For production with persistence, * extend this class to use RegistryStorage for S3-backed caching. */ export class UpstreamCache { /** Cache storage */ private readonly cache: Map = new Map(); /** Configuration */ private readonly config: IUpstreamCacheConfig; /** Maximum cache entries (prevents memory bloat) */ private readonly maxEntries: number; /** Cleanup interval handle */ private cleanupInterval: ReturnType | null = null; constructor(config?: Partial, maxEntries: number = 10000) { this.config = { ...DEFAULT_CACHE_CONFIG, ...config }; this.maxEntries = maxEntries; // Start periodic cleanup if caching is enabled if (this.config.enabled) { this.startCleanup(); } } /** * Check if caching is enabled. */ public isEnabled(): boolean { return this.config.enabled; } /** * Get cached entry for a request context. * Returns null if not found or expired (unless stale-while-revalidate). */ public get(context: IUpstreamFetchContext): ICacheEntry | null { if (!this.config.enabled) { return null; } const key = this.buildCacheKey(context); const entry = this.cache.get(key); if (!entry) { return null; } const now = new Date(); // Check if entry is expired if (entry.expiresAt && entry.expiresAt < now) { // Check if we can serve stale content if (this.config.staleWhileRevalidate && !entry.stale) { const staleAge = (now.getTime() - entry.expiresAt.getTime()) / 1000; if (staleAge <= this.config.staleMaxAgeSeconds) { // Mark as stale and return entry.stale = true; return entry; } } // Entry is too old, remove it this.cache.delete(key); return null; } return entry; } /** * Store a response in the cache. */ public set( context: IUpstreamFetchContext, data: Buffer, contentType: string, headers: Record, upstreamId: string, options?: ICacheSetOptions, ): void { if (!this.config.enabled) { return; } // Enforce max entries limit if (this.cache.size >= this.maxEntries) { this.evictOldest(); } const key = this.buildCacheKey(context); const now = new Date(); // Determine TTL based on content type const ttlSeconds = options?.ttlSeconds ?? this.determineTtl(context, contentType, headers); const entry: ICacheEntry = { data, contentType, headers, cachedAt: now, expiresAt: ttlSeconds > 0 ? new Date(now.getTime() + ttlSeconds * 1000) : undefined, etag: headers['etag'] || options?.etag, upstreamId, stale: false, }; this.cache.set(key, entry); } /** * Store a negative cache entry (404 response). */ public setNegative(context: IUpstreamFetchContext, upstreamId: string): void { if (!this.config.enabled || this.config.negativeCacheTtlSeconds <= 0) { return; } const key = this.buildCacheKey(context); const now = new Date(); const entry: ICacheEntry = { data: Buffer.from(''), contentType: 'application/octet-stream', headers: {}, cachedAt: now, expiresAt: new Date(now.getTime() + this.config.negativeCacheTtlSeconds * 1000), upstreamId, stale: false, }; this.cache.set(key, entry); } /** * Check if there's a negative cache entry for this context. */ public hasNegative(context: IUpstreamFetchContext): boolean { const entry = this.get(context); return entry !== null && entry.data.length === 0; } /** * Invalidate a specific cache entry. */ public invalidate(context: IUpstreamFetchContext): boolean { const key = this.buildCacheKey(context); return this.cache.delete(key); } /** * Invalidate all entries matching a pattern. * Useful for invalidating all versions of a package. */ public invalidatePattern(pattern: RegExp): number { let count = 0; for (const key of this.cache.keys()) { if (pattern.test(key)) { this.cache.delete(key); count++; } } return count; } /** * Invalidate all entries from a specific upstream. */ public invalidateUpstream(upstreamId: string): number { let count = 0; for (const [key, entry] of this.cache.entries()) { if (entry.upstreamId === upstreamId) { this.cache.delete(key); count++; } } return count; } /** * Clear all cache entries. */ public clear(): void { this.cache.clear(); } /** * Get cache statistics. */ public getStats(): ICacheStats { let freshCount = 0; let staleCount = 0; let negativeCount = 0; let totalSize = 0; const now = new Date(); for (const entry of this.cache.values()) { totalSize += entry.data.length; if (entry.data.length === 0) { negativeCount++; } else if (entry.stale || (entry.expiresAt && entry.expiresAt < now)) { staleCount++; } else { freshCount++; } } return { totalEntries: this.cache.size, freshEntries: freshCount, staleEntries: staleCount, negativeEntries: negativeCount, totalSizeBytes: totalSize, maxEntries: this.maxEntries, enabled: this.config.enabled, }; } /** * Stop the cache and cleanup. */ public stop(): void { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } } /** * Build a unique cache key for a request context. */ private buildCacheKey(context: IUpstreamFetchContext): string { // Include method, protocol, path, and sorted query params const queryString = Object.keys(context.query) .sort() .map(k => `${k}=${context.query[k]}`) .join('&'); return `${context.protocol}:${context.method}:${context.path}${queryString ? '?' + queryString : ''}`; } /** * Determine TTL based on content characteristics. */ private determineTtl( context: IUpstreamFetchContext, contentType: string, headers: Record, ): number { // Check for Cache-Control header const cacheControl = headers['cache-control']; if (cacheControl) { const maxAgeMatch = cacheControl.match(/max-age=(\d+)/); if (maxAgeMatch) { return parseInt(maxAgeMatch[1], 10); } if (cacheControl.includes('no-store') || cacheControl.includes('no-cache')) { return 0; } } // Check if content is immutable (content-addressable) if (this.isImmutableContent(context, contentType)) { return this.config.immutableTtlSeconds; } // Default TTL for mutable content return this.config.defaultTtlSeconds; } /** * Check if content is immutable (content-addressable). */ private isImmutableContent(context: IUpstreamFetchContext, contentType: string): boolean { // OCI blobs with digest are immutable if (context.protocol === 'oci' && context.resourceType === 'blob') { return true; } // NPM tarballs are immutable (versioned) if (context.protocol === 'npm' && context.resourceType === 'tarball') { return true; } // Maven artifacts with version are immutable if (context.protocol === 'maven' && context.resourceType === 'artifact') { return true; } // Cargo crate files are immutable if (context.protocol === 'cargo' && context.resourceType === 'crate') { return true; } // Composer dist files are immutable if (context.protocol === 'composer' && context.resourceType === 'dist') { return true; } // PyPI package files are immutable if (context.protocol === 'pypi' && context.resourceType === 'package') { return true; } // RubyGems .gem files are immutable if (context.protocol === 'rubygems' && context.resourceType === 'gem') { return true; } return false; } /** * Evict oldest entries to make room for new ones. */ private evictOldest(): void { // Evict 10% of max entries const evictCount = Math.ceil(this.maxEntries * 0.1); let evicted = 0; // First, try to evict stale entries const now = new Date(); for (const [key, entry] of this.cache.entries()) { if (evicted >= evictCount) break; if (entry.stale || (entry.expiresAt && entry.expiresAt < now)) { this.cache.delete(key); evicted++; } } // If not enough evicted, evict oldest by cachedAt if (evicted < evictCount) { const entries = Array.from(this.cache.entries()) .sort((a, b) => a[1].cachedAt.getTime() - b[1].cachedAt.getTime()); for (const [key] of entries) { if (evicted >= evictCount) break; this.cache.delete(key); evicted++; } } } /** * Start periodic cleanup of expired entries. */ private startCleanup(): void { // Run cleanup every minute this.cleanupInterval = setInterval(() => { this.cleanup(); }, 60000); // Don't keep the process alive just for cleanup if (this.cleanupInterval.unref) { this.cleanupInterval.unref(); } } /** * Remove all expired entries. */ private cleanup(): void { const now = new Date(); const staleDeadline = new Date(now.getTime() - this.config.staleMaxAgeSeconds * 1000); for (const [key, entry] of this.cache.entries()) { if (entry.expiresAt) { // Remove if past stale deadline if (entry.expiresAt < staleDeadline) { this.cache.delete(key); } } } } } /** * Options for cache set operation. */ export interface ICacheSetOptions { /** Override TTL in seconds */ ttlSeconds?: number; /** ETag for conditional requests */ etag?: string; } /** * Cache statistics. */ export interface ICacheStats { /** Total number of cached entries */ totalEntries: number; /** Number of fresh (non-expired) entries */ freshEntries: number; /** Number of stale entries (expired but still usable) */ staleEntries: number; /** Number of negative cache entries */ negativeEntries: number; /** Total size of cached data in bytes */ totalSizeBytes: number; /** Maximum allowed entries */ maxEntries: number; /** Whether caching is enabled */ enabled: boolean; }