@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,521 @@
1
+ import * as plugins from '../plugins.js';
2
+ import type {
3
+ IUpstreamRegistryConfig,
4
+ IUpstreamAuthConfig,
5
+ IUpstreamCacheConfig,
6
+ IUpstreamResilienceConfig,
7
+ IUpstreamResult,
8
+ IUpstreamFetchContext,
9
+ IProtocolUpstreamConfig,
10
+ IUpstreamScopeRule,
11
+ TCircuitState,
12
+ } from './interfaces.upstream.js';
13
+ import {
14
+ DEFAULT_CACHE_CONFIG,
15
+ DEFAULT_RESILIENCE_CONFIG,
16
+ } from './interfaces.upstream.js';
17
+ import { CircuitBreaker, CircuitOpenError, withCircuitBreaker } from './classes.circuitbreaker.js';
18
+ import { UpstreamCache } from './classes.upstreamcache.js';
19
+
20
+ /**
21
+ * Base class for protocol-specific upstream implementations.
22
+ *
23
+ * Provides:
24
+ * - Multi-upstream routing with priority
25
+ * - Scope-based filtering (glob patterns)
26
+ * - Authentication handling
27
+ * - Circuit breaker per upstream
28
+ * - Caching with TTL
29
+ * - Retry with exponential backoff
30
+ * - 429 rate limit handling
31
+ */
32
+ export abstract class BaseUpstream {
33
+ /** Protocol name for logging */
34
+ protected abstract readonly protocolName: string;
35
+
36
+ /** Upstream configuration */
37
+ protected readonly config: IProtocolUpstreamConfig;
38
+
39
+ /** Resolved cache configuration */
40
+ protected readonly cacheConfig: IUpstreamCacheConfig;
41
+
42
+ /** Resolved resilience configuration */
43
+ protected readonly resilienceConfig: IUpstreamResilienceConfig;
44
+
45
+ /** Circuit breakers per upstream */
46
+ protected readonly circuitBreakers: Map<string, CircuitBreaker> = new Map();
47
+
48
+ /** Upstream cache */
49
+ protected readonly cache: UpstreamCache;
50
+
51
+ /** Logger instance */
52
+ protected readonly logger: plugins.smartlog.Smartlog;
53
+
54
+ constructor(config: IProtocolUpstreamConfig, logger?: plugins.smartlog.Smartlog) {
55
+ this.config = config;
56
+ this.cacheConfig = { ...DEFAULT_CACHE_CONFIG, ...config.cache };
57
+ this.resilienceConfig = { ...DEFAULT_RESILIENCE_CONFIG, ...config.resilience };
58
+ this.cache = new UpstreamCache(this.cacheConfig);
59
+ this.logger = logger || new plugins.smartlog.Smartlog({
60
+ logContext: {
61
+ company: 'smartregistry',
62
+ companyunit: 'upstream',
63
+ environment: 'production',
64
+ runtime: 'node',
65
+ }
66
+ });
67
+
68
+ // Initialize circuit breakers for each upstream
69
+ for (const upstream of config.upstreams) {
70
+ const upstreamResilience = { ...this.resilienceConfig, ...upstream.resilience };
71
+ this.circuitBreakers.set(upstream.id, new CircuitBreaker(upstream.id, upstreamResilience));
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Check if upstream is enabled.
77
+ */
78
+ public isEnabled(): boolean {
79
+ return this.config.enabled;
80
+ }
81
+
82
+ /**
83
+ * Get all configured upstreams.
84
+ */
85
+ public getUpstreams(): IUpstreamRegistryConfig[] {
86
+ return this.config.upstreams;
87
+ }
88
+
89
+ /**
90
+ * Get circuit breaker state for an upstream.
91
+ */
92
+ public getCircuitState(upstreamId: string): TCircuitState | null {
93
+ const breaker = this.circuitBreakers.get(upstreamId);
94
+ return breaker ? breaker.getState() : null;
95
+ }
96
+
97
+ /**
98
+ * Get cache statistics.
99
+ */
100
+ public getCacheStats() {
101
+ return this.cache.getStats();
102
+ }
103
+
104
+ /**
105
+ * Fetch a resource from upstreams.
106
+ * Tries upstreams in priority order, respecting circuit breakers and scope rules.
107
+ */
108
+ public async fetch(context: IUpstreamFetchContext): Promise<IUpstreamResult | null> {
109
+ if (!this.config.enabled) {
110
+ return null;
111
+ }
112
+
113
+ // Check cache first
114
+ const cached = this.cache.get(context);
115
+ if (cached && !cached.stale) {
116
+ return {
117
+ success: true,
118
+ status: 200,
119
+ headers: cached.headers,
120
+ body: cached.data,
121
+ upstreamId: cached.upstreamId,
122
+ fromCache: true,
123
+ latencyMs: 0,
124
+ };
125
+ }
126
+
127
+ // Check for negative cache (recent 404)
128
+ if (this.cache.hasNegative(context)) {
129
+ return {
130
+ success: false,
131
+ status: 404,
132
+ headers: {},
133
+ upstreamId: 'cache',
134
+ fromCache: true,
135
+ latencyMs: 0,
136
+ };
137
+ }
138
+
139
+ // Get applicable upstreams sorted by priority
140
+ const applicableUpstreams = this.getApplicableUpstreams(context.resource);
141
+
142
+ if (applicableUpstreams.length === 0) {
143
+ return null;
144
+ }
145
+
146
+ // If we have stale cache, return it immediately and revalidate in background
147
+ if (cached?.stale && this.cacheConfig.staleWhileRevalidate) {
148
+ // Fire and forget revalidation
149
+ this.revalidateInBackground(context, applicableUpstreams);
150
+ return {
151
+ success: true,
152
+ status: 200,
153
+ headers: cached.headers,
154
+ body: cached.data,
155
+ upstreamId: cached.upstreamId,
156
+ fromCache: true,
157
+ latencyMs: 0,
158
+ };
159
+ }
160
+
161
+ // Try each upstream in order
162
+ let lastError: Error | null = null;
163
+
164
+ for (const upstream of applicableUpstreams) {
165
+ const breaker = this.circuitBreakers.get(upstream.id);
166
+ if (!breaker) continue;
167
+
168
+ try {
169
+ const result = await withCircuitBreaker(
170
+ breaker,
171
+ () => this.fetchFromUpstream(upstream, context),
172
+ );
173
+
174
+ // Cache successful responses
175
+ if (result.success && result.body) {
176
+ this.cache.set(
177
+ context,
178
+ Buffer.isBuffer(result.body) ? result.body : Buffer.from(JSON.stringify(result.body)),
179
+ result.headers['content-type'] || 'application/octet-stream',
180
+ result.headers,
181
+ upstream.id,
182
+ );
183
+ }
184
+
185
+ // Cache 404 responses
186
+ if (result.status === 404) {
187
+ this.cache.setNegative(context, upstream.id);
188
+ }
189
+
190
+ return result;
191
+ } catch (error) {
192
+ if (error instanceof CircuitOpenError) {
193
+ this.logger.log('debug', `Circuit open for upstream ${upstream.id}, trying next`);
194
+ } else {
195
+ this.logger.log('warn', `Upstream ${upstream.id} failed: ${(error as Error).message}`);
196
+ }
197
+ lastError = error as Error;
198
+ // Continue to next upstream
199
+ }
200
+ }
201
+
202
+ // All upstreams failed
203
+ if (lastError) {
204
+ this.logger.log('error', `All upstreams failed for ${context.resource}: ${lastError.message}`);
205
+ }
206
+
207
+ return null;
208
+ }
209
+
210
+ /**
211
+ * Invalidate cache for a resource pattern.
212
+ */
213
+ public invalidateCache(pattern: RegExp): number {
214
+ return this.cache.invalidatePattern(pattern);
215
+ }
216
+
217
+ /**
218
+ * Clear all cache entries.
219
+ */
220
+ public clearCache(): void {
221
+ this.cache.clear();
222
+ }
223
+
224
+ /**
225
+ * Stop the upstream (cleanup resources).
226
+ */
227
+ public stop(): void {
228
+ this.cache.stop();
229
+ }
230
+
231
+ /**
232
+ * Get upstreams that apply to a resource, sorted by priority.
233
+ */
234
+ protected getApplicableUpstreams(resource: string): IUpstreamRegistryConfig[] {
235
+ return this.config.upstreams
236
+ .filter(upstream => {
237
+ if (!upstream.enabled) return false;
238
+
239
+ // Check circuit breaker
240
+ const breaker = this.circuitBreakers.get(upstream.id);
241
+ if (breaker && !breaker.canRequest()) return false;
242
+
243
+ // Check scope rules
244
+ return this.matchesScopeRules(resource, upstream.scopeRules);
245
+ })
246
+ .sort((a, b) => a.priority - b.priority);
247
+ }
248
+
249
+ /**
250
+ * Check if a resource matches scope rules.
251
+ * Empty rules = match all.
252
+ */
253
+ protected matchesScopeRules(resource: string, rules?: IUpstreamScopeRule[]): boolean {
254
+ if (!rules || rules.length === 0) {
255
+ return true;
256
+ }
257
+
258
+ // Process rules in order
259
+ // Start with default exclude (nothing matches)
260
+ let matched = false;
261
+
262
+ for (const rule of rules) {
263
+ const isMatch = plugins.minimatch(resource, rule.pattern);
264
+ if (isMatch) {
265
+ matched = rule.action === 'include';
266
+ }
267
+ }
268
+
269
+ return matched;
270
+ }
271
+
272
+ /**
273
+ * Fetch from a specific upstream with retry logic.
274
+ */
275
+ protected async fetchFromUpstream(
276
+ upstream: IUpstreamRegistryConfig,
277
+ context: IUpstreamFetchContext,
278
+ ): Promise<IUpstreamResult> {
279
+ const upstreamResilience = { ...this.resilienceConfig, ...upstream.resilience };
280
+ const startTime = Date.now();
281
+
282
+ let lastError: Error | null = null;
283
+
284
+ for (let attempt = 0; attempt <= upstreamResilience.maxRetries; attempt++) {
285
+ try {
286
+ const result = await this.executeRequest(upstream, context, upstreamResilience.timeoutMs);
287
+ return {
288
+ ...result,
289
+ upstreamId: upstream.id,
290
+ fromCache: false,
291
+ latencyMs: Date.now() - startTime,
292
+ };
293
+ } catch (error) {
294
+ lastError = error as Error;
295
+
296
+ // Don't retry on 4xx errors (except 429)
297
+ if (this.isNonRetryableError(error)) {
298
+ break;
299
+ }
300
+
301
+ // Calculate delay with exponential backoff and jitter
302
+ if (attempt < upstreamResilience.maxRetries) {
303
+ const delay = this.calculateBackoffDelay(
304
+ attempt,
305
+ upstreamResilience.retryDelayMs,
306
+ upstreamResilience.retryMaxDelayMs,
307
+ );
308
+ await this.sleep(delay);
309
+ }
310
+ }
311
+ }
312
+
313
+ throw lastError || new Error('Request failed');
314
+ }
315
+
316
+ /**
317
+ * Execute a single HTTP request to an upstream.
318
+ */
319
+ protected async executeRequest(
320
+ upstream: IUpstreamRegistryConfig,
321
+ context: IUpstreamFetchContext,
322
+ timeoutMs: number,
323
+ ): Promise<Omit<IUpstreamResult, 'upstreamId' | 'fromCache' | 'latencyMs'>> {
324
+ // Build the full URL
325
+ const url = this.buildUpstreamUrl(upstream, context);
326
+
327
+ // Build headers with auth
328
+ const headers = this.buildHeaders(upstream, context);
329
+
330
+ // Make the request using SmartRequest
331
+ const request = plugins.smartrequest.SmartRequest.create()
332
+ .url(url)
333
+ .method(context.method as any)
334
+ .headers(headers)
335
+ .timeout(timeoutMs)
336
+ .handle429Backoff({ maxRetries: 3, fallbackDelay: 1000, maxWaitTime: 30000 });
337
+
338
+ // Add query params if present
339
+ if (Object.keys(context.query).length > 0) {
340
+ request.query(context.query);
341
+ }
342
+
343
+ let response: plugins.smartrequest.ICoreResponse;
344
+
345
+ switch (context.method.toUpperCase()) {
346
+ case 'GET':
347
+ response = await request.get();
348
+ break;
349
+ case 'HEAD':
350
+ // SmartRequest doesn't have head(), use options
351
+ response = await request.method('HEAD').get();
352
+ break;
353
+ default:
354
+ response = await request.get();
355
+ }
356
+
357
+ // Parse response
358
+ const responseHeaders: Record<string, string> = {};
359
+ for (const [key, value] of Object.entries(response.headers)) {
360
+ responseHeaders[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
361
+ }
362
+
363
+ let body: Buffer | any;
364
+ const contentType = responseHeaders['content-type'] || '';
365
+
366
+ if (response.ok) {
367
+ if (contentType.includes('application/json')) {
368
+ body = await response.json();
369
+ } else {
370
+ const arrayBuffer = await response.arrayBuffer();
371
+ body = Buffer.from(arrayBuffer);
372
+ }
373
+ }
374
+
375
+ return {
376
+ success: response.ok,
377
+ status: response.status,
378
+ headers: responseHeaders,
379
+ body,
380
+ };
381
+ }
382
+
383
+ /**
384
+ * Build the full URL for an upstream request.
385
+ * Subclasses can override for protocol-specific URL building.
386
+ */
387
+ protected buildUpstreamUrl(upstream: IUpstreamRegistryConfig, context: IUpstreamFetchContext): string {
388
+ // Remove leading slash if URL already has trailing slash
389
+ let path = context.path;
390
+ if (upstream.url.endsWith('/') && path.startsWith('/')) {
391
+ path = path.slice(1);
392
+ }
393
+ return `${upstream.url}${path}`;
394
+ }
395
+
396
+ /**
397
+ * Build headers including authentication.
398
+ */
399
+ protected buildHeaders(
400
+ upstream: IUpstreamRegistryConfig,
401
+ context: IUpstreamFetchContext,
402
+ ): Record<string, string> {
403
+ const headers: Record<string, string> = { ...context.headers };
404
+
405
+ // Remove host header (will be set by HTTP client)
406
+ delete headers['host'];
407
+
408
+ // Add authentication
409
+ this.addAuthHeaders(headers, upstream.auth);
410
+
411
+ return headers;
412
+ }
413
+
414
+ /**
415
+ * Add authentication headers based on auth config.
416
+ */
417
+ protected addAuthHeaders(headers: Record<string, string>, auth: IUpstreamAuthConfig): void {
418
+ switch (auth.type) {
419
+ case 'basic':
420
+ if (auth.username && auth.password) {
421
+ const credentials = Buffer.from(`${auth.username}:${auth.password}`).toString('base64');
422
+ headers['authorization'] = `Basic ${credentials}`;
423
+ }
424
+ break;
425
+ case 'bearer':
426
+ if (auth.token) {
427
+ headers['authorization'] = `Bearer ${auth.token}`;
428
+ }
429
+ break;
430
+ case 'api-key':
431
+ if (auth.token) {
432
+ const headerName = auth.headerName || 'authorization';
433
+ headers[headerName.toLowerCase()] = auth.token;
434
+ }
435
+ break;
436
+ case 'none':
437
+ default:
438
+ // No authentication
439
+ break;
440
+ }
441
+ }
442
+
443
+ /**
444
+ * Check if an error should not be retried.
445
+ */
446
+ protected isNonRetryableError(error: unknown): boolean {
447
+ // Check for HTTP status errors
448
+ if (error && typeof error === 'object' && 'status' in error) {
449
+ const status = (error as { status: number }).status;
450
+ // Don't retry 4xx errors except 429 (rate limited)
451
+ if (status >= 400 && status < 500 && status !== 429) {
452
+ return true;
453
+ }
454
+ }
455
+ return false;
456
+ }
457
+
458
+ /**
459
+ * Calculate backoff delay with exponential backoff and jitter.
460
+ */
461
+ protected calculateBackoffDelay(
462
+ attempt: number,
463
+ baseDelayMs: number,
464
+ maxDelayMs: number,
465
+ ): number {
466
+ // Exponential backoff: delay = base * 2^attempt
467
+ const exponentialDelay = baseDelayMs * Math.pow(2, attempt);
468
+
469
+ // Cap at max delay
470
+ const cappedDelay = Math.min(exponentialDelay, maxDelayMs);
471
+
472
+ // Add jitter (±25%)
473
+ const jitter = cappedDelay * 0.25 * (Math.random() * 2 - 1);
474
+
475
+ return Math.floor(cappedDelay + jitter);
476
+ }
477
+
478
+ /**
479
+ * Sleep for a specified duration.
480
+ */
481
+ protected sleep(ms: number): Promise<void> {
482
+ return new Promise(resolve => setTimeout(resolve, ms));
483
+ }
484
+
485
+ /**
486
+ * Revalidate cache in background.
487
+ */
488
+ protected async revalidateInBackground(
489
+ context: IUpstreamFetchContext,
490
+ upstreams: IUpstreamRegistryConfig[],
491
+ ): Promise<void> {
492
+ try {
493
+ for (const upstream of upstreams) {
494
+ const breaker = this.circuitBreakers.get(upstream.id);
495
+ if (!breaker || !breaker.canRequest()) continue;
496
+
497
+ try {
498
+ const result = await withCircuitBreaker(
499
+ breaker,
500
+ () => this.fetchFromUpstream(upstream, context),
501
+ );
502
+
503
+ if (result.success && result.body) {
504
+ this.cache.set(
505
+ context,
506
+ Buffer.isBuffer(result.body) ? result.body : Buffer.from(JSON.stringify(result.body)),
507
+ result.headers['content-type'] || 'application/octet-stream',
508
+ result.headers,
509
+ upstream.id,
510
+ );
511
+ return; // Successfully revalidated
512
+ }
513
+ } catch {
514
+ // Continue to next upstream
515
+ }
516
+ }
517
+ } catch (error) {
518
+ this.logger.log('debug', `Background revalidation failed: ${(error as Error).message}`);
519
+ }
520
+ }
521
+ }