@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.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/cargo/classes.cargoregistry.d.ts +7 -1
- package/dist_ts/cargo/classes.cargoregistry.js +42 -4
- package/dist_ts/cargo/classes.cargoupstream.d.ts +44 -0
- package/dist_ts/cargo/classes.cargoupstream.js +129 -0
- package/dist_ts/cargo/index.d.ts +1 -0
- package/dist_ts/cargo/index.js +2 -1
- package/dist_ts/classes.smartregistry.js +8 -8
- package/dist_ts/composer/classes.composerregistry.d.ts +7 -1
- package/dist_ts/composer/classes.composerregistry.js +34 -3
- package/dist_ts/composer/classes.composerupstream.d.ts +40 -0
- package/dist_ts/composer/classes.composerupstream.js +159 -0
- package/dist_ts/composer/index.d.ts +1 -0
- package/dist_ts/composer/index.js +2 -1
- package/dist_ts/core/interfaces.core.d.ts +3 -0
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +3 -1
- package/dist_ts/maven/classes.mavenregistry.d.ts +12 -1
- package/dist_ts/maven/classes.mavenregistry.js +69 -4
- package/dist_ts/maven/classes.mavenupstream.d.ts +45 -0
- package/dist_ts/maven/classes.mavenupstream.js +153 -0
- package/dist_ts/maven/index.d.ts +1 -0
- package/dist_ts/maven/index.js +2 -1
- package/dist_ts/npm/classes.npmregistry.d.ts +3 -1
- package/dist_ts/npm/classes.npmregistry.js +55 -6
- package/dist_ts/npm/classes.npmupstream.d.ts +51 -0
- package/dist_ts/npm/classes.npmupstream.js +206 -0
- package/dist_ts/npm/index.d.ts +1 -0
- package/dist_ts/npm/index.js +2 -1
- package/dist_ts/oci/classes.ociregistry.d.ts +4 -1
- package/dist_ts/oci/classes.ociregistry.js +78 -17
- package/dist_ts/oci/classes.ociupstream.d.ts +62 -0
- package/dist_ts/oci/classes.ociupstream.js +206 -0
- package/dist_ts/oci/index.d.ts +1 -0
- package/dist_ts/oci/index.js +2 -1
- package/dist_ts/plugins.d.ts +4 -1
- package/dist_ts/plugins.js +6 -2
- package/dist_ts/pypi/classes.pypiregistry.d.ts +7 -1
- package/dist_ts/pypi/classes.pypiregistry.js +60 -4
- package/dist_ts/pypi/classes.pypiupstream.d.ts +48 -0
- package/dist_ts/pypi/classes.pypiupstream.js +165 -0
- package/dist_ts/pypi/index.d.ts +1 -0
- package/dist_ts/pypi/index.js +2 -1
- package/dist_ts/rubygems/classes.rubygemsregistry.d.ts +7 -1
- package/dist_ts/rubygems/classes.rubygemsregistry.js +35 -4
- package/dist_ts/rubygems/classes.rubygemsupstream.d.ts +47 -0
- package/dist_ts/rubygems/classes.rubygemsupstream.js +184 -0
- package/dist_ts/rubygems/index.d.ts +1 -0
- package/dist_ts/rubygems/index.js +2 -1
- package/dist_ts/upstream/classes.baseupstream.d.ts +112 -0
- package/dist_ts/upstream/classes.baseupstream.js +409 -0
- package/dist_ts/upstream/classes.circuitbreaker.d.ts +111 -0
- package/dist_ts/upstream/classes.circuitbreaker.js +192 -0
- package/dist_ts/upstream/classes.upstreamcache.d.ts +123 -0
- package/dist_ts/upstream/classes.upstreamcache.js +328 -0
- package/dist_ts/upstream/index.d.ts +6 -0
- package/dist_ts/upstream/index.js +7 -0
- package/dist_ts/upstream/interfaces.upstream.d.ts +169 -0
- package/dist_ts/upstream/interfaces.upstream.js +23 -0
- package/package.json +4 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/cargo/classes.cargoregistry.ts +48 -3
- package/ts/cargo/classes.cargoupstream.ts +159 -0
- package/ts/cargo/index.ts +1 -0
- package/ts/classes.smartregistry.ts +49 -7
- package/ts/composer/classes.composerregistry.ts +39 -2
- package/ts/composer/classes.composerupstream.ts +200 -0
- package/ts/composer/index.ts +1 -0
- package/ts/core/interfaces.core.ts +3 -0
- package/ts/index.ts +3 -0
- package/ts/maven/classes.mavenregistry.ts +84 -3
- package/ts/maven/classes.mavenupstream.ts +220 -0
- package/ts/maven/index.ts +1 -0
- package/ts/npm/classes.npmregistry.ts +61 -5
- package/ts/npm/classes.npmupstream.ts +260 -0
- package/ts/npm/index.ts +1 -0
- package/ts/oci/classes.ociregistry.ts +89 -17
- package/ts/oci/classes.ociupstream.ts +263 -0
- package/ts/oci/index.ts +1 -0
- package/ts/plugins.ts +7 -1
- package/ts/pypi/classes.pypiregistry.ts +68 -3
- package/ts/pypi/classes.pypiupstream.ts +211 -0
- package/ts/pypi/index.ts +1 -0
- package/ts/rubygems/classes.rubygemsregistry.ts +40 -3
- package/ts/rubygems/classes.rubygemsupstream.ts +230 -0
- package/ts/rubygems/index.ts +1 -0
- package/ts/upstream/classes.baseupstream.ts +521 -0
- package/ts/upstream/classes.circuitbreaker.ts +238 -0
- package/ts/upstream/classes.upstreamcache.ts +423 -0
- package/ts/upstream/index.ts +11 -0
- 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
|
+
}
|