layercache 1.2.1 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import {
2
2
  RedisTagIndex
3
- } from "./chunk-GF47Y3XR.js";
3
+ } from "./chunk-QHWG7QS5.js";
4
4
  import {
5
5
  MemoryLayer,
6
6
  TagIndex,
7
7
  createHonoCacheMiddleware
8
- } from "./chunk-46UH7LNM.js";
8
+ } from "./chunk-KOYGHLVP.js";
9
9
  import {
10
10
  PatternMatcher,
11
11
  createStoredValueEnvelope,
@@ -15,7 +15,7 @@ import {
15
15
  remainingStoredTtlSeconds,
16
16
  resolveStoredValue,
17
17
  unwrapStoredValue
18
- } from "./chunk-ZMDB5KOK.js";
18
+ } from "./chunk-7V7XAB74.js";
19
19
 
20
20
  // src/CacheStack.ts
21
21
  import { EventEmitter } from "events";
@@ -360,11 +360,13 @@ var CircuitBreakerManager = class {
360
360
 
361
361
  // src/internal/FetchRateLimiter.ts
362
362
  var FetchRateLimiter = class {
363
- active = 0;
364
- queue = [];
365
- startedAt = [];
363
+ buckets = /* @__PURE__ */ new Map();
364
+ queuesByBucket = /* @__PURE__ */ new Map();
365
+ pendingBuckets = /* @__PURE__ */ new Set();
366
+ fetcherBuckets = /* @__PURE__ */ new WeakMap();
367
+ nextFetcherBucketId = 0;
366
368
  drainTimer;
367
- async schedule(options, task) {
369
+ async schedule(options, context, task) {
368
370
  if (!options) {
369
371
  return task();
370
372
  }
@@ -372,8 +374,18 @@ var FetchRateLimiter = class {
372
374
  if (!normalized) {
373
375
  return task();
374
376
  }
375
- return new Promise((resolve, reject) => {
376
- this.queue.push({ options: normalized, task, resolve, reject });
377
+ return new Promise((resolve2, reject) => {
378
+ const bucketKey = this.resolveBucketKey(normalized, context);
379
+ const queue = this.queuesByBucket.get(bucketKey) ?? [];
380
+ queue.push({
381
+ bucketKey,
382
+ options: normalized,
383
+ task,
384
+ resolve: resolve2,
385
+ reject
386
+ });
387
+ this.queuesByBucket.set(bucketKey, queue);
388
+ this.pendingBuckets.add(bucketKey);
377
389
  this.drain();
378
390
  });
379
391
  }
@@ -387,63 +399,159 @@ var FetchRateLimiter = class {
387
399
  return {
388
400
  maxConcurrent,
389
401
  intervalMs,
390
- maxPerInterval
402
+ maxPerInterval,
403
+ scope: options.scope ?? "global",
404
+ bucketKey: options.bucketKey
391
405
  };
392
406
  }
407
+ resolveBucketKey(options, context) {
408
+ if (options.bucketKey) {
409
+ return `custom:${options.bucketKey}`;
410
+ }
411
+ if (options.scope === "key") {
412
+ return `key:${context.key}`;
413
+ }
414
+ if (options.scope === "fetcher") {
415
+ const existing = this.fetcherBuckets.get(context.fetcher);
416
+ if (existing) {
417
+ return existing;
418
+ }
419
+ const bucket = `fetcher:${this.nextFetcherBucketId}`;
420
+ this.nextFetcherBucketId += 1;
421
+ this.fetcherBuckets.set(context.fetcher, bucket);
422
+ return bucket;
423
+ }
424
+ return "global";
425
+ }
393
426
  drain() {
394
427
  if (this.drainTimer) {
395
428
  clearTimeout(this.drainTimer);
396
429
  this.drainTimer = void 0;
397
430
  }
398
- while (this.queue.length > 0) {
399
- const next = this.queue[0];
400
- if (!next) {
431
+ while (this.pendingBuckets.size > 0) {
432
+ let nextBucketKey;
433
+ let nextWaitMs = Number.POSITIVE_INFINITY;
434
+ for (const bucketKey of this.pendingBuckets) {
435
+ const queue2 = this.queuesByBucket.get(bucketKey);
436
+ if (!queue2 || queue2.length === 0) {
437
+ this.pendingBuckets.delete(bucketKey);
438
+ this.queuesByBucket.delete(bucketKey);
439
+ continue;
440
+ }
441
+ const next2 = queue2[0];
442
+ if (!next2) {
443
+ this.pendingBuckets.delete(bucketKey);
444
+ this.queuesByBucket.delete(bucketKey);
445
+ continue;
446
+ }
447
+ const waitMs = this.waitTime(bucketKey, next2.options);
448
+ if (waitMs <= 0) {
449
+ nextBucketKey = bucketKey;
450
+ break;
451
+ }
452
+ nextWaitMs = Math.min(nextWaitMs, waitMs);
453
+ }
454
+ if (!nextBucketKey) {
455
+ if (Number.isFinite(nextWaitMs)) {
456
+ this.drainTimer = setTimeout(() => {
457
+ this.drainTimer = void 0;
458
+ this.drain();
459
+ }, nextWaitMs);
460
+ this.drainTimer.unref?.();
461
+ }
401
462
  return;
402
463
  }
403
- const waitMs = this.waitTime(next.options);
404
- if (waitMs > 0) {
405
- this.drainTimer = setTimeout(() => {
406
- this.drainTimer = void 0;
407
- this.drain();
408
- }, waitMs);
409
- this.drainTimer.unref?.();
410
- return;
464
+ const queue = this.queuesByBucket.get(nextBucketKey);
465
+ const next = queue?.shift();
466
+ if (!next) {
467
+ this.pendingBuckets.delete(nextBucketKey);
468
+ this.queuesByBucket.delete(nextBucketKey);
469
+ continue;
470
+ }
471
+ if (!queue || queue.length === 0) {
472
+ this.pendingBuckets.delete(nextBucketKey);
473
+ this.queuesByBucket.delete(nextBucketKey);
474
+ }
475
+ const bucket = this.bucketState(next.bucketKey);
476
+ if (bucket.cleanupTimer) {
477
+ clearTimeout(bucket.cleanupTimer);
478
+ bucket.cleanupTimer = void 0;
479
+ }
480
+ bucket.active += 1;
481
+ if (next.options.intervalMs && next.options.maxPerInterval) {
482
+ bucket.startedAt.push(Date.now());
411
483
  }
412
- this.queue.shift();
413
- this.active += 1;
414
- this.startedAt.push(Date.now());
415
484
  void next.task().then(next.resolve, next.reject).finally(() => {
416
- this.active -= 1;
485
+ bucket.active -= 1;
486
+ if ((this.queuesByBucket.get(next.bucketKey)?.length ?? 0) > 0) {
487
+ this.pendingBuckets.add(next.bucketKey);
488
+ }
489
+ this.cleanupBucket(next.bucketKey, bucket, next.options.intervalMs);
417
490
  this.drain();
418
491
  });
419
492
  }
420
493
  }
421
- waitTime(options) {
494
+ waitTime(bucketKey, options) {
495
+ const bucket = this.bucketState(bucketKey);
422
496
  const now = Date.now();
423
- if (options.maxConcurrent && this.active >= options.maxConcurrent) {
497
+ if (options.maxConcurrent && bucket.active >= options.maxConcurrent) {
424
498
  return 1;
425
499
  }
426
500
  if (!options.intervalMs || !options.maxPerInterval) {
427
501
  return 0;
428
502
  }
429
- this.prune(now, options.intervalMs);
430
- if (this.startedAt.length < options.maxPerInterval) {
503
+ this.prune(bucket, now, options.intervalMs);
504
+ if (bucket.startedAt.length < options.maxPerInterval) {
431
505
  return 0;
432
506
  }
433
- const oldest = this.startedAt[0];
507
+ const oldest = bucket.startedAt[0];
434
508
  if (!oldest) {
435
509
  return 0;
436
510
  }
437
511
  return Math.max(1, options.intervalMs - (now - oldest));
438
512
  }
439
- prune(now, intervalMs) {
440
- while (this.startedAt.length > 0) {
441
- const startedAt = this.startedAt[0];
513
+ prune(bucket, now, intervalMs) {
514
+ while (bucket.startedAt.length > 0) {
515
+ const startedAt = bucket.startedAt[0];
442
516
  if (startedAt === void 0 || now - startedAt < intervalMs) {
443
517
  break;
444
518
  }
445
- this.startedAt.shift();
519
+ bucket.startedAt.shift();
520
+ }
521
+ }
522
+ bucketState(bucketKey) {
523
+ const existing = this.buckets.get(bucketKey);
524
+ if (existing) {
525
+ return existing;
526
+ }
527
+ const bucket = { active: 0, startedAt: [] };
528
+ this.buckets.set(bucketKey, bucket);
529
+ return bucket;
530
+ }
531
+ cleanupBucket(bucketKey, bucket, intervalMs) {
532
+ const queued = this.queuesByBucket.get(bucketKey)?.length ?? 0;
533
+ if (queued === 0 && bucket.active === 0 && bucket.startedAt.length === 0) {
534
+ this.buckets.delete(bucketKey);
535
+ this.queuesByBucket.delete(bucketKey);
536
+ this.pendingBuckets.delete(bucketKey);
537
+ return;
538
+ }
539
+ if (!intervalMs || bucket.active > 0 || queued > 0) {
540
+ return;
541
+ }
542
+ if (bucket.cleanupTimer) {
543
+ clearTimeout(bucket.cleanupTimer);
446
544
  }
545
+ bucket.cleanupTimer = setTimeout(() => {
546
+ bucket.cleanupTimer = void 0;
547
+ this.prune(bucket, Date.now(), intervalMs);
548
+ if (bucket.active === 0 && bucket.startedAt.length === 0 && (this.queuesByBucket.get(bucketKey)?.length ?? 0) === 0) {
549
+ this.buckets.delete(bucketKey);
550
+ this.queuesByBucket.delete(bucketKey);
551
+ this.pendingBuckets.delete(bucketKey);
552
+ }
553
+ }, intervalMs);
554
+ bucket.cleanupTimer.unref?.();
447
555
  }
448
556
  };
449
557
 
@@ -633,6 +741,41 @@ var TtlResolver = class {
633
741
  }
634
742
  };
635
743
 
744
+ // src/serialization/JsonSerializer.ts
745
+ var DANGEROUS_JSON_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
746
+ var JsonSerializer = class {
747
+ serialize(value) {
748
+ return JSON.stringify(value);
749
+ }
750
+ deserialize(payload) {
751
+ const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
752
+ return sanitizeJsonValue(JSON.parse(normalized), 0);
753
+ }
754
+ };
755
+ var MAX_SANITIZE_DEPTH = 200;
756
+ function sanitizeJsonValue(value, depth) {
757
+ if (depth > MAX_SANITIZE_DEPTH) {
758
+ return value;
759
+ }
760
+ if (Array.isArray(value)) {
761
+ return value.map((entry) => sanitizeJsonValue(entry, depth + 1));
762
+ }
763
+ if (!isPlainObject(value)) {
764
+ return value;
765
+ }
766
+ const sanitized = {};
767
+ for (const [key, entry] of Object.entries(value)) {
768
+ if (DANGEROUS_JSON_KEYS.has(key)) {
769
+ continue;
770
+ }
771
+ sanitized[key] = sanitizeJsonValue(entry, depth + 1);
772
+ }
773
+ return sanitized;
774
+ }
775
+ function isPlainObject(value) {
776
+ return Object.prototype.toString.call(value) === "[object Object]";
777
+ }
778
+
636
779
  // src/stampede/StampedeGuard.ts
637
780
  import { Mutex as Mutex2 } from "async-mutex";
638
781
  var StampedeGuard = class {
@@ -643,7 +786,8 @@ var StampedeGuard = class {
643
786
  return await entry.mutex.runExclusive(task);
644
787
  } finally {
645
788
  entry.references -= 1;
646
- if (entry.references === 0 && !entry.mutex.isLocked()) {
789
+ const current = this.mutexes.get(key);
790
+ if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
647
791
  this.mutexes.delete(key);
648
792
  }
649
793
  }
@@ -673,8 +817,10 @@ var CacheMissError = class extends Error {
673
817
  var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
674
818
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
675
819
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
820
+ var DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 3e4;
676
821
  var MAX_CACHE_KEY_LENGTH = 1024;
677
822
  var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
823
+ var DANGEROUS_OBJECT_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
678
824
  var DebugLogger = class {
679
825
  enabled;
680
826
  constructor(enabled) {
@@ -721,6 +867,21 @@ var CacheStack = class extends EventEmitter {
721
867
  const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
722
868
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
723
869
  this.tagIndex = options.tagIndex ?? new TagIndex();
870
+ if (!options.tagIndex && layers.some((layer) => layer.isLocal === false)) {
871
+ this.logger.warn?.(
872
+ "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."
873
+ );
874
+ }
875
+ if (!options.tagIndex && layers.some((layer) => layer.isLocal === false && !layer.keys)) {
876
+ this.logger.warn?.(
877
+ "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."
878
+ );
879
+ }
880
+ if (options.invalidationBus && options.broadcastL1Invalidation === void 0 && options.publishSetInvalidation === void 0) {
881
+ this.logger.warn?.(
882
+ "broadcastL1Invalidation defaults to false when an invalidation bus is configured; opt in explicitly if write-triggered L1 invalidation is desired."
883
+ );
884
+ }
724
885
  this.initializeWriteBehind(options.writeBehind);
725
886
  this.startup = this.initialize();
726
887
  }
@@ -734,6 +895,7 @@ var CacheStack = class extends EventEmitter {
734
895
  logger;
735
896
  tagIndex;
736
897
  fetchRateLimiter = new FetchRateLimiter();
898
+ snapshotSerializer = new JsonSerializer();
737
899
  backgroundRefreshes = /* @__PURE__ */ new Map();
738
900
  layerDegradedUntil = /* @__PURE__ */ new Map();
739
901
  ttlResolver;
@@ -742,6 +904,7 @@ var CacheStack = class extends EventEmitter {
742
904
  writeBehindQueue = [];
743
905
  writeBehindTimer;
744
906
  writeBehindFlushPromise;
907
+ generationCleanupPromise;
745
908
  isDisconnecting = false;
746
909
  disconnectPromise;
747
910
  /**
@@ -754,6 +917,9 @@ var CacheStack = class extends EventEmitter {
754
917
  const normalizedKey = this.qualifyKey(this.validateCacheKey(key));
755
918
  this.validateWriteOptions(options);
756
919
  await this.awaitStartup("get");
920
+ return this.getPrepared(normalizedKey, fetcher, options);
921
+ }
922
+ async getPrepared(normalizedKey, fetcher, options) {
757
923
  const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
758
924
  if (hit.found) {
759
925
  this.ttlResolver.recordAccess(normalizedKey);
@@ -831,6 +997,7 @@ var CacheStack = class extends EventEmitter {
831
997
  return true;
832
998
  }
833
999
  } catch {
1000
+ await this.reportRecoverableLayerFailure(layer, "has", new Error(`has() failed for layer "${layer.name}"`));
834
1001
  }
835
1002
  } else {
836
1003
  try {
@@ -838,7 +1005,8 @@ var CacheStack = class extends EventEmitter {
838
1005
  if (value !== null) {
839
1006
  return true;
840
1007
  }
841
- } catch {
1008
+ } catch (error) {
1009
+ await this.reportRecoverableLayerFailure(layer, "has", error);
842
1010
  }
843
1011
  }
844
1012
  }
@@ -930,13 +1098,14 @@ var CacheStack = class extends EventEmitter {
930
1098
  normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
931
1099
  const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
932
1100
  if (!canFastPath) {
1101
+ await this.awaitStartup("mget");
933
1102
  const pendingReads = /* @__PURE__ */ new Map();
934
1103
  return Promise.all(
935
1104
  normalizedEntries.map((entry) => {
936
1105
  const optionsSignature = this.serializeOptions(entry.options);
937
1106
  const existing = pendingReads.get(entry.key);
938
1107
  if (!existing) {
939
- const promise = this.get(entry.key, entry.fetch, entry.options);
1108
+ const promise = this.getPrepared(entry.key, entry.fetch, entry.options);
940
1109
  pendingReads.set(entry.key, {
941
1110
  promise,
942
1111
  fetch: entry.fetch,
@@ -1075,14 +1244,14 @@ var CacheStack = class extends EventEmitter {
1075
1244
  }
1076
1245
  async invalidateByPattern(pattern) {
1077
1246
  await this.awaitStartup("invalidateByPattern");
1078
- const keys = await this.tagIndex.matchPattern(this.qualifyPattern(pattern));
1247
+ const keys = await this.collectKeysMatchingPattern(this.qualifyPattern(pattern));
1079
1248
  await this.deleteKeys(keys);
1080
1249
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1081
1250
  }
1082
1251
  async invalidateByPrefix(prefix) {
1083
1252
  await this.awaitStartup("invalidateByPrefix");
1084
1253
  const qualifiedPrefix = this.qualifyKey(this.validateCacheKey(prefix));
1085
- const keys = this.tagIndex.keysForPrefix ? await this.tagIndex.keysForPrefix(qualifiedPrefix) : await this.tagIndex.matchPattern(`${qualifiedPrefix}*`);
1254
+ const keys = await this.collectKeysWithPrefix(qualifiedPrefix);
1086
1255
  await this.deleteKeys(keys);
1087
1256
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1088
1257
  }
@@ -1132,9 +1301,18 @@ var CacheStack = class extends EventEmitter {
1132
1301
  })
1133
1302
  );
1134
1303
  }
1304
+ /**
1305
+ * Rotates the active generation prefix used for all future cache keys.
1306
+ * Previous-generation keys remain in the underlying layers until they expire,
1307
+ * unless `generationCleanup` is enabled to prune them in the background.
1308
+ */
1135
1309
  bumpGeneration(nextGeneration) {
1136
1310
  const current = this.currentGeneration ?? 0;
1311
+ const previousGeneration = this.currentGeneration;
1137
1312
  this.currentGeneration = nextGeneration ?? current + 1;
1313
+ if (previousGeneration !== void 0 && previousGeneration !== this.currentGeneration && this.shouldCleanupGenerations()) {
1314
+ this.scheduleGenerationCleanup(previousGeneration);
1315
+ }
1138
1316
  return this.currentGeneration;
1139
1317
  }
1140
1318
  /**
@@ -1218,27 +1396,28 @@ var CacheStack = class extends EventEmitter {
1218
1396
  this.assertActive("persistToFile");
1219
1397
  const snapshot = await this.exportState();
1220
1398
  const { promises: fs2 } = await import("fs");
1221
- await fs2.writeFile(filePath, JSON.stringify(snapshot, null, 2), "utf8");
1399
+ await fs2.writeFile(await this.validateSnapshotFilePath(filePath), JSON.stringify(snapshot, null, 2), "utf8");
1222
1400
  }
1223
1401
  async restoreFromFile(filePath) {
1224
1402
  this.assertActive("restoreFromFile");
1225
1403
  const { promises: fs2 } = await import("fs");
1226
- const raw = await fs2.readFile(filePath, "utf8");
1404
+ const raw = await fs2.readFile(await this.validateSnapshotFilePath(filePath), "utf8");
1227
1405
  let parsed;
1228
1406
  try {
1229
- parsed = JSON.parse(raw, (_key, value) => {
1230
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1231
- return Object.assign(/* @__PURE__ */ Object.create(null), value);
1232
- }
1233
- return value;
1234
- });
1407
+ parsed = JSON.parse(raw);
1235
1408
  } catch (cause) {
1236
1409
  throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
1237
1410
  }
1238
1411
  if (!this.isCacheSnapshotEntries(parsed)) {
1239
1412
  throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
1240
1413
  }
1241
- await this.importState(parsed);
1414
+ await this.importState(
1415
+ parsed.map((entry) => ({
1416
+ key: entry.key,
1417
+ value: this.sanitizeSnapshotValue(entry.value),
1418
+ ttl: entry.ttl
1419
+ }))
1420
+ );
1242
1421
  }
1243
1422
  async disconnect() {
1244
1423
  if (!this.disconnectPromise) {
@@ -1247,6 +1426,7 @@ var CacheStack = class extends EventEmitter {
1247
1426
  await this.startup;
1248
1427
  await this.unsubscribeInvalidation?.();
1249
1428
  await this.flushWriteBehindQueue();
1429
+ await this.generationCleanupPromise;
1250
1430
  await Promise.allSettled([...this.backgroundRefreshes.values()]);
1251
1431
  if (this.writeBehindTimer) {
1252
1432
  clearInterval(this.writeBehindTimer);
@@ -1314,6 +1494,7 @@ var CacheStack = class extends EventEmitter {
1314
1494
  try {
1315
1495
  fetched = await this.fetchRateLimiter.schedule(
1316
1496
  options?.fetcherRateLimit ?? this.options.fetcherRateLimit,
1497
+ { key, fetcher },
1317
1498
  fetcher
1318
1499
  );
1319
1500
  this.circuitBreakerManager.recordSuccess(key);
@@ -1329,8 +1510,14 @@ var CacheStack = class extends EventEmitter {
1329
1510
  await this.storeEntry(key, "empty", null, options);
1330
1511
  return null;
1331
1512
  }
1332
- if (options?.shouldCache && !options.shouldCache(fetched)) {
1333
- return fetched;
1513
+ if (options?.shouldCache) {
1514
+ try {
1515
+ if (!options.shouldCache(fetched)) {
1516
+ return fetched;
1517
+ }
1518
+ } catch (error) {
1519
+ this.logger.warn?.("shouldCache-error", { key, error: this.formatError(error) });
1520
+ }
1334
1521
  }
1335
1522
  await this.storeEntry(key, "value", fetched, options);
1336
1523
  return fetched;
@@ -1557,7 +1744,7 @@ var CacheStack = class extends EventEmitter {
1557
1744
  const refresh = (async () => {
1558
1745
  this.metricsCollector.increment("refreshes");
1559
1746
  try {
1560
- await this.fetchWithGuards(key, fetcher, options);
1747
+ await this.runBackgroundRefresh(key, fetcher, options);
1561
1748
  } catch (error) {
1562
1749
  this.metricsCollector.increment("refreshErrors");
1563
1750
  this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
@@ -1567,11 +1754,22 @@ var CacheStack = class extends EventEmitter {
1567
1754
  })();
1568
1755
  this.backgroundRefreshes.set(key, refresh);
1569
1756
  }
1757
+ async runBackgroundRefresh(key, fetcher, options) {
1758
+ const timeoutMs = this.options.backgroundRefreshTimeoutMs ?? DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS;
1759
+ await this.fetchWithGuards(
1760
+ key,
1761
+ () => this.withTimeout(fetcher(), timeoutMs, () => {
1762
+ return new Error(`Background refresh timed out after ${timeoutMs}ms for key "${key}".`);
1763
+ }),
1764
+ options
1765
+ );
1766
+ }
1570
1767
  resolveSingleFlightOptions() {
1571
1768
  return {
1572
1769
  leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
1573
1770
  waitTimeoutMs: this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS,
1574
- pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS
1771
+ pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS,
1772
+ renewIntervalMs: this.options.singleFlightRenewIntervalMs
1575
1773
  };
1576
1774
  }
1577
1775
  async deleteKeys(keys) {
@@ -1631,10 +1829,122 @@ var CacheStack = class extends EventEmitter {
1631
1829
  return String(error);
1632
1830
  }
1633
1831
  sleep(ms) {
1634
- return new Promise((resolve) => setTimeout(resolve, ms));
1832
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
1833
+ }
1834
+ async withTimeout(promise, timeoutMs, onTimeout) {
1835
+ if (timeoutMs <= 0) {
1836
+ return promise;
1837
+ }
1838
+ let timer;
1839
+ const observedPromise = promise.then(
1840
+ (value) => ({ kind: "value", value }),
1841
+ (error) => ({ kind: "error", error })
1842
+ );
1843
+ try {
1844
+ const result = await Promise.race([
1845
+ observedPromise,
1846
+ new Promise((_, reject) => {
1847
+ timer = setTimeout(() => reject(onTimeout()), timeoutMs);
1848
+ timer.unref?.();
1849
+ })
1850
+ ]);
1851
+ if (result && typeof result === "object" && "kind" in result) {
1852
+ if (result.kind === "error") {
1853
+ throw result.error;
1854
+ }
1855
+ return result.value;
1856
+ }
1857
+ return result;
1858
+ } finally {
1859
+ if (timer) {
1860
+ clearTimeout(timer);
1861
+ }
1862
+ }
1635
1863
  }
1636
1864
  shouldBroadcastL1Invalidation() {
1637
- return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? true;
1865
+ return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false;
1866
+ }
1867
+ async collectKeysWithPrefix(prefix) {
1868
+ const matches = new Set(
1869
+ this.tagIndex.keysForPrefix ? await this.tagIndex.keysForPrefix(prefix) : await this.tagIndex.matchPattern(`${prefix}*`)
1870
+ );
1871
+ await Promise.all(
1872
+ this.layers.map(async (layer) => {
1873
+ if (!layer.keys || this.shouldSkipLayer(layer)) {
1874
+ return;
1875
+ }
1876
+ try {
1877
+ const keys = await layer.keys();
1878
+ for (const key of keys) {
1879
+ if (key.startsWith(prefix)) {
1880
+ matches.add(key);
1881
+ }
1882
+ }
1883
+ } catch (error) {
1884
+ await this.handleLayerFailure(layer, "invalidate-prefix-scan", error);
1885
+ }
1886
+ })
1887
+ );
1888
+ return [...matches];
1889
+ }
1890
+ async collectKeysMatchingPattern(pattern) {
1891
+ const matches = new Set(await this.tagIndex.matchPattern(pattern));
1892
+ await Promise.all(
1893
+ this.layers.map(async (layer) => {
1894
+ if (!layer.keys || this.shouldSkipLayer(layer)) {
1895
+ return;
1896
+ }
1897
+ try {
1898
+ const keys = await layer.keys();
1899
+ for (const key of keys) {
1900
+ if (PatternMatcher.matches(pattern, key)) {
1901
+ matches.add(key);
1902
+ }
1903
+ }
1904
+ } catch (error) {
1905
+ await this.handleLayerFailure(layer, "invalidate-pattern-scan", error);
1906
+ }
1907
+ })
1908
+ );
1909
+ return [...matches];
1910
+ }
1911
+ shouldCleanupGenerations() {
1912
+ return Boolean(this.options.generationCleanup);
1913
+ }
1914
+ generationCleanupBatchSize() {
1915
+ const configured = typeof this.options.generationCleanup === "object" ? this.options.generationCleanup.batchSize : void 0;
1916
+ return configured ?? 500;
1917
+ }
1918
+ scheduleGenerationCleanup(generation) {
1919
+ const task = (this.generationCleanupPromise ?? Promise.resolve()).then(() => this.cleanupGeneration(generation)).catch((error) => {
1920
+ this.logger.warn?.("generation-cleanup-error", {
1921
+ generation,
1922
+ error: this.formatError(error)
1923
+ });
1924
+ });
1925
+ this.generationCleanupPromise = task.finally(() => {
1926
+ if (this.generationCleanupPromise === task) {
1927
+ this.generationCleanupPromise = void 0;
1928
+ }
1929
+ });
1930
+ }
1931
+ async cleanupGeneration(generation) {
1932
+ const prefix = `v${generation}:`;
1933
+ const keys = await this.collectKeysWithPrefix(prefix);
1934
+ if (keys.length === 0) {
1935
+ return;
1936
+ }
1937
+ const batchSize = this.generationCleanupBatchSize();
1938
+ for (let index = 0; index < keys.length; index += batchSize) {
1939
+ const batch = keys.slice(index, index + batchSize);
1940
+ await this.deleteKeys(batch);
1941
+ await this.publishInvalidation({
1942
+ scope: "keys",
1943
+ keys: batch,
1944
+ sourceId: this.instanceId,
1945
+ operation: "invalidate"
1946
+ });
1947
+ }
1638
1948
  }
1639
1949
  initializeWriteBehind(options) {
1640
1950
  if (this.options.writeStrategy !== "write-behind") {
@@ -1672,7 +1982,17 @@ var CacheStack = class extends EventEmitter {
1672
1982
  const batchSize = this.options.writeBehind?.batchSize ?? 100;
1673
1983
  const batch = this.writeBehindQueue.splice(0, batchSize);
1674
1984
  this.writeBehindFlushPromise = (async () => {
1675
- await Promise.allSettled(batch.map((operation) => operation()));
1985
+ const results = await Promise.allSettled(batch.map((operation) => operation()));
1986
+ const failures = results.filter((result) => result.status === "rejected");
1987
+ if (failures.length > 0) {
1988
+ this.metricsCollector.increment("writeFailures", failures.length);
1989
+ this.logger.error?.("write-behind-flush-failure", {
1990
+ failed: failures.length,
1991
+ total: batch.length,
1992
+ errors: failures.map((failure) => this.formatError(failure.reason))
1993
+ });
1994
+ this.emitError("write-behind", { failed: failures.length, total: batch.length });
1995
+ }
1676
1996
  })();
1677
1997
  await this.writeBehindFlushPromise;
1678
1998
  this.writeBehindFlushPromise = void 0;
@@ -1776,8 +2096,14 @@ var CacheStack = class extends EventEmitter {
1776
2096
  this.validatePositiveNumber("singleFlightLeaseMs", this.options.singleFlightLeaseMs);
1777
2097
  this.validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
1778
2098
  this.validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
2099
+ this.validatePositiveNumber("singleFlightRenewIntervalMs", this.options.singleFlightRenewIntervalMs);
2100
+ this.validatePositiveNumber("backgroundRefreshTimeoutMs", this.options.backgroundRefreshTimeoutMs);
2101
+ this.validateRateLimitOptions("fetcherRateLimit", this.options.fetcherRateLimit);
1779
2102
  this.validateAdaptiveTtlOptions(this.options.adaptiveTtl);
1780
2103
  this.validateCircuitBreakerOptions(this.options.circuitBreaker);
2104
+ if (typeof this.options.generationCleanup === "object") {
2105
+ this.validatePositiveNumber("generationCleanup.batchSize", this.options.generationCleanup.batchSize);
2106
+ }
1781
2107
  if (this.options.generation !== void 0) {
1782
2108
  this.validateNonNegativeNumber("generation", this.options.generation);
1783
2109
  }
@@ -1795,6 +2121,7 @@ var CacheStack = class extends EventEmitter {
1795
2121
  this.validateTtlPolicy("options.ttlPolicy", options.ttlPolicy);
1796
2122
  this.validateAdaptiveTtlOptions(options.adaptiveTtl);
1797
2123
  this.validateCircuitBreakerOptions(options.circuitBreaker);
2124
+ this.validateRateLimitOptions("options.fetcherRateLimit", options.fetcherRateLimit);
1798
2125
  }
1799
2126
  validateLayerNumberOption(name, value) {
1800
2127
  if (value === void 0) {
@@ -1819,6 +2146,20 @@ var CacheStack = class extends EventEmitter {
1819
2146
  throw new Error(`${name} must be a positive finite number.`);
1820
2147
  }
1821
2148
  }
2149
+ validateRateLimitOptions(name, options) {
2150
+ if (!options) {
2151
+ return;
2152
+ }
2153
+ this.validatePositiveNumber(`${name}.maxConcurrent`, options.maxConcurrent);
2154
+ this.validatePositiveNumber(`${name}.intervalMs`, options.intervalMs);
2155
+ this.validatePositiveNumber(`${name}.maxPerInterval`, options.maxPerInterval);
2156
+ if (options.scope && !["global", "key", "fetcher"].includes(options.scope)) {
2157
+ throw new Error(`${name}.scope must be one of "global", "key", or "fetcher".`);
2158
+ }
2159
+ if (options.bucketKey !== void 0 && options.bucketKey.length === 0) {
2160
+ throw new Error(`${name}.bucketKey must not be empty.`);
2161
+ }
2162
+ }
1822
2163
  validateNonNegativeNumber(name, value) {
1823
2164
  if (!Number.isFinite(value) || value < 0) {
1824
2165
  throw new Error(`${name} must be a non-negative finite number.`);
@@ -1834,6 +2175,9 @@ var CacheStack = class extends EventEmitter {
1834
2175
  if (/[\u0000-\u001F\u007F]/.test(key)) {
1835
2176
  throw new Error("Cache key contains unsupported control characters.");
1836
2177
  }
2178
+ if (/[\uD800-\uDFFF]/.test(key)) {
2179
+ throw new Error("Cache key contains unsupported surrogate code points.");
2180
+ }
1837
2181
  return key;
1838
2182
  }
1839
2183
  validateTtlPolicy(name, policy) {
@@ -1911,6 +2255,14 @@ var CacheStack = class extends EventEmitter {
1911
2255
  this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
1912
2256
  return null;
1913
2257
  }
2258
+ async reportRecoverableLayerFailure(layer, operation, error) {
2259
+ if (this.isGracefulDegradationEnabled()) {
2260
+ await this.handleLayerFailure(layer, operation, error);
2261
+ return;
2262
+ }
2263
+ this.logger.warn?.("layer-operation-failed", { layer: layer.name, operation, error: this.formatError(error) });
2264
+ this.emitError(operation, { layer: layer.name, degraded: false, error: this.formatError(error) });
2265
+ }
1914
2266
  isGracefulDegradationEnabled() {
1915
2267
  return Boolean(this.options.gracefulDegradation);
1916
2268
  }
@@ -1934,10 +2286,16 @@ var CacheStack = class extends EventEmitter {
1934
2286
  }
1935
2287
  }
1936
2288
  serializeKeyPart(value) {
1937
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
1938
- return String(value);
2289
+ if (typeof value === "string") {
2290
+ return `s:${value}`;
2291
+ }
2292
+ if (typeof value === "number") {
2293
+ return `n:${value}`;
2294
+ }
2295
+ if (typeof value === "boolean") {
2296
+ return `b:${value}`;
1939
2297
  }
1940
- return JSON.stringify(this.normalizeForSerialization(value));
2298
+ return `j:${JSON.stringify(this.normalizeForSerialization(value))}`;
1941
2299
  }
1942
2300
  isCacheSnapshotEntries(value) {
1943
2301
  return Array.isArray(value) && value.every((entry) => {
@@ -1945,15 +2303,39 @@ var CacheStack = class extends EventEmitter {
1945
2303
  return false;
1946
2304
  }
1947
2305
  const candidate = entry;
1948
- return typeof candidate.key === "string";
2306
+ return typeof candidate.key === "string" && (candidate.ttl === void 0 || typeof candidate.ttl === "number" && Number.isFinite(candidate.ttl) && candidate.ttl >= 0);
1949
2307
  });
1950
2308
  }
2309
+ sanitizeSnapshotValue(value) {
2310
+ return this.snapshotSerializer.deserialize(this.snapshotSerializer.serialize(value));
2311
+ }
2312
+ async validateSnapshotFilePath(filePath) {
2313
+ if (filePath.length === 0) {
2314
+ throw new Error("filePath must not be empty.");
2315
+ }
2316
+ if (filePath.includes("\0")) {
2317
+ throw new Error("filePath must not contain null bytes.");
2318
+ }
2319
+ const path = await import("path");
2320
+ const resolved = path.resolve(filePath);
2321
+ const baseDir = this.options.snapshotBaseDir === false ? false : path.resolve(this.options.snapshotBaseDir ?? process.cwd());
2322
+ if (baseDir !== false) {
2323
+ const relative = path.relative(baseDir, resolved);
2324
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
2325
+ throw new Error(`filePath is outside the allowed snapshot directory: ${baseDir}`);
2326
+ }
2327
+ }
2328
+ return resolved;
2329
+ }
1951
2330
  normalizeForSerialization(value) {
1952
2331
  if (Array.isArray(value)) {
1953
2332
  return value.map((entry) => this.normalizeForSerialization(entry));
1954
2333
  }
1955
2334
  if (value && typeof value === "object") {
1956
2335
  return Object.keys(value).sort().reduce((normalized, key) => {
2336
+ if (DANGEROUS_OBJECT_KEYS.has(key)) {
2337
+ return normalized;
2338
+ }
1957
2339
  normalized[key] = this.normalizeForSerialization(value[key]);
1958
2340
  return normalized;
1959
2341
  }, {});
@@ -2080,7 +2462,7 @@ function createCachedMethodDecorator(options) {
2080
2462
  function createFastifyLayercachePlugin(cache, options = {}) {
2081
2463
  return async (fastify) => {
2082
2464
  fastify.decorate("cache", cache);
2083
- if (options.exposeStatsRoute !== false && fastify.get) {
2465
+ if (options.exposeStatsRoute === true && fastify.get) {
2084
2466
  fastify.get(options.statsPath ?? "/cache/stats", async () => cache.getStats());
2085
2467
  }
2086
2468
  };
@@ -2096,7 +2478,8 @@ function createExpressCacheMiddleware(cache, options = {}) {
2096
2478
  next();
2097
2479
  return;
2098
2480
  }
2099
- const key = options.keyResolver ? options.keyResolver(req) : `${method}:${req.originalUrl ?? req.url ?? "/"}`;
2481
+ const rawUrl = req.originalUrl ?? req.url ?? "/";
2482
+ const key = options.keyResolver ? options.keyResolver(req) : `${method}:${normalizeUrl(rawUrl)}`;
2100
2483
  const cached = await cache.get(key, void 0, options);
2101
2484
  if (cached !== null) {
2102
2485
  res.setHeader?.("content-type", "application/json; charset=utf-8");
@@ -2112,7 +2495,12 @@ function createExpressCacheMiddleware(cache, options = {}) {
2112
2495
  if (originalJson) {
2113
2496
  res.json = (body) => {
2114
2497
  res.setHeader?.("x-cache", "MISS");
2115
- void cache.set(key, body, options);
2498
+ cache.set(key, body, options).catch((err) => {
2499
+ cache.emit("error", {
2500
+ operation: "set",
2501
+ error: err instanceof Error ? err.message : String(err)
2502
+ });
2503
+ });
2116
2504
  return originalJson(body);
2117
2505
  };
2118
2506
  }
@@ -2122,6 +2510,15 @@ function createExpressCacheMiddleware(cache, options = {}) {
2122
2510
  }
2123
2511
  };
2124
2512
  }
2513
+ function normalizeUrl(url) {
2514
+ try {
2515
+ const parsed = new URL(url, "http://localhost");
2516
+ parsed.searchParams.sort();
2517
+ return decodeURIComponent(parsed.pathname) + parsed.search;
2518
+ } catch {
2519
+ return url;
2520
+ }
2521
+ }
2125
2522
 
2126
2523
  // src/integrations/graphql.ts
2127
2524
  function cacheGraphqlResolver(cache, prefix, resolver, options = {}) {
@@ -2222,19 +2619,6 @@ function createTrpcCacheMiddleware(cache, prefix, options = {}) {
2222
2619
  // src/layers/RedisLayer.ts
2223
2620
  import { promisify } from "util";
2224
2621
  import { brotliCompress, brotliDecompress, gunzip, gzip } from "zlib";
2225
-
2226
- // src/serialization/JsonSerializer.ts
2227
- var JsonSerializer = class {
2228
- serialize(value) {
2229
- return JSON.stringify(value);
2230
- }
2231
- deserialize(payload) {
2232
- const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
2233
- return JSON.parse(normalized);
2234
- }
2235
- };
2236
-
2237
- // src/layers/RedisLayer.ts
2238
2622
  var BATCH_DELETE_SIZE = 500;
2239
2623
  var gzipAsync = promisify(gzip);
2240
2624
  var gunzipAsync = promisify(gunzip);
@@ -2251,6 +2635,7 @@ var RedisLayer = class {
2251
2635
  scanCount;
2252
2636
  compression;
2253
2637
  compressionThreshold;
2638
+ decompressionMaxBytes;
2254
2639
  disconnectOnDispose;
2255
2640
  constructor(options) {
2256
2641
  this.client = options.client;
@@ -2262,6 +2647,7 @@ var RedisLayer = class {
2262
2647
  this.scanCount = options.scanCount ?? 100;
2263
2648
  this.compression = options.compression;
2264
2649
  this.compressionThreshold = options.compressionThreshold ?? 1024;
2650
+ this.decompressionMaxBytes = options.decompressionMaxBytes ?? 64 * 1024 * 1024;
2265
2651
  this.disconnectOnDispose = options.disconnectOnDispose ?? false;
2266
2652
  }
2267
2653
  async get(key) {
@@ -2461,16 +2847,29 @@ var RedisLayer = class {
2461
2847
  }
2462
2848
  /**
2463
2849
  * Decompresses the payload asynchronously if a compression header is present.
2850
+ * Enforces a maximum decompressed size to prevent decompression bomb attacks.
2464
2851
  */
2465
2852
  async decodePayload(payload) {
2466
2853
  if (!Buffer.isBuffer(payload)) {
2467
2854
  return payload;
2468
2855
  }
2469
2856
  if (payload.subarray(0, 10).toString() === "LCZ1:gzip:") {
2470
- return gunzipAsync(payload.subarray(10));
2857
+ const decompressed = await gunzipAsync(payload.subarray(10));
2858
+ if (decompressed.byteLength > this.decompressionMaxBytes) {
2859
+ throw new Error(
2860
+ `Decompressed payload (${decompressed.byteLength} bytes) exceeds decompressionMaxBytes limit (${this.decompressionMaxBytes} bytes).`
2861
+ );
2862
+ }
2863
+ return decompressed;
2471
2864
  }
2472
2865
  if (payload.subarray(0, 12).toString() === "LCZ1:brotli:") {
2473
- return brotliDecompressAsync(payload.subarray(12));
2866
+ const decompressed = await brotliDecompressAsync(payload.subarray(12));
2867
+ if (decompressed.byteLength > this.decompressionMaxBytes) {
2868
+ throw new Error(
2869
+ `Decompressed payload (${decompressed.byteLength} bytes) exceeds decompressionMaxBytes limit (${this.decompressionMaxBytes} bytes).`
2870
+ );
2871
+ }
2872
+ return decompressed;
2474
2873
  }
2475
2874
  return payload;
2476
2875
  }
@@ -2479,7 +2878,7 @@ var RedisLayer = class {
2479
2878
  // src/layers/DiskLayer.ts
2480
2879
  import { createHash } from "crypto";
2481
2880
  import { promises as fs } from "fs";
2482
- import { join } from "path";
2881
+ import { join, resolve } from "path";
2483
2882
  var DiskLayer = class {
2484
2883
  name;
2485
2884
  defaultTtl;
@@ -2489,11 +2888,11 @@ var DiskLayer = class {
2489
2888
  maxFiles;
2490
2889
  writeQueue = Promise.resolve();
2491
2890
  constructor(options) {
2492
- this.directory = options.directory;
2891
+ this.directory = this.resolveDirectory(options.directory);
2493
2892
  this.defaultTtl = options.ttl;
2494
2893
  this.name = options.name ?? "disk";
2495
2894
  this.serializer = options.serializer ?? new JsonSerializer();
2496
- this.maxFiles = options.maxFiles;
2895
+ this.maxFiles = this.normalizeMaxFiles(options.maxFiles);
2497
2896
  }
2498
2897
  async get(key) {
2499
2898
  return unwrapStoredValue(await this.getEntry(key));
@@ -2508,7 +2907,7 @@ var DiskLayer = class {
2508
2907
  }
2509
2908
  let entry;
2510
2909
  try {
2511
- entry = this.serializer.deserialize(raw);
2910
+ entry = this.deserializeEntry(raw);
2512
2911
  } catch {
2513
2912
  await this.safeDelete(filePath);
2514
2913
  return null;
@@ -2530,8 +2929,13 @@ var DiskLayer = class {
2530
2929
  const payload = this.serializer.serialize(entry);
2531
2930
  const targetPath = this.keyToPath(key);
2532
2931
  const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
2533
- await fs.writeFile(tempPath, payload);
2534
- await fs.rename(tempPath, targetPath);
2932
+ try {
2933
+ await fs.writeFile(tempPath, payload);
2934
+ await fs.rename(tempPath, targetPath);
2935
+ } catch (error) {
2936
+ await this.safeDelete(tempPath);
2937
+ throw error;
2938
+ }
2535
2939
  if (this.maxFiles !== void 0) {
2536
2940
  await this.enforceMaxFiles();
2537
2941
  }
@@ -2541,9 +2945,7 @@ var DiskLayer = class {
2541
2945
  return Promise.all(keys.map((key) => this.getEntry(key)));
2542
2946
  }
2543
2947
  async setMany(entries) {
2544
- for (const entry of entries) {
2545
- await this.set(entry.key, entry.value, entry.ttl);
2546
- }
2948
+ await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.ttl)));
2547
2949
  }
2548
2950
  async has(key) {
2549
2951
  const value = await this.getEntry(key);
@@ -2559,8 +2961,9 @@ var DiskLayer = class {
2559
2961
  }
2560
2962
  let entry;
2561
2963
  try {
2562
- entry = this.serializer.deserialize(raw);
2964
+ entry = this.deserializeEntry(raw);
2563
2965
  } catch {
2966
+ await this.safeDelete(filePath);
2564
2967
  return null;
2565
2968
  }
2566
2969
  if (entry.expiresAt === null) {
@@ -2617,7 +3020,7 @@ var DiskLayer = class {
2617
3020
  }
2618
3021
  let entry;
2619
3022
  try {
2620
- entry = this.serializer.deserialize(raw);
3023
+ entry = this.deserializeEntry(raw);
2621
3024
  } catch {
2622
3025
  await this.safeDelete(filePath);
2623
3026
  return;
@@ -2649,6 +3052,31 @@ var DiskLayer = class {
2649
3052
  const hash = createHash("sha256").update(key).digest("hex");
2650
3053
  return join(this.directory, `${hash}.lc`);
2651
3054
  }
3055
+ resolveDirectory(directory) {
3056
+ if (typeof directory !== "string" || directory.trim().length === 0) {
3057
+ throw new Error("DiskLayer.directory must be a non-empty path.");
3058
+ }
3059
+ if (directory.includes("\0")) {
3060
+ throw new Error("DiskLayer.directory must not contain null bytes.");
3061
+ }
3062
+ return resolve(directory);
3063
+ }
3064
+ normalizeMaxFiles(maxFiles) {
3065
+ if (maxFiles === void 0) {
3066
+ return void 0;
3067
+ }
3068
+ if (!Number.isInteger(maxFiles) || maxFiles <= 0) {
3069
+ throw new Error("DiskLayer.maxFiles must be a positive integer.");
3070
+ }
3071
+ return maxFiles;
3072
+ }
3073
+ deserializeEntry(raw) {
3074
+ const entry = this.serializer.deserialize(raw);
3075
+ if (!isDiskEntry(entry)) {
3076
+ throw new Error("Invalid disk cache entry.");
3077
+ }
3078
+ return entry;
3079
+ }
2652
3080
  async safeDelete(filePath) {
2653
3081
  try {
2654
3082
  await fs.unlink(filePath);
@@ -2693,6 +3121,14 @@ var DiskLayer = class {
2693
3121
  await Promise.all(toEvict.map(({ filePath }) => this.safeDelete(filePath)));
2694
3122
  }
2695
3123
  };
3124
+ function isDiskEntry(value) {
3125
+ if (!value || typeof value !== "object") {
3126
+ return false;
3127
+ }
3128
+ const candidate = value;
3129
+ const validExpiry = candidate.expiresAt === null || typeof candidate.expiresAt === "number";
3130
+ return typeof candidate.key === "string" && validExpiry && "value" in candidate;
3131
+ }
2696
3132
 
2697
3133
  // src/layers/MemcachedLayer.ts
2698
3134
  var MemcachedLayer = class {
@@ -2713,6 +3149,7 @@ var MemcachedLayer = class {
2713
3149
  return unwrapStoredValue(await this.getEntry(key));
2714
3150
  }
2715
3151
  async getEntry(key) {
3152
+ this.validateKey(key);
2716
3153
  const result = await this.client.get(this.withPrefix(key));
2717
3154
  if (!result || result.value === null) {
2718
3155
  return null;
@@ -2727,16 +3164,19 @@ var MemcachedLayer = class {
2727
3164
  return Promise.all(keys.map((key) => this.getEntry(key)));
2728
3165
  }
2729
3166
  async set(key, value, ttl = this.defaultTtl) {
3167
+ this.validateKey(key);
2730
3168
  const payload = this.serializer.serialize(value);
2731
3169
  await this.client.set(this.withPrefix(key), payload, {
2732
3170
  expires: ttl && ttl > 0 ? ttl : void 0
2733
3171
  });
2734
3172
  }
2735
3173
  async has(key) {
3174
+ this.validateKey(key);
2736
3175
  const result = await this.client.get(this.withPrefix(key));
2737
3176
  return result !== null && result.value !== null;
2738
3177
  }
2739
3178
  async delete(key) {
3179
+ this.validateKey(key);
2740
3180
  await this.client.delete(this.withPrefix(key));
2741
3181
  }
2742
3182
  async deleteMany(keys) {
@@ -2750,19 +3190,50 @@ var MemcachedLayer = class {
2750
3190
  withPrefix(key) {
2751
3191
  return `${this.keyPrefix}${key}`;
2752
3192
  }
3193
+ validateKey(key) {
3194
+ const fullKey = this.withPrefix(key);
3195
+ if (Buffer.byteLength(fullKey, "utf8") > 250) {
3196
+ throw new Error(`MemcachedLayer: key exceeds 250-byte Memcached limit: "${fullKey.slice(0, 60)}..."`);
3197
+ }
3198
+ if (/[\s\x00-\x1f\x7f]/.test(fullKey)) {
3199
+ throw new Error(
3200
+ "MemcachedLayer: key contains invalid characters (whitespace or control characters are not allowed)."
3201
+ );
3202
+ }
3203
+ }
2753
3204
  };
2754
3205
 
2755
3206
  // src/serialization/MsgpackSerializer.ts
2756
3207
  import { decode, encode } from "@msgpack/msgpack";
3208
+ var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
2757
3209
  var MsgpackSerializer = class {
2758
3210
  serialize(value) {
2759
3211
  return Buffer.from(encode(value));
2760
3212
  }
2761
3213
  deserialize(payload) {
2762
3214
  const normalized = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
2763
- return decode(normalized);
3215
+ return sanitizeMsgpackValue(decode(normalized));
2764
3216
  }
2765
3217
  };
3218
+ function sanitizeMsgpackValue(value) {
3219
+ if (Array.isArray(value)) {
3220
+ return value.map((entry) => sanitizeMsgpackValue(entry));
3221
+ }
3222
+ if (!isPlainObject2(value)) {
3223
+ return value;
3224
+ }
3225
+ const sanitized = {};
3226
+ for (const [key, entry] of Object.entries(value)) {
3227
+ if (DANGEROUS_KEYS.has(key)) {
3228
+ continue;
3229
+ }
3230
+ sanitized[key] = sanitizeMsgpackValue(entry);
3231
+ }
3232
+ return sanitized;
3233
+ }
3234
+ function isPlainObject2(value) {
3235
+ return Object.prototype.toString.call(value) === "[object Object]";
3236
+ }
2766
3237
 
2767
3238
  // src/singleflight/RedisSingleFlightCoordinator.ts
2768
3239
  import { randomUUID } from "crypto";
@@ -2772,6 +3243,12 @@ if redis.call("get", KEYS[1]) == ARGV[1] then
2772
3243
  end
2773
3244
  return 0
2774
3245
  `;
3246
+ var RENEW_SCRIPT = `
3247
+ if redis.call("get", KEYS[1]) == ARGV[1] then
3248
+ return redis.call("pexpire", KEYS[1], ARGV[2])
3249
+ end
3250
+ return 0
3251
+ `;
2775
3252
  var RedisSingleFlightCoordinator = class {
2776
3253
  client;
2777
3254
  prefix;
@@ -2784,14 +3261,29 @@ var RedisSingleFlightCoordinator = class {
2784
3261
  const token = randomUUID();
2785
3262
  const acquired = await this.client.set(lockKey, token, "PX", options.leaseMs, "NX");
2786
3263
  if (acquired === "OK") {
3264
+ const renewTimer = this.startLeaseRenewal(lockKey, token, options);
2787
3265
  try {
2788
3266
  return await worker();
2789
3267
  } finally {
3268
+ if (renewTimer) {
3269
+ clearInterval(renewTimer);
3270
+ }
2790
3271
  await this.client.eval(RELEASE_SCRIPT, 1, lockKey, token);
2791
3272
  }
2792
3273
  }
2793
3274
  return waiter();
2794
3275
  }
3276
+ startLeaseRenewal(lockKey, token, options) {
3277
+ const renewIntervalMs = options.renewIntervalMs ?? Math.max(100, Math.floor(options.leaseMs / 2));
3278
+ if (renewIntervalMs <= 0 || renewIntervalMs >= options.leaseMs) {
3279
+ return void 0;
3280
+ }
3281
+ const timer = setInterval(() => {
3282
+ void this.client.eval(RENEW_SCRIPT, 1, lockKey, token, String(options.leaseMs)).catch(() => void 0);
3283
+ }, renewIntervalMs);
3284
+ timer.unref?.();
3285
+ return timer;
3286
+ }
2795
3287
  };
2796
3288
 
2797
3289
  // src/metrics/PrometheusExporter.ts
@@ -2870,7 +3362,7 @@ function createPrometheusMetricsExporter(stacks) {
2870
3362
  };
2871
3363
  }
2872
3364
  function sanitizeLabel(value) {
2873
- return value.replace(/["\\\n]/g, "_");
3365
+ return value.replace(/["\\\n\r]/g, "_");
2874
3366
  }
2875
3367
  export {
2876
3368
  CacheMissError,