@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.
- 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.d.ts +33 -2
- package/dist_ts/classes.smartregistry.js +45 -12
- 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/classes.authmanager.d.ts +30 -80
- package/dist_ts/core/classes.authmanager.js +63 -337
- package/dist_ts/core/classes.defaultauthprovider.d.ts +78 -0
- package/dist_ts/core/classes.defaultauthprovider.js +311 -0
- package/dist_ts/core/classes.registrystorage.d.ts +70 -4
- package/dist_ts/core/classes.registrystorage.js +165 -5
- package/dist_ts/core/index.d.ts +3 -0
- package/dist_ts/core/index.js +7 -2
- package/dist_ts/core/interfaces.auth.d.ts +83 -0
- package/dist_ts/core/interfaces.auth.js +2 -0
- package/dist_ts/core/interfaces.core.d.ts +38 -0
- package/dist_ts/core/interfaces.storage.d.ts +120 -0
- package/dist_ts/core/interfaces.storage.js +2 -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 +411 -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 +170 -0
- package/dist_ts/upstream/classes.upstreamcache.js +485 -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 +88 -11
- 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/classes.authmanager.ts +74 -412
- package/ts/core/classes.defaultauthprovider.ts +393 -0
- package/ts/core/classes.registrystorage.ts +199 -5
- package/ts/core/index.ts +8 -1
- package/ts/core/interfaces.auth.ts +91 -0
- package/ts/core/interfaces.core.ts +42 -0
- package/ts/core/interfaces.storage.ts +130 -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 +526 -0
- package/ts/upstream/classes.circuitbreaker.ts +238 -0
- package/ts/upstream/classes.upstreamcache.ts +626 -0
- package/ts/upstream/index.ts +11 -0
- 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';
|