layercache 1.2.2 → 1.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -306,6 +306,107 @@ function addMap(base, delta) {
306
306
  return result;
307
307
  }
308
308
 
309
+ // src/invalidation/PatternMatcher.ts
310
+ var PatternMatcher = class _PatternMatcher {
311
+ /**
312
+ * Tests whether a glob-style pattern matches a value.
313
+ * Supports `*` (any sequence of characters) and `?` (any single character).
314
+ * Uses a two-pointer algorithm to avoid ReDoS vulnerabilities and
315
+ * quadratic memory usage on long patterns/keys.
316
+ */
317
+ static matches(pattern, value) {
318
+ return _PatternMatcher.matchLinear(pattern, value);
319
+ }
320
+ /**
321
+ * Linear-time glob matching with O(1) extra memory.
322
+ */
323
+ static matchLinear(pattern, value) {
324
+ let patternIndex = 0;
325
+ let valueIndex = 0;
326
+ let starIndex = -1;
327
+ let backtrackValueIndex = 0;
328
+ while (valueIndex < value.length) {
329
+ const patternChar = pattern[patternIndex];
330
+ const valueChar = value[valueIndex];
331
+ if (patternChar === "*" && patternIndex < pattern.length) {
332
+ starIndex = patternIndex;
333
+ patternIndex += 1;
334
+ backtrackValueIndex = valueIndex;
335
+ continue;
336
+ }
337
+ if (patternChar === "?" || patternChar === valueChar) {
338
+ patternIndex += 1;
339
+ valueIndex += 1;
340
+ continue;
341
+ }
342
+ if (starIndex !== -1) {
343
+ patternIndex = starIndex + 1;
344
+ backtrackValueIndex += 1;
345
+ valueIndex = backtrackValueIndex;
346
+ continue;
347
+ }
348
+ return false;
349
+ }
350
+ while (patternIndex < pattern.length && pattern[patternIndex] === "*") {
351
+ patternIndex += 1;
352
+ }
353
+ return patternIndex === pattern.length;
354
+ }
355
+ };
356
+
357
+ // src/internal/CacheKeyDiscovery.ts
358
+ var CacheKeyDiscovery = class {
359
+ constructor(options) {
360
+ this.options = options;
361
+ }
362
+ options;
363
+ async collectKeysWithPrefix(prefix) {
364
+ const { tagIndex } = this.options;
365
+ const matches = new Set(
366
+ tagIndex.keysForPrefix ? await tagIndex.keysForPrefix(prefix) : await tagIndex.matchPattern(`${prefix}*`)
367
+ );
368
+ await Promise.all(
369
+ this.options.layers.map(async (layer) => {
370
+ if (!layer.keys || this.options.shouldSkipLayer(layer)) {
371
+ return;
372
+ }
373
+ try {
374
+ const keys = await layer.keys();
375
+ for (const key of keys) {
376
+ if (key.startsWith(prefix)) {
377
+ matches.add(key);
378
+ }
379
+ }
380
+ } catch (error) {
381
+ await this.options.handleLayerFailure(layer, "invalidate-prefix-scan", error);
382
+ }
383
+ })
384
+ );
385
+ return [...matches];
386
+ }
387
+ async collectKeysMatchingPattern(pattern) {
388
+ const matches = new Set(await this.options.tagIndex.matchPattern(pattern));
389
+ await Promise.all(
390
+ this.options.layers.map(async (layer) => {
391
+ if (!layer.keys || this.options.shouldSkipLayer(layer)) {
392
+ return;
393
+ }
394
+ try {
395
+ const keys = await layer.keys();
396
+ for (const key of keys) {
397
+ if (PatternMatcher.matches(pattern, key)) {
398
+ matches.add(key);
399
+ }
400
+ }
401
+ } catch (error) {
402
+ await this.options.handleLayerFailure(layer, "invalidate-pattern-scan", error);
403
+ }
404
+ })
405
+ );
406
+ return [...matches];
407
+ }
408
+ };
409
+
309
410
  // src/internal/CircuitBreakerManager.ts
