627 lines
16 KiB
TypeScript
627 lines
16 KiB
TypeScript
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<string, string>;
|
|
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<string, ICacheEntry> = 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<typeof setInterval> | null = null;
|
|
|
|
constructor(
|
|
config?: Partial<IUpstreamCacheConfig>,
|
|
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<ICacheEntry | null> {
|
|
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<string, string>,
|
|
upstreamId: string,
|
|
upstreamUrl: string,
|
|
options?: ICacheSetOptions,
|
|
): Promise<void> {
|
|
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<void> {
|
|
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<boolean> {
|
|
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<boolean> {
|
|
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<number> {
|
|
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<number> {
|
|
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<void> {
|
|
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<ICacheEntry | null> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string, string>,
|
|
): 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;
|
|
}
|