layercache 1.2.9 → 1.3.1

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
@@ -457,15 +457,14 @@ function createInstanceId() {
457
457
  if (globalThis.crypto?.randomUUID) {
458
458
  return globalThis.crypto.randomUUID();
459
459
  }
460
- const bytes = new Uint8Array(16);
461
460
  if (globalThis.crypto?.getRandomValues) {
461
+ const bytes = new Uint8Array(16);
462
462
  globalThis.crypto.getRandomValues(bytes);
463
- } else {
464
- for (let i = 0; i < bytes.length; i += 1) {
465
- bytes[i] = Math.floor(Math.random() * 256);
466
- }
463
+ return `layercache-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
467
464
  }
468
- return `layercache-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
465
+ throw new Error(
466
+ "layercache requires a cryptographic random source. Neither crypto.randomUUID nor crypto.getRandomValues is available in this runtime."
467
+ );
469
468
  }
470
469
 
471
470
  // src/internal/CacheStackGeneration.ts
@@ -1286,7 +1285,8 @@ var CircuitBreakerManager = class {
1286
1285
  }
1287
1286
  const remainingMs = state.openUntil - now;
1288
1287
  const remainingSecs = Math.ceil(remainingMs / 1e3);
1289
- throw new Error(`Circuit breaker is open for key "${key}" (resets in ${remainingSecs}s).`);
1288
+ const displayKey = key.length > 64 ? `${key.slice(0, 64)}...` : key;
1289
+ throw new Error(`Circuit breaker is open for key "${displayKey}" (resets in ${remainingSecs}s).`);
1290
1290
  }
