@push.rocks/smartregistry 2.2.3 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/cargo/classes.cargoregistry.d.ts +7 -1
  3. package/dist_ts/cargo/classes.cargoregistry.js +42 -4
  4. package/dist_ts/cargo/classes.cargoupstream.d.ts +44 -0
  5. package/dist_ts/cargo/classes.cargoupstream.js +129 -0
  6. package/dist_ts/cargo/index.d.ts +1 -0
  7. package/dist_ts/cargo/index.js +2 -1
  8. package/dist_ts/classes.smartregistry.js +8 -8
  9. package/dist_ts/composer/classes.composerregistry.d.ts +7 -1
  10. package/dist_ts/composer/classes.composerregistry.js +34 -3
  11. package/dist_ts/composer/classes.composerupstream.d.ts +40 -0
  12. package/dist_ts/composer/classes.composerupstream.js +159 -0
  13. package/dist_ts/composer/index.d.ts +1 -0
  14. package/dist_ts/composer/index.js +2 -1
  15. package/dist_ts/core/interfaces.core.d.ts +3 -0
  16. package/dist_ts/index.d.ts +1 -0
  17. package/dist_ts/index.js +3 -1
  18. package/dist_ts/maven/classes.mavenregistry.d.ts +12 -1
  19. package/dist_ts/maven/classes.mavenregistry.js +69 -4
  20. package/dist_ts/maven/classes.mavenupstream.d.ts +45 -0
  21. package/dist_ts/maven/classes.mavenupstream.js +153 -0
  22. package/dist_ts/maven/index.d.ts +1 -0
  23. package/dist_ts/maven/index.js +2 -1
  24. package/dist_ts/npm/classes.npmregistry.d.ts +3 -1
  25. package/dist_ts/npm/classes.npmregistry.js +55 -6
  26. package/dist_ts/npm/classes.npmupstream.d.ts +51 -0
  27. package/dist_ts/npm/classes.npmupstream.js +206 -0
  28. package/dist_ts/npm/index.d.ts +1 -0
  29. package/dist_ts/npm/index.js +2 -1
  30. package/dist_ts/oci/classes.ociregistry.d.ts +4 -1
  31. package/dist_ts/oci/classes.ociregistry.js +78 -17
  32. package/dist_ts/oci/classes.ociupstream.d.ts +62 -0
  33. package/dist_ts/oci/classes.ociupstream.js +206 -0
  34. package/dist_ts/oci/index.d.ts +1 -0
  35. package/dist_ts/oci/index.js +2 -1
  36. package/dist_ts/plugins.d.ts +4 -1
  37. package/dist_ts/plugins.js +6 -2
  38. package/dist_ts/pypi/classes.pypiregistry.d.ts +7 -1
  39. package/dist_ts/pypi/classes.pypiregistry.js +60 -4
  40. package/dist_ts/pypi/classes.pypiupstream.d.ts +48 -0
  41. package/dist_ts/pypi/classes.pypiupstream.js +165 -0
  42. package/dist_ts/pypi/index.d.ts +1 -0
  43. package/dist_ts/pypi/index.js +2 -1
  44. package/dist_ts/rubygems/classes.rubygemsregistry.d.ts +7 -1
  45. package/dist_ts/rubygems/classes.rubygemsregistry.js +35 -4
  46. package/dist_ts/rubygems/classes.rubygemsupstream.d.ts +47 -0
  47. package/dist_ts/rubygems/classes.rubygemsupstream.js +184 -0
  48. package/dist_ts/rubygems/index.d.ts +1 -0
  49. package/dist_ts/rubygems/index.js +2 -1
  50. package/dist_ts/upstream/classes.baseupstream.d.ts +112 -0
  51. package/dist_ts/upstream/classes.baseupstream.js +409 -0
  52. package/dist_ts/upstream/classes.circuitbreaker.d.ts +111 -0
  53. package/dist_ts/upstream/classes.circuitbreaker.js +192 -0
  54. package/dist_ts/upstream/classes.upstreamcache.d.ts +123 -0
  55. package/dist_ts/upstream/classes.upstreamcache.js +328 -0
  56. package/dist_ts/upstream/index.d.ts +6 -0
  57. package/dist_ts/upstream/index.js +7 -0
  58. package/dist_ts/upstream/interfaces.upstream.d.ts +169 -0
  59. package/dist_ts/upstream/interfaces.upstream.js +23 -0
  60. package/package.json +4 -2
  61. package/ts/00_commitinfo_data.ts +1 -1
  62. package/ts/cargo/classes.cargoregistry.ts +48 -3
  63. package/ts/cargo/classes.cargoupstream.ts +159 -0
  64. package/ts/cargo/index.ts +1 -0
  65. package/ts/classes.smartregistry.ts +49 -7
  66. package/ts/composer/classes.composerregistry.ts +39 -2
  67. package/ts/composer/classes.composerupstream.ts +200 -0
  68. package/ts/composer/index.ts +1 -0
  69. package/ts/core/interfaces.core.ts +3 -0
  70. package/ts/index.ts +3 -0
  71. package/ts/maven/classes.mavenregistry.ts +84 -3
  72. package/ts/maven/classes.mavenupstream.ts +220 -0
  73. package/ts/maven/index.ts +1 -0
  74. package/ts/npm/classes.npmregistry.ts +61 -5
  75. package/ts/npm/classes.npmupstream.ts +260 -0
  76. package/ts/npm/index.ts +1 -0
  77. package/ts/oci/classes.ociregistry.ts +89 -17
  78. package/ts/oci/classes.ociupstream.ts +263 -0
  79. package/ts/oci/index.ts +1 -0
  80. package/ts/plugins.ts +7 -1
  81. package/ts/pypi/classes.pypiregistry.ts +68 -3
  82. package/ts/pypi/classes.pypiupstream.ts +211 -0
  83. package/ts/pypi/index.ts +1 -0
  84. package/ts/rubygems/classes.rubygemsregistry.ts +40 -3
  85. package/ts/rubygems/classes.rubygemsupstream.ts +230 -0
  86. package/ts/rubygems/index.ts +1 -0
  87. package/ts/upstream/classes.baseupstream.ts +521 -0
  88. package/ts/upstream/classes.circuitbreaker.ts +238 -0
  89. package/ts/upstream/classes.upstreamcache.ts +423 -0
  90. package/ts/upstream/index.ts +11 -0
  91. package/ts/upstream/interfaces.upstream.ts +195 -0
