layercache 1.2.2 → 1.2.4
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/README.md +119 -89
- package/dist/{chunk-ZMDB5KOK.js → chunk-7V7XAB74.js} +24 -1
- package/dist/{chunk-46UH7LNM.js → chunk-KOYGHLVP.js} +142 -18
- package/dist/{chunk-IXCMHVHP.js → chunk-QHWG7QS5.js} +1 -1
- package/dist/cli.cjs +37 -3
- package/dist/cli.js +15 -4
- package/dist/{edge-DLpdQN0W.d.ts → edge-Dw97n89L.d.cts} +33 -1
- package/dist/{edge-DLpdQN0W.d.cts → edge-Dw97n89L.d.ts} +33 -1
- package/dist/edge.cjs +165 -17
- package/dist/edge.d.cts +1 -1
- package/dist/edge.d.ts +1 -1
- package/dist/edge.js +2 -2
- package/dist/index.cjs +657 -146
- package/dist/index.d.cts +10 -2
- package/dist/index.d.ts +10 -2
- package/dist/index.js +447 -84
- package/package.json +4 -4
- package/packages/nestjs/dist/index.cjs +558 -91
- package/packages/nestjs/dist/index.d.cts +24 -0
- package/packages/nestjs/dist/index.d.ts +24 -0
- package/packages/nestjs/dist/index.js +558 -91
package/dist/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
RedisTagIndex
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-QHWG7QS5.js";
|
|
4
4
|
import {
|
|
5
5
|
MemoryLayer,
|
|
6
6
|
TagIndex,
|
|
7
7
|
createHonoCacheMiddleware
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-KOYGHLVP.js";
|
|
9
9
|
import {
|
|
10
10
|
PatternMatcher,
|
|
11
11
|
createStoredValueEnvelope,
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
remainingStoredTtlSeconds,
|
|
16
16
|
resolveStoredValue,
|
|
17
17
|
unwrapStoredValue
|
|
18
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-7V7XAB74.js";
|
|
19
19
|
|
|
20
20
|
// src/CacheStack.ts
|
|
21
21
|
import { EventEmitter } from "events";
|
|
@@ -266,6 +266,59 @@ function addMap(base, delta) {
|
|
|
266
266
|
return result;
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
+
// src/internal/CacheKeyDiscovery.ts
|
|
270
|
+
var CacheKeyDiscovery = class {
|
|
271
|
+
constructor(options) {
|
|
272
|
+
this.options = options;
|
|
273
|
+
}
|
|
274
|
+
options;
|
|
275
|
+
async collectKeysWithPrefix(prefix) {
|
|
276
|
+
const { tagIndex } = this.options;
|
|
277
|
+
const matches = new Set(
|
|
278
|
+
tagIndex.keysForPrefix ? await tagIndex.keysForPrefix(prefix) : await tagIndex.matchPattern(`${prefix}*`)
|
|
279
|
+
);
|
|
280
|
+
await Promise.all(
|
|
281
|
+
this.options.layers.map(async (layer) => {
|
|
282
|
+
if (!layer.keys || this.options.shouldSkipLayer(layer)) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
const keys = await layer.keys();
|
|
287
|
+
for (const key of keys) {
|
|
288
|
+
if (key.startsWith(prefix)) {
|
|
289
|
+
matches.add(key);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
} catch (error) {
|
|
293
|
+
await this.options.handleLayerFailure(layer, "invalidate-prefix-scan", error);
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
);
|
|
297
|
+
return [...matches];
|
|
298
|
+
}
|
|
299
|
+
async collectKeysMatchingPattern(pattern) {
|
|
300
|
+
const matches = new Set(await this.options.tagIndex.matchPattern(pattern));
|
|
301
|
+
await Promise.all(
|
|
302
|
+
this.options.layers.map(async (layer) => {
|
|
303
|
+
if (!layer.keys || this.options.shouldSkipLayer(layer)) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
const keys = await layer.keys();
|
|
308
|
+
for (const key of keys) {
|
|
309
|
+
if (PatternMatcher.matches(pattern, key)) {
|
|
310
|
+
matches.add(key);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
} catch (error) {
|
|
314
|
+
await this.options.handleLayerFailure(layer, "invalidate-pattern-scan", error);
|
|
315
|
+
}
|
|
316
|
+
})
|
|
317
|
+
);
|
|
318
|
+
return [...matches];
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
269
322
|
// src/internal/CircuitBreakerManager.ts
|
|
270
323
|
var CircuitBreakerManager = class {
|
|
271
324
|
breakers = /* @__PURE__ */ new Map();
|
|
@@ -360,8 +413,9 @@ var CircuitBreakerManager = class {
|
|
|
360
413
|
|
|
361
414
|
// src/internal/FetchRateLimiter.ts
|
|
362
415
|
var FetchRateLimiter = class {
|
|
363
|
-
queue = [];
|
|
364
416
|
buckets = /* @__PURE__ */ new Map();
|
|
417
|
+
queuesByBucket = /* @__PURE__ */ new Map();
|
|
418
|
+
pendingBuckets = /* @__PURE__ */ new Set();
|
|
365
419
|
fetcherBuckets = /* @__PURE__ */ new WeakMap();
|
|
366
420
|
nextFetcherBucketId = 0;
|
|
367
421
|
drainTimer;
|
|
@@ -374,13 +428,17 @@ var FetchRateLimiter = class {
|
|
|
374
428
|
return task();
|
|
375
429
|
}
|
|
376
430
|
return new Promise((resolve2, reject) => {
|
|
377
|
-
this.
|
|
378
|
-
|
|
431
|
+
const bucketKey = this.resolveBucketKey(normalized, context);
|
|
432
|
+
const queue = this.queuesByBucket.get(bucketKey) ?? [];
|
|
433
|
+
queue.push({
|
|
434
|
+
bucketKey,
|
|
379
435
|
options: normalized,
|
|
380
436
|
task,
|
|
381
437
|
resolve: resolve2,
|
|
382
438
|
reject
|
|
383
439
|
});
|
|
440
|
+
this.queuesByBucket.set(bucketKey, queue);
|
|
441
|
+
this.pendingBuckets.add(bucketKey);
|
|
384
442
|
this.drain();
|
|
385
443
|
});
|
|
386
444
|
}
|
|
@@ -423,22 +481,30 @@ var FetchRateLimiter = class {
|
|
|
423
481
|
clearTimeout(this.drainTimer);
|
|
424
482
|
this.drainTimer = void 0;
|
|
425
483
|
}
|
|
426
|
-
while (this.
|
|
427
|
-
let
|
|
484
|
+
while (this.pendingBuckets.size > 0) {
|
|
485
|
+
let nextBucketKey;
|
|
428
486
|
let nextWaitMs = Number.POSITIVE_INFINITY;
|
|
429
|
-
for (
|
|
430
|
-
const
|
|
487
|
+
for (const bucketKey of this.pendingBuckets) {
|
|
488
|
+
const queue2 = this.queuesByBucket.get(bucketKey);
|
|
489
|
+
if (!queue2 || queue2.length === 0) {
|
|
490
|
+
this.pendingBuckets.delete(bucketKey);
|
|
491
|
+
this.queuesByBucket.delete(bucketKey);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
const next2 = queue2[0];
|
|
431
495
|
if (!next2) {
|
|
496
|
+
this.pendingBuckets.delete(bucketKey);
|
|
497
|
+
this.queuesByBucket.delete(bucketKey);
|
|
432
498
|
continue;
|
|
433
499
|
}
|
|
434
|
-
const waitMs = this.waitTime(
|
|
500
|
+
const waitMs = this.waitTime(bucketKey, next2.options);
|
|
435
501
|
if (waitMs <= 0) {
|
|
436
|
-
|
|
502
|
+
nextBucketKey = bucketKey;
|
|
437
503
|
break;
|
|
438
504
|
}
|
|
439
505
|
nextWaitMs = Math.min(nextWaitMs, waitMs);
|
|
440
506
|
}
|
|
441
|
-
if (
|
|
507
|
+
if (!nextBucketKey) {
|
|
442
508
|
if (Number.isFinite(nextWaitMs)) {
|
|
443
509
|
this.drainTimer = setTimeout(() => {
|
|
444
510
|
this.drainTimer = void 0;
|
|
@@ -448,15 +514,32 @@ var FetchRateLimiter = class {
|
|
|
448
514
|
}
|
|
449
515
|
return;
|
|
450
516
|
}
|
|
451
|
-
const
|
|
517
|
+
const queue = this.queuesByBucket.get(nextBucketKey);
|
|
518
|
+
const next = queue?.shift();
|
|
452
519
|
if (!next) {
|
|
453
|
-
|
|
520
|
+
this.pendingBuckets.delete(nextBucketKey);
|
|
521
|
+
this.queuesByBucket.delete(nextBucketKey);
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (!queue || queue.length === 0) {
|
|
525
|
+
this.pendingBuckets.delete(nextBucketKey);
|
|
526
|
+
this.queuesByBucket.delete(nextBucketKey);
|
|
454
527
|
}
|
|
455
528
|
const bucket = this.bucketState(next.bucketKey);
|
|
529
|
+
if (bucket.cleanupTimer) {
|
|
530
|
+
clearTimeout(bucket.cleanupTimer);
|
|
531
|
+
bucket.cleanupTimer = void 0;
|
|
532
|
+
}
|
|
456
533
|
bucket.active += 1;
|
|
457
|
-
|
|
534
|
+
if (next.options.intervalMs && next.options.maxPerInterval) {
|
|
535
|
+
bucket.startedAt.push(Date.now());
|
|
536
|
+
}
|
|
458
537
|
void next.task().then(next.resolve, next.reject).finally(() => {
|
|
459
538
|
bucket.active -= 1;
|
|
539
|
+
if ((this.queuesByBucket.get(next.bucketKey)?.length ?? 0) > 0) {
|
|
540
|
+
this.pendingBuckets.add(next.bucketKey);
|
|
541
|
+
}
|
|
542
|
+
this.cleanupBucket(next.bucketKey, bucket, next.options.intervalMs);
|
|
460
543
|
this.drain();
|
|
461
544
|
});
|
|
462
545
|
}
|
|
@@ -498,6 +581,31 @@ var FetchRateLimiter = class {
|
|
|
498
581
|
this.buckets.set(bucketKey, bucket);
|
|
499
582
|
return bucket;
|
|
500
583
|
}
|
|
584
|
+
cleanupBucket(bucketKey, bucket, intervalMs) {
|
|
585
|
+
const queued = this.queuesByBucket.get(bucketKey)?.length ?? 0;
|
|
586
|
+
if (queued === 0 && bucket.active === 0 && bucket.startedAt.length === 0) {
|
|
587
|
+
this.buckets.delete(bucketKey);
|
|
588
|
+
this.queuesByBucket.delete(bucketKey);
|
|
589
|
+
this.pendingBuckets.delete(bucketKey);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
if (!intervalMs || bucket.active > 0 || queued > 0) {
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
if (bucket.cleanupTimer) {
|
|
596
|
+
clearTimeout(bucket.cleanupTimer);
|
|
597
|
+
}
|
|
598
|
+
bucket.cleanupTimer = setTimeout(() => {
|
|
599
|
+
bucket.cleanupTimer = void 0;
|
|
600
|
+
this.prune(bucket, Date.now(), intervalMs);
|
|
601
|
+
if (bucket.active === 0 && bucket.startedAt.length === 0 && (this.queuesByBucket.get(bucketKey)?.length ?? 0) === 0) {
|
|
602
|
+
this.buckets.delete(bucketKey);
|
|
603
|
+
this.queuesByBucket.delete(bucketKey);
|
|
604
|
+
this.pendingBuckets.delete(bucketKey);
|
|
605
|
+
}
|
|
606
|
+
}, intervalMs);
|
|
607
|
+
bucket.cleanupTimer.unref?.();
|
|
608
|
+
}
|
|
501
609
|
};
|
|
502
610
|
|
|
503
611
|
// src/internal/MetricsCollector.ts
|
|
@@ -686,6 +794,41 @@ var TtlResolver = class {
|
|
|
686
794
|
}
|
|
687
795
|
};
|
|
688
796
|
|
|
797
|
+
// src/serialization/JsonSerializer.ts
|
|
798
|
+
var DANGEROUS_JSON_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
799
|
+
var JsonSerializer = class {
|
|
800
|
+
serialize(value) {
|
|
801
|
+
return JSON.stringify(value);
|
|
802
|
+
}
|
|
803
|
+
deserialize(payload) {
|
|
804
|
+
const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
|
|
805
|
+
return sanitizeJsonValue(JSON.parse(normalized), 0);
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
var MAX_SANITIZE_DEPTH = 200;
|
|
809
|
+
function sanitizeJsonValue(value, depth) {
|
|
810
|
+
if (depth > MAX_SANITIZE_DEPTH) {
|
|
811
|
+
return value;
|
|
812
|
+
}
|
|
813
|
+
if (Array.isArray(value)) {
|
|
814
|
+
return value.map((entry) => sanitizeJsonValue(entry, depth + 1));
|
|
815
|
+
}
|
|
816
|
+
if (!isPlainObject(value)) {
|
|
817
|
+
return value;
|
|
818
|
+
}
|
|
819
|
+
const sanitized = {};
|
|
820
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
821
|
+
if (DANGEROUS_JSON_KEYS.has(key)) {
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
sanitized[key] = sanitizeJsonValue(entry, depth + 1);
|
|
825
|
+
}
|
|
826
|
+
return sanitized;
|
|
827
|
+
}
|
|
828
|
+
function isPlainObject(value) {
|
|
829
|
+
return Object.prototype.toString.call(value) === "[object Object]";
|
|
830
|
+
}
|
|
831
|
+
|
|
689
832
|
// src/stampede/StampedeGuard.ts
|
|
690
833
|
import { Mutex as Mutex2 } from "async-mutex";
|
|
691
834
|
var StampedeGuard = class {
|
|
@@ -696,7 +839,8 @@ var StampedeGuard = class {
|
|
|
696
839
|
return await entry.mutex.runExclusive(task);
|
|
697
840
|
} finally {
|
|
698
841
|
entry.references -= 1;
|
|
699
|
-
|
|
842
|
+
const current = this.mutexes.get(key);
|
|
843
|
+
if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
|
|
700
844
|
this.mutexes.delete(key);
|
|
701
845
|
}
|
|
702
846
|
}
|
|
@@ -726,8 +870,10 @@ var CacheMissError = class extends Error {
|
|
|
726
870
|
var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
|
|
727
871
|
var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
|
|
728
872
|
var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
|
|
873
|
+
var DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 3e4;
|
|
729
874
|
var MAX_CACHE_KEY_LENGTH = 1024;
|
|
730
875
|
var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
|
|
876
|
+
var DANGEROUS_OBJECT_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
731
877
|
var DebugLogger = class {
|
|
732
878
|
enabled;
|
|
733
879
|
constructor(enabled) {
|
|
@@ -774,6 +920,29 @@ var CacheStack = class extends EventEmitter {
|
|
|
774
920
|
const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
|
|
775
921
|
this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
|
|
776
922
|
this.tagIndex = options.tagIndex ?? new TagIndex();
|
|
923
|
+
this.keyDiscovery = new CacheKeyDiscovery({
|
|
924
|
+
layers: this.layers,
|
|
925
|
+
tagIndex: this.tagIndex,
|
|
926
|
+
shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
|
|
927
|
+
handleLayerFailure: async (layer, operation, error) => {
|
|
928
|
+
await this.handleLayerFailure(layer, operation, error);
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
if (!options.tagIndex && layers.some((layer) => layer.isLocal === false)) {
|
|
932
|
+
this.logger.warn?.(
|
|
933
|
+
"Using the default in-memory TagIndex with a shared cache layer only tracks keys seen by this process. Use RedisTagIndex for cross-instance tag invalidation."
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
if (!options.tagIndex && layers.some((layer) => layer.isLocal === false && !layer.keys)) {
|
|
937
|
+
this.logger.warn?.(
|
|
938
|
+
"Using the default in-memory TagIndex with a shared cache layer that does not implement keys() can leave invalidateByPattern() and invalidateByPrefix() incomplete after restarts. Use RedisTagIndex or implement keys() on the shared layer."
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
if (options.invalidationBus && options.broadcastL1Invalidation === void 0 && options.publishSetInvalidation === void 0) {
|
|
942
|
+
this.logger.warn?.(
|
|
943
|
+
"broadcastL1Invalidation defaults to false when an invalidation bus is configured; opt in explicitly if write-triggered L1 invalidation is desired."
|
|
944
|
+
);
|
|
945
|
+
}
|
|
777
946
|
this.initializeWriteBehind(options.writeBehind);
|
|
778
947
|
this.startup = this.initialize();
|
|
779
948
|
}
|
|
@@ -786,7 +955,9 @@ var CacheStack = class extends EventEmitter {
|
|
|
786
955
|
unsubscribeInvalidation;
|
|
787
956
|
logger;
|
|
788
957
|
tagIndex;
|
|
958
|
+
keyDiscovery;
|
|
789
959
|
fetchRateLimiter = new FetchRateLimiter();
|
|
960
|
+
snapshotSerializer = new JsonSerializer();
|
|
790
961
|
backgroundRefreshes = /* @__PURE__ */ new Map();
|
|
791
962
|
layerDegradedUntil = /* @__PURE__ */ new Map();
|
|
792
963
|
ttlResolver;
|
|
@@ -795,6 +966,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
795
966
|
writeBehindQueue = [];
|
|
796
967
|
writeBehindTimer;
|
|
797
968
|
writeBehindFlushPromise;
|
|
969
|
+
generationCleanupPromise;
|
|
798
970
|
isDisconnecting = false;
|
|
799
971
|
disconnectPromise;
|
|
800
972
|
/**
|
|
@@ -807,6 +979,9 @@ var CacheStack = class extends EventEmitter {
|
|
|
807
979
|
const normalizedKey = this.qualifyKey(this.validateCacheKey(key));
|
|
808
980
|
this.validateWriteOptions(options);
|
|
809
981
|
await this.awaitStartup("get");
|
|
982
|
+
return this.getPrepared(normalizedKey, fetcher, options);
|
|
983
|
+
}
|
|
984
|
+
async getPrepared(normalizedKey, fetcher, options) {
|
|
810
985
|
const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
|
|
811
986
|
if (hit.found) {
|
|
812
987
|
this.ttlResolver.recordAccess(normalizedKey);
|
|
@@ -884,6 +1059,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
884
1059
|
return true;
|
|
885
1060
|
}
|
|
886
1061
|
} catch {
|
|
1062
|
+
await this.reportRecoverableLayerFailure(layer, "has", new Error(`has() failed for layer "${layer.name}"`));
|
|
887
1063
|
}
|
|
888
1064
|
} else {
|
|
889
1065
|
try {
|
|
@@ -891,7 +1067,8 @@ var CacheStack = class extends EventEmitter {
|
|
|
891
1067
|
if (value !== null) {
|
|
892
1068
|
return true;
|
|
893
1069
|
}
|
|
894
|
-
} catch {
|
|
1070
|
+
} catch (error) {
|
|
1071
|
+
await this.reportRecoverableLayerFailure(layer, "has", error);
|
|
895
1072
|
}
|
|
896
1073
|
}
|
|
897
1074
|
}
|
|
@@ -983,13 +1160,14 @@ var CacheStack = class extends EventEmitter {
|
|
|
983
1160
|
normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
|
|
984
1161
|
const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
|
|
985
1162
|
if (!canFastPath) {
|
|
1163
|
+
await this.awaitStartup("mget");
|
|
986
1164
|
const pendingReads = /* @__PURE__ */ new Map();
|
|
987
1165
|
return Promise.all(
|
|
988
1166
|
normalizedEntries.map((entry) => {
|
|
989
1167
|
const optionsSignature = this.serializeOptions(entry.options);
|
|
990
1168
|
const existing = pendingReads.get(entry.key);
|
|
991
1169
|
if (!existing) {
|
|
992
|
-
const promise = this.
|
|
1170
|
+
const promise = this.getPrepared(entry.key, entry.fetch, entry.options);
|
|
993
1171
|
pendingReads.set(entry.key, {
|
|
994
1172
|
promise,
|
|
995
1173
|
fetch: entry.fetch,
|
|
@@ -1128,14 +1306,14 @@ var CacheStack = class extends EventEmitter {
|
|
|
1128
1306
|
}
|
|
1129
1307
|
async invalidateByPattern(pattern) {
|
|
1130
1308
|
await this.awaitStartup("invalidateByPattern");
|
|
1131
|
-
const keys = await this.
|
|
1309
|
+
const keys = await this.keyDiscovery.collectKeysMatchingPattern(this.qualifyPattern(pattern));
|
|
1132
1310
|
await this.deleteKeys(keys);
|
|
1133
1311
|
await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
|
|
1134
1312
|
}
|
|
1135
1313
|
async invalidateByPrefix(prefix) {
|
|
1136
1314
|
await this.awaitStartup("invalidateByPrefix");
|
|
1137
1315
|
const qualifiedPrefix = this.qualifyKey(this.validateCacheKey(prefix));
|
|
1138
|
-
const keys =
|
|
1316
|
+
const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix);
|
|
1139
1317
|
await this.deleteKeys(keys);
|
|
1140
1318
|
await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
|
|
1141
1319
|
}
|
|
@@ -1185,9 +1363,18 @@ var CacheStack = class extends EventEmitter {
|
|
|
1185
1363
|
})
|
|
1186
1364
|
);
|
|
1187
1365
|
}
|
|
1366
|
+
/**
|
|
1367
|
+
* Rotates the active generation prefix used for all future cache keys.
|
|
1368
|
+
* Previous-generation keys remain in the underlying layers until they expire,
|
|
1369
|
+
* unless `generationCleanup` is enabled to prune them in the background.
|
|
1370
|
+
*/
|
|
1188
1371
|
bumpGeneration(nextGeneration) {
|
|
1189
1372
|
const current = this.currentGeneration ?? 0;
|
|
1373
|
+
const previousGeneration = this.currentGeneration;
|
|
1190
1374
|
this.currentGeneration = nextGeneration ?? current + 1;
|
|
1375
|
+
if (previousGeneration !== void 0 && previousGeneration !== this.currentGeneration && this.shouldCleanupGenerations()) {
|
|
1376
|
+
this.scheduleGenerationCleanup(previousGeneration);
|
|
1377
|
+
}
|
|
1191
1378
|
return this.currentGeneration;
|
|
1192
1379
|
}
|
|
1193
1380
|
/**
|
|
@@ -1271,27 +1458,28 @@ var CacheStack = class extends EventEmitter {
|
|
|
1271
1458
|
this.assertActive("persistToFile");
|
|
1272
1459
|
const snapshot = await this.exportState();
|
|
1273
1460
|
const { promises: fs2 } = await import("fs");
|
|
1274
|
-
await fs2.writeFile(filePath, JSON.stringify(snapshot, null, 2), "utf8");
|
|
1461
|
+
await fs2.writeFile(await this.validateSnapshotFilePath(filePath), JSON.stringify(snapshot, null, 2), "utf8");
|
|
1275
1462
|
}
|
|
1276
1463
|
async restoreFromFile(filePath) {
|
|
1277
1464
|
this.assertActive("restoreFromFile");
|
|
1278
1465
|
const { promises: fs2 } = await import("fs");
|
|
1279
|
-
const raw = await fs2.readFile(filePath, "utf8");
|
|
1466
|
+
const raw = await fs2.readFile(await this.validateSnapshotFilePath(filePath), "utf8");
|
|
1280
1467
|
let parsed;
|
|
1281
1468
|
try {
|
|
1282
|
-
parsed = JSON.parse(raw
|
|
1283
|
-
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
1284
|
-
return Object.assign(/* @__PURE__ */ Object.create(null), value);
|
|
1285
|
-
}
|
|
1286
|
-
return value;
|
|
1287
|
-
});
|
|
1469
|
+
parsed = JSON.parse(raw);
|
|
1288
1470
|
} catch (cause) {
|
|
1289
1471
|
throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
|
|
1290
1472
|
}
|
|
1291
1473
|
if (!this.isCacheSnapshotEntries(parsed)) {
|
|
1292
1474
|
throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
|
|
1293
1475
|
}
|
|
1294
|
-
await this.importState(
|
|
1476
|
+
await this.importState(
|
|
1477
|
+
parsed.map((entry) => ({
|
|
1478
|
+
key: entry.key,
|
|
1479
|
+
value: this.sanitizeSnapshotValue(entry.value),
|
|
1480
|
+
ttl: entry.ttl
|
|
1481
|
+
}))
|
|
1482
|
+
);
|
|
1295
1483
|
}
|
|
1296
1484
|
async disconnect() {
|
|
1297
1485
|
if (!this.disconnectPromise) {
|
|
@@ -1300,6 +1488,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1300
1488
|
await this.startup;
|
|
1301
1489
|
await this.unsubscribeInvalidation?.();
|
|
1302
1490
|
await this.flushWriteBehindQueue();
|
|
1491
|
+
await this.generationCleanupPromise;
|
|
1303
1492
|
await Promise.allSettled([...this.backgroundRefreshes.values()]);
|
|
1304
1493
|
if (this.writeBehindTimer) {
|
|
1305
1494
|
clearInterval(this.writeBehindTimer);
|
|
@@ -1383,8 +1572,14 @@ var CacheStack = class extends EventEmitter {
|
|
|
1383
1572
|
await this.storeEntry(key, "empty", null, options);
|
|
1384
1573
|
return null;
|
|
1385
1574
|
}
|
|
1386
|
-
if (options?.shouldCache
|
|
1387
|
-
|
|
1575
|
+
if (options?.shouldCache) {
|
|
1576
|
+
try {
|
|
1577
|
+
if (!options.shouldCache(fetched)) {
|
|
1578
|
+
return fetched;
|
|
1579
|
+
}
|
|
1580
|
+
} catch (error) {
|
|
1581
|
+
this.logger.warn?.("shouldCache-error", { key, error: this.formatError(error) });
|
|
1582
|
+
}
|
|
1388
1583
|
}
|
|
1389
1584
|
await this.storeEntry(key, "value", fetched, options);
|
|
1390
1585
|
return fetched;
|
|
@@ -1611,7 +1806,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1611
1806
|
const refresh = (async () => {
|
|
1612
1807
|
this.metricsCollector.increment("refreshes");
|
|
1613
1808
|
try {
|
|
1614
|
-
await this.
|
|
1809
|
+
await this.runBackgroundRefresh(key, fetcher, options);
|
|
1615
1810
|
} catch (error) {
|
|
1616
1811
|
this.metricsCollector.increment("refreshErrors");
|
|
1617
1812
|
this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
|
|
@@ -1621,6 +1816,16 @@ var CacheStack = class extends EventEmitter {
|
|
|
1621
1816
|
})();
|
|
1622
1817
|
this.backgroundRefreshes.set(key, refresh);
|
|
1623
1818
|
}
|
|
1819
|
+
async runBackgroundRefresh(key, fetcher, options) {
|
|
1820
|
+
const timeoutMs = this.options.backgroundRefreshTimeoutMs ?? DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS;
|
|
1821
|
+
await this.fetchWithGuards(
|
|
1822
|
+
key,
|
|
1823
|
+
() => this.withTimeout(fetcher(), timeoutMs, () => {
|
|
1824
|
+
return new Error(`Background refresh timed out after ${timeoutMs}ms for key "${key}".`);
|
|
1825
|
+
}),
|
|
1826
|
+
options
|
|
1827
|
+
);
|
|
1828
|
+
}
|
|
1624
1829
|
resolveSingleFlightOptions() {
|
|
1625
1830
|
return {
|
|
1626
1831
|
leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
|
|
@@ -1688,8 +1893,76 @@ var CacheStack = class extends EventEmitter {
|
|
|
1688
1893
|
sleep(ms) {
|
|
1689
1894
|
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
1690
1895
|
}
|
|
1896
|
+
async withTimeout(promise, timeoutMs, onTimeout) {
|
|
1897
|
+
if (timeoutMs <= 0) {
|
|
1898
|
+
return promise;
|
|
1899
|
+
}
|
|
1900
|
+
let timer;
|
|
1901
|
+
const observedPromise = promise.then(
|
|
1902
|
+
(value) => ({ kind: "value", value }),
|
|
1903
|
+
(error) => ({ kind: "error", error })
|
|
1904
|
+
);
|
|
1905
|
+
try {
|
|
1906
|
+
const result = await Promise.race([
|
|
1907
|
+
observedPromise,
|
|
1908
|
+
new Promise((_, reject) => {
|
|
1909
|
+
timer = setTimeout(() => reject(onTimeout()), timeoutMs);
|
|
1910
|
+
timer.unref?.();
|
|
1911
|
+
})
|
|
1912
|
+
]);
|
|
1913
|
+
if (result && typeof result === "object" && "kind" in result) {
|
|
1914
|
+
if (result.kind === "error") {
|
|
1915
|
+
throw result.error;
|
|
1916
|
+
}
|
|
1917
|
+
return result.value;
|
|
1918
|
+
}
|
|
1919
|
+
return result;
|
|
1920
|
+
} finally {
|
|
1921
|
+
if (timer) {
|
|
1922
|
+
clearTimeout(timer);
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1691
1926
|
shouldBroadcastL1Invalidation() {
|
|
1692
|
-
return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ??
|
|
1927
|
+
return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false;
|
|
1928
|
+
}
|
|
1929
|
+
shouldCleanupGenerations() {
|
|
1930
|
+
return Boolean(this.options.generationCleanup);
|
|
1931
|
+
}
|
|
1932
|
+
generationCleanupBatchSize() {
|
|
1933
|
+
const configured = typeof this.options.generationCleanup === "object" ? this.options.generationCleanup.batchSize : void 0;
|
|
1934
|
+
return configured ?? 500;
|
|
1935
|
+
}
|
|
1936
|
+
scheduleGenerationCleanup(generation) {
|
|
1937
|
+
const task = (this.generationCleanupPromise ?? Promise.resolve()).then(() => this.cleanupGeneration(generation)).catch((error) => {
|
|
1938
|
+
this.logger.warn?.("generation-cleanup-error", {
|
|
1939
|
+
generation,
|
|
1940
|
+
error: this.formatError(error)
|
|
1941
|
+
});
|
|
1942
|
+
});
|
|
1943
|
+
this.generationCleanupPromise = task.finally(() => {
|
|
1944
|
+
if (this.generationCleanupPromise === task) {
|
|
1945
|
+
this.generationCleanupPromise = void 0;
|
|
1946
|
+
}
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
async cleanupGeneration(generation) {
|
|
1950
|
+
const prefix = `v${generation}:`;
|
|
1951
|
+
const keys = await this.keyDiscovery.collectKeysWithPrefix(prefix);
|
|
1952
|
+
if (keys.length === 0) {
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
const batchSize = this.generationCleanupBatchSize();
|
|
1956
|
+
for (let index = 0; index < keys.length; index += batchSize) {
|
|
1957
|
+
const batch = keys.slice(index, index + batchSize);
|
|
1958
|
+
await this.deleteKeys(batch);
|
|
1959
|
+
await this.publishInvalidation({
|
|
1960
|
+
scope: "keys",
|
|
1961
|
+
keys: batch,
|
|
1962
|
+
sourceId: this.instanceId,
|
|
1963
|
+
operation: "invalidate"
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1693
1966
|
}
|
|
1694
1967
|
initializeWriteBehind(options) {
|
|
1695
1968
|
if (this.options.writeStrategy !== "write-behind") {
|
|
@@ -1727,7 +2000,17 @@ var CacheStack = class extends EventEmitter {
|
|
|
1727
2000
|
const batchSize = this.options.writeBehind?.batchSize ?? 100;
|
|
1728
2001
|
const batch = this.writeBehindQueue.splice(0, batchSize);
|
|
1729
2002
|
this.writeBehindFlushPromise = (async () => {
|
|
1730
|
-
await Promise.allSettled(batch.map((operation) => operation()));
|
|
2003
|
+
const results = await Promise.allSettled(batch.map((operation) => operation()));
|
|
2004
|
+
const failures = results.filter((result) => result.status === "rejected");
|
|
2005
|
+
if (failures.length > 0) {
|
|
2006
|
+
this.metricsCollector.increment("writeFailures", failures.length);
|
|
2007
|
+
this.logger.error?.("write-behind-flush-failure", {
|
|
2008
|
+
failed: failures.length,
|
|
2009
|
+
total: batch.length,
|
|
2010
|
+
errors: failures.map((failure) => this.formatError(failure.reason))
|
|
2011
|
+
});
|
|
2012
|
+
this.emitError("write-behind", { failed: failures.length, total: batch.length });
|
|
2013
|
+
}
|
|
1731
2014
|
})();
|
|
1732
2015
|
await this.writeBehindFlushPromise;
|
|
1733
2016
|
this.writeBehindFlushPromise = void 0;
|
|
@@ -1832,9 +2115,13 @@ var CacheStack = class extends EventEmitter {
|
|
|
1832
2115
|
this.validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
|
|
1833
2116
|
this.validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
|
|
1834
2117
|
this.validatePositiveNumber("singleFlightRenewIntervalMs", this.options.singleFlightRenewIntervalMs);
|
|
2118
|
+
this.validatePositiveNumber("backgroundRefreshTimeoutMs", this.options.backgroundRefreshTimeoutMs);
|
|
1835
2119
|
this.validateRateLimitOptions("fetcherRateLimit", this.options.fetcherRateLimit);
|
|
1836
2120
|
this.validateAdaptiveTtlOptions(this.options.adaptiveTtl);
|
|
1837
2121
|
this.validateCircuitBreakerOptions(this.options.circuitBreaker);
|
|
2122
|
+
if (typeof this.options.generationCleanup === "object") {
|
|
2123
|
+
this.validatePositiveNumber("generationCleanup.batchSize", this.options.generationCleanup.batchSize);
|
|
2124
|
+
}
|
|
1838
2125
|
if (this.options.generation !== void 0) {
|
|
1839
2126
|
this.validateNonNegativeNumber("generation", this.options.generation);
|
|
1840
2127
|
}
|
|
@@ -1906,6 +2193,9 @@ var CacheStack = class extends EventEmitter {
|
|
|
1906
2193
|
if (/[\u0000-\u001F\u007F]/.test(key)) {
|
|
1907
2194
|
throw new Error("Cache key contains unsupported control characters.");
|
|
1908
2195
|
}
|
|
2196
|
+
if (/[\uD800-\uDFFF]/.test(key)) {
|
|
2197
|
+
throw new Error("Cache key contains unsupported surrogate code points.");
|
|
2198
|
+
}
|
|
1909
2199
|
return key;
|
|
1910
2200
|
}
|
|
1911
2201
|
validateTtlPolicy(name, policy) {
|
|
@@ -1983,6 +2273,14 @@ var CacheStack = class extends EventEmitter {
|
|
|
1983
2273
|
this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
|
|
1984
2274
|
return null;
|
|
1985
2275
|
}
|
|
2276
|
+
async reportRecoverableLayerFailure(layer, operation, error) {
|
|
2277
|
+
if (this.isGracefulDegradationEnabled()) {
|
|
2278
|
+
await this.handleLayerFailure(layer, operation, error);
|
|
2279
|
+
return;
|
|
2280
|
+
}
|
|
2281
|
+
this.logger.warn?.("layer-operation-failed", { layer: layer.name, operation, error: this.formatError(error) });
|
|
2282
|
+
this.emitError(operation, { layer: layer.name, degraded: false, error: this.formatError(error) });
|
|
2283
|
+
}
|
|
1986
2284
|
isGracefulDegradationEnabled() {
|
|
1987
2285
|
return Boolean(this.options.gracefulDegradation);
|
|
1988
2286
|
}
|
|
@@ -2006,10 +2304,16 @@ var CacheStack = class extends EventEmitter {
|
|
|
2006
2304
|
}
|
|
2007
2305
|
}
|
|
2008
2306
|
serializeKeyPart(value) {
|
|
2009
|
-
if (typeof value === "string"
|
|
2010
|
-
return
|
|
2307
|
+
if (typeof value === "string") {
|
|
2308
|
+
return `s:${value}`;
|
|
2011
2309
|
}
|
|
2012
|
-
|
|
2310
|
+
if (typeof value === "number") {
|
|
2311
|
+
return `n:${value}`;
|
|
2312
|
+
}
|
|
2313
|
+
if (typeof value === "boolean") {
|
|
2314
|
+
return `b:${value}`;
|
|
2315
|
+
}
|
|
2316
|
+
return `j:${JSON.stringify(this.normalizeForSerialization(value))}`;
|
|
2013
2317
|
}
|
|
2014
2318
|
isCacheSnapshotEntries(value) {
|
|
2015
2319
|
return Array.isArray(value) && value.every((entry) => {
|
|
@@ -2017,15 +2321,39 @@ var CacheStack = class extends EventEmitter {
|
|
|
2017
2321
|
return false;
|
|
2018
2322
|
}
|
|
2019
2323
|
const candidate = entry;
|
|
2020
|
-
return typeof candidate.key === "string";
|
|
2324
|
+
return typeof candidate.key === "string" && (candidate.ttl === void 0 || typeof candidate.ttl === "number" && Number.isFinite(candidate.ttl) && candidate.ttl >= 0);
|
|
2021
2325
|
});
|
|
2022
2326
|
}
|
|
2327
|
+
sanitizeSnapshotValue(value) {
|
|
2328
|
+
return this.snapshotSerializer.deserialize(this.snapshotSerializer.serialize(value));
|
|
2329
|
+
}
|
|
2330
|
+
async validateSnapshotFilePath(filePath) {
|
|
2331
|
+
if (filePath.length === 0) {
|
|
2332
|
+
throw new Error("filePath must not be empty.");
|
|
2333
|
+
}
|
|
2334
|
+
if (filePath.includes("\0")) {
|
|
2335
|
+
throw new Error("filePath must not contain null bytes.");
|
|
2336
|
+
}
|
|
2337
|
+
const path = await import("path");
|
|
2338
|
+
const resolved = path.resolve(filePath);
|
|
2339
|
+
const baseDir = this.options.snapshotBaseDir === false ? false : path.resolve(this.options.snapshotBaseDir ?? process.cwd());
|
|
2340
|
+
if (baseDir !== false) {
|
|
2341
|
+
const relative = path.relative(baseDir, resolved);
|
|
2342
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
2343
|
+
throw new Error(`filePath is outside the allowed snapshot directory: ${baseDir}`);
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
return resolved;
|
|
2347
|
+
}
|
|
2023
2348
|
normalizeForSerialization(value) {
|
|
2024
2349
|
if (Array.isArray(value)) {
|
|
2025
2350
|
return value.map((entry) => this.normalizeForSerialization(entry));
|
|
2026
2351
|
}
|
|
2027
2352
|
if (value && typeof value === "object") {
|
|
2028
2353
|
return Object.keys(value).sort().reduce((normalized, key) => {
|
|
2354
|
+
if (DANGEROUS_OBJECT_KEYS.has(key)) {
|
|
2355
|
+
return normalized;
|
|
2356
|
+
}
|
|
2029
2357
|
normalized[key] = this.normalizeForSerialization(value[key]);
|
|
2030
2358
|
return normalized;
|
|
2031
2359
|
}, {});
|
|
@@ -2152,7 +2480,7 @@ function createCachedMethodDecorator(options) {
|
|
|
2152
2480
|
function createFastifyLayercachePlugin(cache, options = {}) {
|
|
2153
2481
|
return async (fastify) => {
|
|
2154
2482
|
fastify.decorate("cache", cache);
|
|
2155
|
-
if (options.exposeStatsRoute
|
|
2483
|
+
if (options.exposeStatsRoute === true && fastify.get) {
|
|
2156
2484
|
fastify.get(options.statsPath ?? "/cache/stats", async () => cache.getStats());
|
|
2157
2485
|
}
|
|
2158
2486
|
};
|
|
@@ -2168,7 +2496,8 @@ function createExpressCacheMiddleware(cache, options = {}) {
|
|
|
2168
2496
|
next();
|
|
2169
2497
|
return;
|
|
2170
2498
|
}
|
|
2171
|
-
const
|
|
2499
|
+
const rawUrl = req.originalUrl ?? req.url ?? "/";
|
|
2500
|
+
const key = options.keyResolver ? options.keyResolver(req) : `${method}:${normalizeUrl(rawUrl)}`;
|
|
2172
2501
|
const cached = await cache.get(key, void 0, options);
|
|
2173
2502
|
if (cached !== null) {
|
|
2174
2503
|
res.setHeader?.("content-type", "application/json; charset=utf-8");
|
|
@@ -2184,7 +2513,12 @@ function createExpressCacheMiddleware(cache, options = {}) {
|
|
|
2184
2513
|
if (originalJson) {
|
|
2185
2514
|
res.json = (body) => {
|
|
2186
2515
|
res.setHeader?.("x-cache", "MISS");
|
|
2187
|
-
|
|
2516
|
+
cache.set(key, body, options).catch((err) => {
|
|
2517
|
+
cache.emit("error", {
|
|
2518
|
+
operation: "set",
|
|
2519
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2520
|
+
});
|
|
2521
|
+
});
|
|
2188
2522
|
return originalJson(body);
|
|
2189
2523
|
};
|
|
2190
2524
|
}
|
|
@@ -2194,6 +2528,15 @@ function createExpressCacheMiddleware(cache, options = {}) {
|
|
|
2194
2528
|
}
|
|
2195
2529
|
};
|
|
2196
2530
|
}
|
|
2531
|
+
function normalizeUrl(url) {
|
|
2532
|
+
try {
|
|
2533
|
+
const parsed = new URL(url, "http://localhost");
|
|
2534
|
+
parsed.searchParams.sort();
|
|
2535
|
+
return decodeURIComponent(parsed.pathname) + parsed.search;
|
|
2536
|
+
} catch {
|
|
2537
|
+
return url;
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2197
2540
|
|
|
2198
2541
|
// src/integrations/graphql.ts
|
|
2199
2542
|
function cacheGraphqlResolver(cache, prefix, resolver, options = {}) {
|
|
@@ -2294,39 +2637,6 @@ function createTrpcCacheMiddleware(cache, prefix, options = {}) {
|
|
|
2294
2637
|
// src/layers/RedisLayer.ts
|
|
2295
2638
|
import { promisify } from "util";
|
|
2296
2639
|
import { brotliCompress, brotliDecompress, gunzip, gzip } from "zlib";
|
|
2297
|
-
|
|
2298
|
-
// src/serialization/JsonSerializer.ts
|
|
2299
|
-
var DANGEROUS_JSON_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
2300
|
-
var JsonSerializer = class {
|
|
2301
|
-
serialize(value) {
|
|
2302
|
-
return JSON.stringify(value);
|
|
2303
|
-
}
|
|
2304
|
-
deserialize(payload) {
|
|
2305
|
-
const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
|
|
2306
|
-
return sanitizeJsonValue(JSON.parse(normalized));
|
|
2307
|
-
}
|
|
2308
|
-
};
|
|
2309
|
-
function sanitizeJsonValue(value) {
|
|
2310
|
-
if (Array.isArray(value)) {
|
|
2311
|
-
return value.map((entry) => sanitizeJsonValue(entry));
|
|
2312
|
-
}
|
|
2313
|
-
if (!isPlainObject(value)) {
|
|
2314
|
-
return value;
|
|
2315
|
-
}
|
|
2316
|
-
const sanitized = {};
|
|
2317
|
-
for (const [key, entry] of Object.entries(value)) {
|
|
2318
|
-
if (DANGEROUS_JSON_KEYS.has(key)) {
|
|
2319
|
-
continue;
|
|
2320
|
-
}
|
|
2321
|
-
sanitized[key] = sanitizeJsonValue(entry);
|
|
2322
|
-
}
|
|
2323
|
-
return sanitized;
|
|
2324
|
-
}
|
|
2325
|
-
function isPlainObject(value) {
|
|
2326
|
-
return Object.prototype.toString.call(value) === "[object Object]";
|
|
2327
|
-
}
|
|
2328
|
-
|
|
2329
|
-
// src/layers/RedisLayer.ts
|
|
2330
2640
|
var BATCH_DELETE_SIZE = 500;
|
|
2331
2641
|
var gzipAsync = promisify(gzip);
|
|
2332
2642
|
var gunzipAsync = promisify(gunzip);
|
|
@@ -2343,6 +2653,7 @@ var RedisLayer = class {
|
|
|
2343
2653
|
scanCount;
|
|
2344
2654
|
compression;
|
|
2345
2655
|
compressionThreshold;
|
|
2656
|
+
decompressionMaxBytes;
|
|
2346
2657
|
disconnectOnDispose;
|
|
2347
2658
|
constructor(options) {
|
|
2348
2659
|
this.client = options.client;
|
|
@@ -2354,6 +2665,7 @@ var RedisLayer = class {
|
|
|
2354
2665
|
this.scanCount = options.scanCount ?? 100;
|
|
2355
2666
|
this.compression = options.compression;
|
|
2356
2667
|
this.compressionThreshold = options.compressionThreshold ?? 1024;
|
|
2668
|
+
this.decompressionMaxBytes = options.decompressionMaxBytes ?? 64 * 1024 * 1024;
|
|
2357
2669
|
this.disconnectOnDispose = options.disconnectOnDispose ?? false;
|
|
2358
2670
|
}
|
|
2359
2671
|
async get(key) {
|
|
@@ -2553,16 +2865,29 @@ var RedisLayer = class {
|
|
|
2553
2865
|
}
|
|
2554
2866
|
/**
|
|
2555
2867
|
* Decompresses the payload asynchronously if a compression header is present.
|
|
2868
|
+
* Enforces a maximum decompressed size to prevent decompression bomb attacks.
|
|
2556
2869
|
*/
|
|
2557
2870
|
async decodePayload(payload) {
|
|
2558
2871
|
if (!Buffer.isBuffer(payload)) {
|
|
2559
2872
|
return payload;
|
|
2560
2873
|
}
|
|
2561
2874
|
if (payload.subarray(0, 10).toString() === "LCZ1:gzip:") {
|
|
2562
|
-
|
|
2875
|
+
const decompressed = await gunzipAsync(payload.subarray(10));
|
|
2876
|
+
if (decompressed.byteLength > this.decompressionMaxBytes) {
|
|
2877
|
+
throw new Error(
|
|
2878
|
+
`Decompressed payload (${decompressed.byteLength} bytes) exceeds decompressionMaxBytes limit (${this.decompressionMaxBytes} bytes).`
|
|
2879
|
+
);
|
|
2880
|
+
}
|
|
2881
|
+
return decompressed;
|
|
2563
2882
|
}
|
|
2564
2883
|
if (payload.subarray(0, 12).toString() === "LCZ1:brotli:") {
|
|
2565
|
-
|
|
2884
|
+
const decompressed = await brotliDecompressAsync(payload.subarray(12));
|
|
2885
|
+
if (decompressed.byteLength > this.decompressionMaxBytes) {
|
|
2886
|
+
throw new Error(
|
|
2887
|
+
`Decompressed payload (${decompressed.byteLength} bytes) exceeds decompressionMaxBytes limit (${this.decompressionMaxBytes} bytes).`
|
|
2888
|
+
);
|
|
2889
|
+
}
|
|
2890
|
+
return decompressed;
|
|
2566
2891
|
}
|
|
2567
2892
|
return payload;
|
|
2568
2893
|
}
|
|
@@ -2622,8 +2947,13 @@ var DiskLayer = class {
|
|
|
2622
2947
|
const payload = this.serializer.serialize(entry);
|
|
2623
2948
|
const targetPath = this.keyToPath(key);
|
|
2624
2949
|
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
2625
|
-
|
|
2626
|
-
|
|
2950
|
+
try {
|
|
2951
|
+
await fs.writeFile(tempPath, payload);
|
|
2952
|
+
await fs.rename(tempPath, targetPath);
|
|
2953
|
+
} catch (error) {
|
|
2954
|
+
await this.safeDelete(tempPath);
|
|
2955
|
+
throw error;
|
|
2956
|
+
}
|
|
2627
2957
|
if (this.maxFiles !== void 0) {
|
|
2628
2958
|
await this.enforceMaxFiles();
|
|
2629
2959
|
}
|
|
@@ -2633,9 +2963,7 @@ var DiskLayer = class {
|
|
|
2633
2963
|
return Promise.all(keys.map((key) => this.getEntry(key)));
|
|
2634
2964
|
}
|
|
2635
2965
|
async setMany(entries) {
|
|
2636
|
-
|
|
2637
|
-
await this.set(entry.key, entry.value, entry.ttl);
|
|
2638
|
-
}
|
|
2966
|
+
await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.ttl)));
|
|
2639
2967
|
}
|
|
2640
2968
|
async has(key) {
|
|
2641
2969
|
const value = await this.getEntry(key);
|
|
@@ -2839,6 +3167,7 @@ var MemcachedLayer = class {
|
|
|
2839
3167
|
return unwrapStoredValue(await this.getEntry(key));
|
|
2840
3168
|
}
|
|
2841
3169
|
async getEntry(key) {
|
|
3170
|
+
this.validateKey(key);
|
|
2842
3171
|
const result = await this.client.get(this.withPrefix(key));
|
|
2843
3172
|
if (!result || result.value === null) {
|
|
2844
3173
|
return null;
|
|
@@ -2853,16 +3182,19 @@ var MemcachedLayer = class {
|
|
|
2853
3182
|
return Promise.all(keys.map((key) => this.getEntry(key)));
|
|
2854
3183
|
}
|
|
2855
3184
|
async set(key, value, ttl = this.defaultTtl) {
|
|
3185
|
+
this.validateKey(key);
|
|
2856
3186
|
const payload = this.serializer.serialize(value);
|
|
2857
3187
|
await this.client.set(this.withPrefix(key), payload, {
|
|
2858
3188
|
expires: ttl && ttl > 0 ? ttl : void 0
|
|
2859
3189
|
});
|
|
2860
3190
|
}
|
|
2861
3191
|
async has(key) {
|
|
3192
|
+
this.validateKey(key);
|
|
2862
3193
|
const result = await this.client.get(this.withPrefix(key));
|
|
2863
3194
|
return result !== null && result.value !== null;
|
|
2864
3195
|
}
|
|
2865
3196
|
async delete(key) {
|
|
3197
|
+
this.validateKey(key);
|
|
2866
3198
|
await this.client.delete(this.withPrefix(key));
|
|
2867
3199
|
}
|
|
2868
3200
|
async deleteMany(keys) {
|
|
@@ -2876,19 +3208,50 @@ var MemcachedLayer = class {
|
|
|
2876
3208
|
withPrefix(key) {
|
|
2877
3209
|
return `${this.keyPrefix}${key}`;
|
|
2878
3210
|
}
|
|
3211
|
+
validateKey(key) {
|
|
3212
|
+
const fullKey = this.withPrefix(key);
|
|
3213
|
+
if (Buffer.byteLength(fullKey, "utf8") > 250) {
|
|
3214
|
+
throw new Error(`MemcachedLayer: key exceeds 250-byte Memcached limit: "${fullKey.slice(0, 60)}..."`);
|
|
3215
|
+
}
|
|
3216
|
+
if (/[\s\x00-\x1f\x7f]/.test(fullKey)) {
|
|
3217
|
+
throw new Error(
|
|
3218
|
+
"MemcachedLayer: key contains invalid characters (whitespace or control characters are not allowed)."
|
|
3219
|
+
);
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
2879
3222
|
};
|
|
2880
3223
|
|
|
2881
3224
|
// src/serialization/MsgpackSerializer.ts
|
|
2882
3225
|
import { decode, encode } from "@msgpack/msgpack";
|
|
3226
|
+
var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
2883
3227
|
var MsgpackSerializer = class {
|
|
2884
3228
|
serialize(value) {
|
|
2885
3229
|
return Buffer.from(encode(value));
|
|
2886
3230
|
}
|
|
2887
3231
|
deserialize(payload) {
|
|
2888
3232
|
const normalized = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
|
|
2889
|
-
return decode(normalized);
|
|
3233
|
+
return sanitizeMsgpackValue(decode(normalized));
|
|
2890
3234
|
}
|
|
2891
3235
|
};
|
|
3236
|
+
function sanitizeMsgpackValue(value) {
|
|
3237
|
+
if (Array.isArray(value)) {
|
|
3238
|
+
return value.map((entry) => sanitizeMsgpackValue(entry));
|
|
3239
|
+
}
|
|
3240
|
+
if (!isPlainObject2(value)) {
|
|
3241
|
+
return value;
|
|
3242
|
+
}
|
|
3243
|
+
const sanitized = {};
|
|
3244
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
3245
|
+
if (DANGEROUS_KEYS.has(key)) {
|
|
3246
|
+
continue;
|
|
3247
|
+
}
|
|
3248
|
+
sanitized[key] = sanitizeMsgpackValue(entry);
|
|
3249
|
+
}
|
|
3250
|
+
return sanitized;
|
|
3251
|
+
}
|
|
3252
|
+
function isPlainObject2(value) {
|
|
3253
|
+
return Object.prototype.toString.call(value) === "[object Object]";
|
|
3254
|
+
}
|
|
2892
3255
|
|
|
2893
3256
|
// src/singleflight/RedisSingleFlightCoordinator.ts
|
|
2894
3257
|
import { randomUUID } from "crypto";
|
|
@@ -3017,7 +3380,7 @@ function createPrometheusMetricsExporter(stacks) {
|
|
|
3017
3380
|
};
|
|
3018
3381
|
}
|
|
3019
3382
|
function sanitizeLabel(value) {
|
|
3020
|
-
return value.replace(/["\\\n]/g, "_");
|
|
3383
|
+
return value.replace(/["\\\n\r]/g, "_");
|
|
3021
3384
|
}
|
|
3022
3385
|
export {
|
|
3023
3386
|
CacheMissError,
|