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.cjs CHANGED
@@ -545,15 +545,14 @@ function createInstanceId() {
545
545
  if (globalThis.crypto?.randomUUID) {
546
546
  return globalThis.crypto.randomUUID();
547
547
  }
548
- const bytes = new Uint8Array(16);
549
548
  if (globalThis.crypto?.getRandomValues) {
549
+ const bytes = new Uint8Array(16);
550
550
  globalThis.crypto.getRandomValues(bytes);
551
- } else {
552
- for (let i = 0; i < bytes.length; i += 1) {
553
- bytes[i] = Math.floor(Math.random() * 256);
554
- }
551
+ return `layercache-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
555
552
  }
556
- return `layercache-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
553
+ throw new Error(
554
+ "layercache requires a cryptographic random source. Neither crypto.randomUUID nor crypto.getRandomValues is available in this runtime."
555
+ );
557
556
  }
558
557
 
559
558
  // src/internal/CacheStackGeneration.ts
@@ -1532,7 +1531,8 @@ var CircuitBreakerManager = class {
1532
1531
  }
1533
1532
  const remainingMs = state.openUntil - now;
1534
1533
  const remainingSecs = Math.ceil(remainingMs / 1e3);
1535
- throw new Error(`Circuit breaker is open for key "${key}" (resets in ${remainingSecs}s).`);
1534
+ const displayKey = key.length > 64 ? `${key.slice(0, 64)}...` : key;
1535
+ throw new Error(`Circuit breaker is open for key "${displayKey}" (resets in ${remainingSecs}s).`);
1536
1536
  }