310
411
  var CircuitBreakerManager = class {
311
412
  breakers = /* @__PURE__ */ new Map();
@@ -400,8 +501,9 @@ var CircuitBreakerManager = class {
400
501
 
401
502
  // src/internal/FetchRateLimiter.ts
402
503
  var FetchRateLimiter = class {
403
- queue = [];
404
504
  buckets = /* @__PURE__ */ new Map();
505
+ queuesByBucket = /* @__PURE__ */ new Map();
506
+ pendingBuckets = /* @__PURE__ */ new Set();
405
507
  fetcherBuckets = /* @__PURE__ */ new WeakMap();
406
508
  nextFetcherBucketId = 0;
407
509
  drainTimer;
@@ -414,13 +516,17 @@ var FetchRateLimiter = class {
414
516
  return task();
415
517
  }
416
518
  return new Promise((resolve2, reject) => {
417
- this.queue.push({
418
- bucketKey: this.resolveBucketKey(normalized, context),
519
+ const bucketKey = this.resolveBucketKey(normalized, context);
520
+ const queue = this.queuesByBucket.get(bucketKey) ?? [];
521
+ queue.push({
522
+ bucketKey,
419
523
  options: normalized,
420
524
  task,
421
525
  resolve: resolve2,
422
526
  reject
423
527
  });
528
+ this.queuesByBucket.set(bucketKey, queue);
529
+ this.pendingBuckets.add(bucketKey);
424
530
  this.drain();
425
531
  });
426
532
  }
@@ -463,22 +569,30 @@ var FetchRateLimiter = class {
463
569
  clearTimeout(this.drainTimer);
464
570
  this.drainTimer = void 0;
465
571
  }
466
- while (this.queue.length > 0) {
467
- let nextIndex = -1;
572
+ while (this.pendingBuckets.size > 0) {
573
+ let nextBucketKey;
468
574
  let nextWaitMs = Number.POSITIVE_INFINITY;
469
- for (let index = 0; index < this.queue.length; index += 1) {
470
- const next2 = this.queue[index];
575
+ for (const bucketKey of this.pendingBuckets) {
576
+ const queue2 = this.queuesByBucket.get(bucketKey);
577
+ if (!queue2 || queue2.length === 0) {
578
+ this.pendingBuckets.delete(bucketKey);
579
+ this.queuesByBucket.delete(bucketKey);
580
+ continue;
581
+ }
582
+ const next2 = queue2[0];
471
583
  if (!next2) {
584
+ this.pendingBuckets.delete(bucketKey);
585
+ this.queuesByBucket.delete(bucketKey);
472
586
  continue;
473
587
  }
474
- const waitMs = this.waitTime(next2.bucketKey, next2.options);
588
+ const waitMs = this.waitTime(bucketKey, next2.options);
475
589
  if (waitMs <= 0) {
476
- nextIndex = index;
590
+ nextBucketKey = bucketKey;
477
591
  break;
478
592
  }
479
593
  nextWaitMs = Math.min(nextWaitMs, waitMs);
480
594
  }
481
- if (nextIndex < 0) {
595
+ if (!nextBucketKey) {
482
596
  if (Number.isFinite(nextWaitMs)) {
483
597
  this.drainTimer = setTimeout(() => {
484
598
  this.drainTimer = void 0;
@@ -488,15 +602,32 @@ var FetchRateLimiter = class {
488
602
  }
489
603
  return;
490
604
  }
491
- const next = this.queue.splice(nextIndex, 1)[0];
605
+ const queue = this.queuesByBucket.get(nextBucketKey);
606
+ const next = queue?.shift();
492
607
  if (!next) {
493
- return;
608
+ this.pendingBuckets.delete(nextBucketKey);
609
+ this.queuesByBucket.delete(nextBucketKey);
610
+ continue;
611
+ }
612
+ if (!queue || queue.length === 0) {
613
+ this.pendingBuckets.delete(nextBucketKey);
614
+ this.queuesByBucket.delete(nextBucketKey);
494
615
  }
495
616
  const bucket = this.bucketState(next.bucketKey);
617
+ if (bucket.cleanupTimer) {
618
+ clearTimeout(bucket.cleanupTimer);
619
+ bucket.cleanupTimer = void 0;
620
+ }
496
621
  bucket.active += 1;
497
- bucket.startedAt.push(Date.now());
622
+ if (next.options.intervalMs && next.options.maxPerInterval) {
623
+ bucket.startedAt.push(Date.now());
624
+ }
498
625
  void next.task().then(next.resolve, next.reject).finally(() => {
499
626
  bucket.active -= 1;
627
+ if ((this.queuesByBucket.get(next.bucketKey)?.length ?? 0) > 0) {
628
+ this.pendingBuckets.add(next.bucketKey);
629
+ }
630
+ this.cleanupBucket(next.bucketKey, bucket, next.options.intervalMs);
500
631
  this.drain();
501
632
  });
502
633
  }
@@ -538,6 +669,31 @@ var FetchRateLimiter = class {
538
669
  this.buckets.set(bucketKey, bucket);
539
670
  return bucket;
540
671
  }
672
+ cleanupBucket(bucketKey, bucket, intervalMs) {
673
+ const queued = this.queuesByBucket.get(bucketKey)?.length ?? 0;
674
+ if (queued === 0 && bucket.active === 0 && bucket.startedAt.length === 0) {
675
+ this.buckets.delete(bucketKey);
676
+ this.queuesByBucket.delete(bucketKey);
677
+ this.pendingBuckets.delete(bucketKey);
678
+ return;
679
+ }
680
+ if (!intervalMs || bucket.active > 0 || queued > 0) {
681
+ return;
682
+ }
683
+ if (bucket.cleanupTimer) {
684
+ clearTimeout(bucket.cleanupTimer);
685
+ }
686
+ bucket.cleanupTimer = setTimeout(() => {
687
+ bucket.cleanupTimer = void 0;
688
+ this.prune(bucket, Date.now(), intervalMs);
689
+ if (bucket.active === 0 && bucket.startedAt.length === 0 && (this.queuesByBucket.get(bucketKey)?.length ?? 0) === 0) {
690
+ this.buckets.delete(bucketKey);
691
+ this.queuesByBucket.delete(bucketKey);
692
+ this.pendingBuckets.delete(bucketKey);
693
+ }
694
+ }, intervalMs);
695
+ bucket.cleanupTimer.unref?.();
696
+ }
541
697
  };
542
698
 
543
699
  // src/internal/MetricsCollector.ts
@@ -616,7 +772,30 @@ var MetricsCollector = class {
616
772
 
617
773
  // src/internal/StoredValue.ts
618
774
  function isStoredValueEnvelope(value) {
619
- return typeof value === "object" && value !== null && "__layercache" in value && value.__layercache === 1 && "kind" in value;
775
+ if (typeof value !== "object" || value === null) {
776
+ return false;
777
+ }
778
+ const v = value;
779
+ if (v.__layercache !== 1) {
780
+ return false;
781
+ }
782
+ if (v.kind !== "value" && v.kind !== "empty") {
783
+ return false;
784
+ }
785
+ if (v.freshUntil !== null && typeof v.freshUntil !== "number") {
786
+ return false;
787
+ }
788
+ if (v.staleUntil !== null && typeof v.staleUntil !== "number") {
789
+ return false;
790
+ }
791
+ if (v.errorUntil !== null && typeof v.errorUntil !== "number") {
792
+ return false;
793
+ }
794
+ const maxTimestamp = Date.now() + 10 * 365 * 24 * 60 * 60 * 1e3;
795
+ if (typeof v.freshUntil === "number" && v.freshUntil > maxTimestamp) {
796
+ return false;
797
+ }
798
+ return true;
620
799
  }
621
800
  function createStoredValueEnvelope(options) {
622
801
  const now = options.now ?? Date.now();
@@ -827,69 +1006,23 @@ var TtlResolver = class {
827
1006
  }
828
1007
  };
829
1008
 
830
- // src/invalidation/PatternMatcher.ts
831
- var PatternMatcher = class _PatternMatcher {
832
- /**
833
- * Tests whether a glob-style pattern matches a value.
834
- * Supports `*` (any sequence of characters) and `?` (any single character).
835
- * Uses a two-pointer algorithm to avoid ReDoS vulnerabilities and
836
- * quadratic memory usage on long patterns/keys.
837
- */
838
- static matches(pattern, value) {
839
- return _PatternMatcher.matchLinear(pattern, value);
840
- }
841
- /**
842
- * Linear-time glob matching with O(1) extra memory.
843
- */
844
- static matchLinear(pattern, value) {
845
- let patternIndex = 0;
846
- let valueIndex = 0;
847
- let starIndex = -1;
848
- let backtrackValueIndex = 0;
849
- while (valueIndex < value.length) {
850
- const patternChar = pattern[patternIndex];
851
- const valueChar = value[valueIndex];
852
- if (patternChar === "*" && patternIndex < pattern.length) {
853
- starIndex = patternIndex;
854
- patternIndex += 1;
855
- backtrackValueIndex = valueIndex;
856
- continue;
857
- }
858
- if (patternChar === "?" || patternChar === valueChar) {
859
- patternIndex += 1;
860
- valueIndex += 1;
861
- continue;
862
- }
863
- if (starIndex !== -1) {
864
- patternIndex = starIndex + 1;
865
- backtrackValueIndex += 1;
866
- valueIndex = backtrackValueIndex;
867
- continue;
868
- }
869
- return false;
870
- }
871
- while (patternIndex < pattern.length && pattern[patternIndex] === "*") {
872
- patternIndex += 1;
873
- }
874
- return patternIndex === pattern.length;
875
- }
876
- };
877
-
878
1009
  // src/invalidation/TagIndex.ts
879
1010
  var TagIndex = class {
880
1011
  tagToKeys = /* @__PURE__ */ new Map();
881
1012
  keyToTags = /* @__PURE__ */ new Map();
882
1013
  knownKeys = /* @__PURE__ */ new Set();
883
1014
  maxKnownKeys;
1015
+ nextNodeId = 1;
1016
+ root = this.createTrieNode();
884
1017
  constructor(options = {}) {
885
- this.maxKnownKeys = options.maxKnownKeys;
1018
+ this.maxKnownKeys = options.maxKnownKeys ?? 1e5;
886
1019
  }
887
1020
  async touch(key) {
888
- this.knownKeys.add(key);
1021
+ this.insertKnownKey(key);
889
1022
  this.pruneKnownKeysIfNeeded();
890
1023
  }
891
1024
  async track(key, tags) {
892
- this.knownKeys.add(key);
1025
+ this.insertKnownKey(key);
893
1026
  this.pruneKnownKeysIfNeeded();
894
1027
  if (tags.length === 0) {
895
1028
  return;
@@ -915,18 +1048,104 @@ var TagIndex = class {
915
1048
  return [...this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set()];
916
1049
  }
917
1050
  async keysForPrefix(prefix) {
918
- return [...this.knownKeys].filter((key) => key.startsWith(prefix));
1051
+ const node = this.findNode(prefix);
1052
+ if (!node) {
1053
+ return [];
1054
+ }
1055
+ const matches = [];
1056
+ this.collectFromNode(node, prefix, matches);
1057
+ return matches;
919
1058
  }
920
1059
  async tagsForKey(key) {
921
1060
  return [...this.keyToTags.get(key) ?? /* @__PURE__ */ new Set()];
922
1061
  }
923
1062
  async matchPattern(pattern) {
924
- return [...this.knownKeys].filter((key) => PatternMatcher.matches(pattern, key));
1063
+ const matches = /* @__PURE__ */ new Set();
1064
+ this.collectPatternMatches(this.root, "", pattern, 0, matches, /* @__PURE__ */ new Set());
1065
+ return [...matches];
925
1066
  }
926
1067
  async clear() {
927
1068
  this.tagToKeys.clear();
928
1069
  this.keyToTags.clear();
929
1070
  this.knownKeys.clear();
1071
+ this.root.children.clear();
1072
+ this.root.terminal = false;
1073
+ this.nextNodeId = this.root.id + 1;
1074
+ }
1075
+ createTrieNode() {
1076
+ return {
1077
+ id: this.nextNodeId++,
1078
+ terminal: false,
1079
+ children: /* @__PURE__ */ new Map()
1080
+ };
1081
+ }
1082
+ insertKnownKey(key) {
1083
+ if (this.knownKeys.has(key)) {
1084
+ return;
1085
+ }
1086
+ this.knownKeys.add(key);
1087
+ let node = this.root;
1088
+ for (const character of key) {
1089
+ let child = node.children.get(character);
1090
+ if (!child) {
1091
+ child = this.createTrieNode();
1092
+ node.children.set(character, child);
1093
+ }
1094
+ node = child;
1095
+ }
1096
+ node.terminal = true;
1097
+ }
1098
+ findNode(prefix) {
1099
+ let node = this.root;
1100
+ for (const character of prefix) {
1101
+ node = node.children.get(character);
1102
+ if (!node) {
1103
+ return void 0;
1104
+ }
1105
+ }
1106
+ return node;
1107
+ }
1108
+ collectFromNode(node, prefix, matches) {
1109
+ if (node.terminal) {
1110
+ matches.push(prefix);
1111
+ }
1112
+ for (const [character, child] of node.children) {
1113
+ this.collectFromNode(child, `${prefix}${character}`, matches);
1114
+ }
1115
+ }
1116
+ collectPatternMatches(node, prefix, pattern, patternIndex, matches, visited) {
1117
+ const stateKey = `${node.id}:${patternIndex}`;
1118
+ if (visited.has(stateKey)) {
1119
+ return;
1120
+ }
1121
+ visited.add(stateKey);
1122
+ if (patternIndex === pattern.length) {
1123
+ if (node.terminal) {
1124
+ matches.add(prefix);
1125
+ }
1126
+ return;
1127
+ }
1128
+ const patternChar = pattern[patternIndex];
1129
+ if (patternChar === void 0) {
1130
+ return;
1131
+ }
1132
+ if (patternChar === "*") {
1133
+ this.collectPatternMatches(node, prefix, pattern, patternIndex + 1, matches, visited);
1134
+ for (const [character, child2] of node.children) {
1135
+ this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex, matches, visited);
1136
+ }
1137
+ return;
1138
+ }
1139
+ if (patternChar === "?") {
1140
+ for (const [character, child2] of node.children) {
1141
+ this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex + 1, matches, visited);
1142
+ }
1143
+ return;
1144
+ }
1145
+ const child = node.children.get(patternChar);
1146
+ if (child) {
1147
+ this.collectPatternMatches(child, `${prefix}${patternChar}`, pattern, patternIndex + 1, matches, visited);
1148
+ }
930
1149
  }
931
1150
  pruneKnownKeysIfNeeded() {
932
1151
  if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
@@ -943,7 +1162,7 @@ var TagIndex = class {
943
1162
  }
944
1163
  }
945
1164
  removeKey(key) {
946
- this.knownKeys.delete(key);
1165
+ this.removeKnownKey(key);
947
1166
  const tags = this.keyToTags.get(key);
948
1167
  if (!tags) {
949
1168
  return;
@@ -960,7 +1179,70 @@ var TagIndex = class {
960
1179
  }
961
1180
  this.keyToTags.delete(key);
962
1181
  }
1182
+ removeKnownKey(key) {
1183
+ if (!this.knownKeys.delete(key)) {
1184
+ return;
1185
+ }
1186
+ const path = [];
1187
+ let node = this.root;
1188
+ for (const character of key) {
1189
+ const child = node.children.get(character);
1190
+ if (!child) {
1191
+ return;
1192
+ }
1193
+ path.push([node, character]);
1194
+ node = child;
1195
+ }
1196
+ node.terminal = false;
1197
+ for (let index = path.length - 1; index >= 0; index -= 1) {
1198
+ const entry = path[index];
1199
+ if (!entry) {
1200
+ continue;
1201
+ }
1202
+ const [parent, character] = entry;
1203
+ const child = parent.children.get(character);
1204
+ if (!child || child.terminal || child.children.size > 0) {
1205
+ break;
1206
+ }
1207
+ parent.children.delete(character);
1208
+ }
1209
+ }
1210
+ };
1211
+
1212
+ // src/serialization/JsonSerializer.ts
1213
+ var DANGEROUS_JSON_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
1214
+ var JsonSerializer = class {
1215
+ serialize(value) {
1216
+ return JSON.stringify(value);
1217
+ }
1218
+ deserialize(payload) {
1219
+ const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
1220
+ return sanitizeJsonValue(JSON.parse(normalized), 0);
1221
+ }
963
1222
  };
1223
+ var MAX_SANITIZE_DEPTH = 200;
1224
+ function sanitizeJsonValue(value, depth) {
1225
+ if (depth > MAX_SANITIZE_DEPTH) {
1226
+ return value;
1227
+ }
1228
+ if (Array.isArray(value)) {
1229
+ return value.map((entry) => sanitizeJsonValue(entry, depth + 1));
1230
+ }
1231
+ if (!isPlainObject(value)) {
1232
+ return value;
1233
+ }
1234
+ const sanitized = {};
1235
+ for (const [key, entry] of Object.entries(value)) {
1236
+ if (DANGEROUS_JSON_KEYS.has(key)) {
1237
+ continue;
1238
+ }
1239
+ sanitized[key] = sanitizeJsonValue(entry, depth + 1);
1240
+ }
1241
+ return sanitized;
1242
+ }
1243
+ function isPlainObject(value) {
1244
+ return Object.prototype.toString.call(value) === "[object Object]";
1245
+ }
964
1246
 
965
1247
  // src/stampede/StampedeGuard.ts
966
1248
  var import_async_mutex2 = require("async-mutex");
@@ -972,7 +1254,8 @@ var StampedeGuard = class {
972
1254
  return await entry.mutex.runExclusive(task);
973
1255
  } finally {
974
1256
  entry.references -= 1;
975
- if (entry.references === 0 && !entry.mutex.isLocked()) {
1257
+ const current = this.mutexes.get(key);
1258
+ if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
976
1259
  this.mutexes.delete(key);
977
1260
  }
978
1261
  }
@@ -1002,8 +1285,10 @@ var CacheMissError = class extends Error {
1002
1285
  var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
1003
1286
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
1004
1287
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
1288
+ var DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 3e4;
1005
1289
  var MAX_CACHE_KEY_LENGTH = 1024;
1006
1290
  var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
1291
+ var DANGEROUS_OBJECT_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
1007
1292
  var DebugLogger = class {
1008
1293
  enabled;
1009
1294
  constructor(enabled) {
@@ -1050,6 +1335,29 @@ var CacheStack = class extends import_node_events.EventEmitter {
1050
1335
  const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
1051
1336
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
1052
1337
  this.tagIndex = options.tagIndex ?? new TagIndex();
1338
+ this.keyDiscovery = new CacheKeyDiscovery({
1339
+ layers: this.layers,
1340
+ tagIndex: this.tagIndex,
1341
+ shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
1342
+ handleLayerFailure: async (layer, operation, error) => {
1343
+ await this.handleLayerFailure(layer, operation, error);
1344
+ }
1345
+ });
1346
+ if (!options.tagIndex && layers.some((layer) => layer.isLocal === false)) {
1347
+ this.logger.warn?.(
1348
+ "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."
1349
+ );
1350
+ }
1351
+ if (!options.tagIndex && layers.some((layer) => layer.isLocal === false && !layer.keys)) {
1352
+ this.logger.warn?.(
1353
+ "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."
1354
+ );
1355
+ }
1356
+ if (options.invalidationBus && options.broadcastL1Invalidation === void 0 && options.publishSetInvalidation === void 0) {
1357
+ this.logger.warn?.(
1358
+ "broadcastL1Invalidation defaults to false when an invalidation bus is configured; opt in explicitly if write-triggered L1 invalidation is desired."
1359
+ );
1360
+ }
1053
1361
  this.initializeWriteBehind(options.writeBehind);
1054
1362
  this.startup = this.initialize();
1055
1363
  }
@@ -1062,7 +1370,9 @@ var CacheStack = class extends import_node_events.EventEmitter {
1062
1370
  unsubscribeInvalidation;
1063
1371
  logger;
1064
1372
  tagIndex;
1373
+ keyDiscovery;
1065
1374
  fetchRateLimiter = new FetchRateLimiter();
1375
+ snapshotSerializer = new JsonSerializer();
1066
1376
  backgroundRefreshes = /* @__PURE__ */ new Map();
1067
1377
  layerDegradedUntil = /* @__PURE__ */ new Map();
1068
1378
  ttlResolver;
@@ -1071,6 +1381,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1071
1381
  writeBehindQueue = [];
1072
1382
  writeBehindTimer;
1073
1383
  writeBehindFlushPromise;
1384
+ generationCleanupPromise;
1074
1385
  isDisconnecting = false;
1075
1386
  disconnectPromise;
1076
1387
  /**
@@ -1083,6 +1394,9 @@ var CacheStack = class extends import_node_events.EventEmitter {
1083
1394
  const normalizedKey = this.qualifyKey(this.validateCacheKey(key));
1084
1395
  this.validateWriteOptions(options);
1085
1396
  await this.awaitStartup("get");
1397
+ return this.getPrepared(normalizedKey, fetcher, options);
1398
+ }
1399
+ async getPrepared(normalizedKey, fetcher, options) {
1086
1400
  const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
1087
1401
  if (hit.found) {
1088
1402
  this.ttlResolver.recordAccess(normalizedKey);
@@ -1160,6 +1474,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1160
1474
  return true;
1161
1475
  }
1162
1476
  } catch {
1477
+ await this.reportRecoverableLayerFailure(layer, "has", new Error(`has() failed for layer "${layer.name}"`));
1163
1478
  }
1164
1479
  } else {
1165
1480
  try {
@@ -1167,7 +1482,8 @@ var CacheStack = class extends import_node_events.EventEmitter {
1167
1482
  if (value !== null) {
1168
1483
  return true;
1169
1484
  }
1170
- } catch {
1485
+ } catch (error) {
1486
+ await this.reportRecoverableLayerFailure(layer, "has", error);
1171
1487
  }
1172
1488
  }
1173
1489
  }
@@ -1259,13 +1575,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
1259
1575
  normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
1260
1576
  const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
1261
1577
  if (!canFastPath) {
1578
+ await this.awaitStartup("mget");
1262
1579
  const pendingReads = /* @__PURE__ */ new Map();
1263
1580
  return Promise.all(
1264
1581
  normalizedEntries.map((entry) => {
1265
1582
  const optionsSignature = this.serializeOptions(entry.options);
1266
1583
  const existing = pendingReads.get(entry.key);
1267
1584
  if (!existing) {
1268
- const promise = this.get(entry.key, entry.fetch, entry.options);
1585
+ const promise = this.getPrepared(entry.key, entry.fetch, entry.options);
1269
1586
  pendingReads.set(entry.key, {
1270
1587
  promise,
1271
1588
  fetch: entry.fetch,
@@ -1404,14 +1721,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
1404
1721
  }
1405
1722
  async invalidateByPattern(pattern) {
1406
1723
  await this.awaitStartup("invalidateByPattern");
1407
- const keys = await this.tagIndex.matchPattern(this.qualifyPattern(pattern));
1724
+ const keys = await this.keyDiscovery.collectKeysMatchingPattern(this.qualifyPattern(pattern));
1408
1725
  await this.deleteKeys(keys);
1409
1726
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1410
1727
  }
1411
1728
  async invalidateByPrefix(prefix) {
1412
1729
  await this.awaitStartup("invalidateByPrefix");
1413
1730
  const qualifiedPrefix = this.qualifyKey(this.validateCacheKey(prefix));
1414
- const keys = this.tagIndex.keysForPrefix ? await this.tagIndex.keysForPrefix(qualifiedPrefix) : await this.tagIndex.matchPattern(`${qualifiedPrefix}*`);
1731
+ const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix);
1415
1732
  await this.deleteKeys(keys);
1416
1733
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1417
1734
  }
@@ -1461,9 +1778,18 @@ var CacheStack = class extends import_node_events.EventEmitter {
1461
1778
  })
1462
1779
  );
1463
1780
  }
1781
+ /**
1782
+ * Rotates the active generation prefix used for all future cache keys.
1783
+ * Previous-generation keys remain in the underlying layers until they expire,
1784
+ * unless `generationCleanup` is enabled to prune them in the background.
1785
+ */
1464
1786
  bumpGeneration(nextGeneration) {
1465
1787
  const current = this.currentGeneration ?? 0;
1788
+ const previousGeneration = this.currentGeneration;
1466
1789
  this.currentGeneration = nextGeneration ?? current + 1;
1790
+ if (previousGeneration !== void 0 && previousGeneration !== this.currentGeneration && this.shouldCleanupGenerations()) {
1791
+ this.scheduleGenerationCleanup(previousGeneration);
1792
+ }
1467
1793
  return this.currentGeneration;
1468
1794
  }
1469
1795
  /**
@@ -1547,27 +1873,28 @@ var CacheStack = class extends import_node_events.EventEmitter {
1547
1873
  this.assertActive("persistToFile");
1548
1874
  const snapshot = await this.exportState();
1549
1875
  const { promises: fs2 } = await import("fs");
1550
- await fs2.writeFile(filePath, JSON.stringify(snapshot, null, 2), "utf8");
1876
+ await fs2.writeFile(await this.validateSnapshotFilePath(filePath), JSON.stringify(snapshot, null, 2), "utf8");
1551
1877
  }
1552
1878
  async restoreFromFile(filePath) {
1553
1879
  this.assertActive("restoreFromFile");
1554
1880
  const { promises: fs2 } = await import("fs");
1555
- const raw = await fs2.readFile(filePath, "utf8");
1881
+ const raw = await fs2.readFile(await this.validateSnapshotFilePath(filePath), "utf8");
1556
1882
  let parsed;
1557
1883
  try {
1558
- parsed = JSON.parse(raw, (_key, value) => {
1559
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1560
- return Object.assign(/* @__PURE__ */ Object.create(null), value);
1561
- }
1562
- return value;
1563
- });
1884
+ parsed = JSON.parse(raw);
1564
1885
  } catch (cause) {
1565
1886
  throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
1566
1887
  }
1567
1888
  if (!this.isCacheSnapshotEntries(parsed)) {
1568
1889
  throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
1569
1890
  }
1570
- await this.importState(parsed);
1891
+ await this.importState(
1892
+ parsed.map((entry) => ({
1893
+ key: entry.key,
1894
+ value: this.sanitizeSnapshotValue(entry.value),
1895
+ ttl: entry.ttl
1896
+ }))
1897
+ );
1571
1898
  }
1572
1899
  async disconnect() {
1573
1900
  if (!this.disconnectPromise) {
@@ -1576,6 +1903,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1576
1903
  await this.startup;
1577
1904
  await this.unsubscribeInvalidation?.();
1578
1905
  await this.flushWriteBehindQueue();
1906
+ await this.generationCleanupPromise;
1579
1907
  await Promise.allSettled([...this.backgroundRefreshes.values()]);
1580
1908
  if (this.writeBehindTimer) {
1581
1909
  clearInterval(this.writeBehindTimer);
@@ -1659,8 +1987,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
1659
1987
  await this.storeEntry(key, "empty", null, options);
1660
1988
  return null;
1661
1989
  }
1662
- if (options?.shouldCache && !options.shouldCache(fetched)) {
1663
- return fetched;
1990
+ if (options?.shouldCache) {
1991
+ try {
1992
+ if (!options.shouldCache(fetched)) {
1993
+ return fetched;
1994
+ }
1995
+ } catch (error) {
1996
+ this.logger.warn?.("shouldCache-error", { key, error: this.formatError(error) });
1997
+ }
1664
1998
  }
1665
1999
  await this.storeEntry(key, "value", fetched, options);
1666
2000
  return fetched;
@@ -1887,7 +2221,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1887
2221
  const refresh = (async () => {
1888
2222
  this.metricsCollector.increment("refreshes");
1889
2223
  try {
1890
- await this.fetchWithGuards(key, fetcher, options);
2224
+ await this.runBackgroundRefresh(key, fetcher, options);
1891
2225
  } catch (error) {
1892
2226
  this.metricsCollector.increment("refreshErrors");
1893
2227
  this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
@@ -1897,6 +2231,16 @@ var CacheStack = class extends import_node_events.EventEmitter {
1897
2231
  })();
1898
2232
  this.backgroundRefreshes.set(key, refresh);
1899
2233
  }
2234
+ async runBackgroundRefresh(key, fetcher, options) {
2235
+ const timeoutMs = this.options.backgroundRefreshTimeoutMs ?? DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS;
2236
+ await this.fetchWithGuards(
2237
+ key,
2238
+ () => this.withTimeout(fetcher(), timeoutMs, () => {
2239
+ return new Error(`Background refresh timed out after ${timeoutMs}ms for key "${key}".`);
2240
+ }),
2241
+ options
2242
+ );
2243
+ }
1900
2244
  resolveSingleFlightOptions() {
1901
2245
  return {
1902
2246
  leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
@@ -1964,8 +2308,76 @@ var CacheStack = class extends import_node_events.EventEmitter {
1964
2308
  sleep(ms) {
1965
2309
  return new Promise((resolve2) => setTimeout(resolve2, ms));
1966
2310
  }
2311
+ async withTimeout(promise, timeoutMs, onTimeout) {
2312
+ if (timeoutMs <= 0) {
2313
+ return promise;
2314
+ }
2315
+ let timer;
2316
+ const observedPromise = promise.then(
2317
+ (value) => ({ kind: "value", value }),
2318
+ (error) => ({ kind: "error", error })
2319
+ );
2320
+ try {
2321
+ const result = await Promise.race([
2322
+ observedPromise,
2323
+ new Promise((_, reject) => {
2324
+ timer = setTimeout(() => reject(onTimeout()), timeoutMs);
2325
+ timer.unref?.();
2326
+ })
2327
+ ]);
2328
+ if (result && typeof result === "object" && "kind" in result) {
2329
+ if (result.kind === "error") {
2330
+ throw result.error;
2331
+ }
2332
+ return result.value;
2333
+ }
2334
+ return result;
2335
+ } finally {
2336
+ if (timer) {
2337
+ clearTimeout(timer);
2338
+ }
2339
+ }
2340
+ }
1967
2341
  shouldBroadcastL1Invalidation() {
1968
- return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? true;
2342
+ return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false;
2343
+ }
2344
+ shouldCleanupGenerations() {
2345
+ return Boolean(this.options.generationCleanup);
2346
+ }
2347
+ generationCleanupBatchSize() {
2348
+ const configured = typeof this.options.generationCleanup === "object" ? this.options.generationCleanup.batchSize : void 0;
2349
+ return configured ?? 500;
2350
+ }
2351
+ scheduleGenerationCleanup(generation) {
2352
+ const task = (this.generationCleanupPromise ?? Promise.resolve()).then(() => this.cleanupGeneration(generation)).catch((error) => {
2353
+ this.logger.warn?.("generation-cleanup-error", {
2354
+ generation,
2355
+ error: this.formatError(error)
2356
+ });
2357
+ });
2358
+ this.generationCleanupPromise = task.finally(() => {
2359
+ if (this.generationCleanupPromise === task) {
2360
+ this.generationCleanupPromise = void 0;
2361
+ }
2362
+ });
2363
+ }
2364
+ async cleanupGeneration(generation) {
2365
+ const prefix = `v${generation}:`;
2366
+ const keys = await this.keyDiscovery.collectKeysWithPrefix(prefix);
2367
+ if (keys.length === 0) {
2368
+ return;
2369
+ }
2370
+ const batchSize = this.generationCleanupBatchSize();
2371
+ for (let index = 0; index < keys.length; index += batchSize) {
2372
+ const batch = keys.slice(index, index + batchSize);
2373
+ await this.deleteKeys(batch);
2374
+ await this.publishInvalidation({
2375
+ scope: "keys",
2376
+ keys: batch,
2377
+ sourceId: this.instanceId,
2378
+ operation: "invalidate"
2379
+ });
2380
+ }
1969
2381
  }
1970
2382
  initializeWriteBehind(options) {
1971
2383
  if (this.options.writeStrategy !== "write-behind") {
@@ -2003,7 +2415,17 @@ var CacheStack = class extends import_node_events.EventEmitter {
2003
2415
  const batchSize = this.options.writeBehind?.batchSize ?? 100;
2004
2416
  const batch = this.writeBehindQueue.splice(0, batchSize);
2005
2417
  this.writeBehindFlushPromise = (async () => {
2006
- await Promise.allSettled(batch.map((operation) => operation()));
2418
+ const results = await Promise.allSettled(batch.map((operation) => operation()));
2419
+ const failures = results.filter((result) => result.status === "rejected");
2420
+ if (failures.length > 0) {
2421
+ this.metricsCollector.increment("writeFailures", failures.length);
2422
+ this.logger.error?.("write-behind-flush-failure", {
2423
+ failed: failures.length,
2424
+ total: batch.length,
2425
+ errors: failures.map((failure) => this.formatError(failure.reason))
2426
+ });
2427
+ this.emitError("write-behind", { failed: failures.length, total: batch.length });
2428
+ }
2007
2429
  })();
2008
2430
  await this.writeBehindFlushPromise;
2009
2431
  this.writeBehindFlushPromise = void 0;
@@ -2108,9 +2530,13 @@ var CacheStack = class extends import_node_events.EventEmitter {
2108
2530
  this.validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
2109
2531
  this.validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
2110
2532
  this.validatePositiveNumber("singleFlightRenewIntervalMs", this.options.singleFlightRenewIntervalMs);
2533
+ this.validatePositiveNumber("backgroundRefreshTimeoutMs", this.options.backgroundRefreshTimeoutMs);
2111
2534
  this.validateRateLimitOptions("fetcherRateLimit", this.options.fetcherRateLimit);
2112
2535
  this.validateAdaptiveTtlOptions(this.options.adaptiveTtl);
2113
2536
  this.validateCircuitBreakerOptions(this.options.circuitBreaker);
2537
+ if (typeof this.options.generationCleanup === "object") {
2538
+ this.validatePositiveNumber("generationCleanup.batchSize", this.options.generationCleanup.batchSize);
2539
+ }
2114
2540
  if (this.options.generation !== void 0) {
2115
2541
  this.validateNonNegativeNumber("generation", this.options.generation);
2116
2542
  }
@@ -2182,6 +2608,9 @@ var CacheStack = class extends import_node_events.EventEmitter {
2182
2608
  if (/[\u0000-\u001F\u007F]/.test(key)) {
2183
2609
  throw new Error("Cache key contains unsupported control characters.");
2184
2610
  }
2611
+ if (/[\uD800-\uDFFF]/.test(key)) {
2612
+ throw new Error("Cache key contains unsupported surrogate code points.");
2613
+ }
2185
2614
  return key;
2186
2615
  }
2187
2616
  validateTtlPolicy(name, policy) {
@@ -2259,6 +2688,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
2259
2688
  this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
2260
2689
  return null;
2261
2690
  }
2691
+ async reportRecoverableLayerFailure(layer, operation, error) {
2692
+ if (this.isGracefulDegradationEnabled()) {
2693
+ await this.handleLayerFailure(layer, operation, error);
2694
+ return;
2695
+ }
2696
+ this.logger.warn?.("layer-operation-failed", { layer: layer.name, operation, error: this.formatError(error) });
2697
+ this.emitError(operation, { layer: layer.name, degraded: false, error: this.formatError(error) });
2698
+ }
2262
2699
  isGracefulDegradationEnabled() {
2263
2700
  return Boolean(this.options.gracefulDegradation);
2264
2701
  }
@@ -2282,10 +2719,16 @@ var CacheStack = class extends import_node_events.EventEmitter {
2282
2719
  }
2283
2720
  }
2284
2721
  serializeKeyPart(value) {
2285
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
2286
- return String(value);
2722
+ if (typeof value === "string") {
2723
+ return `s:${value}`;
2724
+ }
2725
+ if (typeof value === "number") {
2726
+ return `n:${value}`;
2727
+ }
2728
+ if (typeof value === "boolean") {
2729
+ return `b:${value}`;
2287
2730
  }
2288
- return JSON.stringify(this.normalizeForSerialization(value));
2731
+ return `j:${JSON.stringify(this.normalizeForSerialization(value))}`;
2289
2732
  }
2290
2733
  isCacheSnapshotEntries(value) {
2291
2734
  return Array.isArray(value) && value.every((entry) => {
@@ -2293,15 +2736,39 @@ var CacheStack = class extends import_node_events.EventEmitter {
2293
2736
  return false;
2294
2737
  }
2295
2738
  const candidate = entry;
2296
- return typeof candidate.key === "string";
2739
+ return typeof candidate.key === "string" && (candidate.ttl === void 0 || typeof candidate.ttl === "number" && Number.isFinite(candidate.ttl) && candidate.ttl >= 0);
2297
2740
  });
2298
2741
  }
2742
+ sanitizeSnapshotValue(value) {
2743
+ return this.snapshotSerializer.deserialize(this.snapshotSerializer.serialize(value));
2744
+ }
2745
+ async validateSnapshotFilePath(filePath) {
2746
+ if (filePath.length === 0) {
2747
+ throw new Error("filePath must not be empty.");
2748
+ }
2749
+ if (filePath.includes("\0")) {
2750
+ throw new Error("filePath must not contain null bytes.");
2751
+ }
2752
+ const path = await import("path");
2753
+ const resolved = path.resolve(filePath);
2754
+ const baseDir = this.options.snapshotBaseDir === false ? false : path.resolve(this.options.snapshotBaseDir ?? process.cwd());
2755
+ if (baseDir !== false) {
2756
+ const relative = path.relative(baseDir, resolved);
2757
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
2758
+ throw new Error(`filePath is outside the allowed snapshot directory: ${baseDir}`);
2759
+ }
2760
+ }
2761
+ return resolved;
2762
+ }
2299
2763
  normalizeForSerialization(value) {
2300
2764
  if (Array.isArray(value)) {
2301
2765
  return value.map((entry) => this.normalizeForSerialization(entry));
2302
2766
  }
2303
2767
  if (value && typeof value === "object") {
2304
2768
  return Object.keys(value).sort().reduce((normalized, key) => {
2769
+ if (DANGEROUS_OBJECT_KEYS.has(key)) {
2770
+ return normalized;
2771
+ }
2305
2772
  normalized[key] = this.normalizeForSerialization(value[key]);
2306
2773
  return normalized;
2307
2774
  }, {});
@@ -2562,7 +3029,7 @@ function createCachedMethodDecorator(options) {
2562
3029
  function createFastifyLayercachePlugin(cache, options = {}) {
2563
3030
  return async (fastify) => {
2564
3031
  fastify.decorate("cache", cache);
2565
- if (options.exposeStatsRoute !== false && fastify.get) {
3032
+ if (options.exposeStatsRoute === true && fastify.get) {
2566
3033
  fastify.get(options.statsPath ?? "/cache/stats", async () => cache.getStats());
2567
3034
  }
2568
3035
  };
@@ -2578,7 +3045,8 @@ function createExpressCacheMiddleware(cache, options = {}) {
2578
3045
  next();
2579
3046
  return;
2580
3047
  }
2581
- const key = options.keyResolver ? options.keyResolver(req) : `${method}:${req.originalUrl ?? req.url ?? "/"}`;
3048
+ const rawUrl = req.originalUrl ?? req.url ?? "/";
3049
+ const key = options.keyResolver ? options.keyResolver(req) : `${method}:${normalizeUrl(rawUrl)}`;
2582
3050
  const cached = await cache.get(key, void 0, options);
2583
3051
  if (cached !== null) {
2584
3052
  res.setHeader?.("content-type", "application/json; charset=utf-8");
@@ -2594,7 +3062,12 @@ function createExpressCacheMiddleware(cache, options = {}) {
2594
3062
  if (originalJson) {
2595
3063
  res.json = (body) => {
2596
3064
  res.setHeader?.("x-cache", "MISS");
2597
- void cache.set(key, body, options);
3065
+ cache.set(key, body, options).catch((err) => {
3066
+ cache.emit("error", {
3067
+ operation: "set",
3068
+ error: err instanceof Error ? err.message : String(err)
3069
+ });
3070
+ });
2598
3071
  return originalJson(body);
2599
3072
  };
2600
3073
  }
@@ -2604,6 +3077,15 @@ function createExpressCacheMiddleware(cache, options = {}) {
2604
3077
  }
2605
3078
  };
2606
3079
  }
3080
+ function normalizeUrl(url) {
3081
+ try {
3082
+ const parsed = new URL(url, "http://localhost");
3083
+ parsed.searchParams.sort();
3084
+ return decodeURIComponent(parsed.pathname) + parsed.search;
3085
+ } catch {
3086
+ return url;
3087
+ }
3088
+ }
2607
3089
 
2608
3090
  // src/integrations/graphql.ts
2609
3091
  function cacheGraphqlResolver(cache, prefix, resolver, options = {}) {
@@ -2623,7 +3105,8 @@ function createHonoCacheMiddleware(cache, options = {}) {
2623
3105
  await next();
2624
3106
  return;
2625
3107
  }
2626
- const key = options.keyResolver ? options.keyResolver(context.req) : `${method}:${context.req.path ?? context.req.url ?? "/"}`;
3108
+ const rawPath = context.req.path ?? context.req.url ?? "/";
3109
+ const key = options.keyResolver ? options.keyResolver(context.req) : `${method}:${normalizeUrl2(rawPath)}`;
2627
3110
  const cached = await cache.get(key, void 0, options);
2628
3111
  if (cached !== null) {
2629
3112
  context.header?.("x-cache", "HIT");
@@ -2634,12 +3117,26 @@ function createHonoCacheMiddleware(cache, options = {}) {
2634
3117
  const originalJson = context.json.bind(context);
2635
3118
  context.json = (body, status) => {
2636
3119
  context.header?.("x-cache", "MISS");
2637
- void cache.set(key, body, options);
3120
+ cache.set(key, body, options).catch((err) => {
3121
+ cache.emit("error", {
3122
+ operation: "set",
3123
+ error: err instanceof Error ? err.message : String(err)
3124
+ });
3125
+ });
2638
3126
  return originalJson(body, status);
2639
3127
  };
2640
3128
  await next();
2641
3129
  };
2642
3130
  }
3131
+ function normalizeUrl2(url) {
3132
+ try {
3133
+ const parsed = new URL(url, "http://localhost");
3134
+ parsed.searchParams.sort();
3135
+ return decodeURIComponent(parsed.pathname) + parsed.search;
3136
+ } catch {
3137
+ return url;
3138
+ }
3139
+ }
2643
3140
 
2644
3141
  // src/integrations/opentelemetry.ts
2645
3142
  function createOpenTelemetryPlugin(cache, tracer) {
@@ -2774,16 +3271,10 @@ var MemoryLayer = class {
2774
3271
  return entry.value;
2775
3272
  }
2776
3273
  async getMany(keys) {
2777
- const values = [];
2778
- for (const key of keys) {
2779
- values.push(await this.getEntry(key));
2780
- }
2781
- return values;
3274
+ return Promise.all(keys.map((key) => this.getEntry(key)));
2782
3275
  }
2783
3276
  async setMany(entries) {
2784
- for (const entry of entries) {
2785
- await this.set(entry.key, entry.value, entry.ttl);
2786
- }
3277
+ await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.ttl)));
2787
3278
  }
2788
3279
  async set(key, value, ttl = this.defaultTtl) {
2789
3280
  this.entries.delete(key);
@@ -2919,39 +3410,6 @@ var MemoryLayer = class {
2919
3410
  // src/layers/RedisLayer.ts
2920
3411
  var import_node_util = require("util");
2921
3412
  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
3413
  var BATCH_DELETE_SIZE = 500;
2956
3414
  var gzipAsync = (0, import_node_util.promisify)(import_node_zlib.gzip);
2957
3415
  var gunzipAsync = (0, import_node_util.promisify)(import_node_zlib.gunzip);
@@ -2968,6 +3426,7 @@ var RedisLayer = class {
2968
3426
  scanCount;
2969
3427
  compression;
2970
3428
  compressionThreshold;
3429
+ decompressionMaxBytes;
2971
3430
  disconnectOnDispose;
2972
3431
  constructor(options) {
2973
3432
  this.client = options.client;
@@ -2979,6 +3438,7 @@ var RedisLayer = class {
2979
3438
  this.scanCount = options.scanCount ?? 100;
2980
3439
  this.compression = options.compression;
2981
3440
  this.compressionThreshold = options.compressionThreshold ?? 1024;
3441
+ this.decompressionMaxBytes = options.decompressionMaxBytes ?? 64 * 1024 * 1024;
2982
3442
  this.disconnectOnDispose = options.disconnectOnDispose ?? false;
2983
3443
  }
2984
3444
  async get(key) {
@@ -3178,16 +3638,29 @@ var RedisLayer = class {
3178
3638
  }
3179
3639
  /**
3180
3640
  * Decompresses the payload asynchronously if a compression header is present.
3641
+ * Enforces a maximum decompressed size to prevent decompression bomb attacks.
3181
3642
  */
3182
3643
  async decodePayload(payload) {
3183
3644
  if (!Buffer.isBuffer(payload)) {
3184
3645
  return payload;
3185
3646
  }
3186
3647
  if (payload.subarray(0, 10).toString() === "LCZ1:gzip:") {
3187
- return gunzipAsync(payload.subarray(10));
3648
+ const decompressed = await gunzipAsync(payload.subarray(10));
3649
+ if (decompressed.byteLength > this.decompressionMaxBytes) {
3650
+ throw new Error(
3651
+ `Decompressed payload (${decompressed.byteLength} bytes) exceeds decompressionMaxBytes limit (${this.decompressionMaxBytes} bytes).`
3652
+ );
3653
+ }
3654
+ return decompressed;
3188
3655
  }
3189
3656
  if (payload.subarray(0, 12).toString() === "LCZ1:brotli:") {
3190
- return brotliDecompressAsync(payload.subarray(12));
3657
+ const decompressed = await brotliDecompressAsync(payload.subarray(12));
3658
+ if (decompressed.byteLength > this.decompressionMaxBytes) {
3659
+ throw new Error(
3660
+ `Decompressed payload (${decompressed.byteLength} bytes) exceeds decompressionMaxBytes limit (${this.decompressionMaxBytes} bytes).`
3661
+ );
3662
+ }
3663
+ return decompressed;
3191
3664
  }
3192
3665
  return payload;
3193
3666
  }
@@ -3247,8 +3720,13 @@ var DiskLayer = class {
3247
3720
  const payload = this.serializer.serialize(entry);
3248
3721
  const targetPath = this.keyToPath(key);
3249
3722
  const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
3250
- await import_node_fs.promises.writeFile(tempPath, payload);
3251
- await import_node_fs.promises.rename(tempPath, targetPath);
3723
+ try {
3724
+ await import_node_fs.promises.writeFile(tempPath, payload);
3725
+ await import_node_fs.promises.rename(tempPath, targetPath);
3726
+ } catch (error) {
3727
+ await this.safeDelete(tempPath);
3728
+ throw error;
3729
+ }
3252
3730
  if (this.maxFiles !== void 0) {
3253
3731
  await this.enforceMaxFiles();
3254
3732
  }
@@ -3258,9 +3736,7 @@ var DiskLayer = class {
3258
3736
  return Promise.all(keys.map((key) => this.getEntry(key)));
3259
3737
  }
3260
3738
  async setMany(entries) {
3261
- for (const entry of entries) {
3262
- await this.set(entry.key, entry.value, entry.ttl);
3263
- }
3739
+ await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.ttl)));
3264
3740
  }
3265
3741
  async has(key) {
3266
3742
  const value = await this.getEntry(key);
@@ -3464,6 +3940,7 @@ var MemcachedLayer = class {
3464
3940
  return unwrapStoredValue(await this.getEntry(key));
3465
3941
  }
3466
3942
  async getEntry(key) {
3943
+ this.validateKey(key);
3467
3944
  const result = await this.client.get(this.withPrefix(key));
3468
3945
  if (!result || result.value === null) {
3469
3946
  return null;
@@ -3478,16 +3955,19 @@ var MemcachedLayer = class {
3478
3955
  return Promise.all(keys.map((key) => this.getEntry(key)));
3479
3956
  }
3480
3957
  async set(key, value, ttl = this.defaultTtl) {
3958
+ this.validateKey(key);
3481
3959
  const payload = this.serializer.serialize(value);
3482
3960
  await this.client.set(this.withPrefix(key), payload, {
3483
3961
  expires: ttl && ttl > 0 ? ttl : void 0
3484
3962
  });
3485
3963
  }
3486
3964
  async has(key) {
3965
+ this.validateKey(key);
3487
3966
  const result = await this.client.get(this.withPrefix(key));
3488
3967
  return result !== null && result.value !== null;
3489
3968
  }
3490
3969
  async delete(key) {
3970
+ this.validateKey(key);
3491
3971
  await this.client.delete(this.withPrefix(key));
3492
3972
  }
3493
3973
  async deleteMany(keys) {
@@ -3501,19 +3981,50 @@ var MemcachedLayer = class {
3501
3981
  withPrefix(key) {
3502
3982
  return `${this.keyPrefix}${key}`;
3503
3983
  }
3984
+ validateKey(key) {
3985
+ const fullKey = this.withPrefix(key);
3986
+ if (Buffer.byteLength(fullKey, "utf8") > 250) {
3987
+ throw new Error(`MemcachedLayer: key exceeds 250-byte Memcached limit: "${fullKey.slice(0, 60)}..."`);
3988
+ }
3989
+ if (/[\s\x00-\x1f\x7f]/.test(fullKey)) {
3990
+ throw new Error(
3991
+ "MemcachedLayer: key contains invalid characters (whitespace or control characters are not allowed)."
3992
+ );
3993
+ }
3994
+ }
3504
3995
  };
3505
3996
 
3506
3997
  // src/serialization/MsgpackSerializer.ts
3507
3998
  var import_msgpack = require("@msgpack/msgpack");
3999
+ var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
3508
4000
  var MsgpackSerializer = class {
3509
4001
  serialize(value) {
3510
4002
  return Buffer.from((0, import_msgpack.encode)(value));
3511
4003
  }
3512
4004
  deserialize(payload) {
3513
4005
  const normalized = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
3514
- return (0, import_msgpack.decode)(normalized);
4006
+ return sanitizeMsgpackValue((0, import_msgpack.decode)(normalized));
3515
4007
  }
3516
4008
  };
4009
+ function sanitizeMsgpackValue(value) {
4010
+ if (Array.isArray(value)) {
4011
+ return value.map((entry) => sanitizeMsgpackValue(entry));
4012
+ }
4013
+ if (!isPlainObject2(value)) {
4014
+ return value;
4015
+ }
4016
+ const sanitized = {};
4017
+ for (const [key, entry] of Object.entries(value)) {
4018
+ if (DANGEROUS_KEYS.has(key)) {
4019
+ continue;
4020
+ }
4021
+ sanitized[key] = sanitizeMsgpackValue(entry);
4022
+ }
4023
+ return sanitized;
4024
+ }
4025
+ function isPlainObject2(value) {
4026
+ return Object.prototype.toString.call(value) === "[object Object]";
4027
+ }
3517
4028
 
3518
4029
  // src/singleflight/RedisSingleFlightCoordinator.ts
3519
4030
  var import_node_crypto2 = require("crypto");
@@ -3642,7 +4153,7 @@ function createPrometheusMetricsExporter(stacks) {
3642
4153
  };
3643
4154
  }
3644
4155
  function sanitizeLabel(value) {
3645
- return value.replace(/["\\\n]/g, "_");
4156
+ return value.replace(/["\\\n\r]/g, "_");
3646
4157
  }
3647
4158
  // Annotate the CommonJS export names for ESM import in node:
3648
4159
  0 && (module.exports = {