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 +29 -9
- package/dist/{chunk-6X7NV5BG.js → chunk-L6L7QXYF.js} +95 -14
- package/dist/{chunk-IVX6ABFX.js → chunk-XMUT66SH.js} +116 -90
- package/dist/cli.cjs +153 -25
- package/dist/cli.js +69 -13
- package/dist/{edge-BCU8D-Yd.d.cts → edge-LBUuZAdr.d.cts} +61 -2
- package/dist/{edge-BCU8D-Yd.d.ts → edge-LBUuZAdr.d.ts} +61 -2
- package/dist/edge.cjs +114 -90
- package/dist/edge.d.cts +1 -1
- package/dist/edge.d.ts +1 -1
- package/dist/edge.js +1 -1
- package/dist/index.cjs +578 -220
- package/dist/index.d.cts +55 -3
- package/dist/index.d.ts +55 -3
- package/dist/index.js +366 -113
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
|
-
<img src="./
|
|
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-
|
|
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=
|
|
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://
|
|
27
|
+
<a href="https://flyingsquirrel0419.github.io/layercache">Website</a> |
|
|
28
28
|
<a href="#-quick-start">Quick Start</a> |
|
|
29
29
|
<a href="#-performance">Performance</a> |
|
|
30
30
|
<a href="./docs/api.md">API Reference</a> |
|
|
@@ -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
|
-
| **
|
|
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
|
|
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({
|
|
380
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
342
|
-
|
|
343
|
-
|
|
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
|
|
374
|
-
|
|
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
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
|
475
|
-
const
|
|
476
|
-
if (
|
|
477
|
-
|
|
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}:${
|
|
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
|
-
|
|
554
|
-
cache.
|
|
555
|
-
|
|
556
|
-
|
|
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
|
|
565
|
-
|
|
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
|
};
|