layercache 1.2.2 → 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 +27 -8
- 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-B_rUqDy6.d.cts} +34 -1
- package/dist/{edge-DLpdQN0W.d.cts → edge-B_rUqDy6.d.ts} +34 -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 +591 -98
- package/dist/index.d.cts +10 -2
- package/dist/index.d.ts +10 -2
- package/dist/index.js +429 -84
- package/package.json +4 -4
- package/packages/nestjs/dist/index.cjs +492 -43
- package/packages/nestjs/dist/index.d.cts +25 -0
- package/packages/nestjs/dist/index.d.ts +25 -0
- package/packages/nestjs/dist/index.js +492 -43
package/dist/index.cjs
CHANGED
|
@@ -400,8 +400,9 @@ var CircuitBreakerManager = class {
|
|
|
400
400
|
|
|
401
401
|
// src/internal/FetchRateLimiter.ts
|
|
402
402
|
var FetchRateLimiter = class {
|
|
403
|
-
queue = [];
|
|
404
403
|
buckets = /* @__PURE__ */ new Map();
|
|
404
|
+
queuesByBucket = /* @__PURE__ */ new Map();
|
|
405
|
+
pendingBuckets = /* @__PURE__ */ new Set();
|
|
405
406
|
fetcherBuckets = /* @__PURE__ */ new WeakMap();
|
|
406
407
|
nextFetcherBucketId = 0;
|
|
407
408
|
drainTimer;
|
|
@@ -414,13 +415,17 @@ var FetchRateLimiter = class {
|
|
|
414
415
|
return task();
|
|
415
416
|
}
|
|
416
417
|
return new Promise((resolve2, reject) => {
|
|
417
|
-
this.
|
|
418
|
-
|
|
418
|
+
const bucketKey = this.resolveBucketKey(normalized, context);
|
|
419
|
+
const queue = this.queuesByBucket.get(bucketKey) ?? [];
|
|
420
|
+
queue.push({
|
|
421
|
+
bucketKey,
|
|
419
422
|
options: normalized,
|
|
420
423
|
task,
|
|
421
424
|
resolve: resolve2,
|
|
422
425
|
reject
|
|
423
426
|
});
|
|
427
|
+
this.queuesByBucket.set(bucketKey, queue);
|
|
428
|
+
this.pendingBuckets.add(bucketKey);
|
|
424
429
|
this.drain();
|
|
425
430
|
});
|
|
426
431
|
}
|
|
@@ -463,22 +468,30 @@ var FetchRateLimiter = class {
|
|
|
463
468
|
clearTimeout(this.drainTimer);
|
|
464
469
|
this.drainTimer = void 0;
|
|
465
470
|
}
|
|
466
|
-
while (this.
|
|
467
|
-
let
|
|
471
|
+
while (this.pendingBuckets.size > 0) {
|
|
472
|
+
let nextBucketKey;
|
|
468
473
|
let nextWaitMs = Number.POSITIVE_INFINITY;
|
|
469
|
-
for (
|
|
470
|
-
const
|
|
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];
|
|
471
482
|
if (!next2) {
|
|
483
|
+
this.pendingBuckets.delete(bucketKey);
|
|
484
|
+
this.queuesByBucket.delete(bucketKey);
|
|
472
485
|
continue;
|
|
473
486
|
}
|
|
474
|
-
const waitMs = this.waitTime(
|
|
487
|
+
const waitMs = this.waitTime(bucketKey, next2.options);
|
|
475
488
|
if (waitMs <= 0) {
|
|
476
|
-
|
|
489
|
+
nextBucketKey = bucketKey;
|
|
477
490
|
break;
|
|
478
491
|
}
|
|
479
492
|
nextWaitMs = Math.min(nextWaitMs, waitMs);
|
|
480
493
|
}
|
|
481
|
-
if (
|
|
494
|
+
if (!nextBucketKey) {
|
|
482
495
|
if (Number.isFinite(nextWaitMs)) {
|
|
483
496
|
this.drainTimer = setTimeout(() => {
|
|
484
497
|
this.drainTimer = void 0;
|
|
@@ -488,15 +501,32 @@ var FetchRateLimiter = class {
|
|
|
488
501
|
}
|
|
489
502
|
return;
|
|
490
503
|
}
|
|
491
|
-
const
|
|
504
|
+
const queue = this.queuesByBucket.get(nextBucketKey);
|
|
505
|
+
const next = queue?.shift();
|
|
492
506
|
if (!next) {
|
|
493
|
-
|
|
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);
|
|
494
514
|
}
|
|
495
515
|
const bucket = this.bucketState(next.bucketKey);
|
|
516
|
+
if (bucket.cleanupTimer) {
|
|
517
|
+
clearTimeout(bucket.cleanupTimer);
|
|
518
|
+
bucket.cleanupTimer = void 0;
|
|
519
|
+
}
|
|
496
520
|
bucket.active += 1;
|
|
497
|
-
|
|
521
|
+
if (next.options.intervalMs && next.options.maxPerInterval) {
|
|
522
|
+
bucket.startedAt.push(Date.now());
|
|
523
|
+
}
|
|
498
524
|
void next.task().then(next.resolve, next.reject).finally(() => {
|
|
499
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);
|
|
500
530
|
this.drain();
|
|
501
531
|
});
|
|
502
532
|
}
|
|
@@ -538,6 +568,31 @@ var FetchRateLimiter = class {
|
|
|
538
568
|
this.buckets.set(bucketKey, bucket);
|
|
539
569
|
return bucket;
|
|
540
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?.();
|
|
595
|
+
}
|
|
541
596
|
};
|
|
542
597
|
|
|
543
598
|
// src/internal/MetricsCollector.ts
|
|
@@ -616,7 +671,30 @@ var MetricsCollector = class {
|
|
|
616
671
|
|
|
617
672
|
// src/internal/StoredValue.ts
|
|
618
673
|
function isStoredValueEnvelope(value) {
|
|
619
|
-
|
|
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;
|
|
620
698
|
}
|
|
621
699
|
function createStoredValueEnvelope(options) {
|
|
622
700
|
const now = options.now ?? Date.now();
|
|
@@ -881,15 +959,17 @@ var TagIndex = class {
|
|
|
881
959
|
keyToTags = /* @__PURE__ */ new Map();
|
|
882
960
|
knownKeys = /* @__PURE__ */ new Set();
|
|
883
961
|
maxKnownKeys;
|
|
962
|
+
nextNodeId = 1;
|
|
963
|
+
root = this.createTrieNode();
|
|
884
964
|
constructor(options = {}) {
|
|
885
|
-
this.maxKnownKeys = options.maxKnownKeys;
|
|
965
|
+
this.maxKnownKeys = options.maxKnownKeys ?? 1e5;
|
|
886
966
|
}
|
|
887
967
|
async touch(key) {
|
|
888
|
-
this.
|
|
968
|
+
this.insertKnownKey(key);
|
|
889
969
|
this.pruneKnownKeysIfNeeded();
|
|
890
970
|
}
|
|
891
971
|
async track(key, tags) {
|
|
892
|
-
this.
|
|
972
|
+
this.insertKnownKey(key);
|
|
893
973
|
this.pruneKnownKeysIfNeeded();
|
|
894
974
|
if (tags.length === 0) {
|
|
895
975
|
return;
|
|
@@ -915,18 +995,104 @@ var TagIndex = class {
|
|
|
915
995
|
return [...this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set()];
|
|
916
996
|
}
|
|
917
997
|
async keysForPrefix(prefix) {
|
|
918
|
-
|
|
998
|
+
const node = this.findNode(prefix);
|
|
999
|
+
if (!node) {
|
|
1000
|
+
return [];
|
|
1001
|
+
}
|
|
1002
|
+
const matches = [];
|
|
1003
|
+
this.collectFromNode(node, prefix, matches);
|
|
1004
|
+
return matches;
|
|
919
1005
|
}
|
|
920
1006
|
async tagsForKey(key) {
|
|
921
1007
|
return [...this.keyToTags.get(key) ?? /* @__PURE__ */ new Set()];
|
|
922
1008
|
}
|
|
923
1009
|
async matchPattern(pattern) {
|
|
924
|
-
|
|
1010
|
+
const matches = /* @__PURE__ */ new Set();
|
|
1011
|
+
this.collectPatternMatches(this.root, "", pattern, 0, matches, /* @__PURE__ */ new Set());
|
|
1012
|
+
return [...matches];
|
|
925
1013
|
}
|
|
926
1014
|
async clear() {
|
|
927
1015
|
this.tagToKeys.clear();
|
|
928
1016
|
this.keyToTags.clear();
|
|
929
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
|
+
}
|
|
930
1096
|
}
|
|
931
1097
|
pruneKnownKeysIfNeeded() {
|
|
932
1098
|
if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
|
|
@@ -943,7 +1109,7 @@ var TagIndex = class {
|
|
|
943
1109
|
}
|
|
944
1110
|
}
|
|
945
1111
|
removeKey(key) {
|
|
946
|
-
this.
|
|
1112
|
+
this.removeKnownKey(key);
|
|
947
1113
|
const tags = this.keyToTags.get(key);
|
|
948
1114
|
if (!tags) {
|
|
949
1115
|
return;
|
|
@@ -960,8 +1126,71 @@ var TagIndex = class {
|
|
|
960
1126
|
}
|
|
961
1127
|
this.keyToTags.delete(key);
|
|
962
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
|
+
}
|
|
963
1157
|
};
|
|
964
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
|
+
}
|
|
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
|
+
}
|
|
1193
|
+
|
|
965
1194
|
// src/stampede/StampedeGuard.ts
|
|
966
1195
|
var import_async_mutex2 = require("async-mutex");
|
|
967
1196
|
var StampedeGuard = class {
|
|
@@ -972,7 +1201,8 @@ var StampedeGuard = class {
|
|
|
972
1201
|
return await entry.mutex.runExclusive(task);
|
|
973
1202
|
} finally {
|
|
974
1203
|
entry.references -= 1;
|
|
975
|
-
|
|
1204
|
+
const current = this.mutexes.get(key);
|
|
1205
|
+
if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
|
|
976
1206
|
this.mutexes.delete(key);
|
|
977
1207
|
}
|
|
978
1208
|
}
|
|
@@ -1002,8 +1232,10 @@ var CacheMissError = class extends Error {
|
|
|
1002
1232
|
var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
|
|
1003
1233
|
var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
|
|
1004
1234
|
var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
|
|
1235
|
+
var DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 3e4;
|
|
1005
1236
|
var MAX_CACHE_KEY_LENGTH = 1024;
|
|
1006
1237
|
var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
|
|
1238
|
+
var DANGEROUS_OBJECT_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
1007
1239
|
var DebugLogger = class {
|
|
1008
1240
|
enabled;
|
|
1009
1241
|
constructor(enabled) {
|
|
@@ -1050,6 +1282,21 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1050
1282
|
const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
|
|
1051
1283
|
this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
|
|
1052
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
|
+
}
|
|
1053
1300
|
this.initializeWriteBehind(options.writeBehind);
|
|
1054
1301
|
this.startup = this.initialize();
|
|
1055
1302
|
}
|
|
@@ -1063,6 +1310,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1063
1310
|
logger;
|
|
1064
1311
|
tagIndex;
|
|
1065
1312
|
fetchRateLimiter = new FetchRateLimiter();
|
|
1313
|
+
snapshotSerializer = new JsonSerializer();
|
|
1066
1314
|
backgroundRefreshes = /* @__PURE__ */ new Map();
|
|
1067
1315
|
layerDegradedUntil = /* @__PURE__ */ new Map();
|
|
1068
1316
|
ttlResolver;
|
|
@@ -1071,6 +1319,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1071
1319
|
writeBehindQueue = [];
|
|
1072
1320
|
writeBehindTimer;
|
|
1073
1321
|
writeBehindFlushPromise;
|
|
1322
|
+
generationCleanupPromise;
|
|
1074
1323
|
isDisconnecting = false;
|
|
1075
1324
|
disconnectPromise;
|
|
1076
1325
|
/**
|
|
@@ -1083,6 +1332,9 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1083
1332
|
const normalizedKey = this.qualifyKey(this.validateCacheKey(key));
|
|
1084
1333
|
this.validateWriteOptions(options);
|
|
1085
1334
|
await this.awaitStartup("get");
|
|
1335
|
+
return this.getPrepared(normalizedKey, fetcher, options);
|
|
1336
|
+
}
|
|
1337
|
+
async getPrepared(normalizedKey, fetcher, options) {
|
|
1086
1338
|
const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
|
|
1087
1339
|
if (hit.found) {
|
|
1088
1340
|
this.ttlResolver.recordAccess(normalizedKey);
|
|
@@ -1160,6 +1412,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1160
1412
|
return true;
|
|
1161
1413
|
}
|
|
1162
1414
|
} catch {
|
|
1415
|
+
await this.reportRecoverableLayerFailure(layer, "has", new Error(`has() failed for layer "${layer.name}"`));
|
|
1163
1416
|
}
|
|
1164
1417
|
} else {
|
|
1165
1418
|
try {
|
|
@@ -1167,7 +1420,8 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1167
1420
|
if (value !== null) {
|
|
1168
1421
|
return true;
|
|
1169
1422
|
}
|
|
1170
|
-
} catch {
|
|
1423
|
+
} catch (error) {
|
|
1424
|
+
await this.reportRecoverableLayerFailure(layer, "has", error);
|
|
1171
1425
|
}
|
|
1172
1426
|
}
|
|
1173
1427
|
}
|
|
@@ -1259,13 +1513,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1259
1513
|
normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
|
|
1260
1514
|
const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
|
|
1261
1515
|
if (!canFastPath) {
|
|
1516
|
+
await this.awaitStartup("mget");
|
|
1262
1517
|
const pendingReads = /* @__PURE__ */ new Map();
|
|
1263
1518
|
return Promise.all(
|
|
1264
1519
|
normalizedEntries.map((entry) => {
|
|
1265
1520
|
const optionsSignature = this.serializeOptions(entry.options);
|
|
1266
1521
|
const existing = pendingReads.get(entry.key);
|
|
1267
1522
|
if (!existing) {
|
|
1268
|
-
const promise = this.
|
|
1523
|
+
const promise = this.getPrepared(entry.key, entry.fetch, entry.options);
|
|
1269
1524
|
pendingReads.set(entry.key, {
|
|
1270
1525
|
promise,
|
|
1271
1526
|
fetch: entry.fetch,
|
|
@@ -1404,14 +1659,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1404
1659
|
}
|
|
1405
1660
|
async invalidateByPattern(pattern) {
|
|
1406
1661
|
await this.awaitStartup("invalidateByPattern");
|
|
1407
|
-
const keys = await this.
|
|
1662
|
+
const keys = await this.collectKeysMatchingPattern(this.qualifyPattern(pattern));
|
|
1408
1663
|
await this.deleteKeys(keys);
|
|
1409
1664
|
await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
|
|
1410
1665
|
}
|
|
1411
1666
|
async invalidateByPrefix(prefix) {
|
|
1412
1667
|
await this.awaitStartup("invalidateByPrefix");
|
|
1413
1668
|
const qualifiedPrefix = this.qualifyKey(this.validateCacheKey(prefix));
|
|
1414
|
-
const keys =
|
|
1669
|
+
const keys = await this.collectKeysWithPrefix(qualifiedPrefix);
|
|
1415
1670
|
await this.deleteKeys(keys);
|
|
1416
1671
|
await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
|
|
1417
1672
|
}
|
|
@@ -1461,9 +1716,18 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1461
1716
|
})
|
|
1462
1717
|
);
|
|
1463
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
|
+
*/
|
|
1464
1724
|
bumpGeneration(nextGeneration) {
|
|
1465
1725
|
const current = this.currentGeneration ?? 0;
|
|
1726
|
+
const previousGeneration = this.currentGeneration;
|
|
1466
1727
|
this.currentGeneration = nextGeneration ?? current + 1;
|
|
1728
|
+
if (previousGeneration !== void 0 && previousGeneration !== this.currentGeneration && this.shouldCleanupGenerations()) {
|
|
1729
|
+
this.scheduleGenerationCleanup(previousGeneration);
|
|
1730
|
+
}
|
|
1467
1731
|
return this.currentGeneration;
|
|
1468
1732
|
}
|
|
1469
1733
|
/**
|
|
@@ -1547,27 +1811,28 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1547
1811
|
this.assertActive("persistToFile");
|
|
1548
1812
|
const snapshot = await this.exportState();
|
|
1549
1813
|
const { promises: fs2 } = await import("fs");
|
|
1550
|
-
await fs2.writeFile(filePath, JSON.stringify(snapshot, null, 2), "utf8");
|
|
1814
|
+
await fs2.writeFile(await this.validateSnapshotFilePath(filePath), JSON.stringify(snapshot, null, 2), "utf8");
|
|
1551
1815
|
}
|
|
1552
1816
|
async restoreFromFile(filePath) {
|
|
1553
1817
|
this.assertActive("restoreFromFile");
|
|
1554
1818
|
const { promises: fs2 } = await import("fs");
|
|
1555
|
-
const raw = await fs2.readFile(filePath, "utf8");
|
|
1819
|
+
const raw = await fs2.readFile(await this.validateSnapshotFilePath(filePath), "utf8");
|
|
1556
1820
|
let parsed;
|
|
1557
1821
|
try {
|
|
1558
|
-
parsed = JSON.parse(raw
|
|
1559
|
-
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
1560
|
-
return Object.assign(/* @__PURE__ */ Object.create(null), value);
|
|
1561
|
-
}
|
|
1562
|
-
return value;
|
|
1563
|
-
});
|
|
1822
|
+
parsed = JSON.parse(raw);
|
|
1564
1823
|
} catch (cause) {
|
|
1565
1824
|
throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
|
|
1566
1825
|
}
|
|
1567
1826
|
if (!this.isCacheSnapshotEntries(parsed)) {
|
|
1568
1827
|
throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
|
|
1569
1828
|
}
|
|
1570
|
-
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
|
+
);
|
|
1571
1836
|
}
|
|
1572
1837
|
async disconnect() {
|
|
1573
1838
|
if (!this.disconnectPromise) {
|
|
@@ -1576,6 +1841,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1576
1841
|
await this.startup;
|
|
1577
1842
|
await this.unsubscribeInvalidation?.();
|
|
1578
1843
|
await this.flushWriteBehindQueue();
|
|
1844
|
+
await this.generationCleanupPromise;
|
|
1579
1845
|
await Promise.allSettled([...this.backgroundRefreshes.values()]);
|
|
1580
1846
|
if (this.writeBehindTimer) {
|
|
1581
1847
|
clearInterval(this.writeBehindTimer);
|
|
@@ -1659,8 +1925,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1659
1925
|
await this.storeEntry(key, "empty", null, options);
|
|
1660
1926
|
return null;
|
|
1661
1927
|
}
|
|
1662
|
-
if (options?.shouldCache
|
|
1663
|
-
|
|
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
|
+
}
|
|
1664
1936
|
}
|
|
1665
1937
|
await this.storeEntry(key, "value", fetched, options);
|
|
1666
1938
|
return fetched;
|
|
@@ -1887,7 +2159,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1887
2159
|
const refresh = (async () => {
|
|
1888
2160
|
this.metricsCollector.increment("refreshes");
|
|
1889
2161
|
try {
|
|
1890
|
-
await this.
|
|
2162
|
+
await this.runBackgroundRefresh(key, fetcher, options);
|
|
1891
2163
|
} catch (error) {
|
|
1892
2164
|
this.metricsCollector.increment("refreshErrors");
|
|
1893
2165
|
this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
|
|
@@ -1897,6 +2169,16 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1897
2169
|
})();
|
|
1898
2170
|
this.backgroundRefreshes.set(key, refresh);
|
|
1899
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
|
+
}
|
|
1900
2182
|
resolveSingleFlightOptions() {
|
|
1901
2183
|
return {
|
|
1902
2184
|
leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
|
|
@@ -1964,8 +2246,120 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1964
2246
|
sleep(ms) {
|
|
1965
2247
|
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
1966
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
|
+
}
|
|
2278
|
+
}
|
|
1967
2279
|
shouldBroadcastL1Invalidation() {
|
|
1968
|
-
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
|
+
}
|
|
1969
2363
|
}
|
|
1970
2364
|
initializeWriteBehind(options) {
|
|
1971
2365
|
if (this.options.writeStrategy !== "write-behind") {
|
|
@@ -2003,7 +2397,17 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
2003
2397
|
const batchSize = this.options.writeBehind?.batchSize ?? 100;
|
|
2004
2398
|
const batch = this.writeBehindQueue.splice(0, batchSize);
|
|
2005
2399
|
this.writeBehindFlushPromise = (async () => {
|
|
2006
|
-
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
|
+
}
|
|
2007
2411
|
})();
|
|
2008
2412
|
await this.writeBehindFlushPromise;
|
|
2009
2413
|
this.writeBehindFlushPromise = void 0;
|
|
@@ -2108,9 +2512,13 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
2108
2512
|
this.validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
|
|
2109
2513
|
this.validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
|
|
2110
2514
|
this.validatePositiveNumber("singleFlightRenewIntervalMs", this.options.singleFlightRenewIntervalMs);
|
|
2515
|
+
this.validatePositiveNumber("backgroundRefreshTimeoutMs", this.options.backgroundRefreshTimeoutMs);
|
|
2111
2516
|
this.validateRateLimitOptions("fetcherRateLimit", this.options.fetcherRateLimit);
|
|
2112
2517
|
this.validateAdaptiveTtlOptions(this.options.adaptiveTtl);
|
|
2113
2518
|
this.validateCircuitBreakerOptions(this.options.circuitBreaker);
|
|
2519
|
+
if (typeof this.options.generationCleanup === "object") {
|
|
2520
|
+
this.validatePositiveNumber("generationCleanup.batchSize", this.options.generationCleanup.batchSize);
|
|
2521
|
+
}
|
|
2114
2522
|
if (this.options.generation !== void 0) {
|
|
2115
2523
|
this.validateNonNegativeNumber("generation", this.options.generation);
|
|
2116
2524
|
}
|
|
@@ -2182,6 +2590,9 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
2182
2590
|
if (/[\u0000-\u001F\u007F]/.test(key)) {
|
|
2183
2591
|
throw new Error("Cache key contains unsupported control characters.");
|
|
2184
2592
|
}
|
|
2593
|
+
if (/[\uD800-\uDFFF]/.test(key)) {
|
|
2594
|
+
throw new Error("Cache key contains unsupported surrogate code points.");
|
|
2595
|
+
}
|
|
2185
2596
|
return key;
|
|
2186
2597
|
}
|
|
2187
2598
|
validateTtlPolicy(name, policy) {
|
|
@@ -2259,6 +2670,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
2259
2670
|
this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
|
|
2260
2671
|
return null;
|
|
2261
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
|
+
}
|
|
2262
2681
|
isGracefulDegradationEnabled() {
|
|
2263
2682
|
return Boolean(this.options.gracefulDegradation);
|
|
2264
2683
|
}
|
|
@@ -2282,10 +2701,16 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
2282
2701
|
}
|
|
2283
2702
|
}
|
|
2284
2703
|
serializeKeyPart(value) {
|
|
2285
|
-
if (typeof value === "string"
|
|
2286
|
-
return
|
|
2704
|
+
if (typeof value === "string") {
|
|
2705
|
+
return `s:${value}`;
|
|
2287
2706
|
}
|
|
2288
|
-
|
|
2707
|
+
if (typeof value === "number") {
|
|
2708
|
+
return `n:${value}`;
|
|
2709
|
+
}
|
|
2710
|
+
if (typeof value === "boolean") {
|
|
2711
|
+
return `b:${value}`;
|
|
2712
|
+
}
|
|
2713
|
+
return `j:${JSON.stringify(this.normalizeForSerialization(value))}`;
|
|
2289
2714
|
}
|
|
2290
2715
|
isCacheSnapshotEntries(value) {
|
|
2291
2716
|
return Array.isArray(value) && value.every((entry) => {
|
|
@@ -2293,15 +2718,39 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
2293
2718
|
return false;
|
|
2294
2719
|
}
|
|
2295
2720
|
const candidate = entry;
|
|
2296
|
-
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);
|
|
2297
2722
|
});
|
|
2298
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
|
+
}
|
|
2299
2745
|
normalizeForSerialization(value) {
|
|
2300
2746
|
if (Array.isArray(value)) {
|
|
2301
2747
|
return value.map((entry) => this.normalizeForSerialization(entry));
|
|
2302
2748
|
}
|
|
2303
2749
|
if (value && typeof value === "object") {
|
|
2304
2750
|
return Object.keys(value).sort().reduce((normalized, key) => {
|
|
2751
|
+
if (DANGEROUS_OBJECT_KEYS.has(key)) {
|
|
2752
|
+
return normalized;
|
|
2753
|
+
}
|
|
2305
2754
|
normalized[key] = this.normalizeForSerialization(value[key]);
|
|
2306
2755
|
return normalized;
|
|
2307
2756
|
}, {});
|
|
@@ -2562,7 +3011,7 @@ function createCachedMethodDecorator(options) {
|
|
|
2562
3011
|
function createFastifyLayercachePlugin(cache, options = {}) {
|
|
2563
3012
|
return async (fastify) => {
|
|
2564
3013
|
fastify.decorate("cache", cache);
|
|
2565
|
-
if (options.exposeStatsRoute
|
|
3014
|
+
if (options.exposeStatsRoute === true && fastify.get) {
|
|
2566
3015
|
fastify.get(options.statsPath ?? "/cache/stats", async () => cache.getStats());
|
|
2567
3016
|
}
|
|
2568
3017
|
};
|
|
@@ -2578,7 +3027,8 @@ function createExpressCacheMiddleware(cache, options = {}) {
|
|
|
2578
3027
|
next();
|
|
2579
3028
|
return;
|
|
2580
3029
|
}
|
|
2581
|
-
const
|
|
3030
|
+
const rawUrl = req.originalUrl ?? req.url ?? "/";
|
|
3031
|
+
const key = options.keyResolver ? options.keyResolver(req) : `${method}:${normalizeUrl(rawUrl)}`;
|
|
2582
3032
|
const cached = await cache.get(key, void 0, options);
|
|
2583
3033
|
if (cached !== null) {
|
|
2584
3034
|
res.setHeader?.("content-type", "application/json; charset=utf-8");
|
|
@@ -2594,7 +3044,12 @@ function createExpressCacheMiddleware(cache, options = {}) {
|
|
|
2594
3044
|
if (originalJson) {
|
|
2595
3045
|
res.json = (body) => {
|
|
2596
3046
|
res.setHeader?.("x-cache", "MISS");
|
|
2597
|
-
|
|
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
|
+
});
|
|
2598
3053
|
return originalJson(body);
|
|
2599
3054
|
};
|
|
2600
3055
|
}
|
|
@@ -2604,6 +3059,15 @@ function createExpressCacheMiddleware(cache, options = {}) {
|
|
|
2604
3059
|
}
|
|
2605
3060
|
};
|
|
2606
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
|
+
}
|
|
2607
3071
|
|
|
2608
3072
|
// src/integrations/graphql.ts
|
|
2609
3073
|
function cacheGraphqlResolver(cache, prefix, resolver, options = {}) {
|
|
@@ -2623,7 +3087,8 @@ function createHonoCacheMiddleware(cache, options = {}) {
|
|
|
2623
3087
|
await next();
|
|
2624
3088
|
return;
|
|
2625
3089
|
}
|
|
2626
|
-
const
|
|
3090
|
+
const rawPath = context.req.path ?? context.req.url ?? "/";
|
|
3091
|
+
const key = options.keyResolver ? options.keyResolver(context.req) : `${method}:${normalizeUrl2(rawPath)}`;
|
|
2627
3092
|
const cached = await cache.get(key, void 0, options);
|
|
2628
3093
|
if (cached !== null) {
|
|
2629
3094
|
context.header?.("x-cache", "HIT");
|
|
@@ -2634,12 +3099,26 @@ function createHonoCacheMiddleware(cache, options = {}) {
|
|
|
2634
3099
|
const originalJson = context.json.bind(context);
|
|
2635
3100
|
context.json = (body, status) => {
|
|
2636
3101
|
context.header?.("x-cache", "MISS");
|
|
2637
|
-
|
|
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
|
+
});
|
|
2638
3108
|
return originalJson(body, status);
|
|
2639
3109
|
};
|
|
2640
3110
|
await next();
|
|
2641
3111
|
};
|
|
2642
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
|
+
}
|
|
2643
3122
|
|
|
2644
3123
|
// src/integrations/opentelemetry.ts
|
|
2645
3124
|
function createOpenTelemetryPlugin(cache, tracer) {
|
|
@@ -2774,16 +3253,10 @@ var MemoryLayer = class {
|
|
|
2774
3253
|
return entry.value;
|
|
2775
3254
|
}
|
|
2776
3255
|
async getMany(keys) {
|
|
2777
|
-
|
|
2778
|
-
for (const key of keys) {
|
|
2779
|
-
values.push(await this.getEntry(key));
|
|
2780
|
-
}
|
|
2781
|
-
return values;
|
|
3256
|
+
return Promise.all(keys.map((key) => this.getEntry(key)));
|
|
2782
3257
|
}
|
|
2783
3258
|
async setMany(entries) {
|
|
2784
|
-
|
|
2785
|
-
await this.set(entry.key, entry.value, entry.ttl);
|
|
2786
|
-
}
|
|
3259
|
+
await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.ttl)));
|
|
2787
3260
|
}
|
|
2788
3261
|
async set(key, value, ttl = this.defaultTtl) {
|
|
2789
3262
|
this.entries.delete(key);
|
|
@@ -2919,39 +3392,6 @@ var MemoryLayer = class {
|
|
|
2919
3392
|
// src/layers/RedisLayer.ts
|
|
2920
3393
|
var import_node_util = require("util");
|
|
2921
3394
|
var import_node_zlib = require("zlib");
|
|
2922
|
-
|
|
2923
|
-
// src/serialization/JsonSerializer.ts
|
|
2924
|
-
var DANGEROUS_JSON_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
2925
|
-
var JsonSerializer = class {
|
|
2926
|
-
serialize(value) {
|
|
2927
|
-
return JSON.stringify(value);
|
|
2928
|
-
}
|
|
2929
|
-
deserialize(payload) {
|
|
2930
|
-
const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
|
|
2931
|
-
return sanitizeJsonValue(JSON.parse(normalized));
|
|
2932
|
-
}
|
|
2933
|
-
};
|
|
2934
|
-
function sanitizeJsonValue(value) {
|
|
2935
|
-
if (Array.isArray(value)) {
|
|
2936
|
-
return value.map((entry) => sanitizeJsonValue(entry));
|
|
2937
|
-
}
|
|
2938
|
-
if (!isPlainObject(value)) {
|
|
2939
|
-
return value;
|
|
2940
|
-
}
|
|
2941
|
-
const sanitized = {};
|
|
2942
|
-
for (const [key, entry] of Object.entries(value)) {
|
|
2943
|
-
if (DANGEROUS_JSON_KEYS.has(key)) {
|
|
2944
|
-
continue;
|
|
2945
|
-
}
|
|
2946
|
-
sanitized[key] = sanitizeJsonValue(entry);
|
|
2947
|
-
}
|
|
2948
|
-
return sanitized;
|
|
2949
|
-
}
|
|
2950
|
-
function isPlainObject(value) {
|
|
2951
|
-
return Object.prototype.toString.call(value) === "[object Object]";
|
|
2952
|
-
}
|
|
2953
|
-
|
|
2954
|
-
// src/layers/RedisLayer.ts
|
|
2955
3395
|
var BATCH_DELETE_SIZE = 500;
|
|
2956
3396
|
var gzipAsync = (0, import_node_util.promisify)(import_node_zlib.gzip);
|
|
2957
3397
|
var gunzipAsync = (0, import_node_util.promisify)(import_node_zlib.gunzip);
|
|
@@ -2968,6 +3408,7 @@ var RedisLayer = class {
|
|
|
2968
3408
|
scanCount;
|
|
2969
3409
|
compression;
|
|
2970
3410
|
compressionThreshold;
|
|
3411
|
+
decompressionMaxBytes;
|
|
2971
3412
|
disconnectOnDispose;
|
|
2972
3413
|
constructor(options) {
|
|
2973
3414
|
this.client = options.client;
|
|
@@ -2979,6 +3420,7 @@ var RedisLayer = class {
|
|
|
2979
3420
|
this.scanCount = options.scanCount ?? 100;
|
|
2980
3421
|
this.compression = options.compression;
|
|
2981
3422
|
this.compressionThreshold = options.compressionThreshold ?? 1024;
|
|
3423
|
+
this.decompressionMaxBytes = options.decompressionMaxBytes ?? 64 * 1024 * 1024;
|
|
2982
3424
|
this.disconnectOnDispose = options.disconnectOnDispose ?? false;
|
|
2983
3425
|
}
|
|
2984
3426
|
async get(key) {
|
|
@@ -3178,16 +3620,29 @@ var RedisLayer = class {
|
|
|
3178
3620
|
}
|
|
3179
3621
|
/**
|
|
3180
3622
|
* Decompresses the payload asynchronously if a compression header is present.
|
|
3623
|
+
* Enforces a maximum decompressed size to prevent decompression bomb attacks.
|
|
3181
3624
|
*/
|
|
3182
3625
|
async decodePayload(payload) {
|
|
3183
3626
|
if (!Buffer.isBuffer(payload)) {
|
|
3184
3627
|
return payload;
|
|
3185
3628
|
}
|
|
3186
3629
|
if (payload.subarray(0, 10).toString() === "LCZ1:gzip:") {
|
|
3187
|
-
|
|
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;
|
|
3188
3637
|
}
|
|
3189
3638
|
if (payload.subarray(0, 12).toString() === "LCZ1:brotli:") {
|
|
3190
|
-
|
|
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;
|
|
3191
3646
|
}
|
|
3192
3647
|
return payload;
|
|
3193
3648
|
}
|
|
@@ -3247,8 +3702,13 @@ var DiskLayer = class {
|
|
|
3247
3702
|
const payload = this.serializer.serialize(entry);
|
|
3248
3703
|
const targetPath = this.keyToPath(key);
|
|
3249
3704
|
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
3250
|
-
|
|
3251
|
-
|
|
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
|
+
}
|
|
3252
3712
|
if (this.maxFiles !== void 0) {
|
|
3253
3713
|
await this.enforceMaxFiles();
|
|
3254
3714
|
}
|
|
@@ -3258,9 +3718,7 @@ var DiskLayer = class {
|
|
|
3258
3718
|
return Promise.all(keys.map((key) => this.getEntry(key)));
|
|
3259
3719
|
}
|
|
3260
3720
|
async setMany(entries) {
|
|
3261
|
-
|
|
3262
|
-
await this.set(entry.key, entry.value, entry.ttl);
|
|
3263
|
-
}
|
|
3721
|
+
await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.ttl)));
|
|
3264
3722
|
}
|
|
3265
3723
|
async has(key) {
|
|
3266
3724
|
const value = await this.getEntry(key);
|
|
@@ -3464,6 +3922,7 @@ var MemcachedLayer = class {
|
|
|
3464
3922
|
return unwrapStoredValue(await this.getEntry(key));
|
|
3465
3923
|
}
|
|
3466
3924
|
async getEntry(key) {
|
|
3925
|
+
this.validateKey(key);
|
|
3467
3926
|
const result = await this.client.get(this.withPrefix(key));
|
|
3468
3927
|
if (!result || result.value === null) {
|
|
3469
3928
|
return null;
|
|
@@ -3478,16 +3937,19 @@ var MemcachedLayer = class {
|
|
|
3478
3937
|
return Promise.all(keys.map((key) => this.getEntry(key)));
|
|
3479
3938
|
}
|
|
3480
3939
|
async set(key, value, ttl = this.defaultTtl) {
|
|
3940
|
+
this.validateKey(key);
|
|
3481
3941
|
const payload = this.serializer.serialize(value);
|
|
3482
3942
|
await this.client.set(this.withPrefix(key), payload, {
|
|
3483
3943
|
expires: ttl && ttl > 0 ? ttl : void 0
|
|
3484
3944
|
});
|
|
3485
3945
|
}
|
|
3486
3946
|
async has(key) {
|
|
3947
|
+
this.validateKey(key);
|
|
3487
3948
|
const result = await this.client.get(this.withPrefix(key));
|
|
3488
3949
|
return result !== null && result.value !== null;
|
|
3489
3950
|
}
|
|
3490
3951
|
async delete(key) {
|
|
3952
|
+
this.validateKey(key);
|
|
3491
3953
|
await this.client.delete(this.withPrefix(key));
|
|
3492
3954
|
}
|
|
3493
3955
|
async deleteMany(keys) {
|
|
@@ -3501,19 +3963,50 @@ var MemcachedLayer = class {
|
|
|
3501
3963
|
withPrefix(key) {
|
|
3502
3964
|
return `${this.keyPrefix}${key}`;
|
|
3503
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
|
+
}
|
|
3504
3977
|
};
|
|
3505
3978
|
|
|
3506
3979
|
// src/serialization/MsgpackSerializer.ts
|
|
3507
3980
|
var import_msgpack = require("@msgpack/msgpack");
|
|
3981
|
+
var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
3508
3982
|
var MsgpackSerializer = class {
|
|
3509
3983
|
serialize(value) {
|
|
3510
3984
|
return Buffer.from((0, import_msgpack.encode)(value));
|
|
3511
3985
|
}
|
|
3512
3986
|
deserialize(payload) {
|
|
3513
3987
|
const normalized = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
|
|
3514
|
-
return (0, import_msgpack.decode)(normalized);
|
|
3988
|
+
return sanitizeMsgpackValue((0, import_msgpack.decode)(normalized));
|
|
3515
3989
|
}
|
|
3516
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
|
+
}
|
|
3517
4010
|
|
|
3518
4011
|
// src/singleflight/RedisSingleFlightCoordinator.ts
|
|
3519
4012
|
var import_node_crypto2 = require("crypto");
|
|
@@ -3642,7 +4135,7 @@ function createPrometheusMetricsExporter(stacks) {
|
|
|
3642
4135
|
};
|
|
3643
4136
|
}
|
|
3644
4137
|
function sanitizeLabel(value) {
|
|
3645
|
-
return value.replace(/["\\\n]/g, "_");
|
|
4138
|
+
return value.replace(/["\\\n\r]/g, "_");
|
|
3646
4139
|
}
|
|
3647
4140
|
// Annotate the CommonJS export names for ESM import in node:
|
|
3648
4141
|
0 && (module.exports = {
|