layercache 2.1.0 → 3.0.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>
@@ -24,7 +24,7 @@
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
@@ -363,7 +375,7 @@ layercache is built for multi-instance production environments:
363
375
 
364
376
  - **Redis single-flight** - dedup misses across instances with distributed locks
365
377
  - **Redis invalidation bus** - pub/sub-based L1 invalidation for memory consistency
366
- - **Redis tag index** - shared tag tracking with optional sharding
378
+ - **Redis tag index** - shared tag tracking with 16 known-key shards by default
367
379
  - **Snapshot persistence** - export/import state between instances
368
380
 
369
381
  <details>
@@ -375,9 +387,13 @@ import {
375
387
  RedisInvalidationBus, RedisTagIndex, RedisSingleFlightCoordinator
376
388
  } from 'layercache'
377
389
 
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' })
390
+ const redis = new Redis(process.env.REDIS_URL)
391
+ const bus = new RedisInvalidationBus({
392
+ publisher: redis,
393
+ subscriber: new Redis(process.env.REDIS_URL),
394
+ signingSecret: process.env.LAYERCACHE_INVALIDATION_SECRET
395
+ })
396
+ const tagIndex = new RedisTagIndex({ client: redis, prefix: 'myapp:tags', knownKeysShards: 16 })
381
397
  const coordinator = new RedisSingleFlightCoordinator({ client: redis })
382
398
 
383
399
  const cache = new CacheStack(
@@ -371,6 +371,9 @@ var TagIndex = class {
371
371
  }
372
372
  insertKnownKey(key) {
373
373
  const isNew = !this.knownKeys.has(key);
374
+ if (!isNew) {
375
+ this.knownKeys.delete(key);
376
+ }
374
377
  this.knownKeys.set(key, Date.now());
375
378
  if (!isNew) {
376
379
  return;
@@ -469,13 +472,13 @@ var TagIndex = class {
469
472
  if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
470
473
  return;
471
474
  }
472
- const sorted = [...this.knownKeys.entries()].sort((a, b) => a[1] - b[1]);
473
475
  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]);
476
+ for (let i = 0; i < toRemove; i += 1) {
477
+ const oldestKey = this.knownKeys.keys().next().value;
478
+ if (oldestKey === void 0) {
479
+ break;
478
480
  }
481
+ this.removeKnownKey(oldestKey);
479
482
  }
480
483
  }
481
484
  removeKey(key) {
@@ -526,6 +529,41 @@ var TagIndex = class {
526
529
  }
527
530
  };
528
531
 
532
+ // src/integrations/httpCacheKeys.ts
533
+ var SENSITIVE_QUERY_PARAMETERS = /* @__PURE__ */ new Set([
534
+ "access_token",
535
+ "api_key",
536
+ "apikey",
537
+ "auth",
538
+ "authorization",
539
+ "code",
540
+ "credentials",
541
+ "id_token",
542
+ "jwt",
543
+ "password",
544
+ "private_key",
545
+ "refresh_token",
546
+ "secret",
547
+ "session",
548
+ "sessionid",
549
+ "session_id",
550
+ "token"
551
+ ]);
552
+ function normalizeHttpCacheUrl(url) {
553
+ try {
554
+ const parsed = new URL(url, "http://localhost");
555
+ for (const name of [...parsed.searchParams.keys()]) {
556
+ if (SENSITIVE_QUERY_PARAMETERS.has(name.toLowerCase())) {
557
+ parsed.searchParams.delete(name);
558
+ }
559
+ }
560
+ parsed.searchParams.sort();
561
+ return parsed.pathname + parsed.search;
562
+ } catch {
563
+ return url;
564
+ }
565
+ }
566
+
529
567
  // src/integrations/hono.ts
530
568
  function createHonoCacheMiddleware(cache, options = {}) {
531
569
  const allowedMethods = new Set((options.methods ?? ["GET"]).map((method) => method.toUpperCase()));
@@ -540,39 +578,44 @@ function createHonoCacheMiddleware(cache, options = {}) {
540
578
  return;
541
579
  }
542
580
  const rawPath = context.req.path ?? context.req.url ?? "/";
543
- const key = options.keyResolver ? options.keyResolver(context.req) : `${method}:${normalizeUrl(rawPath)}`;
581
+ const key = options.keyResolver ? options.keyResolver(context.req) : `${method}:${normalizeHttpCacheUrl(rawPath)}`;
544
582
  const cached = await cache.get(key, void 0, options);
545
583
  if (cached !== null) {
546
584
  context.header?.("x-cache", "HIT");
547
585
  context.header?.("content-type", "application/json; charset=utf-8");
548
586
  return context.json(cached);
549
587
  }
588
+ let currentStatus;
589
+ const originalStatus = context.status?.bind(context);
590
+ if (originalStatus) {
591
+ context.status = (status) => {
592
+ currentStatus = status;
593
+ return originalStatus(status);
594
+ };
595
+ }
550
596
  const originalJson = context.json.bind(context);
551
597
  context.json = (body, status) => {
552
598
  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)
599
+ if (isSuccessfulStatus(status ?? currentStatus)) {
600
+ cache.set(key, body, options).catch((err) => {
601
+ cache.emit("error", {
602
+ operation: "set",
603
+ error: err instanceof Error ? err.message : String(err)
604
+ });
557
605
  });
558
- });
606
+ }
559
607
  return originalJson(body, status);
