layercache 2.1.0 → 3.1.0

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/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
- <img src="./logo.png" width="520" alt="layercache logo">
6
+ <img src="./layercache-stampede.gif" width="930" alt="layercache stampede prevention demo">
7
7
  </p>
8
8
 
9
9
  <h1 align="center">layercache</h1>
@@ -19,12 +19,12 @@
19
19
  <a href="./LICENSE"><img src="https://img.shields.io/badge/license-Apache_2.0-green" alt="license"></a>
20
20
  <a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-first-3178C6?logo=typescript&logoColor=white" alt="TypeScript"></a>
21
21
  <img src="https://img.shields.io/badge/Node.js-%E2%89%A5_20-339933?logo=nodedotjs&logoColor=white" alt="Node.js >= 20">
22
- <img src="https://img.shields.io/badge/tests-549_passing-brightgreen" alt="tests">
23
- <a href="https://coveralls.io/github/flyingsquirrel0419/layercache?branch=main"><img src="https://coveralls.io/repos/github/flyingsquirrel0419/layercache/badge.svg?branch=main&t=20260410" alt="Coveralls"></a>
22
+ <img src="https://img.shields.io/badge/tests-598_passing-brightgreen" alt="tests">
23
+ <a href="https://coveralls.io/github/flyingsquirrel0419/layercache?branch=main"><img src="https://coveralls.io/repos/github/flyingsquirrel0419/layercache/badge.svg?branch=main&t=20260517" alt="Coveralls"></a>
24
24
  </p>
25
25
 
26
26
  <p align="center">
27
- <a href="https://layercache.flyingsquirrel.me">Website</a>&nbsp;&nbsp;|&nbsp;&nbsp;
27
+ <a href="https://flyingsquirrel0419.github.io/layercache">Website</a>&nbsp;&nbsp;|&nbsp;&nbsp;
28
28
  <a href="#-quick-start">Quick Start</a>&nbsp;&nbsp;|&nbsp;&nbsp;
29
29
  <a href="#-performance">Performance</a>&nbsp;&nbsp;|&nbsp;&nbsp;
30
30
  <a href="./docs/api.md">API Reference</a>&nbsp;&nbsp;|&nbsp;&nbsp;
@@ -53,6 +53,18 @@ layercache is a multi-layer cache (Memory → Redis → Disk) for Node.js. Stamp
53
53
 
54
54
  ---
55
55
 
56
+ ## What's New in 3.0
57
+
58
+ - `RedisTagIndex` uses 16 known-key shards by default. Existing Redis tag indexes that still use the legacy `<prefix>:keys` set should be migrated with `npx layercache migrate-tag-index`.
59
+ - Production CLI commands reject plaintext `redis://` URLs unless `--allow-plaintext` is passed. Prefer `rediss://` for production Redis endpoints.
60
+ - Express and Hono implicit URL cache keys now strip sensitive query parameters before caching, and non-2xx JSON responses are not cached by default.
61
+ - Redis-backed generation persistence is available through `RedisGenerationStore`, and `CacheStack.getGeneration()` exposes the active generation.
62
+ - The docs site now runs on Rspress and GitHub Pages.
63
+
64
+ See the [changelog](./CHANGELOG.md) and [migration guide](./docs/migration-guide.md) before upgrading an existing deployment.
65
+
66
+ ---
67
+
56
68
  ## Quick Start
57
69
 
