@push.rocks/smartregistry 2.3.0 → 2.5.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 (33) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/classes.smartregistry.d.ts +33 -2
  3. package/dist_ts/classes.smartregistry.js +38 -5
  4. package/dist_ts/core/classes.authmanager.d.ts +30 -80
  5. package/dist_ts/core/classes.authmanager.js +63 -337
  6. package/dist_ts/core/classes.defaultauthprovider.d.ts +78 -0
  7. package/dist_ts/core/classes.defaultauthprovider.js +311 -0
  8. package/dist_ts/core/classes.registrystorage.d.ts +70 -4
  9. package/dist_ts/core/classes.registrystorage.js +165 -5
  10. package/dist_ts/core/index.d.ts +3 -0
  11. package/dist_ts/core/index.js +7 -2
  12. package/dist_ts/core/interfaces.auth.d.ts +83 -0
  13. package/dist_ts/core/interfaces.auth.js +2 -0
  14. package/dist_ts/core/interfaces.core.d.ts +35 -0
  15. package/dist_ts/core/interfaces.storage.d.ts +120 -0
  16. package/dist_ts/core/interfaces.storage.js +2 -0
  17. package/dist_ts/upstream/classes.baseupstream.d.ts +2 -2
  18. package/dist_ts/upstream/classes.baseupstream.js +16 -14
  19. package/dist_ts/upstream/classes.upstreamcache.d.ts +69 -22
  20. package/dist_ts/upstream/classes.upstreamcache.js +207 -50
  21. package/package.json +1 -1
  22. package/readme.md +225 -1
  23. package/ts/00_commitinfo_data.ts +1 -1
  24. package/ts/classes.smartregistry.ts +39 -4
  25. package/ts/core/classes.authmanager.ts +74 -412
  26. package/ts/core/classes.defaultauthprovider.ts +393 -0
  27. package/ts/core/classes.registrystorage.ts +199 -5
  28. package/ts/core/index.ts +8 -1
  29. package/ts/core/interfaces.auth.ts +91 -0
  30. package/ts/core/interfaces.core.ts +39 -0
  31. package/ts/core/interfaces.storage.ts +130 -0
  32. package/ts/upstream/classes.baseupstream.ts +20 -15
  33. package/ts/upstream/classes.upstreamcache.ts +256 -53
@@ -4,9 +4,23 @@ import type {
4
4
  IUpstreamFetchContext,
5
5
  } from './interfaces.upstream.js';
6
6
  import { DEFAULT_CACHE_CONFIG } from './interfaces.upstream.js';
7
+ import type { IStorageBackend } from '../core/interfaces.core.js';
7
8
 
8
9
  /**
9
- * In-memory cache for upstream responses.
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.
10
24
  *
11
25
  * Features:
12
26
  * - TTL-based expiration
@@ -14,26 +28,45 @@ import { DEFAULT_CACHE_CONFIG } from './interfaces.upstream.js';
14
28
  * - Negative caching (404s)
15
29
  * - Content-type aware caching
16
30
  * - ETag support for conditional requests
31
+ * - Multi-upstream support via URL-based cache paths
32
+ * - Persistent S3 storage with in-memory hot layer
17
33
  *
18
- * Note: This is an in-memory implementation. For production with persistence,
19
- * extend this class to use RegistryStorage for S3-backed caching.
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
+ * ```
20
45
  */
