@push.rocks/smartregistry 2.2.3 → 2.4.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 (110) 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.d.ts +33 -2
  9. package/dist_ts/classes.smartregistry.js +45 -12
  10. package/dist_ts/composer/classes.composerregistry.d.ts +7 -1
  11. package/dist_ts/composer/classes.composerregistry.js +34 -3
  12. package/dist_ts/composer/classes.composerupstream.d.ts +40 -0
  13. package/dist_ts/composer/classes.composerupstream.js +159 -0
  14. package/dist_ts/composer/index.d.ts +1 -0
  15. package/dist_ts/composer/index.js +2 -1
  16. package/dist_ts/core/classes.authmanager.d.ts +30 -80
  17. package/dist_ts/core/classes.authmanager.js +63 -337
  18. package/dist_ts/core/classes.defaultauthprovider.d.ts +78 -0
  19. package/dist_ts/core/classes.defaultauthprovider.js +311 -0
  20. package/dist_ts/core/classes.registrystorage.d.ts +70 -4
  21. package/dist_ts/core/classes.registrystorage.js +165 -5
  22. package/dist_ts/core/index.d.ts +3 -0
  23. package/dist_ts/core/index.js +7 -2
  24. package/dist_ts/core/interfaces.auth.d.ts +83 -0
  25. package/dist_ts/core/interfaces.auth.js +2 -0
  26. package/dist_ts/core/interfaces.core.d.ts +38 -0
  27. package/dist_ts/core/interfaces.storage.d.ts +120 -0
  28. package/dist_ts/core/interfaces.storage.js +2 -0
  29. package/dist_ts/index.d.ts +1 -0
  30. package/dist_ts/index.js +3 -1
  31. package/dist_ts/maven/classes.mavenregistry.d.ts +12 -1
  32. package/dist_ts/maven/classes.mavenregistry.js +69 -4
  33. package/dist_ts/maven/classes.mavenupstream.d.ts +45 -0
  34. package/dist_ts/maven/classes.mavenupstream.js +153 -0
  35. package/dist_ts/maven/index.d.ts +1 -0
  36. package/dist_ts/maven/index.js +2 -1
  37. package/dist_ts/npm/classes.npmregistry.d.ts +3 -1
  38. package/dist_ts/npm/classes.npmregistry.js +55 -6
  39. package/dist_ts/npm/classes.npmupstream.d.ts +51 -0
  40. package/dist_ts/npm/classes.npmupstream.js +206 -0
  41. package/dist_ts/npm/index.d.ts +1 -0
  42. package/dist_ts/npm/index.js +2 -1
  43. package/dist_ts/oci/classes.ociregistry.d.ts +4 -1
  44. package/dist_ts/oci/classes.ociregistry.js +78 -17
  45. package/dist_ts/oci/classes.ociupstream.d.ts +62 -0
  46. package/dist_ts/oci/classes.ociupstream.js +206 -0
  47. package/dist_ts/oci/index.d.ts +1 -0
  48. package/dist_ts/oci/index.js +2 -1
  49. package/dist_ts/plugins.d.ts +4 -1
  50. package/dist_ts/plugins.js +6 -2
  51. package/dist_ts/pypi/classes.pypiregistry.d.ts +7 -1
  52. package/dist_ts/pypi/classes.pypiregistry.js +60 -4
  53. package/dist_ts/pypi/classes.pypiupstream.d.ts +48 -0
  54. package/dist_ts/pypi/classes.pypiupstream.js +165 -0
  55. package/dist_ts/pypi/index.d.ts +1 -0
  56. package/dist_ts/pypi/index.js +2 -1
  57. package/dist_ts/rubygems/classes.rubygemsregistry.d.ts +7 -1
  58. package/dist_ts/rubygems/classes.rubygemsregistry.js +35 -4
  59. package/dist_ts/rubygems/classes.rubygemsupstream.d.ts +47 -0
  60. package/dist_ts/rubygems/classes.rubygemsupstream.js +184 -0
  61. package/dist_ts/rubygems/index.d.ts +1 -0
  62. package/dist_ts/rubygems/index.js +2 -1
  63. package/dist_ts/upstream/classes.baseupstream.d.ts +112 -0
  64. package/dist_ts/upstream/classes.baseupstream.js +411 -0
  65. package/dist_ts/upstream/classes.circuitbreaker.d.ts +111 -0
  66. package/dist_ts/upstream/classes.circuitbreaker.js +192 -0
  67. package/dist_ts/upstream/classes.upstreamcache.d.ts +170 -0
  68. package/dist_ts/upstream/classes.upstreamcache.js +485 -0
  69. package/dist_ts/upstream/index.d.ts +6 -0
  70. package/dist_ts/upstream/index.js +7 -0
  71. package/dist_ts/upstream/interfaces.upstream.d.ts +169 -0
  72. package/dist_ts/upstream/interfaces.upstream.js +23 -0
  73. package/package.json +4 -2
  74. package/ts/00_commitinfo_data.ts +1 -1
  75. package/ts/cargo/classes.cargoregistry.ts +48 -3
  76. package/ts/cargo/classes.cargoupstream.ts +159 -0
  77. package/ts/cargo/index.ts +1 -0
  78. package/ts/classes.smartregistry.ts +88 -11
  79. package/ts/composer/classes.composerregistry.ts +39 -2
  80. package/ts/composer/classes.composerupstream.ts +200 -0
  81. package/ts/composer/index.ts +1 -0
  82. package/ts/core/classes.authmanager.ts +74 -412
  83. package/ts/core/classes.defaultauthprovider.ts +393 -0
  84. package/ts/core/classes.registrystorage.ts +199 -5
  85. package/ts/core/index.ts +8 -1
  86. package/ts/core/interfaces.auth.ts +91 -0
  87. package/ts/core/interfaces.core.ts +42 -0
  88. package/ts/core/interfaces.storage.ts +130 -0
  89. package/ts/index.ts +3 -0
  90. package/ts/maven/classes.mavenregistry.ts +84 -3
  91. package/ts/maven/classes.mavenupstream.ts +220 -0
  92. package/ts/maven/index.ts +1 -0
  93. package/ts/npm/classes.npmregistry.ts +61 -5
  94. package/ts/npm/classes.npmupstream.ts +260 -0
  95. package/ts/npm/index.ts +1 -0
  96. package/ts/oci/classes.ociregistry.ts +89 -17
  97. package/ts/oci/classes.ociupstream.ts +263 -0
  98. package/ts/oci/index.ts +1 -0
  99. package/ts/plugins.ts +7 -1
  100. package/ts/pypi/classes.pypiregistry.ts +68 -3
  101. package/ts/pypi/classes.pypiupstream.ts +211 -0
  102. package/ts/pypi/index.ts +1 -0
  103. package/ts/rubygems/classes.rubygemsregistry.ts +40 -3
  104. package/ts/rubygems/classes.rubygemsupstream.ts +230 -0
  105. package/ts/rubygems/index.ts +1 -0
  106. package/ts/upstream/classes.baseupstream.ts +526 -0
  107. package/ts/upstream/classes.circuitbreaker.ts +238 -0
  108. package/ts/upstream/classes.upstreamcache.ts +626 -0
  109. package/ts/upstream/index.ts +11 -0
  110. package/ts/upstream/interfaces.upstream.ts +195 -0