@@ -0,0 +1,238 @@
1
+ import type { TCircuitState, IUpstreamResilienceConfig } from './interfaces.upstream.js';
2
+ import { DEFAULT_RESILIENCE_CONFIG } from './interfaces.upstream.js';
3
+
4
+ /**
5
+ * Circuit breaker implementation for upstream resilience.
6
+ *
7
+ * States:
8
+ * - CLOSED: Normal operation, requests pass through
9
+ * - OPEN: Circuit is tripped, requests fail fast
10
+ * - HALF_OPEN: Testing if upstream has recovered
11
+ *
12
+ * Transitions:
13
+ * - CLOSED → OPEN: When failure count exceeds threshold
14
+ * - OPEN → HALF_OPEN: After reset timeout expires
15
+ * - HALF_OPEN → CLOSED: On successful request
16
+ * - HALF_OPEN → OPEN: On failed request
17
+ */
18
+ export class CircuitBreaker {
19
+ /** Unique identifier for logging and metrics */
20
+ public readonly id: string;
21
+
22
+ /** Current circuit state */
23
+ private state: TCircuitState = 'CLOSED';
24
+
25
+ /** Count of consecutive failures */
26
+ private failureCount: number = 0;
27
+
28
+ /** Timestamp when circuit was opened */
29
+ private openedAt: number = 0;
30
+
31
+ /** Number of successful requests in half-open state */
32
+ private halfOpenSuccesses: number = 0;
33
+
34
+ /** Configuration */
35
+ private readonly config: IUpstreamResilienceConfig;
36
+
37
+ /** Number of successes required to close circuit from half-open */
38
+ private readonly halfOpenThreshold: number = 2;
39
+
40
+ constructor(id: string, config?: Partial<IUpstreamResilienceConfig>) {
41
+ this.id = id;
42
+ this.config = { ...DEFAULT_RESILIENCE_CONFIG, ...config };
43
+ }
44
+
45
+ /**
46
+ * Get current circuit state.
47
+ */
48
+ public getState(): TCircuitState {
49
+ // Check if we should transition from OPEN to HALF_OPEN
50
+ if (this.state === 'OPEN') {
51
+ const elapsed = Date.now() - this.openedAt;
52
+ if (elapsed >= this.config.circuitBreakerResetMs) {
53
+ this.transitionTo('HALF_OPEN');
54
+ }
55
+ }
56
+ return this.state;
57
+ }
58
+
59
+ /**
60
+ * Check if circuit allows requests.
61
+ * Returns true if requests should be allowed.
62
+ */
63
+ public canRequest(): boolean {
64
+ const currentState = this.getState();
65
+ return currentState !== 'OPEN';
66
+ }
67
+
68
+ /**
69
+ * Record a successful request.
70
+ * May transition circuit from HALF_OPEN to CLOSED.
71
+ */
72
+ public recordSuccess(): void {
73
+ if (this.state === 'HALF_OPEN') {
74
+ this.halfOpenSuccesses++;
75
+ if (this.halfOpenSuccesses >= this.halfOpenThreshold) {
76
+ this.transitionTo('CLOSED');
77
+ }
78
+ } else if (this.state === 'CLOSED') {
79
+ // Reset failure count on success
80
+ this.failureCount = 0;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Record a failed request.
86
+ * May transition circuit from CLOSED/HALF_OPEN to OPEN.
87
+ */
88
+ public recordFailure(): void {
89
+ if (this.state === 'HALF_OPEN') {
90
+ // Any failure in half-open immediately opens circuit
91
+ this.transitionTo('OPEN');
92
+ } else if (this.state === 'CLOSED') {
93
+ this.failureCount++;
94
+ if (this.failureCount >= this.config.circuitBreakerThreshold) {
95
+ this.transitionTo('OPEN');
96
+ }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Force circuit to open state.
102
+ * Useful for manual intervention or external health checks.
103
+ */
104
+ public forceOpen(): void {
105
+ this.transitionTo('OPEN');
106
+ }
107
+
108
+ /**
109
+ * Force circuit to closed state.
110
+ * Useful for manual intervention after fixing upstream issues.
111
+ */
112
+ public forceClose(): void {
113
+ this.transitionTo('CLOSED');
114
+ }
115
+
116
+ /**
117
+ * Reset circuit to initial state.
118
+ */
119
+ public reset(): void {
120
+ this.state = 'CLOSED';
121
+ this.failureCount = 0;
122
+ this.openedAt = 0;
123
+ this.halfOpenSuccesses = 0;
124
+ }
125
+
126
+ /**
127
+ * Get circuit metrics for monitoring.
128
+ */
129
+ public getMetrics(): ICircuitBreakerMetrics {
130
+ return {
131
+ id: this.id,
132
+ state: this.getState(),
133
+ failureCount: this.failureCount,
134
+ openedAt: this.openedAt > 0 ? new Date(this.openedAt) : null,
135
+ timeUntilHalfOpen: this.state === 'OPEN'
136
+ ? Math.max(0, this.config.circuitBreakerResetMs - (Date.now() - this.openedAt))
137
+ : 0,
138
+ halfOpenSuccesses: this.halfOpenSuccesses,
139
+ threshold: this.config.circuitBreakerThreshold,
140
+ resetMs: this.config.circuitBreakerResetMs,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Transition to a new state with proper cleanup.
146
+ */
147
+ private transitionTo(newState: TCircuitState): void {
148
+ const previousState = this.state;
149
+ this.state = newState;
150
+
151
+ switch (newState) {
152
+ case 'OPEN':
153
+ this.openedAt = Date.now();
154
+ this.halfOpenSuccesses = 0;
155
+ break;
156
+ case 'HALF_OPEN':
157
+ this.halfOpenSuccesses = 0;
158
+ break;
159
+ case 'CLOSED':
160
+ this.failureCount = 0;
161
+ this.openedAt = 0;
162
+ this.halfOpenSuccesses = 0;
163
+ break;
164
+ }
165
+
166
+ // Log state transition (useful for debugging and monitoring)
167
+ // In production, this would emit events or metrics
168
+ if (previousState !== newState) {
169
+ // State changed - could emit event here
170
+ }
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Metrics for circuit breaker monitoring.
176
+ */
177
+ export interface ICircuitBreakerMetrics {
178
+ /** Circuit breaker identifier */
179
+ id: string;
180
+ /** Current state */
181
+ state: TCircuitState;
182
+ /** Number of consecutive failures */
183
+ failureCount: number;
184
+ /** When circuit was opened (null if never opened) */
185
+ openedAt: Date | null;
186
+ /** Milliseconds until circuit transitions to half-open (0 if not open) */
187
+ timeUntilHalfOpen: number;
188
+ /** Number of successes in half-open state */
189
+ halfOpenSuccesses: number;
190
+ /** Failure threshold for opening circuit */
191
+ threshold: number;
192
+ /** Reset timeout in milliseconds */
193
+ resetMs: number;
194
+ }
195
+
196
+ /**
197
+ * Execute a function with circuit breaker protection.
198
+ *
199
+ * @param breaker The circuit breaker to use
200
+ * @param fn The async function to execute
201
+ * @param fallback Optional fallback function when circuit is open
202
+ * @returns The result of fn or fallback
203
+ * @throws CircuitOpenError if circuit is open and no fallback provided
204
+ */
205
+ export async function withCircuitBreaker<T>(
206
+ breaker: CircuitBreaker,
207
+ fn: () => Promise<T>,
208
+ fallback?: () => Promise<T>,
209
+ ): Promise<T> {
210
+ if (!breaker.canRequest()) {
211
+ if (fallback) {
212
+ return fallback();
213
+ }
214
+ throw new CircuitOpenError(breaker.id);
215
+ }
216
+
217
+ try {
218
+ const result = await fn();
219
+ breaker.recordSuccess();
220
+ return result;
221
+ } catch (error) {
222
+ breaker.recordFailure();
223
+ throw error;
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Error thrown when circuit is open and no fallback is provided.
229
+ */
230
+ export class CircuitOpenError extends Error {
231
+ public readonly circuitId: string;
232
+
233
+ constructor(circuitId: string) {
234
+ super(`Circuit breaker '${circuitId}' is open`);
235
+ this.name = 'CircuitOpenError';
236
+ this.circuitId = circuitId;
237
+ }
238
+ }
@@ -0,0 +1,423 @@
1
+ import type {
2
+ ICacheEntry,
3
+ IUpstreamCacheConfig,
4
+ IUpstreamFetchContext,
5
+ } from './interfaces.upstream.js';
6
+ import { DEFAULT_CACHE_CONFIG } from './interfaces.upstream.js';
7
+
8
+ /**
9
+ * In-memory cache for upstream responses.
10
+ *
11
+ * Features:
12
+ * - TTL-based expiration
13
+ * - Stale-while-revalidate support
14
+ * - Negative caching (404s)
15
+ * - Content-type aware caching
16
+ * - ETag support for conditional requests
17
+ *
18
+ * Note: This is an in-memory implementation. For production with persistence,
19
+ * extend this class to use RegistryStorage for S3-backed caching.
20
+ */
21
+ export class UpstreamCache {
22
+ /** Cache storage */
23
+ private readonly cache: Map<string, ICacheEntry> = new Map();
24
+
25
+ /** Configuration */
26
+ private readonly config: IUpstreamCacheConfig;
27
+
28
+ /** Maximum cache entries (prevents memory bloat) */
29
+ private readonly maxEntries: number;
30
+
31
+ /** Cleanup interval handle */
32
+ private cleanupInterval: ReturnType<typeof setInterval> | null = null;
33
+
34
+ constructor(config?: Partial<IUpstreamCacheConfig>, maxEntries: number = 10000) {
35
+ this.config = { ...DEFAULT_CACHE_CONFIG, ...config };
36
+ this.maxEntries = maxEntries;
37
+
38
+ // Start periodic cleanup if caching is enabled
39
+ if (this.config.enabled) {
40
+ this.startCleanup();
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Check if caching is enabled.
46
+ */
47
+ public isEnabled(): boolean {
48
+ return this.config.enabled;
49
+ }
50
+
51
+ /**
52
+ * Get cached entry for a request context.
53
+ * Returns null if not found or expired (unless stale-while-revalidate).
54
+ */
55
+ public get(context: IUpstreamFetchContext): ICacheEntry | null {
56
+ if (!this.config.enabled) {
57
+ return null;
58
+ }
59
+
60
+ const key = this.buildCacheKey(context);
61
+ const entry = this.cache.get(key);
62
+
63
+ if (!entry) {
64
+ return null;
65
+ }
66
+
67
+ const now = new Date();
68
+
69
+ // Check if entry is expired
70
+ if (entry.expiresAt && entry.expiresAt < now) {
71
+ // Check if we can serve stale content
72
+ if (this.config.staleWhileRevalidate && !entry.stale) {
73
+ const staleAge = (now.getTime() - entry.expiresAt.getTime()) / 1000;
74
+ if (staleAge <= this.config.staleMaxAgeSeconds) {
75
+ // Mark as stale and return
76
+ entry.stale = true;
77
+ return entry;
78
+ }
79
+ }
80
+ // Entry is too old, remove it
81
+ this.cache.delete(key);
82
+ return null;
83
+ }
84
+
85
+ return entry;
86
+ }
87
+
88
+ /**
89
+ * Store a response in the cache.
90
+ */
91
+ public set(
92
+ context: IUpstreamFetchContext,
93
+ data: Buffer,
94
+ contentType: string,
95
+ headers: Record<string, string>,
96
+ upstreamId: string,
97
+ options?: ICacheSetOptions,
98
+ ): void {
99
+ if (!this.config.enabled) {
100
+ return;
101
+ }
102
+
103
+ // Enforce max entries limit
104
+ if (this.cache.size >= this.maxEntries) {
105
+ this.evictOldest();
106
+ }
107
+
108
+ const key = this.buildCacheKey(context);
109
+ const now = new Date();
110
+
111
+ // Determine TTL based on content type
112
+ const ttlSeconds = options?.ttlSeconds ?? this.determineTtl(context, contentType, headers);
113
+
114
+ const entry: ICacheEntry = {
115
+ data,
116
+ contentType,
117
+ headers,
118
+ cachedAt: now,
119
+ expiresAt: ttlSeconds > 0 ? new Date(now.getTime() + ttlSeconds * 1000) : undefined,
120
+ etag: headers['etag'] || options?.etag,
121
+ upstreamId,
122
+ stale: false,
123
+ };
124
+
125
+ this.cache.set(key, entry);
126
+ }
127
+
128
+ /**
129
+ * Store a negative cache entry (404 response).
130
+ */
131
+ public setNegative(context: IUpstreamFetchContext, upstreamId: string): void {
132
+ if (!this.config.enabled || this.config.negativeCacheTtlSeconds <= 0) {
133
+ return;
134
+ }
135
+
136
+ const key = this.buildCacheKey(context);
137
+ const now = new Date();
138
+
139
+ const entry: ICacheEntry = {
140
+ data: Buffer.from(''),
141
+ contentType: 'application/octet-stream',
142
+ headers: {},
143
+ cachedAt: now,
144
+ expiresAt: new Date(now.getTime() + this.config.negativeCacheTtlSeconds * 1000),
145
+ upstreamId,
146
+ stale: false,
147
+ };
148
+
149
+ this.cache.set(key, entry);
150
+ }
151
+
152
+ /**
153
+ * Check if there's a negative cache entry for this context.
154
+ */
155
+ public hasNegative(context: IUpstreamFetchContext): boolean {
156
+ const entry = this.get(context);
157
+ return entry !== null && entry.data.length === 0;
158
+ }
159
+
160
+ /**
161
+ * Invalidate a specific cache entry.
162
+ */
163
+ public invalidate(context: IUpstreamFetchContext): boolean {
164
+ const key = this.buildCacheKey(context);
165
+ return this.cache.delete(key);
166
+ }
167
+
168
+ /**
169
+ * Invalidate all entries matching a pattern.
170
+ * Useful for invalidating all versions of a package.
171
+ */
172
+ public invalidatePattern(pattern: RegExp): number {
173
+ let count = 0;
174
+ for (const key of this.cache.keys()) {
175
+ if (pattern.test(key)) {
176
+ this.cache.delete(key);
177
+ count++;
178
+ }
179
+ }
180
+ return count;
181
+ }
182
+
183
+ /**
184
+ * Invalidate all entries from a specific upstream.
185
+ */
186
+ public invalidateUpstream(upstreamId: string): number {
187
+ let count = 0;
188
+ for (const [key, entry] of this.cache.entries()) {
189
+ if (entry.upstreamId === upstreamId) {
190
+ this.cache.delete(key);
191
+ count++;
192
+ }
193
+ }
194
+ return count;
195
+ }
196
+
197
+ /**
198
+ * Clear all cache entries.
199
+ */
200
+ public clear(): void {
201
+ this.cache.clear();
202
+ }
203
+
204
+ /**
205
+ * Get cache statistics.
206
+ */
207
+ public getStats(): ICacheStats {
208
+ let freshCount = 0;
209
+ let staleCount = 0;
210
+ let negativeCount = 0;
211
+ let totalSize = 0;
212
+ const now = new Date();
213
+
214
+ for (const entry of this.cache.values()) {
215
+ totalSize += entry.data.length;
216
+
217
+ if (entry.data.length === 0) {
218
+ negativeCount++;
219
+ } else if (entry.stale || (entry.expiresAt && entry.expiresAt < now)) {
220
+ staleCount++;
221
+ } else {
222
+ freshCount++;
223
+ }
224
+ }
225
+
226
+ return {
227
+ totalEntries: this.cache.size,
228
+ freshEntries: freshCount,
229
+ staleEntries: staleCount,
230
+ negativeEntries: negativeCount,
231
+ totalSizeBytes: totalSize,
232
+ maxEntries: this.maxEntries,
233
+ enabled: this.config.enabled,
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Stop the cache and cleanup.
239
+ */
240
+ public stop(): void {
241
+ if (this.cleanupInterval) {
242
+ clearInterval(this.cleanupInterval);
243
+ this.cleanupInterval = null;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Build a unique cache key for a request context.
249
+ */
250
+ private buildCacheKey(context: IUpstreamFetchContext): string {
251
+ // Include method, protocol, path, and sorted query params
252
+ const queryString = Object.keys(context.query)
253
+ .sort()
254
+ .map(k => `${k}=${context.query[k]}`)
255
+ .join('&');
256
+
257
+ return `${context.protocol}:${context.method}:${context.path}${queryString ? '?' + queryString : ''}`;
258
+ }
259
+
260
+ /**
261
+ * Determine TTL based on content characteristics.
262
+ */
263
+ private determineTtl(
264
+ context: IUpstreamFetchContext,
265
+ contentType: string,
266
+ headers: Record<string, string>,
267
+ ): number {
268
+ // Check for Cache-Control header
269
+ const cacheControl = headers['cache-control'];
270
+ if (cacheControl) {
271
+ const maxAgeMatch = cacheControl.match(/max-age=(\d+)/);
272
+ if (maxAgeMatch) {
273
+ return parseInt(maxAgeMatch[1], 10);
274
+ }
275
+ if (cacheControl.includes('no-store') || cacheControl.includes('no-cache')) {
276
+ return 0;
277
+ }
278
+ }
279
+
280
+ // Check if content is immutable (content-addressable)
281
+ if (this.isImmutableContent(context, contentType)) {
282
+ return this.config.immutableTtlSeconds;
283
+ }
284
+
285
+ // Default TTL for mutable content
286
+ return this.config.defaultTtlSeconds;
287
+ }
288
+
289
+ /**
290
+ * Check if content is immutable (content-addressable).
291
+ */
292
+ private isImmutableContent(context: IUpstreamFetchContext, contentType: string): boolean {
293
+ // OCI blobs with digest are immutable
294
+ if (context.protocol === 'oci' && context.resourceType === 'blob') {
295
+ return true;
296
+ }
297
+
298
+ // NPM tarballs are immutable (versioned)
299
+ if (context.protocol === 'npm' && context.resourceType === 'tarball') {
300
+ return true;
301
+ }
302
+
303
+ // Maven artifacts with version are immutable
304
+ if (context.protocol === 'maven' && context.resourceType === 'artifact') {
305
+ return true;
306
+ }
307
+
308
+ // Cargo crate files are immutable
309
+ if (context.protocol === 'cargo' && context.resourceType === 'crate') {
310
+ return true;
311
+ }
312
+
313
+ // Composer dist files are immutable
314
+ if (context.protocol === 'composer' && context.resourceType === 'dist') {
315
+ return true;
316
+ }
317
+
318
+ // PyPI package files are immutable
319
+ if (context.protocol === 'pypi' && context.resourceType === 'package') {
320
+ return true;
321
+ }
322
+
323
+ // RubyGems .gem files are immutable
324
+ if (context.protocol === 'rubygems' && context.resourceType === 'gem') {
325
+ return true;
326
+ }
327
+
328
+ return false;
329
+ }
330
+
331
+ /**
332
+ * Evict oldest entries to make room for new ones.
333
+ */
334
+ private evictOldest(): void {
335
+ // Evict 10% of max entries
336
+ const evictCount = Math.ceil(this.maxEntries * 0.1);
337
+ let evicted = 0;
338
+
339
+ // First, try to evict stale entries
340
+ const now = new Date();
341
+ for (const [key, entry] of this.cache.entries()) {
342
+ if (evicted >= evictCount) break;
343
+ if (entry.stale || (entry.expiresAt && entry.expiresAt < now)) {
344
+ this.cache.delete(key);
345
+ evicted++;
346
+ }
347
+ }
348
+
349
+ // If not enough evicted, evict oldest by cachedAt
350
+ if (evicted < evictCount) {
351
+ const entries = Array.from(this.cache.entries())
352
+ .sort((a, b) => a[1].cachedAt.getTime() - b[1].cachedAt.getTime());
353
+
354
+ for (const [key] of entries) {
355
+ if (evicted >= evictCount) break;
356
+ this.cache.delete(key);
357
+ evicted++;
358
+ }
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Start periodic cleanup of expired entries.
364
+ */
365
+ private startCleanup(): void {
366
+ // Run cleanup every minute
367
+ this.cleanupInterval = setInterval(() => {
368
+ this.cleanup();
369
+ }, 60000);
370
+
371
+ // Don't keep the process alive just for cleanup
372
+ if (this.cleanupInterval.unref) {
373
+ this.cleanupInterval.unref();
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Remove all expired entries.
379
+ */
380
+ private cleanup(): void {
381
+ const now = new Date();
382
+ const staleDeadline = new Date(now.getTime() - this.config.staleMaxAgeSeconds * 1000);
383
+
384
+ for (const [key, entry] of this.cache.entries()) {
385
+ if (entry.expiresAt) {
386
+ // Remove if past stale deadline
387
+ if (entry.expiresAt < staleDeadline) {
388
+ this.cache.delete(key);
389
+ }
390
+ }
391
+ }
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Options for cache set operation.
397
+ */
398
+ export interface ICacheSetOptions {
399
+ /** Override TTL in seconds */
400
+ ttlSeconds?: number;
401
+ /** ETag for conditional requests */
402
+ etag?: string;
403
+ }
404
+
405
+ /**
406
+ * Cache statistics.
407
+ */
408
+ export interface ICacheStats {
409
+ /** Total number of cached entries */
410
+ totalEntries: number;
411
+ /** Number of fresh (non-expired) entries */
412
+ freshEntries: number;
413
+ /** Number of stale entries (expired but still usable) */
414
+ staleEntries: number;
415
+ /** Number of negative cache entries */
416
+ negativeEntries: number;
417
+ /** Total size of cached data in bytes */
418
+ totalSizeBytes: number;
419
+ /** Maximum allowed entries */
420
+ maxEntries: number;
421
+ /** Whether caching is enabled */
422
+ enabled: boolean;
423
+ }
@@ -0,0 +1,11 @@
1
+ // Interfaces and types
2
+ export * from './interfaces.upstream.js';
3
+
4
+ // Classes
5
+ export { CircuitBreaker, CircuitOpenError, withCircuitBreaker } from './classes.circuitbreaker.js';
6
+ export type { ICircuitBreakerMetrics } from './classes.circuitbreaker.js';
7
+
8
+ export { UpstreamCache } from './classes.upstreamcache.js';
9
+ export type { ICacheSetOptions, ICacheStats } from './classes.upstreamcache.js';
10
+
11
+ export { BaseUpstream } from './classes.baseupstream.js';