layercache 1.3.0 → 1.3.2

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
@@ -2308,6 +2315,12 @@ var JsonSerializer = class {
2308
2315
  // src/stampede/StampedeGuard.ts
2309
2316
  var StampedeGuard = class {
2310
2317
  inFlight = /* @__PURE__ */ new Map();
2318
+ maxInFlight;
2319
+ entryTimeoutMs;
2320
+ constructor(options = {}) {
2321
+ this.maxInFlight = options.maxInFlight ?? 1e4;
2322
+ this.entryTimeoutMs = options.entryTimeoutMs;
2323
+ }
2311
2324
  async execute(key, task) {
2312
2325
  const existing = this.inFlight.get(key);
2313
2326
  if (existing) {
@@ -2318,8 +2331,15 @@ var StampedeGuard = class {
2318
2331
  this.releaseEntry(key, existing);
2319
2332
  }
2320
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;
2321
2341
  const entry = {
2322
- promise: Promise.resolve().then(task),
2342
+ promise: guardedPromise,
2323
2343
  references: 1
2324
2344
  };
2325
2345
  this.inFlight.set(key, entry);
@@ -2329,6 +2349,27 @@ var StampedeGuard = class {
2329
2349
  this.releaseEntry(key, entry);
2330
2350
  }
2331
2351
  }
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
+ }
2332
2373
  releaseEntry(key, entry) {
2333
2374
  entry.references -= 1;
2334
2375
  const current = this.inFlight.get(key);
@@ -2394,6 +2435,10 @@ var CacheStack = class extends import_node_events.EventEmitter {
2394
2435
  const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES;
2395
2436
  this.ttlResolver = new TtlResolver({ maxProfileEntries });
2396
2437
  this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries });
2438
+ this.stampedeGuard = new StampedeGuard({
2439
+ maxInFlight: options.stampedeMaxInFlight,
2440
+ entryTimeoutMs: options.stampedeEntryTimeoutMs
2441
+ });
2397
2442
  this.currentGeneration = options.generation;
2398
2443
  if (options.publishSetInvalidation !== void 0) {
2399
2444
  console.warn(
@@ -2472,7 +2517,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
2472
2517
  }
2473
2518
  layers;
2474
2519
  options;
2475
- stampedeGuard = new StampedeGuard();
2520
+ stampedeGuard;
2476
2521
  metricsCollector = new MetricsCollector();
2477
2522
  instanceId = createInstanceId();
2478
2523
  startup;
@@ -2710,7 +2755,8 @@ var CacheStack = class extends import_node_events.EventEmitter {
2710
2755
  return promise;
2711
2756
  }
2712
2757
  if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
2713
- 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}".`);
2714
2760
  }
2715
2761
  return existing.promise;
2716
2762
  })
