layercache 1.2.3 → 1.2.5

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
@@ -5,7 +5,7 @@ import {
5
5
  MemoryLayer,
6
6
  TagIndex,
7
7
  createHonoCacheMiddleware
8
- } from "./chunk-KOYGHLVP.js";
8
+ } from "./chunk-JC26W3KK.js";
9
9
  import {
10
10
  PatternMatcher,
11
11
  createStoredValueEnvelope,
@@ -136,6 +136,7 @@ var CacheNamespace = class _CacheNamespace {
136
136
  * ```
137
137
  */
138
138
  namespace(childPrefix) {
139
+ validateNamespaceKey(childPrefix);
139
140
  return new _CacheNamespace(this.cache, `${this.prefix}:${childPrefix}`);
140
141
  }
141
142
  qualify(key) {
@@ -265,6 +266,70 @@ function addMap(base, delta) {
265
266
  }
266
267
  return result;
267
268
  }
269
+ function validateNamespaceKey(key) {
270
+ if (key.length === 0) {
271
+ throw new Error("Namespace prefix must not be empty.");
272
+ }
273
+ if (key.length > 256) {
274
+ throw new Error("Namespace prefix must be at most 256 characters.");
275
+ }
276
+ if (/[\u0000-\u001F\u007F]/.test(key)) {
277
+ throw new Error("Namespace prefix contains unsupported control characters.");
278
+ }
279
+ }
280
+
281
+ // src/internal/CacheKeyDiscovery.ts
282
+ var CacheKeyDiscovery = class {
283
+ constructor(options) {
284
+ this.options = options;
285
+ }
286
+ options;
287
+ async collectKeysWithPrefix(prefix) {
288
+ const { tagIndex } = this.options;
289
+ const matches = new Set(
290
+ tagIndex.keysForPrefix ? await tagIndex.keysForPrefix(prefix) : await tagIndex.matchPattern(`${prefix}*`)
291
+ );
292
+ await Promise.all(
293
+ this.options.layers.map(async (layer) => {
294
+ if (!layer.keys || this.options.shouldSkipLayer(layer)) {
295
+ return;
296
+ }
297
+ try {
298
+ const keys = await layer.keys();
299
+ for (const key of keys) {
300
+ if (key.startsWith(prefix)) {
301
+ matches.add(key);
302
+ }
303
+ }
304
+ } catch (error) {
305
+ await this.options.handleLayerFailure(layer, "invalidate-prefix-scan", error);
306
+ }
307
+ })
308
+ );
309
+ return [...matches];
310
+ }
311
+ async collectKeysMatchingPattern(pattern) {
312
+ const matches = new Set(await this.options.tagIndex.matchPattern(pattern));
313
+ await Promise.all(
314
+ this.options.layers.map(async (layer) => {
315
+ if (!layer.keys || this.options.shouldSkipLayer(layer)) {
316
+ return;
317
+ }
318
+ try {
319
+ const keys = await layer.keys();
320
+ for (const key of keys) {
321
+ if (PatternMatcher.matches(pattern, key)) {
322
+ matches.add(key);
323
+ }
324
+ }
325
+ } catch (error) {
326
+ await this.options.handleLayerFailure(layer, "invalidate-pattern-scan", error);
327
+ }
328
+ })
329
+ );
330
+ return [...matches];
331
+ }
332
+ };
268
333
 
269
334
  // src/internal/CircuitBreakerManager.ts
270
335
  var CircuitBreakerManager = class {
@@ -284,9 +349,7 @@ var CircuitBreakerManager = class {
284
349
  }
285
350
  const now = Date.now();
286
351
  if (state.openUntil <= now) {
287
- state.openUntil = null;
288
- state.failures = 0;
289
- this.breakers.set(key, state);
352
+ this.breakers.delete(key);
290
353
  return;
291
354
  }
292
355
  const remainingMs = state.openUntil - now;
@@ -297,15 +360,15 @@ var CircuitBreakerManager = class {
297
360
  if (!options) {
298
361
  return;
299
362
  }
363
+ this.pruneIfNeeded();
300
364
  const failureThreshold = options.failureThreshold ?? 3;
301
365
  const cooldownMs = options.cooldownMs ?? 3e4;
302
- const state = this.breakers.get(key) ?? { failures: 0, openUntil: null };
366
+ const state = this.breakers.get(key) ?? { failures: 0, openUntil: null, createdAt: Date.now() };
303
367
  state.failures += 1;
304
368
  if (state.failures >= failureThreshold) {
305
369
  state.openUntil = Date.now() + cooldownMs;
306
370
  }
307
371
  this.breakers.set(key, state);
308
- this.pruneIfNeeded();
309
372
  }
310
373
  recordSuccess(key) {
311
374
  this.breakers.delete(key);
@@ -316,8 +379,7 @@ var CircuitBreakerManager = class {
316
379
  return false;
317
380
  }
318
381
  if (state.openUntil <= Date.now()) {
319
- state.openUntil = null;
320
- state.failures = 0;
382
+ this.breakers.delete(key);
321
383
  return false;
322
384
  }
323
385
  return true;
@@ -341,15 +403,20 @@ var CircuitBreakerManager = class {
341
403
  if (this.breakers.size <= this.maxEntries) {
342
404
  return;
343
405
  }
406
+ const now = Date.now();
344
407
  for (const [key, state] of this.breakers.entries()) {
345
408
  if (this.breakers.size <= this.maxEntries) {
346
- break;
409
+ return;
347
410
  }
348
- if (!state.openUntil || state.openUntil <= Date.now()) {
411
+ if (!state.openUntil || state.openUntil <= now) {
349
412
  this.breakers.delete(key);
350
413
  }
351
414
  }
352
- for (const key of this.breakers.keys()) {
415
+ if (this.breakers.size <= this.maxEntries) {
416
+ return;
417
+ }
418
+ const sorted = [...this.breakers.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
419
+ for (const [key] of sorted) {
353
420
  if (this.breakers.size <= this.maxEntries) {
354
421
  break;
355
422
  }
@@ -359,6 +426,7 @@ var CircuitBreakerManager = class {
359
426
  };
360
427
 
361
428
  // src/internal/FetchRateLimiter.ts
429
+ var MAX_BUCKETS = 1e4;
362
430
  var FetchRateLimiter = class {
363
431
  buckets = /* @__PURE__ */ new Map();
364
432
  queuesByBucket = /* @__PURE__ */ new Map();
@@ -524,10 +592,25 @@ var FetchRateLimiter = class {
524
592
  if (existing) {
525
593
  return existing;
526
594
  }
595
+ if (this.buckets.size >= MAX_BUCKETS) {
596
+ this.evictIdleBuckets();
597
+ }
527
598
  const bucket = { active: 0, startedAt: [] };
528
599
  this.buckets.set(bucketKey, bucket);
529
600
  return bucket;
530
601
  }
602
+ evictIdleBuckets() {
603
+ for (const [key, bucket] of this.buckets.entries()) {
604
+ if (this.buckets.size <= MAX_BUCKETS * 0.9) {
605
+ break;
606
+ }
607
+ if (bucket.active === 0 && bucket.startedAt.length === 0 && !this.queuesByBucket.has(key)) {
608
+ this.buckets.delete(key);
609
+ this.queuesByBucket.delete(key);
610
+ this.pendingBuckets.delete(key);
611
+ }
612
+ }
613
+ }
531
614
  cleanupBucket(bucketKey, bucket, intervalMs) {
532
615
  const queued = this.queuesByBucket.get(bucketKey)?.length ?? 0;
533
616
  if (queued === 0 && bucket.active === 0 && bucket.startedAt.length === 0) {
@@ -730,13 +813,12 @@ var TtlResolver = class {
730
813
  return;
731
814
  }
732
815
  const toRemove = Math.ceil(this.maxProfileEntries * 0.1);
733
- let removed = 0;
734
- for (const key of this.accessProfiles.keys()) {
735
- if (removed >= toRemove) {
736
- break;
816
+ const sorted = [...this.accessProfiles.entries()].sort((a, b) => a[1].lastAccessAt - b[1].lastAccessAt);
817
+ for (let i = 0; i < toRemove && i < sorted.length; i++) {
818
+ const entry = sorted[i];
819
+ if (entry) {
820
+ this.accessProfiles.delete(entry[0]);
737
821
  }
738
- this.accessProfiles.delete(key);
739
- removed += 1;
740
822
  }
741
823
  }
742
824
  };
@@ -819,6 +901,7 @@ var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
819
901
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
820
902
  var DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 3e4;
821
903
  var MAX_CACHE_KEY_LENGTH = 1024;
904
+ var MAX_PATTERN_LENGTH = 1024;
822
905
  var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
823
906
  var DANGEROUS_OBJECT_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
824
907
  var DebugLogger = class {
@@ -867,6 +950,14 @@ var CacheStack = class extends EventEmitter {
867
950
  const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
868
951
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
869
952
  this.tagIndex = options.tagIndex ?? new TagIndex();
953
+ this.keyDiscovery = new CacheKeyDiscovery({
954
+ layers: this.layers,
955
+ tagIndex: this.tagIndex,
956
+ shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
957
+ handleLayerFailure: async (layer, operation, error) => {
958
+ await this.handleLayerFailure(layer, operation, error);
959
+ }
960
+ });
870
961
  if (!options.tagIndex && layers.some((layer) => layer.isLocal === false)) {
871
962
  this.logger.warn?.(
872
963
  "Using the default in-memory TagIndex with a shared cache layer only tracks keys seen by this process. Use RedisTagIndex for cross-instance tag invalidation."
@@ -894,6 +985,7 @@ var CacheStack = class extends EventEmitter {
894
985
  unsubscribeInvalidation;
895
986
  logger;
896
987
  tagIndex;
988
+ keyDiscovery;
897
989
  fetchRateLimiter = new FetchRateLimiter();
898
990
  snapshotSerializer = new JsonSerializer();
899
991
  backgroundRefreshes = /* @__PURE__ */ new Map();
@@ -1243,15 +1335,16 @@ var CacheStack = class extends EventEmitter {
1243
1335
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1244
1336
  }
1245
1337
  async invalidateByPattern(pattern) {
1338
+ this.validatePattern(pattern);
1246
1339
  await this.awaitStartup("invalidateByPattern");
1247
- const keys = await this.collectKeysMatchingPattern(this.qualifyPattern(pattern));
1340
+ const keys = await this.keyDiscovery.collectKeysMatchingPattern(this.qualifyPattern(pattern));
1248
1341
  await this.deleteKeys(keys);
1249
1342
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1250
1343
  }
1251
1344
  async invalidateByPrefix(prefix) {
1252
1345
  await this.awaitStartup("invalidateByPrefix");
1253
1346
  const qualifiedPrefix = this.qualifyKey(this.validateCacheKey(prefix));
1254
- const keys = await this.collectKeysWithPrefix(qualifiedPrefix);
1347
+ const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix);
1255
1348
  await this.deleteKeys(keys);
1256
1349
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1257
1350
  }
@@ -1864,50 +1957,6 @@ var CacheStack = class extends EventEmitter {
1864
1957
  shouldBroadcastL1Invalidation() {
1865
1958
  return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false;
1866
1959
  }
1867
- async collectKeysWithPrefix(prefix) {
1868
- const matches = new Set(
1869
- this.tagIndex.keysForPrefix ? await this.tagIndex.keysForPrefix(prefix) : await this.tagIndex.matchPattern(`${prefix}*`)
1870
- );
1871
- await Promise.all(
1872
- this.layers.map(async (layer) => {
1873
- if (!layer.keys || this.shouldSkipLayer(layer)) {
1874
- return;
1875
- }
1876
- try {
1877
- const keys = await layer.keys();
1878
- for (const key of keys) {
1879
- if (key.startsWith(prefix)) {
1880
- matches.add(key);
1881
- }
1882
- }
1883
- } catch (error) {
1884
- await this.handleLayerFailure(layer, "invalidate-prefix-scan", error);
1885
- }
1886
- })
1887
- );
1888
- return [...matches];
1889
- }
1890
- async collectKeysMatchingPattern(pattern) {
1891
- const matches = new Set(await this.tagIndex.matchPattern(pattern));
1892
- await Promise.all(
1893
- this.layers.map(async (layer) => {
1894
- if (!layer.keys || this.shouldSkipLayer(layer)) {
1895
- return;
1896
- }
1897
- try {
1898
- const keys = await layer.keys();
1899
- for (const key of keys) {
1900
- if (PatternMatcher.matches(pattern, key)) {
1901
- matches.add(key);
1902
- }
1903
- }
1904
- } catch (error) {
1905
- await this.handleLayerFailure(layer, "invalidate-pattern-scan", error);
1906
- }
1907
- })
1908
- );
1909
- return [...matches];
1910
- }
1911
1960
  shouldCleanupGenerations() {
1912
1961
  return Boolean(this.options.generationCleanup);
1913
1962
  }
@@ -1930,7 +1979,7 @@ var CacheStack = class extends EventEmitter {
1930
1979
  }
1931
1980
  async cleanupGeneration(generation) {
1932
1981
  const prefix = `v${generation}:`;
1933
- const keys = await this.collectKeysWithPrefix(prefix);
1982
+ const keys = await this.keyDiscovery.collectKeysWithPrefix(prefix);
1934
1983
  if (keys.length === 0) {
1935
1984
  return;
1936
1985
  }
@@ -2180,6 +2229,17 @@ var CacheStack = class extends EventEmitter {
2180
2229
  }
2181
2230
  return key;
2182
2231
  }
2232
+ validatePattern(pattern) {
2233
+ if (pattern.length === 0) {
2234
+ throw new Error("Pattern must not be empty.");
2235
+ }
2236
+ if (pattern.length > MAX_PATTERN_LENGTH) {
2237
+ throw new Error(`Pattern length must be at most ${MAX_PATTERN_LENGTH} characters.`);
2238
+ }
2239
+ if (/[\u0000-\u001F\u007F]/.test(pattern)) {
2240
+ throw new Error("Pattern contains unsupported control characters.");
2241
+ }
2242
+ }
2183
2243
  validateTtlPolicy(name, policy) {
2184
2244
  if (!policy || typeof policy === "function" || policy === "until-midnight" || policy === "next-hour") {
2185
2245
  return;
@@ -2344,7 +2404,18 @@ var CacheStack = class extends EventEmitter {
2344
2404
  }
2345
2405
  };
2346
2406
  function createInstanceId() {
2347
- return globalThis.crypto?.randomUUID?.() ?? `layercache-${Date.now()}-${Math.random().toString(36).slice(2)}`;
2407
+ if (globalThis.crypto?.randomUUID) {
2408
+ return globalThis.crypto.randomUUID();
2409
+ }
2410
+ const bytes = new Uint8Array(16);
2411
+ if (globalThis.crypto?.getRandomValues) {
2412
+ globalThis.crypto.getRandomValues(bytes);
2413
+ } else {
2414
+ for (let i = 0; i < bytes.length; i++) {
2415
+ bytes[i] = Math.floor(Math.random() * 256);
2416
+ }
2417
+ }
2418
+ return `layercache-${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`;
2348
2419
  }
2349
2420
 
2350
2421
  // src/invalidation/RedisInvalidationBus.ts
@@ -2386,7 +2457,7 @@ var RedisInvalidationBus = class {
2386
2457
  async dispatchToHandlers(payload) {
2387
2458
  let message;
2388
2459
  try {
2389
- const parsed = JSON.parse(payload);
2460
+ const parsed = sanitizeJsonValue2(JSON.parse(payload));
2390
2461
  if (!this.isInvalidationMessage(parsed)) {
2391
2462
  throw new Error("Invalid invalidation payload shape.");
2392
2463
  }
@@ -2423,12 +2494,30 @@ var RedisInvalidationBus = class {
2423
2494
  console.error(`[layercache] ${message}`, error);
2424
2495
  }
2425
2496
  };
2497
+ var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
2498
+ function sanitizeJsonValue2(value) {
2499
+ if (Array.isArray(value)) {
2500
+ return value.map(sanitizeJsonValue2);
2501
+ }
2502
+ if (value && typeof value === "object") {
2503
+ const result = /* @__PURE__ */ Object.create(null);
2504
+ for (const key of Object.keys(value)) {
2505
+ if (!DANGEROUS_KEYS.has(key)) {
2506
+ result[key] = sanitizeJsonValue2(value[key]);
2507
+ }
2508
+ }
2509
+ return result;
2510
+ }
2511
+ return value;
2512
+ }
2426
2513
 
2427
2514
  // src/http/createCacheStatsHandler.ts
2428
2515
  function createCacheStatsHandler(cache) {
2429
2516
  return async (_request, response) => {
2430
2517
  response.statusCode = 200;
2431
2518
  response.setHeader?.("content-type", "application/json; charset=utf-8");
2519
+ response.setHeader?.("cache-control", "no-store");
2520
+ response.setHeader?.("x-content-type-options", "nosniff");
2432
2521
  response.end(JSON.stringify(cache.getStats(), null, 2));
2433
2522
  };
2434
2523
  }
@@ -2514,7 +2603,7 @@ function normalizeUrl(url) {
2514
2603
  try {
2515
2604
  const parsed = new URL(url, "http://localhost");
2516
2605
  parsed.searchParams.sort();
2517
- return decodeURIComponent(parsed.pathname) + parsed.search;
2606
+ return parsed.pathname + parsed.search;
2518
2607
  } catch {
2519
2608
  return url;
2520
2609
  }
@@ -2804,8 +2893,9 @@ var RedisLayer = class {
2804
2893
  }
2805
2894
  }
2806
2895
  try {
2807
- await this.client.del(this.withPrefix(key)).catch(() => void 0);
2808
- } catch {
2896
+ await this.client.del(this.withPrefix(key));
2897
+ } catch (deleteError) {
2898
+ console.warn(`[layercache] RedisLayer: failed to delete corrupted key "${key}"`, deleteError);
2809
2899
  }
2810
2900
  return null;
2811
2901
  }
@@ -3205,7 +3295,7 @@ var MemcachedLayer = class {
3205
3295
 
3206
3296
  // src/serialization/MsgpackSerializer.ts
3207
3297
  import { decode, encode } from "@msgpack/msgpack";
3208
- var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
3298
+ var DANGEROUS_KEYS2 = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
3209
3299
  var MsgpackSerializer = class {
3210
3300
  serialize(value) {
3211
3301
  return Buffer.from(encode(value));
@@ -3224,7 +3314,7 @@ function sanitizeMsgpackValue(value) {
3224
3314
  }
3225
3315
  const sanitized = {};
3226
3316
  for (const [key, entry] of Object.entries(value)) {
3227
- if (DANGEROUS_KEYS.has(key)) {
3317
+ if (DANGEROUS_KEYS2.has(key)) {
3228
3318
  continue;
3229
3319
  }
3230
3320
  sanitized[key] = sanitizeMsgpackValue(entry);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "layercache",
3
- "version": "1.2.3",
3
+ "version": "1.2.5",
4
4
  "description": "Hardened multi-layer caching for Node.js with memory, Redis, stampede prevention, and operational invalidation helpers.",
5
5
  "license": "MIT",
6
6
  "repository": {