ioredis-toolkit 0.0.1
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/LICENSE +21 -0
- package/README.md +645 -0
- package/dist/cache.d.ts +298 -0
- package/dist/cache.js +606 -0
- package/dist/client.d.ts +177 -0
- package/dist/client.js +958 -0
- package/dist/cluster-slot.d.ts +4 -0
- package/dist/cluster-slot.js +31 -0
- package/dist/cluster.d.ts +79 -0
- package/dist/cluster.js +156 -0
- package/dist/errors.d.ts +30 -0
- package/dist/errors.js +63 -0
- package/dist/health.d.ts +39 -0
- package/dist/health.js +106 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +44 -0
- package/dist/lock.d.ts +215 -0
- package/dist/lock.js +385 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.js +40 -0
- package/dist/pubsub.d.ts +171 -0
- package/dist/pubsub.js +285 -0
- package/dist/ratelimiter.d.ts +162 -0
- package/dist/ratelimiter.js +289 -0
- package/dist/session/index.d.ts +23 -0
- package/dist/session/index.js +16 -0
- package/dist/session/revocation-store.d.ts +171 -0
- package/dist/session/revocation-store.js +310 -0
- package/dist/session/scripts/cleanup-index.lua +21 -0
- package/dist/session/scripts/conditional-update-encrypted.lua +60 -0
- package/dist/session/scripts/conditional-update.lua +63 -0
- package/dist/session/scripts/create.lua +68 -0
- package/dist/session/scripts/delete-by-user.lua +29 -0
- package/dist/session/scripts/delete.lua +15 -0
- package/dist/session/scripts/enforce-limit.lua +38 -0
- package/dist/session/scripts/revoke.lua +61 -0
- package/dist/session/scripts/rotate-encrypted.lua +107 -0
- package/dist/session/scripts/rotate.lua +119 -0
- package/dist/session/scripts/touch-encrypted.lua +89 -0
- package/dist/session/scripts/touch.lua +72 -0
- package/dist/session/scripts/validate.lua +90 -0
- package/dist/session/session-circuit-breaker.d.ts +42 -0
- package/dist/session/session-circuit-breaker.js +129 -0
- package/dist/session/session-config.d.ts +335 -0
- package/dist/session/session-config.js +162 -0
- package/dist/session/session-cookie.d.ts +72 -0
- package/dist/session/session-cookie.js +101 -0
- package/dist/session/session-encryption.d.ts +87 -0
- package/dist/session/session-encryption.js +139 -0
- package/dist/session/session-errors.d.ts +85 -0
- package/dist/session/session-errors.js +145 -0
- package/dist/session/session-health.d.ts +38 -0
- package/dist/session/session-health.js +60 -0
- package/dist/session/session-keys.d.ts +51 -0
- package/dist/session/session-keys.js +113 -0
- package/dist/session/session-manager.d.ts +59 -0
- package/dist/session/session-manager.js +94 -0
- package/dist/session/session-metrics.d.ts +33 -0
- package/dist/session/session-metrics.js +112 -0
- package/dist/session/session-repository.d.ts +161 -0
- package/dist/session/session-repository.js +683 -0
- package/dist/session/session-scripts.d.ts +36 -0
- package/dist/session/session-scripts.js +130 -0
- package/dist/session/session-serializer.d.ts +42 -0
- package/dist/session/session-serializer.js +248 -0
- package/dist/session/session-service.d.ts +104 -0
- package/dist/session/session-service.js +611 -0
- package/dist/session/session-token.d.ts +38 -0
- package/dist/session/session-token.js +86 -0
- package/dist/session/session-types.d.ts +253 -0
- package/dist/session/session-types.js +16 -0
- package/dist/types.d.ts +782 -0
- package/dist/types.js +140 -0
- package/package.json +97 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Your Organization
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
# ioredis-toolkit
|
|
2
|
+
|
|
3
|
+
Production-grade Redis infrastructure for distributed systems: a unified client, cache, rate limiter, distributed lock, pub/sub and health checking — all working in **standalone**, **sentinel** and **cluster** modes.
|
|
4
|
+
|
|
5
|
+
## Topology-safe client API
|
|
6
|
+
|
|
7
|
+
The recommended entry point is `createRedisClient()`. Redis topology is a discriminated union, so configuration mistakes are rejected before a connection is created. Cluster-only administrative helpers are exposed by the factory only for cluster configurations.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { createRedisClient } from 'ioredis-toolkit';
|
|
11
|
+
|
|
12
|
+
const client = createRedisClient({
|
|
13
|
+
mode: 'cluster',
|
|
14
|
+
clusterNodes: [
|
|
15
|
+
{ host: 'redis-1', port: 6379 },
|
|
16
|
+
{ host: 'redis-2', port: 6379 },
|
|
17
|
+
],
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
await client.set('{user:42}:profile', JSON.stringify({ id: 42 }));
|
|
21
|
+
const slot = client.calculateSlot('{user:42}:profile');
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
For standalone/Sentinel, the same normal Redis API works automatically; no separate implementation is required. Sentinel uses ioredis master discovery/failover and Cluster uses ioredis slot routing. Cluster-only APIs such as `calculateSlot()` and `getClusterSlots()` are not part of the non-cluster factory type.
|
|
25
|
+
|
|
26
|
+
### Important topology rules
|
|
27
|
+
|
|
28
|
+
- Standalone: databases `0..15` are supported.
|
|
29
|
+
- Sentinel: `SELECT` is available against the selected Sentinel master.
|
|
30
|
+
- Cluster: database is always `0`; `SELECT` is unavailable.
|
|
31
|
+
- Multi-key operations spanning slots are grouped and executed as bounded fan-out operations; they are not atomic across slots.
|
|
32
|
+
- Lua scripts that need atomic multi-key behavior must use keys with the same hash tag, e.g. `{userId}:session` and `{userId}:index`.
|
|
33
|
+
- Administrative namespace cleanup uses `SCAN`, never `KEYS`.
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
## Features
|
|
37
|
+
|
|
38
|
+
- **Unified client** (`RedisClientWrapper`) — one API for standalone / sentinel / cluster; all multi-key operations (`mget`, `mset`, `scanIterator`, ...) are slot-aware and cluster-safe
|
|
39
|
+
- **Cache** (`Cache`) — JSON serialization, optional gzip compression, namespaces, TTLs, hash helpers and pattern-based cleanup
|
|
40
|
+
- **Rate limiter** (`RateLimiter`) — generic, works for any resource (routes, users, IPs, API keys, databases, ...) with fixed and sliding windows
|
|
41
|
+
- **Distributed lock** (`DistributedLock`) — atomic acquire/release, auto-extension, retries
|
|
42
|
+
- **Pub/Sub** (`PubSub`) — publish, subscribe, pattern subscriptions
|
|
43
|
+
- **Health checker** (`HealthChecker`) — periodic health monitoring with callbacks
|
|
44
|
+
- **Session subsystem** (`createSessionManager`) — the production session stack: validation, retry-safe rotation, idle/absolute expiry, eviction ceilings, security versioning, optional AES-256-GCM encryption at rest, fail-closed circuit breaker, metrics and health
|
|
45
|
+
- **Revocation store** (`RedisRevocationStore`) — TTL-backed token revocation with batch operations and fail-closed checks
|
|
46
|
+
- **Lua scripts** (`eval` / `evalsha`) — atomic server-side logic, Cluster-safe when keys share a hash slot
|
|
47
|
+
- **Observability** — pino-compatible logging and slow-command warnings
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npm install ioredis-toolkit ioredis zod
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Quick start
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { RedisClient, Cache, RateLimiter } from 'ioredis-toolkit';
|
|
59
|
+
|
|
60
|
+
// 1. Create the client
|
|
61
|
+
const client = new RedisClient({
|
|
62
|
+
mode: 'standalone',
|
|
63
|
+
host: 'localhost',
|
|
64
|
+
port: 6379,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// 2. Cache
|
|
68
|
+
const cache = new Cache(client, { defaultTTL: 3600, compressionThreshold: 1024 });
|
|
69
|
+
await cache.set('user:1', { name: 'alice' });
|
|
70
|
+
const user = await cache.get('user:1');
|
|
71
|
+
|
|
72
|
+
// 3. Rate limiting
|
|
73
|
+
const limiter = new RateLimiter(client, { limit: 100, duration: 60 });
|
|
74
|
+
const result = await limiter.consume('/api/login', 'ip-10.0.0.1');
|
|
75
|
+
if (!result.allowed) {
|
|
76
|
+
// HTTP 429, set Retry-After: result.retryAfter
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 4. Shut down gracefully
|
|
80
|
+
await client.close();
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Connection modes
|
|
84
|
+
|
|
85
|
+
All features behave identically in every mode. Choose the mode via the `mode` config field.
|
|
86
|
+
|
|
87
|
+
### Standalone
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
const client = new RedisClient({
|
|
91
|
+
mode: 'standalone',
|
|
92
|
+
host: 'localhost',
|
|
93
|
+
port: 6379,
|
|
94
|
+
password: 'secret',
|
|
95
|
+
database: 0,
|
|
96
|
+
});
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Or with a URL:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
const client = new RedisClient({ mode: 'standalone', url: 'redis://:secret@localhost:6379/0' });
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Sentinel
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
const client = new RedisClient({
|
|
109
|
+
mode: 'sentinel',
|
|
110
|
+
sentinelNodes: [
|
|
111
|
+
{ host: 'sentinel1', port: 26379 },
|
|
112
|
+
{ host: 'sentinel2', port: 26380 },
|
|
113
|
+
],
|
|
114
|
+
sentinelMasterName: 'mymaster',
|
|
115
|
+
password: 'secret',
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Cluster
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
const client = new RedisClient({
|
|
123
|
+
mode: 'cluster',
|
|
124
|
+
clusterNodes: [
|
|
125
|
+
{ host: 'redis1', port: 7000 },
|
|
126
|
+
{ host: 'redis2', port: 7001 },
|
|
127
|
+
{ host: 'redis3', port: 7002 },
|
|
128
|
+
],
|
|
129
|
+
password: 'secret',
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Common configuration
|
|
134
|
+
|
|
135
|
+
| Option | Type | Default | Description |
|
|
136
|
+
| --- | --- | --- | --- |
|
|
137
|
+
| `maxRetries` | number | `3` | Max reconnect attempts |
|
|
138
|
+
| `retryDelay` | number | `1000` | Base reconnect delay (ms) |
|
|
139
|
+
| `connectionTimeout` | number | `5000` | Connect timeout (ms) |
|
|
140
|
+
| `defaultTTL` | number | `3600` | Default cache TTL (seconds) |
|
|
141
|
+
| `compressionThreshold` | number | `1024` | Cache compression threshold (bytes) |
|
|
142
|
+
| `slowCommandThreshold` | number | `1000` | Log commands slower than this (ms) |
|
|
143
|
+
| `tls` | boolean | `false` | Enable TLS (`tlsOptions` for CA/cert/key) |
|
|
144
|
+
|
|
145
|
+
Configs are validated with Zod (`RedisConfigSchema`).
|
|
146
|
+
|
|
147
|
+
## RedisClient
|
|
148
|
+
|
|
149
|
+
Full reference is in the generated `.d.ts` (JSDoc with params + examples). Highlights:
|
|
150
|
+
|
|
151
|
+
### Strings & keys
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
await client.set('name', 'alice'); // SET
|
|
155
|
+
await client.set('session', 'x', 3600); // SET ... EX
|
|
156
|
+
await client.setexnx('job:1', 'w', 60); // SET ... EX NX (only if missing)
|
|
157
|
+
await client.setnx('lock:1', 'owner', 30); // SETNX + EXPIRE, returns 1|0
|
|
158
|
+
await client.get('name'); // 'alice' | null
|
|
159
|
+
await client.getdel('queue:job'); // GETDEL
|
|
160
|
+
await client.exists('name'); // 1 | 0
|
|
161
|
+
await client.del('a', 'b'); // number deleted
|
|
162
|
+
await client.expire('session', 3600); // set TTL
|
|
163
|
+
await client.ttl('session'); // seconds left
|
|
164
|
+
await client.incr('visits'); // counters
|
|
165
|
+
await client.decr('stock:sku-1');
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Batch operations (cluster-safe)
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
await client.mset(['user:1', 'alice'], ['user:2', 'bob']); // grouped by hash slot
|
|
172
|
+
const [a, b] = await client.mget('user:1', 'user:2'); // routed per slot
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Hashes, sets, sorted sets
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
await client.hset('user:1', 'name', 'alice');
|
|
179
|
+
await client.hget('user:1', 'name');
|
|
180
|
+
await client.hgetall('user:1');
|
|
181
|
+
await client.hdel('user:1', 'age');
|
|
182
|
+
|
|
183
|
+
await client.sadd('tags:1', 'redis', 'typescript');
|
|
184
|
+
await client.smembers('tags:1');
|
|
185
|
+
await client.sismember('tags:1', 'redis');
|
|
186
|
+
await client.srem('tags:1', 'redis');
|
|
187
|
+
|
|
188
|
+
await client.zadd('leaderboard', 100, 'p1');
|
|
189
|
+
await client.zrange('leaderboard', 0, 9);
|
|
190
|
+
await client.zrem('leaderboard', 'p1');
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Scanning & pipelines
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
// Scans every node in cluster mode
|
|
197
|
+
for await (const key of client.scanIterator('session:*')) {
|
|
198
|
+
console.log(key);
|
|
199
|
+
}
|
|
200
|
+
await client.deletePattern('temp:*'); // delete by glob, cluster-safe
|
|
201
|
+
|
|
202
|
+
// Pipelines (cluster mode: keys must share a hash slot)
|
|
203
|
+
const pipeline = client.pipeline();
|
|
204
|
+
pipeline.set('a', '1');
|
|
205
|
+
pipeline.incr('b');
|
|
206
|
+
const results = await pipeline.exec();
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Cluster helpers
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
client.isCluster(); // boolean
|
|
213
|
+
client.getClusterNodes(); // raw node clients
|
|
214
|
+
client.getClusterSlots(); // raw slot map
|
|
215
|
+
client.getSlotRanges(); // Map<slot, host:port[]>
|
|
216
|
+
client.calculateSlot('{user}:a'); // CRC16 slot, honors hash tags
|
|
217
|
+
await client.getNodeForKey('user:1');// node serving a key
|
|
218
|
+
await client.isKeyServed('user:1'); // slot is served
|
|
219
|
+
await client.executeOnNode('user:1', 'get', 'user:1'); // run on owning node
|
|
220
|
+
await client.mgetClusterAware([...]); // slot-grouped multi-get
|
|
221
|
+
client.getClusterInfo(); // topology snapshot
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Lifecycle & low-level
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
await client.ping(); // boolean
|
|
228
|
+
await client.close(); // graceful QUIT
|
|
229
|
+
client.raw; // raw ioredis client
|
|
230
|
+
client.defineCommand(name, def);// custom commands (e.g. fastify-rate-limit)
|
|
231
|
+
await client.info('memory'); // INFO output
|
|
232
|
+
await client.select(1); // standalone only
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Lua scripts (atomic server-side logic)
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
// EVAL: the first numKeys arguments are KEYS, everything else is ARGV.
|
|
239
|
+
// Cluster mode: every key touched inside the script must be declared in
|
|
240
|
+
// KEYS and share one hash slot.
|
|
241
|
+
const script = `
|
|
242
|
+
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
|
243
|
+
return redis.call('DEL', KEYS[1])
|
|
244
|
+
end
|
|
245
|
+
return 0
|
|
246
|
+
`;
|
|
247
|
+
await client.set('lock:job', 'owner-1');
|
|
248
|
+
await client.eval(script, 1, 'lock:job', 'owner-1'); // 1 (deleted)
|
|
249
|
+
|
|
250
|
+
// SCRIPT LOAD + EVALSHA: avoid re-sending the script body on every call.
|
|
251
|
+
// evalsha falls back to EVAL automatically when the server cache was flushed.
|
|
252
|
+
const sha = await client.scriptLoad(script);
|
|
253
|
+
await client.evalsha(sha, script, 1, 'lock:job', 'owner-2'); // 0 (not owner)
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Convenience accessors
|
|
257
|
+
|
|
258
|
+
`RedisClient` lazily creates and shares one instance of each sub-component. Access them as properties — no manual wiring needed:
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
client.cache; // shared Cache (created on first access)
|
|
262
|
+
client.pubsub; // shared PubSub
|
|
263
|
+
client.lock; // shared DistributedLock
|
|
264
|
+
client.rateLimiter; // shared RateLimiter
|
|
265
|
+
|
|
266
|
+
await client.cache.set('user:1', { name: 'alice' });
|
|
267
|
+
const ok = await client.lock.acquire('order:42');
|
|
268
|
+
const { allowed } = await client.rateLimiter.consume('/api', 'ip-1', { limit: 5, duration: 60 });
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
The corresponding setters replace the shared instance with a custom one (e.g. one built with different defaults):
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
client.cache = new Cache(client, config, logger);
|
|
275
|
+
client.rateLimiter = new RateLimiter(client, { limit: 50, duration: 10 });
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
## Cache
|
|
279
|
+
|
|
280
|
+
### Basic usage
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
const cache = new Cache(client, { defaultTTL: 3600, compressionThreshold: 1024 });
|
|
284
|
+
|
|
285
|
+
await cache.set('user:1', { name: 'alice' }, { ttl: 300 });
|
|
286
|
+
const user = await cache.get('user:1');
|
|
287
|
+
|
|
288
|
+
await cache.delete('user:1');
|
|
289
|
+
await cache.exists('user:1');
|
|
290
|
+
await cache.expire('user:1', 60);
|
|
291
|
+
await cache.ttl('user:1');
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
- Values are JSON-serialized; strings/numbers/buffers are stored as-is.
|
|
295
|
+
- Values larger than `compressionThreshold` bytes are gzip-compressed transparently.
|
|
296
|
+
- `compress: false` disables compression for a single write.
|
|
297
|
+
|
|
298
|
+
### Namespaces
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
await cache.set('token', 'abc', { namespace: 'auth' });
|
|
302
|
+
await cache.get('token', 'auth'); // 'abc'
|
|
303
|
+
await cache.get('token'); // null
|
|
304
|
+
|
|
305
|
+
await cache.clearNamespace('sessions'); // delete every 'sessions:*' key
|
|
306
|
+
await cache.keys('session:*'); // list keys (cluster-safe)
|
|
307
|
+
await cache.deletePattern('temp:*'); // delete by pattern
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### Atomic & batch operations
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
await cache.setNX('job:1', 'worker-1', { ttl: 60 }); // only if missing
|
|
314
|
+
await cache.setEXNX('lock:1', 'txn', { ttl: 30 }); // atomic with TTL
|
|
315
|
+
|
|
316
|
+
await cache.increment('stats:visits'); // 1, 2, 3, ...
|
|
317
|
+
await cache.decrement('stock:sku-1');
|
|
318
|
+
|
|
319
|
+
await cache.mset({ 'user:1': alice, 'user:2': bob }, { ttl: 300 }); // slot-grouped
|
|
320
|
+
const [a, b] = await cache.mget(['user:1', 'user:2']);
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### Hash helpers
|
|
324
|
+
|
|
325
|
+
```ts
|
|
326
|
+
await cache.hset('user:1', 'age', 30);
|
|
327
|
+
await cache.hget('user:1', 'age'); // 30
|
|
328
|
+
await cache.hgetall('user:1'); // { age: 30, ... }
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
## RateLimiter
|
|
332
|
+
|
|
333
|
+
Generic rate limiting for **any** resource — routes, API endpoints, users, IPs, API keys, database writes, email sends, webhooks...
|
|
334
|
+
|
|
335
|
+
```ts
|
|
336
|
+
const limiter = new RateLimiter(client, { limit: 100, duration: 60 });
|
|
337
|
+
|
|
338
|
+
const result = await limiter.consume('/api/login', 'ip-10.0.0.1');
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
### Result
|
|
342
|
+
|
|
343
|
+
```ts
|
|
344
|
+
interface RateLimitResult {
|
|
345
|
+
allowed: boolean; // request may proceed
|
|
346
|
+
limit: number; // configured max
|
|
347
|
+
used: number; // requests in current window
|
|
348
|
+
remaining: number; // left in the window
|
|
349
|
+
resetAt: number; // epoch ms when the window resets
|
|
350
|
+
retryAfter: number; // seconds to wait (0 when allowed)
|
|
351
|
+
}
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
### Usage in an HTTP handler
|
|
355
|
+
|
|
356
|
+
```ts
|
|
357
|
+
const result = await limiter.consume('/api/orders', request.ip, { limit: 5, duration: 60 });
|
|
358
|
+
if (!result.allowed) {
|
|
359
|
+
response.setHeader('Retry-After', String(result.retryAfter));
|
|
360
|
+
return response.status(429).json({ error: 'Too many requests' });
|
|
361
|
+
}
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### Peek & reset
|
|
365
|
+
|
|
366
|
+
```ts
|
|
367
|
+
const state = await limiter.check('/api/search', 'user-1'); // no capacity consumed
|
|
368
|
+
await limiter.reset('/api/export', 'user-7'); // grant full capacity again
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
### Algorithms
|
|
372
|
+
|
|
373
|
+
| Algorithm | Key type | Characteristics |
|
|
374
|
+
| --- | --- | --- |
|
|
375
|
+
| `sliding` (default) | sorted set + atomic Lua | smoothest; precise rolling window |
|
|
376
|
+
| `fixed` | counter (`INCR`/`EXPIRE`) | cheapest; window resets at fixed boundaries |
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
const fixed = new RateLimiter(client, { limit: 10, duration: 1, algorithm: 'fixed' });
|
|
380
|
+
const perRoute = await fixed.consume('/api', 'user-1', { limit: 3, duration: 10 }); // per-call override
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
Keys are `ratelimit:{resource}:{identifier}` — each resource/identifier pair is tracked independently, so routes and callers never interfere. If Redis is unavailable the limiter **fails open** (allows requests) so an outage cannot take down the whole app.
|
|
384
|
+
|
|
385
|
+
## DistributedLock
|
|
386
|
+
|
|
387
|
+
```ts
|
|
388
|
+
const lock = new DistributedLock(client, { ttl: 30000, retryCount: 3, retryDelay: 200 });
|
|
389
|
+
|
|
390
|
+
await lock.acquire('order:42'); // boolean
|
|
391
|
+
await lock.release('order:42'); // owner-checked Lua delete
|
|
392
|
+
await lock.releaseForce('order:42'); // delete without ownership check
|
|
393
|
+
await lock.withLock('order:42', async () => {
|
|
394
|
+
// exclusive section; TTL is auto-extended, lock released afterwards
|
|
395
|
+
});
|
|
396
|
+
await lock.extend('order:42', 60000); // renew TTL while owned
|
|
397
|
+
await lock.isLocked('order:42');
|
|
398
|
+
await lock.getLockInfo('order:42'); // { locked, ttl, lockId }
|
|
399
|
+
await lock.getLockOwner('order:42'); // lock id | null
|
|
400
|
+
await lock.getLockTTL('order:42'); // seconds left
|
|
401
|
+
await lock.cleanupAll(); // delete every lock:* key (tests/emergency)
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
## PubSub
|
|
405
|
+
|
|
406
|
+
```ts
|
|
407
|
+
const pubsub = new PubSub(client);
|
|
408
|
+
await pubsub.connectSubscriber(redisConfig); // dedicated subscriber connection
|
|
409
|
+
|
|
410
|
+
await pubsub.subscribe('orders:created', (message) => {
|
|
411
|
+
console.log(message); // message payload (JSON-parsed)
|
|
412
|
+
});
|
|
413
|
+
await pubsub.publish('orders:created', { id: 1 }); // JSON-serialized
|
|
414
|
+
|
|
415
|
+
await pubsub.unsubscribe('orders:created', handler); // one handler
|
|
416
|
+
await pubsub.unsubscribe('orders:created'); // whole channel
|
|
417
|
+
await pubsub.psubscribe('orders:*', ({ channel, message }) => {
|
|
418
|
+
// pattern handlers receive { channel, message }
|
|
419
|
+
});
|
|
420
|
+
await pubsub.punsubscribe('orders:*');
|
|
421
|
+
await pubsub.close(); // closes subscriber only
|
|
422
|
+
pubsub.getStats(); // { subscriptions, patternSubscriptions, connected }
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
## HealthChecker
|
|
426
|
+
|
|
427
|
+
```ts
|
|
428
|
+
const health = new HealthChecker(client);
|
|
429
|
+
health.start(10000); // check every 10s
|
|
430
|
+
health.onChange((status) => console.log(status));
|
|
431
|
+
|
|
432
|
+
const status = await health.check(); // ping + latency
|
|
433
|
+
health.getStatus(); // last result (null before first check)
|
|
434
|
+
await health.waitForHealthy(30000); // boolean
|
|
435
|
+
health.stop();
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
## Revocation store
|
|
439
|
+
|
|
440
|
+
`RedisRevocationStore` supports refresh-token revocation workflows: short-lived entries (`revoked:{jti}`) so rotated/logged-out tokens are rejected for their remaining lifetime. It is framework-independent, works identically on standalone, Sentinel and Cluster, never stores raw tokens — only ids (`jti`) — and is the same store the session subsystem consults when `checkRevocationStore: true`.
|
|
441
|
+
|
|
442
|
+
```ts
|
|
443
|
+
import { RedisRevocationStore } from 'ioredis-toolkit/session';
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
### RedisRevocationStore
|
|
447
|
+
|
|
448
|
+
Each revoked jti is stored as `{prefix}{jti} -> reason` with a Redis TTL equal to the token's remaining lifetime, so expired entries are reclaimed automatically — no sweep job required. Every operation is single-key (or a slot-grouped pipeline for batches), so no hash tags are needed on any topology.
|
|
449
|
+
|
|
450
|
+
```ts
|
|
451
|
+
const revocations = new RedisRevocationStore({
|
|
452
|
+
client,
|
|
453
|
+
keyPrefix: 'authcore:revoked:',
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
const expiry = Math.floor(Date.now() / 1000) + 86400;
|
|
457
|
+
|
|
458
|
+
// Revoke (single or batch)
|
|
459
|
+
await revocations.revoke({ jti: 'a1b2c3d4', reason: 'logout', expiresAt: expiry });
|
|
460
|
+
await revocations.revokeMany([
|
|
461
|
+
{ jti: 'b2', reason: 'logout-all', expiresAt: expiry },
|
|
462
|
+
{ jti: 'c3', reason: 'password-change', expiresAt: expiry },
|
|
463
|
+
]);
|
|
464
|
+
|
|
465
|
+
// Check (single or batch)
|
|
466
|
+
await revocations.isRevoked('a1b2c3d4'); // boolean
|
|
467
|
+
const revoked = await revocations.isRevokedMany(['b2', 'c3', 'd4']); // Set<string>
|
|
468
|
+
|
|
469
|
+
if (revoked.has('b2')) {
|
|
470
|
+
// reject the token
|
|
471
|
+
}
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
Fail-closed semantics: if a batched command fails (Redis error, timeout, ...), `revokeMany` / `isRevokedMany` throw `RevocationBatchError` carrying the exact jtis that failed — a check can never silently treat a token as "not revoked" when its status is unknown.
|
|
475
|
+
|
|
476
|
+
## Session subsystem
|
|
477
|
+
|
|
478
|
+
The production session stack (`createSessionManager`): validation with
|
|
479
|
+
fail-closed semantics, rotation with retry-safe idempotency, throttled
|
|
480
|
+
touches, idle + absolute expiry, per-user eviction ceilings, security
|
|
481
|
+
versioning, optional AES-256-GCM encryption at rest, an optional jti index
|
|
482
|
+
for userId-free lookup, a fail-closed circuit breaker, metrics and health
|
|
483
|
+
— all Cluster-safe by construction. It supersedes the historical
|
|
484
|
+
`RedisSessionStore` (removed; see the migration note below).
|
|
485
|
+
|
|
486
|
+
See `SESSION.md` (spec) and `docs/architecture.md` (decisions,
|
|
487
|
+
deviations, exact semantics) before adopting it. Highlights:
|
|
488
|
+
|
|
489
|
+
```ts
|
|
490
|
+
import { createSessionManager } from 'ioredis-toolkit/session';
|
|
491
|
+
|
|
492
|
+
const manager = createSessionManager({
|
|
493
|
+
client,
|
|
494
|
+
config: {
|
|
495
|
+
enabled: true, // explicit opt-in
|
|
496
|
+
namespace: 'authcore',
|
|
497
|
+
maxSessionsPerUser: 20,
|
|
498
|
+
securityVersion: { enabled: true },
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
await manager.init(); // preloads the Lua scripts
|
|
502
|
+
|
|
503
|
+
const { token, session } = await manager.service.create({ userId: 'user-42' });
|
|
504
|
+
|
|
505
|
+
// Validate: single round trip when userId is known. Never throws for
|
|
506
|
+
// invalid sessions; throws (SessionStorageError) only on infra failure.
|
|
507
|
+
const result = await manager.service.validate(token, { userId: 'user-42' });
|
|
508
|
+
if (result.valid) { /* session is live */ }
|
|
509
|
+
|
|
510
|
+
// Rotate with a retry-safe nonce (idempotent retries).
|
|
511
|
+
const rotated = await manager.service.rotate(token, { userId: 'user-42', rotationNonce: 'uuid' });
|
|
512
|
+
|
|
513
|
+
await manager.service.touch(token, { userId: 'user-42' });
|
|
514
|
+
await manager.service.revoke(token, { userId: 'user-42' }); // tombstone
|
|
515
|
+
await manager.service.revokeAll('user-42'); // logout all devices
|
|
516
|
+
await manager.service.setSecurityVersion('user-42', 2); // invalidates old sessions
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
- **Never stores the raw token** — only `jti = SHA-256(token)`.
|
|
520
|
+
- **Validation results** are discriminated: `{ valid: true, session }` or
|
|
521
|
+
`{ valid: false, reason: 'invalid' | 'not_found' | 'expired' |
|
|
522
|
+
'idle_timeout' | 'revoked' | 'binding_mismatch' }`.
|
|
523
|
+
- **Fail closed**: infra errors are `SessionStorageError` (503), never
|
|
524
|
+
"invalid"; a broken revocation-store read is a 503, not a 401. The
|
|
525
|
+
circuit breaker trips only on storage failures — bad tokens, consumed
|
|
526
|
+
sessions and concurrent-update conflicts can never open it.
|
|
527
|
+
- **Idempotent creation**: `enableCreateIdempotency: true` + an
|
|
528
|
+
`idempotencyKey` on `create()` makes retries return the original
|
|
529
|
+
session (`replayed: true`) instead of duplicating it; the claim is
|
|
530
|
+
TTL-bounded, so replay windows cannot grow forever.
|
|
531
|
+
- **Encryption at rest**: `encryption: { enabled: true }` +
|
|
532
|
+
`encryptionKeyProvider` (AES-256-GCM, key-versioned).
|
|
533
|
+
- **Revocation store**: `RedisRevocationStore` plugs in via the
|
|
534
|
+
`revocationStore` option for external jti denylists.
|
|
535
|
+
- **Manager surface**: `manager.service` (ops), `manager.metrics`,
|
|
536
|
+
`manager.health`, `manager.circuitBreaker`, `manager.cookies`,
|
|
537
|
+
`manager.token`, `manager.keys` — plus `manager.config`.
|
|
538
|
+
- **Cookie helpers**: `manager.cookies.serialize(token)` returns the
|
|
539
|
+
`Set-Cookie` string; `serializeWithAttributes(token)` additionally
|
|
540
|
+
returns the structured cookie object
|
|
541
|
+
(`{ header, name, value, attributes: { path, domain?, httpOnly, secure,
|
|
542
|
+
sameSite, maxAge? } }`, types exported from both entry points).
|
|
543
|
+
|
|
544
|
+
### Session configuration
|
|
545
|
+
|
|
546
|
+
| Option | Default | Description |
|
|
547
|
+
| --- | --- | --- |
|
|
548
|
+
| `enabled` | `false` | Explicit opt-in; the manager refuses to construct otherwise |
|
|
549
|
+
| `namespace` | — | Key prefix (e.g. `authcore`) |
|
|
550
|
+
| `ttl` | `2592000` (30d) | Absolute session lifetime in seconds |
|
|
551
|
+
| `idleTimeout` | `86400` (24h) | Rolling idle timeout in seconds (`null` disables) |
|
|
552
|
+
| `rolling` | `true` | `touch` extends the idle boundary |
|
|
553
|
+
| `touchInterval` | `300` | Minimum seconds between touch writes (throttling) |
|
|
554
|
+
| `maxSessionsPerUser` | `20` | Eviction ceiling, enforced atomically inside the create script |
|
|
555
|
+
| `securityVersion` | off | Global per-user version; a bump invalidates older sessions |
|
|
556
|
+
| `encryption` | off | AES-256-GCM envelopes (`enabled` + `encryptionKeyProvider`) |
|
|
557
|
+
| `jtiIndex` | off | `jti -> userId` map so validate/touch/rotate work without `userId` |
|
|
558
|
+
| `checkRevocationStore` | `false` | Consult the revocation store during validation |
|
|
559
|
+
| `bindingPolicy` | `disabled` | `strict` rejects on IP/UA/device mismatch, `advisory` reports it |
|
|
560
|
+
| `circuitBreaker` | off | `failureThreshold` 10, `resetTimeoutMs` 30_000, `halfOpenMaxRequests` 5 |
|
|
561
|
+
| `enableCreateIdempotency` | `false` | Idempotent `create()` via `idempotencyKey` |
|
|
562
|
+
| `retainConsumedTombstones` | `true` | Keep consumed records (TTL-bounded) for replay detection |
|
|
563
|
+
|
|
564
|
+
### Session errors
|
|
565
|
+
|
|
566
|
+
All session errors extend `SessionError` (itself a `RedisError`):
|
|
567
|
+
`SessionNotFoundError`, `SessionExpiredError`, `SessionInvalidError`,
|
|
568
|
+
`SessionRevokedError`, `SessionRotationError`, `SessionConcurrencyError`,
|
|
569
|
+
`SessionStorageError` (503 — the only one that trips the circuit breaker),
|
|
570
|
+
`SessionSerializationError`, `SessionConfigurationError`,
|
|
571
|
+
`SessionBindingError`. Public error messages
|
|
572
|
+
never contain raw tokens or identifiers.
|
|
573
|
+
|
|
574
|
+
- Tested end-to-end against standalone, Sentinel and Cluster — the
|
|
575
|
+
Sentinel topology (`test/infra/`) includes a **live failover drill**
|
|
576
|
+
(`sentinel-failover-probe.mjs`): sessions created before a master
|
|
577
|
+
outage stay valid on the promoted replica. The real-Redis suites skip
|
|
578
|
+
cleanly when Redis is unreachable.
|
|
579
|
+
|
|
580
|
+
### Migration from `RedisSessionStore` (removed)
|
|
581
|
+
|
|
582
|
+
`RedisSessionStore` and its types (`SessionStore`, `LegacySessionRecord`,
|
|
583
|
+
`CreateSessionInput`, `UpdateSessionInput`) are **removed**. The data and
|
|
584
|
+
token models are incompatible, so old sessions cannot be read through the
|
|
585
|
+
new API:
|
|
586
|
+
|
|
587
|
+
- The legacy store keyed sessions by a caller-supplied `jti` and stored
|
|
588
|
+
raw JSON; the subsystem persists token-derived jtis (`SHA-256(token)`)
|
|
589
|
+
in versioned envelopes and validates **tokens**, not jtis.
|
|
590
|
+
- **Existing sessions must be re-established** (users re-authenticate).
|
|
591
|
+
For a smooth cutover, validate old tokens through a time-boxed
|
|
592
|
+
app-side shim (legacy jti lookup in your own data → mint a new session
|
|
593
|
+
via `create()`) and remove the shim once the old tokens age out.
|
|
594
|
+
- Do not run both against the same Redis namespace — records and indexes
|
|
595
|
+
share the same key layout but use different formats.
|
|
596
|
+
|
|
597
|
+
## Errors
|
|
598
|
+
|
|
599
|
+
```ts
|
|
600
|
+
import { RedisError } from 'ioredis-toolkit';
|
|
601
|
+
|
|
602
|
+
try {
|
|
603
|
+
await client.select(1);
|
|
604
|
+
} catch (error) {
|
|
605
|
+
if (error instanceof RedisError && error.code === 'CLUSTER_MODE') {
|
|
606
|
+
// SELECT is not available in cluster mode
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
## Logging
|
|
612
|
+
|
|
613
|
+
Every component accepts a pino-compatible logger (`trace/debug/info/warn/error/fatal` + `child`). Defaults to `console`.
|
|
614
|
+
|
|
615
|
+
```ts
|
|
616
|
+
import { createLogger } from 'pino';
|
|
617
|
+
const logger = createLogger();
|
|
618
|
+
const client = new RedisClient(config, logger);
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
## Mode compatibility
|
|
622
|
+
|
|
623
|
+
| Operation | Standalone | Sentinel | Cluster |
|
|
624
|
+
| --- | --- | --- | --- |
|
|
625
|
+
| Single-key commands (get/set/hash/set/zset/incr/...) | ✅ | ✅ | ✅ |
|
|
626
|
+
| `mget` / `mset` / `mgetClusterAware` | ✅ | ✅ | ✅ slot-grouped |
|
|
627
|
+
| `scanIterator` / `deletePattern` / `keys` | ✅ | ✅ | ✅ all nodes scanned |
|
|
628
|
+
| Pipelines | ✅ | ✅ | ✅ (same-slot keys per pipeline) |
|
|
629
|
+
| Lua scripts (`eval` / `evalsha` / `scriptLoad`) | ✅ | ✅ | ✅ (keys declared in `KEYS`, one slot) |
|
|
630
|
+
| `RedisRevocationStore` | ✅ | ✅ | ✅ batch ops slot-grouped |
|
|
631
|
+
| `createSessionManager` | ✅ | ✅ | ✅ hash-tagged Lua scripts, slot-grouped fan-out |
|
|
632
|
+
| `select(database)` | ✅ | ✅ | ❌ (Redis limitation) |
|
|
633
|
+
| Hash-tag keys `{tag}:...` | ✅ | ✅ | ✅ same slot |
|
|
634
|
+
|
|
635
|
+
## Development
|
|
636
|
+
|
|
637
|
+
```bash
|
|
638
|
+
npm install
|
|
639
|
+
npm run build # tsc + asset copy (Lua scripts land in dist/session/scripts)
|
|
640
|
+
npm run typecheck # src + test + scripts (tsconfig.test.json)
|
|
641
|
+
npm test # vitest
|
|
642
|
+
npm run test:watch
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
The test suite covers the client, cache and rate limiter (including cluster-mode behavior) using in-memory fakes — no Redis server required. The session suites are gated: they run against real Redis (`localhost:6379`, or `REDIS_MODE=cluster` / `REDIS_MODE=sentinel` with the compose topologies in `test/infra/`) and skip cleanly when it is unreachable.
|