import type { ICacheEntry, IUpstreamCacheConfig, IUpstreamFetchContext, } from './interfaces.upstream.js'; import { DEFAULT_CACHE_CONFIG } from './interfaces.upstream.js'; import type { IStorageBackend } from '../core/interfaces.core.js'; /** * Cache metadata stored alongside cache entries. */ interface ICacheMetadata { contentType: string; headers: Record; cachedAt: string; expiresAt?: string; etag?: string; upstreamId: string; upstreamUrl: string; } /** * S3-backed upstream cache with in-memory hot layer. * * Features: * - TTL-based expiration * - Stale-while-revalidate support * - Negative caching (404s) * - Content-type aware caching * - ETag support for conditional requests * - Multi-upstream support via URL-based cache paths * - Persistent S3 storage with in-memory hot layer * * Cache paths are structured as: * cache/{escaped-upstream-url}/{protocol}:{method}:{path} * * @example * ```typescript * // In-memory only (default) * const cache = new UpstreamCache(config); * * // With S3 persistence * const cache = new UpstreamCache(config, 10000, storage); * ``` */ export class UpstreamCache { /** In-memory hot cache */ private readonly memoryCache: Map = new Map(); /** Configuration */ private readonly config: IUpstreamCacheConfig; /** Maximum in-memory cache entries */ private readonly maxMemoryEntries: number; /** S3 storage backend (optional) */ private readonly storage?: IStorageBackend; /** Cleanup interval handle */ private cleanupInterval: ReturnType | null = null; constructor( config?: Partial, maxMemoryEntries: number = 10000, storage?: IStorageBackend ) { this.config = { ...DEFAULT_CACHE_CONFIG, ...config }; this.maxMemoryEntries = maxMemoryEntries; this.storage = storage; // 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; } /** * Check if S3 storage is configured. */ public hasStorage(): boolean { return !!this.storage; } /** * Get cached entry for a request context. * Checks memory first, then falls back to S3. * Returns null if not found or expired (unless stale-while-revalidate). */ public async get(context: IUpstreamFetchContext, upstreamUrl?: string): Promise { if (!this.config.enabled) { return null; } const key = this.buildCacheKey(context, upstreamUrl); // Check memory cache first let entry = this.memoryCache.get(key); // If not in memory and we have storage, check S3 if (!entry && this.storage) { entry = await this.loadFromStorage(key); if (entry) { // Promote to memory cache this.memoryCache.set(key, entry); } } 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.memoryCache.delete(key); if (this.storage) { await this.deleteFromStorage(key).catch(() => {}); } return null; } return entry; } /** * Store a response in the cache (memory and optionally S3). */ public async set( context: IUpstreamFetchContext, data: Buffer, contentType: string, headers: Record, upstreamId: string, upstreamUrl: string, options?: ICacheSetOptions, ): Promise { if (!this.config.enabled) { return; } // Enforce max memory entries limit if (this.memoryCache.size >= this.maxMemoryEntries) { this.evictOldest(); } const key = this.buildCacheKey(context, upstreamUrl); 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, }; // Store in memory this.memoryCache.set(key, entry); // Store in S3 if available if (this.storage) { await this.saveToStorage(key, entry, upstreamUrl).catch(() => {}); } } /** * Store a negative cache entry (404 response). */ public async setNegative(context: IUpstreamFetchContext, upstreamId: string, upstreamUrl: string): Promise { if (!this.config.enabled || this.config.negativeCacheTtlSeconds <= 0) { return; } const key = this.buildCacheKey(context, upstreamUrl); 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.memoryCache.set(key, entry); if (this.storage) { await this.saveToStorage(key, entry, upstreamUrl).catch(() => {}); } } /** * Check if there's a negative cache entry for this context. */ public async hasNegative(context: IUpstreamFetchContext, upstreamUrl?: string): Promise { const entry = await this.get(context, upstreamUrl); return entry !== null && entry.data.length === 0; } /** * Invalidate a specific cache entry. */ public async invalidate(context: IUpstreamFetchContext, upstreamUrl?: string): Promise { const key = this.buildCacheKey(context, upstreamUrl); const deleted = this.memoryCache.delete(key); if (this.storage) { await this.deleteFromStorage(key).catch(() => {}); } return deleted; } /** * Invalidate all entries matching a pattern. * Useful for invalidating all versions of a package. */ public async invalidatePattern(pattern: RegExp): Promise { let count = 0; for (const key of this.memoryCache.keys()) { if (pattern.test(key)) { this.memoryCache.delete(key); if (this.storage) { await this.deleteFromStorage(key).catch(() => {}); } count++; } } return count; } /** * Invalidate all entries from a specific upstream. */ public async invalidateUpstream(upstreamId: string): Promise { let count = 0; for (const [key, entry] of this.memoryCache.entries()) { if (entry.upstreamId === upstreamId) { this.memoryCache.delete(key); if (this.storage) { await this.deleteFromStorage(key).catch(() => {}); } count++; } } return count; } /** * Clear all cache entries (memory and S3). */ public async clear(): Promise { this.memoryCache.clear(); // Note: S3 cleanup would require listing and deleting all cache/* objects // This is left as a future enhancement for bulk cleanup } /** * 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.memoryCache.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.memoryCache.size, freshEntries: freshCount, staleEntries: staleCount, negativeEntries: negativeCount, totalSizeBytes: totalSize, maxEntries: this.maxMemoryEntries, enabled: this.config.enabled, hasStorage: !!this.storage, }; } /** * Stop the cache and cleanup. */ public stop(): void { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } } // ======================================================================== // Storage Methods // ======================================================================== /** * Build storage path for a cache key. * Escapes upstream URL for safe use in S3 paths. */ private buildStoragePath(key: string): string { return `cache/${key}`; } /** * Build storage path for cache metadata. */ private buildMetadataPath(key: string): string { return `cache/${key}.meta`; } /** * Load a cache entry from S3 storage. */ private async loadFromStorage(key: string): Promise { if (!this.storage) return null; try { const dataPath = this.buildStoragePath(key); const metaPath = this.buildMetadataPath(key); // Load data and metadata in parallel const [data, metaBuffer] = await Promise.all([ this.storage.getObject(dataPath), this.storage.getObject(metaPath), ]); if (!data || !metaBuffer) { return null; } const meta: ICacheMetadata = JSON.parse(metaBuffer.toString('utf-8')); return { data, contentType: meta.contentType, headers: meta.headers, cachedAt: new Date(meta.cachedAt), expiresAt: meta.expiresAt ? new Date(meta.expiresAt) : undefined, etag: meta.etag, upstreamId: meta.upstreamId, stale: false, }; } catch { return null; } } /** * Save a cache entry to S3 storage. */ private async saveToStorage(key: string, entry: ICacheEntry, upstreamUrl: string): Promise { if (!this.storage) return; const dataPath = this.buildStoragePath(key); const metaPath = this.buildMetadataPath(key); const meta: ICacheMetadata = { contentType: entry.contentType, headers: entry.headers, cachedAt: entry.cachedAt.toISOString(), expiresAt: entry.expiresAt?.toISOString(), etag: entry.etag, upstreamId: entry.upstreamId, upstreamUrl, }; // Save data and metadata in parallel await Promise.all([ this.storage.putObject(dataPath, entry.data), this.storage.putObject(metaPath, Buffer.from(JSON.stringify(meta), 'utf-8')), ]); } /** * Delete a cache entry from S3 storage. */ private async deleteFromStorage(key: string): Promise { if (!this.storage) return; const dataPath = this.buildStoragePath(key); const metaPath = this.buildMetadataPath(key); await Promise.all([ this.storage.deleteObject(dataPath).catch(() => {}), this.storage.deleteObject(metaPath).catch(() => {}), ]); } // ======================================================================== // Helper Methods // ======================================================================== /** * Escape a URL for safe use in storage paths. */ private escapeUrl(url: string): string { // Remove protocol prefix and escape special characters return url .replace(/^https?:\/\//, '') .replace(/[\/\\:*?"<>|]/g, '_') .replace(/__+/g, '_'); } /** * Build a unique cache key for a request context. * Includes escaped upstream URL for multi-upstream support. */ private buildCacheKey(context: IUpstreamFetchContext, upstreamUrl?: string): string { // Include method, protocol, path, and sorted query params const queryString = Object.keys(context.query) .sort() .map(k => `${k}=${context.query[k]}`) .join('&'); const baseKey = `${context.protocol}:${context.method}:${context.path}${queryString ? '?' + queryString : ''}`; if (upstreamUrl) { return `${this.escapeUrl(upstreamUrl)}/${baseKey}`; } return baseKey; } /** * 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.maxMemoryEntries * 0.1); let evicted = 0; // First, try to evict stale entries const now = new Date(); for (const [key, entry] of this.memoryCache.entries()) { if (evicted >= evictCount) break; if (entry.stale || (entry.expiresAt && entry.expiresAt < now)) { this.memoryCache.delete(key); evicted++; } } // If not enough evicted, evict oldest by cachedAt if (evicted < evictCount) { const entries = Array.from(this.memoryCache.entries()) .sort((a, b) => a[1].cachedAt.getTime() - b[1].cachedAt.getTime()); for (const [key] of entries) { if (evicted >= evictCount) break; this.memoryCache.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 from memory cache. */ private cleanup(): void { const now = new Date(); const staleDeadline = new Date(now.getTime() - this.config.staleMaxAgeSeconds * 1000); for (const [key, entry] of this.memoryCache.entries()) { if (entry.expiresAt) { // Remove if past stale deadline if (entry.expiresAt < staleDeadline) { this.memoryCache.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 in memory */ 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 (memory only) */ totalSizeBytes: number; /** Maximum allowed memory entries */ maxEntries: number; /** Whether caching is enabled */ enabled: boolean; /** Whether S3 storage is configured */ hasStorage: boolean; }