@@ -0,0 +1,626 @@
1
+ import type {
2
+ ICacheEntry,
3
+ IUpstreamCacheConfig,
4
+ IUpstreamFetchContext,
5
+ } from './interfaces.upstream.js';
6
+ import { DEFAULT_CACHE_CONFIG } from './interfaces.upstream.js';
7
+ import type { IStorageBackend } from '../core/interfaces.core.js';
8
+
9
+ /**
10
+ * Cache metadata stored alongside cache entries.
11
+ */
12
+ interface ICacheMetadata {
13
+ contentType: string;
14
+ headers: Record<string, string>;
15
+ cachedAt: string;
16
+ expiresAt?: string;
17
+ etag?: string;
18
+ upstreamId: string;
19
+ upstreamUrl: string;
20
+ }
21
+
22
+ /**
23
+ * S3-backed upstream cache with in-memory hot layer.
24
+ *
25
+ * Features:
26
+ * - TTL-based expiration
27
+ * - Stale-while-revalidate support
28
+ * - Negative caching (404s)
29
+ * - Content-type aware caching
30
+ * - ETag support for conditional requests
31
+ * - Multi-upstream support via URL-based cache paths
32
+ * - Persistent S3 storage with in-memory hot layer
33
+ *
34
+ * Cache paths are structured as:
35
+ * cache/{escaped-upstream-url}/{protocol}:{method}:{path}
36
+ *
37
+ * @example
38
+ * ```typescript
39
+ * // In-memory only (default)
40
+ * const cache = new UpstreamCache(config);
41
+ *
42
+ * // With S3 persistence
43
+ * const cache = new UpstreamCache(config, 10000, storage);
44
+ * ```
45
+ */
46
+ export class UpstreamCache {
47
+ /** In-memory hot cache */
48
+ private readonly memoryCache: Map<string, ICacheEntry> = new Map();
49
+
50
+ /** Configuration */
51
+ private readonly config: IUpstreamCacheConfig;
52
+
53
+ /** Maximum in-memory cache entries */
54
+ private readonly maxMemoryEntries: number;
55
+
56
+ /** S3 storage backend (optional) */
57
+ private readonly storage?: IStorageBackend;
58
+
59
+ /** Cleanup interval handle */
60
+ private cleanupInterval: ReturnType<typeof setInterval> | null = null;
61
+
62
+ constructor(
63
+ config?: Partial<IUpstreamCacheConfig>,
64
+ maxMemoryEntries: number = 10000,
65
+ storage?: IStorageBackend
66
+ ) {
67
+ this.config = { ...DEFAULT_CACHE_CONFIG, ...config };
68
+ this.maxMemoryEntries = maxMemoryEntries;
69
+ this.storage = storage;
70
+
71
+ // Start periodic cleanup if caching is enabled
72
+ if (this.config.enabled) {
73
+ this.startCleanup();
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Check if caching is enabled.
79
+ */
80
+ public isEnabled(): boolean {
81
+ return this.config.enabled;
82
+ }
83
+
84
+ /**
85
+ * Check if S3 storage is configured.
86
+ */
87
+ public hasStorage(): boolean {
88
+ return !!this.storage;
89
+ }
90
+
91
+ /**
92
+ * Get cached entry for a request context.
93
+ * Checks memory first, then falls back to S3.
94
+ * Returns null if not found or expired (unless stale-while-revalidate).
95
+ */
96
+ public async get(context: IUpstreamFetchContext, upstreamUrl?: string): Promise<ICacheEntry | null> {
97
+ if (!this.config.enabled) {
98
+ return null;
99
+ }
100
+
101
+ const key = this.buildCacheKey(context, upstreamUrl);
102
+
103
+ // Check memory cache first
104
+ let entry = this.memoryCache.get(key);
105
+
106
+ // If not in memory and we have storage, check S3
107
+ if (!entry && this.storage) {
108
+ entry = await this.loadFromStorage(key);
109
+ if (entry) {
110
+ // Promote to memory cache
111
+ this.memoryCache.set(key, entry);
112
+ }
113
+ }
114
+
115
+ if (!entry) {
116
+ return null;
117
+ }
118
+
119
+ const now = new Date();
120
+
121
+ // Check if entry is expired
122
+ if (entry.expiresAt && entry.expiresAt < now) {
123
+ // Check if we can serve stale content
124
+ if (this.config.staleWhileRevalidate && !entry.stale) {
125
+ const staleAge = (now.getTime() - entry.expiresAt.getTime()) / 1000;
126
+ if (staleAge <= this.config.staleMaxAgeSeconds) {
127
+ // Mark as stale and return
128
+ entry.stale = true;
129
+ return entry;
130
+ }
131
+ }
132
+ // Entry is too old, remove it
133
+ this.memoryCache.delete(key);
134
+ if (this.storage) {
135
+ await this.deleteFromStorage(key).catch(() => {});
136
+ }
137
+ return null;
138
+ }
139
+
140
+ return entry;
141
+ }
142
+
143
+ /**
144
+ * Store a response in the cache (memory and optionally S3).
145
+ */
146
+ public async set(
147
+ context: IUpstreamFetchContext,
148
+ data: Buffer,
149
+ contentType: string,
150
+ headers: Record<string, string>,
151
+ upstreamId: string,
152
+ upstreamUrl: string,
153
+ options?: ICacheSetOptions,
154
+ ): Promise<void> {
155
+ if (!this.config.enabled) {
156
+ return;
157
+ }
158
+
159
+ // Enforce max memory entries limit
160
+ if (this.memoryCache.size >= this.maxMemoryEntries) {
161
+ this.evictOldest();
162
+ }
163
+
164
+ const key = this.buildCacheKey(context, upstreamUrl);
165
+ const now = new Date();
166
+
167
+ // Determine TTL based on content type
168
+ const ttlSeconds = options?.ttlSeconds ?? this.determineTtl(context, contentType, headers);
169
+
170
+ const entry: ICacheEntry = {
171
+ data,
172
+ contentType,
173
+ headers,
174
+ cachedAt: now,
175
+ expiresAt: ttlSeconds > 0 ? new Date(now.getTime() + ttlSeconds * 1000) : undefined,
176
+ etag: headers['etag'] || options?.etag,
177
+ upstreamId,
178
+ stale: false,
179
+ };
180
+
181
+ // Store in memory
182
+ this.memoryCache.set(key, entry);
183
+
184
+ // Store in S3 if available
185
+ if (this.storage) {
186
+ await this.saveToStorage(key, entry, upstreamUrl).catch(() => {});
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Store a negative cache entry (404 response).
192
+ */
193
+ public async setNegative(context: IUpstreamFetchContext, upstreamId: string, upstreamUrl: string): Promise<void> {
194
+ if (!this.config.enabled || this.config.negativeCacheTtlSeconds <= 0) {
195
+ return;
196
+ }
197
+
198
+ const key = this.buildCacheKey(context, upstreamUrl);
199
+ const now = new Date();
200
+
201
+ const entry: ICacheEntry = {
202
+ data: Buffer.from(''),
203
+ contentType: 'application/octet-stream',
204
+ headers: {},
205
+ cachedAt: now,
206
+ expiresAt: new Date(now.getTime() + this.config.negativeCacheTtlSeconds * 1000),
207
+ upstreamId,
208
+ stale: false,
209
+ };
210
+
211
+ this.memoryCache.set(key, entry);
212
+
213
+ if (this.storage) {
214
+ await this.saveToStorage(key, entry, upstreamUrl).catch(() => {});
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Check if there's a negative cache entry for this context.
220
+ */
221
+ public async hasNegative(context: IUpstreamFetchContext, upstreamUrl?: string): Promise<boolean> {
222
+ const entry = await this.get(context, upstreamUrl);
223
+ return entry !== null && entry.data.length === 0;
224
+ }
225
+
226
+ /**
227
+ * Invalidate a specific cache entry.
228
+ */
229
+ public async invalidate(context: IUpstreamFetchContext, upstreamUrl?: string): Promise<boolean> {
230
+ const key = this.buildCacheKey(context, upstreamUrl);
231
+ const deleted = this.memoryCache.delete(key);
232
+
233
+ if (this.storage) {
234
+ await this.deleteFromStorage(key).catch(() => {});
235
+ }
236
+
237
+ return deleted;
238
+ }
239
+
240
+ /**
241
+ * Invalidate all entries matching a pattern.
242
+ * Useful for invalidating all versions of a package.
243
+ */
244
+ public async invalidatePattern(pattern: RegExp): Promise<number> {
245
+ let count = 0;
246
+ for (const key of this.memoryCache.keys()) {
247
+ if (pattern.test(key)) {
248
+ this.memoryCache.delete(key);
249
+ if (this.storage) {
250
+ await this.deleteFromStorage(key).catch(() => {});
251
+ }
252
+ count++;
253
+ }
254
+ }
255
+ return count;
256
+ }
257
+
258
+ /**
259
+ * Invalidate all entries from a specific upstream.
260
+ */
261
+ public async invalidateUpstream(upstreamId: string): Promise<number> {
262
+ let count = 0;
263
+ for (const [key, entry] of this.memoryCache.entries()) {
264
+ if (entry.upstreamId === upstreamId) {
265
+ this.memoryCache.delete(key);
266
+ if (this.storage) {
267
+ await this.deleteFromStorage(key).catch(() => {});
268
+ }
269
+ count++;
270
+ }
271
+ }
272
+ return count;
273
+ }
274
+
275
+ /**
276
+ * Clear all cache entries (memory and S3).
277
+ */
278
+ public async clear(): Promise<void> {
279
+ this.memoryCache.clear();
280
+
281
+ // Note: S3 cleanup would require listing and deleting all cache/* objects
282
+ // This is left as a future enhancement for bulk cleanup
283
+ }
284
+
285
+ /**
286
+ * Get cache statistics.
287
+ */
288
+ public getStats(): ICacheStats {
289
+ let freshCount = 0;
290
+ let staleCount = 0;
291
+ let negativeCount = 0;
292
+ let totalSize = 0;
293
+ const now = new Date();
294
+
295
+ for (const entry of this.memoryCache.values()) {
296
+ totalSize += entry.data.length;
297
+
298
+ if (entry.data.length === 0) {
299
+ negativeCount++;
300
+ } else if (entry.stale || (entry.expiresAt && entry.expiresAt < now)) {
301
+ staleCount++;
302
+ } else {
303
+ freshCount++;
304
+ }
305
+ }
306
+
307
+ return {
308
+ totalEntries: this.memoryCache.size,
309
+ freshEntries: freshCount,
310
+ staleEntries: staleCount,
311
+ negativeEntries: negativeCount,
312
+ totalSizeBytes: totalSize,
313
+ maxEntries: this.maxMemoryEntries,
314
+ enabled: this.config.enabled,
315
+ hasStorage: !!this.storage,
316
+ };
317
+ }
318
+
319
+ /**
320
+ * Stop the cache and cleanup.
321
+ */
322
+ public stop(): void {
323
+ if (this.cleanupInterval) {
324
+ clearInterval(this.cleanupInterval);
325
+ this.cleanupInterval = null;
326
+ }
327
+ }
328
+
329
+ // ========================================================================
330
+ // Storage Methods
331
+ // ========================================================================
332
+
333
+ /**
334
+ * Build storage path for a cache key.
335
+ * Escapes upstream URL for safe use in S3 paths.
336
+ */
337
+ private buildStoragePath(key: string): string {
338
+ return `cache/${key}`;
339
+ }
340
+
341
+ /**
342
+ * Build storage path for cache metadata.
343
+ */
344
+ private buildMetadataPath(key: string): string {
345
+ return `cache/${key}.meta`;
346
+ }
347
+
348
+ /**
349
+ * Load a cache entry from S3 storage.
350
+ */
351
+ private async loadFromStorage(key: string): Promise<ICacheEntry | null> {
352
+ if (!this.storage) return null;
353
+
354
+ try {
355
+ const dataPath = this.buildStoragePath(key);
356
+ const metaPath = this.buildMetadataPath(key);
357
+
358
+ // Load data and metadata in parallel
359
+ const [data, metaBuffer] = await Promise.all([
360
+ this.storage.getObject(dataPath),
361
+ this.storage.getObject(metaPath),
362
+ ]);
363
+
364
+ if (!data || !metaBuffer) {
365
+ return null;
366
+ }
367
+
368
+ const meta: ICacheMetadata = JSON.parse(metaBuffer.toString('utf-8'));
369
+
370
+ return {
371
+ data,
372
+ contentType: meta.contentType,
373
+ headers: meta.headers,
374
+ cachedAt: new Date(meta.cachedAt),
375
+ expiresAt: meta.expiresAt ? new Date(meta.expiresAt) : undefined,
376
+ etag: meta.etag,
377
+ upstreamId: meta.upstreamId,
378
+ stale: false,
379
+ };
380
+ } catch {
381
+ return null;
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Save a cache entry to S3 storage.
387
+ */
388
+ private async saveToStorage(key: string, entry: ICacheEntry, upstreamUrl: string): Promise<void> {
389
+ if (!this.storage) return;
390
+
391
+ const dataPath = this.buildStoragePath(key);
392
+ const metaPath = this.buildMetadataPath(key);
393
+
394
+ const meta: ICacheMetadata = {
395
+ contentType: entry.contentType,
396
+ headers: entry.headers,
397
+ cachedAt: entry.cachedAt.toISOString(),
398
+ expiresAt: entry.expiresAt?.toISOString(),
399
+ etag: entry.etag,
400
+ upstreamId: entry.upstreamId,
401
+ upstreamUrl,
402
+ };
403
+
404
+ // Save data and metadata in parallel
405
+ await Promise.all([
406
+ this.storage.putObject(dataPath, entry.data),
407
+ this.storage.putObject(metaPath, Buffer.from(JSON.stringify(meta), 'utf-8')),
408
+ ]);
409
+ }
410
+
411
+ /**
412
+ * Delete a cache entry from S3 storage.
413
+ */
414
+ private async deleteFromStorage(key: string): Promise<void> {
415
+ if (!this.storage) return;
416
+
417
+ const dataPath = this.buildStoragePath(key);
418
+ const metaPath = this.buildMetadataPath(key);
419
+
420
+ await Promise.all([
421
+ this.storage.deleteObject(dataPath).catch(() => {}),
422
+ this.storage.deleteObject(metaPath).catch(() => {}),
423
+ ]);
424
+ }
425
+
426
+ // ========================================================================
427
+ // Helper Methods
428
+ // ========================================================================
429
+
430
+ /**
431
+ * Escape a URL for safe use in storage paths.
432
+ */
433
+ private escapeUrl(url: string): string {
434
+ // Remove protocol prefix and escape special characters
435
+ return url
436
+ .replace(/^https?:\/\//, '')
437
+ .replace(/[\/\\:*?"<>|]/g, '_')
438
+ .replace(/__+/g, '_');
439
+ }
440
+
441
+ /**
442
+ * Build a unique cache key for a request context.
443
+ * Includes escaped upstream URL for multi-upstream support.
444
+ */
445
+ private buildCacheKey(context: IUpstreamFetchContext, upstreamUrl?: string): string {
446
+ // Include method, protocol, path, and sorted query params
447
+ const queryString = Object.keys(context.query)
448
+ .sort()
449
+ .map(k => `${k}=${context.query[k]}`)
450
+ .join('&');
451
+
452
+ const baseKey = `${context.protocol}:${context.method}:${context.path}${queryString ? '?' + queryString : ''}`;
453
+
454
+ if (upstreamUrl) {
455
+ return `${this.escapeUrl(upstreamUrl)}/${baseKey}`;
456
+ }
457
+
458
+ return baseKey;
459
+ }
460
+
461
+ /**
462
+ * Determine TTL based on content characteristics.
463
+ */
464
+ private determineTtl(
465
+ context: IUpstreamFetchContext,
466
+ contentType: string,
467
+ headers: Record<string, string>,
468
+ ): number {
469
+ // Check for Cache-Control header
470
+ const cacheControl = headers['cache-control'];
471
+ if (cacheControl) {
472
+ const maxAgeMatch = cacheControl.match(/max-age=(\d+)/);
473
+ if (maxAgeMatch) {
474
+ return parseInt(maxAgeMatch[1], 10);
475
+ }
476
+ if (cacheControl.includes('no-store') || cacheControl.includes('no-cache')) {
477
+ return 0;
478
+ }
479
+ }
480
+
481
+ // Check if content is immutable (content-addressable)
482
+ if (this.isImmutableContent(context, contentType)) {
483
+ return this.config.immutableTtlSeconds;
484
+ }
485
+
486
+ // Default TTL for mutable content
487
+ return this.config.defaultTtlSeconds;
488
+ }
489
+
490
+ /**
491
+ * Check if content is immutable (content-addressable).
492
+ */
493
+ private isImmutableContent(context: IUpstreamFetchContext, contentType: string): boolean {
494
+ // OCI blobs with digest are immutable
495
+ if (context.protocol === 'oci' && context.resourceType === 'blob') {
496
+ return true;
497
+ }
498
+
499
+ // NPM tarballs are immutable (versioned)
500
+ if (context.protocol === 'npm' && context.resourceType === 'tarball') {
501
+ return true;
502
+ }
503
+
504
+ // Maven artifacts with version are immutable
505
+ if (context.protocol === 'maven' && context.resourceType === 'artifact') {
506
+ return true;
507
+ }
508
+
509
+ // Cargo crate files are immutable
510
+ if (context.protocol === 'cargo' && context.resourceType === 'crate') {
511
+ return true;
512
+ }
513
+
514
+ // Composer dist files are immutable
515
+ if (context.protocol === 'composer' && context.resourceType === 'dist') {
516
+ return true;
517
+ }
518
+
519
+ // PyPI package files are immutable
520
+ if (context.protocol === 'pypi' && context.resourceType === 'package') {
521
+ return true;
522
+ }
523
+
524
+ // RubyGems .gem files are immutable
525
+ if (context.protocol === 'rubygems' && context.resourceType === 'gem') {
526
+ return true;
527
+ }
528
+
529
+ return false;
530
+ }
531
+
532
+ /**
533
+ * Evict oldest entries to make room for new ones.
534
+ */
535
+ private evictOldest(): void {
536
+ // Evict 10% of max entries
537
+ const evictCount = Math.ceil(this.maxMemoryEntries * 0.1);
538
+ let evicted = 0;
539
+
540
+ // First, try to evict stale entries
541
+ const now = new Date();
542
+ for (const [key, entry] of this.memoryCache.entries()) {
543
+ if (evicted >= evictCount) break;
544
+ if (entry.stale || (entry.expiresAt && entry.expiresAt < now)) {
545
+ this.memoryCache.delete(key);
546
+ evicted++;
547
+ }
548
+ }
549
+
550
+ // If not enough evicted, evict oldest by cachedAt
551
+ if (evicted < evictCount) {
552
+ const entries = Array.from(this.memoryCache.entries())
553
+ .sort((a, b) => a[1].cachedAt.getTime() - b[1].cachedAt.getTime());
554
+
555
+ for (const [key] of entries) {
556
+ if (evicted >= evictCount) break;
557
+ this.memoryCache.delete(key);
558
+ evicted++;
559
+ }
560
+ }
561
+ }
562
+
563
+ /**
564
+ * Start periodic cleanup of expired entries.
565
+ */
566
+ private startCleanup(): void {
567
+ // Run cleanup every minute
568
+ this.cleanupInterval = setInterval(() => {
569
+ this.cleanup();
570
+ }, 60000);
571
+
572
+ // Don't keep the process alive just for cleanup
573
+ if (this.cleanupInterval.unref) {
574
+ this.cleanupInterval.unref();
575
+ }
576
+ }
577
+
578
+ /**
579
+ * Remove all expired entries from memory cache.
580
+ */
581
+ private cleanup(): void {
582
+ const now = new Date();
583
+ const staleDeadline = new Date(now.getTime() - this.config.staleMaxAgeSeconds * 1000);
584
+
585
+ for (const [key, entry] of this.memoryCache.entries()) {
586
+ if (entry.expiresAt) {
587
+ // Remove if past stale deadline
588
+ if (entry.expiresAt < staleDeadline) {
589
+ this.memoryCache.delete(key);
590
+ }
591
+ }
592
+ }
593
+ }
594
+ }
595
+
596
+ /**
597
+ * Options for cache set operation.
598
+ */
599
+ export interface ICacheSetOptions {
600
+ /** Override TTL in seconds */
601
+ ttlSeconds?: number;
602
+ /** ETag for conditional requests */
603
+ etag?: string;
604
+ }
605
+
606
+ /**
607
+ * Cache statistics.
608
+ */
609
+ export interface ICacheStats {
610
+ /** Total number of cached entries in memory */
611
+ totalEntries: number;
612
+ /** Number of fresh (non-expired) entries */
613
+ freshEntries: number;
614
+ /** Number of stale entries (expired but still usable) */
615
+ staleEntries: number;
616
+ /** Number of negative cache entries */
617
+ negativeEntries: number;
618
+ /** Total size of cached data in bytes (memory only) */
619
+ totalSizeBytes: number;
620
+ /** Maximum allowed memory entries */
621
+ maxEntries: number;
622
+ /** Whether caching is enabled */
623
+ enabled: boolean;
624
+ /** Whether S3 storage is configured */
625
+ hasStorage: boolean;
626
+ }
@@ -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';