schemantic 0.3.0 → 0.4.1
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/CHANGELOG.md +204 -174
- package/LICENSE +21 -21
- package/README.md +88 -830
- package/dist/cli/index.d.ts +4 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +185 -61
- package/dist/cli/index.js.map +1 -1
- package/dist/core/configuration.d.ts +6 -0
- package/dist/core/configuration.d.ts.map +1 -0
- package/dist/core/configuration.js +64 -0
- package/dist/core/configuration.js.map +1 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/output-writer.d.ts +7 -0
- package/dist/core/output-writer.d.ts.map +1 -0
- package/dist/core/output-writer.js +255 -0
- package/dist/core/output-writer.js.map +1 -0
- package/dist/core/schemantic.d.ts +8 -2
- package/dist/core/schemantic.d.ts.map +1 -1
- package/dist/core/schemantic.js +196 -71
- package/dist/core/schemantic.js.map +1 -1
- package/dist/generators/api-client-generator.d.ts +10 -20
- package/dist/generators/api-client-generator.d.ts.map +1 -1
- package/dist/generators/api-client-generator.js +374 -424
- package/dist/generators/api-client-generator.js.map +1 -1
- package/dist/generators/base.d.ts.map +1 -1
- package/dist/generators/base.js +7 -25
- package/dist/generators/base.js.map +1 -1
- package/dist/generators/enum-generator.d.ts.map +1 -1
- package/dist/generators/enum-generator.js +30 -3
- package/dist/generators/enum-generator.js.map +1 -1
- package/dist/generators/hook-generator.d.ts +3 -17
- package/dist/generators/hook-generator.d.ts.map +1 -1
- package/dist/generators/hook-generator.js +51 -445
- package/dist/generators/hook-generator.js.map +1 -1
- package/dist/generators/naming.d.ts +10 -0
- package/dist/generators/naming.d.ts.map +1 -0
- package/dist/generators/naming.js +331 -0
- package/dist/generators/naming.js.map +1 -0
- package/dist/generators/object-generator.d.ts +0 -4
- package/dist/generators/object-generator.d.ts.map +1 -1
- package/dist/generators/object-generator.js +13 -101
- package/dist/generators/object-generator.js.map +1 -1
- package/dist/generators/primitive-generator.d.ts +1 -13
- package/dist/generators/primitive-generator.d.ts.map +1 -1
- package/dist/generators/primitive-generator.js +11 -76
- package/dist/generators/primitive-generator.js.map +1 -1
- package/dist/generators/projections.d.ts +10 -0
- package/dist/generators/projections.d.ts.map +1 -0
- package/dist/generators/projections.js +65 -0
- package/dist/generators/projections.js.map +1 -0
- package/dist/generators/schema-type.d.ts +23 -0
- package/dist/generators/schema-type.d.ts.map +1 -0
- package/dist/generators/schema-type.js +233 -0
- package/dist/generators/schema-type.js.map +1 -0
- package/dist/parsers/base.d.ts +2 -0
- package/dist/parsers/base.d.ts.map +1 -1
- package/dist/parsers/base.js +12 -2
- package/dist/parsers/base.js.map +1 -1
- package/dist/parsers/factory.d.ts.map +1 -1
- package/dist/parsers/factory.js +2 -1
- package/dist/parsers/factory.js.map +1 -1
- package/dist/parsers/openapi.d.ts +4 -0
- package/dist/parsers/openapi.d.ts.map +1 -1
- package/dist/parsers/openapi.js +107 -20
- package/dist/parsers/openapi.js.map +1 -1
- package/dist/plugins/branded-types.d.ts.map +1 -1
- package/dist/plugins/branded-types.js +231 -365
- package/dist/plugins/branded-types.js.map +1 -1
- package/dist/plugins/builtin.d.ts.map +1 -1
- package/dist/plugins/builtin.js +6 -141
- package/dist/plugins/builtin.js.map +1 -1
- package/dist/plugins/index.d.ts +4 -0
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +17 -0
- package/dist/plugins/index.js.map +1 -1
- package/dist/plugins/loader.d.ts +5 -1
- package/dist/plugins/loader.d.ts.map +1 -1
- package/dist/plugins/loader.js +42 -5
- package/dist/plugins/loader.js.map +1 -1
- package/dist/plugins/manager.d.ts.map +1 -1
- package/dist/plugins/manager.js +7 -7
- package/dist/plugins/manager.js.map +1 -1
- package/dist/plugins/performance-monitoring.d.ts +31 -1
- package/dist/plugins/performance-monitoring.d.ts.map +1 -1
- package/dist/plugins/performance-monitoring.js +146 -207
- package/dist/plugins/performance-monitoring.js.map +1 -1
- package/dist/plugins/request-deduplication.d.ts.map +1 -1
- package/dist/plugins/request-deduplication.js +151 -586
- package/dist/plugins/request-deduplication.js.map +1 -1
- package/dist/plugins/zod-validation.d.ts +23 -15
- package/dist/plugins/zod-validation.d.ts.map +1 -1
- package/dist/plugins/zod-validation.js +392 -268
- package/dist/plugins/zod-validation.js.map +1 -1
- package/dist/types/core.d.ts +20 -3
- package/dist/types/core.d.ts.map +1 -1
- package/dist/types/core.js +2 -0
- package/dist/types/core.js.map +1 -1
- package/dist/types/openapi.d.ts +14 -1
- package/dist/types/openapi.d.ts.map +1 -1
- package/docs/ADVANCED_PLUGINS_IMPLEMENTATION.md +24 -355
- package/docs/ADVANCED_PLUGIN_DEVELOPMENT.md +33 -309
- package/docs/API.md +80 -601
- package/docs/AUDIT.md +49 -0
- package/docs/CONFIGURATION.md +81 -715
- package/docs/FASTAPI_TUTORIAL.md +109 -1497
- package/docs/NAMING_MIGRATION.md +59 -0
- package/docs/PLUGINS.md +49 -687
- package/docs/PLUGIN_DEVELOPMENT.md +53 -720
- package/docs/REAUDIT.md +399 -0
- package/docs/RELEASING.md +88 -0
- package/docs/REMEDIATION.md +58 -0
- package/docs/superpowers/plans/2026-10-10-audit-remediation.md +113 -0
- package/package.json +114 -95
- package/dist/__tests__/setup.d.ts +0 -6
- package/dist/__tests__/setup.d.ts.map +0 -1
- package/dist/__tests__/setup.js +0 -30
- package/dist/__tests__/setup.js.map +0 -1
- package/dist/__tests__/test-config.d.ts +0 -36
- package/dist/__tests__/test-config.d.ts.map +0 -1
- package/dist/__tests__/test-config.js +0 -562
- package/dist/__tests__/test-config.js.map +0 -1
|
@@ -36,18 +36,12 @@ exports.requestDeduplicationPlugin = {
|
|
|
36
36
|
if (!options.enabled) {
|
|
37
37
|
return;
|
|
38
38
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
// Add deduplication exports
|
|
46
|
-
generatedClient.exports.push("RequestDeduplicator", "CacheManager", "RequestCoalescer", "clearRequestCache", "getCacheStats");
|
|
47
|
-
}
|
|
48
|
-
catch (error) {
|
|
49
|
-
console.warn("Request deduplication plugin afterClientGeneration failed:", error);
|
|
50
|
-
}
|
|
39
|
+
// Add deduplication middleware to client
|
|
40
|
+
const deduplicationCode = generateDeduplicationCode(generatedClient, options);
|
|
41
|
+
generatedClient.content += deduplicationCode;
|
|
42
|
+
// Update dependencies
|
|
43
|
+
// Add deduplication exports
|
|
44
|
+
generatedClient.exports.push("RequestDeduplicator", "CacheManager", "RequestCoalescer", "clearRequestCache", "getCacheStats", "invalidateCache", "withDeduplication", "DeduplicationRequest", "RequestKeyGenerator");
|
|
51
45
|
},
|
|
52
46
|
};
|
|
53
47
|
/**
|
|
@@ -71,580 +65,151 @@ function getPluginOptions(context) {
|
|
|
71
65
|
* Generate request deduplication code for API client
|
|
72
66
|
*/
|
|
73
67
|
function generateDeduplicationCode(_generatedClient, options) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
this.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
this.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
this.
|
|
150
|
-
return this.cache.size;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
url
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
};
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
private static normalizeURL(url: string): string {
|
|
222
|
-
try {
|
|
223
|
-
const urlObj = new URL(url);
|
|
224
|
-
// Sort query parameters for consistent fingerprinting
|
|
225
|
-
const params = new URLSearchParams(urlObj.search);
|
|
226
|
-
const sortedParams = new URLSearchParams();
|
|
227
|
-
Array.from(params.keys()).sort().forEach(key => {
|
|
228
|
-
params.getAll(key).forEach(value => sortedParams.append(key, value));
|
|
229
|
-
});
|
|
230
|
-
urlObj.search = sortedParams.toString();
|
|
231
|
-
return urlObj.toString();
|
|
232
|
-
} catch {
|
|
233
|
-
return url;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
private static normalizeHeaders(headers: Record<string, string>): Record<string, string> {
|
|
238
|
-
const normalized: Record<string, string> = {};
|
|
239
|
-
|
|
240
|
-
// Only include cache-relevant headers and normalize case
|
|
241
|
-
const relevantHeaders = ['authorization', 'content-type', 'accept', 'cache-control'];
|
|
242
|
-
|
|
243
|
-
for (const [key, value] of Object.entries(headers)) {
|
|
244
|
-
const lowerKey = key.toLowerCase();
|
|
245
|
-
if (relevantHeaders.includes(lowerKey)) {
|
|
246
|
-
normalized[lowerKey] = value;
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
return normalized;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
private static simpleHash(str: string): string {
|
|
254
|
-
let hash = 0;
|
|
255
|
-
for (let i = 0; i < str.length; i++) {
|
|
256
|
-
const char = str.charCodeAt(i);
|
|
257
|
-
hash = ((hash << 5) - hash) + char;
|
|
258
|
-
hash = hash & hash; // Convert to 32-bit integer
|
|
259
|
-
}
|
|
260
|
-
return hash.toString(36);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/**
|
|
265
|
-
* Request coalescing manager
|
|
266
|
-
*/
|
|
267
|
-
class RequestCoalescer {
|
|
268
|
-
private readonly pendingRequests = new Map<string, Promise<unknown>>();
|
|
269
|
-
private readonly maxPending: number;
|
|
270
|
-
|
|
271
|
-
constructor(maxPending = ${options.maxPendingRequests || 100}) {
|
|
272
|
-
this.maxPending = maxPending;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* Coalesce identical requests
|
|
277
|
-
*/
|
|
278
|
-
async coalesce<T>(
|
|
279
|
-
fingerprint: string,
|
|
280
|
-
requestFactory: () => Promise<T>
|
|
281
|
-
): Promise<T> {
|
|
282
|
-
// Check if identical request is already pending
|
|
283
|
-
const existingRequest = this.pendingRequests.get(fingerprint);
|
|
284
|
-
if (existingRequest) {
|
|
285
|
-
${options.enableDebugLogging
|
|
286
|
-
? "console.debug(`Request coalesced: ${fingerprint}`);"
|
|
287
|
-
: ""}
|
|
288
|
-
return existingRequest as Promise<T>;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// Check if we're at capacity
|
|
292
|
-
if (this.pendingRequests.size >= this.maxPending) {
|
|
293
|
-
${options.enableDebugLogging
|
|
294
|
-
? "console.warn(`Request coalescing at capacity, executing directly`);"
|
|
295
|
-
: ""}
|
|
296
|
-
return requestFactory();
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// Create new request and track it
|
|
300
|
-
const requestPromise = requestFactory()
|
|
301
|
-
.finally(() => {
|
|
302
|
-
// Clean up when request completes
|
|
303
|
-
this.pendingRequests.delete(fingerprint);
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
this.pendingRequests.set(fingerprint, requestPromise);
|
|
307
|
-
return requestPromise;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* Clear all pending requests
|
|
312
|
-
*/
|
|
313
|
-
clear(): void {
|
|
314
|
-
this.pendingRequests.clear();
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* Get current pending request count
|
|
319
|
-
*/
|
|
320
|
-
getPendingCount(): number {
|
|
321
|
-
return this.pendingRequests.size;
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
/**
|
|
326
|
-
* Cache manager with stale-while-revalidate support
|
|
327
|
-
*/
|
|
328
|
-
class CacheManager<T = unknown> {
|
|
329
|
-
private readonly cache = new LRUCacheWithTTL<string, CacheEntry<T>>();
|
|
330
|
-
private readonly staleRevalidatePromises = new Map<string, Promise<T>>();
|
|
331
|
-
|
|
332
|
-
constructor(private readonly options: {
|
|
333
|
-
defaultTTL: number;
|
|
334
|
-
enableStaleWhileRevalidate: boolean;
|
|
335
|
-
debugLogging: boolean;
|
|
336
|
-
}) {}
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* Get cached response or undefined if not cached/expired
|
|
340
|
-
*/
|
|
341
|
-
get(fingerprint: string): T | undefined {
|
|
342
|
-
const entry = this.cache.get(fingerprint);
|
|
343
|
-
|
|
344
|
-
if (!entry) {
|
|
345
|
-
return undefined;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
const now = Date.now();
|
|
349
|
-
const age = now - entry.timestamp;
|
|
350
|
-
|
|
351
|
-
if (age > entry.ttl) {
|
|
352
|
-
// Entry is expired
|
|
353
|
-
if (this.options.enableStaleWhileRevalidate && !entry.stale) {
|
|
354
|
-
// Mark as stale but return it
|
|
355
|
-
entry.stale = true;
|
|
356
|
-
this.cache.set(fingerprint, entry, entry.ttl);
|
|
357
|
-
${options.enableDebugLogging
|
|
358
|
-
? "this.options.debugLogging && console.debug(`Serving stale data: ${fingerprint}`);"
|
|
359
|
-
: ""}
|
|
360
|
-
return entry.data;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// Entry is too old even for stale-while-revalidate
|
|
364
|
-
this.cache.delete(fingerprint);
|
|
365
|
-
return undefined;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
${options.enableDebugLogging
|
|
369
|
-
? "this.options.debugLogging && console.debug(`Cache hit: ${fingerprint}`);"
|
|
370
|
-
: ""}
|
|
371
|
-
return entry.data;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* Set cached response
|
|
376
|
-
*/
|
|
377
|
-
set(fingerprint: string, data: T, ttl?: number): void {
|
|
378
|
-
const entry: CacheEntry<T> = {
|
|
379
|
-
data,
|
|
380
|
-
timestamp: Date.now(),
|
|
381
|
-
ttl: ttl || this.options.defaultTTL,
|
|
382
|
-
stale: false
|
|
383
|
-
};
|
|
384
|
-
|
|
385
|
-
this.cache.set(fingerprint, entry, entry.ttl);
|
|
386
|
-
${options.enableDebugLogging
|
|
387
|
-
? "this.options.debugLogging && console.debug(`Cache set: ${fingerprint}`);"
|
|
388
|
-
: ""}
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
/**
|
|
392
|
-
* Check if entry exists and is fresh
|
|
393
|
-
*/
|
|
394
|
-
hasFresh(fingerprint: string): boolean {
|
|
395
|
-
const entry = this.cache.get(fingerprint);
|
|
396
|
-
if (!entry) return false;
|
|
397
|
-
|
|
398
|
-
const age = Date.now() - entry.timestamp;
|
|
399
|
-
return age <= entry.ttl && !entry.stale;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
/**
|
|
403
|
-
* Check if entry exists (including stale)
|
|
404
|
-
*/
|
|
405
|
-
has(fingerprint: string): boolean {
|
|
406
|
-
return this.cache.has(fingerprint);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/**
|
|
410
|
-
* Invalidate cache entry
|
|
411
|
-
*/
|
|
412
|
-
invalidate(fingerprint: string): void {
|
|
413
|
-
this.cache.delete(fingerprint);
|
|
414
|
-
this.staleRevalidatePromises.delete(fingerprint);
|
|
415
|
-
${options.enableDebugLogging
|
|
416
|
-
? "this.options.debugLogging && console.debug(`Cache invalidated: ${fingerprint}`);"
|
|
417
|
-
: ""}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
/**
|
|
421
|
-
* Clear entire cache
|
|
422
|
-
*/
|
|
423
|
-
clear(): void {
|
|
424
|
-
this.cache.clear();
|
|
425
|
-
this.staleRevalidatePromises.clear();
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
/**
|
|
429
|
-
* Get cache statistics
|
|
430
|
-
*/
|
|
431
|
-
getStats(): { size: number; hitRate: number } {
|
|
432
|
-
return {
|
|
433
|
-
size: this.cache.size(),
|
|
434
|
-
hitRate: 0 // Would need hit/miss tracking
|
|
435
|
-
};
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
/**
|
|
439
|
-
* Revalidate stale entry in background
|
|
440
|
-
*/
|
|
441
|
-
revalidateInBackground<U extends T>(
|
|
442
|
-
fingerprint: string,
|
|
443
|
-
revalidator: () => Promise<U>
|
|
444
|
-
): void {
|
|
445
|
-
if (this.staleRevalidatePromises.has(fingerprint)) {
|
|
446
|
-
return; // Already revalidating
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
const revalidationPromise = revalidator()
|
|
450
|
-
.then(freshData => {
|
|
451
|
-
this.set(fingerprint, freshData);
|
|
452
|
-
return freshData;
|
|
453
|
-
})
|
|
454
|
-
.catch(error => {
|
|
455
|
-
${options.enableDebugLogging
|
|
456
|
-
? "this.options.debugLogging && console.warn(`Background revalidation failed: ${fingerprint}`, error);"
|
|
457
|
-
: ""}
|
|
458
|
-
throw error;
|
|
459
|
-
})
|
|
460
|
-
.finally(() => {
|
|
461
|
-
this.staleRevalidatePromises.delete(fingerprint);
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
this.staleRevalidatePromises.set(fingerprint, revalidationPromise);
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
/**
|
|
469
|
-
* Main request deduplicator
|
|
470
|
-
*/
|
|
471
|
-
export class RequestDeduplicator {
|
|
472
|
-
private readonly cacheManager: CacheManager;
|
|
473
|
-
private readonly coalescer: RequestCoalescer;
|
|
474
|
-
private readonly endpointPolicies: Record<string, any>;
|
|
475
|
-
|
|
476
|
-
constructor() {
|
|
477
|
-
this.cacheManager = new CacheManager({
|
|
478
|
-
defaultTTL: ${options.defaultTTL || 5 * 60 * 1000},
|
|
479
|
-
enableStaleWhileRevalidate: ${options.enableStaleWhileRevalidate || true},
|
|
480
|
-
debugLogging: ${options.enableDebugLogging || false}
|
|
481
|
-
});
|
|
482
|
-
|
|
483
|
-
this.coalescer = new RequestCoalescer(${options.maxPendingRequests || 100});
|
|
484
|
-
this.endpointPolicies = ${JSON.stringify(options.endpointPolicies || {})};
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
/**
|
|
488
|
-
* Execute request with deduplication
|
|
489
|
-
*/
|
|
490
|
-
async execute<T>(
|
|
491
|
-
method: string,
|
|
492
|
-
url: string,
|
|
493
|
-
requestFactory: () => Promise<T>,
|
|
494
|
-
options: {
|
|
495
|
-
headers?: Record<string, string>;
|
|
496
|
-
body?: unknown;
|
|
497
|
-
params?: Record<string, unknown>;
|
|
498
|
-
skipCache?: boolean;
|
|
499
|
-
customTTL?: number;
|
|
500
|
-
} = {}
|
|
501
|
-
): Promise<T> {
|
|
502
|
-
if (options.skipCache) {
|
|
503
|
-
return requestFactory();
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
// Generate request fingerprint
|
|
507
|
-
const fingerprint = RequestFingerprintGenerator.generate(
|
|
508
|
-
method,
|
|
509
|
-
url,
|
|
510
|
-
options.headers,
|
|
511
|
-
options.body,
|
|
512
|
-
options.params
|
|
513
|
-
);
|
|
514
|
-
|
|
515
|
-
// Check endpoint-specific policy
|
|
516
|
-
const policy = this.getEndpointPolicy(url);
|
|
517
|
-
if (!policy.enabled) {
|
|
518
|
-
return requestFactory();
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
// Try cache first
|
|
522
|
-
const cachedResult = this.cacheManager.get(fingerprint);
|
|
523
|
-
if (cachedResult !== undefined) {
|
|
524
|
-
// If stale and stale-while-revalidate is enabled, revalidate in background
|
|
525
|
-
if (policy.staleWhileRevalidate && !this.cacheManager.hasFresh(fingerprint)) {
|
|
526
|
-
this.cacheManager.revalidateInBackground(fingerprint, requestFactory);
|
|
527
|
-
}
|
|
528
|
-
return cachedResult as T;
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
// No cache hit, execute request with coalescing
|
|
532
|
-
const result = await this.coalescer.coalesce(fingerprint, requestFactory);
|
|
533
|
-
|
|
534
|
-
// Cache the result
|
|
535
|
-
const ttl = options.customTTL || policy.ttl || ${options.defaultTTL || 5 * 60 * 1000};
|
|
536
|
-
this.cacheManager.set(fingerprint, result, ttl);
|
|
537
|
-
|
|
538
|
-
return result;
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
/**
|
|
542
|
-
* Invalidate cache for specific pattern
|
|
543
|
-
*/
|
|
544
|
-
invalidate(pattern: string | RegExp): void {
|
|
545
|
-
// In a real implementation, this would iterate through cache keys
|
|
546
|
-
// and invalidate matching entries
|
|
547
|
-
if (typeof pattern === 'string') {
|
|
548
|
-
const fingerprint = RequestFingerprintGenerator.generate('GET', pattern);
|
|
549
|
-
this.cacheManager.invalidate(fingerprint);
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
/**
|
|
554
|
-
* Clear all caches
|
|
555
|
-
*/
|
|
556
|
-
clearCache(): void {
|
|
557
|
-
this.cacheManager.clear();
|
|
558
|
-
this.coalescer.clear();
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
/**
|
|
562
|
-
* Get cache statistics
|
|
563
|
-
*/
|
|
564
|
-
getStats(): {
|
|
565
|
-
cacheSize: number;
|
|
566
|
-
pendingRequests: number;
|
|
567
|
-
cacheHitRate: number;
|
|
568
|
-
} {
|
|
569
|
-
return {
|
|
570
|
-
cacheSize: this.cacheManager.getStats().size,
|
|
571
|
-
pendingRequests: this.coalescer.getPendingCount(),
|
|
572
|
-
cacheHitRate: this.cacheManager.getStats().hitRate
|
|
573
|
-
};
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
private getEndpointPolicy(url: string): {
|
|
577
|
-
enabled: boolean;
|
|
578
|
-
ttl: number;
|
|
579
|
-
staleWhileRevalidate: boolean;
|
|
580
|
-
} {
|
|
581
|
-
// Simple URL matching - in production, use more sophisticated matching
|
|
582
|
-
for (const [pattern, policy] of Object.entries(this.endpointPolicies)) {
|
|
583
|
-
if (url.includes(pattern)) {
|
|
584
|
-
return {
|
|
585
|
-
enabled: policy.enabled ?? true,
|
|
586
|
-
ttl: policy.ttl ?? ${options.defaultTTL || 5 * 60 * 1000},
|
|
587
|
-
staleWhileRevalidate: policy.staleWhileRevalidate ?? ${options.enableStaleWhileRevalidate || true}
|
|
588
|
-
};
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
return {
|
|
593
|
-
enabled: true,
|
|
594
|
-
ttl: ${options.defaultTTL || 5 * 60 * 1000},
|
|
595
|
-
staleWhileRevalidate: ${options.enableStaleWhileRevalidate || true}
|
|
596
|
-
};
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
/**
|
|
601
|
-
* Global deduplicator instance
|
|
602
|
-
*/
|
|
603
|
-
const globalDeduplicator = new RequestDeduplicator();
|
|
604
|
-
|
|
605
|
-
/**
|
|
606
|
-
* Utility functions
|
|
607
|
-
*/
|
|
608
|
-
export function clearRequestCache(): void {
|
|
609
|
-
globalDeduplicator.clearCache();
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
export function getCacheStats(): ReturnType<RequestDeduplicator['getStats']> {
|
|
613
|
-
return globalDeduplicator.getStats();
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
export function invalidateCache(pattern: string | RegExp): void {
|
|
617
|
-
globalDeduplicator.invalidate(pattern);
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
/**
|
|
621
|
-
* Request wrapper with deduplication
|
|
622
|
-
*/
|
|
623
|
-
export function withDeduplication<T extends (...args: any[]) => Promise<any>>(
|
|
624
|
-
requestFn: T,
|
|
625
|
-
options: {
|
|
626
|
-
method: string;
|
|
627
|
-
urlExtractor: (...args: Parameters<T>) => string;
|
|
628
|
-
headersExtractor?: (...args: Parameters<T>) => Record<string, string>;
|
|
629
|
-
bodyExtractor?: (...args: Parameters<T>) => unknown;
|
|
630
|
-
paramsExtractor?: (...args: Parameters<T>) => Record<string, unknown>;
|
|
631
|
-
}
|
|
632
|
-
): T {
|
|
633
|
-
return ((...args: Parameters<T>) => {
|
|
634
|
-
const url = options.urlExtractor(...args);
|
|
635
|
-
const headers = options.headersExtractor?.(...args);
|
|
636
|
-
const body = options.bodyExtractor?.(...args);
|
|
637
|
-
const params = options.paramsExtractor?.(...args);
|
|
638
|
-
|
|
639
|
-
return globalDeduplicator.execute(
|
|
640
|
-
options.method,
|
|
641
|
-
url,
|
|
642
|
-
() => requestFn(...args),
|
|
643
|
-
{ headers, body, params }
|
|
644
|
-
);
|
|
645
|
-
}) as T;
|
|
646
|
-
}`;
|
|
68
|
+
if (options.customKeyGenerator !== undefined) {
|
|
69
|
+
throw new Error("Generation-time customKeyGenerator names are unsupported; pass a callback to the runtime RequestDeduplicator constructor");
|
|
70
|
+
}
|
|
71
|
+
return `
|
|
72
|
+
/** Request identity includes all supplied headers. For implicit cookie/session credentials,
|
|
73
|
+
* supply cacheScope, create one deduplicator per session, or use skipCache. Keys are never logged. */
|
|
74
|
+
export interface DeduplicationRequest {
|
|
75
|
+
method: string; url: string; headers: Record<string, string>; body?: unknown;
|
|
76
|
+
params?: Record<string, unknown>; cacheScope?: string;
|
|
77
|
+
}
|
|
78
|
+
export type RequestKeyGenerator = (request: DeduplicationRequest) => string;
|
|
79
|
+
function canonicalRequestValue(value: unknown, seen = new Set<unknown>()): string {
|
|
80
|
+
if (value === null) return 'null';
|
|
81
|
+
if (typeof value !== 'object') {
|
|
82
|
+
if (typeof value === 'function' || typeof value === 'symbol') throw new Error('Unsupported cache identity');
|
|
83
|
+
return typeof value + ':' + JSON.stringify(typeof value === 'number' && Object.is(value, -0) ? '-0' : String(value));
|
|
84
|
+
}
|
|
85
|
+
if (seen.has(value)) throw new Error('Cyclic cache identity');
|
|
86
|
+
seen.add(value);
|
|
87
|
+
try {
|
|
88
|
+
if (value instanceof Date) return 'date:' + value.toISOString();
|
|
89
|
+
if (Array.isArray(value)) return '[' + Array.from(value, item => canonicalRequestValue(item, seen)).join(',') + ']';
|
|
90
|
+
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) throw new Error('Unsupported cache identity');
|
|
91
|
+
return '{' + Object.keys(value).sort().map(key => JSON.stringify(key) + ':' + canonicalRequestValue((value as Record<string, unknown>)[key], seen)).join(',') + '}';
|
|
92
|
+
} finally { seen.delete(value); }
|
|
93
|
+
}
|
|
94
|
+
function requestIdentity(request: DeduplicationRequest): string {
|
|
95
|
+
const headers: Record<string, string> = {};
|
|
96
|
+
new Headers(request.headers).forEach((value, key) => { headers[key] = value; });
|
|
97
|
+
// Preserve URL query ordering: repeated/order-sensitive query values can affect server responses.
|
|
98
|
+
return canonicalRequestValue({ ...request, method: request.method.toUpperCase(), headers });
|
|
99
|
+
}
|
|
100
|
+
export class RequestCoalescer {
|
|
101
|
+
private readonly pending = new Map<string, Promise<unknown>>();
|
|
102
|
+
constructor(private readonly maxPending = ${options.maxPendingRequests ?? 100}) {}
|
|
103
|
+
async coalesce<T>(key: string, factory: () => Promise<T>): Promise<T> {
|
|
104
|
+
const existing = this.pending.get(key);
|
|
105
|
+
if (existing) return existing as Promise<T>;
|
|
106
|
+
if (this.pending.size >= this.maxPending) return factory();
|
|
107
|
+
const promise = Promise.resolve().then(factory).finally(() => {
|
|
108
|
+
if (this.pending.get(key) === promise) this.pending.delete(key);
|
|
109
|
+
});
|
|
110
|
+
this.pending.set(key, promise); return promise;
|
|
111
|
+
}
|
|
112
|
+
clear(): void { this.pending.clear(); }
|
|
113
|
+
getPendingCount(): number { return this.pending.size; }
|
|
114
|
+
}
|
|
115
|
+
interface CacheEntry<T> { data: T; timestamp: number; ttl: number; staleWhileRevalidate: boolean; }
|
|
116
|
+
export class CacheManager<T = unknown> {
|
|
117
|
+
private readonly cache = new Map<string, CacheEntry<T>>();
|
|
118
|
+
private readonly revalidations = new Map<string, Promise<unknown>>();
|
|
119
|
+
private hits = 0; private misses = 0; private evictions = 0;
|
|
120
|
+
constructor(private readonly options: { defaultTTL: number; enableStaleWhileRevalidate: boolean; debugLogging: boolean; maxSize?: number }) {}
|
|
121
|
+
get(key: string, allowStale = this.options.enableStaleWhileRevalidate): T | undefined {
|
|
122
|
+
const entry = this.cache.get(key);
|
|
123
|
+
if (!entry || (Date.now() - entry.timestamp >= entry.ttl && !(allowStale && entry.staleWhileRevalidate))) {
|
|
124
|
+
this.misses++; if (entry) this.cache.delete(key); return undefined;
|
|
125
|
+
}
|
|
126
|
+
this.hits++; this.cache.delete(key); this.cache.set(key, entry); return entry.data;
|
|
127
|
+
}
|
|
128
|
+
set(key: string, data: T, ttl = this.options.defaultTTL, staleWhileRevalidate = this.options.enableStaleWhileRevalidate): void {
|
|
129
|
+
if ((this.options.maxSize ?? ${options.maxCacheSize ?? 1000}) <= 0) return;
|
|
130
|
+
this.cache.delete(key);
|
|
131
|
+
if (this.cache.size >= (this.options.maxSize ?? ${options.maxCacheSize ?? 1000})) {
|
|
132
|
+
const oldest = this.cache.keys().next().value;
|
|
133
|
+
if (oldest !== undefined) { this.cache.delete(oldest); this.evictions++; }
|
|
134
|
+
}
|
|
135
|
+
this.cache.set(key, { data, ttl, timestamp: Date.now(), staleWhileRevalidate });
|
|
136
|
+
}
|
|
137
|
+
hasFresh(key: string): boolean { const e = this.cache.get(key); return !!e && Date.now() - e.timestamp < e.ttl; }
|
|
138
|
+
has(key: string): boolean { return this.cache.has(key); }
|
|
139
|
+
invalidate(key: string): void { this.cache.delete(key); this.revalidations.delete(key); }
|
|
140
|
+
clear(): void { this.cache.clear(); this.revalidations.clear(); this.hits = 0; this.misses = 0; this.evictions = 0; }
|
|
141
|
+
keys(): IterableIterator<string> { return this.cache.keys(); }
|
|
142
|
+
getStats(): { size: number; hitRate: number; hits: number; misses: number; evictions: number } {
|
|
143
|
+
for (const [key, entry] of this.cache) if (!entry.staleWhileRevalidate && Date.now() - entry.timestamp >= entry.ttl) this.cache.delete(key);
|
|
144
|
+
return { size: this.cache.size, hitRate: this.hits + this.misses ? this.hits / (this.hits + this.misses) : 0, hits: this.hits, misses: this.misses, evictions: this.evictions };
|
|
145
|
+
}
|
|
146
|
+
revalidateInBackground<U extends T>(key: string, factory: () => Promise<U>, ttl = this.options.defaultTTL, staleWhileRevalidate = this.options.enableStaleWhileRevalidate): void {
|
|
147
|
+
if (this.revalidations.has(key)) return;
|
|
148
|
+
let refresh: Promise<U>; try { refresh = factory(); } catch { return; }
|
|
149
|
+
const promise = refresh.then(data => {
|
|
150
|
+
// Invalidation or clear revokes pending writes.
|
|
151
|
+
if (this.revalidations.get(key) === promise) this.set(key, data, ttl, staleWhileRevalidate);
|
|
152
|
+
}).catch(() => undefined).finally(() => { if (this.revalidations.get(key) === promise) this.revalidations.delete(key); });
|
|
153
|
+
this.revalidations.set(key, promise);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
export class RequestDeduplicator {
|
|
157
|
+
private readonly cacheManager = new CacheManager({ defaultTTL: ${options.defaultTTL ?? 300000}, enableStaleWhileRevalidate: ${options.enableStaleWhileRevalidate ?? true}, debugLogging: ${options.enableDebugLogging ?? false}, maxSize: ${options.maxCacheSize ?? 1000} });
|
|
158
|
+
private readonly coalescer = new RequestCoalescer();
|
|
159
|
+
private readonly urls = new Map<string, Set<string>>();
|
|
160
|
+
private epoch = 0;
|
|
161
|
+
private readonly endpointPolicies: Record<string, { enabled?: boolean; ttl?: number; staleWhileRevalidate?: boolean }> = ${JSON.stringify(options.endpointPolicies ?? {})};
|
|
162
|
+
constructor(private readonly runtimeOptions: { customKeyGenerator?: RequestKeyGenerator } = {}) {}
|
|
163
|
+
async execute<T>(method: string, url: string, factory: () => Promise<T>, options: { headers?: Record<string, string>; body?: unknown; params?: Record<string, unknown>; cacheScope?: string; skipCache?: boolean; customTTL?: number } = {}): Promise<T> {
|
|
164
|
+
if (options.skipCache || !['GET', 'HEAD'].includes(method.toUpperCase())) return factory();
|
|
165
|
+
const policy = this.getEndpointPolicy(url); if (!policy.enabled) return factory();
|
|
166
|
+
const request: DeduplicationRequest = { method: method.toUpperCase(), url, headers: options.headers ?? {}, ...(options.body !== undefined ? { body: options.body } : {}), ...(options.params !== undefined ? { params: options.params } : {}), ...(options.cacheScope !== undefined ? { cacheScope: options.cacheScope } : {}) };
|
|
167
|
+
const keyGenerator = this.runtimeOptions.customKeyGenerator;
|
|
168
|
+
let key: string;
|
|
169
|
+
if (keyGenerator) key = keyGenerator(request);
|
|
170
|
+
else { try { key = requestIdentity(request); } catch { return factory(); } }
|
|
171
|
+
const ttl = options.customTTL ?? policy.ttl;
|
|
172
|
+
const cached = this.cacheManager.get(key, policy.staleWhileRevalidate);
|
|
173
|
+
if (cached !== undefined || this.cacheManager.has(key)) {
|
|
174
|
+
if (policy.staleWhileRevalidate && !this.cacheManager.hasFresh(key)) this.cacheManager.revalidateInBackground(key, factory, ttl, policy.staleWhileRevalidate);
|
|
175
|
+
return cached as T;
|
|
176
|
+
}
|
|
177
|
+
const epoch = this.epoch;
|
|
178
|
+
const result = ${options.enableCoalescing ?? true} ? await this.coalescer.coalesce(key, factory) : await factory();
|
|
179
|
+
if (epoch === this.epoch) {
|
|
180
|
+
this.cacheManager.set(key, result, ttl, policy.staleWhileRevalidate);
|
|
181
|
+
const urls = this.urls.get(key) ?? new Set<string>(); urls.add(url); this.urls.set(key, urls);
|
|
182
|
+
for (const known of this.urls.keys()) if (!this.cacheManager.has(known)) this.urls.delete(known);
|
|
183
|
+
}
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
invalidate(pattern: string | RegExp): void {
|
|
187
|
+
this.epoch++; this.coalescer.clear();
|
|
188
|
+
for (const [key, urls] of this.urls) {
|
|
189
|
+
const matches = Array.from(urls).some(url => { if (typeof pattern === 'string') return url.includes(pattern); pattern.lastIndex = 0; return pattern.test(url); });
|
|
190
|
+
if (matches) { this.cacheManager.invalidate(key); this.urls.delete(key); }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
clearCache(): void { this.epoch++; this.cacheManager.clear(); this.coalescer.clear(); this.urls.clear(); }
|
|
194
|
+
getStats(): { cacheSize: number; pendingRequests: number; cacheHitRate: number; hits: number; misses: number; evictions: number } {
|
|
195
|
+
const stats = this.cacheManager.getStats(); return { cacheSize: stats.size, pendingRequests: this.coalescer.getPendingCount(), cacheHitRate: stats.hitRate, hits: stats.hits, misses: stats.misses, evictions: stats.evictions };
|
|
196
|
+
}
|
|
197
|
+
private getEndpointPolicy(url: string): { enabled: boolean; ttl: number; staleWhileRevalidate: boolean } {
|
|
198
|
+
const match = Object.entries(this.endpointPolicies).find(([pattern]) => url.includes(pattern));
|
|
199
|
+
const policy = match?.[1]; return { enabled: policy?.enabled ?? true, ttl: policy?.ttl ?? ${options.defaultTTL ?? 300000}, staleWhileRevalidate: policy?.staleWhileRevalidate ?? ${options.enableStaleWhileRevalidate ?? true} };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const globalDeduplicator = new RequestDeduplicator();
|
|
203
|
+
export function clearRequestCache(): void { globalDeduplicator.clearCache(); }
|
|
204
|
+
export function getCacheStats(): ReturnType<RequestDeduplicator['getStats']> { return globalDeduplicator.getStats(); }
|
|
205
|
+
export function invalidateCache(pattern: string | RegExp): void { globalDeduplicator.invalidate(pattern); }
|
|
206
|
+
export function withDeduplication<T extends (...args: any[]) => Promise<any>>(requestFn: T, options: { method: string; urlExtractor: (...args: Parameters<T>) => string; headersExtractor?: (...args: Parameters<T>) => Record<string, string>; bodyExtractor?: (...args: Parameters<T>) => unknown; paramsExtractor?: (...args: Parameters<T>) => Record<string, unknown>; cacheScopeExtractor?: (...args: Parameters<T>) => string }): T {
|
|
207
|
+
return ((...args: Parameters<T>) => {
|
|
208
|
+
const headers = options.headersExtractor?.(...args); const body = options.bodyExtractor?.(...args); const params = options.paramsExtractor?.(...args); const cacheScope = options.cacheScopeExtractor?.(...args);
|
|
209
|
+
return globalDeduplicator.execute(options.method, options.urlExtractor(...args), () => requestFn(...args), { ...(headers !== undefined ? { headers } : {}), ...(body !== undefined ? { body } : {}), ...(params !== undefined ? { params } : {}), ...(cacheScope !== undefined ? { cacheScope } : {}) });
|
|
210
|
+
}) as T;
|
|
211
|
+
}
|
|
212
|
+
`;
|
|
647
213
|
}
|
|
648
|
-
// Export plugin for registration
|
|
649
214
|
exports.default = exports.requestDeduplicationPlugin;
|
|
650
215
|
//# sourceMappingURL=request-deduplication.js.map
|