layercache 1.3.1 → 1.3.3
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 +156 -119
- package/dist/{chunk-GJBKCFE6.js → chunk-5RCAX2BQ.js} +9 -9
- package/dist/{chunk-BQLL6IM5.js → chunk-BORDQ3LA.js} +135 -0
- package/dist/cli.cjs +77 -5
- package/dist/cli.js +37 -7
- package/dist/edge.cjs +9 -9
- package/dist/edge.js +1 -1
- package/dist/index.cjs +96 -58
- package/dist/index.js +81 -156
- package/package.json +2 -12
- package/examples/nestjs-module/app.module.ts +0 -15
- package/packages/nestjs/dist/index.cjs +0 -3952
- package/packages/nestjs/dist/index.d.cts +0 -629
- package/packages/nestjs/dist/index.d.ts +0 -629
- package/packages/nestjs/dist/index.js +0 -3915
package/README.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<strong>English</strong> | <a href="./docs/i18n/README.ko.md">한국어</a> | <a href="./docs/i18n/README.zh-CN.md">简体中文</a> | <a href="./docs/i18n/README.ja.md">日本語</a> | <a href="./docs/i18n/README.es.md">Español</a>
|
|
3
|
+
</p>
|
|
4
|
+
|
|
1
5
|
<p align="center">
|
|
2
6
|
<img src="./logo.png" width="520" alt="layercache logo">
|
|
3
7
|
</p>
|
|
@@ -5,8 +9,8 @@
|
|
|
5
9
|
<h1 align="center">layercache</h1>
|
|
6
10
|
|
|
7
11
|
<p align="center">
|
|
8
|
-
<strong>
|
|
9
|
-
<em>
|
|
12
|
+
<strong>100 concurrent requests. 1 DB call. Always.</strong><br>
|
|
13
|
+
<em>Multi-layer cache (Memory → Redis → Disk) with stampede prevention built in.</em>
|
|
10
14
|
</p>
|
|
11
15
|
|
|
12
16
|
<p align="center">
|
|
@@ -22,7 +26,7 @@
|
|
|
22
26
|
<p align="center">
|
|
23
27
|
<a href="https://layercache.flyingsquirrel.me">Website</a> |
|
|
24
28
|
<a href="#-quick-start">Quick Start</a> |
|
|
25
|
-
<a href="#-
|
|
29
|
+
<a href="#-performance">Performance</a> |
|
|
26
30
|
<a href="./docs/api.md">API Reference</a> |
|
|
27
31
|
<a href="#-integrations">Integrations</a> |
|
|
28
32
|
<a href="#-comparison">Comparison</a> |
|
|
@@ -32,37 +36,20 @@
|
|
|
32
36
|
|
|
33
37
|
---
|
|
34
38
|
|
|
35
|
-
##
|
|
39
|
+
## Why layercache?
|
|
36
40
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
```ts
|
|
42
|
+
// 100 concurrent requests hit an empty cache at the same time.
|
|
43
|
+
// Without stampede prevention, your DB gets 100 calls.
|
|
44
|
+
const results = await Promise.all(
|
|
45
|
+
Array.from({ length: 100 }, () =>
|
|
46
|
+
cache.get('user:1', () => db.findUser(1))
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
// fetcherExecutions: 1 ← your DB was called exactly once
|
|
44
50
|
```
|
|
45
51
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
**layercache** gives you a unified multi-layer cache with production-grade features built in:
|
|
49
|
-
|
|
50
|
-
```
|
|
51
|
-
┌───────────────────────────────────────┐
|
|
52
|
-
your app ---->│ layercache │
|
|
53
|
-
│ │
|
|
54
|
-
│ L1 Memory ~0.01ms (per-process) │
|
|
55
|
-
│ | │
|
|
56
|
-
│ L2 Redis ~0.5ms (shared) │
|
|
57
|
-
│ | │
|
|
58
|
-
│ L3 Disk ~2ms (persistent) │
|
|
59
|
-
│ | │
|
|
60
|
-
│ Fetcher ~20ms (runs once) │
|
|
61
|
-
└───────────────────────────────────────┘
|
|
62
|
-
|
|
63
|
-
On a hit --> serves the fastest layer, backfills the rest
|
|
64
|
-
On a miss --> fetcher runs ONCE (even under 100x concurrency)
|
|
65
|
-
```
|
|
52
|
+
layercache is a multi-layer cache (Memory → Redis → Disk) for Node.js. Stampede prevention, tag invalidation, and distributed consistency are built in — no extra config required.
|
|
66
53
|
|
|
67
54
|
---
|
|
68
55
|
|
|
@@ -113,8 +100,144 @@ const cache = new CacheStack([
|
|
|
113
100
|
|
|
114
101
|
---
|
|
115
102
|
|
|
103
|
+
## Performance
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
Environment: Node.js v20.20.1, Redis 7-alpine, Linux x86_64
|
|
107
|
+
CPU: AMD EPYC 4584PX 16-Core | RAM: 1.9 GB
|
|
108
|
+
Layers: MemoryLayer(ttl=60, maxSize=2000) + RedisLayer(ttl=300)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
┌──────────────────────────────┬──────────┬──────────┬──────────┬──────────┐
|
|
113
|
+
│ Scenario │ avg ms │ p95 ms │ min ms │ max ms │
|
|
114
|
+
├──────────────────────────────┼──────────┼──────────┼──────────┼──────────┤
|
|
115
|
+
│ L1 memory hit (warm) │ 0.011 │ 0.016 │ 0.004 │ 0.405 │
|
|
116
|
+
│ L1 hit in layered setup │ 0.006 │ 0.007 │ 0.004 │ 0.077 │
|
|
117
|
+
│ No cache / origin fetch │ 6.844 │ 11.196 │ 4.683 │ 11.196 │
|
|
118
|
+
└──────────────────────────────┴──────────┴──────────┴──────────┴──────────┘
|
|
119
|
+
|
|
120
|
+
┌──────────────────────────────┬────────────────────┐
|
|
121
|
+
│ │ 75 concurrent req │
|
|
122
|
+
├──────────────────────────────┼────────────────────┤
|
|
123
|
+
│ Without layercache │ 75 origin calls │
|
|
124
|
+
│ With layercache │ 1 origin call │ ← stampede prevention
|
|
125
|
+
└──────────────────────────────┴────────────────────┘
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Benchmark commands and full scenario notes: [docs/benchmarking.md](./docs/benchmarking.md)
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Migrating from node-cache-manager?
|
|
133
|
+
|
|
134
|
+
<table>
|
|
135
|
+
<tr>
|
|
136
|
+
<th>Before</th>
|
|
137
|
+
<th>After</th>
|
|
138
|
+
</tr>
|
|
139
|
+
<tr>
|
|
140
|
+
<td>
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
import { caching, multiCaching }
|
|
144
|
+
from 'cache-manager'
|
|
145
|
+
import { redisStore }
|
|
146
|
+
from 'cache-manager-redis-yet'
|
|
147
|
+
|
|
148
|
+
const mem = await caching('memory', {
|
|
149
|
+
max: 100,
|
|
150
|
+
ttl: 60 * 1000 // ms
|
|
151
|
+
})
|
|
152
|
+
const red = await caching(redisStore, {
|
|
153
|
+
url: 'redis://localhost:6379',
|
|
154
|
+
ttl: 300 * 1000 // ms
|
|
155
|
+
})
|
|
156
|
+
const cache = multiCaching([mem, red])
|
|
157
|
+
|
|
158
|
+
// stampede prevention: ❌
|
|
159
|
+
// auto backfill: ❌
|
|
160
|
+
// tag invalidation: ❌
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
</td>
|
|
164
|
+
<td>
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
import {
|
|
168
|
+
CacheStack,
|
|
169
|
+
MemoryLayer,
|
|
170
|
+
RedisLayer
|
|
171
|
+
} from 'layercache'
|
|
172
|
+
import Redis from 'ioredis'
|
|
173
|
+
|
|
174
|
+
const cache = new CacheStack([
|
|
175
|
+
new MemoryLayer({ ttl: 60 }), // s
|
|
176
|
+
new RedisLayer({
|
|
177
|
+
client: new Redis(),
|
|
178
|
+
ttl: 300 // s
|
|
179
|
+
})
|
|
180
|
+
])
|
|
181
|
+
|
|
182
|
+
// stampede prevention: ✅
|
|
183
|
+
// auto backfill: ✅
|
|
184
|
+
// tag invalidation: ✅
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
</td>
|
|
188
|
+
</tr>
|
|
189
|
+
</table>
|
|
190
|
+
|
|
191
|
+
> Full migration guides for [keyv and cacheable](./docs/migration-guide.md).
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Comparison
|
|
196
|
+
|
|
197
|
+
| | node-cache-manager | keyv | cacheable | **layercache** |
|
|
198
|
+
|---|:---:|:---:|:---:|:---:|
|
|
199
|
+
| Multi-layer + auto backfill | Partial | Plugin | -- | **Yes** |
|
|
200
|
+
| Stampede prevention | -- | -- | -- | **Yes** |
|
|
201
|
+
| Tag invalidation | -- | Yes | Yes | **Yes** |
|
|
202
|
+
| TypeScript-first | Partial | Yes | Yes | **Yes** |
|
|
203
|
+
| Event hooks | Yes | Yes | Yes | **Yes** |
|
|
204
|
+
|
|
205
|
+
<details>
|
|
206
|
+
<summary>Full comparison (19 features)</summary>
|
|
207
|
+
|
|
208
|
+
| | node-cache-manager | keyv | cacheable | **layercache** |
|
|
209
|
+
|---|:---:|:---:|:---:|:---:|
|
|
210
|
+
| Multi-layer with auto backfill | Partial | Plugin | -- | **Yes** |
|
|
211
|
+
| Stampede prevention | -- | -- | -- | **Yes** |
|
|
212
|
+
| Distributed single-flight | -- | -- | -- | **Yes** |
|
|
213
|
+
| Tag invalidation | -- | Yes | Yes | **Yes** |
|
|
214
|
+
| Distributed tags | -- | -- | -- | **Yes** |
|
|
215
|
+
| Cross-server L1 flush | -- | -- | -- | **Yes** |
|
|
216
|
+
| Stale-while-revalidate | -- | -- | -- | **Yes** |
|
|
217
|
+
| Circuit breaker | -- | -- | -- | **Yes** |
|
|
218
|
+
| Graceful degradation | -- | -- | -- | **Yes** |
|
|
219
|
+
| Sliding / adaptive TTL | -- | -- | -- | **Yes** |
|
|
220
|
+
| Cache warming | -- | -- | -- | **Yes** |
|
|
221
|
+
| Persistence / snapshots | -- | -- | -- | **Yes** |
|
|
222
|
+
| Compression | -- | -- | Yes | **Yes** |
|
|
223
|
+
| Admin CLI | -- | -- | -- | **Yes** |
|
|
224
|
+
| TypeScript-first | Partial | Yes | Yes | **Yes** |
|
|
225
|
+
| Wrap / decorator API | Yes | -- | -- | **Yes** |
|
|
226
|
+
| Namespaces | -- | Yes | Yes | **Yes** |
|
|
227
|
+
| Event hooks | Yes | Yes | Yes | **Yes** |
|
|
228
|
+
| Custom layers | Partial | -- | -- | **Yes** |
|
|
229
|
+
|
|
230
|
+
</details>
|
|
231
|
+
|
|
232
|
+
> See the full [comparison guide](./docs/comparison.md) for detailed breakdowns.
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
116
236
|
## Features
|
|
117
237
|
|
|
238
|
+
<details>
|
|
239
|
+
<summary><b>Core Caching, Invalidation, Resilience & Observability (click to expand)</b></summary>
|
|
240
|
+
|
|
118
241
|
### Core Caching
|
|
119
242
|
|
|
120
243
|
| Feature | What it does |
|
|
@@ -169,6 +292,8 @@ const cache = new CacheStack([
|
|
|
169
292
|
| **HTTP stats handler** | JSON endpoint for dashboards |
|
|
170
293
|
| **Admin CLI** | `npx layercache stats\|keys\|invalidate` for Redis-backed caches |
|
|
171
294
|
|
|
295
|
+
</details>
|
|
296
|
+
|
|
172
297
|
---
|
|
173
298
|
|
|
174
299
|
## Integrations
|
|
@@ -182,7 +307,6 @@ layercache plugs into the frameworks you already use:
|
|
|
182
307
|
| **Hono** | `createHonoCacheMiddleware(cache, opts)` - edge-compatible middleware |
|
|
183
308
|
| **tRPC** | `createTrpcCacheMiddleware(cache, prefix, opts)` - procedure middleware |
|
|
184
309
|
| **GraphQL** | `cacheGraphqlResolver(cache, prefix, resolver, opts)` - field resolver wrapper |
|
|
185
|
-
| **NestJS** | `@cachestack/nestjs` - `CacheStackModule.forRoot()`, `@Cacheable()` decorator |
|
|
186
310
|
| **Next.js** | Works natively with App Router and API routes |
|
|
187
311
|
| **OpenTelemetry** | `createOpenTelemetryPlugin(cache, tracer)` - event-driven tracing spans without monkey-patching |
|
|
188
312
|
|
|
@@ -205,42 +329,6 @@ app.get('/api/users', createExpressCacheMiddleware(cache, {
|
|
|
205
329
|
|
|
206
330
|
</details>
|
|
207
331
|
|
|
208
|
-
<details>
|
|
209
|
-
<summary><b>NestJS example</b></summary>
|
|
210
|
-
|
|
211
|
-
```bash
|
|
212
|
-
npm install @cachestack/nestjs
|
|
213
|
-
```
|
|
214
|
-
|
|
215
|
-
```ts
|
|
216
|
-
// app.module.ts
|
|
217
|
-
import { CacheStackModule } from '@cachestack/nestjs'
|
|
218
|
-
|
|
219
|
-
@Module({
|
|
220
|
-
imports: [
|
|
221
|
-
CacheStackModule.forRoot({
|
|
222
|
-
layers: [
|
|
223
|
-
new MemoryLayer({ ttl: 20 }),
|
|
224
|
-
new RedisLayer({ client: redis, ttl: 300 })
|
|
225
|
-
]
|
|
226
|
-
})
|
|
227
|
-
]
|
|
228
|
-
})
|
|
229
|
-
export class AppModule {}
|
|
230
|
-
|
|
231
|
-
// user.service.ts
|
|
232
|
-
@Injectable()
|
|
233
|
-
export class UserService {
|
|
234
|
-
constructor(@InjectCacheStack() private readonly cache: CacheStack) {}
|
|
235
|
-
|
|
236
|
-
async getUser(id: number) {
|
|
237
|
-
return this.cache.get(`user:${id}`, () => this.db.findUser(id))
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
```
|
|
241
|
-
|
|
242
|
-
</details>
|
|
243
|
-
|
|
244
332
|
<details>
|
|
245
333
|
<summary><b>Next.js App Router example</b></summary>
|
|
246
334
|
|
|
@@ -309,56 +397,6 @@ const cache = new CacheStack(
|
|
|
309
397
|
|
|
310
398
|
---
|
|
311
399
|
|
|
312
|
-
## Performance
|
|
313
|
-
|
|
314
|
-
```
|
|
315
|
-
┌─────────────────────┬──────────────┐
|
|
316
|
-
│ Scenario │ Avg Latency │
|
|
317
|
-
├─────────────────────┼──────────────┤
|
|
318
|
-
│ L1 memory hit │ ~0.006 ms │
|
|
319
|
-
│ L2 Redis hit │ ~0.020 ms │
|
|
320
|
-
│ No cache (sim. DB) │ ~1.08 ms │
|
|
321
|
-
└─────────────────────┴──────────────┘
|
|
322
|
-
|
|
323
|
-
┌─────────────────────┬────────┐
|
|
324
|
-
│ concurrentRequests │ 100 │
|
|
325
|
-
│ fetcherExecutions │ 1 │ <-- stampede prevention
|
|
326
|
-
└─────────────────────┴────────┘
|
|
327
|
-
```
|
|
328
|
-
|
|
329
|
-
Benchmark commands, fixtures, and scenario notes live in [docs/benchmarking.md](./docs/benchmarking.md).
|
|
330
|
-
|
|
331
|
-
---
|
|
332
|
-
|
|
333
|
-
## Comparison
|
|
334
|
-
|
|
335
|
-
| | node-cache-manager | keyv | cacheable | **layercache** |
|
|
336
|
-
|---|:---:|:---:|:---:|:---:|
|
|
337
|
-
| Multi-layer with auto backfill | Partial | Plugin | -- | **Yes** |
|
|
338
|
-
| Stampede prevention | -- | -- | -- | **Yes** |
|
|
339
|
-
| Distributed single-flight | -- | -- | -- | **Yes** |
|
|
340
|
-
| Tag invalidation | -- | -- | Yes | **Yes** |
|
|
341
|
-
| Distributed tags | -- | -- | -- | **Yes** |
|
|
342
|
-
| Cross-server L1 flush | -- | -- | -- | **Yes** |
|
|
343
|
-
| Stale-while-revalidate | -- | -- | -- | **Yes** |
|
|
344
|
-
| Circuit breaker | -- | -- | -- | **Yes** |
|
|
345
|
-
| Graceful degradation | -- | -- | -- | **Yes** |
|
|
346
|
-
| Sliding / adaptive TTL | -- | -- | -- | **Yes** |
|
|
347
|
-
| Cache warming | -- | -- | -- | **Yes** |
|
|
348
|
-
| Persistence / snapshots | -- | -- | -- | **Yes** |
|
|
349
|
-
| Compression | -- | -- | Yes | **Yes** |
|
|
350
|
-
| Admin CLI | -- | -- | -- | **Yes** |
|
|
351
|
-
| NestJS module | -- | -- | -- | **Yes** |
|
|
352
|
-
| TypeScript-first | Partial | Yes | Yes | **Yes** |
|
|
353
|
-
| Wrap / decorator API | Yes | -- | -- | **Yes** |
|
|
354
|
-
| Namespaces | -- | Yes | Yes | **Yes** |
|
|
355
|
-
| Event hooks | Yes | Yes | Yes | **Yes** |
|
|
356
|
-
| Custom layers | Partial | -- | -- | **Yes** |
|
|
357
|
-
|
|
358
|
-
> See the full [comparison guide](./docs/comparison.md) for detailed breakdowns.
|
|
359
|
-
|
|
360
|
-
---
|
|
361
|
-
|
|
362
400
|
## Documentation
|
|
363
401
|
|
|
364
402
|
| Document | Description |
|
|
@@ -377,7 +415,6 @@ Benchmark commands, fixtures, and scenario notes live in [docs/benchmarking.md](
|
|
|
377
415
|
The [`examples/`](./examples) directory contains ready-to-run projects:
|
|
378
416
|
|
|
379
417
|
- [`express-api/`](./examples/express-api/) - Express REST API with layered caching
|
|
380
|
-
- [`nestjs-module/`](./examples/nestjs-module/) - NestJS module integration
|
|
381
418
|
- [`nextjs-api-routes/`](./examples/nextjs-api-routes/) - Next.js App Router with layercache
|
|
382
419
|
|
|
383
420
|
---
|
|
@@ -195,7 +195,7 @@ var MAX_PATTERN_RECURSION_DEPTH = 500;
|
|
|
195
195
|
var TagIndex = class {
|
|
196
196
|
tagToKeys = /* @__PURE__ */ new Map();
|
|
197
197
|
keyToTags = /* @__PURE__ */ new Map();
|
|
198
|
-
knownKeys = /* @__PURE__ */ new
|
|
198
|
+
knownKeys = /* @__PURE__ */ new Map();
|
|
199
199
|
maxKnownKeys;
|
|
200
200
|
nextNodeId = 1;
|
|
201
201
|
root = this.createTrieNode();
|
|
@@ -283,10 +283,11 @@ var TagIndex = class {
|
|
|
283
283
|
};
|
|
284
284
|
}
|
|
285
285
|
insertKnownKey(key) {
|
|
286
|
-
|
|
286
|
+
const isNew = !this.knownKeys.has(key);
|
|
287
|
+
this.knownKeys.set(key, Date.now());
|
|
288
|
+
if (!isNew) {
|
|
287
289
|
return;
|
|
288
290
|
}
|
|
289
|
-
this.knownKeys.add(key);
|
|
290
291
|
let node = this.root;
|
|
291
292
|
for (const character of key) {
|
|
292
293
|
let child = node.children.get(character);
|
|
@@ -381,14 +382,13 @@ var TagIndex = class {
|
|
|
381
382
|
if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
|
|
382
383
|
return;
|
|
383
384
|
}
|
|
385
|
+
const sorted = [...this.knownKeys.entries()].sort((a, b) => a[1] - b[1]);
|
|
384
386
|
const toRemove = Math.ceil(this.maxKnownKeys * 0.1);
|
|
385
|
-
let
|
|
386
|
-
|
|
387
|
-
if (
|
|
388
|
-
|
|
387
|
+
for (let i = 0; i < toRemove && i < sorted.length; i += 1) {
|
|
388
|
+
const entry = sorted[i];
|
|
389
|
+
if (entry) {
|
|
390
|
+
this.removeKey(entry[0]);
|
|
389
391
|
}
|
|
390
|
-
this.removeKey(key);
|
|
391
|
-
removed += 1;
|
|
392
392
|
}
|
|
393
393
|
}
|
|
394
394
|
removeKey(key) {
|
|
@@ -2,6 +2,130 @@ import {
|
|
|
2
2
|
PatternMatcher
|
|
3
3
|
} from "./chunk-4PPBOOXT.js";
|
|
4
4
|
|
|
5
|
+
// src/internal/CacheStackValidation.ts
|
|
6
|
+
var MAX_CACHE_KEY_LENGTH = 1024;
|
|
7
|
+
var MAX_PATTERN_LENGTH = 1024;
|
|
8
|
+
var MAX_TAGS_PER_OPERATION = 128;
|
|
9
|
+
function validatePositiveNumber(name, value) {
|
|
10
|
+
if (value === void 0) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
14
|
+
throw new Error(`${name} must be a positive finite number.`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function validateNonNegativeNumber(name, value) {
|
|
18
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
19
|
+
throw new Error(`${name} must be a non-negative finite number.`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function validateLayerNumberOption(name, value) {
|
|
23
|
+
if (value === void 0) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (typeof value === "number") {
|
|
27
|
+
validateNonNegativeNumber(name, value);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
for (const [layerName, layerValue] of Object.entries(value)) {
|
|
31
|
+
if (layerValue === void 0) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
validateNonNegativeNumber(`${name}.${layerName}`, layerValue);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function validateRateLimitOptions(name, options) {
|
|
38
|
+
if (!options) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
validatePositiveNumber(`${name}.maxConcurrent`, options.maxConcurrent);
|
|
42
|
+
validatePositiveNumber(`${name}.intervalMs`, options.intervalMs);
|
|
43
|
+
validatePositiveNumber(`${name}.maxPerInterval`, options.maxPerInterval);
|
|
44
|
+
if (options.scope && !["global", "key", "fetcher"].includes(options.scope)) {
|
|
45
|
+
throw new Error(`${name}.scope must be one of "global", "key", or "fetcher".`);
|
|
46
|
+
}
|
|
47
|
+
if (options.bucketKey !== void 0 && options.bucketKey.length === 0) {
|
|
48
|
+
throw new Error(`${name}.bucketKey must not be empty.`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function validateCacheKey(key) {
|
|
52
|
+
if (key.length === 0) {
|
|
53
|
+
throw new Error("Cache key must not be empty.");
|
|
54
|
+
}
|
|
55
|
+
if (key.length > MAX_CACHE_KEY_LENGTH) {
|
|
56
|
+
throw new Error(`Cache key length must be at most ${MAX_CACHE_KEY_LENGTH} characters.`);
|
|
57
|
+
}
|
|
58
|
+
if (/[\u0000-\u001F\u007F]/.test(key)) {
|
|
59
|
+
throw new Error("Cache key contains unsupported control characters.");
|
|
60
|
+
}
|
|
61
|
+
if (/[\uD800-\uDFFF]/.test(key)) {
|
|
62
|
+
throw new Error("Cache key contains unsupported surrogate code points.");
|
|
63
|
+
}
|
|
64
|
+
return key;
|
|
65
|
+
}
|
|
66
|
+
function validateTag(tag) {
|
|
67
|
+
if (tag.length === 0) {
|
|
68
|
+
throw new Error("Cache tag must not be empty.");
|
|
69
|
+
}
|
|
70
|
+
if (tag.length > MAX_CACHE_KEY_LENGTH) {
|
|
71
|
+
throw new Error(`Cache tag length must be at most ${MAX_CACHE_KEY_LENGTH} characters.`);
|
|
72
|
+
}
|
|
73
|
+
if (/[\u0000-\u001F\u007F]/.test(tag)) {
|
|
74
|
+
throw new Error("Cache tag contains unsupported control characters.");
|
|
75
|
+
}
|
|
76
|
+
if (/[\uD800-\uDFFF]/.test(tag)) {
|
|
77
|
+
throw new Error("Cache tag contains unsupported surrogate code points.");
|
|
78
|
+
}
|
|
79
|
+
return tag;
|
|
80
|
+
}
|
|
81
|
+
function validateTags(tags) {
|
|
82
|
+
if (!tags) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (tags.length > MAX_TAGS_PER_OPERATION) {
|
|
86
|
+
throw new Error(`options.tags must contain at most ${MAX_TAGS_PER_OPERATION} tags.`);
|
|
87
|
+
}
|
|
88
|
+
for (const tag of tags) {
|
|
89
|
+
validateTag(tag);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function validatePattern(pattern) {
|
|
93
|
+
if (pattern.length === 0) {
|
|
94
|
+
throw new Error("Pattern must not be empty.");
|
|
95
|
+
}
|
|
96
|
+
if (pattern.length > MAX_PATTERN_LENGTH) {
|
|
97
|
+
throw new Error(`Pattern length must be at most ${MAX_PATTERN_LENGTH} characters.`);
|
|
98
|
+
}
|
|
99
|
+
if (/[\u0000-\u001F\u007F]/.test(pattern)) {
|
|
100
|
+
throw new Error("Pattern contains unsupported control characters.");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function validateTtlPolicy(name, policy) {
|
|
104
|
+
if (!policy || typeof policy === "function" || policy === "until-midnight" || policy === "next-hour") {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if ("alignTo" in policy) {
|
|
108
|
+
validatePositiveNumber(`${name}.alignTo`, policy.alignTo);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
throw new Error(`${name} is invalid.`);
|
|
112
|
+
}
|
|
113
|
+
function validateAdaptiveTtlOptions(options) {
|
|
114
|
+
if (!options || options === true) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
validatePositiveNumber("adaptiveTtl.hotAfter", options.hotAfter);
|
|
118
|
+
validateLayerNumberOption("adaptiveTtl.step", options.step);
|
|
119
|
+
validateLayerNumberOption("adaptiveTtl.maxTtl", options.maxTtl);
|
|
120
|
+
}
|
|
121
|
+
function validateCircuitBreakerOptions(options) {
|
|
122
|
+
if (!options) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
validatePositiveNumber("circuitBreaker.failureThreshold", options.failureThreshold);
|
|
126
|
+
validatePositiveNumber("circuitBreaker.cooldownMs", options.cooldownMs);
|
|
127
|
+
}
|
|
128
|
+
|
|
5
129
|
// src/invalidation/RedisTagIndex.ts
|
|
6
130
|
var RedisTagIndex = class {
|
|
7
131
|
client;
|
|
@@ -183,5 +307,16 @@ function simpleHash(value) {
|
|
|
183
307
|
}
|
|
184
308
|
|
|
185
309
|
export {
|
|
310
|
+
validatePositiveNumber,
|
|
311
|
+
validateNonNegativeNumber,
|
|
312
|
+
validateLayerNumberOption,
|
|
313
|
+
validateRateLimitOptions,
|
|
314
|
+
validateCacheKey,
|
|
315
|
+
validateTag,
|
|
316
|
+
validateTags,
|
|
317
|
+
validatePattern,
|
|
318
|
+
validateTtlPolicy,
|
|
319
|
+
validateAdaptiveTtlOptions,
|
|
320
|
+
validateCircuitBreakerOptions,
|
|
186
321
|
RedisTagIndex
|
|
187
322
|
};
|
package/dist/cli.cjs
CHANGED
|
@@ -36,6 +36,51 @@ __export(cli_exports, {
|
|
|
36
36
|
module.exports = __toCommonJS(cli_exports);
|
|
37
37
|
var import_ioredis = __toESM(require("ioredis"), 1);
|
|
38
38
|
|
|
39
|
+
// src/internal/CacheStackValidation.ts
|
|
40
|
+
var MAX_CACHE_KEY_LENGTH = 1024;
|
|
41
|
+
var MAX_PATTERN_LENGTH = 1024;
|
|
42
|
+
function validateCacheKey(key) {
|
|
43
|
+
if (key.length === 0) {
|
|
44
|
+
throw new Error("Cache key must not be empty.");
|
|
45
|
+
}
|
|
46
|
+
if (key.length > MAX_CACHE_KEY_LENGTH) {
|
|
47
|
+
throw new Error(`Cache key length must be at most ${MAX_CACHE_KEY_LENGTH} characters.`);
|
|
48
|
+
}
|
|
49
|
+
if (/[\u0000-\u001F\u007F]/.test(key)) {
|
|
50
|
+
throw new Error("Cache key contains unsupported control characters.");
|
|
51
|
+
}
|
|
52
|
+
if (/[\uD800-\uDFFF]/.test(key)) {
|
|
53
|
+
throw new Error("Cache key contains unsupported surrogate code points.");
|
|
54
|
+
}
|
|
55
|
+
return key;
|
|
56
|
+
}
|
|
57
|
+
function validateTag(tag) {
|
|
58
|
+
if (tag.length === 0) {
|
|
59
|
+
throw new Error("Cache tag must not be empty.");
|
|
60
|
+
}
|
|
61
|
+
if (tag.length > MAX_CACHE_KEY_LENGTH) {
|
|
62
|
+
throw new Error(`Cache tag length must be at most ${MAX_CACHE_KEY_LENGTH} characters.`);
|
|
63
|
+
}
|
|
64
|
+
if (/[\u0000-\u001F\u007F]/.test(tag)) {
|
|
65
|
+
throw new Error("Cache tag contains unsupported control characters.");
|
|
66
|
+
}
|
|
67
|
+
if (/[\uD800-\uDFFF]/.test(tag)) {
|
|
68
|
+
throw new Error("Cache tag contains unsupported surrogate code points.");
|
|
69
|
+
}
|
|
70
|
+
return tag;
|
|
71
|
+
}
|
|
72
|
+
function validatePattern(pattern) {
|
|
73
|
+
if (pattern.length === 0) {
|
|
74
|
+
throw new Error("Pattern must not be empty.");
|
|
75
|
+
}
|
|
76
|
+
if (pattern.length > MAX_PATTERN_LENGTH) {
|
|
77
|
+
throw new Error(`Pattern length must be at most ${MAX_PATTERN_LENGTH} characters.`);
|
|
78
|
+
}
|
|
79
|
+
if (/[\u0000-\u001F\u007F]/.test(pattern)) {
|
|
80
|
+
throw new Error("Pattern contains unsupported control characters.");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
39
84
|
// src/internal/StoredValue.ts
|
|
40
85
|
function isStoredValueEnvelope(value) {
|
|
41
86
|
if (typeof value !== "object" || value === null) {
|
|
@@ -388,13 +433,17 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
388
433
|
throw new Error(`Failed to connect to Redis at ${maskRedisUrl(redisUrl)}: ${message}`);
|
|
389
434
|
});
|
|
390
435
|
if (args.command === "stats") {
|
|
391
|
-
const
|
|
392
|
-
|
|
436
|
+
const pattern = args.pattern ?? "*";
|
|
437
|
+
if (args.pattern && !validateCliInput(args.pattern, validatePattern)) return;
|
|
438
|
+
const keys = await scanKeys(redis, pattern);
|
|
439
|
+
process.stdout.write(`${JSON.stringify({ totalKeys: keys.length, pattern }, null, 2)}
|
|
393
440
|
`);
|
|
394
441
|
return;
|
|
395
442
|
}
|
|
396
443
|
if (args.command === "keys") {
|
|
397
|
-
const
|
|
444
|
+
const pattern = args.pattern ?? "*";
|
|
445
|
+
if (args.pattern && !validateCliInput(args.pattern, validatePattern)) return;
|
|
446
|
+
const keys = await scanKeys(redis, pattern);
|
|
398
447
|
if (keys.length > 0) {
|
|
399
448
|
process.stdout.write(`${keys.join("\n")}
|
|
400
449
|
`);
|
|
@@ -403,6 +452,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
403
452
|
}
|
|
404
453
|
if (args.command === "invalidate") {
|
|
405
454
|
if (args.tag) {
|
|
455
|
+
if (!validateCliInput(args.tag, validateTag)) return;
|
|
406
456
|
const tagIndex = new RedisTagIndex({ client: redis, prefix: args.tagIndexPrefix ?? "layercache:tag-index" });
|
|
407
457
|
const keys2 = await tagIndex.keysForTag(args.tag);
|
|
408
458
|
if (keys2.length > 0) {
|
|
@@ -412,11 +462,18 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
412
462
|
`);
|
|
413
463
|
return;
|
|
414
464
|
}
|
|
415
|
-
const
|
|
465
|
+
const effectivePattern = args.pattern ?? "*";
|
|
466
|
+
if (args.pattern && !validateCliInput(args.pattern, validatePattern)) return;
|
|
467
|
+
const keys = await scanKeys(redis, effectivePattern);
|
|
468
|
+
if (!args.pattern && !args.force && keys.length > 0) {
|
|
469
|
+
process.stderr.write(`Warning: this operation will invalidate ${keys.length} keys. Use --force to confirm.
|
|
470
|
+
`);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
416
473
|
if (keys.length > 0) {
|
|
417
474
|
await batchDelete(redis, keys);
|
|
418
475
|
}
|
|
419
|
-
process.stdout.write(`${JSON.stringify({ deletedKeys: keys.length, pattern:
|
|
476
|
+
process.stdout.write(`${JSON.stringify({ deletedKeys: keys.length, pattern: effectivePattern }, null, 2)}
|
|
420
477
|
`);
|
|
421
478
|
return;
|
|
422
479
|
}
|
|
@@ -424,6 +481,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
424
481
|
if (!args.key) {
|
|
425
482
|
throw new Error("inspect requires --key <key>.");
|
|
426
483
|
}
|
|
484
|
+
if (!validateCliInput(args.key, validateCacheKey)) return;
|
|
427
485
|
const payload = await redis.getBuffer(args.key);
|
|
428
486
|
const ttl = await redis.ttl(args.key);
|
|
429
487
|
const decoded = decodeInspectablePayload(payload);
|
|
@@ -498,6 +556,8 @@ function parseArgs(argv) {
|
|
|
498
556
|
index += 1;
|
|
499
557
|
} else if (token === "--require-tls") {
|
|
500
558
|
parsed.requireTls = true;
|
|
559
|
+
} else if (token === "--force") {
|
|
560
|
+
parsed.force = true;
|
|
501
561
|
}
|
|
502
562
|
}
|
|
503
563
|
return parsed;
|
|
@@ -574,6 +634,18 @@ function maskRedisUrl(url) {
|
|
|
574
634
|
return url.replace(/:([^@/]+)@/, ":***@");
|
|
575
635
|
}
|
|
576
636
|
}
|
|
637
|
+
function validateCliInput(value, validator) {
|
|
638
|
+
try {
|
|
639
|
+
validator(value);
|
|
640
|
+
return true;
|
|
641
|
+
} catch (error) {
|
|
642
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
643
|
+
process.stderr.write(`Error: ${message}
|
|
644
|
+
`);
|
|
645
|
+
process.exitCode = 1;
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
577
649
|
if (process.argv[1]?.endsWith("cli.cjs") || process.argv[1]?.endsWith("cli.js")) {
|
|
578
650
|
void main();
|
|
579
651
|
}
|