feat(upstream): Add upstream proxy/cache subsystem and integrate per-protocol upstreams
This commit is contained in:
423
ts/upstream/classes.upstreamcache.ts
Normal file
423
ts/upstream/classes.upstreamcache.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
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<string, ICacheEntry> = new Map();
|
||||
|
||||
/** Configuration */
|
||||
private readonly config: IUpstreamCacheConfig;
|
||||
|
||||
/** Maximum cache entries (prevents memory bloat) */
|
||||
private readonly maxEntries: number;
|
||||
|
||||
/** Cleanup interval handle */
|
||||
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(config?: Partial<IUpstreamCacheConfig>, 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<string, string>,
|
||||
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<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.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;
|
||||
}
|
||||
Reference in New Issue
Block a user