1537
1537
  recordFailure(key, options) {
1538
1538
  if (!options) {
@@ -2297,7 +2297,14 @@ var JsonSerializer = class {
2297
2297
  }
2298
2298
  deserialize(payload) {
2299
2299
  const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
2300
- return sanitizeStructuredData(JSON.parse(normalized), {
2300
+ let parsed;
2301
+ try {
2302
+ parsed = JSON.parse(normalized);
2303
+ } catch (error) {
2304
+ const message = error instanceof Error ? error.message : String(error);
2305
+ throw new Error(`JsonSerializer: failed to parse JSON payload: ${message}`);
2306
+ }
2307
+ return sanitizeStructuredData(parsed, {
2301
2308
  label: "JSON payload",
2302
2309
  maxDepth: 200,
2303
2310
  maxNodes: 1e4
@@ -2306,29 +2313,69 @@ var JsonSerializer = class {
2306
2313
  };
2307
2314
 
2308
2315
  // src/stampede/StampedeGuard.ts
2309
- var import_async_mutex2 = require("async-mutex");
2310
2316
  var StampedeGuard = class {
2311
- mutexes = /* @__PURE__ */ new Map();
2317
+ inFlight = /* @__PURE__ */ new Map();
2318
+ maxInFlight;
2319
+ entryTimeoutMs;
2320
+ constructor(options = {}) {
2321
+ this.maxInFlight = options.maxInFlight ?? 1e4;
2322
+ this.entryTimeoutMs = options.entryTimeoutMs;
2323
+ }
2312
2324
  async execute(key, task) {
2313
- const entry = this.getMutexEntry(key);
2325
+ const existing = this.inFlight.get(key);
2326
+ if (existing) {
2327
+ existing.references += 1;
2328
+ try {
2329
+ return await existing.promise;
2330
+ } finally {
2331
+ this.releaseEntry(key, existing);
2332
+ }
2333
+ }
2334
+ if (this.inFlight.size >= this.maxInFlight) {
2335
+ throw new Error(
2336
+ `StampedeGuard: in-flight limit of ${this.maxInFlight} exceeded. Rejecting new key to prevent memory exhaustion.`
2337
+ );
2338
+ }
2339
+ const taskPromise = Promise.resolve().then(task);
2340
+ const guardedPromise = this.entryTimeoutMs ? this.withTimeout(key, taskPromise, this.entryTimeoutMs) : taskPromise;
2341
+ const entry = {
2342
+ promise: guardedPromise,
2343
+ references: 1
2344
+ };
2345
+ this.inFlight.set(key, entry);
2314
2346
  try {
2315
- return await entry.mutex.runExclusive(task);
2347
+ return await entry.promise;
2316
2348
  } finally {
2317
- entry.references -= 1;
2318
- const current = this.mutexes.get(key);
2319
- if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
2320
- this.mutexes.delete(key);
2321
- }
2349
+ this.releaseEntry(key, entry);
2322
2350
  }
2323
2351
  }
2324
- getMutexEntry(key) {
2325
- let entry = this.mutexes.get(key);
2326
- if (!entry) {
2327
- entry = { mutex: new import_async_mutex2.Mutex(), references: 0 };
2328
- this.mutexes.set(key, entry);
2352
+ withTimeout(key, promise, timeoutMs) {
2353
+ return new Promise((resolve2, reject) => {
2354
+ const timer = setTimeout(() => {
2355
+ reject(
2356
+ new Error(
2357
+ `StampedeGuard: task for key "${key.slice(0, 64)}${key.length > 64 ? "..." : ""}" timed out after ${timeoutMs}ms.`
2358
+ )
2359
+ );
2360
+ }, timeoutMs);
2361
+ promise.then(
2362
+ (value) => {
2363
+ clearTimeout(timer);
2364
+ resolve2(value);
2365
+ },
2366
+ (error) => {
2367
+ clearTimeout(timer);
2368
+ reject(error);
2369
+ }
2370
+ );
2371
+ });
2372
+ }
2373
+ releaseEntry(key, entry) {
2374
+ entry.references -= 1;
2375
+ const current = this.inFlight.get(key);
2376
+ if (current === entry && entry.references === 0) {
2377
+ this.inFlight.delete(key);
2329
2378
  }
2330
- entry.references += 1;
2331
- return entry;
2332
2379
  }
2333
2380
  };
2334
2381
 
@@ -2388,6 +2435,10 @@ var CacheStack = class extends import_node_events.EventEmitter {
2388
2435
  const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES;
2389
2436
  this.ttlResolver = new TtlResolver({ maxProfileEntries });
2390
2437
  this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries });
2438
+ this.stampedeGuard = new StampedeGuard({
2439
+ maxInFlight: options.stampedeMaxInFlight,
2440
+ entryTimeoutMs: options.stampedeEntryTimeoutMs
2441
+ });
2391
2442
  this.currentGeneration = options.generation;
2392
2443
  if (options.publishSetInvalidation !== void 0) {
2393
2444
  console.warn(
@@ -2466,7 +2517,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
2466
2517
  }
2467
2518
  layers;
2468
2519
  options;
2469
- stampedeGuard = new StampedeGuard();
2520
+ stampedeGuard;
2470
2521
  metricsCollector = new MetricsCollector();
2471
2522
  instanceId = createInstanceId();
2472
2523
  startup;
@@ -2543,7 +2594,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
2543
2594
  if (!fetcher) {
2544
2595
  return null;
2545
2596
  }
2546
- return this.fetchWithGuards(normalizedKey, fetcher, options);
2597
+ return this.fetchWithGuards(normalizedKey, fetcher, options, void 0, void 0, true);
2547
2598
  }
2548
2599
  /**
2549
2600
  * Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
@@ -2704,7 +2755,8 @@ var CacheStack = class extends import_node_events.EventEmitter {
2704
2755
  return promise;
2705
2756
  }
2706
2757
  if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
2707
- throw new Error(`mget received conflicting entries for key "${entry.key}".`);
2758
+ const displayKey = entry.key.length > 64 ? `${entry.key.slice(0, 64)}...` : entry.key;
2759
+ throw new Error(`mget received conflicting entries for key "${displayKey}".`);
2708
2760
  }
2709
2761
  return existing.promise;
2710
2762
  })
@@ -3034,12 +3086,15 @@ var CacheStack = class extends import_node_events.EventEmitter {
3034
3086
  await this.handleInvalidationMessage(message);
3035
3087
  });
3036
3088
  }
3037
- async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
3089
+ async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, initialMissConfirmed = false) {
3038
3090
  const fetchTask = async () => {
3039
- const secondHit = await this.readFromLayers(key, options, "fresh-only");
3040
- if (secondHit.found) {
3041
- this.metricsCollector.increment("hits");
3042
- return secondHit.value;
3091
+ const shouldRecheckFreshLayers = !(initialMissConfirmed && this.options.singleFlightCoordinator);
3092
+ if (shouldRecheckFreshLayers) {
3093
+ const secondHit = await this.readFromLayers(key, options, "fresh-only");
3094
+ if (secondHit.found) {
3095
+ this.metricsCollector.increment("hits");
3096
+ return secondHit.value;
3097
+ }
3043
3098
  }
3044
3099
  return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch);
3045
3100
  };
@@ -3047,12 +3102,22 @@ var CacheStack = class extends import_node_events.EventEmitter {
3047
3102
  if (!this.options.singleFlightCoordinator) {
3048
3103
  return fetchTask();
3049
3104
  }
3050
- return this.options.singleFlightCoordinator.execute(
3051
- key,
3052
- this.resolveSingleFlightOptions(),
3053
- fetchTask,
3054
- () => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
3055
- );
3105
+ try {
3106
+ return await this.options.singleFlightCoordinator.execute(
3107
+ key,
3108
+ this.resolveSingleFlightOptions(),
3109
+ fetchTask,
3110
+ () => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
3111
+ );
3112
+ } catch (error) {
3113
+ if (!this.isGracefulDegradationEnabled()) {
3114
+ throw error;
3115
+ }
3116
+ this.metricsCollector.increment("degradedOperations");
3117
+ this.logger.warn?.("single-flight-coordinator-degraded", { key, error: this.formatError(error) });
3118
+ this.emitError("single-flight", { key, degraded: true, error: this.formatError(error) });
3119
+ return fetchTask();
3120
+ }
3056
3121
  };
3057
3122
  if (this.options.stampedePrevention === false) {
3058
3123
  return singleFlightTask();
@@ -3923,6 +3988,11 @@ function simpleHash(value) {
3923
3988
 
3924
3989
  // src/http/createCacheStatsHandler.ts
3925
3990
  function createCacheStatsHandler(cache, options = {}) {
3991
+ if (options.allowPublicAccess === true) {
3992
+ console.warn(
3993
+ "[layercache] WARNING: Stats endpoint is publicly accessible without authentication. Set allowPublicAccess: false (or provide an authorize callback) before deploying to production."
3994
+ );
3995
+ }
3926
3996
  return async (request, response) => {
3927
3997
  response.setHeader?.("content-type", "application/json; charset=utf-8");
3928
3998
  response.setHeader?.("cache-control", "no-store");
@@ -3965,6 +4035,11 @@ function createCachedMethodDecorator(options) {
3965
4035
 
3966
4036
  // src/integrations/fastify.ts
3967
4037
  function createFastifyLayercachePlugin(cache, options = {}) {
4038
+ if (options.exposeStatsRoute === true && options.allowPublicStatsRoute === true) {
4039
+ console.warn(
4040
+ "[layercache] WARNING: Cache stats route is publicly accessible without authentication. Set allowPublicStatsRoute: false (or provide an authorizeStatsRoute callback) before deploying to production."
4041
+ );
4042
+ }
3968
4043
  return async (fastify) => {
3969
4044
  fastify.decorate("cache", cache);
3970
4045
  if (options.exposeStatsRoute === true && fastify.get) {
@@ -4393,6 +4468,7 @@ var RedisLayer = class {
4393
4468
  compression;
4394
4469
  compressionThreshold;
4395
4470
  decompressionMaxBytes;
4471
+ commandTimeoutMs;
4396
4472
  disconnectOnDispose;
4397
4473
  constructor(options) {
4398
4474
  this.client = options.client;
@@ -4405,6 +4481,7 @@ var RedisLayer = class {
4405
4481
  this.compression = options.compression;
4406
4482
  this.compressionThreshold = options.compressionThreshold ?? 1024;
4407
4483
  this.decompressionMaxBytes = options.decompressionMaxBytes ?? 64 * 1024 * 1024;
4484
+ this.commandTimeoutMs = this.normalizeCommandTimeoutMs(options.commandTimeoutMs);
4408
4485
  this.disconnectOnDispose = options.disconnectOnDispose ?? false;
4409
4486
  }
4410
4487
  async get(key) {
@@ -4412,7 +4489,11 @@ var RedisLayer = class {
4412
4489
  return unwrapStoredValue(payload);
4413
4490
  }
4414
4491
  async getEntry(key) {
4415
- const payload = await this.client.getBuffer(this.withPrefix(key));
4492
+ this.validateKey(key);
4493
+ const payload = await this.runCommand(
4494
+ `get(${this.displayKey(key)})`,
4495
+ () => this.client.getBuffer(this.withPrefix(key))
4496
+ );
4416
4497
  if (payload === null) {
4417
4498
  return null;
4418
4499
  }
@@ -4422,11 +4503,14 @@ var RedisLayer = class {
4422
4503
  if (keys.length === 0) {
4423
4504
  return [];
4424
4505
  }
4506
+ for (const key of keys) {
4507
+ this.validateKey(key);
4508
+ }
4425
4509
  const pipeline = this.client.pipeline();
4426
4510
  for (const key of keys) {
4427
4511
  pipeline.getBuffer(this.withPrefix(key));
4428
4512
  }
4429
- const results = await pipeline.exec();
4513
+ const results = await this.runCommand(`mget(${keys.length})`, () => pipeline.exec());
4430
4514
  if (results === null) {
4431
4515
  return keys.map(() => null);
4432
4516
  }
@@ -4444,6 +4528,9 @@ var RedisLayer = class {
4444
4528
  if (entries.length === 0) {
4445
4529
  return;
4446
4530
  }
4531
+ for (const entry of entries) {
4532
+ this.validateKey(entry.key);
4533
+ }
4447
4534
  const pipeline = this.client.pipeline();
4448
4535
  for (const entry of entries) {
4449
4536
  const serialized = this.primarySerializer().serialize(entry.value);
@@ -4455,33 +4542,46 @@ var RedisLayer = class {
4455
4542
  pipeline.set(normalizedKey, payload);
4456
4543
  }
4457
4544
  }
4458
- await pipeline.exec();
4545
+ await this.runCommand(`mset(${entries.length})`, () => pipeline.exec());
4459
4546
  }
4460
4547
  async set(key, value, ttl = this.defaultTtl) {
4548
+ this.validateKey(key);
4461
4549
  const serialized = this.primarySerializer().serialize(value);
4462
4550
  const payload = await this.encodePayload(serialized);
4463
4551
  const normalizedKey = this.withPrefix(key);
4464
4552
  if (ttl && ttl > 0) {
4465
- await this.client.set(normalizedKey, payload, "EX", ttl);
4553
+ await this.runCommand(
4554
+ `set(${this.displayKey(key)})`,
4555
+ () => this.client.set(normalizedKey, payload, "EX", ttl)
4556
+ );
4466
4557
  return;
4467
4558
  }
4468
- await this.client.set(normalizedKey, payload);
4559
+ await this.runCommand(`set(${this.displayKey(key)})`, () => this.client.set(normalizedKey, payload));
4469
4560
  }
4470
4561
  async delete(key) {
4471
- await this.client.del(this.withPrefix(key));
4562
+ this.validateKey(key);
4563
+ await this.runCommand(`delete(${this.displayKey(key)})`, () => this.client.del(this.withPrefix(key)));
4472
4564
  }
4473
4565
  async deleteMany(keys) {
4474
4566
  if (keys.length === 0) {
4475
4567
  return;
4476
4568
  }
4477
- await this.client.del(...keys.map((key) => this.withPrefix(key)));
4569
+ for (const key of keys) {
4570
+ this.validateKey(key);
4571
+ }
4572
+ await this.runCommand(
4573
+ `deleteMany(${keys.length})`,
4574
+ () => this.client.del(...keys.map((key) => this.withPrefix(key)))
4575
+ );
4478
4576
  }
4479
4577
  async has(key) {
4480
- const exists = await this.client.exists(this.withPrefix(key));
4578
+ this.validateKey(key);
4579
+ const exists = await this.runCommand(`has(${this.displayKey(key)})`, () => this.client.exists(this.withPrefix(key)));
4481
4580
  return exists > 0;
4482
4581
  }
4483
4582
  async ttl(key) {
4484
- const remaining = await this.client.ttl(this.withPrefix(key));
4583
+ this.validateKey(key);
4584
+ const remaining = await this.runCommand(`ttl(${this.displayKey(key)})`, () => this.client.ttl(this.withPrefix(key)));
4485
4585
  if (remaining < 0) {
4486
4586
  return null;
4487
4587
  }
@@ -4489,13 +4589,16 @@ var RedisLayer = class {
4489
4589
  }
4490
4590
  async size() {
4491
4591
  if (!this.prefix) {
4492
- return this.client.dbsize();
4592
+ return this.runCommand("dbsize()", () => this.client.dbsize());
4493
4593
  }
4494
4594
  const pattern = `${this.prefix}*`;
4495
4595
  let cursor = "0";
4496
4596
  let count = 0;
4497
4597
  do {
4498
- const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
4598
+ const [nextCursor, keys] = await this.runCommand(
4599
+ `scan("${pattern}")`,
4600
+ () => this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount)
4601
+ );
4499
4602
  cursor = nextCursor;
4500
4603
  count += keys.length;
4501
4604
  } while (cursor !== "0");
@@ -4503,7 +4606,7 @@ var RedisLayer = class {
4503
4606
  }
4504
4607
  async ping() {
4505
4608
  try {
4506
- return await this.client.ping() === "PONG";
4609
+ return await this.runCommand("ping()", () => this.client.ping()) === "PONG";
4507
4610
  } catch {
4508
4611
  return false;
4509
4612
  }
@@ -4526,14 +4629,17 @@ var RedisLayer = class {
4526
4629
  const pattern = `${this.prefix}*`;
4527
4630
  let cursor = "0";
4528
4631
  do {
4529
- const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
4632
+ const [nextCursor, keys] = await this.runCommand(
4633
+ `scan("${pattern}")`,
4634
+ () => this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount)
4635
+ );
4530
4636
  cursor = nextCursor;
4531
4637
  if (keys.length === 0) {
4532
4638
  continue;
4533
4639
  }
4534
4640
  for (let i = 0; i < keys.length; i += BATCH_DELETE_SIZE) {
4535
4641
  const batch = keys.slice(i, i + BATCH_DELETE_SIZE);
4536
- await this.client.del(...batch);
4642
+ await this.runCommand(`clear-del(${batch.length})`, () => this.client.del(...batch));
4537
4643
  }
4538
4644
  } while (cursor !== "0");
4539
4645
  }
@@ -4549,7 +4655,10 @@ var RedisLayer = class {
4549
4655
  const pattern = `${this.prefix}*`;
4550
4656
  let cursor = "0";
4551
4657
  do {
4552
- const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
4658
+ const [nextCursor, keys] = await this.runCommand(
4659
+ `scan("${pattern}")`,
4660
+ () => this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount)
4661
+ );
4553
4662
  cursor = nextCursor;
4554
4663
  for (const key of keys) {
4555
4664
  await visitor(this.prefix ? key.slice(this.prefix.length) : key);
@@ -4560,7 +4669,10 @@ var RedisLayer = class {
4560
4669
  const matches = [];
4561
4670
  let cursor = "0";
4562
4671
  do {
4563
- const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
4672
+ const [nextCursor, keys] = await this.runCommand(
4673
+ `scan("${pattern}")`,
4674
+ () => this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount)
4675
+ );
4564
4676
  cursor = nextCursor;
4565
4677
  matches.push(...keys);
4566
4678
  } while (cursor !== "0");
@@ -4569,6 +4681,23 @@ var RedisLayer = class {
4569
4681
  withPrefix(key) {
4570
4682
  return `${this.prefix}${key}`;
4571
4683
  }
4684
+ validateKey(key) {
4685
+ if (key.length === 0) {
4686
+ throw new Error("RedisLayer: key must not be empty.");
4687
+ }
4688
+ if (key.length > 1024) {
4689
+ throw new Error(`RedisLayer: key length must be at most 1 024 characters (got ${key.length}).`);
4690
+ }
4691
+ if (/[\u0000-\u001F\u007F]/.test(key)) {
4692
+ throw new Error("RedisLayer: key contains unsupported control characters.");
4693
+ }
4694
+ if (/[\uD800-\uDFFF]/.test(key)) {
4695
+ throw new Error("RedisLayer: key contains unsupported surrogate code points.");
4696
+ }
4697
+ }
4698
+ displayKey(key) {
4699
+ return key.length > 64 ? `${key.slice(0, 64)}...` : key;
4700
+ }
4572
4701
  async deserializeOrDelete(key, payload) {
4573
4702
  let decodedPayload;
4574
4703
  try {
@@ -4592,20 +4721,30 @@ var RedisLayer = class {
4592
4721
  }
4593
4722
  async deleteCorruptedKey(key) {
4594
4723
  try {
4595
- await this.client.del(this.withPrefix(key));
4724
+ await this.runCommand(`deleteCorrupted(${this.displayKey(key)})`, () => this.client.del(this.withPrefix(key)));
4596
4725
  } catch (deleteError) {
4597
- console.warn(`[layercache] RedisLayer: failed to delete corrupted key "${key}"`, deleteError);
4726
+ const displayKey = key.length > 64 ? `${key.slice(0, 64)}...` : key;
4727
+ console.warn(`[layercache] RedisLayer: failed to delete corrupted key "${displayKey}"`, deleteError);
4598
4728
  }
4599
4729
  }
4600
4730
  async rewriteWithPrimarySerializer(key, value) {
4601
4731
  const serialized = this.primarySerializer().serialize(value);
4602
4732
  const payload = await this.encodePayload(serialized);
4603
- const ttl = await this.client.ttl(this.withPrefix(key));
4733
+ const ttl = await this.runCommand(
4734
+ `rewrite-ttl(${this.displayKey(key)})`,
4735
+ () => this.client.ttl(this.withPrefix(key))
4736
+ );
4604
4737
  if (ttl > 0) {
4605
- await this.client.set(this.withPrefix(key), payload, "EX", ttl);
4738
+ await this.runCommand(
4739
+ `rewrite-set(${this.displayKey(key)})`,
4740
+ () => this.client.set(this.withPrefix(key), payload, "EX", ttl)
4741
+ );
4606
4742
  return;
4607
4743
  }
4608
- await this.client.set(this.withPrefix(key), payload);
4744
+ await this.runCommand(
4745
+ `rewrite-set(${this.displayKey(key)})`,
4746
+ () => this.client.set(this.withPrefix(key), payload)
4747
+ );
4609
4748
  }
4610
4749
  primarySerializer() {
4611
4750
  const serializer = this.serializers[0];
@@ -4701,12 +4840,163 @@ var RedisLayer = class {
4701
4840
  source.pipe(decompressor);
4702
4841
  });
4703
4842
  }
4843
+ normalizeCommandTimeoutMs(value) {
4844
+ if (value === void 0) {
4845
+ return void 0;
4846
+ }
4847
+ if (!Number.isFinite(value) || value <= 0) {
4848
+ throw new Error("RedisLayer.commandTimeoutMs must be a positive number.");
4849
+ }
4850
+ return value;
4851
+ }
4852
+ async runCommand(operation, command) {
4853
+ const promise = command();
4854
+ if (!this.commandTimeoutMs) {
4855
+ return promise;
4856
+ }
4857
+ let timer;
4858
+ return Promise.race([
4859
+ promise,
4860
+ new Promise((_, reject) => {
4861
+ timer = setTimeout(() => {
4862
+ reject(new Error(`RedisLayer command ${operation} timed out after ${this.commandTimeoutMs}ms.`));
4863
+ }, this.commandTimeoutMs);
4864
+ timer.unref?.();
4865
+ })
4866
+ ]).finally(() => {
4867
+ if (timer) {
4868
+ clearTimeout(timer);
4869
+ }
4870
+ });
4871
+ }
4704
4872
  };
4705
4873
 
4706
4874
  // src/layers/DiskLayer.ts
4707
- var import_node_crypto2 = require("crypto");
4875
+ var import_node_crypto3 = require("crypto");
4708
4876
  var import_node_fs2 = require("fs");
4709
4877
  var import_node_path2 = require("path");
4878
+
4879
+ // src/internal/PayloadProtection.ts
4880
+ var import_node_crypto2 = require("crypto");
4881
+ var MAGIC_ENCRYPTED = Buffer.from("LCP1:");
4882
+ var MAGIC_SIGNED = Buffer.from("LCS1:");
4883
+ var ALGORITHM = "aes-256-gcm";
4884
+ var IV_LENGTH = 12;
4885
+ var AUTH_TAG_LENGTH = 16;
4886
+ var HMAC_LENGTH = 32;
4887
+ var PayloadProtection = class {
4888
+ encryptionKey;
4889
+ signingKey;
4890
+ constructor(options) {
4891
+ if (options.encryptionKey) {
4892
+ const raw = Buffer.isBuffer(options.encryptionKey) ? options.encryptionKey : Buffer.from(options.encryptionKey, "utf8");
4893
+ this.encryptionKey = (0, import_node_crypto2.createHash)("sha256").update(raw).digest();
4894
+ }
4895
+ if (options.signingKey && !options.encryptionKey) {
4896
+ const raw = Buffer.isBuffer(options.signingKey) ? options.signingKey : Buffer.from(options.signingKey, "utf8");
4897
+ this.signingKey = (0, import_node_crypto2.createHash)("sha256").update(raw).digest();
4898
+ }
4899
+ }
4900
+ /** Returns `true` when any protection (encryption or signing) is configured. */
4901
+ get isEnabled() {
4902
+ return this.encryptionKey !== void 0 || this.signingKey !== void 0;
4903
+ }
4904
+ /**
4905
+ * Applies the configured protection (encryption or signing) to a payload.
4906
+ * Returns the input unchanged when no protection is configured.
4907
+ */
4908
+ protect(payload) {
4909
+ if (this.encryptionKey) {
4910
+ return this.encrypt(payload, this.encryptionKey);
4911
+ }
4912
+ if (this.signingKey) {
4913
+ return this.sign(payload, this.signingKey);
4914
+ }
4915
+ return payload;
4916
+ }
4917
+ /**
4918
+ * Removes the protection layer from a payload.
4919
+ *
4920
+ * - Protected payloads are decrypted/verified using the configured keys.
4921
+ * - Legacy unprotected payloads pass through unchanged when **no** protection
4922
+ * is configured.
4923
+ * - If protection **is** configured but the payload is not protected, the
4924
+ * payload is treated as a legacy entry. Callers can handle this case by
4925
+ * checking `isEnabled` separately.
4926
+ */
4927
+ unprotect(payload) {
4928
+ if (this.startsWith(payload, MAGIC_ENCRYPTED)) {
4929
+ if (!this.encryptionKey) {
4930
+ throw new PayloadProtectionError("Encrypted payload but no encryptionKey configured.");
4931
+ }
4932
+ return this.decrypt(payload, this.encryptionKey);
4933
+ }
4934
+ if (this.startsWith(payload, MAGIC_SIGNED)) {
4935
+ if (!this.signingKey) {
4936
+ throw new PayloadProtectionError("Signed payload but no signingKey configured.");
4937
+ }
4938
+ return this.verify(payload, this.signingKey);
4939
+ }
4940
+ return payload;
4941
+ }
4942
+ // ── Encryption (AES-256-GCM) ──────────────────────────────────────────
4943
+ encrypt(plaintext, key) {
4944
+ const iv = (0, import_node_crypto2.randomBytes)(IV_LENGTH);
4945
+ const cipher = (0, import_node_crypto2.createCipheriv)(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
4946
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
4947
+ const authTag = cipher.getAuthTag();
4948
+ return Buffer.concat([MAGIC_ENCRYPTED, iv, authTag, encrypted]);
4949
+ }
4950
+ decrypt(payload, key) {
4951
+ const headerEnd = MAGIC_ENCRYPTED.length;
4952
+ const iv = payload.subarray(headerEnd, headerEnd + IV_LENGTH);
4953
+ const authTag = payload.subarray(headerEnd + IV_LENGTH, headerEnd + IV_LENGTH + AUTH_TAG_LENGTH);
4954
+ const ciphertext = payload.subarray(headerEnd + IV_LENGTH + AUTH_TAG_LENGTH);
4955
+ try {
4956
+ const decipher = (0, import_node_crypto2.createDecipheriv)(ALGORITHM, key, iv, {
4957
+ authTagLength: AUTH_TAG_LENGTH
4958
+ });
4959
+ decipher.setAuthTag(authTag);
4960
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
4961
+ } catch {
4962
+ throw new PayloadProtectionError(
4963
+ "Decryption failed. The data may have been tampered with or the encryptionKey is incorrect."
4964
+ );
4965
+ }
4966
+ }
4967
+ // ── Signing (HMAC-SHA256) ─────────────────────────────────────────────
4968
+ sign(payload, key) {
4969
+ const hmac = (0, import_node_crypto2.createHmac)("sha256", key).update(payload).digest();
4970
+ return Buffer.concat([MAGIC_SIGNED, hmac, payload]);
4971
+ }
4972
+ verify(payload, key) {
4973
+ const headerEnd = MAGIC_SIGNED.length;
4974
+ const receivedHmac = payload.subarray(headerEnd, headerEnd + HMAC_LENGTH);
4975
+ const data = payload.subarray(headerEnd + HMAC_LENGTH);
4976
+ const expectedHmac = (0, import_node_crypto2.createHmac)("sha256", key).update(data).digest();
4977
+ if (receivedHmac.length !== HMAC_LENGTH || !(0, import_node_crypto2.timingSafeEqual)(receivedHmac, expectedHmac)) {
4978
+ throw new PayloadProtectionError(
4979
+ "HMAC verification failed. The data may have been tampered with or the signingKey is incorrect."
4980
+ );
4981
+ }
4982
+ return data;
4983
+ }
4984
+ // ── Helpers ────────────────────────────────────────────────────────────
4985
+ startsWith(buffer, prefix) {
4986
+ if (buffer.length < prefix.length) {
4987
+ return false;
4988
+ }
4989
+ return buffer.subarray(0, prefix.length).equals(prefix);
4990
+ }
4991
+ };
4992
+ var PayloadProtectionError = class extends Error {
4993
+ constructor(message) {
4994
+ super(message);
4995
+ this.name = "PayloadProtectionError";
4996
+ }
4997
+ };
4998
+
4999
+ // src/layers/DiskLayer.ts
4710
5000
  var FILE_SCAN_CONCURRENCY = 32;
4711
5001
  var DiskLayer = class {
4712
5002
  name;
@@ -4716,6 +5006,7 @@ var DiskLayer = class {
4716
5006
  serializer;
4717
5007
  maxFiles;
4718
5008
  maxEntryBytes;
5009
+ protection;
4719
5010
  writeQueue = Promise.resolve();
4720
5011
  constructor(options) {
4721
5012
  this.directory = this.resolveDirectory(options.directory);
@@ -4724,6 +5015,10 @@ var DiskLayer = class {
4724
5015
  this.serializer = options.serializer ?? new JsonSerializer();
4725
5016
  this.maxFiles = this.normalizeMaxFiles(options.maxFiles);
4726
5017
  this.maxEntryBytes = this.normalizeMaxEntryBytes(options.maxEntryBytes);
5018
+ this.protection = new PayloadProtection({
5019
+ encryptionKey: options.encryptionKey,
5020
+ signingKey: options.signingKey
5021
+ });
4727
5022
  }
4728
5023
  async get(key) {
4729
5024
  return unwrapStoredValue(await this.getEntry(key));
@@ -4756,10 +5051,12 @@ var DiskLayer = class {
4756
5051
  expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
4757
5052
  };
4758
5053
  const payload = this.serializer.serialize(entry);
5054
+ const raw = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, "utf8");
5055
+ const protectedPayload = this.protection.protect(raw);
4759
5056
  const targetPath = this.keyToPath(key);
4760
- const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${(0, import_node_crypto2.randomBytes)(8).toString("hex")}.tmp`;
5057
+ const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${(0, import_node_crypto3.randomBytes)(8).toString("hex")}.tmp`;
4761
5058
  try {
4762
- await import_node_fs2.promises.writeFile(tempPath, payload);
5059
+ await import_node_fs2.promises.writeFile(tempPath, protectedPayload);
4763
5060
  await import_node_fs2.promises.rename(tempPath, targetPath);
4764
5061
  } catch (error) {
4765
5062
  await this.safeDelete(tempPath);
@@ -4857,7 +5154,7 @@ var DiskLayer = class {
4857
5154
  async dispose() {
4858
5155
  }
4859
5156
  keyToPath(key) {
4860
- const hash = (0, import_node_crypto2.createHash)("sha256").update(key).digest("hex");
5157
+ const hash = (0, import_node_crypto3.createHash)("sha256").update(key).digest("hex");
4861
5158
  return (0, import_node_path2.join)(this.directory, `${hash}.lc`);
4862
5159
  }
4863
5160
  resolveDirectory(directory) {
@@ -4871,10 +5168,13 @@ var DiskLayer = class {
4871
5168
  }
4872
5169
  normalizeMaxFiles(maxFiles) {
4873
5170
  if (maxFiles === void 0) {
5171
+ return 5e4;
5172
+ }
5173
+ if (maxFiles === Number.POSITIVE_INFINITY) {
4874
5174
  return void 0;
4875
5175
  }
4876
5176
  if (!Number.isInteger(maxFiles) || maxFiles <= 0) {
4877
- throw new Error("DiskLayer.maxFiles must be a positive integer.");
5177
+ throw new Error("DiskLayer.maxFiles must be a positive integer or Infinity.");
4878
5178
  }
4879
5179
  return maxFiles;
4880
5180
  }
@@ -4986,7 +5286,8 @@ var DiskLayer = class {
4986
5286
  );
4987
5287
  }
4988
5288
  deserializeEntry(raw) {
4989
- const entry = this.serializer.deserialize(raw);
5289
+ const unprotected = this.protection.unprotect(raw);
5290
+ const entry = this.serializer.deserialize(unprotected);
4990
5291
  if (!isDiskEntry(entry)) {
4991
5292
  throw new Error("Invalid disk cache entry.");
4992
5293
  }
@@ -5108,7 +5409,10 @@ var MemcachedLayer = class {
5108
5409
  validateKey(key) {
5109
5410
  const fullKey = this.withPrefix(key);
5110
5411
  if (Buffer.byteLength(fullKey, "utf8") > 250) {
5111
- throw new Error(`MemcachedLayer: key exceeds 250-byte Memcached limit: "${fullKey.slice(0, 60)}..."`);
5412
+ const displayKey = fullKey.slice(0, 64);
5413
+ throw new Error(
5414
+ `MemcachedLayer: key exceeds 250-byte Memcached limit: "${displayKey}${fullKey.length > 64 ? "..." : ""}"`
5415
+ );
5112
5416
  }
5113
5417
  if (/[\s\x00-\x1f\x7f]/.test(fullKey)) {
5114
5418
  throw new Error(
@@ -5135,7 +5439,7 @@ var MsgpackSerializer = class {
5135
5439
  };
5136
5440
 
5137
5441
  // src/singleflight/RedisSingleFlightCoordinator.ts
5138
- var import_node_crypto3 = require("crypto");
5442
+ var import_node_crypto4 = require("crypto");
5139
5443
  var RELEASE_SCRIPT = `
5140
5444
  if redis.call("get", KEYS[1]) == ARGV[1] then
5141
5445
  return redis.call("del", KEYS[1])
@@ -5151,14 +5455,19 @@ return 0
5151
5455
  var RedisSingleFlightCoordinator = class {
5152
5456
  client;
5153
5457
  prefix;
5458
+ commandTimeoutMs;
5154
5459
  constructor(options) {
5155
5460
  this.client = options.client;
5156
5461
  this.prefix = options.prefix ?? "layercache:singleflight";
5462
+ this.commandTimeoutMs = this.normalizeCommandTimeoutMs(options.commandTimeoutMs);
5157
5463
  }
5158
5464
  async execute(key, options, worker, waiter) {
5159
5465
  const lockKey = `${this.prefix}:${encodeURIComponent(key)}`;
5160
- const token = (0, import_node_crypto3.randomUUID)();
5161
- const acquired = await this.client.set(lockKey, token, "PX", options.leaseMs, "NX");
5466
+ const token = (0, import_node_crypto4.randomUUID)();
5467
+ const acquired = await this.runCommand(
5468
+ `acquire("${key}")`,
5469
+ () => this.client.set(lockKey, token, "PX", options.leaseMs, "NX")
5470
+ );
5162
5471
  if (acquired === "OK") {
5163
5472
  const renewTimer = this.startLeaseRenewal(lockKey, token, options);
5164
5473
  try {
@@ -5167,7 +5476,7 @@ var RedisSingleFlightCoordinator = class {
5167
5476
  if (renewTimer) {
5168
5477
  clearInterval(renewTimer);
5169
5478
  }
5170
- await this.client.eval(RELEASE_SCRIPT, 1, lockKey, token);
5479
+ await this.runCommand(`release("${key}")`, () => this.client.eval(RELEASE_SCRIPT, 1, lockKey, token));
5171
5480
  }
5172
5481
  }
5173
5482
  return waiter();
@@ -5178,11 +5487,45 @@ var RedisSingleFlightCoordinator = class {
5178
5487
  return void 0;
5179
5488
  }
5180
5489
  const timer = setInterval(() => {
5181
- void this.client.eval(RENEW_SCRIPT, 1, lockKey, token, String(options.leaseMs)).catch(() => void 0);
5490
+ void this.runCommand(
5491
+ `renew("${lockKey}")`,
5492
+ () => this.client.eval(RENEW_SCRIPT, 1, lockKey, token, String(options.leaseMs))
5493
+ ).catch(() => void 0);
5182
5494
  }, renewIntervalMs);
5183
5495
  timer.unref?.();
5184
5496
  return timer;
5185
5497
  }
5498
+ normalizeCommandTimeoutMs(value) {
5499
+ if (value === void 0) {
5500
+ return void 0;
5501
+ }
5502
+ if (!Number.isFinite(value) || value <= 0) {
5503
+ throw new Error("RedisSingleFlightCoordinator.commandTimeoutMs must be a positive number.");
5504
+ }
5505
+ return value;
5506
+ }
5507
+ async runCommand(operation, command) {
5508
+ const promise = command();
5509
+ if (!this.commandTimeoutMs) {
5510
+ return promise;
5511
+ }
5512
+ let timer;
5513
+ return Promise.race([
5514
+ promise,
5515
+ new Promise((_, reject) => {
5516
+ timer = setTimeout(() => {
5517
+ reject(
5518
+ new Error(`RedisSingleFlightCoordinator command ${operation} timed out after ${this.commandTimeoutMs}ms.`)
5519
+ );
5520
+ }, this.commandTimeoutMs);
5521
+ timer.unref?.();
5522
+ })
5523
+ ]).finally(() => {
5524
+ if (timer) {
5525
+ clearTimeout(timer);
5526
+ }
5527
+ });
5528
+ }
5186
5529
  };
5187
5530
 
5188
5531
  // src/metrics/PrometheusExporter.ts