21
46
  export class UpstreamCache {
22
- /** Cache storage */
23
- private readonly cache: Map<string, ICacheEntry> = new Map();
47
+ /** In-memory hot cache */
48
+ private readonly memoryCache: Map<string, ICacheEntry> = new Map();
24
49
 
25
50
  /** Configuration */
26
51
  private readonly config: IUpstreamCacheConfig;
27
52
 
28
- /** Maximum cache entries (prevents memory bloat) */
29
- private readonly maxEntries: number;
53
+ /** Maximum in-memory cache entries */
54
+ private readonly maxMemoryEntries: number;
55
+
56
+ /** S3 storage backend (optional) */
57
+ private readonly storage?: IStorageBackend;
30
58
 
31
59
  /** Cleanup interval handle */
32
60
  private cleanupInterval: ReturnType<typeof setInterval> | null = null;
33
61
 
34
- constructor(config?: Partial<IUpstreamCacheConfig>, maxEntries: number = 10000) {
62
+ constructor(
63
+ config?: Partial<IUpstreamCacheConfig>,
64
+ maxMemoryEntries: number = 10000,
65
+ storage?: IStorageBackend
66
+ ) {
35
67
  this.config = { ...DEFAULT_CACHE_CONFIG, ...config };
36
- this.maxEntries = maxEntries;
68
+ this.maxMemoryEntries = maxMemoryEntries;
69
+ this.storage = storage;
37
70
 
38
71
  // Start periodic cleanup if caching is enabled
39
72
  if (this.config.enabled) {
@@ -48,17 +81,36 @@ export class UpstreamCache {
48
81
  return this.config.enabled;
49
82
  }
50
83
 
84
+ /**
85
+ * Check if S3 storage is configured.
86
+ */
87
+ public hasStorage(): boolean {
88
+ return !!this.storage;
89
+ }
90
+
51
91
  /**
52
92
  * Get cached entry for a request context.
93
+ * Checks memory first, then falls back to S3.
53
94
  * Returns null if not found or expired (unless stale-while-revalidate).
54
95
  */
55
- public get(context: IUpstreamFetchContext): ICacheEntry | null {
96
+ public async get(context: IUpstreamFetchContext, upstreamUrl?: string): Promise<ICacheEntry | null> {
56
97
  if (!this.config.enabled) {
57
98
  return null;
58
99
  }
59
100
 
60
- const key = this.buildCacheKey(context);
61
- const entry = this.cache.get(key);
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
+ }
62
114
 
63
115
  if (!entry) {
64
116
  return null;
@@ -78,7 +130,10 @@ export class UpstreamCache {
78
130
  }
79
131
  }
80
132
  // Entry is too old, remove it
81
- this.cache.delete(key);
133
+ this.memoryCache.delete(key);
134
+ if (this.storage) {
135
+ await this.deleteFromStorage(key).catch(() => {});
136
+ }
82
137
  return null;
83
138
  }
84
139
 
@@ -86,26 +141,27 @@ export class UpstreamCache {
86
141
  }
87
142
 
88
143
  /**
89
- * Store a response in the cache.
144
+ * Store a response in the cache (memory and optionally S3).
90
145
  */
91
- public set(
146
+ public async set(
92
147
  context: IUpstreamFetchContext,
93
148
  data: Buffer,
94
149
  contentType: string,
95
150
  headers: Record<string, string>,
96
151
  upstreamId: string,
152
+ upstreamUrl: string,
97
153
  options?: ICacheSetOptions,
98
- ): void {
154
+ ): Promise<void> {
99
155
  if (!this.config.enabled) {
100
156
  return;
101
157
  }
102
158
 
103
- // Enforce max entries limit
104
- if (this.cache.size >= this.maxEntries) {
159
+ // Enforce max memory entries limit
160
+ if (this.memoryCache.size >= this.maxMemoryEntries) {
105
161
  this.evictOldest();
106
162
  }
107
163
 
108
- const key = this.buildCacheKey(context);
164
+ const key = this.buildCacheKey(context, upstreamUrl);
109
165
  const now = new Date();
110
166
 
111
167
  // Determine TTL based on content type
@@ -122,18 +178,24 @@ export class UpstreamCache {
122
178
  stale: false,
123
179
  };
124
180
 
125
- this.cache.set(key, entry);
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
+ }
126
188
  }
127
189
 
128
190
  /**
129
191
  * Store a negative cache entry (404 response).
130
192
  */
131
- public setNegative(context: IUpstreamFetchContext, upstreamId: string): void {
193
+ public async setNegative(context: IUpstreamFetchContext, upstreamId: string, upstreamUrl: string): Promise<void> {
132
194
  if (!this.config.enabled || this.config.negativeCacheTtlSeconds <= 0) {
133
195
  return;
134
196
  }
135
197
 
136
- const key = this.buildCacheKey(context);
198
+ const key = this.buildCacheKey(context, upstreamUrl);
137
199
  const now = new Date();
138
200
 
139
201
  const entry: ICacheEntry = {
@@ -146,34 +208,47 @@ export class UpstreamCache {
146
208
  stale: false,
147
209
  };
148
210
 
149
- this.cache.set(key, entry);
211
+ this.memoryCache.set(key, entry);
212
+
213
+ if (this.storage) {
214
+ await this.saveToStorage(key, entry, upstreamUrl).catch(() => {});
215
+ }
150
216
  }
151
217
 
152
218
  /**
153
219
  * Check if there's a negative cache entry for this context.
154
220
  */
155
- public hasNegative(context: IUpstreamFetchContext): boolean {
156
- const entry = this.get(context);
221
+ public async hasNegative(context: IUpstreamFetchContext, upstreamUrl?: string): Promise<boolean> {
222
+ const entry = await this.get(context, upstreamUrl);
157
223
  return entry !== null && entry.data.length === 0;
158
224
  }
159
225
 
160
226
  /**
161
227
  * Invalidate a specific cache entry.
162
228
  */
163
- public invalidate(context: IUpstreamFetchContext): boolean {
164
- const key = this.buildCacheKey(context);
165
- return this.cache.delete(key);
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;
166
238
  }
167
239
 
168
240
  /**
169
241
  * Invalidate all entries matching a pattern.
170
242
  * Useful for invalidating all versions of a package.
171
243
  */
172
- public invalidatePattern(pattern: RegExp): number {
244
+ public async invalidatePattern(pattern: RegExp): Promise<number> {
173
245
  let count = 0;
174
- for (const key of this.cache.keys()) {
246
+ for (const key of this.memoryCache.keys()) {
175
247
  if (pattern.test(key)) {
176
- this.cache.delete(key);
248
+ this.memoryCache.delete(key);
249
+ if (this.storage) {
250
+ await this.deleteFromStorage(key).catch(() => {});
251
+ }
177
252
  count++;
178
253
  }
179
254
  }
@@ -183,11 +258,14 @@ export class UpstreamCache {
183
258
  /**
184
259
  * Invalidate all entries from a specific upstream.
185
260
  */
186
- public invalidateUpstream(upstreamId: string): number {
261
+ public async invalidateUpstream(upstreamId: string): Promise<number> {
187
262
  let count = 0;
188
- for (const [key, entry] of this.cache.entries()) {
263
+ for (const [key, entry] of this.memoryCache.entries()) {
189
264
  if (entry.upstreamId === upstreamId) {
190
- this.cache.delete(key);
265
+ this.memoryCache.delete(key);
266
+ if (this.storage) {
267
+ await this.deleteFromStorage(key).catch(() => {});
268
+ }
191
269
  count++;
192
270
  }
193
271
  }
@@ -195,10 +273,13 @@ export class UpstreamCache {
195
273
  }
196
274
 
197
275
  /**
198
- * Clear all cache entries.
276
+ * Clear all cache entries (memory and S3).
199
277
  */
200
- public clear(): void {
201
- this.cache.clear();
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
202
283
  }
203
284
 
204
285
  /**
@@ -211,7 +292,7 @@ export class UpstreamCache {
211
292
  let totalSize = 0;
212
293
  const now = new Date();
213
294
 
214
- for (const entry of this.cache.values()) {
295
+ for (const entry of this.memoryCache.values()) {
215
296
  totalSize += entry.data.length;
216
297
 
217
298
  if (entry.data.length === 0) {
@@ -224,13 +305,14 @@ export class UpstreamCache {
224
305
  }
225
306
 
226
307
  return {
227
- totalEntries: this.cache.size,
308
+ totalEntries: this.memoryCache.size,
228
309
  freshEntries: freshCount,
229
310
  staleEntries: staleCount,
230
311
  negativeEntries: negativeCount,
231
312
  totalSizeBytes: totalSize,
232
- maxEntries: this.maxEntries,
313
+ maxEntries: this.maxMemoryEntries,
233
314
  enabled: this.config.enabled,
315
+ hasStorage: !!this.storage,
234
316
  };
235
317
  }
236
318
 
@@ -244,17 +326,136 @@ export class UpstreamCache {
244
326
  }
245
327
  }
246
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
+
247
441
  /**
248
442
  * Build a unique cache key for a request context.
443
+ * Includes escaped upstream URL for multi-upstream support.
249
444
  */
250
- private buildCacheKey(context: IUpstreamFetchContext): string {
445
+ private buildCacheKey(context: IUpstreamFetchContext, upstreamUrl?: string): string {
251
446
  // Include method, protocol, path, and sorted query params
252
447
  const queryString = Object.keys(context.query)
253
448
  .sort()
254
449
  .map(k => `${k}=${context.query[k]}`)
255
450
  .join('&');
256
451
 
257
- return `${context.protocol}:${context.method}:${context.path}${queryString ? '?' + queryString : ''}`;
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;
258
459
  }
259
460
 
260
461
  /**
@@ -333,27 +534,27 @@ export class UpstreamCache {
333
534
  */
334
535
  private evictOldest(): void {
335
536
  // Evict 10% of max entries
336
- const evictCount = Math.ceil(this.maxEntries * 0.1);
537
+ const evictCount = Math.ceil(this.maxMemoryEntries * 0.1);
337
538
  let evicted = 0;
338
539
 
339
540
  // First, try to evict stale entries
340
541
  const now = new Date();
341
- for (const [key, entry] of this.cache.entries()) {
542
+ for (const [key, entry] of this.memoryCache.entries()) {
342
543
  if (evicted >= evictCount) break;
343
544
  if (entry.stale || (entry.expiresAt && entry.expiresAt < now)) {
344
- this.cache.delete(key);
545
+ this.memoryCache.delete(key);
345
546
  evicted++;
346
547
  }
347
548
  }
348
549
 
349
550
  // If not enough evicted, evict oldest by cachedAt
350
551
  if (evicted < evictCount) {
351
- const entries = Array.from(this.cache.entries())
552
+ const entries = Array.from(this.memoryCache.entries())
352
553
  .sort((a, b) => a[1].cachedAt.getTime() - b[1].cachedAt.getTime());
353
554
 
354
555
  for (const [key] of entries) {
355
556
  if (evicted >= evictCount) break;
356
- this.cache.delete(key);
557
+ this.memoryCache.delete(key);
357
558
  evicted++;
358
559
  }
359
560
  }
@@ -375,17 +576,17 @@ export class UpstreamCache {
375
576
  }
376
577
 
377
578
  /**
378
- * Remove all expired entries.
579
+ * Remove all expired entries from memory cache.
379
580
  */
380
581
  private cleanup(): void {
381
582
  const now = new Date();
382
583
  const staleDeadline = new Date(now.getTime() - this.config.staleMaxAgeSeconds * 1000);
383
584
 
384
- for (const [key, entry] of this.cache.entries()) {
585
+ for (const [key, entry] of this.memoryCache.entries()) {
385
586
  if (entry.expiresAt) {
386
587
  // Remove if past stale deadline
387
588
  if (entry.expiresAt < staleDeadline) {
388
- this.cache.delete(key);
589
+ this.memoryCache.delete(key);
389
590
  }
390
591
  }
391
592
  }
@@ -406,7 +607,7 @@ export interface ICacheSetOptions {
406
607
  * Cache statistics.
407
608
  */
408
609
  export interface ICacheStats {
409
- /** Total number of cached entries */
610
+ /** Total number of cached entries in memory */
410
611
  totalEntries: number;
411
612
  /** Number of fresh (non-expired) entries */
412
613
  freshEntries: number;
@@ -414,10 +615,12 @@ export interface ICacheStats {
414
615
  staleEntries: number;
415
616
  /** Number of negative cache entries */
416
617
  negativeEntries: number;
417
- /** Total size of cached data in bytes */
618
+ /** Total size of cached data in bytes (memory only) */
418
619
  totalSizeBytes: number;
419
- /** Maximum allowed entries */
620
+ /** Maximum allowed memory entries */
420
621
  maxEntries: number;
421
622
  /** Whether caching is enabled */
422
623
  enabled: boolean;
624
+ /** Whether S3 storage is configured */
625
+ hasStorage: boolean;
423
626
  }