@@ -3942,6 +3988,11 @@ function simpleHash(value) {
3942
3988
 
3943
3989
  // src/http/createCacheStatsHandler.ts
3944
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
+ }
3945
3996
  return async (request, response) => {
3946
3997
  response.setHeader?.("content-type", "application/json; charset=utf-8");
3947
3998
  response.setHeader?.("cache-control", "no-store");
@@ -3984,6 +4035,11 @@ function createCachedMethodDecorator(options) {
3984
4035
 
3985
4036
  // src/integrations/fastify.ts
3986
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
+ }
3987
4043
  return async (fastify) => {
3988
4044
  fastify.decorate("cache", cache);
3989
4045
  if (options.exposeStatsRoute === true && fastify.get) {
@@ -4433,7 +4489,11 @@ var RedisLayer = class {
4433
4489
  return unwrapStoredValue(payload);
4434
4490
  }
4435
4491
  async getEntry(key) {
4436
- const payload = await this.runCommand(`get("${key}")`, () => 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
+ );
4437
4497
  if (payload === null) {
4438
4498
  return null;
4439
4499
  }
@@ -4443,6 +4503,9 @@ var RedisLayer = class {
4443
4503
  if (keys.length === 0) {
4444
4504
  return [];
4445
4505
  }
4506
+ for (const key of keys) {
4507
+ this.validateKey(key);
4508
+ }
4446
4509
  const pipeline = this.client.pipeline();
4447
4510
  for (const key of keys) {
4448
4511
  pipeline.getBuffer(this.withPrefix(key));
@@ -4465,6 +4528,9 @@ var RedisLayer = class {
4465
4528
  if (entries.length === 0) {
4466
4529
  return;
4467
4530
  }
4531
+ for (const entry of entries) {
4532
+ this.validateKey(entry.key);
4533
+ }
4468
4534
  const pipeline = this.client.pipeline();
4469
4535
  for (const entry of entries) {
4470
4536
  const serialized = this.primarySerializer().serialize(entry.value);
@@ -4479,33 +4545,43 @@ var RedisLayer = class {
4479
4545
  await this.runCommand(`mset(${entries.length})`, () => pipeline.exec());
4480
4546
  }
4481
4547
  async set(key, value, ttl = this.defaultTtl) {
4548
+ this.validateKey(key);
4482
4549
  const serialized = this.primarySerializer().serialize(value);
4483
4550
  const payload = await this.encodePayload(serialized);
4484
4551
  const normalizedKey = this.withPrefix(key);
4485
4552
  if (ttl && ttl > 0) {
4486
- await this.runCommand(`set("${key}")`, () => 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
+ );
4487
4557
  return;
4488
4558
  }
4489
- await this.runCommand(`set("${key}")`, () => this.client.set(normalizedKey, payload));
4559
+ await this.runCommand(`set(${this.displayKey(key)})`, () => this.client.set(normalizedKey, payload));
4490
4560
  }
4491
4561
  async delete(key) {
4492
- await this.runCommand(`delete("${key}")`, () => this.client.del(this.withPrefix(key)));
4562
+ this.validateKey(key);
4563
+ await this.runCommand(`delete(${this.displayKey(key)})`, () => this.client.del(this.withPrefix(key)));
4493
4564
  }
4494
4565
  async deleteMany(keys) {
4495
4566
  if (keys.length === 0) {
4496
4567
  return;
4497
4568
  }
4569
+ for (const key of keys) {
4570
+ this.validateKey(key);
4571
+ }
4498
4572
  await this.runCommand(
4499
4573
  `deleteMany(${keys.length})`,
4500
4574
  () => this.client.del(...keys.map((key) => this.withPrefix(key)))
4501
4575
  );
4502
4576
  }
4503
4577
  async has(key) {
4504
- const exists = await this.runCommand(`has("${key}")`, () => 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)));
4505
4580
  return exists > 0;
4506
4581
  }
4507
4582
  async ttl(key) {
4508
- const remaining = await this.runCommand(`ttl("${key}")`, () => 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)));
4509
4585
  if (remaining < 0) {
4510
4586
  return null;
4511
4587
  }
@@ -4605,6 +4681,23 @@ var RedisLayer = class {
4605
4681
  withPrefix(key) {
4606
4682
  return `${this.prefix}${key}`;
4607
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
+ }
4608
4701
  async deserializeOrDelete(key, payload) {
4609
4702
  let decodedPayload;
4610
4703
  try {
@@ -4628,23 +4721,30 @@ var RedisLayer = class {
4628
4721
  }
4629
4722
  async deleteCorruptedKey(key) {
4630
4723
  try {
4631
- await this.runCommand(`deleteCorrupted("${key}")`, () => this.client.del(this.withPrefix(key)));
4724
+ await this.runCommand(`deleteCorrupted(${this.displayKey(key)})`, () => this.client.del(this.withPrefix(key)));
4632
4725
  } catch (deleteError) {
4633
- 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);
4634
4728
  }
4635
4729
  }
4636
4730
  async rewriteWithPrimarySerializer(key, value) {
4637
4731
  const serialized = this.primarySerializer().serialize(value);
4638
4732
  const payload = await this.encodePayload(serialized);
4639
- const ttl = await this.runCommand(`rewrite-ttl("${key}")`, () => 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
+ );
4640
4737
  if (ttl > 0) {
4641
4738
  await this.runCommand(
4642
- `rewrite-set("${key}")`,
4739
+ `rewrite-set(${this.displayKey(key)})`,
4643
4740
  () => this.client.set(this.withPrefix(key), payload, "EX", ttl)
4644
4741
  );
4645
4742
  return;
4646
4743
  }
4647
- await this.runCommand(`rewrite-set("${key}")`, () => 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
+ );
4648
4748
  }
4649
4749
  primarySerializer() {
4650
4750
  const serializer = this.serializers[0];
@@ -4772,9 +4872,131 @@ var RedisLayer = class {
4772
4872
  };
4773
4873
 
4774
4874
  // src/layers/DiskLayer.ts
4775
- var import_node_crypto2 = require("crypto");
4875
+ var import_node_crypto3 = require("crypto");
4776
4876
  var import_node_fs2 = require("fs");
4777
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
4778
5000
  var FILE_SCAN_CONCURRENCY = 32;
4779
5001
  var DiskLayer = class {
4780
5002
  name;
@@ -4784,6 +5006,7 @@ var DiskLayer = class {
4784
5006
  serializer;
4785
5007
  maxFiles;
4786
5008
  maxEntryBytes;
5009
+ protection;
4787
5010
  writeQueue = Promise.resolve();
4788
5011
  constructor(options) {
4789
5012
  this.directory = this.resolveDirectory(options.directory);
@@ -4792,6 +5015,10 @@ var DiskLayer = class {
4792
5015
  this.serializer = options.serializer ?? new JsonSerializer();
4793
5016
  this.maxFiles = this.normalizeMaxFiles(options.maxFiles);
4794
5017
  this.maxEntryBytes = this.normalizeMaxEntryBytes(options.maxEntryBytes);
5018
+ this.protection = new PayloadProtection({
5019
+ encryptionKey: options.encryptionKey,
5020
+ signingKey: options.signingKey
5021
+ });
4795
5022
  }
4796
5023
  async get(key) {
4797
5024
  return unwrapStoredValue(await this.getEntry(key));
@@ -4824,10 +5051,12 @@ var DiskLayer = class {
4824
5051
  expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
4825
5052
  };
4826
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);
4827
5056
  const targetPath = this.keyToPath(key);
4828
- 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`;
4829
5058
  try {
4830
- await import_node_fs2.promises.writeFile(tempPath, payload);
5059
+ await import_node_fs2.promises.writeFile(tempPath, protectedPayload);
4831
5060
  await import_node_fs2.promises.rename(tempPath, targetPath);
4832
5061
  } catch (error) {
4833
5062
  await this.safeDelete(tempPath);
@@ -4925,7 +5154,7 @@ var DiskLayer = class {
4925
5154
  async dispose() {
4926
5155
  }
4927
5156
  keyToPath(key) {
4928
- 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");
4929
5158
  return (0, import_node_path2.join)(this.directory, `${hash}.lc`);
4930
5159
  }
4931
5160
  resolveDirectory(directory) {
@@ -4939,10 +5168,13 @@ var DiskLayer = class {
4939
5168
  }
4940
5169
  normalizeMaxFiles(maxFiles) {
4941
5170
  if (maxFiles === void 0) {
5171
+ return 5e4;
5172
+ }
5173
+ if (maxFiles === Number.POSITIVE_INFINITY) {
4942
5174
  return void 0;
4943
5175
  }
4944
5176
  if (!Number.isInteger(maxFiles) || maxFiles <= 0) {
4945
- throw new Error("DiskLayer.maxFiles must be a positive integer.");
5177
+ throw new Error("DiskLayer.maxFiles must be a positive integer or Infinity.");
4946
5178
  }
4947
5179
  return maxFiles;
4948
5180
  }
@@ -5054,7 +5286,8 @@ var DiskLayer = class {
5054
5286
  );
5055
5287
  }
5056
5288
  deserializeEntry(raw) {
5057
- const entry = this.serializer.deserialize(raw);
5289
+ const unprotected = this.protection.unprotect(raw);
5290
+ const entry = this.serializer.deserialize(unprotected);
5058
5291
  if (!isDiskEntry(entry)) {
5059
5292
  throw new Error("Invalid disk cache entry.");
5060
5293
  }
@@ -5176,7 +5409,10 @@ var MemcachedLayer = class {
5176
5409
  validateKey(key) {
5177
5410
  const fullKey = this.withPrefix(key);
5178
5411
  if (Buffer.byteLength(fullKey, "utf8") > 250) {
5179
- 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
+ );
5180
5416
  }
5181
5417
  if (/[\s\x00-\x1f\x7f]/.test(fullKey)) {
5182
5418
  throw new Error(
@@ -5203,7 +5439,7 @@ var MsgpackSerializer = class {
5203
5439
  };
5204
5440
 
5205
5441
  // src/singleflight/RedisSingleFlightCoordinator.ts
5206
- var import_node_crypto3 = require("crypto");
5442
+ var import_node_crypto4 = require("crypto");
5207
5443
  var RELEASE_SCRIPT = `
5208
5444
  if redis.call("get", KEYS[1]) == ARGV[1] then
5209
5445
  return redis.call("del", KEYS[1])
@@ -5227,7 +5463,7 @@ var RedisSingleFlightCoordinator = class {
5227
5463
  }
5228
5464
  async execute(key, options, worker, waiter) {
5229
5465
  const lockKey = `${this.prefix}:${encodeURIComponent(key)}`;
5230
- const token = (0, import_node_crypto3.randomUUID)();
5466
+ const token = (0, import_node_crypto4.randomUUID)();
5231
5467
  const acquired = await this.runCommand(
5232
5468
  `acquire("${key}")`,
5233
5469
  () => this.client.set(lockKey, token, "PX", options.leaseMs, "NX")
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { I as InvalidationBus, C as CacheLogger, a as InvalidationMessage, b as CacheTagIndex, c as CacheStack, d as CacheWrapOptions, e as CacheGetOptions, f as CacheLayer, g as CacheSerializer, h as CacheLayerSetManyEntry, i as CacheSingleFlightCoordinator, j as CacheSingleFlightExecutionOptions } from './edge-BXWTKlI1.cjs';
2
- export { k as CacheAdaptiveTtlOptions, l as CacheCircuitBreakerOptions, m as CacheDegradationOptions, n as CacheHealthCheckResult, o as CacheHitRateSnapshot, p as CacheInspectResult, q as CacheLayerLatency, r as CacheMGetEntry, s as CacheMSetEntry, t as CacheMetricsSnapshot, u as CacheMissError, v as CacheNamespace, w as CacheRateLimitOptions, x as CacheSnapshotEntry, y as CacheStackEvents, z as CacheStackOptions, A as CacheStatsSnapshot, B as CacheTtlPolicy, D as CacheTtlPolicyContext, E as CacheWarmEntry, F as CacheWarmOptions, G as CacheWarmProgress, H as CacheWriteBehindOptions, J as CacheWriteOptions, K as EvictionPolicy, L as LayerTtlMap, M as MemoryLayer, N as MemoryLayerOptions, O as MemoryLayerSnapshotEntry, P as PatternMatcher, T as TagIndex, Q as createHonoCacheMiddleware } from './edge-BXWTKlI1.cjs';
1
+ import { I as InvalidationBus, C as CacheLogger, a as InvalidationMessage, b as CacheTagIndex, c as CacheStack, d as CacheWrapOptions, e as CacheGetOptions, f as CacheLayer, g as CacheSerializer, h as CacheLayerSetManyEntry, i as CacheSingleFlightCoordinator, j as CacheSingleFlightExecutionOptions } from './edge-CUHTP9Bc.cjs';
2
+ export { k as CacheAdaptiveTtlOptions, l as CacheCircuitBreakerOptions, m as CacheDegradationOptions, n as CacheHealthCheckResult, o as CacheHitRateSnapshot, p as CacheInspectResult, q as CacheLayerLatency, r as CacheMGetEntry, s as CacheMSetEntry, t as CacheMetricsSnapshot, u as CacheMissError, v as CacheNamespace, w as CacheRateLimitOptions, x as CacheSnapshotEntry, y as CacheStackEvents, z as CacheStackOptions, A as CacheStatsSnapshot, B as CacheTtlPolicy, D as CacheTtlPolicyContext, E as CacheWarmEntry, F as CacheWarmOptions, G as CacheWarmProgress, H as CacheWriteBehindOptions, J as CacheWriteOptions, K as EvictionPolicy, L as LayerTtlMap, M as MemoryLayer, N as MemoryLayerOptions, O as MemoryLayerSnapshotEntry, P as PatternMatcher, T as TagIndex, Q as createHonoCacheMiddleware } from './edge-CUHTP9Bc.cjs';
3
3
  import Redis from 'ioredis';
4
4
  import 'node:events';
5
5
 
@@ -63,6 +63,11 @@ declare class RedisTagIndex implements CacheTagIndex {
63
63
  }
64
64
 
65
65
  interface CacheStatsHandlerOptions {
66
+ /**
67
+ * @deprecated Exposing cache stats without authentication is a security risk.
68
+ * Provide an `authorize` callback instead. This option will be removed in a
69
+ * future release.
70
+ */
66
71
  allowPublicAccess?: boolean;
67
72
  authorize?: (request: unknown) => boolean | Promise<boolean>;
68
73
  unauthorizedStatusCode?: number;
@@ -91,6 +96,11 @@ interface FastifyLikeReply {
91
96
  interface FastifyLayercachePluginOptions {
92
97
  exposeStatsRoute?: boolean;
93
98
  statsPath?: string;
99
+ /**
100
+ * @deprecated Exposing cache stats without authentication is a security risk.
101
+ * Provide an `authorizeStatsRoute` callback instead. This option will be
102
+ * removed in a future release.
103
+ */
94
104
  allowPublicStatsRoute?: boolean;
95
105
  authorizeStatsRoute?: (request: unknown) => boolean | Promise<boolean>;
96
106
  unauthorizedStatusCode?: number;
@@ -246,6 +256,8 @@ declare class RedisLayer implements CacheLayer {
246
256
  forEachKey(visitor: (key: string) => void | Promise<void>): Promise<void>;
247
257
  private scanKeys;
248
258
  private withPrefix;
259
+ private validateKey;
260
+ private displayKey;
249
261
  private deserializeOrDelete;
250
262
  private deleteCorruptedKey;
251
263
  private rewriteWithPrimarySerializer;
@@ -274,7 +286,8 @@ interface DiskLayerOptions {
274
286
  /**
275
287
  * Maximum number of cache files to store on disk. When exceeded, the oldest
276
288
  * entries (by file mtime) are evicted to keep the directory bounded.
277
- * Defaults to unlimited.
289
+ * Defaults to 50 000. Set to `Infinity` to disable the limit (not recommended
290
+ * in production — unbounded growth will eventually exhaust disk space).
278
291
  */
279
292
  maxFiles?: number;
280
293
  /**
@@ -283,6 +296,19 @@ interface DiskLayerOptions {
283
296
  * Set to `false` to disable the limit.
284
297
  */
285
298
  maxEntryBytes?: number | false;
299
+ /**
300
+ * Encrypt cached data at rest using AES-256-GCM. Accepts a string or Buffer.
301
+ * The key material is hashed with SHA-256 to derive the actual cipher key.
302
+ * Encryption also provides authenticated integrity — a separate signingKey
303
+ * is unnecessary when encryption is enabled.
304
+ */
305
+ encryptionKey?: string | Buffer;
306
+ /**
307
+ * Sign cached data at rest using HMAC-SHA256 for integrity verification.
308
+ * Accepts a string or Buffer. Ignored when `encryptionKey` is also provided
309
+ * (AES-GCM already provides integrity).
310
+ */
311
+ signingKey?: string | Buffer;
286
312
  }
287
313
  /**
288
314
  * A file-system backed cache layer.
@@ -303,6 +329,7 @@ declare class DiskLayer implements CacheLayer {
303
329
  private readonly serializer;
304
330
  private readonly maxFiles;
305
331
  private readonly maxEntryBytes;
332
+ private readonly protection;
306
333
  private writeQueue;
307
334
  constructor(options: DiskLayerOptions);
308
335
  get<T>(key: string): Promise<T | null>;
@@ -429,9 +456,27 @@ declare class RedisSingleFlightCoordinator implements CacheSingleFlightCoordinat
429
456
  private runCommand;
430
457
  }
431
458
 
459
+ interface StampedeGuardOptions {
460
+ /**
461
+ * Maximum number of concurrent in-flight keys. When exceeded, new `execute`
462
+ * calls for keys that are not already in-flight will throw immediately.
463
+ * Defaults to 10 000.
464
+ */
465
+ maxInFlight?: number;
466
+ /**
467
+ * Maximum milliseconds to wait for a single in-flight task before rejecting
468
+ * with a timeout error. When a timeout fires the entry is released so
469
+ * subsequent callers can retry. Defaults to no timeout.
470
+ */
471
+ entryTimeoutMs?: number;
472
+ }
432
473
  declare class StampedeGuard {
433
474
  private readonly inFlight;
475
+ private readonly maxInFlight;
476
+ private readonly entryTimeoutMs;
477
+ constructor(options?: StampedeGuardOptions);
434
478
  execute<T>(key: string, task: () => Promise<T>): Promise<T>;
479
+ private withTimeout;
435
480
  private releaseEntry;
436
481
  }
437
482