@tekir/redis 0.1.0 → 0.1.2
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/dist/manager.d.ts +6 -0
- package/dist/manager.js +6 -0
- package/dist/redis.d.ts +48 -4
- package/dist/redis.js +146 -12
- package/package.json +2 -2
- package/src/manager.ts +6 -0
- package/src/redis.ts +154 -11
package/dist/manager.d.ts
CHANGED
|
@@ -106,12 +106,18 @@ export declare class RedisManager {
|
|
|
106
106
|
unsubscribe(channel?: string): Promise<void>;
|
|
107
107
|
/** @see {@link Redis.send} */
|
|
108
108
|
send(command: string, args?: string[]): Promise<any>;
|
|
109
|
+
/** @see {@link Redis.keyName} */
|
|
110
|
+
keyName(key: string): string;
|
|
109
111
|
/** @see {@link Redis.getJSON} */
|
|
110
112
|
getJSON<T = any>(key: string): Promise<T | null>;
|
|
113
|
+
/** @see {@link Redis.setEx} */
|
|
114
|
+
setEx(key: string, value: string | number, seconds: number): Promise<void>;
|
|
111
115
|
/** @see {@link Redis.setJSON} */
|
|
112
116
|
setJSON(key: string, value: any, expireSeconds?: number): Promise<void>;
|
|
113
117
|
/** @see {@link Redis.remember} */
|
|
114
118
|
remember<T>(key: string, seconds: number, callback: () => Promise<T>): Promise<T>;
|
|
119
|
+
/** @see {@link Redis.clearPrefix} */
|
|
120
|
+
clearPrefix(): Promise<number>;
|
|
115
121
|
/** @see {@link Redis.flushdb} */
|
|
116
122
|
flushdb(): Promise<void>;
|
|
117
123
|
/** @see {@link Redis.connected} */
|
package/dist/manager.js
CHANGED
|
@@ -138,12 +138,18 @@ export class RedisManager {
|
|
|
138
138
|
unsubscribe(channel) { return this.connection().unsubscribe(channel); }
|
|
139
139
|
/** @see {@link Redis.send} */
|
|
140
140
|
send(command, args = []) { return this.connection().send(command, args); }
|
|
141
|
+
/** @see {@link Redis.keyName} */
|
|
142
|
+
keyName(key) { return this.connection().keyName(key); }
|
|
141
143
|
/** @see {@link Redis.getJSON} */
|
|
142
144
|
getJSON(key) { return this.connection().getJSON(key); }
|
|
145
|
+
/** @see {@link Redis.setEx} */
|
|
146
|
+
setEx(key, value, seconds) { return this.connection().setEx(key, value, seconds); }
|
|
143
147
|
/** @see {@link Redis.setJSON} */
|
|
144
148
|
setJSON(key, value, expireSeconds) { return this.connection().setJSON(key, value, expireSeconds); }
|
|
145
149
|
/** @see {@link Redis.remember} */
|
|
146
150
|
remember(key, seconds, callback) { return this.connection().remember(key, seconds, callback); }
|
|
151
|
+
/** @see {@link Redis.clearPrefix} */
|
|
152
|
+
clearPrefix() { return this.connection().clearPrefix(); }
|
|
147
153
|
/** @see {@link Redis.flushdb} */
|
|
148
154
|
flushdb() { return this.connection().flushdb(); }
|
|
149
155
|
/** @see {@link Redis.connected} */
|
package/dist/redis.d.ts
CHANGED
|
@@ -21,6 +21,15 @@ export declare class Redis {
|
|
|
21
21
|
*/
|
|
22
22
|
constructor(config?: RedisConnectionConfig);
|
|
23
23
|
private key;
|
|
24
|
+
/** Resolve the actual Redis key after applying this connection's namespace. */
|
|
25
|
+
keyName(key: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* Strip any credentials embedded in a Redis URL so it is safe to log.
|
|
28
|
+
*
|
|
29
|
+
* @param url - The connection URL, possibly containing `user:pass@host`.
|
|
30
|
+
* @returns The URL with the userinfo component masked.
|
|
31
|
+
*/
|
|
32
|
+
private static maskUrl;
|
|
24
33
|
/**
|
|
25
34
|
* Open the connection to the Redis server.
|
|
26
35
|
*
|
|
@@ -66,6 +75,8 @@ export declare class Redis {
|
|
|
66
75
|
* ```
|
|
67
76
|
*/
|
|
68
77
|
set(key: string, value: string | number): Promise<void>;
|
|
78
|
+
/** Atomically set a value and its expiration time. */
|
|
79
|
+
setEx(key: string, value: string | number, seconds: number): Promise<void>;
|
|
69
80
|
/**
|
|
70
81
|
* Delete one or more keys.
|
|
71
82
|
*
|
|
@@ -185,6 +196,11 @@ export declare class Redis {
|
|
|
185
196
|
/**
|
|
186
197
|
* Subscribe to a channel and receive messages via a callback.
|
|
187
198
|
*
|
|
199
|
+
* The message passed to the callback is the raw string received from Redis and
|
|
200
|
+
* is NOT deserialized by this wrapper. Treat it as untrusted input: validate
|
|
201
|
+
* it and never `eval` it. If you `JSON.parse` it, wrap the parse in a
|
|
202
|
+
* try/catch to guard against poisoned payloads.
|
|
203
|
+
*
|
|
188
204
|
* @param channel - The channel name to subscribe to.
|
|
189
205
|
* @param callback - Invoked for each message received on the channel.
|
|
190
206
|
*
|
|
@@ -203,10 +219,16 @@ export declare class Redis {
|
|
|
203
219
|
*/
|
|
204
220
|
unsubscribe(channel?: string): Promise<void>;
|
|
205
221
|
/**
|
|
206
|
-
* Send a raw Redis command.
|
|
222
|
+
* Send a raw Redis command. ADVANCED / INTERNAL escape hatch.
|
|
223
|
+
*
|
|
224
|
+
* This bypasses the key prefix and every higher-level safeguard. The `command`
|
|
225
|
+
* and `args` are passed straight to Redis, so they MUST be trusted, statically
|
|
226
|
+
* known values. Never build a command name or its arguments from user input:
|
|
227
|
+
* doing so allows execution of dangerous commands (`FLUSHALL`, `CONFIG`,
|
|
228
|
+
* `EVAL`, `KEYS *`, ...) and lets callers escape the prefix namespace.
|
|
207
229
|
*
|
|
208
|
-
* @param command -
|
|
209
|
-
* @param args - Arguments for the command.
|
|
230
|
+
* @param command - A trusted, statically-known Redis command (e.g. `'PING'`, `'INFO'`).
|
|
231
|
+
* @param args - Arguments for the command. Must not contain untrusted input.
|
|
210
232
|
* @returns The raw response from Redis.
|
|
211
233
|
*
|
|
212
234
|
* @example
|
|
@@ -249,6 +271,11 @@ export declare class Redis {
|
|
|
249
271
|
* @param callback - Async function invoked to compute the value on a cache miss.
|
|
250
272
|
* @returns The cached or freshly computed value.
|
|
251
273
|
*
|
|
274
|
+
* On a cache miss this acquires a short-lived `SET NX` lock so that, under
|
|
275
|
+
* concurrent misses, only one caller runs `callback()` while the others wait
|
|
276
|
+
* for the freshly-populated value. This protects the backend from a stampede
|
|
277
|
+
* (thundering herd) when a hot key expires.
|
|
278
|
+
*
|
|
252
279
|
* @example
|
|
253
280
|
* ```ts
|
|
254
281
|
* const users = await redis.remember('all-users', 60, async () => {
|
|
@@ -258,7 +285,24 @@ export declare class Redis {
|
|
|
258
285
|
*/
|
|
259
286
|
remember<T>(key: string, seconds: number, callback: () => Promise<T>): Promise<T>;
|
|
260
287
|
/**
|
|
261
|
-
* Delete
|
|
288
|
+
* Delete every key under this connection's prefix using a non-blocking SCAN.
|
|
289
|
+
*
|
|
290
|
+
* Unlike {@link Redis.flushdb}, this is scoped: it only removes keys that
|
|
291
|
+
* belong to this logical store (`<prefix>:*`), leaving other stores that
|
|
292
|
+
* share the same Redis database (sessions, queues, ...) untouched. If no
|
|
293
|
+
* prefix is configured this deletes nothing and returns `0`, to avoid
|
|
294
|
+
* accidentally wiping the whole database.
|
|
295
|
+
*
|
|
296
|
+
* @returns The number of keys deleted.
|
|
297
|
+
*/
|
|
298
|
+
clearPrefix(): Promise<number>;
|
|
299
|
+
/**
|
|
300
|
+
* Delete ALL keys in the currently selected database. DANGEROUS.
|
|
301
|
+
*
|
|
302
|
+
* This ignores the key prefix and removes every key in the database, including
|
|
303
|
+
* data owned by other logical stores (sessions, queues, other caches) that
|
|
304
|
+
* share the same Redis database. Prefer {@link Redis.clearPrefix} to delete
|
|
305
|
+
* only this store's keys. Never expose this to user-triggered code paths.
|
|
262
306
|
*
|
|
263
307
|
* @returns A promise that resolves once the database has been flushed.
|
|
264
308
|
*/
|
package/dist/redis.js
CHANGED
|
@@ -20,9 +20,17 @@ export class Redis {
|
|
|
20
20
|
*/
|
|
21
21
|
constructor(config = {}) {
|
|
22
22
|
this.config = config;
|
|
23
|
-
this.prefix = config.prefix || '';
|
|
23
|
+
this.prefix = (config.prefix || '').replace(/:+$/, '');
|
|
24
24
|
const { RedisClient } = Bun;
|
|
25
25
|
const url = config.url || process.env.REDIS_URL || 'redis://localhost:6379';
|
|
26
|
+
// Encourage TLS in production: a plaintext redis:// connection exposes
|
|
27
|
+
// credentials and data to network observers. Warn once instead of throwing
|
|
28
|
+
// to stay backward compatible with local/dev setups.
|
|
29
|
+
const isTls = config.tls != null || url.startsWith('rediss://');
|
|
30
|
+
if (!isTls && process.env.NODE_ENV === 'production') {
|
|
31
|
+
console.warn(`[@tekir/redis] Connecting to ${Redis.maskUrl(url)} without TLS in production. ` +
|
|
32
|
+
`Use a rediss:// URL or set tls to encrypt credentials and data in transit.`);
|
|
33
|
+
}
|
|
26
34
|
this.client = new RedisClient(url, {
|
|
27
35
|
connectionTimeout: config.connectionTimeout,
|
|
28
36
|
idleTimeout: config.idleTimeout,
|
|
@@ -35,6 +43,17 @@ export class Redis {
|
|
|
35
43
|
key(k) {
|
|
36
44
|
return this.prefix ? `${this.prefix}:${k}` : k;
|
|
37
45
|
}
|
|
46
|
+
/** Resolve the actual Redis key after applying this connection's namespace. */
|
|
47
|
+
keyName(key) { return this.key(key); }
|
|
48
|
+
/**
|
|
49
|
+
* Strip any credentials embedded in a Redis URL so it is safe to log.
|
|
50
|
+
*
|
|
51
|
+
* @param url - The connection URL, possibly containing `user:pass@host`.
|
|
52
|
+
* @returns The URL with the userinfo component masked.
|
|
53
|
+
*/
|
|
54
|
+
static maskUrl(url) {
|
|
55
|
+
return url.replace(/(\w+:\/\/)([^@/]+)@/, '$1***@');
|
|
56
|
+
}
|
|
38
57
|
// ─── Connection ────────────────────────────────────────
|
|
39
58
|
/**
|
|
40
59
|
* Open the connection to the Redis server.
|
|
@@ -82,6 +101,12 @@ export class Redis {
|
|
|
82
101
|
* ```
|
|
83
102
|
*/
|
|
84
103
|
async set(key, value) { await this.client.set(this.key(key), String(value)); }
|
|
104
|
+
/** Atomically set a value and its expiration time. */
|
|
105
|
+
async setEx(key, value, seconds) {
|
|
106
|
+
if (!Number.isFinite(seconds) || seconds <= 0)
|
|
107
|
+
throw new Error('Redis setEx seconds must be a positive number');
|
|
108
|
+
await this.client.send('SET', [this.key(key), String(value), 'EX', String(Math.floor(seconds))]);
|
|
109
|
+
}
|
|
85
110
|
/**
|
|
86
111
|
* Delete one or more keys.
|
|
87
112
|
*
|
|
@@ -204,6 +229,11 @@ export class Redis {
|
|
|
204
229
|
/**
|
|
205
230
|
* Subscribe to a channel and receive messages via a callback.
|
|
206
231
|
*
|
|
232
|
+
* The message passed to the callback is the raw string received from Redis and
|
|
233
|
+
* is NOT deserialized by this wrapper. Treat it as untrusted input: validate
|
|
234
|
+
* it and never `eval` it. If you `JSON.parse` it, wrap the parse in a
|
|
235
|
+
* try/catch to guard against poisoned payloads.
|
|
236
|
+
*
|
|
207
237
|
* @param channel - The channel name to subscribe to.
|
|
208
238
|
* @param callback - Invoked for each message received on the channel.
|
|
209
239
|
*
|
|
@@ -225,10 +255,16 @@ export class Redis {
|
|
|
225
255
|
async unsubscribe(channel) { await this.client.unsubscribe(channel); }
|
|
226
256
|
// ─── Raw command ──────────────────────────────────────
|
|
227
257
|
/**
|
|
228
|
-
* Send a raw Redis command.
|
|
258
|
+
* Send a raw Redis command. ADVANCED / INTERNAL escape hatch.
|
|
259
|
+
*
|
|
260
|
+
* This bypasses the key prefix and every higher-level safeguard. The `command`
|
|
261
|
+
* and `args` are passed straight to Redis, so they MUST be trusted, statically
|
|
262
|
+
* known values. Never build a command name or its arguments from user input:
|
|
263
|
+
* doing so allows execution of dangerous commands (`FLUSHALL`, `CONFIG`,
|
|
264
|
+
* `EVAL`, `KEYS *`, ...) and lets callers escape the prefix namespace.
|
|
229
265
|
*
|
|
230
|
-
* @param command -
|
|
231
|
-
* @param args - Arguments for the command.
|
|
266
|
+
* @param command - A trusted, statically-known Redis command (e.g. `'PING'`, `'INFO'`).
|
|
267
|
+
* @param args - Arguments for the command. Must not contain untrusted input.
|
|
232
268
|
* @returns The raw response from Redis.
|
|
233
269
|
*
|
|
234
270
|
* @example
|
|
@@ -256,7 +292,11 @@ export class Redis {
|
|
|
256
292
|
try {
|
|
257
293
|
return JSON.parse(val);
|
|
258
294
|
}
|
|
259
|
-
catch {
|
|
295
|
+
catch (e) {
|
|
296
|
+
// Surface corrupt/poisoned payloads instead of silently masking them as a
|
|
297
|
+
// cache miss. Returning null still preserves the previous behaviour for
|
|
298
|
+
// callers, but the warning makes the problem diagnosable.
|
|
299
|
+
console.warn(`[@tekir/redis] Failed to parse JSON for key "${key}": ${e.message}`);
|
|
260
300
|
return null;
|
|
261
301
|
}
|
|
262
302
|
}
|
|
@@ -273,9 +313,17 @@ export class Redis {
|
|
|
273
313
|
* ```
|
|
274
314
|
*/
|
|
275
315
|
async setJSON(key, value, expireSeconds) {
|
|
276
|
-
|
|
277
|
-
if (
|
|
278
|
-
|
|
316
|
+
const payload = JSON.stringify(value);
|
|
317
|
+
if (payload === undefined)
|
|
318
|
+
throw new Error(`Redis cannot serialize undefined for key "${key}"`);
|
|
319
|
+
if (expireSeconds && expireSeconds > 0) {
|
|
320
|
+
// Atomic SET ... EX so a crash between writing the value and setting the
|
|
321
|
+
// TTL can never leave a permanent (TTL-less) key behind.
|
|
322
|
+
await this.client.send('SET', [this.key(key), payload, 'EX', String(Math.floor(expireSeconds))]);
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
await this.client.set(this.key(key), payload);
|
|
326
|
+
}
|
|
279
327
|
}
|
|
280
328
|
/**
|
|
281
329
|
* Cache-aside helper: return the cached value if it exists, otherwise execute
|
|
@@ -286,6 +334,11 @@ export class Redis {
|
|
|
286
334
|
* @param callback - Async function invoked to compute the value on a cache miss.
|
|
287
335
|
* @returns The cached or freshly computed value.
|
|
288
336
|
*
|
|
337
|
+
* On a cache miss this acquires a short-lived `SET NX` lock so that, under
|
|
338
|
+
* concurrent misses, only one caller runs `callback()` while the others wait
|
|
339
|
+
* for the freshly-populated value. This protects the backend from a stampede
|
|
340
|
+
* (thundering herd) when a hot key expires.
|
|
341
|
+
*
|
|
289
342
|
* @example
|
|
290
343
|
* ```ts
|
|
291
344
|
* const users = await redis.remember('all-users', 60, async () => {
|
|
@@ -297,12 +350,93 @@ export class Redis {
|
|
|
297
350
|
const cached = await this.getJSON(key);
|
|
298
351
|
if (cached !== null)
|
|
299
352
|
return cached;
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
353
|
+
const lockKey = this.key(`${key}:__lock`);
|
|
354
|
+
const lockToken = crypto.randomUUID();
|
|
355
|
+
// Try to become the single flight that computes the value. SET NX EX gives
|
|
356
|
+
// a self-expiring lock so a crashed holder cannot deadlock other callers.
|
|
357
|
+
const acquired = await this.client.send('SET', [lockKey, lockToken, 'NX', 'EX', '10']);
|
|
358
|
+
if (acquired == null) {
|
|
359
|
+
// Another caller is computing it. Wait for the populated value, but do
|
|
360
|
+
// not run the callback without owning the lock: doing so would turn slow
|
|
361
|
+
// cache fills back into a stampede. A bounded timeout keeps callers from
|
|
362
|
+
// hanging forever if Redis itself is unhealthy.
|
|
363
|
+
for (let i = 0; i < 300; i++) {
|
|
364
|
+
await new Promise(r => setTimeout(r, 100));
|
|
365
|
+
const waited = await this.getJSON(key);
|
|
366
|
+
if (waited !== null)
|
|
367
|
+
return waited;
|
|
368
|
+
}
|
|
369
|
+
throw new Error(`Redis remember timed out waiting for lock on "${key}"`);
|
|
370
|
+
}
|
|
371
|
+
const renewScript = `
|
|
372
|
+
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
|
373
|
+
return redis.call('EXPIRE', KEYS[1], 10)
|
|
374
|
+
end
|
|
375
|
+
return 0
|
|
376
|
+
`;
|
|
377
|
+
const renewTimer = setInterval(() => {
|
|
378
|
+
void this.client.send('EVAL', [renewScript, '1', lockKey, lockToken]).catch(() => { });
|
|
379
|
+
}, 3000);
|
|
380
|
+
renewTimer.unref?.();
|
|
381
|
+
try {
|
|
382
|
+
// Re-check after acquiring the lock: a racing holder may have populated it.
|
|
383
|
+
const fresh = await this.getJSON(key);
|
|
384
|
+
if (fresh !== null)
|
|
385
|
+
return fresh;
|
|
386
|
+
const value = await callback();
|
|
387
|
+
await this.setJSON(key, value, seconds);
|
|
388
|
+
return value;
|
|
389
|
+
}
|
|
390
|
+
finally {
|
|
391
|
+
clearInterval(renewTimer);
|
|
392
|
+
// Only the owner may release the lock. A plain DEL lets a slow callback
|
|
393
|
+
// delete a successor's lock after its own 10s lease expired, reopening
|
|
394
|
+
// the stampede window. Waiters that never acquired it release nothing.
|
|
395
|
+
if (acquired != null) {
|
|
396
|
+
const releaseScript = `
|
|
397
|
+
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
|
398
|
+
return redis.call('DEL', KEYS[1])
|
|
399
|
+
end
|
|
400
|
+
return 0
|
|
401
|
+
`;
|
|
402
|
+
await this.client.send('EVAL', [releaseScript, '1', lockKey, lockToken]).catch(() => { });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Delete every key under this connection's prefix using a non-blocking SCAN.
|
|
408
|
+
*
|
|
409
|
+
* Unlike {@link Redis.flushdb}, this is scoped: it only removes keys that
|
|
410
|
+
* belong to this logical store (`<prefix>:*`), leaving other stores that
|
|
411
|
+
* share the same Redis database (sessions, queues, ...) untouched. If no
|
|
412
|
+
* prefix is configured this deletes nothing and returns `0`, to avoid
|
|
413
|
+
* accidentally wiping the whole database.
|
|
414
|
+
*
|
|
415
|
+
* @returns The number of keys deleted.
|
|
416
|
+
*/
|
|
417
|
+
async clearPrefix() {
|
|
418
|
+
if (!this.prefix)
|
|
419
|
+
return 0;
|
|
420
|
+
const pattern = `${this.prefix}:*`;
|
|
421
|
+
let cursor = '0';
|
|
422
|
+
let deleted = 0;
|
|
423
|
+
do {
|
|
424
|
+
const [next, batch] = await this.client.send('SCAN', [cursor, 'MATCH', pattern, 'COUNT', '100']);
|
|
425
|
+
cursor = next;
|
|
426
|
+
if (batch.length) {
|
|
427
|
+
await this.client.send('DEL', batch);
|
|
428
|
+
deleted += batch.length;
|
|
429
|
+
}
|
|
430
|
+
} while (cursor !== '0');
|
|
431
|
+
return deleted;
|
|
303
432
|
}
|
|
304
433
|
/**
|
|
305
|
-
* Delete
|
|
434
|
+
* Delete ALL keys in the currently selected database. DANGEROUS.
|
|
435
|
+
*
|
|
436
|
+
* This ignores the key prefix and removes every key in the database, including
|
|
437
|
+
* data owned by other logical stores (sessions, queues, other caches) that
|
|
438
|
+
* share the same Redis database. Prefer {@link Redis.clearPrefix} to delete
|
|
439
|
+
* only this store's keys. Never expose this to user-triggered code paths.
|
|
306
440
|
*
|
|
307
441
|
* @returns A promise that resolves once the database has been flushed.
|
|
308
442
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tekir/redis",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Redis connection and client management",
|
|
5
5
|
"author": "dev@tekir.io",
|
|
6
6
|
"license": "MIT",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
}
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@tekir/core": "^0.1.
|
|
42
|
+
"@tekir/core": "^0.1.35"
|
|
43
43
|
},
|
|
44
44
|
"scripts": {
|
|
45
45
|
"build": "rm -rf dist && tsc --noEmit false",
|
package/src/manager.ts
CHANGED
|
@@ -146,12 +146,18 @@ export class RedisManager {
|
|
|
146
146
|
unsubscribe(channel?: string) { return this.connection().unsubscribe(channel) }
|
|
147
147
|
/** @see {@link Redis.send} */
|
|
148
148
|
send(command: string, args: string[] = []) { return this.connection().send(command, args) }
|
|
149
|
+
/** @see {@link Redis.keyName} */
|
|
150
|
+
keyName(key: string) { return this.connection().keyName(key) }
|
|
149
151
|
/** @see {@link Redis.getJSON} */
|
|
150
152
|
getJSON<T = any>(key: string) { return this.connection().getJSON<T>(key) }
|
|
153
|
+
/** @see {@link Redis.setEx} */
|
|
154
|
+
setEx(key: string, value: string | number, seconds: number) { return this.connection().setEx(key, value, seconds) }
|
|
151
155
|
/** @see {@link Redis.setJSON} */
|
|
152
156
|
setJSON(key: string, value: any, expireSeconds?: number) { return this.connection().setJSON(key, value, expireSeconds) }
|
|
153
157
|
/** @see {@link Redis.remember} */
|
|
154
158
|
remember<T>(key: string, seconds: number, callback: () => Promise<T>) { return this.connection().remember<T>(key, seconds, callback) }
|
|
159
|
+
/** @see {@link Redis.clearPrefix} */
|
|
160
|
+
clearPrefix() { return this.connection().clearPrefix() }
|
|
155
161
|
/** @see {@link Redis.flushdb} */
|
|
156
162
|
flushdb() { return this.connection().flushdb() }
|
|
157
163
|
/** @see {@link Redis.connected} */
|
package/src/redis.ts
CHANGED
|
@@ -23,11 +23,22 @@ export class Redis {
|
|
|
23
23
|
*/
|
|
24
24
|
constructor(config: RedisConnectionConfig = {}) {
|
|
25
25
|
this.config = config
|
|
26
|
-
this.prefix = config.prefix || ''
|
|
26
|
+
this.prefix = (config.prefix || '').replace(/:+$/, '')
|
|
27
27
|
|
|
28
28
|
const { RedisClient } = Bun as any
|
|
29
29
|
const url = config.url || process.env.REDIS_URL || 'redis://localhost:6379'
|
|
30
30
|
|
|
31
|
+
// Encourage TLS in production: a plaintext redis:// connection exposes
|
|
32
|
+
// credentials and data to network observers. Warn once instead of throwing
|
|
33
|
+
// to stay backward compatible with local/dev setups.
|
|
34
|
+
const isTls = config.tls != null || url.startsWith('rediss://')
|
|
35
|
+
if (!isTls && process.env.NODE_ENV === 'production') {
|
|
36
|
+
console.warn(
|
|
37
|
+
`[@tekir/redis] Connecting to ${Redis.maskUrl(url)} without TLS in production. ` +
|
|
38
|
+
`Use a rediss:// URL or set tls to encrypt credentials and data in transit.`
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
this.client = new RedisClient(url, {
|
|
32
43
|
connectionTimeout: config.connectionTimeout,
|
|
33
44
|
idleTimeout: config.idleTimeout,
|
|
@@ -42,6 +53,19 @@ export class Redis {
|
|
|
42
53
|
return this.prefix ? `${this.prefix}:${k}` : k
|
|
43
54
|
}
|
|
44
55
|
|
|
56
|
+
/** Resolve the actual Redis key after applying this connection's namespace. */
|
|
57
|
+
keyName(key: string): string { return this.key(key) }
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Strip any credentials embedded in a Redis URL so it is safe to log.
|
|
61
|
+
*
|
|
62
|
+
* @param url - The connection URL, possibly containing `user:pass@host`.
|
|
63
|
+
* @returns The URL with the userinfo component masked.
|
|
64
|
+
*/
|
|
65
|
+
private static maskUrl(url: string): string {
|
|
66
|
+
return url.replace(/(\w+:\/\/)([^@/]+)@/, '$1***@')
|
|
67
|
+
}
|
|
68
|
+
|
|
45
69
|
// ─── Connection ────────────────────────────────────────
|
|
46
70
|
|
|
47
71
|
/**
|
|
@@ -96,6 +120,12 @@ export class Redis {
|
|
|
96
120
|
*/
|
|
97
121
|
async set(key: string, value: string | number): Promise<void> { await this.client.set(this.key(key), String(value)) }
|
|
98
122
|
|
|
123
|
+
/** Atomically set a value and its expiration time. */
|
|
124
|
+
async setEx(key: string, value: string | number, seconds: number): Promise<void> {
|
|
125
|
+
if (!Number.isFinite(seconds) || seconds <= 0) throw new Error('Redis setEx seconds must be a positive number')
|
|
126
|
+
await this.client.send('SET', [this.key(key), String(value), 'EX', String(Math.floor(seconds))])
|
|
127
|
+
}
|
|
128
|
+
|
|
99
129
|
/**
|
|
100
130
|
* Delete one or more keys.
|
|
101
131
|
*
|
|
@@ -236,6 +266,11 @@ export class Redis {
|
|
|
236
266
|
/**
|
|
237
267
|
* Subscribe to a channel and receive messages via a callback.
|
|
238
268
|
*
|
|
269
|
+
* The message passed to the callback is the raw string received from Redis and
|
|
270
|
+
* is NOT deserialized by this wrapper. Treat it as untrusted input: validate
|
|
271
|
+
* it and never `eval` it. If you `JSON.parse` it, wrap the parse in a
|
|
272
|
+
* try/catch to guard against poisoned payloads.
|
|
273
|
+
*
|
|
239
274
|
* @param channel - The channel name to subscribe to.
|
|
240
275
|
* @param callback - Invoked for each message received on the channel.
|
|
241
276
|
*
|
|
@@ -260,10 +295,16 @@ export class Redis {
|
|
|
260
295
|
// ─── Raw command ──────────────────────────────────────
|
|
261
296
|
|
|
262
297
|
/**
|
|
263
|
-
* Send a raw Redis command.
|
|
298
|
+
* Send a raw Redis command. ADVANCED / INTERNAL escape hatch.
|
|
264
299
|
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
300
|
+
* This bypasses the key prefix and every higher-level safeguard. The `command`
|
|
301
|
+
* and `args` are passed straight to Redis, so they MUST be trusted, statically
|
|
302
|
+
* known values. Never build a command name or its arguments from user input:
|
|
303
|
+
* doing so allows execution of dangerous commands (`FLUSHALL`, `CONFIG`,
|
|
304
|
+
* `EVAL`, `KEYS *`, ...) and lets callers escape the prefix namespace.
|
|
305
|
+
*
|
|
306
|
+
* @param command - A trusted, statically-known Redis command (e.g. `'PING'`, `'INFO'`).
|
|
307
|
+
* @param args - Arguments for the command. Must not contain untrusted input.
|
|
267
308
|
* @returns The raw response from Redis.
|
|
268
309
|
*
|
|
269
310
|
* @example
|
|
@@ -289,7 +330,15 @@ export class Redis {
|
|
|
289
330
|
async getJSON<T = any>(key: string): Promise<T | null> {
|
|
290
331
|
const val = await this.get(key)
|
|
291
332
|
if (val === null) return null
|
|
292
|
-
try {
|
|
333
|
+
try {
|
|
334
|
+
return JSON.parse(val)
|
|
335
|
+
} catch (e) {
|
|
336
|
+
// Surface corrupt/poisoned payloads instead of silently masking them as a
|
|
337
|
+
// cache miss. Returning null still preserves the previous behaviour for
|
|
338
|
+
// callers, but the warning makes the problem diagnosable.
|
|
339
|
+
console.warn(`[@tekir/redis] Failed to parse JSON for key "${key}": ${(e as Error).message}`)
|
|
340
|
+
return null
|
|
341
|
+
}
|
|
293
342
|
}
|
|
294
343
|
|
|
295
344
|
/**
|
|
@@ -305,8 +354,15 @@ export class Redis {
|
|
|
305
354
|
* ```
|
|
306
355
|
*/
|
|
307
356
|
async setJSON(key: string, value: any, expireSeconds?: number): Promise<void> {
|
|
308
|
-
|
|
309
|
-
if (
|
|
357
|
+
const payload = JSON.stringify(value)
|
|
358
|
+
if (payload === undefined) throw new Error(`Redis cannot serialize undefined for key "${key}"`)
|
|
359
|
+
if (expireSeconds && expireSeconds > 0) {
|
|
360
|
+
// Atomic SET ... EX so a crash between writing the value and setting the
|
|
361
|
+
// TTL can never leave a permanent (TTL-less) key behind.
|
|
362
|
+
await this.client.send('SET', [this.key(key), payload, 'EX', String(Math.floor(expireSeconds))])
|
|
363
|
+
} else {
|
|
364
|
+
await this.client.set(this.key(key), payload)
|
|
365
|
+
}
|
|
310
366
|
}
|
|
311
367
|
|
|
312
368
|
/**
|
|
@@ -318,6 +374,11 @@ export class Redis {
|
|
|
318
374
|
* @param callback - Async function invoked to compute the value on a cache miss.
|
|
319
375
|
* @returns The cached or freshly computed value.
|
|
320
376
|
*
|
|
377
|
+
* On a cache miss this acquires a short-lived `SET NX` lock so that, under
|
|
378
|
+
* concurrent misses, only one caller runs `callback()` while the others wait
|
|
379
|
+
* for the freshly-populated value. This protects the backend from a stampede
|
|
380
|
+
* (thundering herd) when a hot key expires.
|
|
381
|
+
*
|
|
321
382
|
* @example
|
|
322
383
|
* ```ts
|
|
323
384
|
* const users = await redis.remember('all-users', 60, async () => {
|
|
@@ -328,13 +389,95 @@ export class Redis {
|
|
|
328
389
|
async remember<T>(key: string, seconds: number, callback: () => Promise<T>): Promise<T> {
|
|
329
390
|
const cached = await this.getJSON<T>(key)
|
|
330
391
|
if (cached !== null) return cached
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
392
|
+
|
|
393
|
+
const lockKey = this.key(`${key}:__lock`)
|
|
394
|
+
const lockToken = crypto.randomUUID()
|
|
395
|
+
// Try to become the single flight that computes the value. SET NX EX gives
|
|
396
|
+
// a self-expiring lock so a crashed holder cannot deadlock other callers.
|
|
397
|
+
const acquired = await this.client.send('SET', [lockKey, lockToken, 'NX', 'EX', '10'])
|
|
398
|
+
|
|
399
|
+
if (acquired == null) {
|
|
400
|
+
// Another caller is computing it. Wait for the populated value, but do
|
|
401
|
+
// not run the callback without owning the lock: doing so would turn slow
|
|
402
|
+
// cache fills back into a stampede. A bounded timeout keeps callers from
|
|
403
|
+
// hanging forever if Redis itself is unhealthy.
|
|
404
|
+
for (let i = 0; i < 300; i++) {
|
|
405
|
+
await new Promise(r => setTimeout(r, 100))
|
|
406
|
+
const waited = await this.getJSON<T>(key)
|
|
407
|
+
if (waited !== null) return waited
|
|
408
|
+
}
|
|
409
|
+
throw new Error(`Redis remember timed out waiting for lock on "${key}"`)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const renewScript = `
|
|
413
|
+
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
|
414
|
+
return redis.call('EXPIRE', KEYS[1], 10)
|
|
415
|
+
end
|
|
416
|
+
return 0
|
|
417
|
+
`
|
|
418
|
+
const renewTimer = setInterval(() => {
|
|
419
|
+
void this.client.send('EVAL', [renewScript, '1', lockKey, lockToken]).catch(() => {})
|
|
420
|
+
}, 3000)
|
|
421
|
+
;(renewTimer as any).unref?.()
|
|
422
|
+
|
|
423
|
+
try {
|
|
424
|
+
// Re-check after acquiring the lock: a racing holder may have populated it.
|
|
425
|
+
const fresh = await this.getJSON<T>(key)
|
|
426
|
+
if (fresh !== null) return fresh
|
|
427
|
+
const value = await callback()
|
|
428
|
+
await this.setJSON(key, value, seconds)
|
|
429
|
+
return value
|
|
430
|
+
} finally {
|
|
431
|
+
clearInterval(renewTimer)
|
|
432
|
+
// Only the owner may release the lock. A plain DEL lets a slow callback
|
|
433
|
+
// delete a successor's lock after its own 10s lease expired, reopening
|
|
434
|
+
// the stampede window. Waiters that never acquired it release nothing.
|
|
435
|
+
if (acquired != null) {
|
|
436
|
+
const releaseScript = `
|
|
437
|
+
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
|
438
|
+
return redis.call('DEL', KEYS[1])
|
|
439
|
+
end
|
|
440
|
+
return 0
|
|
441
|
+
`
|
|
442
|
+
await this.client.send('EVAL', [releaseScript, '1', lockKey, lockToken]).catch(() => {})
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Delete every key under this connection's prefix using a non-blocking SCAN.
|
|
449
|
+
*
|
|
450
|
+
* Unlike {@link Redis.flushdb}, this is scoped: it only removes keys that
|
|
451
|
+
* belong to this logical store (`<prefix>:*`), leaving other stores that
|
|
452
|
+
* share the same Redis database (sessions, queues, ...) untouched. If no
|
|
453
|
+
* prefix is configured this deletes nothing and returns `0`, to avoid
|
|
454
|
+
* accidentally wiping the whole database.
|
|
455
|
+
*
|
|
456
|
+
* @returns The number of keys deleted.
|
|
457
|
+
*/
|
|
458
|
+
async clearPrefix(): Promise<number> {
|
|
459
|
+
if (!this.prefix) return 0
|
|
460
|
+
const pattern = `${this.prefix}:*`
|
|
461
|
+
let cursor = '0'
|
|
462
|
+
let deleted = 0
|
|
463
|
+
do {
|
|
464
|
+
const [next, batch]: [string, string[]] = await this.client.send('SCAN', [cursor, 'MATCH', pattern, 'COUNT', '100'])
|
|
465
|
+
cursor = next
|
|
466
|
+
if (batch.length) {
|
|
467
|
+
await this.client.send('DEL', batch)
|
|
468
|
+
deleted += batch.length
|
|
469
|
+
}
|
|
470
|
+
} while (cursor !== '0')
|
|
471
|
+
return deleted
|
|
334
472
|
}
|
|
335
473
|
|
|
336
474
|
/**
|
|
337
|
-
* Delete
|
|
475
|
+
* Delete ALL keys in the currently selected database. DANGEROUS.
|
|
476
|
+
*
|
|
477
|
+
* This ignores the key prefix and removes every key in the database, including
|
|
478
|
+
* data owned by other logical stores (sessions, queues, other caches) that
|
|
479
|
+
* share the same Redis database. Prefer {@link Redis.clearPrefix} to delete
|
|
480
|
+
* only this store's keys. Never expose this to user-triggered code paths.
|
|
338
481
|
*
|
|
339
482
|
* @returns A promise that resolves once the database has been flushed.
|
|
340
483
|
*/
|