560
608
  };
561
609
  await next();
562
610
  };
563
611
  }
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
- }
612
+ function isSuccessfulStatus(statusCode) {
613
+ return statusCode === void 0 || statusCode >= 200 && statusCode < 300;
572
614
  }
573
615
 
574
616
  export {
575
617
  MemoryLayer,
576
618
  TagIndex,
619
+ normalizeHttpCacheUrl,
577
620
  createHonoCacheMiddleware
578
621
  };
@@ -140,16 +140,20 @@ function validateContextEntryOptions(name, options) {
140
140
  }
141
141
 
142
142
  // src/invalidation/RedisTagIndex.ts
143
+ var DEFAULT_KNOWN_KEYS_SHARDS = 16;
143
144
  var RedisTagIndex = class {
144
145
  client;
145
146
  prefix;
146
147
  scanCount;
147
148
  knownKeysShards;
149
+ logger;
150
+ warnedLegacyKnownKeys = false;
148
151
  constructor(options) {
149
152
  this.client = options.client;
150
153
  this.prefix = options.prefix ?? "layercache:tag-index";
151
154
  this.scanCount = options.scanCount ?? 100;
152
155
  this.knownKeysShards = normalizeKnownKeysShards(options.knownKeysShards);
156
+ this.logger = options.logger;
153
157
  }
154
158
  /**
155
159
  * Records a key as known without changing tag assignments.
@@ -185,6 +189,9 @@ var RedisTagIndex = class {
185
189
  const existingTags = await this.client.smembers(keyTagsKey);
186
190
  const pipeline = this.client.pipeline();
187
191
  pipeline.srem(this.knownKeysKeyFor(key), key);
192
+ if (this.knownKeysShards > 1) {
193
+ pipeline.srem(this.legacyKnownKeysKey(), key);
194
+ }
188
195
  pipeline.del(keyTagsKey);
189
196
  for (const tag of existingTags) {
190
197
  pipeline.srem(this.tagKeysKey(tag), key);
@@ -215,28 +222,34 @@ var RedisTagIndex = class {
215
222
  * Returns known keys that start with a prefix.
216
223
  */
217
224
  async keysForPrefix(prefix) {
218
- const matches = [];
219
- for (const knownKeysKey of this.knownKeysKeys()) {
225
+ const matches = /* @__PURE__ */ new Set();
226
+ for (const knownKeysKey of await this.knownKeysKeysForRead()) {
220
227
  let cursor = "0";
221
228
  do {
222
229
  const [nextCursor, keys] = await this.client.sscan(knownKeysKey, cursor, "COUNT", this.scanCount);
223
230
  cursor = nextCursor;
224
- matches.push(...keys.filter((key) => key.startsWith(prefix)));
231
+ for (const key of keys) {
232
+ if (key.startsWith(prefix)) {
233
+ matches.add(key);
234
+ }
235
+ }
225
236
  } while (cursor !== "0");
226
237
  }
227
- return matches;
238
+ return [...matches];
228
239
  }
229
240
  /**
230
241
  * Visits known keys that start with a prefix.
231
242
  */
232
243
  async forEachKeyForPrefix(prefix, visitor) {
233
- for (const knownKeysKey of this.knownKeysKeys()) {
244
+ const visited = /* @__PURE__ */ new Set();
245
+ for (const knownKeysKey of await this.knownKeysKeysForRead()) {
234
246
  let cursor = "0";
235
247
  do {
236
248
  const [nextCursor, keys] = await this.client.sscan(knownKeysKey, cursor, "COUNT", this.scanCount);
237
249
  cursor = nextCursor;
238
250
  for (const key of keys) {
239
- if (key.startsWith(prefix)) {
251
+ if (key.startsWith(prefix) && !visited.has(key)) {
252
+ visited.add(key);
240
253
  await visitor(key);
241
254
  }
242
255
  }
@@ -253,8 +266,8 @@ var RedisTagIndex = class {
253
266
  * Returns known keys matching a wildcard pattern.
254
267
  */
255
268
  async matchPattern(pattern) {
256
- const matches = [];
257
- for (const knownKeysKey of this.knownKeysKeys()) {
269
+ const matches = /* @__PURE__ */ new Set();
270
+ for (const knownKeysKey of await this.knownKeysKeysForRead()) {
258
271
  let cursor = "0";
259
272
  do {
260
273
  const [nextCursor, keys] = await this.client.sscan(
@@ -266,16 +279,21 @@ var RedisTagIndex = class {
266
279
  this.scanCount
267
280
  );
268
281
  cursor = nextCursor;
269
- matches.push(...keys.filter((key) => PatternMatcher.matches(pattern, key)));
282
+ for (const key of keys) {
283
+ if (PatternMatcher.matches(pattern, key)) {
284
+ matches.add(key);
285
+ }
286
+ }
270
287
  } while (cursor !== "0");
271
288
  }
272
- return matches;
289
+ return [...matches];
273
290
  }
274
291
  /**
275
292
  * Visits known keys matching a wildcard pattern.
276
293
  */
277
294
  async forEachKeyMatchingPattern(pattern, visitor) {
278
- for (const knownKeysKey of this.knownKeysKeys()) {
295
+ const visited = /* @__PURE__ */ new Set();
296
+ for (const knownKeysKey of await this.knownKeysKeysForRead()) {
279
297
  let cursor = "0";
280
298
  do {
281
299
  const [nextCursor, keys] = await this.client.sscan(
@@ -288,7 +306,8 @@ var RedisTagIndex = class {
288
306
  );
289
307
  cursor = nextCursor;
290
308
  for (const key of keys) {
291
- if (PatternMatcher.matches(pattern, key)) {
309
+ if (PatternMatcher.matches(pattern, key) && !visited.has(key)) {
310
+ visited.add(key);
292
311
  await visitor(key);
293
312
  }
294
313
  }
@@ -305,6 +324,31 @@ var RedisTagIndex = class {
305
324
  }
306
325
  await this.client.del(...indexKeys);
307
326
  }
327
+ async migrateLegacyKnownKeys() {
328
+ if (this.knownKeysShards === 1) {
329
+ return { migratedKeys: 0 };
330
+ }
331
+ const legacyKey = this.legacyKnownKeysKey();
332
+ let cursor = "0";
333
+ let migratedKeys = 0;
334
+ do {
335
+ const [nextCursor, keys] = await this.client.sscan(legacyKey, cursor, "COUNT", this.scanCount);
336
+ cursor = nextCursor;
337
+ if (keys.length === 0) {
338
+ continue;
339
+ }
340
+ const pipeline = this.client.pipeline();
341
+ for (const key of keys) {
342
+ pipeline.sadd(this.knownKeysKeyFor(key), key);
343
+ }
344
+ await pipeline.exec();
345
+ migratedKeys += keys.length;
346
+ } while (cursor !== "0");
347
+ if (migratedKeys > 0) {
348
+ await this.client.del(legacyKey);
349
+ }
350
+ return { migratedKeys };
351
+ }
308
352
  async scanIndexKeys() {
309
353
  const matches = [];
310
354
  let cursor = "0";
@@ -322,12 +366,40 @@ var RedisTagIndex = class {
322
366
  }
323
367
  return `${this.prefix}:keys:${simpleHash(key) % this.knownKeysShards}`;
324
368
  }
369
+ async knownKeysKeysForRead() {
370
+ if (this.knownKeysShards === 1) {
371
+ return [this.legacyKnownKeysKey()];
372
+ }
373
+ const shardedKeys = this.knownKeysKeys();
374
+ const legacyKey = this.legacyKnownKeysKey();
375
+ const legacyExists = await this.client.exists(legacyKey) > 0;
376
+ if (!legacyExists) {
377
+ return shardedKeys;
378
+ }
379
+ this.warnLegacyKnownKeys(legacyKey);
380
+ return [legacyKey, ...shardedKeys];
381
+ }
325
382
  knownKeysKeys() {
326
383
  if (this.knownKeysShards === 1) {
327
384
  return [`${this.prefix}:keys`];
328
385
  }
329
386
  return Array.from({ length: this.knownKeysShards }, (_, index) => `${this.prefix}:keys:${index}`);
330
387
  }
388
+ legacyKnownKeysKey() {
389
+ return `${this.prefix}:keys`;
390
+ }
391
+ warnLegacyKnownKeys(legacyKey) {
392
+ if (this.warnedLegacyKnownKeys) {
393
+ return;
394
+ }
395
+ this.warnedLegacyKnownKeys = true;
396
+ const message = "RedisTagIndex detected a legacy RedisTagIndex known-key set. Run `layercache migrate-tag-index` to migrate keys into the sharded layout.";
397
+ if (this.logger?.warn) {
398
+ this.logger.warn(message, { legacyKey, knownKeysShards: this.knownKeysShards });
399
+ return;
400
+ }
401
+ console.warn(`[layercache] ${message}`, { legacyKey, knownKeysShards: this.knownKeysShards });
402
+ }
331
403
  keyTagsKey(key) {
332
404
  return `${this.prefix}:key:${encodeURIComponent(key)}`;
333
405
  }
@@ -337,7 +409,7 @@ var RedisTagIndex = class {
337
409
  };
338
410
  function normalizeKnownKeysShards(value) {
339
411
  if (value === void 0) {
340
- return 1;
412
+ return DEFAULT_KNOWN_KEYS_SHARDS;
341
413
  }
342
414
  if (!Number.isInteger(value) || value <= 0) {
343
415
  throw new Error("RedisTagIndex.knownKeysShards must be a positive integer.");