1291
1291
  recordFailure(key, options) {
1292
1292
  if (!options) {
@@ -1802,7 +1802,14 @@ var JsonSerializer = class {
1802
1802
  }
1803
1803
  deserialize(payload) {
1804
1804
  const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
1805
- return sanitizeStructuredData(JSON.parse(normalized), {
1805
+ let parsed;
1806
+ try {
1807
+ parsed = JSON.parse(normalized);
1808
+ } catch (error) {
1809
+ const message = error instanceof Error ? error.message : String(error);
1810
+ throw new Error(`JsonSerializer: failed to parse JSON payload: ${message}`);
1811
+ }
1812
+ return sanitizeStructuredData(parsed, {
1806
1813
  label: "JSON payload",
1807
1814
  maxDepth: 200,
1808
1815
  maxNodes: 1e4
@@ -1811,29 +1818,69 @@ var JsonSerializer = class {
1811
1818
  };
1812
1819
 
1813
1820
  // src/stampede/StampedeGuard.ts
1814
- import { Mutex as Mutex2 } from "async-mutex";
1815
1821
  var StampedeGuard = class {
1816
- mutexes = /* @__PURE__ */ new Map();
1822
+ inFlight = /* @__PURE__ */ new Map();
1823
+ maxInFlight;
1824
+ entryTimeoutMs;
1825
+ constructor(options = {}) {
1826
+ this.maxInFlight = options.maxInFlight ?? 1e4;
1827
+ this.entryTimeoutMs = options.entryTimeoutMs;
1828
+ }
1817
1829
  async execute(key, task) {
1818
- const entry = this.getMutexEntry(key);
1830
+ const existing = this.inFlight.get(key);
1831
+ if (existing) {
1832
+ existing.references += 1;
1833
+ try {
1834
+ return await existing.promise;
1835
+ } finally {
1836
+ this.releaseEntry(key, existing);
1837
+ }
1838
+ }
1839
+ if (this.inFlight.size >= this.maxInFlight) {
1840
+ throw new Error(
1841
+ `StampedeGuard: in-flight limit of ${this.maxInFlight} exceeded. Rejecting new key to prevent memory exhaustion.`
1842
+ );
1843
+ }
1844
+ const taskPromise = Promise.resolve().then(task);
1845
+ const guardedPromise = this.entryTimeoutMs ? this.withTimeout(key, taskPromise, this.entryTimeoutMs) : taskPromise;
1846
+ const entry = {
1847
+ promise: guardedPromise,
1848
+ references: 1
1849
+ };
1850
+ this.inFlight.set(key, entry);
1819
1851
  try {
1820
- return await entry.mutex.runExclusive(task);
1852
+ return await entry.promise;
1821
1853
  } finally {
1822
- entry.references -= 1;
1823
- const current = this.mutexes.get(key);
1824
- if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
1825
- this.mutexes.delete(key);
1826
- }
1854
+ this.releaseEntry(key, entry);
1827
1855
  }
1828
1856
  }
1829
- getMutexEntry(key) {
1830
- let entry = this.mutexes.get(key);
1831
- if (!entry) {
1832
- entry = { mutex: new Mutex2(), references: 0 };
1833
- this.mutexes.set(key, entry);
1857
+ withTimeout(key, promise, timeoutMs) {
1858
+ return new Promise((resolve2, reject) => {
1859
+ const timer = setTimeout(() => {
1860
+ reject(
1861
+ new Error(
1862
+ `StampedeGuard: task for key "${key.slice(0, 64)}${key.length > 64 ? "..." : ""}" timed out after ${timeoutMs}ms.`
1863
+ )
1864
+ );
1865
+ }, timeoutMs);
1866
+ promise.then(
1867
+ (value) => {
1868
+ clearTimeout(timer);
1869
+ resolve2(value);
1870
+ },
1871
+ (error) => {
1872
+ clearTimeout(timer);
1873
+ reject(error);
1874
+ }
1875
+ );
1876
+ });
1877
+ }
1878
+ releaseEntry(key, entry) {
1879
+ entry.references -= 1;
1880
+ const current = this.inFlight.get(key);
1881
+ if (current === entry && entry.references === 0) {
1882
+ this.inFlight.delete(key);
1834
1883
  }
1835
- entry.references += 1;
1836
- return entry;
1837
1884
  }
1838
1885
  };
1839
1886
 
@@ -1893,6 +1940,10 @@ var CacheStack = class extends EventEmitter {
1893
1940
  const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES;
1894
1941
  this.ttlResolver = new TtlResolver({ maxProfileEntries });
1895
1942
  this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries });
1943
+ this.stampedeGuard = new StampedeGuard({
1944
+ maxInFlight: options.stampedeMaxInFlight,
1945
+ entryTimeoutMs: options.stampedeEntryTimeoutMs
1946
+ });
1896
1947
  this.currentGeneration = options.generation;
1897
1948
  if (options.publishSetInvalidation !== void 0) {
1898
1949
  console.warn(
@@ -1971,7 +2022,7 @@ var CacheStack = class extends EventEmitter {
1971
2022
  }
1972
2023
  layers;
1973
2024
  options;
1974
- stampedeGuard = new StampedeGuard();
2025
+ stampedeGuard;
1975
2026
  metricsCollector = new MetricsCollector();
1976
2027
  instanceId = createInstanceId();
1977
2028
  startup;
@@ -2048,7 +2099,7 @@ var CacheStack = class extends EventEmitter {
2048
2099
  if (!fetcher) {
2049
2100
  return null;
2050
2101
  }
2051
- return this.fetchWithGuards(normalizedKey, fetcher, options);
2102
+ return this.fetchWithGuards(normalizedKey, fetcher, options, void 0, void 0, true);
2052
2103
  }
2053
2104
  /**
2054
2105
  * Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
@@ -2209,7 +2260,8 @@ var CacheStack = class extends EventEmitter {
2209
2260
  return promise;
2210
2261
  }
2211
2262
  if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
2212
- throw new Error(`mget received conflicting entries for key "${entry.key}".`);
2263
+ const displayKey = entry.key.length > 64 ? `${entry.key.slice(0, 64)}...` : entry.key;
2264
+ throw new Error(`mget received conflicting entries for key "${displayKey}".`);
2213
2265
  }
2214
2266
  return existing.promise;
2215
2267
  })
@@ -2539,12 +2591,15 @@ var CacheStack = class extends EventEmitter {
2539
2591
  await this.handleInvalidationMessage(message);
2540
2592
  });
2541
2593
  }
2542
- async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
2594
+ async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, initialMissConfirmed = false) {
2543
2595
  const fetchTask = async () => {
2544
- const secondHit = await this.readFromLayers(key, options, "fresh-only");
2545
- if (secondHit.found) {
2546
- this.metricsCollector.increment("hits");
2547
- return secondHit.value;
2596
+ const shouldRecheckFreshLayers = !(initialMissConfirmed && this.options.singleFlightCoordinator);
2597
+ if (shouldRecheckFreshLayers) {
2598
+ const secondHit = await this.readFromLayers(key, options, "fresh-only");
2599
+ if (secondHit.found) {
2600
+ this.metricsCollector.increment("hits");
2601
+ return secondHit.value;
2602
+ }
2548
2603
  }
2549
2604
  return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch);
2550
2605
  };
@@ -2552,12 +2607,22 @@ var CacheStack = class extends EventEmitter {
2552
2607
  if (!this.options.singleFlightCoordinator) {
2553
2608
  return fetchTask();
2554
2609
  }
2555
- return this.options.singleFlightCoordinator.execute(
2556
- key,
2557
- this.resolveSingleFlightOptions(),
2558
- fetchTask,
2559
- () => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
2560
- );
2610
+ try {
2611
+ return await this.options.singleFlightCoordinator.execute(
2612
+ key,
2613
+ this.resolveSingleFlightOptions(),
2614
+ fetchTask,
2615
+ () => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
2616
+ );
2617
+ } catch (error) {
2618
+ if (!this.isGracefulDegradationEnabled()) {
2619
+ throw error;
2620
+ }
2621
+ this.metricsCollector.increment("degradedOperations");
2622
+ this.logger.warn?.("single-flight-coordinator-degraded", { key, error: this.formatError(error) });
2623
+ this.emitError("single-flight", { key, degraded: true, error: this.formatError(error) });
2624
+ return fetchTask();
2625
+ }
2561
2626
  };
2562
2627
  if (this.options.stampedePrevention === false) {
2563
2628
  return singleFlightTask();
@@ -3248,6 +3313,11 @@ var RedisInvalidationBus = class {
3248
3313
 
3249
3314
  // src/http/createCacheStatsHandler.ts
3250
3315
  function createCacheStatsHandler(cache, options = {}) {
3316
+ if (options.allowPublicAccess === true) {
3317
+ console.warn(
3318
+ "[layercache] WARNING: Stats endpoint is publicly accessible without authentication. Set allowPublicAccess: false (or provide an authorize callback) before deploying to production."
3319
+ );
3320
+ }
3251
3321
  return async (request, response) => {
3252
3322
  response.setHeader?.("content-type", "application/json; charset=utf-8");
3253
3323
  response.setHeader?.("cache-control", "no-store");
@@ -3290,6 +3360,11 @@ function createCachedMethodDecorator(options) {
3290
3360
 
3291
3361
  // src/integrations/fastify.ts
3292
3362
  function createFastifyLayercachePlugin(cache, options = {}) {
3363
+ if (options.exposeStatsRoute === true && options.allowPublicStatsRoute === true) {
3364
+ console.warn(
3365
+ "[layercache] WARNING: Cache stats route is publicly accessible without authentication. Set allowPublicStatsRoute: false (or provide an authorizeStatsRoute callback) before deploying to production."
3366
+ );
3367
+ }
3293
3368
  return async (fastify) => {
3294
3369
  fastify.decorate("cache", cache);
3295
3370
  if (options.exposeStatsRoute === true && fastify.get) {
@@ -3485,6 +3560,7 @@ var RedisLayer = class {
3485
3560
  compression;
3486
3561
  compressionThreshold;
3487
3562
  decompressionMaxBytes;
3563
+ commandTimeoutMs;
3488
3564
  disconnectOnDispose;
3489
3565
  constructor(options) {
3490
3566
  this.client = options.client;
@@ -3497,6 +3573,7 @@ var RedisLayer = class {
3497
3573
  this.compression = options.compression;
3498
3574
  this.compressionThreshold = options.compressionThreshold ?? 1024;
3499
3575
  this.decompressionMaxBytes = options.decompressionMaxBytes ?? 64 * 1024 * 1024;
3576
+ this.commandTimeoutMs = this.normalizeCommandTimeoutMs(options.commandTimeoutMs);
3500
3577
  this.disconnectOnDispose = options.disconnectOnDispose ?? false;
3501
3578
  }
3502
3579
  async get(key) {
@@ -3504,7 +3581,11 @@ var RedisLayer = class {
3504
3581
  return unwrapStoredValue(payload);
3505
3582
  }
3506
3583
  async getEntry(key) {
3507
- const payload = await this.client.getBuffer(this.withPrefix(key));
3584
+ this.validateKey(key);
3585
+ const payload = await this.runCommand(
3586
+ `get(${this.displayKey(key)})`,
3587
+ () => this.client.getBuffer(this.withPrefix(key))
3588
+ );
3508
3589
  if (payload === null) {
3509
3590
  return null;
3510
3591
  }
@@ -3514,11 +3595,14 @@ var RedisLayer = class {
3514
3595
  if (keys.length === 0) {
3515
3596
  return [];
3516
3597
  }
3598
+ for (const key of keys) {
3599
+ this.validateKey(key);
3600
+ }
3517
3601
  const pipeline = this.client.pipeline();
3518
3602
  for (const key of keys) {
3519
3603
  pipeline.getBuffer(this.withPrefix(key));
3520
3604
  }
3521
- const results = await pipeline.exec();
3605
+ const results = await this.runCommand(`mget(${keys.length})`, () => pipeline.exec());
3522
3606
  if (results === null) {
3523
3607
  return keys.map(() => null);
3524
3608
  }
@@ -3536,6 +3620,9 @@ var RedisLayer = class {
3536
3620
  if (entries.length === 0) {
3537
3621
  return;
3538
3622
  }
3623
+ for (const entry of entries) {
3624
+ this.validateKey(entry.key);
3625
+ }
3539
3626
  const pipeline = this.client.pipeline();
3540
3627
  for (const entry of entries) {
3541
3628
  const serialized = this.primarySerializer().serialize(entry.value);
@@ -3547,33 +3634,46 @@ var RedisLayer = class {
3547
3634
  pipeline.set(normalizedKey, payload);
3548
3635
  }
3549
3636
  }
3550
- await pipeline.exec();
3637
+ await this.runCommand(`mset(${entries.length})`, () => pipeline.exec());
3551
3638
  }
3552
3639
  async set(key, value, ttl = this.defaultTtl) {
3640
+ this.validateKey(key);
3553
3641
  const serialized = this.primarySerializer().serialize(value);
3554
3642
  const payload = await this.encodePayload(serialized);
3555
3643
  const normalizedKey = this.withPrefix(key);
3556
3644
  if (ttl && ttl > 0) {
3557
- await this.client.set(normalizedKey, payload, "EX", ttl);
3645
+ await this.runCommand(
3646
+ `set(${this.displayKey(key)})`,
3647
+ () => this.client.set(normalizedKey, payload, "EX", ttl)
3648
+ );
3558
3649
  return;
3559
3650
  }
3560
- await this.client.set(normalizedKey, payload);
3651
+ await this.runCommand(`set(${this.displayKey(key)})`, () => this.client.set(normalizedKey, payload));
3561
3652
  }
3562
3653
  async delete(key) {
3563
- await this.client.del(this.withPrefix(key));
3654
+ this.validateKey(key);
3655
+ await this.runCommand(`delete(${this.displayKey(key)})`, () => this.client.del(this.withPrefix(key)));
3564
3656
  }
3565
3657
  async deleteMany(keys) {
3566
3658
  if (keys.length === 0) {
3567
3659
  return;
3568
3660
  }
3569
- await this.client.del(...keys.map((key) => this.withPrefix(key)));
3661
+ for (const key of keys) {
3662
+ this.validateKey(key);
3663
+ }
3664
+ await this.runCommand(
3665
+ `deleteMany(${keys.length})`,
3666
+ () => this.client.del(...keys.map((key) => this.withPrefix(key)))
3667
+ );
3570
3668
  }
3571
3669
  async has(key) {
3572
- const exists = await this.client.exists(this.withPrefix(key));
3670
+ this.validateKey(key);
3671
+ const exists = await this.runCommand(`has(${this.displayKey(key)})`, () => this.client.exists(this.withPrefix(key)));
3573
3672
  return exists > 0;
3574
3673
  }
3575
3674
  async ttl(key) {
3576
- const remaining = await this.client.ttl(this.withPrefix(key));
3675
+ this.validateKey(key);
3676
+ const remaining = await this.runCommand(`ttl(${this.displayKey(key)})`, () => this.client.ttl(this.withPrefix(key)));
3577
3677
  if (remaining < 0) {
3578
3678
  return null;
3579
3679
  }
@@ -3581,13 +3681,16 @@ var RedisLayer = class {
3581
3681
  }
3582
3682
  async size() {
3583
3683
  if (!this.prefix) {
3584
- return this.client.dbsize();
3684
+ return this.runCommand("dbsize()", () => this.client.dbsize());
3585
3685
  }
3586
3686
  const pattern = `${this.prefix}*`;
3587
3687
  let cursor = "0";
3588
3688
  let count = 0;
3589
3689
  do {
3590
- const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
3690
+ const [nextCursor, keys] = await this.runCommand(
3691
+ `scan("${pattern}")`,
3692
+ () => this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount)
3693
+ );
3591
3694
  cursor = nextCursor;
3592
3695
  count += keys.length;
3593
3696
  } while (cursor !== "0");
@@ -3595,7 +3698,7 @@ var RedisLayer = class {
3595
3698
  }
3596
3699
  async ping() {
3597
3700
  try {
3598
- return await this.client.ping() === "PONG";
3701
+ return await this.runCommand("ping()", () => this.client.ping()) === "PONG";
3599
3702
  } catch {
3600
3703
  return false;
3601
3704
  }
@@ -3618,14 +3721,17 @@ var RedisLayer = class {
3618
3721
  const pattern = `${this.prefix}*`;
3619
3722
  let cursor = "0";
3620
3723
  do {
3621
- const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
3724
+ const [nextCursor, keys] = await this.runCommand(
3725
+ `scan("${pattern}")`,
3726
+ () => this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount)
3727
+ );
3622
3728
  cursor = nextCursor;
3623
3729
  if (keys.length === 0) {
3624
3730
  continue;
3625
3731
  }
3626
3732
  for (let i = 0; i < keys.length; i += BATCH_DELETE_SIZE) {
3627
3733
  const batch = keys.slice(i, i + BATCH_DELETE_SIZE);
3628
- await this.client.del(...batch);
3734
+ await this.runCommand(`clear-del(${batch.length})`, () => this.client.del(...batch));
3629
3735
  }
3630
3736
  } while (cursor !== "0");
3631
3737
  }
@@ -3641,7 +3747,10 @@ var RedisLayer = class {
3641
3747
  const pattern = `${this.prefix}*`;
3642
3748
  let cursor = "0";
3643
3749
  do {
3644
- const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
3750
+ const [nextCursor, keys] = await this.runCommand(
3751
+ `scan("${pattern}")`,
3752
+ () => this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount)
3753
+ );
3645
3754
  cursor = nextCursor;
3646
3755
  for (const key of keys) {
3647
3756
  await visitor(this.prefix ? key.slice(this.prefix.length) : key);
@@ -3652,7 +3761,10 @@ var RedisLayer = class {
3652
3761
  const matches = [];
3653
3762
  let cursor = "0";
3654
3763
  do {
3655
- const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
3764
+ const [nextCursor, keys] = await this.runCommand(
3765
+ `scan("${pattern}")`,
3766
+ () => this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount)
3767
+ );
3656
3768
  cursor = nextCursor;
3657
3769
  matches.push(...keys);
3658
3770
  } while (cursor !== "0");
@@ -3661,6 +3773,23 @@ var RedisLayer = class {
3661
3773
  withPrefix(key) {
3662
3774
  return `${this.prefix}${key}`;
3663
3775
  }
3776
+ validateKey(key) {
3777
+ if (key.length === 0) {
3778
+ throw new Error("RedisLayer: key must not be empty.");
3779
+ }
3780
+ if (key.length > 1024) {
3781
+ throw new Error(`RedisLayer: key length must be at most 1 024 characters (got ${key.length}).`);
3782
+ }
3783
+ if (/[\u0000-\u001F\u007F]/.test(key)) {
3784
+ throw new Error("RedisLayer: key contains unsupported control characters.");
3785
+ }
3786
+ if (/[\uD800-\uDFFF]/.test(key)) {
3787
+ throw new Error("RedisLayer: key contains unsupported surrogate code points.");
3788
+ }
3789
+ }
3790
+ displayKey(key) {
3791
+ return key.length > 64 ? `${key.slice(0, 64)}...` : key;
3792
+ }
3664
3793
  async deserializeOrDelete(key, payload) {
3665
3794
  let decodedPayload;
3666
3795
  try {
@@ -3684,20 +3813,30 @@ var RedisLayer = class {
3684
3813
  }
3685
3814
  async deleteCorruptedKey(key) {
3686
3815
  try {
3687
- await this.client.del(this.withPrefix(key));
3816
+ await this.runCommand(`deleteCorrupted(${this.displayKey(key)})`, () => this.client.del(this.withPrefix(key)));
3688
3817
  } catch (deleteError) {
3689
- console.warn(`[layercache] RedisLayer: failed to delete corrupted key "${key}"`, deleteError);
3818
+ const displayKey = key.length > 64 ? `${key.slice(0, 64)}...` : key;
3819
+ console.warn(`[layercache] RedisLayer: failed to delete corrupted key "${displayKey}"`, deleteError);
3690
3820
  }
3691
3821
  }
3692
3822
  async rewriteWithPrimarySerializer(key, value) {
3693
3823
  const serialized = this.primarySerializer().serialize(value);
3694
3824
  const payload = await this.encodePayload(serialized);
3695
- const ttl = await this.client.ttl(this.withPrefix(key));
3825
+ const ttl = await this.runCommand(
3826
+ `rewrite-ttl(${this.displayKey(key)})`,
3827
+ () => this.client.ttl(this.withPrefix(key))
3828
+ );
3696
3829
  if (ttl > 0) {
3697
- await this.client.set(this.withPrefix(key), payload, "EX", ttl);
3830
+ await this.runCommand(
3831
+ `rewrite-set(${this.displayKey(key)})`,
3832
+ () => this.client.set(this.withPrefix(key), payload, "EX", ttl)
3833
+ );
3698
3834
  return;
3699
3835
  }
3700
- await this.client.set(this.withPrefix(key), payload);
3836
+ await this.runCommand(
3837
+ `rewrite-set(${this.displayKey(key)})`,
3838
+ () => this.client.set(this.withPrefix(key), payload)
3839
+ );
3701
3840
  }
3702
3841
  primarySerializer() {
3703
3842
  const serializer = this.serializers[0];
@@ -3793,12 +3932,163 @@ var RedisLayer = class {
3793
3932
  source.pipe(decompressor);
3794
3933
  });
3795
3934
  }
3935
+ normalizeCommandTimeoutMs(value) {
3936
+ if (value === void 0) {
3937
+ return void 0;
3938
+ }
3939
+ if (!Number.isFinite(value) || value <= 0) {
3940
+ throw new Error("RedisLayer.commandTimeoutMs must be a positive number.");
3941
+ }
3942
+ return value;
3943
+ }
3944
+ async runCommand(operation, command) {
3945
+ const promise = command();
3946
+ if (!this.commandTimeoutMs) {
3947
+ return promise;
3948
+ }
3949
+ let timer;
3950
+ return Promise.race([
3951
+ promise,
3952
+ new Promise((_, reject) => {
3953
+ timer = setTimeout(() => {
3954
+ reject(new Error(`RedisLayer command ${operation} timed out after ${this.commandTimeoutMs}ms.`));
3955
+ }, this.commandTimeoutMs);
3956
+ timer.unref?.();
3957
+ })
3958
+ ]).finally(() => {
3959
+ if (timer) {
3960
+ clearTimeout(timer);
3961
+ }
3962
+ });
3963
+ }
3796
3964
  };
3797
3965
 
3798
3966
  // src/layers/DiskLayer.ts
3799
- import { createHash, randomBytes as randomBytes2 } from "crypto";
3967
+ import { createHash as createHash2, randomBytes as randomBytes3 } from "crypto";
3800
3968
  import { promises as fs2 } from "fs";
3801
3969
  import { join, resolve } from "path";
3970
+
3971
+ // src/internal/PayloadProtection.ts
3972
+ import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes as randomBytes2, timingSafeEqual } from "crypto";
3973
+ var MAGIC_ENCRYPTED = Buffer.from("LCP1:");
3974
+ var MAGIC_SIGNED = Buffer.from("LCS1:");
3975
+ var ALGORITHM = "aes-256-gcm";
3976
+ var IV_LENGTH = 12;
3977
+ var AUTH_TAG_LENGTH = 16;
3978
+ var HMAC_LENGTH = 32;
3979
+ var PayloadProtection = class {
3980
+ encryptionKey;
3981
+ signingKey;
3982
+ constructor(options) {
3983
+ if (options.encryptionKey) {
3984
+ const raw = Buffer.isBuffer(options.encryptionKey) ? options.encryptionKey : Buffer.from(options.encryptionKey, "utf8");
3985
+ this.encryptionKey = createHash("sha256").update(raw).digest();
3986
+ }
3987
+ if (options.signingKey && !options.encryptionKey) {
3988
+ const raw = Buffer.isBuffer(options.signingKey) ? options.signingKey : Buffer.from(options.signingKey, "utf8");
3989
+ this.signingKey = createHash("sha256").update(raw).digest();
3990
+ }
3991
+ }
3992
+ /** Returns `true` when any protection (encryption or signing) is configured. */
3993
+ get isEnabled() {
3994
+ return this.encryptionKey !== void 0 || this.signingKey !== void 0;
3995
+ }
3996
+ /**
3997
+ * Applies the configured protection (encryption or signing) to a payload.
3998
+ * Returns the input unchanged when no protection is configured.
3999
+ */
4000
+ protect(payload) {
4001
+ if (this.encryptionKey) {
4002
+ return this.encrypt(payload, this.encryptionKey);
4003
+ }
4004
+ if (this.signingKey) {
4005
+ return this.sign(payload, this.signingKey);
4006
+ }
4007
+ return payload;
4008
+ }
4009
+ /**
4010
+ * Removes the protection layer from a payload.
4011
+ *
4012
+ * - Protected payloads are decrypted/verified using the configured keys.
4013
+ * - Legacy unprotected payloads pass through unchanged when **no** protection
4014
+ * is configured.
4015
+ * - If protection **is** configured but the payload is not protected, the
4016
+ * payload is treated as a legacy entry. Callers can handle this case by
4017
+ * checking `isEnabled` separately.
4018
+ */
4019
+ unprotect(payload) {
4020
+ if (this.startsWith(payload, MAGIC_ENCRYPTED)) {
4021
+ if (!this.encryptionKey) {
4022
+ throw new PayloadProtectionError("Encrypted payload but no encryptionKey configured.");
4023
+ }
4024
+ return this.decrypt(payload, this.encryptionKey);
4025
+ }
4026
+ if (this.startsWith(payload, MAGIC_SIGNED)) {
4027
+ if (!this.signingKey) {
4028
+ throw new PayloadProtectionError("Signed payload but no signingKey configured.");
4029
+ }
4030
+ return this.verify(payload, this.signingKey);
4031
+ }
4032
+ return payload;
4033
+ }
4034
+ // ── Encryption (AES-256-GCM) ──────────────────────────────────────────
4035
+ encrypt(plaintext, key) {
4036
+ const iv = randomBytes2(IV_LENGTH);
4037
+ const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
4038
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
4039
+ const authTag = cipher.getAuthTag();
4040
+ return Buffer.concat([MAGIC_ENCRYPTED, iv, authTag, encrypted]);
4041
+ }
4042
+ decrypt(payload, key) {
4043
+ const headerEnd = MAGIC_ENCRYPTED.length;
4044
+ const iv = payload.subarray(headerEnd, headerEnd + IV_LENGTH);
4045
+ const authTag = payload.subarray(headerEnd + IV_LENGTH, headerEnd + IV_LENGTH + AUTH_TAG_LENGTH);
4046
+ const ciphertext = payload.subarray(headerEnd + IV_LENGTH + AUTH_TAG_LENGTH);
4047
+ try {
4048
+ const decipher = createDecipheriv(ALGORITHM, key, iv, {
4049
+ authTagLength: AUTH_TAG_LENGTH
4050
+ });
4051
+ decipher.setAuthTag(authTag);
4052
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
4053
+ } catch {
4054
+ throw new PayloadProtectionError(
4055
+ "Decryption failed. The data may have been tampered with or the encryptionKey is incorrect."
4056
+ );
4057
+ }
4058
+ }
4059
+ // ── Signing (HMAC-SHA256) ─────────────────────────────────────────────
4060
+ sign(payload, key) {
4061
+ const hmac = createHmac("sha256", key).update(payload).digest();
4062
+ return Buffer.concat([MAGIC_SIGNED, hmac, payload]);
4063
+ }
4064
+ verify(payload, key) {
4065
+ const headerEnd = MAGIC_SIGNED.length;
4066
+ const receivedHmac = payload.subarray(headerEnd, headerEnd + HMAC_LENGTH);
4067
+ const data = payload.subarray(headerEnd + HMAC_LENGTH);
4068
+ const expectedHmac = createHmac("sha256", key).update(data).digest();
4069
+ if (receivedHmac.length !== HMAC_LENGTH || !timingSafeEqual(receivedHmac, expectedHmac)) {
4070
+ throw new PayloadProtectionError(
4071
+ "HMAC verification failed. The data may have been tampered with or the signingKey is incorrect."
4072
+ );
4073
+ }
4074
+ return data;
4075
+ }
4076
+ // ── Helpers ────────────────────────────────────────────────────────────
4077
+ startsWith(buffer, prefix) {
4078
+ if (buffer.length < prefix.length) {
4079
+ return false;
4080
+ }
4081
+ return buffer.subarray(0, prefix.length).equals(prefix);
4082
+ }
4083
+ };
4084
+ var PayloadProtectionError = class extends Error {
4085
+ constructor(message) {
4086
+ super(message);
4087
+ this.name = "PayloadProtectionError";
4088
+ }
4089
+ };
4090
+
4091
+ // src/layers/DiskLayer.ts
3802
4092
  var FILE_SCAN_CONCURRENCY = 32;
3803
4093
  var DiskLayer = class {
3804
4094
  name;
@@ -3808,6 +4098,7 @@ var DiskLayer = class {
3808
4098
  serializer;
3809
4099
  maxFiles;
3810
4100
  maxEntryBytes;
4101
+ protection;
3811
4102
  writeQueue = Promise.resolve();
3812
4103
  constructor(options) {
3813
4104
  this.directory = this.resolveDirectory(options.directory);
@@ -3816,6 +4107,10 @@ var DiskLayer = class {
3816
4107
  this.serializer = options.serializer ?? new JsonSerializer();
3817
4108
  this.maxFiles = this.normalizeMaxFiles(options.maxFiles);
3818
4109
  this.maxEntryBytes = this.normalizeMaxEntryBytes(options.maxEntryBytes);
4110
+ this.protection = new PayloadProtection({
4111
+ encryptionKey: options.encryptionKey,
4112
+ signingKey: options.signingKey
4113
+ });
3819
4114
  }
3820
4115
  async get(key) {
3821
4116
  return unwrapStoredValue(await this.getEntry(key));
@@ -3848,10 +4143,12 @@ var DiskLayer = class {
3848
4143
  expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
3849
4144
  };
3850
4145
  const payload = this.serializer.serialize(entry);
4146
+ const raw = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, "utf8");
4147
+ const protectedPayload = this.protection.protect(raw);
3851
4148
  const targetPath = this.keyToPath(key);
3852
- const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${randomBytes2(8).toString("hex")}.tmp`;
4149
+ const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${randomBytes3(8).toString("hex")}.tmp`;
3853
4150
  try {
3854
- await fs2.writeFile(tempPath, payload);
4151
+ await fs2.writeFile(tempPath, protectedPayload);
3855
4152
  await fs2.rename(tempPath, targetPath);
3856
4153
  } catch (error) {
3857
4154
  await this.safeDelete(tempPath);
@@ -3949,7 +4246,7 @@ var DiskLayer = class {
3949
4246
  async dispose() {
3950
4247
  }
3951
4248
  keyToPath(key) {
3952
- const hash = createHash("sha256").update(key).digest("hex");
4249
+ const hash = createHash2("sha256").update(key).digest("hex");
3953
4250
  return join(this.directory, `${hash}.lc`);
3954
4251
  }
3955
4252
  resolveDirectory(directory) {
@@ -3963,10 +4260,13 @@ var DiskLayer = class {
3963
4260
  }
3964
4261
  normalizeMaxFiles(maxFiles) {
3965
4262
  if (maxFiles === void 0) {
4263
+ return 5e4;
4264
+ }
4265
+ if (maxFiles === Number.POSITIVE_INFINITY) {
3966
4266
  return void 0;
3967
4267
  }
3968
4268
  if (!Number.isInteger(maxFiles) || maxFiles <= 0) {
3969
- throw new Error("DiskLayer.maxFiles must be a positive integer.");
4269
+ throw new Error("DiskLayer.maxFiles must be a positive integer or Infinity.");
3970
4270
  }
3971
4271
  return maxFiles;
3972
4272
  }
@@ -4078,7 +4378,8 @@ var DiskLayer = class {
4078
4378
  );
4079
4379
  }
4080
4380
  deserializeEntry(raw) {
4081
- const entry = this.serializer.deserialize(raw);
4381
+ const unprotected = this.protection.unprotect(raw);
4382
+ const entry = this.serializer.deserialize(unprotected);
4082
4383
  if (!isDiskEntry(entry)) {
4083
4384
  throw new Error("Invalid disk cache entry.");
4084
4385
  }
@@ -4200,7 +4501,10 @@ var MemcachedLayer = class {
4200
4501
  validateKey(key) {
4201
4502
  const fullKey = this.withPrefix(key);
4202
4503
  if (Buffer.byteLength(fullKey, "utf8") > 250) {
4203
- throw new Error(`MemcachedLayer: key exceeds 250-byte Memcached limit: "${fullKey.slice(0, 60)}..."`);
4504
+ const displayKey = fullKey.slice(0, 64);
4505
+ throw new Error(
4506
+ `MemcachedLayer: key exceeds 250-byte Memcached limit: "${displayKey}${fullKey.length > 64 ? "..." : ""}"`
4507
+ );
4204
4508
  }
4205
4509
  if (/[\s\x00-\x1f\x7f]/.test(fullKey)) {
4206
4510
  throw new Error(
@@ -4243,14 +4547,19 @@ return 0
4243
4547
  var RedisSingleFlightCoordinator = class {
4244
4548
  client;
4245
4549
  prefix;
4550
+ commandTimeoutMs;
4246
4551
  constructor(options) {
4247
4552
  this.client = options.client;
4248
4553
  this.prefix = options.prefix ?? "layercache:singleflight";
4554
+ this.commandTimeoutMs = this.normalizeCommandTimeoutMs(options.commandTimeoutMs);
4249
4555
  }
4250
4556
  async execute(key, options, worker, waiter) {
4251
4557
  const lockKey = `${this.prefix}:${encodeURIComponent(key)}`;
4252
4558
  const token = randomUUID();
4253
- const acquired = await this.client.set(lockKey, token, "PX", options.leaseMs, "NX");
4559
+ const acquired = await this.runCommand(
4560
+ `acquire("${key}")`,
4561
+ () => this.client.set(lockKey, token, "PX", options.leaseMs, "NX")
4562
+ );
4254
4563
  if (acquired === "OK") {
4255
4564
  const renewTimer = this.startLeaseRenewal(lockKey, token, options);
4256
4565
  try {
@@ -4259,7 +4568,7 @@ var RedisSingleFlightCoordinator = class {
4259
4568
  if (renewTimer) {
4260
4569
  clearInterval(renewTimer);
4261
4570
  }
4262
- await this.client.eval(RELEASE_SCRIPT, 1, lockKey, token);
4571
+ await this.runCommand(`release("${key}")`, () => this.client.eval(RELEASE_SCRIPT, 1, lockKey, token));
4263
4572
  }
4264
4573
  }
4265
4574
  return waiter();
@@ -4270,11 +4579,45 @@ var RedisSingleFlightCoordinator = class {
4270
4579
  return void 0;
4271
4580
  }
4272
4581
  const timer = setInterval(() => {
4273
- void this.client.eval(RENEW_SCRIPT, 1, lockKey, token, String(options.leaseMs)).catch(() => void 0);
4582
+ void this.runCommand(
4583
+ `renew("${lockKey}")`,
4584
+ () => this.client.eval(RENEW_SCRIPT, 1, lockKey, token, String(options.leaseMs))
4585
+ ).catch(() => void 0);
4274
4586
  }, renewIntervalMs);
4275
4587
  timer.unref?.();
4276
4588
  return timer;
4277
4589
  }
4590
+ normalizeCommandTimeoutMs(value) {
4591
+ if (value === void 0) {
4592
+ return void 0;
4593
+ }
4594
+ if (!Number.isFinite(value) || value <= 0) {
4595
+ throw new Error("RedisSingleFlightCoordinator.commandTimeoutMs must be a positive number.");
4596
+ }
4597
+ return value;
4598
+ }
4599
+ async runCommand(operation, command) {
4600
+ const promise = command();
4601
+ if (!this.commandTimeoutMs) {
4602
+ return promise;
4603
+ }
4604
+ let timer;
4605
+ return Promise.race([
4606
+ promise,
4607
+ new Promise((_, reject) => {
4608
+ timer = setTimeout(() => {
4609
+ reject(
4610
+ new Error(`RedisSingleFlightCoordinator command ${operation} timed out after ${this.commandTimeoutMs}ms.`)
4611
+ );
4612
+ }, this.commandTimeoutMs);
4613
+ timer.unref?.();
4614
+ })
4615
+ ]).finally(() => {
4616
+ if (timer) {
4617
+ clearTimeout(timer);
4618
+ }
4619
+ });
4620
+ }
4278
4621
  };
4279
4622
 
4280
4623
  // src/metrics/PrometheusExporter.ts