layercache 1.2.1 → 1.2.3
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 +66 -11
- package/dist/{chunk-ZMDB5KOK.js → chunk-7V7XAB74.js} +24 -1
- package/dist/{chunk-46UH7LNM.js → chunk-KOYGHLVP.js} +142 -18
- package/dist/{chunk-GF47Y3XR.js → chunk-QHWG7QS5.js} +56 -25
- package/dist/cli.cjs +92 -27
- package/dist/cli.js +15 -4
- package/dist/{edge-C1sBhTfv.d.ts → edge-B_rUqDy6.d.cts} +39 -1
- package/dist/{edge-C1sBhTfv.d.cts → edge-B_rUqDy6.d.ts} +39 -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 +798 -127
- package/dist/index.d.cts +18 -3
- package/dist/index.d.ts +18 -3
- package/dist/index.js +582 -90
- package/package.json +5 -5
- package/packages/nestjs/dist/index.cjs +582 -61
- package/packages/nestjs/dist/index.d.cts +30 -0
- package/packages/nestjs/dist/index.d.ts +30 -0
- package/packages/nestjs/dist/index.js +582 -61
package/dist/index.cjs
CHANGED
|
@@ -400,11 +400,13 @@ var CircuitBreakerManager = class {
|
|
|
400
400
|
|
|
401
401
|
// src/internal/FetchRateLimiter.ts
|
|
402
402
|
var FetchRateLimiter = class {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
403
|
+
buckets = /* @__PURE__ */ new Map();
|
|
404
|
+
queuesByBucket = /* @__PURE__ */ new Map();
|
|
405
|
+
pendingBuckets = /* @__PURE__ */ new Set();
|
|
406
|
+
fetcherBuckets = /* @__PURE__ */ new WeakMap();
|
|
407
|
+
nextFetcherBucketId = 0;
|
|
406
408
|
drainTimer;
|
|
407
|
-
async schedule(options, task) {
|
|
409
|
+
async schedule(options, context, task) {
|
|
408
410
|
if (!options) {
|
|
409
411
|
return task();
|
|
410
412
|
}
|
|
@@ -412,8 +414,18 @@ var FetchRateLimiter = class {
|
|
|
412
414
|
if (!normalized) {
|
|
413
415
|
return task();
|
|
414
416
|
}
|
|
415
|
-
return new Promise((
|
|
416
|
-
this.
|
|
417
|
+
return new Promise((resolve2, reject) => {
|
|
418
|
+
const bucketKey = this.resolveBucketKey(normalized, context);
|
|
419
|
+
const queue = this.queuesByBucket.get(bucketKey) ?? [];
|
|
420
|
+
queue.push({
|
|
421
|
+
bucketKey,
|
|
422
|
+
options: normalized,
|
|
423
|
+
task,
|
|
424
|
+
resolve: resolve2,
|
|
425
|
+
reject
|
|
426
|
+
});
|
|
427
|
+
this.queuesByBucket.set(bucketKey, queue);
|
|
428
|
+
this.pendingBuckets.add(bucketKey);
|
|
417
429
|
this.drain();
|
|
418
430
|
});
|
|
419
431
|
}
|
|
@@ -427,63 +439,159 @@ var FetchRateLimiter = class {
|
|
|
427
439
|
return {
|
|
428
440
|
maxConcurrent,
|
|
429
441
|
intervalMs,
|
|
430
|
-
maxPerInterval
|
|
442
|
+
maxPerInterval,
|
|
443
|
+
scope: options.scope ?? "global",
|
|
444
|
+
bucketKey: options.bucketKey
|
|
431
445
|
};
|
|
432
446
|
}
|
|
447
|
+
resolveBucketKey(options, context) {
|
|
448
|
+
if (options.bucketKey) {
|
|
449
|
+
return `custom:${options.bucketKey}`;
|
|
450
|
+
}
|
|
451
|
+
if (options.scope === "key") {
|
|
452
|
+
return `key:${context.key}`;
|
|
453
|
+
}
|
|
454
|
+
if (options.scope === "fetcher") {
|
|
455
|
+
const existing = this.fetcherBuckets.get(context.fetcher);
|
|
456
|
+
if (existing) {
|
|
457
|
+
return existing;
|
|
458
|
+
}
|
|
459
|
+
const bucket = `fetcher:${this.nextFetcherBucketId}`;
|
|
460
|
+
this.nextFetcherBucketId += 1;
|
|
461
|
+
this.fetcherBuckets.set(context.fetcher, bucket);
|
|
462
|
+
return bucket;
|
|
463
|
+
}
|
|
464
|
+
return "global";
|
|
465
|
+
}
|
|
433
466
|
drain() {
|
|
434
467
|
if (this.drainTimer) {
|
|
435
468
|
clearTimeout(this.drainTimer);
|
|
436
469
|
this.drainTimer = void 0;
|
|
437
470
|
}
|
|
438
|
-
while (this.
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
471
|
+
while (this.pendingBuckets.size > 0) {
|
|
472
|
+
let nextBucketKey;
|
|
473
|
+
let nextWaitMs = Number.POSITIVE_INFINITY;
|
|
474
|
+
for (const bucketKey of this.pendingBuckets) {
|
|
475
|
+
const queue2 = this.queuesByBucket.get(bucketKey);
|
|
476
|
+
if (!queue2 || queue2.length === 0) {
|
|
477
|
+
this.pendingBuckets.delete(bucketKey);
|
|
478
|
+
this.queuesByBucket.delete(bucketKey);
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
const next2 = queue2[0];
|
|
482
|
+
if (!next2) {
|
|
483
|
+
this.pendingBuckets.delete(bucketKey);
|
|
484
|
+
this.queuesByBucket.delete(bucketKey);
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
const waitMs = this.waitTime(bucketKey, next2.options);
|
|
488
|
+
if (waitMs <= 0) {
|
|
489
|
+
nextBucketKey = bucketKey;
|
|
490
|
+
break;
|
|
491
|
+
}
|
|
492
|
+
nextWaitMs = Math.min(nextWaitMs, waitMs);
|
|
442
493
|
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
494
|
+
if (!nextBucketKey) {
|
|
495
|
+
if (Number.isFinite(nextWaitMs)) {
|
|
496
|
+
this.drainTimer = setTimeout(() => {
|
|
497
|
+
this.drainTimer = void 0;
|
|
498
|
+
this.drain();
|
|
499
|
+
}, nextWaitMs);
|
|
500
|
+
this.drainTimer.unref?.();
|
|
501
|
+
}
|
|
450
502
|
return;
|
|
451
503
|
}
|
|
452
|
-
this.
|
|
453
|
-
|
|
454
|
-
|
|
504
|
+
const queue = this.queuesByBucket.get(nextBucketKey);
|
|
505
|
+
const next = queue?.shift();
|
|
506
|
+
if (!next) {
|
|
507
|
+
this.pendingBuckets.delete(nextBucketKey);
|
|
508
|
+
this.queuesByBucket.delete(nextBucketKey);
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
if (!queue || queue.length === 0) {
|
|
512
|
+
this.pendingBuckets.delete(nextBucketKey);
|
|
513
|
+
this.queuesByBucket.delete(nextBucketKey);
|
|
514
|
+
}
|
|
515
|
+
const bucket = this.bucketState(next.bucketKey);
|
|
516
|
+
if (bucket.cleanupTimer) {
|
|
517
|
+
clearTimeout(bucket.cleanupTimer);
|
|
518
|
+
bucket.cleanupTimer = void 0;
|
|
519
|
+
}
|
|
520
|
+
bucket.active += 1;
|
|
521
|
+
if (next.options.intervalMs && next.options.maxPerInterval) {
|
|
522
|
+
bucket.startedAt.push(Date.now());
|
|
523
|
+
}
|
|
455
524
|
void next.task().then(next.resolve, next.reject).finally(() => {
|
|
456
|
-
|
|
525
|
+
bucket.active -= 1;
|
|
526
|
+
if ((this.queuesByBucket.get(next.bucketKey)?.length ?? 0) > 0) {
|
|
527
|
+
this.pendingBuckets.add(next.bucketKey);
|
|
528
|
+
}
|
|
529
|
+
this.cleanupBucket(next.bucketKey, bucket, next.options.intervalMs);
|
|
457
530
|
this.drain();
|
|
458
531
|
});
|
|
459
532
|
}
|
|
460
533
|
}
|
|
461
|
-
waitTime(options) {
|
|
534
|
+
waitTime(bucketKey, options) {
|
|
535
|
+
const bucket = this.bucketState(bucketKey);
|
|
462
536
|
const now = Date.now();
|
|
463
|
-
if (options.maxConcurrent &&
|
|
537
|
+
if (options.maxConcurrent && bucket.active >= options.maxConcurrent) {
|
|
464
538
|
return 1;
|
|
465
539
|
}
|
|
466
540
|
if (!options.intervalMs || !options.maxPerInterval) {
|
|
467
541
|
return 0;
|
|
468
542
|
}
|
|
469
|
-
this.prune(now, options.intervalMs);
|
|
470
|
-
if (
|
|
543
|
+
this.prune(bucket, now, options.intervalMs);
|
|
544
|
+
if (bucket.startedAt.length < options.maxPerInterval) {
|
|
471
545
|
return 0;
|
|
472
546
|
}
|
|
473
|
-
const oldest =
|
|
547
|
+
const oldest = bucket.startedAt[0];
|
|
474
548
|
if (!oldest) {
|
|
475
549
|
return 0;
|
|
476
550
|
}
|
|
477
551
|
return Math.max(1, options.intervalMs - (now - oldest));
|
|
478
552
|
}
|
|
479
|
-
prune(now, intervalMs) {
|
|
480
|
-
while (
|
|
481
|
-
const startedAt =
|
|
553
|
+
prune(bucket, now, intervalMs) {
|
|
554
|
+
while (bucket.startedAt.length > 0) {
|
|
555
|
+
const startedAt = bucket.startedAt[0];
|
|
482
556
|
if (startedAt === void 0 || now - startedAt < intervalMs) {
|
|
483
557
|
break;
|
|
484
558
|
}
|
|
485
|
-
|
|
559
|
+
bucket.startedAt.shift();
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
bucketState(bucketKey) {
|
|
563
|
+
const existing = this.buckets.get(bucketKey);
|
|
564
|
+
if (existing) {
|
|
565
|
+
return existing;
|
|
486
566
|
}
|
|
567
|
+
const bucket = { active: 0, startedAt: [] };
|
|
568
|
+
this.buckets.set(bucketKey, bucket);
|
|
569
|
+
return bucket;
|
|
570
|
+
}
|
|
571
|
+
cleanupBucket(bucketKey, bucket, intervalMs) {
|
|
572
|
+
const queued = this.queuesByBucket.get(bucketKey)?.length ?? 0;
|
|
573
|
+
if (queued === 0 && bucket.active === 0 && bucket.startedAt.length === 0) {
|
|
574
|
+
this.buckets.delete(bucketKey);
|
|
575
|
+
this.queuesByBucket.delete(bucketKey);
|
|
576
|
+
this.pendingBuckets.delete(bucketKey);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (!intervalMs || bucket.active > 0 || queued > 0) {
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
if (bucket.cleanupTimer) {
|
|
583
|
+
clearTimeout(bucket.cleanupTimer);
|
|
584
|
+
}
|
|
585
|
+
bucket.cleanupTimer = setTimeout(() => {
|
|
586
|
+
bucket.cleanupTimer = void 0;
|
|
587
|
+
this.prune(bucket, Date.now(), intervalMs);
|
|
588
|
+
if (bucket.active === 0 && bucket.startedAt.length === 0 && (this.queuesByBucket.get(bucketKey)?.length ?? 0) === 0) {
|
|
589
|
+
this.buckets.delete(bucketKey);
|
|
590
|
+
this.queuesByBucket.delete(bucketKey);
|
|
591
|
+
this.pendingBuckets.delete(bucketKey);
|
|
592
|
+
}
|
|
593
|
+
}, intervalMs);
|
|
594
|
+
bucket.cleanupTimer.unref?.();
|
|
487
595
|
}
|
|
488
596
|
};
|
|
489
597
|
|
|
@@ -563,7 +671,30 @@ var MetricsCollector = class {
|
|
|
563
671
|
|
|
564
672
|
// src/internal/StoredValue.ts
|
|
565
673
|
function isStoredValueEnvelope(value) {
|
|
566
|
-
|
|
674
|
+
if (typeof value !== "object" || value === null) {
|
|
675
|
+
return false;
|
|
676
|
+
}
|
|
677
|
+
const v = value;
|
|
678
|
+
if (v.__layercache !== 1) {
|
|
679
|
+
return false;
|
|
680
|
+
}
|
|
681
|
+
if (v.kind !== "value" && v.kind !== "empty") {
|
|
682
|
+
return false;
|
|
683
|
+
}
|
|
684
|
+
if (v.freshUntil !== null && typeof v.freshUntil !== "number") {
|
|
685
|
+
return false;
|
|
686
|
+
}
|
|
687
|
+
if (v.staleUntil !== null && typeof v.staleUntil !== "number") {
|
|
688
|
+
return false;
|
|
689
|
+
}
|
|
690
|
+
if (v.errorUntil !== null && typeof v.errorUntil !== "number") {
|
|
691
|
+
return false;
|
|
692
|
+
}
|
|
693
|
+
const maxTimestamp = Date.now() + 10 * 365 * 24 * 60 * 60 * 1e3;
|
|
694
|
+
if (typeof v.freshUntil === "number" && v.freshUntil > maxTimestamp) {
|
|
695
|
+
return false;
|
|
696
|
+
}
|
|
697
|
+
return true;
|
|
567
698
|
}
|
|
568
699
|
function createStoredValueEnvelope(options) {
|
|
569
700
|
const now = options.now ?? Date.now();
|
|
@@ -828,15 +959,17 @@ var TagIndex = class {
|
|
|
828
959
|
keyToTags = /* @__PURE__ */ new Map();
|
|
829
960
|
knownKeys = /* @__PURE__ */ new Set();
|
|
830
961
|
maxKnownKeys;
|
|
962
|
+
nextNodeId = 1;
|
|
963
|
+
root = this.createTrieNode();
|
|
831
964
|
constructor(options = {}) {
|
|
832
|
-
this.maxKnownKeys = options.maxKnownKeys;
|
|
965
|
+
this.maxKnownKeys = options.maxKnownKeys ?? 1e5;
|
|
833
966
|
}
|
|
834
967
|
async touch(key) {
|
|
835
|
-
this.
|
|
968
|
+
this.insertKnownKey(key);
|
|
836
969
|
this.pruneKnownKeysIfNeeded();
|
|
837
970
|
}
|
|
838
971
|
async track(key, tags) {
|
|
839
|
-
this.
|
|
972
|
+
this.insertKnownKey(key);
|
|
840
973
|
this.pruneKnownKeysIfNeeded();
|
|
841
974
|
if (tags.length === 0) {
|
|
842
975
|
return;
|
|
@@ -862,18 +995,104 @@ var TagIndex = class {
|
|
|
862
995
|
return [...this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set()];
|
|
863
996
|
}
|
|
864
997
|
async keysForPrefix(prefix) {
|
|
865
|
-
|
|
998
|
+
const node = this.findNode(prefix);
|
|
999
|
+
if (!node) {
|
|
1000
|
+
return [];
|
|
1001
|
+
}
|
|
1002
|
+
const matches = [];
|
|
1003
|
+
this.collectFromNode(node, prefix, matches);
|
|
1004
|
+
return matches;
|
|
866
1005
|
}
|
|
867
1006
|
async tagsForKey(key) {
|
|
868
1007
|
return [...this.keyToTags.get(key) ?? /* @__PURE__ */ new Set()];
|
|
869
1008
|
}
|
|
870
1009
|
async matchPattern(pattern) {
|
|
871
|
-
|
|
1010
|
+
const matches = /* @__PURE__ */ new Set();
|
|
1011
|
+
this.collectPatternMatches(this.root, "", pattern, 0, matches, /* @__PURE__ */ new Set());
|
|
1012
|
+
return [...matches];
|
|
872
1013
|
}
|
|
873
1014
|
async clear() {
|
|
874
1015
|
this.tagToKeys.clear();
|
|
875
1016
|
this.keyToTags.clear();
|
|
876
1017
|
this.knownKeys.clear();
|
|
1018
|
+
this.root.children.clear();
|
|
1019
|
+
this.root.terminal = false;
|
|
1020
|
+
this.nextNodeId = this.root.id + 1;
|
|
1021
|
+
}
|
|
1022
|
+
createTrieNode() {
|
|
1023
|
+
return {
|
|
1024
|
+
id: this.nextNodeId++,
|
|
1025
|
+
terminal: false,
|
|
1026
|
+
children: /* @__PURE__ */ new Map()
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
insertKnownKey(key) {
|
|
1030
|
+
if (this.knownKeys.has(key)) {
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
this.knownKeys.add(key);
|
|
1034
|
+
let node = this.root;
|
|
1035
|
+
for (const character of key) {
|
|
1036
|
+
let child = node.children.get(character);
|
|
1037
|
+
if (!child) {
|
|
1038
|
+
child = this.createTrieNode();
|
|
1039
|
+
node.children.set(character, child);
|
|
1040
|
+
}
|
|
1041
|
+
node = child;
|
|
1042
|
+
}
|
|
1043
|
+
node.terminal = true;
|
|
1044
|
+
}
|
|
1045
|
+
findNode(prefix) {
|
|
1046
|
+
let node = this.root;
|
|
1047
|
+
for (const character of prefix) {
|
|
1048
|
+
node = node.children.get(character);
|
|
1049
|
+
if (!node) {
|
|
1050
|
+
return void 0;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
return node;
|
|
1054
|
+
}
|
|
1055
|
+
collectFromNode(node, prefix, matches) {
|
|
1056
|
+
if (node.terminal) {
|
|
1057
|
+
matches.push(prefix);
|
|
1058
|
+
}
|
|
1059
|
+
for (const [character, child] of node.children) {
|
|
1060
|
+
this.collectFromNode(child, `${prefix}${character}`, matches);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
collectPatternMatches(node, prefix, pattern, patternIndex, matches, visited) {
|
|
1064
|
+
const stateKey = `${node.id}:${patternIndex}`;
|
|
1065
|
+
if (visited.has(stateKey)) {
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
visited.add(stateKey);
|
|
1069
|
+
if (patternIndex === pattern.length) {
|
|
1070
|
+
if (node.terminal) {
|
|
1071
|
+
matches.add(prefix);
|
|
1072
|
+
}
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
const patternChar = pattern[patternIndex];
|
|
1076
|
+
if (patternChar === void 0) {
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
if (patternChar === "*") {
|
|
1080
|
+
this.collectPatternMatches(node, prefix, pattern, patternIndex + 1, matches, visited);
|
|
1081
|
+
for (const [character, child2] of node.children) {
|
|
1082
|
+
this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex, matches, visited);
|
|
1083
|
+
}
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
if (patternChar === "?") {
|
|
1087
|
+
for (const [character, child2] of node.children) {
|
|
1088
|
+
this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex + 1, matches, visited);
|
|
1089
|
+
}
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
const child = node.children.get(patternChar);
|
|
1093
|
+
if (child) {
|
|
1094
|
+
this.collectPatternMatches(child, `${prefix}${patternChar}`, pattern, patternIndex + 1, matches, visited);
|
|
1095
|
+
}
|
|
877
1096
|
}
|
|
878
1097
|
pruneKnownKeysIfNeeded() {
|
|
879
1098
|
if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
|
|
@@ -890,7 +1109,7 @@ var TagIndex = class {
|
|
|
890
1109
|
}
|
|
891
1110
|
}
|
|
892
1111
|
removeKey(key) {
|
|
893
|
-
this.
|
|
1112
|
+
this.removeKnownKey(key);
|
|
894
1113
|
const tags = this.keyToTags.get(key);
|
|
895
1114
|
if (!tags) {
|
|
896
1115
|
return;
|
|
@@ -907,7 +1126,70 @@ var TagIndex = class {
|
|
|
907
1126
|
}
|
|
908
1127
|
this.keyToTags.delete(key);
|
|
909
1128
|
}
|
|
1129
|
+
removeKnownKey(key) {
|
|
1130
|
+
if (!this.knownKeys.delete(key)) {
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
const path = [];
|
|
1134
|
+
let node = this.root;
|
|
1135
|
+
for (const character of key) {
|
|
1136
|
+
const child = node.children.get(character);
|
|
1137
|
+
if (!child) {
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
path.push([node, character]);
|
|
1141
|
+
node = child;
|
|
1142
|
+
}
|
|
1143
|
+
node.terminal = false;
|
|
1144
|
+
for (let index = path.length - 1; index >= 0; index -= 1) {
|
|
1145
|
+
const entry = path[index];
|
|
1146
|
+
if (!entry) {
|
|
1147
|
+
continue;
|
|
1148
|
+
}
|
|
1149
|
+
const [parent, character] = entry;
|
|
1150
|
+
const child = parent.children.get(character);
|
|
1151
|
+
if (!child || child.terminal || child.children.size > 0) {
|
|
1152
|
+
break;
|
|
1153
|
+
}
|
|
1154
|
+
parent.children.delete(character);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
};
|
|
1158
|
+
|
|
1159
|
+
// src/serialization/JsonSerializer.ts
|
|
1160
|
+
var DANGEROUS_JSON_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
1161
|
+
var JsonSerializer = class {
|
|
1162
|
+
serialize(value) {
|
|
1163
|
+
return JSON.stringify(value);
|
|
1164
|
+
}
|
|
1165
|
+
deserialize(payload) {
|
|
1166
|
+
const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
|
|
1167
|
+
return sanitizeJsonValue(JSON.parse(normalized), 0);
|
|
1168
|
+
}
|
|
910
1169
|
};
|
|
1170
|
+
var MAX_SANITIZE_DEPTH = 200;
|
|
1171
|
+
function sanitizeJsonValue(value, depth) {
|
|
1172
|
+
if (depth > MAX_SANITIZE_DEPTH) {
|
|
1173
|
+
return value;
|
|
1174
|
+
}
|
|
1175
|
+
if (Array.isArray(value)) {
|
|
1176
|
+
return value.map((entry) => sanitizeJsonValue(entry, depth + 1));
|
|
1177
|
+
}
|
|
1178
|
+
if (!isPlainObject(value)) {
|
|
1179
|
+
return value;
|
|
1180
|
+
}
|
|
1181
|
+
const sanitized = {};
|
|
1182
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
1183
|
+
if (DANGEROUS_JSON_KEYS.has(key)) {
|
|
1184
|
+
continue;
|
|
1185
|
+
}
|
|
1186
|
+
sanitized[key] = sanitizeJsonValue(entry, depth + 1);
|
|
1187
|
+
}
|
|
1188
|
+
return sanitized;
|
|
1189
|
+
}
|
|
1190
|
+
function isPlainObject(value) {
|
|
1191
|
+
return Object.prototype.toString.call(value) === "[object Object]";
|
|
1192
|
+
}
|
|
911
1193
|
|
|
912
1194
|
// src/stampede/StampedeGuard.ts
|
|
913
1195
|
var import_async_mutex2 = require("async-mutex");
|
|
@@ -919,7 +1201,8 @@ var StampedeGuard = class {
|
|
|
919
1201
|
return await entry.mutex.runExclusive(task);
|
|
920
1202
|
} finally {
|
|
921
1203
|
entry.references -= 1;
|
|
922
|
-
|
|
1204
|
+
const current = this.mutexes.get(key);
|
|
1205
|
+
if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
|
|
923
1206
|
this.mutexes.delete(key);
|
|
924
1207
|
}
|
|
925
1208
|
}
|
|
@@ -949,8 +1232,10 @@ var CacheMissError = class extends Error {
|
|
|
949
1232
|
var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
|
|
950
1233
|
var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
|
|
951
1234
|
var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
|
|
1235
|
+
var DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 3e4;
|
|
952
1236
|
var MAX_CACHE_KEY_LENGTH = 1024;
|
|
953
1237
|
var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
|
|
1238
|
+
var DANGEROUS_OBJECT_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
954
1239
|
var DebugLogger = class {
|
|
955
1240
|
enabled;
|
|
956
1241
|
constructor(enabled) {
|
|
@@ -997,6 +1282,21 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
997
1282
|
const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
|
|
998
1283
|
this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
|
|
999
1284
|
this.tagIndex = options.tagIndex ?? new TagIndex();
|
|
1285
|
+
if (!options.tagIndex && layers.some((layer) => layer.isLocal === false)) {
|
|
1286
|
+
this.logger.warn?.(
|
|
1287
|
+
"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."
|
|
1288
|
+
);
|
|
1289
|
+
}
|
|
1290
|
+
if (!options.tagIndex && layers.some((layer) => layer.isLocal === false && !layer.keys)) {
|
|
1291
|
+
this.logger.warn?.(
|
|
1292
|
+
"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."
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
1295
|
+
if (options.invalidationBus && options.broadcastL1Invalidation === void 0 && options.publishSetInvalidation === void 0) {
|
|
1296
|
+
this.logger.warn?.(
|
|
1297
|
+
"broadcastL1Invalidation defaults to false when an invalidation bus is configured; opt in explicitly if write-triggered L1 invalidation is desired."
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1000
1300
|
this.initializeWriteBehind(options.writeBehind);
|
|
1001
1301
|
this.startup = this.initialize();
|
|
1002
1302
|
}
|
|
@@ -1010,6 +1310,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1010
1310
|
logger;
|
|
1011
1311
|
tagIndex;
|
|
1012
1312
|
fetchRateLimiter = new FetchRateLimiter();
|
|
1313
|
+
snapshotSerializer = new JsonSerializer();
|
|
1013
1314
|
backgroundRefreshes = /* @__PURE__ */ new Map();
|
|
1014
1315
|
layerDegradedUntil = /* @__PURE__ */ new Map();
|
|
1015
1316
|
ttlResolver;
|
|
@@ -1018,6 +1319,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1018
1319
|
writeBehindQueue = [];
|
|
1019
1320
|
writeBehindTimer;
|
|
1020
1321
|
writeBehindFlushPromise;
|
|
1322
|
+
generationCleanupPromise;
|
|
1021
1323
|
isDisconnecting = false;
|
|
1022
1324
|
disconnectPromise;
|
|
1023
1325
|
/**
|
|
@@ -1030,6 +1332,9 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1030
1332
|
const normalizedKey = this.qualifyKey(this.validateCacheKey(key));
|
|
1031
1333
|
this.validateWriteOptions(options);
|
|
1032
1334
|
await this.awaitStartup("get");
|
|
1335
|
+
return this.getPrepared(normalizedKey, fetcher, options);
|
|
1336
|
+
}
|
|
1337
|
+
async getPrepared(normalizedKey, fetcher, options) {
|
|
1033
1338
|
const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
|
|
1034
1339
|
if (hit.found) {
|
|
1035
1340
|
this.ttlResolver.recordAccess(normalizedKey);
|
|
@@ -1107,6 +1412,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1107
1412
|
return true;
|
|
1108
1413
|
}
|
|
1109
1414
|
} catch {
|
|
1415
|
+
await this.reportRecoverableLayerFailure(layer, "has", new Error(`has() failed for layer "${layer.name}"`));
|
|
1110
1416
|
}
|
|
1111
1417
|
} else {
|
|
1112
1418
|
try {
|
|
@@ -1114,7 +1420,8 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1114
1420
|
if (value !== null) {
|
|
1115
1421
|
return true;
|
|
1116
1422
|
}
|
|
1117
|
-
} catch {
|
|
1423
|
+
} catch (error) {
|
|
1424
|
+
await this.reportRecoverableLayerFailure(layer, "has", error);
|
|
1118
1425
|
}
|
|
1119
1426
|
}
|
|
1120
1427
|
}
|
|
@@ -1206,13 +1513,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1206
1513
|
normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
|
|
1207
1514
|
const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
|
|
1208
1515
|
if (!canFastPath) {
|
|
1516
|
+
await this.awaitStartup("mget");
|
|
1209
1517
|
const pendingReads = /* @__PURE__ */ new Map();
|
|
1210
1518
|
return Promise.all(
|
|
1211
1519
|
normalizedEntries.map((entry) => {
|
|
1212
1520
|
const optionsSignature = this.serializeOptions(entry.options);
|
|
1213
1521
|
const existing = pendingReads.get(entry.key);
|
|
1214
1522
|
if (!existing) {
|
|
1215
|
-
const promise = this.
|
|
1523
|
+
const promise = this.getPrepared(entry.key, entry.fetch, entry.options);
|
|
1216
1524
|
pendingReads.set(entry.key, {
|
|
1217
1525
|
promise,
|
|
1218
1526
|
fetch: entry.fetch,
|
|
@@ -1351,14 +1659,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1351
1659
|
}
|
|
1352
1660
|
async invalidateByPattern(pattern) {
|
|
1353
1661
|
await this.awaitStartup("invalidateByPattern");
|
|
1354
|
-
const keys = await this.
|
|
1662
|
+
const keys = await this.collectKeysMatchingPattern(this.qualifyPattern(pattern));
|
|
1355
1663
|
await this.deleteKeys(keys);
|
|
1356
1664
|
await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
|
|
1357
1665
|
}
|
|
1358
1666
|
async invalidateByPrefix(prefix) {
|
|
1359
1667
|
await this.awaitStartup("invalidateByPrefix");
|
|
1360
1668
|
const qualifiedPrefix = this.qualifyKey(this.validateCacheKey(prefix));
|
|
1361
|
-
const keys =
|
|
1669
|
+
const keys = await this.collectKeysWithPrefix(qualifiedPrefix);
|
|
1362
1670
|
await this.deleteKeys(keys);
|
|
1363
1671
|
await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
|
|
1364
1672
|
}
|
|
@@ -1408,9 +1716,18 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1408
1716
|
})
|
|
1409
1717
|
);
|
|
1410
1718
|
}
|
|
1719
|
+
/**
|
|
1720
|
+
* Rotates the active generation prefix used for all future cache keys.
|
|
1721
|
+
* Previous-generation keys remain in the underlying layers until they expire,
|
|
1722
|
+
* unless `generationCleanup` is enabled to prune them in the background.
|
|
1723
|
+
*/
|
|
1411
1724
|
bumpGeneration(nextGeneration) {
|
|
1412
1725
|
const current = this.currentGeneration ?? 0;
|
|
1726
|
+
const previousGeneration = this.currentGeneration;
|
|
1413
1727
|
this.currentGeneration = nextGeneration ?? current + 1;
|
|
1728
|
+
if (previousGeneration !== void 0 && previousGeneration !== this.currentGeneration && this.shouldCleanupGenerations()) {
|
|
1729
|
+
this.scheduleGenerationCleanup(previousGeneration);
|
|
1730
|
+
}
|
|
1414
1731
|
return this.currentGeneration;
|
|
1415
1732
|
}
|
|
1416
1733
|
/**
|
|
@@ -1494,27 +1811,28 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1494
1811
|
this.assertActive("persistToFile");
|
|
1495
1812
|
const snapshot = await this.exportState();
|
|
1496
1813
|
const { promises: fs2 } = await import("fs");
|
|
1497
|
-
await fs2.writeFile(filePath, JSON.stringify(snapshot, null, 2), "utf8");
|
|
1814
|
+
await fs2.writeFile(await this.validateSnapshotFilePath(filePath), JSON.stringify(snapshot, null, 2), "utf8");
|
|
1498
1815
|
}
|
|
1499
1816
|
async restoreFromFile(filePath) {
|
|
1500
1817
|
this.assertActive("restoreFromFile");
|
|
1501
1818
|
const { promises: fs2 } = await import("fs");
|
|
1502
|
-
const raw = await fs2.readFile(filePath, "utf8");
|
|
1819
|
+
const raw = await fs2.readFile(await this.validateSnapshotFilePath(filePath), "utf8");
|
|
1503
1820
|
let parsed;
|
|
1504
1821
|
try {
|
|
1505
|
-
parsed = JSON.parse(raw
|
|
1506
|
-
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
1507
|
-
return Object.assign(/* @__PURE__ */ Object.create(null), value);
|
|
1508
|
-
}
|
|
1509
|
-
return value;
|
|
1510
|
-
});
|
|
1822
|
+
parsed = JSON.parse(raw);
|
|
1511
1823
|
} catch (cause) {
|
|
1512
1824
|
throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
|
|
1513
1825
|
}
|
|
1514
1826
|
if (!this.isCacheSnapshotEntries(parsed)) {
|
|
1515
1827
|
throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
|
|
1516
1828
|
}
|
|
1517
|
-
await this.importState(
|
|
1829
|
+
await this.importState(
|
|
1830
|
+
parsed.map((entry) => ({
|
|
1831
|
+
key: entry.key,
|
|
1832
|
+
value: this.sanitizeSnapshotValue(entry.value),
|
|
1833
|
+
ttl: entry.ttl
|
|
1834
|
+
}))
|
|
1835
|
+
);
|
|
1518
1836
|
}
|
|
1519
1837
|
async disconnect() {
|
|
1520
1838
|
if (!this.disconnectPromise) {
|
|
@@ -1523,6 +1841,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1523
1841
|
await this.startup;
|
|
1524
1842
|
await this.unsubscribeInvalidation?.();
|
|
1525
1843
|
await this.flushWriteBehindQueue();
|
|
1844
|
+
await this.generationCleanupPromise;
|
|
1526
1845
|
await Promise.allSettled([...this.backgroundRefreshes.values()]);
|
|
1527
1846
|
if (this.writeBehindTimer) {
|
|
1528
1847
|
clearInterval(this.writeBehindTimer);
|
|
@@ -1590,6 +1909,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1590
1909
|
try {
|
|
1591
1910
|
fetched = await this.fetchRateLimiter.schedule(
|
|
1592
1911
|
options?.fetcherRateLimit ?? this.options.fetcherRateLimit,
|
|
1912
|
+
{ key, fetcher },
|
|
1593
1913
|
fetcher
|
|
1594
1914
|
);
|
|
1595
1915
|
this.circuitBreakerManager.recordSuccess(key);
|
|
@@ -1605,8 +1925,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1605
1925
|
await this.storeEntry(key, "empty", null, options);
|
|
1606
1926
|
return null;
|
|
1607
1927
|
}
|
|
1608
|
-
if (options?.shouldCache
|
|
1609
|
-
|
|
1928
|
+
if (options?.shouldCache) {
|
|
1929
|
+
try {
|
|
1930
|
+
if (!options.shouldCache(fetched)) {
|
|
1931
|
+
return fetched;
|
|
1932
|
+
}
|
|
1933
|
+
} catch (error) {
|
|
1934
|
+
this.logger.warn?.("shouldCache-error", { key, error: this.formatError(error) });
|
|
1935
|
+
}
|
|
1610
1936
|
}
|
|
1611
1937
|
await this.storeEntry(key, "value", fetched, options);
|
|
1612
1938
|
return fetched;
|
|
@@ -1833,7 +2159,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1833
2159
|
const refresh = (async () => {
|
|
1834
2160
|
this.metricsCollector.increment("refreshes");
|
|
1835
2161
|
try {
|
|
1836
|
-
await this.
|
|
2162
|
+
await this.runBackgroundRefresh(key, fetcher, options);
|
|
1837
2163
|
} catch (error) {
|
|
1838
2164
|
this.metricsCollector.increment("refreshErrors");
|
|
1839
2165
|
this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
|
|
@@ -1843,11 +2169,22 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1843
2169
|
})();
|
|
1844
2170
|
this.backgroundRefreshes.set(key, refresh);
|
|
1845
2171
|
}
|
|
2172
|
+
async runBackgroundRefresh(key, fetcher, options) {
|
|
2173
|
+
const timeoutMs = this.options.backgroundRefreshTimeoutMs ?? DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS;
|
|
2174
|
+
await this.fetchWithGuards(
|
|
2175
|
+
key,
|
|
2176
|
+
() => this.withTimeout(fetcher(), timeoutMs, () => {
|
|
2177
|
+
return new Error(`Background refresh timed out after ${timeoutMs}ms for key "${key}".`);
|
|
2178
|
+
}),
|
|
2179
|
+
options
|
|
2180
|
+
);
|
|
2181
|
+
}
|
|
1846
2182
|
resolveSingleFlightOptions() {
|
|
1847
2183
|
return {
|
|
1848
2184
|
leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
|
|
1849
2185
|
waitTimeoutMs: this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS,
|
|
1850
|
-
pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS
|
|
2186
|
+
pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS,
|
|
2187
|
+
renewIntervalMs: this.options.singleFlightRenewIntervalMs
|
|
1851
2188
|
};
|
|
1852
2189
|
}
|
|
1853
2190
|
async deleteKeys(keys) {
|
|
@@ -1907,10 +2244,122 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1907
2244
|
return String(error);
|
|
1908
2245
|
}
|
|
1909
2246
|
sleep(ms) {
|
|
1910
|
-
return new Promise((
|
|
2247
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
2248
|
+
}
|
|
2249
|
+
async withTimeout(promise, timeoutMs, onTimeout) {
|
|
2250
|
+
if (timeoutMs <= 0) {
|
|
2251
|
+
return promise;
|
|
2252
|
+
}
|
|
2253
|
+
let timer;
|
|
2254
|
+
const observedPromise = promise.then(
|
|
2255
|
+
(value) => ({ kind: "value", value }),
|
|
2256
|
+
(error) => ({ kind: "error", error })
|
|
2257
|
+
);
|
|
2258
|
+
try {
|
|
2259
|
+
const result = await Promise.race([
|
|
2260
|
+
observedPromise,
|
|
2261
|
+
new Promise((_, reject) => {
|
|
2262
|
+
timer = setTimeout(() => reject(onTimeout()), timeoutMs);
|
|
2263
|
+
timer.unref?.();
|
|
2264
|
+
})
|
|
2265
|
+
]);
|
|
2266
|
+
if (result && typeof result === "object" && "kind" in result) {
|
|
2267
|
+
if (result.kind === "error") {
|
|
2268
|
+
throw result.error;
|
|
2269
|
+
}
|
|
2270
|
+
return result.value;
|
|
2271
|
+
}
|
|
2272
|
+
return result;
|
|
2273
|
+
} finally {
|
|
2274
|
+
if (timer) {
|
|
2275
|
+
clearTimeout(timer);
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
1911
2278
|
}
|
|
1912
2279
|
shouldBroadcastL1Invalidation() {
|
|
1913
|
-
return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ??
|
|
2280
|
+
return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false;
|
|
2281
|
+
}
|
|
2282
|
+
async collectKeysWithPrefix(prefix) {
|
|
2283
|
+
const matches = new Set(
|
|
2284
|
+
this.tagIndex.keysForPrefix ? await this.tagIndex.keysForPrefix(prefix) : await this.tagIndex.matchPattern(`${prefix}*`)
|
|
2285
|
+
);
|
|
2286
|
+
await Promise.all(
|
|
2287
|
+
this.layers.map(async (layer) => {
|
|
2288
|
+
if (!layer.keys || this.shouldSkipLayer(layer)) {
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
try {
|
|
2292
|
+
const keys = await layer.keys();
|
|
2293
|
+
for (const key of keys) {
|
|
2294
|
+
if (key.startsWith(prefix)) {
|
|
2295
|
+
matches.add(key);
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
} catch (error) {
|
|
2299
|
+
await this.handleLayerFailure(layer, "invalidate-prefix-scan", error);
|
|
2300
|
+
}
|
|
2301
|
+
})
|
|
2302
|
+
);
|
|
2303
|
+
return [...matches];
|
|
2304
|
+
}
|
|
2305
|
+
async collectKeysMatchingPattern(pattern) {
|
|
2306
|
+
const matches = new Set(await this.tagIndex.matchPattern(pattern));
|
|
2307
|
+
await Promise.all(
|
|
2308
|
+
this.layers.map(async (layer) => {
|
|
2309
|
+
if (!layer.keys || this.shouldSkipLayer(layer)) {
|
|
2310
|
+
return;
|
|
2311
|
+
}
|
|
2312
|
+
try {
|
|
2313
|
+
const keys = await layer.keys();
|
|
2314
|
+
for (const key of keys) {
|
|
2315
|
+
if (PatternMatcher.matches(pattern, key)) {
|
|
2316
|
+
matches.add(key);
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
} catch (error) {
|
|
2320
|
+
await this.handleLayerFailure(layer, "invalidate-pattern-scan", error);
|
|
2321
|
+
}
|
|
2322
|
+
})
|
|
2323
|
+
);
|
|
2324
|
+
return [...matches];
|
|
2325
|
+
}
|
|
2326
|
+
shouldCleanupGenerations() {
|
|
2327
|
+
return Boolean(this.options.generationCleanup);
|
|
2328
|
+
}
|
|
2329
|
+
generationCleanupBatchSize() {
|
|
2330
|
+
const configured = typeof this.options.generationCleanup === "object" ? this.options.generationCleanup.batchSize : void 0;
|
|
2331
|
+
return configured ?? 500;
|
|
2332
|
+
}
|
|
2333
|
+
scheduleGenerationCleanup(generation) {
|
|
2334
|
+
const task = (this.generationCleanupPromise ?? Promise.resolve()).then(() => this.cleanupGeneration(generation)).catch((error) => {
|
|
2335
|
+
this.logger.warn?.("generation-cleanup-error", {
|
|
2336
|
+
generation,
|
|
2337
|
+
error: this.formatError(error)
|
|
2338
|
+
});
|
|
2339
|
+
});
|
|
2340
|
+
this.generationCleanupPromise = task.finally(() => {
|
|
2341
|
+
if (this.generationCleanupPromise === task) {
|
|
2342
|
+
this.generationCleanupPromise = void 0;
|
|
2343
|
+
}
|
|
2344
|
+
});
|
|
2345
|
+
}
|
|
2346
|
+
async cleanupGeneration(generation) {
|
|
2347
|
+
const prefix = `v${generation}:`;
|
|
2348
|
+
const keys = await this.collectKeysWithPrefix(prefix);
|
|
2349
|
+
if (keys.length === 0) {
|
|
2350
|
+
return;
|
|
2351
|
+
}
|
|
2352
|
+
const batchSize = this.generationCleanupBatchSize();
|
|
2353
|
+
for (let index = 0; index < keys.length; index += batchSize) {
|
|
2354
|
+
const batch = keys.slice(index, index + batchSize);
|
|
2355
|
+
await this.deleteKeys(batch);
|
|
2356
|
+
await this.publishInvalidation({
|
|
2357
|
+
scope: "keys",
|
|
2358
|
+
keys: batch,
|
|
2359
|
+
sourceId: this.instanceId,
|
|
2360
|
+
operation: "invalidate"
|
|
2361
|
+
});
|
|
2362
|
+
}
|
|
1914
2363
|
}
|
|
1915
2364
|
initializeWriteBehind(options) {
|
|
1916
2365
|
if (this.options.writeStrategy !== "write-behind") {
|
|
@@ -1948,7 +2397,17 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1948
2397
|
const batchSize = this.options.writeBehind?.batchSize ?? 100;
|
|
1949
2398
|
const batch = this.writeBehindQueue.splice(0, batchSize);
|
|
1950
2399
|
this.writeBehindFlushPromise = (async () => {
|
|
1951
|
-
await Promise.allSettled(batch.map((operation) => operation()));
|
|
2400
|
+
const results = await Promise.allSettled(batch.map((operation) => operation()));
|
|
2401
|
+
const failures = results.filter((result) => result.status === "rejected");
|
|
2402
|
+
if (failures.length > 0) {
|
|
2403
|
+
this.metricsCollector.increment("writeFailures", failures.length);
|
|
2404
|
+
this.logger.error?.("write-behind-flush-failure", {
|
|
2405
|
+
failed: failures.length,
|
|
2406
|
+
total: batch.length,
|
|
2407
|
+
errors: failures.map((failure) => this.formatError(failure.reason))
|
|
2408
|
+
});
|
|
2409
|
+
this.emitError("write-behind", { failed: failures.length, total: batch.length });
|
|
2410
|
+
}
|
|
1952
2411
|
})();
|
|
1953
2412
|
await this.writeBehindFlushPromise;
|
|
1954
2413
|
this.writeBehindFlushPromise = void 0;
|
|
@@ -2052,8 +2511,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
2052
2511
|
this.validatePositiveNumber("singleFlightLeaseMs", this.options.singleFlightLeaseMs);
|
|
2053
2512
|
this.validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
|
|
2054
2513
|
this.validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
|
|
2514
|
+
this.validatePositiveNumber("singleFlightRenewIntervalMs", this.options.singleFlightRenewIntervalMs);
|
|
2515
|
+
this.validatePositiveNumber("backgroundRefreshTimeoutMs", this.options.backgroundRefreshTimeoutMs);
|
|
2516
|
+
this.validateRateLimitOptions("fetcherRateLimit", this.options.fetcherRateLimit);
|
|
2055
2517
|
this.validateAdaptiveTtlOptions(this.options.adaptiveTtl);
|
|
2056
2518
|
this.validateCircuitBreakerOptions(this.options.circuitBreaker);
|
|
2519
|
+
if (typeof this.options.generationCleanup === "object") {
|
|
2520
|
+
this.validatePositiveNumber("generationCleanup.batchSize", this.options.generationCleanup.batchSize);
|
|
2521
|
+
}
|
|
2057
2522
|
if (this.options.generation !== void 0) {
|
|
2058
2523
|
this.validateNonNegativeNumber("generation", this.options.generation);
|
|
2059
2524
|
}
|
|
@@ -2071,6 +2536,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
2071
2536
|
this.validateTtlPolicy("options.ttlPolicy", options.ttlPolicy);
|
|
2072
2537
|
this.validateAdaptiveTtlOptions(options.adaptiveTtl);
|
|
2073
2538
|
this.validateCircuitBreakerOptions(options.circuitBreaker);
|
|
2539
|
+
this.validateRateLimitOptions("options.fetcherRateLimit", options.fetcherRateLimit);
|
|
2074
2540
|
}
|
|
2075
2541
|
validateLayerNumberOption(name, value) {
|
|
2076
2542
|
if (value === void 0) {
|
|
@@ -2095,6 +2561,20 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
2095
2561
|
throw new Error(`${name} must be a positive finite number.`);
|
|
2096
2562
|
}
|
|
2097
2563
|
}
|
|
2564
|
+
validateRateLimitOptions(name, options) {
|
|
2565
|
+
if (!options) {
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
this.validatePositiveNumber(`${name}.maxConcurrent`, options.maxConcurrent);
|
|
2569
|
+
this.validatePositiveNumber(`${name}.intervalMs`, options.intervalMs);
|
|
2570
|
+
this.validatePositiveNumber(`${name}.maxPerInterval`, options.maxPerInterval);
|
|
2571
|
+
if (options.scope && !["global", "key", "fetcher"].includes(options.scope)) {
|
|
2572
|
+
throw new Error(`${name}.scope must be one of "global", "key", or "fetcher".`);
|
|
2573
|
+
}
|
|
2574
|
+
if (options.bucketKey !== void 0 && options.bucketKey.length === 0) {
|
|
2575
|
+
throw new Error(`${name}.bucketKey must not be empty.`);
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2098
2578
|
validateNonNegativeNumber(name, value) {
|
|
2099
2579
|
if (!Number.isFinite(value) || value < 0) {
|
|
2100
2580
|
throw new Error(`${name} must be a non-negative finite number.`);
|
|
@@ -2110,6 +2590,9 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
2110
2590
|
if (/[\u0000-\u001F\u007F]/.test(key)) {
|
|
2111
2591
|
throw new Error("Cache key contains unsupported control characters.");
|
|
2112
2592
|
}
|
|
2593
|
+
if (/[\uD800-\uDFFF]/.test(key)) {
|
|
2594
|
+
throw new Error("Cache key contains unsupported surrogate code points.");
|
|
2595
|
+
}
|
|
2113
2596
|
return key;
|
|
2114
2597
|
}
|
|
2115
2598
|
validateTtlPolicy(name, policy) {
|
|
@@ -2187,6 +2670,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
2187
2670
|
this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
|
|
2188
2671
|
return null;
|
|
2189
2672
|
}
|
|
2673
|
+
async reportRecoverableLayerFailure(layer, operation, error) {
|
|
2674
|
+
if (this.isGracefulDegradationEnabled()) {
|
|
2675
|
+
await this.handleLayerFailure(layer, operation, error);
|
|
2676
|
+
return;
|
|
2677
|
+
}
|
|
2678
|
+
this.logger.warn?.("layer-operation-failed", { layer: layer.name, operation, error: this.formatError(error) });
|
|
2679
|
+
this.emitError(operation, { layer: layer.name, degraded: false, error: this.formatError(error) });
|
|
2680
|
+
}
|
|
2190
2681
|
isGracefulDegradationEnabled() {
|
|
2191
2682
|
return Boolean(this.options.gracefulDegradation);
|
|
2192
2683
|
}
|
|
@@ -2210,10 +2701,16 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
2210
2701
|
}
|
|
2211
2702
|
}
|
|
2212
2703
|
serializeKeyPart(value) {
|
|
2213
|
-
if (typeof value === "string"
|
|
2214
|
-
return
|
|
2704
|
+
if (typeof value === "string") {
|
|
2705
|
+
return `s:${value}`;
|
|
2706
|
+
}
|
|
2707
|
+
if (typeof value === "number") {
|
|
2708
|
+
return `n:${value}`;
|
|
2215
2709
|
}
|
|
2216
|
-
|
|
2710
|
+
if (typeof value === "boolean") {
|
|
2711
|
+
return `b:${value}`;
|
|
2712
|
+
}
|
|
2713
|
+
return `j:${JSON.stringify(this.normalizeForSerialization(value))}`;
|
|
2217
2714
|
}
|
|
2218
2715
|
isCacheSnapshotEntries(value) {
|
|
2219
2716
|
return Array.isArray(value) && value.every((entry) => {
|
|
@@ -2221,15 +2718,39 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
2221
2718
|
return false;
|
|
2222
2719
|
}
|
|
2223
2720
|
const candidate = entry;
|
|
2224
|
-
return typeof candidate.key === "string";
|
|
2721
|
+
return typeof candidate.key === "string" && (candidate.ttl === void 0 || typeof candidate.ttl === "number" && Number.isFinite(candidate.ttl) && candidate.ttl >= 0);
|
|
2225
2722
|
});
|
|
2226
2723
|
}
|
|
2724
|
+
sanitizeSnapshotValue(value) {
|
|
2725
|
+
return this.snapshotSerializer.deserialize(this.snapshotSerializer.serialize(value));
|
|
2726
|
+
}
|
|
2727
|
+
async validateSnapshotFilePath(filePath) {
|
|
2728
|
+
if (filePath.length === 0) {
|
|
2729
|
+
throw new Error("filePath must not be empty.");
|
|
2730
|
+
}
|
|
2731
|
+
if (filePath.includes("\0")) {
|
|
2732
|
+
throw new Error("filePath must not contain null bytes.");
|
|
2733
|
+
}
|
|
2734
|
+
const path = await import("path");
|
|
2735
|
+
const resolved = path.resolve(filePath);
|
|
2736
|
+
const baseDir = this.options.snapshotBaseDir === false ? false : path.resolve(this.options.snapshotBaseDir ?? process.cwd());
|
|
2737
|
+
if (baseDir !== false) {
|
|
2738
|
+
const relative = path.relative(baseDir, resolved);
|
|
2739
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
2740
|
+
throw new Error(`filePath is outside the allowed snapshot directory: ${baseDir}`);
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
return resolved;
|
|
2744
|
+
}
|
|
2227
2745
|
normalizeForSerialization(value) {
|
|
2228
2746
|
if (Array.isArray(value)) {
|
|
2229
2747
|
return value.map((entry) => this.normalizeForSerialization(entry));
|
|
2230
2748
|
}
|
|
2231
2749
|
if (value && typeof value === "object") {
|
|
2232
2750
|
return Object.keys(value).sort().reduce((normalized, key) => {
|
|
2751
|
+
if (DANGEROUS_OBJECT_KEYS.has(key)) {
|
|
2752
|
+
return normalized;
|
|
2753
|
+
}
|
|
2233
2754
|
normalized[key] = this.normalizeForSerialization(value[key]);
|
|
2234
2755
|
return normalized;
|
|
2235
2756
|
}, {});
|
|
@@ -2323,19 +2844,21 @@ var RedisTagIndex = class {
|
|
|
2323
2844
|
client;
|
|
2324
2845
|
prefix;
|
|
2325
2846
|
scanCount;
|
|
2847
|
+
knownKeysShards;
|
|
2326
2848
|
constructor(options) {
|
|
2327
2849
|
this.client = options.client;
|
|
2328
2850
|
this.prefix = options.prefix ?? "layercache:tag-index";
|
|
2329
2851
|
this.scanCount = options.scanCount ?? 100;
|
|
2852
|
+
this.knownKeysShards = normalizeKnownKeysShards(options.knownKeysShards);
|
|
2330
2853
|
}
|
|
2331
2854
|
async touch(key) {
|
|
2332
|
-
await this.client.sadd(this.
|
|
2855
|
+
await this.client.sadd(this.knownKeysKeyFor(key), key);
|
|
2333
2856
|
}
|
|
2334
2857
|
async track(key, tags) {
|
|
2335
2858
|
const keyTagsKey = this.keyTagsKey(key);
|
|
2336
2859
|
const existingTags = await this.client.smembers(keyTagsKey);
|
|
2337
2860
|
const pipeline = this.client.pipeline();
|
|
2338
|
-
pipeline.sadd(this.
|
|
2861
|
+
pipeline.sadd(this.knownKeysKeyFor(key), key);
|
|
2339
2862
|
for (const tag of existingTags) {
|
|
2340
2863
|
pipeline.srem(this.tagKeysKey(tag), key);
|
|
2341
2864
|
}
|
|
@@ -2352,7 +2875,7 @@ var RedisTagIndex = class {
|
|
|
2352
2875
|
const keyTagsKey = this.keyTagsKey(key);
|
|
2353
2876
|
const existingTags = await this.client.smembers(keyTagsKey);
|
|
2354
2877
|
const pipeline = this.client.pipeline();
|
|
2355
|
-
pipeline.srem(this.
|
|
2878
|
+
pipeline.srem(this.knownKeysKeyFor(key), key);
|
|
2356
2879
|
pipeline.del(keyTagsKey);
|
|
2357
2880
|
for (const tag of existingTags) {
|
|
2358
2881
|
pipeline.srem(this.tagKeysKey(tag), key);
|
|
@@ -2364,12 +2887,14 @@ var RedisTagIndex = class {
|
|
|
2364
2887
|
}
|
|
2365
2888
|
async keysForPrefix(prefix) {
|
|
2366
2889
|
const matches = [];
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2890
|
+
for (const knownKeysKey of this.knownKeysKeys()) {
|
|
2891
|
+
let cursor = "0";
|
|
2892
|
+
do {
|
|
2893
|
+
const [nextCursor, keys] = await this.client.sscan(knownKeysKey, cursor, "COUNT", this.scanCount);
|
|
2894
|
+
cursor = nextCursor;
|
|
2895
|
+
matches.push(...keys.filter((key) => key.startsWith(prefix)));
|
|
2896
|
+
} while (cursor !== "0");
|
|
2897
|
+
}
|
|
2373
2898
|
return matches;
|
|
2374
2899
|
}
|
|
2375
2900
|
async tagsForKey(key) {
|
|
@@ -2377,19 +2902,21 @@ var RedisTagIndex = class {
|
|
|
2377
2902
|
}
|
|
2378
2903
|
async matchPattern(pattern) {
|
|
2379
2904
|
const matches = [];
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
this.
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2905
|
+
for (const knownKeysKey of this.knownKeysKeys()) {
|
|
2906
|
+
let cursor = "0";
|
|
2907
|
+
do {
|
|
2908
|
+
const [nextCursor, keys] = await this.client.sscan(
|
|
2909
|
+
knownKeysKey,
|
|
2910
|
+
cursor,
|
|
2911
|
+
"MATCH",
|
|
2912
|
+
pattern,
|
|
2913
|
+
"COUNT",
|
|
2914
|
+
this.scanCount
|
|
2915
|
+
);
|
|
2916
|
+
cursor = nextCursor;
|
|
2917
|
+
matches.push(...keys.filter((key) => PatternMatcher.matches(pattern, key)));
|
|
2918
|
+
} while (cursor !== "0");
|
|
2919
|
+
}
|
|
2393
2920
|
return matches;
|
|
2394
2921
|
}
|
|
2395
2922
|
async clear() {
|
|
@@ -2410,8 +2937,17 @@ var RedisTagIndex = class {
|
|
|
2410
2937
|
} while (cursor !== "0");
|
|
2411
2938
|
return matches;
|
|
2412
2939
|
}
|
|
2413
|
-
|
|
2414
|
-
|
|
2940
|
+
knownKeysKeyFor(key) {
|
|
2941
|
+
if (this.knownKeysShards === 1) {
|
|
2942
|
+
return `${this.prefix}:keys`;
|
|
2943
|
+
}
|
|
2944
|
+
return `${this.prefix}:keys:${simpleHash(key) % this.knownKeysShards}`;
|
|
2945
|
+
}
|
|
2946
|
+
knownKeysKeys() {
|
|
2947
|
+
if (this.knownKeysShards === 1) {
|
|
2948
|
+
return [`${this.prefix}:keys`];
|
|
2949
|
+
}
|
|
2950
|
+
return Array.from({ length: this.knownKeysShards }, (_, index) => `${this.prefix}:keys:${index}`);
|
|
2415
2951
|
}
|
|
2416
2952
|
keyTagsKey(key) {
|
|
2417
2953
|
return `${this.prefix}:key:${encodeURIComponent(key)}`;
|
|
@@ -2420,6 +2956,22 @@ var RedisTagIndex = class {
|
|
|
2420
2956
|
return `${this.prefix}:tag:${encodeURIComponent(tag)}`;
|
|
2421
2957
|
}
|
|
2422
2958
|
};
|
|
2959
|
+
function normalizeKnownKeysShards(value) {
|
|
2960
|
+
if (value === void 0) {
|
|
2961
|
+
return 1;
|
|
2962
|
+
}
|
|
2963
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
2964
|
+
throw new Error("RedisTagIndex.knownKeysShards must be a positive integer.");
|
|
2965
|
+
}
|
|
2966
|
+
return value;
|
|
2967
|
+
}
|
|
2968
|
+
function simpleHash(value) {
|
|
2969
|
+
let hash = 0;
|
|
2970
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
2971
|
+
hash = hash * 31 + value.charCodeAt(index) >>> 0;
|
|
2972
|
+
}
|
|
2973
|
+
return hash;
|
|
2974
|
+
}
|
|
2423
2975
|
|
|
2424
2976
|
// src/http/createCacheStatsHandler.ts
|
|
2425
2977
|
function createCacheStatsHandler(cache) {
|
|
@@ -2459,7 +3011,7 @@ function createCachedMethodDecorator(options) {
|
|
|
2459
3011
|
function createFastifyLayercachePlugin(cache, options = {}) {
|
|
2460
3012
|
return async (fastify) => {
|
|
2461
3013
|
fastify.decorate("cache", cache);
|
|
2462
|
-
if (options.exposeStatsRoute
|
|
3014
|
+
if (options.exposeStatsRoute === true && fastify.get) {
|
|
2463
3015
|
fastify.get(options.statsPath ?? "/cache/stats", async () => cache.getStats());
|
|
2464
3016
|
}
|
|
2465
3017
|
};
|
|
@@ -2475,7 +3027,8 @@ function createExpressCacheMiddleware(cache, options = {}) {
|
|
|
2475
3027
|
next();
|
|
2476
3028
|
return;
|
|
2477
3029
|
}
|
|
2478
|
-
const
|
|
3030
|
+
const rawUrl = req.originalUrl ?? req.url ?? "/";
|
|
3031
|
+
const key = options.keyResolver ? options.keyResolver(req) : `${method}:${normalizeUrl(rawUrl)}`;
|
|
2479
3032
|
const cached = await cache.get(key, void 0, options);
|
|
2480
3033
|
if (cached !== null) {
|
|
2481
3034
|
res.setHeader?.("content-type", "application/json; charset=utf-8");
|
|
@@ -2491,7 +3044,12 @@ function createExpressCacheMiddleware(cache, options = {}) {
|
|
|
2491
3044
|
if (originalJson) {
|
|
2492
3045
|
res.json = (body) => {
|
|
2493
3046
|
res.setHeader?.("x-cache", "MISS");
|
|
2494
|
-
|
|
3047
|
+
cache.set(key, body, options).catch((err) => {
|
|
3048
|
+
cache.emit("error", {
|
|
3049
|
+
operation: "set",
|
|
3050
|
+
error: err instanceof Error ? err.message : String(err)
|
|
3051
|
+
});
|
|
3052
|
+
});
|
|
2495
3053
|
return originalJson(body);
|
|
2496
3054
|
};
|
|
2497
3055
|
}
|
|
@@ -2501,6 +3059,15 @@ function createExpressCacheMiddleware(cache, options = {}) {
|
|
|
2501
3059
|
}
|
|
2502
3060
|
};
|
|
2503
3061
|
}
|
|
3062
|
+
function normalizeUrl(url) {
|
|
3063
|
+
try {
|
|
3064
|
+
const parsed = new URL(url, "http://localhost");
|
|
3065
|
+
parsed.searchParams.sort();
|
|
3066
|
+
return decodeURIComponent(parsed.pathname) + parsed.search;
|
|
3067
|
+
} catch {
|
|
3068
|
+
return url;
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
2504
3071
|
|
|
2505
3072
|
// src/integrations/graphql.ts
|
|
2506
3073
|
function cacheGraphqlResolver(cache, prefix, resolver, options = {}) {
|
|
@@ -2520,7 +3087,8 @@ function createHonoCacheMiddleware(cache, options = {}) {
|
|
|
2520
3087
|
await next();
|
|
2521
3088
|
return;
|
|
2522
3089
|
}
|
|
2523
|
-
const
|
|
3090
|
+
const rawPath = context.req.path ?? context.req.url ?? "/";
|
|
3091
|
+
const key = options.keyResolver ? options.keyResolver(context.req) : `${method}:${normalizeUrl2(rawPath)}`;
|
|
2524
3092
|
const cached = await cache.get(key, void 0, options);
|
|
2525
3093
|
if (cached !== null) {
|
|
2526
3094
|
context.header?.("x-cache", "HIT");
|
|
@@ -2531,12 +3099,26 @@ function createHonoCacheMiddleware(cache, options = {}) {
|
|
|
2531
3099
|
const originalJson = context.json.bind(context);
|
|
2532
3100
|
context.json = (body, status) => {
|
|
2533
3101
|
context.header?.("x-cache", "MISS");
|
|
2534
|
-
|
|
3102
|
+
cache.set(key, body, options).catch((err) => {
|
|
3103
|
+
cache.emit("error", {
|
|
3104
|
+
operation: "set",
|
|
3105
|
+
error: err instanceof Error ? err.message : String(err)
|
|
3106
|
+
});
|
|
3107
|
+
});
|
|
2535
3108
|
return originalJson(body, status);
|
|
2536
3109
|
};
|
|
2537
3110
|
await next();
|
|
2538
3111
|
};
|
|
2539
3112
|
}
|
|
3113
|
+
function normalizeUrl2(url) {
|
|
3114
|
+
try {
|
|
3115
|
+
const parsed = new URL(url, "http://localhost");
|
|
3116
|
+
parsed.searchParams.sort();
|
|
3117
|
+
return decodeURIComponent(parsed.pathname) + parsed.search;
|
|
3118
|
+
} catch {
|
|
3119
|
+
return url;
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
2540
3122
|
|
|
2541
3123
|
// src/integrations/opentelemetry.ts
|
|
2542
3124
|
function createOpenTelemetryPlugin(cache, tracer) {
|
|
@@ -2671,16 +3253,10 @@ var MemoryLayer = class {
|
|
|
2671
3253
|
return entry.value;
|
|
2672
3254
|
}
|
|
2673
3255
|
async getMany(keys) {
|
|
2674
|
-
|
|
2675
|
-
for (const key of keys) {
|
|
2676
|
-
values.push(await this.getEntry(key));
|
|
2677
|
-
}
|
|
2678
|
-
return values;
|
|
3256
|
+
return Promise.all(keys.map((key) => this.getEntry(key)));
|
|
2679
3257
|
}
|
|
2680
3258
|
async setMany(entries) {
|
|
2681
|
-
|
|
2682
|
-
await this.set(entry.key, entry.value, entry.ttl);
|
|
2683
|
-
}
|
|
3259
|
+
await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.ttl)));
|
|
2684
3260
|
}
|
|
2685
3261
|
async set(key, value, ttl = this.defaultTtl) {
|
|
2686
3262
|
this.entries.delete(key);
|
|
@@ -2816,19 +3392,6 @@ var MemoryLayer = class {
|
|
|
2816
3392
|
// src/layers/RedisLayer.ts
|
|
2817
3393
|
var import_node_util = require("util");
|
|
2818
3394
|
var import_node_zlib = require("zlib");
|
|
2819
|
-
|
|
2820
|
-
// src/serialization/JsonSerializer.ts
|
|
2821
|
-
var JsonSerializer = class {
|
|
2822
|
-
serialize(value) {
|
|
2823
|
-
return JSON.stringify(value);
|
|
2824
|
-
}
|
|
2825
|
-
deserialize(payload) {
|
|
2826
|
-
const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
|
|
2827
|
-
return JSON.parse(normalized);
|
|
2828
|
-
}
|
|
2829
|
-
};
|
|
2830
|
-
|
|
2831
|
-
// src/layers/RedisLayer.ts
|
|
2832
3395
|
var BATCH_DELETE_SIZE = 500;
|
|
2833
3396
|
var gzipAsync = (0, import_node_util.promisify)(import_node_zlib.gzip);
|
|
2834
3397
|
var gunzipAsync = (0, import_node_util.promisify)(import_node_zlib.gunzip);
|
|
@@ -2845,6 +3408,7 @@ var RedisLayer = class {
|
|
|
2845
3408
|
scanCount;
|
|
2846
3409
|
compression;
|
|
2847
3410
|
compressionThreshold;
|
|
3411
|
+
decompressionMaxBytes;
|
|
2848
3412
|
disconnectOnDispose;
|
|
2849
3413
|
constructor(options) {
|
|
2850
3414
|
this.client = options.client;
|
|
@@ -2856,6 +3420,7 @@ var RedisLayer = class {
|
|
|
2856
3420
|
this.scanCount = options.scanCount ?? 100;
|
|
2857
3421
|
this.compression = options.compression;
|
|
2858
3422
|
this.compressionThreshold = options.compressionThreshold ?? 1024;
|
|
3423
|
+
this.decompressionMaxBytes = options.decompressionMaxBytes ?? 64 * 1024 * 1024;
|
|
2859
3424
|
this.disconnectOnDispose = options.disconnectOnDispose ?? false;
|
|
2860
3425
|
}
|
|
2861
3426
|
async get(key) {
|
|
@@ -3055,16 +3620,29 @@ var RedisLayer = class {
|
|
|
3055
3620
|
}
|
|
3056
3621
|
/**
|
|
3057
3622
|
* Decompresses the payload asynchronously if a compression header is present.
|
|
3623
|
+
* Enforces a maximum decompressed size to prevent decompression bomb attacks.
|
|
3058
3624
|
*/
|
|
3059
3625
|
async decodePayload(payload) {
|
|
3060
3626
|
if (!Buffer.isBuffer(payload)) {
|
|
3061
3627
|
return payload;
|
|
3062
3628
|
}
|
|
3063
3629
|
if (payload.subarray(0, 10).toString() === "LCZ1:gzip:") {
|
|
3064
|
-
|
|
3630
|
+
const decompressed = await gunzipAsync(payload.subarray(10));
|
|
3631
|
+
if (decompressed.byteLength > this.decompressionMaxBytes) {
|
|
3632
|
+
throw new Error(
|
|
3633
|
+
`Decompressed payload (${decompressed.byteLength} bytes) exceeds decompressionMaxBytes limit (${this.decompressionMaxBytes} bytes).`
|
|
3634
|
+
);
|
|
3635
|
+
}
|
|
3636
|
+
return decompressed;
|
|
3065
3637
|
}
|
|
3066
3638
|
if (payload.subarray(0, 12).toString() === "LCZ1:brotli:") {
|
|
3067
|
-
|
|
3639
|
+
const decompressed = await brotliDecompressAsync(payload.subarray(12));
|
|
3640
|
+
if (decompressed.byteLength > this.decompressionMaxBytes) {
|
|
3641
|
+
throw new Error(
|
|
3642
|
+
`Decompressed payload (${decompressed.byteLength} bytes) exceeds decompressionMaxBytes limit (${this.decompressionMaxBytes} bytes).`
|
|
3643
|
+
);
|
|
3644
|
+
}
|
|
3645
|
+
return decompressed;
|
|
3068
3646
|
}
|
|
3069
3647
|
return payload;
|
|
3070
3648
|
}
|
|
@@ -3083,11 +3661,11 @@ var DiskLayer = class {
|
|
|
3083
3661
|
maxFiles;
|
|
3084
3662
|
writeQueue = Promise.resolve();
|
|
3085
3663
|
constructor(options) {
|
|
3086
|
-
this.directory = options.directory;
|
|
3664
|
+
this.directory = this.resolveDirectory(options.directory);
|
|
3087
3665
|
this.defaultTtl = options.ttl;
|
|
3088
3666
|
this.name = options.name ?? "disk";
|
|
3089
3667
|
this.serializer = options.serializer ?? new JsonSerializer();
|
|
3090
|
-
this.maxFiles = options.maxFiles;
|
|
3668
|
+
this.maxFiles = this.normalizeMaxFiles(options.maxFiles);
|
|
3091
3669
|
}
|
|
3092
3670
|
async get(key) {
|
|
3093
3671
|
return unwrapStoredValue(await this.getEntry(key));
|
|
@@ -3102,7 +3680,7 @@ var DiskLayer = class {
|
|
|
3102
3680
|
}
|
|
3103
3681
|
let entry;
|
|
3104
3682
|
try {
|
|
3105
|
-
entry = this.
|
|
3683
|
+
entry = this.deserializeEntry(raw);
|
|
3106
3684
|
} catch {
|
|
3107
3685
|
await this.safeDelete(filePath);
|
|
3108
3686
|
return null;
|
|
@@ -3124,8 +3702,13 @@ var DiskLayer = class {
|
|
|
3124
3702
|
const payload = this.serializer.serialize(entry);
|
|
3125
3703
|
const targetPath = this.keyToPath(key);
|
|
3126
3704
|
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
3127
|
-
|
|
3128
|
-
|
|
3705
|
+
try {
|
|
3706
|
+
await import_node_fs.promises.writeFile(tempPath, payload);
|
|
3707
|
+
await import_node_fs.promises.rename(tempPath, targetPath);
|
|
3708
|
+
} catch (error) {
|
|
3709
|
+
await this.safeDelete(tempPath);
|
|
3710
|
+
throw error;
|
|
3711
|
+
}
|
|
3129
3712
|
if (this.maxFiles !== void 0) {
|
|
3130
3713
|
await this.enforceMaxFiles();
|
|
3131
3714
|
}
|
|
@@ -3135,9 +3718,7 @@ var DiskLayer = class {
|
|
|
3135
3718
|
return Promise.all(keys.map((key) => this.getEntry(key)));
|
|
3136
3719
|
}
|
|
3137
3720
|
async setMany(entries) {
|
|
3138
|
-
|
|
3139
|
-
await this.set(entry.key, entry.value, entry.ttl);
|
|
3140
|
-
}
|
|
3721
|
+
await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.ttl)));
|
|
3141
3722
|
}
|
|
3142
3723
|
async has(key) {
|
|
3143
3724
|
const value = await this.getEntry(key);
|
|
@@ -3153,8 +3734,9 @@ var DiskLayer = class {
|
|
|
3153
3734
|
}
|
|
3154
3735
|
let entry;
|
|
3155
3736
|
try {
|
|
3156
|
-
entry = this.
|
|
3737
|
+
entry = this.deserializeEntry(raw);
|
|
3157
3738
|
} catch {
|
|
3739
|
+
await this.safeDelete(filePath);
|
|
3158
3740
|
return null;
|
|
3159
3741
|
}
|
|
3160
3742
|
if (entry.expiresAt === null) {
|
|
@@ -3211,7 +3793,7 @@ var DiskLayer = class {
|
|
|
3211
3793
|
}
|
|
3212
3794
|
let entry;
|
|
3213
3795
|
try {
|
|
3214
|
-
entry = this.
|
|
3796
|
+
entry = this.deserializeEntry(raw);
|
|
3215
3797
|
} catch {
|
|
3216
3798
|
await this.safeDelete(filePath);
|
|
3217
3799
|
return;
|
|
@@ -3243,6 +3825,31 @@ var DiskLayer = class {
|
|
|
3243
3825
|
const hash = (0, import_node_crypto.createHash)("sha256").update(key).digest("hex");
|
|
3244
3826
|
return (0, import_node_path.join)(this.directory, `${hash}.lc`);
|
|
3245
3827
|
}
|
|
3828
|
+
resolveDirectory(directory) {
|
|
3829
|
+
if (typeof directory !== "string" || directory.trim().length === 0) {
|
|
3830
|
+
throw new Error("DiskLayer.directory must be a non-empty path.");
|
|
3831
|
+
}
|
|
3832
|
+
if (directory.includes("\0")) {
|
|
3833
|
+
throw new Error("DiskLayer.directory must not contain null bytes.");
|
|
3834
|
+
}
|
|
3835
|
+
return (0, import_node_path.resolve)(directory);
|
|
3836
|
+
}
|
|
3837
|
+
normalizeMaxFiles(maxFiles) {
|
|
3838
|
+
if (maxFiles === void 0) {
|
|
3839
|
+
return void 0;
|
|
3840
|
+
}
|
|
3841
|
+
if (!Number.isInteger(maxFiles) || maxFiles <= 0) {
|
|
3842
|
+
throw new Error("DiskLayer.maxFiles must be a positive integer.");
|
|
3843
|
+
}
|
|
3844
|
+
return maxFiles;
|
|
3845
|
+
}
|
|
3846
|
+
deserializeEntry(raw) {
|
|
3847
|
+
const entry = this.serializer.deserialize(raw);
|
|
3848
|
+
if (!isDiskEntry(entry)) {
|
|
3849
|
+
throw new Error("Invalid disk cache entry.");
|
|
3850
|
+
}
|
|
3851
|
+
return entry;
|
|
3852
|
+
}
|
|
3246
3853
|
async safeDelete(filePath) {
|
|
3247
3854
|
try {
|
|
3248
3855
|
await import_node_fs.promises.unlink(filePath);
|
|
@@ -3287,6 +3894,14 @@ var DiskLayer = class {
|
|
|
3287
3894
|
await Promise.all(toEvict.map(({ filePath }) => this.safeDelete(filePath)));
|
|
3288
3895
|
}
|
|
3289
3896
|
};
|
|
3897
|
+
function isDiskEntry(value) {
|
|
3898
|
+
if (!value || typeof value !== "object") {
|
|
3899
|
+
return false;
|
|
3900
|
+
}
|
|
3901
|
+
const candidate = value;
|
|
3902
|
+
const validExpiry = candidate.expiresAt === null || typeof candidate.expiresAt === "number";
|
|
3903
|
+
return typeof candidate.key === "string" && validExpiry && "value" in candidate;
|
|
3904
|
+
}
|
|
3290
3905
|
|
|
3291
3906
|
// src/layers/MemcachedLayer.ts
|
|
3292
3907
|
var MemcachedLayer = class {
|
|
@@ -3307,6 +3922,7 @@ var MemcachedLayer = class {
|
|
|
3307
3922
|
return unwrapStoredValue(await this.getEntry(key));
|
|
3308
3923
|
}
|
|
3309
3924
|
async getEntry(key) {
|
|
3925
|
+
this.validateKey(key);
|
|
3310
3926
|
const result = await this.client.get(this.withPrefix(key));
|
|
3311
3927
|
if (!result || result.value === null) {
|
|
3312
3928
|
return null;
|
|
@@ -3321,16 +3937,19 @@ var MemcachedLayer = class {
|
|
|
3321
3937
|
return Promise.all(keys.map((key) => this.getEntry(key)));
|
|
3322
3938
|
}
|
|
3323
3939
|
async set(key, value, ttl = this.defaultTtl) {
|
|
3940
|
+
this.validateKey(key);
|
|
3324
3941
|
const payload = this.serializer.serialize(value);
|
|
3325
3942
|
await this.client.set(this.withPrefix(key), payload, {
|
|
3326
3943
|
expires: ttl && ttl > 0 ? ttl : void 0
|
|
3327
3944
|
});
|
|
3328
3945
|
}
|
|
3329
3946
|
async has(key) {
|
|
3947
|
+
this.validateKey(key);
|
|
3330
3948
|
const result = await this.client.get(this.withPrefix(key));
|
|
3331
3949
|
return result !== null && result.value !== null;
|
|
3332
3950
|
}
|
|
3333
3951
|
async delete(key) {
|
|
3952
|
+
this.validateKey(key);
|
|
3334
3953
|
await this.client.delete(this.withPrefix(key));
|
|
3335
3954
|
}
|
|
3336
3955
|
async deleteMany(keys) {
|
|
@@ -3344,19 +3963,50 @@ var MemcachedLayer = class {
|
|
|
3344
3963
|
withPrefix(key) {
|
|
3345
3964
|
return `${this.keyPrefix}${key}`;
|
|
3346
3965
|
}
|
|
3966
|
+
validateKey(key) {
|
|
3967
|
+
const fullKey = this.withPrefix(key);
|
|
3968
|
+
if (Buffer.byteLength(fullKey, "utf8") > 250) {
|
|
3969
|
+
throw new Error(`MemcachedLayer: key exceeds 250-byte Memcached limit: "${fullKey.slice(0, 60)}..."`);
|
|
3970
|
+
}
|
|
3971
|
+
if (/[\s\x00-\x1f\x7f]/.test(fullKey)) {
|
|
3972
|
+
throw new Error(
|
|
3973
|
+
"MemcachedLayer: key contains invalid characters (whitespace or control characters are not allowed)."
|
|
3974
|
+
);
|
|
3975
|
+
}
|
|
3976
|
+
}
|
|
3347
3977
|
};
|
|
3348
3978
|
|
|
3349
3979
|
// src/serialization/MsgpackSerializer.ts
|
|
3350
3980
|
var import_msgpack = require("@msgpack/msgpack");
|
|
3981
|
+
var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
3351
3982
|
var MsgpackSerializer = class {
|
|
3352
3983
|
serialize(value) {
|
|
3353
3984
|
return Buffer.from((0, import_msgpack.encode)(value));
|
|
3354
3985
|
}
|
|
3355
3986
|
deserialize(payload) {
|
|
3356
3987
|
const normalized = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
|
|
3357
|
-
return (0, import_msgpack.decode)(normalized);
|
|
3988
|
+
return sanitizeMsgpackValue((0, import_msgpack.decode)(normalized));
|
|
3358
3989
|
}
|
|
3359
3990
|
};
|
|
3991
|
+
function sanitizeMsgpackValue(value) {
|
|
3992
|
+
if (Array.isArray(value)) {
|
|
3993
|
+
return value.map((entry) => sanitizeMsgpackValue(entry));
|
|
3994
|
+
}
|
|
3995
|
+
if (!isPlainObject2(value)) {
|
|
3996
|
+
return value;
|
|
3997
|
+
}
|
|
3998
|
+
const sanitized = {};
|
|
3999
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
4000
|
+
if (DANGEROUS_KEYS.has(key)) {
|
|
4001
|
+
continue;
|
|
4002
|
+
}
|
|
4003
|
+
sanitized[key] = sanitizeMsgpackValue(entry);
|
|
4004
|
+
}
|
|
4005
|
+
return sanitized;
|
|
4006
|
+
}
|
|
4007
|
+
function isPlainObject2(value) {
|
|
4008
|
+
return Object.prototype.toString.call(value) === "[object Object]";
|
|
4009
|
+
}
|
|
3360
4010
|
|
|
3361
4011
|
// src/singleflight/RedisSingleFlightCoordinator.ts
|
|
3362
4012
|
var import_node_crypto2 = require("crypto");
|
|
@@ -3366,6 +4016,12 @@ if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
|
3366
4016
|
end
|
|
3367
4017
|
return 0
|
|
3368
4018
|
`;
|
|
4019
|
+
var RENEW_SCRIPT = `
|
|
4020
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
4021
|
+
return redis.call("pexpire", KEYS[1], ARGV[2])
|
|
4022
|
+
end
|
|
4023
|
+
return 0
|
|
4024
|
+
`;
|
|
3369
4025
|
var RedisSingleFlightCoordinator = class {
|
|
3370
4026
|
client;
|
|
3371
4027
|
prefix;
|
|
@@ -3378,14 +4034,29 @@ var RedisSingleFlightCoordinator = class {
|
|
|
3378
4034
|
const token = (0, import_node_crypto2.randomUUID)();
|
|
3379
4035
|
const acquired = await this.client.set(lockKey, token, "PX", options.leaseMs, "NX");
|
|
3380
4036
|
if (acquired === "OK") {
|
|
4037
|
+
const renewTimer = this.startLeaseRenewal(lockKey, token, options);
|
|
3381
4038
|
try {
|
|
3382
4039
|
return await worker();
|
|
3383
4040
|
} finally {
|
|
4041
|
+
if (renewTimer) {
|
|
4042
|
+
clearInterval(renewTimer);
|
|
4043
|
+
}
|
|
3384
4044
|
await this.client.eval(RELEASE_SCRIPT, 1, lockKey, token);
|
|
3385
4045
|
}
|
|
3386
4046
|
}
|
|
3387
4047
|
return waiter();
|
|
3388
4048
|
}
|
|
4049
|
+
startLeaseRenewal(lockKey, token, options) {
|
|
4050
|
+
const renewIntervalMs = options.renewIntervalMs ?? Math.max(100, Math.floor(options.leaseMs / 2));
|
|
4051
|
+
if (renewIntervalMs <= 0 || renewIntervalMs >= options.leaseMs) {
|
|
4052
|
+
return void 0;
|
|
4053
|
+
}
|
|
4054
|
+
const timer = setInterval(() => {
|
|
4055
|
+
void this.client.eval(RENEW_SCRIPT, 1, lockKey, token, String(options.leaseMs)).catch(() => void 0);
|
|
4056
|
+
}, renewIntervalMs);
|
|
4057
|
+
timer.unref?.();
|
|
4058
|
+
return timer;
|
|
4059
|
+
}
|
|
3389
4060
|
};
|
|
3390
4061
|
|
|
3391
4062
|
// src/metrics/PrometheusExporter.ts
|
|
@@ -3464,7 +4135,7 @@ function createPrometheusMetricsExporter(stacks) {
|
|
|
3464
4135
|
};
|
|
3465
4136
|
}
|
|
3466
4137
|
function sanitizeLabel(value) {
|
|
3467
|
-
return value.replace(/["\\\n]/g, "_");
|
|
4138
|
+
return value.replace(/["\\\n\r]/g, "_");
|
|
3468
4139
|
}
|
|
3469
4140
|
// Annotate the CommonJS export names for ESM import in node:
|
|
3470
4141
|
0 && (module.exports = {
|