58
70
  ```bash
@@ -250,6 +262,8 @@ const cache = new CacheStack([
250
262
  | **Namespaces** | Scoped cache views with hierarchical prefix support |
251
263
  | **Cache warming** | Pre-populate layers at startup with priority-based loading |
252
264
  | **Negative caching** | Cache misses (e.g., "user not found") for short TTLs |
265
+ | **Stored null values** | `cacheNullValues` keeps intentional `null` values distinct from misses |
266
+ | **Entry introspection** | `getEntry()` reports value, kind, state, key, and source layer |
253
267
 
254
268
  ### Invalidation & Freshness
255
269
 
@@ -273,9 +287,11 @@ const cache = new CacheStack([
273
287
  |---|---|
274
288
  | **Graceful degradation** | Skip failed layers temporarily, keep cache available |
275
289
  | **Circuit breaker** | Stop hammering broken upstreams after repeated failures |
276
- | **Fetcher rate limiting** | Scoped to global, per-key, or per-fetcher with custom buckets |
290
+ | **Shared circuit breaker scopes** | Group failures by backend dependency with `scope: 'shared'` and `breakerKey` |
291
+ | **Fetcher rate limiting** | Scoped to global, per-key, or per-fetcher; `queueOverflow: 'reject'` rejects saturated queues and `'bypass'` runs overflow work directly |
277
292
  | **Write policies** | `strict` (fail if any layer fails) or `best-effort` |
278
293
  | **Write-behind** | Batch writes with configurable flush interval |
294
+ | **Bounded disk writes** | `DiskLayer.maxWriteQueueDepth` prevents unbounded serialized write buildup |
279
295
  | **Compression** | gzip / brotli in RedisLayer with configurable threshold |
280
296
  | **MessagePack** | Pluggable serializers (JSON default, MessagePack alternative) |
281
297
  | **Persistence** | Export/import snapshots to memory or disk |
@@ -363,7 +379,7 @@ layercache is built for multi-instance production environments:
363
379
 
364
380
  - **Redis single-flight** - dedup misses across instances with distributed locks
365
381
  - **Redis invalidation bus** - pub/sub-based L1 invalidation for memory consistency
366
- - **Redis tag index** - shared tag tracking with optional sharding
382
+ - **Redis tag index** - shared tag tracking with 16 known-key shards by default
367
383
  - **Snapshot persistence** - export/import state between instances
368
384
 
369
385
  <details>
@@ -375,9 +391,13 @@ import {
375
391
  RedisInvalidationBus, RedisTagIndex, RedisSingleFlightCoordinator
376
392
  } from 'layercache'
377
393
 
378
- const redis = new Redis()
379
- const bus = new RedisInvalidationBus({ publisher: redis, subscriber: new Redis() })
380
- const tagIndex = new RedisTagIndex({ client: redis, prefix: 'myapp:tags' })
394
+ const redis = new Redis(process.env.REDIS_URL)
395
+ const bus = new RedisInvalidationBus({
396
+ publisher: redis,
397
+ subscriber: new Redis(process.env.REDIS_URL),
398
+ signingSecret: process.env.LAYERCACHE_INVALIDATION_SECRET
399
+ })
400
+ const tagIndex = new RedisTagIndex({ client: redis, prefix: 'myapp:tags', knownKeysShards: 16 })
381
401
  const coordinator = new RedisSingleFlightCoordinator({ client: redis })
382
402
 
383
403
  const cache = new CacheStack(
@@ -41,9 +41,12 @@ function validateRateLimitOptions(name, options) {
41
41
  validatePositiveNumber(`${name}.maxConcurrent`, options.maxConcurrent);
42
42
  validatePositiveNumber(`${name}.intervalMs`, options.intervalMs);
43
43
  validatePositiveNumber(`${name}.maxPerInterval`, options.maxPerInterval);
44
- if (options.scope && !["global", "key", "fetcher"].includes(options.scope)) {
44
+ if (options.scope !== void 0 && !["global", "key", "fetcher"].includes(options.scope)) {
45
45
  throw new Error(`${name}.scope must be one of "global", "key", or "fetcher".`);
46
46
  }
47
+ if (options.queueOverflow !== void 0 && !["reject", "bypass"].includes(options.queueOverflow)) {
48
+ throw new Error(`${name}.queueOverflow must be one of "reject" or "bypass".`);
49
+ }
47
50
  if (options.bucketKey !== void 0 && options.bucketKey.length === 0) {
48
51
  throw new Error(`${name}.bucketKey must not be empty.`);
49
52
  }
@@ -124,6 +127,12 @@ function validateCircuitBreakerOptions(options) {
124
127
  }
125
128
  validatePositiveNumber("circuitBreaker.failureThreshold", options.failureThreshold);
126
129
  validatePositiveNumber("circuitBreaker.cooldownMs", options.cooldownMs);
130
+ if (options.scope !== void 0 && !["key", "shared"].includes(options.scope)) {
131
+ throw new Error('circuitBreaker.scope must be one of "key" or "shared".');
132
+ }
133
+ if (options.breakerKey !== void 0 && options.breakerKey.length === 0) {
134
+ throw new Error("circuitBreaker.breakerKey must not be empty.");
135
+ }
127
136
  }
128
137
  function validateContextEntryOptions(name, options) {
129
138
  if (!options) {
@@ -140,16 +149,20 @@ function validateContextEntryOptions(name, options) {
140
149
  }
141
150
 
142
151
  // src/invalidation/RedisTagIndex.ts
152
+ var DEFAULT_KNOWN_KEYS_SHARDS = 16;
143
153
  var RedisTagIndex = class {
144
154
  client;
145
155
  prefix;
146
156
  scanCount;
147
157
  knownKeysShards;
158
+ logger;
159
+ warnedLegacyKnownKeys = false;
148
160
  constructor(options) {
149
161
  this.client = options.client;
150
162
  this.prefix = options.prefix ?? "layercache:tag-index";
151
163
  this.scanCount = options.scanCount ?? 100;
152
164
  this.knownKeysShards = normalizeKnownKeysShards(options.knownKeysShards);
165
+ this.logger = options.logger;
153
166
  }
154
167
  /**
155
168
  * Records a key as known without changing tag assignments.
@@ -185,6 +198,9 @@ var RedisTagIndex = class {
185
198
  const existingTags = await this.client.smembers(keyTagsKey);
186
199
  const pipeline = this.client.pipeline();
187
200
  pipeline.srem(this.knownKeysKeyFor(key), key);
201
+ if (this.knownKeysShards > 1) {
202
+ pipeline.srem(this.legacyKnownKeysKey(), key);
203
+ }
188
204
  pipeline.del(keyTagsKey);
189
205
  for (const tag of existingTags) {
190
206
  pipeline.srem(this.tagKeysKey(tag), key);
@@ -215,28 +231,34 @@ var RedisTagIndex = class {
215
231
  * Returns known keys that start with a prefix.
216
232
  */
217
233
  async keysForPrefix(prefix) {
218
- const matches = [];
219
- for (const knownKeysKey of this.knownKeysKeys()) {
234
+ const matches = /* @__PURE__ */ new Set();
235
+ for (const knownKeysKey of await this.knownKeysKeysForRead()) {
220
236
  let cursor = "0";
221
237
  do {
222
238
  const [nextCursor, keys] = await this.client.sscan(knownKeysKey, cursor, "COUNT", this.scanCount);
223
239
  cursor = nextCursor;
224
- matches.push(...keys.filter((key) => key.startsWith(prefix)));
240
+ for (const key of keys) {
241
+ if (key.startsWith(prefix)) {
242
+ matches.add(key);
243
+ }
244
+ }
225
245
  } while (cursor !== "0");
226
246
  }
227
- return matches;
247
+ return [...matches];
228
248
  }
229
249
  /**
230
250
  * Visits known keys that start with a prefix.
231
251
  */
232
252
  async forEachKeyForPrefix(prefix, visitor) {
233
- for (const knownKeysKey of this.knownKeysKeys()) {
253
+ const visited = /* @__PURE__ */ new Set();
254
+ for (const knownKeysKey of await this.knownKeysKeysForRead()) {
234
255
  let cursor = "0";
235
256
  do {
236
257
  const [nextCursor, keys] = await this.client.sscan(knownKeysKey, cursor, "COUNT", this.scanCount);
237
258
  cursor = nextCursor;
238
259
  for (const key of keys) {
239
- if (key.startsWith(prefix)) {
260
+ if (key.startsWith(prefix) && !visited.has(key)) {
261
+ visited.add(key);
240
262
  await visitor(key);
241
263
  }
242
264
  }
@@ -253,8 +275,8 @@ var RedisTagIndex = class {
253
275
  * Returns known keys matching a wildcard pattern.
254
276
  */
255
277
  async matchPattern(pattern) {
256
- const matches = [];
257
- for (const knownKeysKey of this.knownKeysKeys()) {
278
+ const matches = /* @__PURE__ */ new Set();
279
+ for (const knownKeysKey of await this.knownKeysKeysForRead()) {
258
280
  let cursor = "0";
259
281
  do {
260
282
  const [nextCursor, keys] = await this.client.sscan(
@@ -266,16 +288,21 @@ var RedisTagIndex = class {
266
288
  this.scanCount
267
289
  );
268
290
  cursor = nextCursor;
269
- matches.push(...keys.filter((key) => PatternMatcher.matches(pattern, key)));
291
+ for (const key of keys) {
292
+ if (PatternMatcher.matches(pattern, key)) {
293
+ matches.add(key);
294
+ }
295
+ }
270
296
  } while (cursor !== "0");
271
297
  }
272
- return matches;
298
+ return [...matches];
273
299
  }
274
300
  /**
275
301
  * Visits known keys matching a wildcard pattern.
276
302
  */
277
303
  async forEachKeyMatchingPattern(pattern, visitor) {
278
- for (const knownKeysKey of this.knownKeysKeys()) {
304
+ const visited = /* @__PURE__ */ new Set();
305
+ for (const knownKeysKey of await this.knownKeysKeysForRead()) {
279
306
  let cursor = "0";
280
307
  do {
281
308
  const [nextCursor, keys] = await this.client.sscan(
@@ -288,7 +315,8 @@ var RedisTagIndex = class {
288
315
  );
289
316
  cursor = nextCursor;
290
317
  for (const key of keys) {
291
- if (PatternMatcher.matches(pattern, key)) {
318
+ if (PatternMatcher.matches(pattern, key) && !visited.has(key)) {
319
+ visited.add(key);
292
320
  await visitor(key);
293
321
  }
294
322
  }
@@ -305,6 +333,31 @@ var RedisTagIndex = class {
305
333
  }
306
334
  await this.client.del(...indexKeys);
307
335
  }
336
+ async migrateLegacyKnownKeys() {
337
+ if (this.knownKeysShards === 1) {
338
+ return { migratedKeys: 0 };
339
+ }
340
+ const legacyKey = this.legacyKnownKeysKey();
341
+ let cursor = "0";
342
+ let migratedKeys = 0;
343
+ do {
344
+ const [nextCursor, keys] = await this.client.sscan(legacyKey, cursor, "COUNT", this.scanCount);
345
+ cursor = nextCursor;
346
+ if (keys.length === 0) {
347
+ continue;
348
+ }
349
+ const pipeline = this.client.pipeline();
350
+ for (const key of keys) {
351
+ pipeline.sadd(this.knownKeysKeyFor(key), key);
352
+ }
353
+ await pipeline.exec();
354
+ migratedKeys += keys.length;
355
+ } while (cursor !== "0");
356
+ if (migratedKeys > 0) {
357
+ await this.client.del(legacyKey);
358
+ }
359
+ return { migratedKeys };
360
+ }
308
361
  async scanIndexKeys() {
309
362
  const matches = [];
310
363
  let cursor = "0";
@@ -322,12 +375,40 @@ var RedisTagIndex = class {
322
375
  }
323
376
  return `${this.prefix}:keys:${simpleHash(key) % this.knownKeysShards}`;
324
377
  }
378
+ async knownKeysKeysForRead() {
379
+ if (this.knownKeysShards === 1) {
380
+ return [this.legacyKnownKeysKey()];
381
+ }
382
+ const shardedKeys = this.knownKeysKeys();
383
+ const legacyKey = this.legacyKnownKeysKey();
384
+ const legacyExists = await this.client.exists(legacyKey) > 0;
385
+ if (!legacyExists) {
386
+ return shardedKeys;
387
+ }
388
+ this.warnLegacyKnownKeys(legacyKey);
389
+ return [legacyKey, ...shardedKeys];
390
+ }
325
391
  knownKeysKeys() {
326
392
  if (this.knownKeysShards === 1) {
327
393
  return [`${this.prefix}:keys`];
328
394
  }
329
395
  return Array.from({ length: this.knownKeysShards }, (_, index) => `${this.prefix}:keys:${index}`);
330
396
  }
397
+ legacyKnownKeysKey() {
398
+ return `${this.prefix}:keys`;
399
+ }
400
+ warnLegacyKnownKeys(legacyKey) {
401
+ if (this.warnedLegacyKnownKeys) {
402
+ return;
403
+ }
404
+ this.warnedLegacyKnownKeys = true;
405
+ const message = "RedisTagIndex detected a legacy RedisTagIndex known-key set. Run `layercache migrate-tag-index` to migrate keys into the sharded layout.";
406
+ if (this.logger?.warn) {
407
+ this.logger.warn(message, { legacyKey, knownKeysShards: this.knownKeysShards });
408
+ return;
409
+ }
410
+ console.warn(`[layercache] ${message}`, { legacyKey, knownKeysShards: this.knownKeysShards });
411
+ }
331
412
  keyTagsKey(key) {
332
413
  return `${this.prefix}:key:${encodeURIComponent(key)}`;
333
414
  }
@@ -337,7 +418,7 @@ var RedisTagIndex = class {
337
418
  };
338
419
  function normalizeKnownKeysShards(value) {
339
420
  if (value === void 0) {
340
- return 1;
421
+ return DEFAULT_KNOWN_KEYS_SHARDS;
341
422
  }
342
423
  if (!Number.isInteger(value) || value <= 0) {
343
424
  throw new Error("RedisTagIndex.knownKeysShards must be a positive integer.");
@@ -1,4 +1,5 @@
1
1
  import {
2
+ PatternMatcher,
2
3
  unwrapStoredValue
3
4
  } from "./chunk-KJDFYE5T.js";
4
5
 
@@ -245,30 +246,34 @@ var MemoryLayer = class {
245
246
  };
246
247
 
247
248
  // src/invalidation/TagIndex.ts
248
- var MAX_PATTERN_RECURSION_DEPTH = 500;
249
+ var DEFAULT_TOUCH_REFRESH_INTERVAL_MS = 1e3;
249
250
  var TagIndex = class {
250
251
  tagToKeys = /* @__PURE__ */ new Map();
251
252
  keyToTags = /* @__PURE__ */ new Map();
252
253
  knownKeys = /* @__PURE__ */ new Map();
253
254
  maxKnownKeys;
255
+ touchRefreshIntervalMs;
254
256
  nextNodeId = 1;
255
257
  root = this.createTrieNode();
256
258
  constructor(options = {}) {
257
259
  this.maxKnownKeys = options.maxKnownKeys ?? 1e5;
260
+ this.touchRefreshIntervalMs = options.touchRefreshIntervalMs ?? DEFAULT_TOUCH_REFRESH_INTERVAL_MS;
258
261
  }
259
262
  /**
260
263
  * Records a key as known without changing tag assignments.
261
264
  */
262
265
  async touch(key) {
263
- this.insertKnownKey(key);
264
- this.pruneKnownKeysIfNeeded();
266
+ if (this.insertKnownKey(key)) {
267
+ this.pruneKnownKeysIfNeeded();
268
+ }
265
269
  }
266
270
  /**
267
271
  * Replaces the tags associated with a key and records the key as known.
268
272
  */
269
273
  async track(key, tags) {
270
- this.insertKnownKey(key);
271
- this.pruneKnownKeysIfNeeded();
274
+ if (this.insertKnownKey(key)) {
275
+ this.pruneKnownKeysIfNeeded();
276
+ }
272
277
  if (tags.length === 0) {
273
278
  return;
274
279
  }
@@ -338,9 +343,14 @@ var TagIndex = class {
338
343
  * Returns known keys matching a wildcard pattern.
339
344
  */
340
345
  async matchPattern(pattern) {
341
- const matches = /* @__PURE__ */ new Set();
342
- this.collectPatternMatches(this.root, "", pattern, 0, matches, /* @__PURE__ */ new Set(), 0);
343
- return [...matches];
346
+ const literalPrefix = this.literalPrefix(pattern);
347
+ const node = this.findNode(literalPrefix);
348
+ if (!node) {
349
+ return [];
350
+ }
351
+ const candidates = [];
352
+ this.collectFromNode(node, literalPrefix, candidates);
353
+ return candidates.filter((key) => PatternMatcher.matches(pattern, key));
344
354
  }
345
355
  /**
346
356
  * Visits known keys matching a wildcard pattern.
@@ -370,10 +380,18 @@ var TagIndex = class {
370
380
  };
371
381
  }
372
382
  insertKnownKey(key) {
373
- const isNew = !this.knownKeys.has(key);
374
- this.knownKeys.set(key, Date.now());
383
+ const previousTouch = this.knownKeys.get(key);
384
+ const isNew = previousTouch === void 0;
385
+ const now = Date.now();
386
+ if (!isNew && now - previousTouch < this.touchRefreshIntervalMs) {
387
+ return false;
388
+ }
375
389
  if (!isNew) {
376
- return;
390
+ this.knownKeys.delete(key);
391
+ }
392
+ this.knownKeys.set(key, now);
393
+ if (!isNew) {
394
+ return true;
377
395
  }
378
396
  let node = this.root;
379
397
  for (const character of key) {
@@ -385,6 +403,7 @@ var TagIndex = class {
385
403
  node = child;
386
404
  }
387
405
  node.terminal = true;
406
+ return true;
388
407
  }
389
408
  findNode(prefix) {
390
409
  let node = this.root;
@@ -397,85 +416,52 @@ var TagIndex = class {
397
416
  return node;
398
417
  }
399
418
  collectFromNode(node, prefix, matches) {
400
- if (node.terminal) {
401
- matches.push(prefix);
402
- }
403
- for (const [character, child] of node.children) {
404
- this.collectFromNode(child, `${prefix}${character}`, matches);
419
+ const stack = [{ node, prefix }];
420
+ while (stack.length > 0) {
421
+ const current = stack.pop();
422
+ if (!current) {
423
+ continue;
424
+ }
425
+ if (current.node.terminal) {
426
+ matches.push(current.prefix);
427
+ }
428
+ const children = [...current.node.children].reverse();
429
+ for (const [character, child] of children) {
430
+ stack.push({ node: child, prefix: `${current.prefix}${character}` });
431
+ }
405
432
  }
406
433
  }
407
434
  async visitFromNode(node, prefix, visitor) {
408
- if (node.terminal) {
409
- await visitor(prefix);
410
- }
411
- for (const [character, child] of node.children) {
412
- await this.visitFromNode(child, `${prefix}${character}`, visitor);
413
- }
414
- }
415
- collectPatternMatches(node, prefix, pattern, patternIndex, matches, visited, depth) {
416
- if (depth > MAX_PATTERN_RECURSION_DEPTH) {
417
- return;
418
- }
419
- const stateKey = `${node.id}:${patternIndex}`;
420
- if (visited.has(stateKey)) {
421
- return;
422
- }
423
- visited.add(stateKey);
424
- if (patternIndex === pattern.length) {
425
- if (node.terminal) {
426
- matches.add(prefix);
435
+ const stack = [{ node, prefix }];
436
+ while (stack.length > 0) {
437
+ const current = stack.pop();
438
+ if (!current) {
439
+ continue;
427
440
  }
428
- return;
429
- }
430
- const patternChar = pattern[patternIndex];
431
- if (patternChar === void 0) {
432
- return;
433
- }
434
- if (patternChar === "*") {
435
- this.collectPatternMatches(node, prefix, pattern, patternIndex + 1, matches, visited, depth + 1);
436
- for (const [character, child2] of node.children) {
437
- this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex, matches, visited, depth + 1);
441
+ if (current.node.terminal) {
442
+ await visitor(current.prefix);
438
443
  }
439
- return;
440
- }
441
- if (patternChar === "?") {
442
- for (const [character, child2] of node.children) {
443
- this.collectPatternMatches(
444
- child2,
445
- `${prefix}${character}`,
446
- pattern,
447
- patternIndex + 1,
448
- matches,
449
- visited,
450
- depth + 1
451
- );
444
+ const children = [...current.node.children].reverse();
445
+ for (const [character, child] of children) {
446
+ stack.push({ node: child, prefix: `${current.prefix}${character}` });
452
447
  }
453
- return;
454
- }
455
- const child = node.children.get(patternChar);
456
- if (child) {
457
- this.collectPatternMatches(
458
- child,
459
- `${prefix}${patternChar}`,
460
- pattern,
461
- patternIndex + 1,
462
- matches,
463
- visited,
464
- depth + 1
465
- );
466
448
  }
467
449
  }
450
+ literalPrefix(pattern) {
451
+ const wildcardIndex = pattern.search(/[*?]/);
452
+ return wildcardIndex === -1 ? pattern : pattern.slice(0, wildcardIndex);
453
+ }
468
454
  pruneKnownKeysIfNeeded() {
469
455
  if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
470
456
  return;
471
457
  }
472
- const sorted = [...this.knownKeys.entries()].sort((a, b) => a[1] - b[1]);
473
458
  const toRemove = Math.ceil(this.maxKnownKeys * 0.1);
474
- for (let i = 0; i < toRemove && i < sorted.length; i += 1) {
475
- const entry = sorted[i];
476
- if (entry) {
477
- this.removeKey(entry[0]);
459
+ for (let i = 0; i < toRemove; i += 1) {
460
+ const oldestKey = this.knownKeys.keys().next().value;
461
+ if (oldestKey === void 0) {
462
+ break;
478
463
  }
464
+ this.removeKnownKey(oldestKey);
479
465
  }
480
466
  }
481
467
  removeKey(key) {
@@ -526,6 +512,41 @@ var TagIndex = class {
526
512
  }
527
513
  };
528
514
 
515
+ // src/integrations/httpCacheKeys.ts
516
+ var SENSITIVE_QUERY_PARAMETERS = /* @__PURE__ */ new Set([
517
+ "access_token",
518
+ "api_key",
519
+ "apikey",
520
+ "auth",
521
+ "authorization",
522
+ "code",
523
+ "credentials",
524
+ "id_token",
525
+ "jwt",
526
+ "password",
527
+ "private_key",
528
+ "refresh_token",
529
+ "secret",
530
+ "session",
531
+ "sessionid",
532
+ "session_id",
533
+ "token"
534
+ ]);
535
+ function normalizeHttpCacheUrl(url) {
536
+ try {
537
+ const parsed = new URL(url, "http://localhost");
538
+ for (const name of [...parsed.searchParams.keys()]) {
539
+ if (SENSITIVE_QUERY_PARAMETERS.has(name.toLowerCase())) {
540
+ parsed.searchParams.delete(name);
541
+ }
542
+ }
543
+ parsed.searchParams.sort();
544
+ return parsed.pathname + parsed.search;
545
+ } catch {
546
+ return url;
547
+ }
548
+ }
549
+
529
550
  // src/integrations/hono.ts
530
551
  function createHonoCacheMiddleware(cache, options = {}) {
531
552
  const allowedMethods = new Set((options.methods ?? ["GET"]).map((method) => method.toUpperCase()));
@@ -540,39 +561,44 @@ function createHonoCacheMiddleware(cache, options = {}) {
540
561
  return;
541
562
  }
542
563
  const rawPath = context.req.path ?? context.req.url ?? "/";
543
- const key = options.keyResolver ? options.keyResolver(context.req) : `${method}:${normalizeUrl(rawPath)}`;
564
+ const key = options.keyResolver ? options.keyResolver(context.req) : `${method}:${normalizeHttpCacheUrl(rawPath)}`;
544
565
  const cached = await cache.get(key, void 0, options);
545
566
  if (cached !== null) {
546
567
  context.header?.("x-cache", "HIT");
547
568
  context.header?.("content-type", "application/json; charset=utf-8");
548
569
  return context.json(cached);
549
570
  }
571
+ let currentStatus;
572
+ const originalStatus = context.status?.bind(context);
573
+ if (originalStatus) {
574
+ context.status = (status) => {
575
+ currentStatus = status;
576
+ return originalStatus(status);
577
+ };
578
+ }
550
579
  const originalJson = context.json.bind(context);
551
580
  context.json = (body, status) => {
552
581
  context.header?.("x-cache", "MISS");
553
- cache.set(key, body, options).catch((err) => {
554
- cache.emit("error", {
555
- operation: "set",
556
- error: err instanceof Error ? err.message : String(err)
582
+ if (isSuccessfulStatus(status ?? currentStatus)) {
583
+ cache.set(key, body, options).catch((err) => {
584
+ cache.emit("error", {
585
+ operation: "set",
586
+ error: err instanceof Error ? err.message : String(err)
587
+ });
557
588
  });
558
- });
589
+ }
559
590
  return originalJson(body, status);
560
591
  };
561
592
  await next();
562
593
  };
563
594
  }
564
- function normalizeUrl(url) {
565
- try {
566
- const parsed = new URL(url, "http://localhost");
567
- parsed.searchParams.sort();
568
- return parsed.pathname + parsed.search;
569
- } catch {
570
- return url;
571
- }
595
+ function isSuccessfulStatus(statusCode) {
596
+ return statusCode === void 0 || statusCode >= 200 && statusCode < 300;
572
597
  }
573
598
 
574
599
  export {
575
600
  MemoryLayer,
576
601
  TagIndex,
602
+ normalizeHttpCacheUrl,
577
603
  createHonoCacheMiddleware
578
604
  };