@tekir/redis 0.1.0 → 0.1.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/dist/manager.d.ts CHANGED
@@ -112,6 +112,8 @@ export declare class RedisManager {
112
112
  setJSON(key: string, value: any, expireSeconds?: number): Promise<void>;
113
113
  /** @see {@link Redis.remember} */
114
114
  remember<T>(key: string, seconds: number, callback: () => Promise<T>): Promise<T>;
115
+ /** @see {@link Redis.clearPrefix} */
116
+ clearPrefix(): Promise<number>;
115
117
  /** @see {@link Redis.flushdb} */
116
118
  flushdb(): Promise<void>;
117
119
  /** @see {@link Redis.connected} */
package/dist/manager.js CHANGED
@@ -144,6 +144,8 @@ export class RedisManager {
144
144
  setJSON(key, value, expireSeconds) { return this.connection().setJSON(key, value, expireSeconds); }
145
145
  /** @see {@link Redis.remember} */
146
146
  remember(key, seconds, callback) { return this.connection().remember(key, seconds, callback); }
147
+ /** @see {@link Redis.clearPrefix} */
148
+ clearPrefix() { return this.connection().clearPrefix(); }
147
149
  /** @see {@link Redis.flushdb} */
148
150
  flushdb() { return this.connection().flushdb(); }
149
151
  /** @see {@link Redis.connected} */
package/dist/redis.d.ts CHANGED
@@ -21,6 +21,13 @@ export declare class Redis {
21
21
  */
22
22
  constructor(config?: RedisConnectionConfig);
23
23
  private key;
24
+ /**
25
+ * Strip any credentials embedded in a Redis URL so it is safe to log.
26
+ *
27
+ * @param url - The connection URL, possibly containing `user:pass@host`.
28
+ * @returns The URL with the userinfo component masked.
29
+ */
30
+ private static maskUrl;
24
31
  /**
25
32
  * Open the connection to the Redis server.
26
33
  *
@@ -185,6 +192,11 @@ export declare class Redis {
185
192
  /**
186
193
  * Subscribe to a channel and receive messages via a callback.
187
194
  *
195
+ * The message passed to the callback is the raw string received from Redis and
196
+ * is NOT deserialized by this wrapper. Treat it as untrusted input: validate
197
+ * it and never `eval` it. If you `JSON.parse` it, wrap the parse in a
198
+ * try/catch to guard against poisoned payloads.
199
+ *
188
200
  * @param channel - The channel name to subscribe to.
189
201
  * @param callback - Invoked for each message received on the channel.
190
202
  *
@@ -203,10 +215,16 @@ export declare class Redis {
203
215
  */
204
216
  unsubscribe(channel?: string): Promise<void>;
205
217
  /**
206
- * Send a raw Redis command.
218
+ * Send a raw Redis command. ADVANCED / INTERNAL escape hatch.
219
+ *
220
+ * This bypasses the key prefix and every higher-level safeguard. The `command`
221
+ * and `args` are passed straight to Redis, so they MUST be trusted, statically
222
+ * known values. Never build a command name or its arguments from user input:
223
+ * doing so allows execution of dangerous commands (`FLUSHALL`, `CONFIG`,
224
+ * `EVAL`, `KEYS *`, ...) and lets callers escape the prefix namespace.
207
225
  *
208
- * @param command - The Redis command string (e.g. `'PING'`, `'INFO'`).
209
- * @param args - Arguments for the command.
226
+ * @param command - A trusted, statically-known Redis command (e.g. `'PING'`, `'INFO'`).
227
+ * @param args - Arguments for the command. Must not contain untrusted input.
210
228
  * @returns The raw response from Redis.
211
229
  *
212
230
  * @example
@@ -249,6 +267,11 @@ export declare class Redis {
249
267
  * @param callback - Async function invoked to compute the value on a cache miss.
250
268
  * @returns The cached or freshly computed value.
251
269
  *
270
+ * On a cache miss this acquires a short-lived `SET NX` lock so that, under
271
+ * concurrent misses, only one caller runs `callback()` while the others wait
272
+ * for the freshly-populated value. This protects the backend from a stampede
273
+ * (thundering herd) when a hot key expires.
274
+ *
252
275
  * @example
253
276
  * ```ts
254
277
  * const users = await redis.remember('all-users', 60, async () => {
@@ -258,7 +281,24 @@ export declare class Redis {
258
281
  */
259
282
  remember<T>(key: string, seconds: number, callback: () => Promise<T>): Promise<T>;
260
283
  /**
261
- * Delete all keys in the currently selected database.
284
+ * Delete every key under this connection's prefix using a non-blocking SCAN.
285
+ *
286
+ * Unlike {@link Redis.flushdb}, this is scoped: it only removes keys that
287
+ * belong to this logical store (`<prefix>:*`), leaving other stores that
288
+ * share the same Redis database (sessions, queues, ...) untouched. If no
289
+ * prefix is configured this deletes nothing and returns `0`, to avoid
290
+ * accidentally wiping the whole database.
291
+ *
292
+ * @returns The number of keys deleted.
293
+ */
294
+ clearPrefix(): Promise<number>;
295
+ /**
296
+ * Delete ALL keys in the currently selected database. DANGEROUS.
297
+ *
298
+ * This ignores the key prefix and removes every key in the database, including
299
+ * data owned by other logical stores (sessions, queues, other caches) that
300
+ * share the same Redis database. Prefer {@link Redis.clearPrefix} to delete
301
+ * only this store's keys. Never expose this to user-triggered code paths.
262
302
  *
263
303
  * @returns A promise that resolves once the database has been flushed.
264
304
  */
package/dist/redis.js CHANGED
@@ -23,6 +23,14 @@ export class Redis {
23
23
  this.prefix = config.prefix || '';
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,15 @@ export class Redis {
35
43
  key(k) {
36
44
  return this.prefix ? `${this.prefix}:${k}` : k;
37
45
  }
46
+ /**
47
+ * Strip any credentials embedded in a Redis URL so it is safe to log.
48
+ *
49
+ * @param url - The connection URL, possibly containing `user:pass@host`.
50
+ * @returns The URL with the userinfo component masked.
51
+ */
52
+ static maskUrl(url) {
53
+ return url.replace(/(\w+:\/\/)([^@/]+)@/, '$1***@');
54
+ }
38
55
  // ─── Connection ────────────────────────────────────────
39
56
  /**
40
57
  * Open the connection to the Redis server.
@@ -204,6 +221,11 @@ export class Redis {
204
221
  /**
205
222
  * Subscribe to a channel and receive messages via a callback.
206
223
  *
224
+ * The message passed to the callback is the raw string received from Redis and
225
+ * is NOT deserialized by this wrapper. Treat it as untrusted input: validate
226
+ * it and never `eval` it. If you `JSON.parse` it, wrap the parse in a
227
+ * try/catch to guard against poisoned payloads.
228
+ *
207
229
  * @param channel - The channel name to subscribe to.
208
230
  * @param callback - Invoked for each message received on the channel.
209
231
  *
@@ -225,10 +247,16 @@ export class Redis {
225
247
  async unsubscribe(channel) { await this.client.unsubscribe(channel); }
226
248
  // ─── Raw command ──────────────────────────────────────
227
249
  /**
228
- * Send a raw Redis command.
250
+ * Send a raw Redis command. ADVANCED / INTERNAL escape hatch.
251
+ *
252
+ * This bypasses the key prefix and every higher-level safeguard. The `command`
253
+ * and `args` are passed straight to Redis, so they MUST be trusted, statically
254
+ * known values. Never build a command name or its arguments from user input:
255
+ * doing so allows execution of dangerous commands (`FLUSHALL`, `CONFIG`,
256
+ * `EVAL`, `KEYS *`, ...) and lets callers escape the prefix namespace.
229
257
  *
230
- * @param command - The Redis command string (e.g. `'PING'`, `'INFO'`).
231
- * @param args - Arguments for the command.
258
+ * @param command - A trusted, statically-known Redis command (e.g. `'PING'`, `'INFO'`).
259
+ * @param args - Arguments for the command. Must not contain untrusted input.
232
260
  * @returns The raw response from Redis.
233
261
  *
234
262
  * @example
@@ -256,7 +284,11 @@ export class Redis {
256
284
  try {
257
285
  return JSON.parse(val);
258
286
  }
259
- catch {
287
+ catch (e) {
288
+ // Surface corrupt/poisoned payloads instead of silently masking them as a
289
+ // cache miss. Returning null still preserves the previous behaviour for
290
+ // callers, but the warning makes the problem diagnosable.
291
+ console.warn(`[@tekir/redis] Failed to parse JSON for key "${key}": ${e.message}`);
260
292
  return null;
261
293
  }
262
294
  }
@@ -273,9 +305,15 @@ export class Redis {
273
305
  * ```
274
306
  */
275
307
  async setJSON(key, value, expireSeconds) {
276
- await this.set(key, JSON.stringify(value));
277
- if (expireSeconds)
278
- await this.expire(key, expireSeconds);
308
+ const payload = JSON.stringify(value);
309
+ if (expireSeconds && expireSeconds > 0) {
310
+ // Atomic SET ... EX so a crash between writing the value and setting the
311
+ // TTL can never leave a permanent (TTL-less) key behind.
312
+ await this.client.send('SET', [this.key(key), payload, 'EX', String(Math.floor(expireSeconds))]);
313
+ }
314
+ else {
315
+ await this.client.set(this.key(key), payload);
316
+ }
279
317
  }
280
318
  /**
281
319
  * Cache-aside helper: return the cached value if it exists, otherwise execute
@@ -286,6 +324,11 @@ export class Redis {
286
324
  * @param callback - Async function invoked to compute the value on a cache miss.
287
325
  * @returns The cached or freshly computed value.
288
326
  *
327
+ * On a cache miss this acquires a short-lived `SET NX` lock so that, under
328
+ * concurrent misses, only one caller runs `callback()` while the others wait
329
+ * for the freshly-populated value. This protects the backend from a stampede
330
+ * (thundering herd) when a hot key expires.
331
+ *
289
332
  * @example
290
333
  * ```ts
291
334
  * const users = await redis.remember('all-users', 60, async () => {
@@ -297,12 +340,67 @@ export class Redis {
297
340
  const cached = await this.getJSON(key);
298
341
  if (cached !== null)
299
342
  return cached;
300
- const value = await callback();
301
- await this.setJSON(key, value, seconds);
302
- return value;
343
+ const lockKey = this.key(`${key}:__lock`);
344
+ // Try to become the single flight that computes the value. SET NX EX gives
345
+ // a self-expiring lock so a crashed holder cannot deadlock other callers.
346
+ const acquired = await this.client.send('SET', [lockKey, '1', 'NX', 'EX', '10']);
347
+ if (acquired == null) {
348
+ // Another caller is computing it. Briefly poll for the populated value
349
+ // before falling back to computing it ourselves (lock may have expired).
350
+ for (let i = 0; i < 50; i++) {
351
+ await new Promise(r => setTimeout(r, 100));
352
+ const waited = await this.getJSON(key);
353
+ if (waited !== null)
354
+ return waited;
355
+ }
356
+ }
357
+ try {
358
+ // Re-check after acquiring the lock: a racing holder may have populated it.
359
+ const fresh = await this.getJSON(key);
360
+ if (fresh !== null)
361
+ return fresh;
362
+ const value = await callback();
363
+ await this.setJSON(key, value, seconds);
364
+ return value;
365
+ }
366
+ finally {
367
+ await this.del(`${key}:__lock`).catch(() => { });
368
+ }
369
+ }
370
+ /**
371
+ * Delete every key under this connection's prefix using a non-blocking SCAN.
372
+ *
373
+ * Unlike {@link Redis.flushdb}, this is scoped: it only removes keys that
374
+ * belong to this logical store (`<prefix>:*`), leaving other stores that
375
+ * share the same Redis database (sessions, queues, ...) untouched. If no
376
+ * prefix is configured this deletes nothing and returns `0`, to avoid
377
+ * accidentally wiping the whole database.
378
+ *
379
+ * @returns The number of keys deleted.
380
+ */
381
+ async clearPrefix() {
382
+ if (!this.prefix)
383
+ return 0;
384
+ const pattern = `${this.prefix}:*`;
385
+ let cursor = '0';
386
+ let deleted = 0;
387
+ do {
388
+ const [next, batch] = await this.client.send('SCAN', [cursor, 'MATCH', pattern, 'COUNT', '100']);
389
+ cursor = next;
390
+ if (batch.length) {
391
+ await this.client.send('DEL', batch);
392
+ deleted += batch.length;
393
+ }
394
+ } while (cursor !== '0');
395
+ return deleted;
303
396
  }
304
397
  /**
305
- * Delete all keys in the currently selected database.
398
+ * Delete ALL keys in the currently selected database. DANGEROUS.
399
+ *
400
+ * This ignores the key prefix and removes every key in the database, including
401
+ * data owned by other logical stores (sessions, queues, other caches) that
402
+ * share the same Redis database. Prefer {@link Redis.clearPrefix} to delete
403
+ * only this store's keys. Never expose this to user-triggered code paths.
306
404
  *
307
405
  * @returns A promise that resolves once the database has been flushed.
308
406
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekir/redis",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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.0"
42
+ "@tekir/core": "^0.1.34"
43
43
  },
44
44
  "scripts": {
45
45
  "build": "rm -rf dist && tsc --noEmit false",
package/src/manager.ts CHANGED
@@ -152,6 +152,8 @@ export class RedisManager {
152
152
  setJSON(key: string, value: any, expireSeconds?: number) { return this.connection().setJSON(key, value, expireSeconds) }
153
153
  /** @see {@link Redis.remember} */
154
154
  remember<T>(key: string, seconds: number, callback: () => Promise<T>) { return this.connection().remember<T>(key, seconds, callback) }
155
+ /** @see {@link Redis.clearPrefix} */
156
+ clearPrefix() { return this.connection().clearPrefix() }
155
157
  /** @see {@link Redis.flushdb} */
156
158
  flushdb() { return this.connection().flushdb() }
157
159
  /** @see {@link Redis.connected} */
package/src/redis.ts CHANGED
@@ -28,6 +28,17 @@ export class Redis {
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,16 @@ export class Redis {
42
53
  return this.prefix ? `${this.prefix}:${k}` : k
43
54
  }
44
55
 
56
+ /**
57
+ * Strip any credentials embedded in a Redis URL so it is safe to log.
58
+ *
59
+ * @param url - The connection URL, possibly containing `user:pass@host`.
60
+ * @returns The URL with the userinfo component masked.
61
+ */
62
+ private static maskUrl(url: string): string {
63
+ return url.replace(/(\w+:\/\/)([^@/]+)@/, '$1***@')
64
+ }
65
+
45
66
  // ─── Connection ────────────────────────────────────────
46
67
 
47
68
  /**
@@ -236,6 +257,11 @@ export class Redis {
236
257
  /**
237
258
  * Subscribe to a channel and receive messages via a callback.
238
259
  *
260
+ * The message passed to the callback is the raw string received from Redis and
261
+ * is NOT deserialized by this wrapper. Treat it as untrusted input: validate
262
+ * it and never `eval` it. If you `JSON.parse` it, wrap the parse in a
263
+ * try/catch to guard against poisoned payloads.
264
+ *
239
265
  * @param channel - The channel name to subscribe to.
240
266
  * @param callback - Invoked for each message received on the channel.
241
267
  *
@@ -260,10 +286,16 @@ export class Redis {
260
286
  // ─── Raw command ──────────────────────────────────────
261
287
 
262
288
  /**
263
- * Send a raw Redis command.
289
+ * Send a raw Redis command. ADVANCED / INTERNAL escape hatch.
264
290
  *
265
- * @param command - The Redis command string (e.g. `'PING'`, `'INFO'`).
266
- * @param args - Arguments for the command.
291
+ * This bypasses the key prefix and every higher-level safeguard. The `command`
292
+ * and `args` are passed straight to Redis, so they MUST be trusted, statically
293
+ * known values. Never build a command name or its arguments from user input:
294
+ * doing so allows execution of dangerous commands (`FLUSHALL`, `CONFIG`,
295
+ * `EVAL`, `KEYS *`, ...) and lets callers escape the prefix namespace.
296
+ *
297
+ * @param command - A trusted, statically-known Redis command (e.g. `'PING'`, `'INFO'`).
298
+ * @param args - Arguments for the command. Must not contain untrusted input.
267
299
  * @returns The raw response from Redis.
268
300
  *
269
301
  * @example
@@ -289,7 +321,15 @@ export class Redis {
289
321
  async getJSON<T = any>(key: string): Promise<T | null> {
290
322
  const val = await this.get(key)
291
323
  if (val === null) return null
292
- try { return JSON.parse(val) } catch { return null }
324
+ try {
325
+ return JSON.parse(val)
326
+ } catch (e) {
327
+ // Surface corrupt/poisoned payloads instead of silently masking them as a
328
+ // cache miss. Returning null still preserves the previous behaviour for
329
+ // callers, but the warning makes the problem diagnosable.
330
+ console.warn(`[@tekir/redis] Failed to parse JSON for key "${key}": ${(e as Error).message}`)
331
+ return null
332
+ }
293
333
  }
294
334
 
295
335
  /**
@@ -305,8 +345,14 @@ export class Redis {
305
345
  * ```
306
346
  */
307
347
  async setJSON(key: string, value: any, expireSeconds?: number): Promise<void> {
308
- await this.set(key, JSON.stringify(value))
309
- if (expireSeconds) await this.expire(key, expireSeconds)
348
+ const payload = JSON.stringify(value)
349
+ if (expireSeconds && expireSeconds > 0) {
350
+ // Atomic SET ... EX so a crash between writing the value and setting the
351
+ // TTL can never leave a permanent (TTL-less) key behind.
352
+ await this.client.send('SET', [this.key(key), payload, 'EX', String(Math.floor(expireSeconds))])
353
+ } else {
354
+ await this.client.set(this.key(key), payload)
355
+ }
310
356
  }
311
357
 
312
358
  /**
@@ -318,6 +364,11 @@ export class Redis {
318
364
  * @param callback - Async function invoked to compute the value on a cache miss.
319
365
  * @returns The cached or freshly computed value.
320
366
  *
367
+ * On a cache miss this acquires a short-lived `SET NX` lock so that, under
368
+ * concurrent misses, only one caller runs `callback()` while the others wait
369
+ * for the freshly-populated value. This protects the backend from a stampede
370
+ * (thundering herd) when a hot key expires.
371
+ *
321
372
  * @example
322
373
  * ```ts
323
374
  * const users = await redis.remember('all-users', 60, async () => {
@@ -328,13 +379,68 @@ export class Redis {
328
379
  async remember<T>(key: string, seconds: number, callback: () => Promise<T>): Promise<T> {
329
380
  const cached = await this.getJSON<T>(key)
330
381
  if (cached !== null) return cached
331
- const value = await callback()
332
- await this.setJSON(key, value, seconds)
333
- return value
382
+
383
+ const lockKey = this.key(`${key}:__lock`)
384
+ // Try to become the single flight that computes the value. SET NX EX gives
385
+ // a self-expiring lock so a crashed holder cannot deadlock other callers.
386
+ const acquired = await this.client.send('SET', [lockKey, '1', 'NX', 'EX', '10'])
387
+
388
+ if (acquired == null) {
389
+ // Another caller is computing it. Briefly poll for the populated value
390
+ // before falling back to computing it ourselves (lock may have expired).
391
+ for (let i = 0; i < 50; i++) {
392
+ await new Promise(r => setTimeout(r, 100))
393
+ const waited = await this.getJSON<T>(key)
394
+ if (waited !== null) return waited
395
+ }
396
+ }
397
+
398
+ try {
399
+ // Re-check after acquiring the lock: a racing holder may have populated it.
400
+ const fresh = await this.getJSON<T>(key)
401
+ if (fresh !== null) return fresh
402
+ const value = await callback()
403
+ await this.setJSON(key, value, seconds)
404
+ return value
405
+ } finally {
406
+ await this.del(`${key}:__lock`).catch(() => {})
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Delete every key under this connection's prefix using a non-blocking SCAN.
412
+ *
413
+ * Unlike {@link Redis.flushdb}, this is scoped: it only removes keys that
414
+ * belong to this logical store (`<prefix>:*`), leaving other stores that
415
+ * share the same Redis database (sessions, queues, ...) untouched. If no
416
+ * prefix is configured this deletes nothing and returns `0`, to avoid
417
+ * accidentally wiping the whole database.
418
+ *
419
+ * @returns The number of keys deleted.
420
+ */
421
+ async clearPrefix(): Promise<number> {
422
+ if (!this.prefix) return 0
423
+ const pattern = `${this.prefix}:*`
424
+ let cursor = '0'
425
+ let deleted = 0
426
+ do {
427
+ const [next, batch]: [string, string[]] = await this.client.send('SCAN', [cursor, 'MATCH', pattern, 'COUNT', '100'])
428
+ cursor = next
429
+ if (batch.length) {
430
+ await this.client.send('DEL', batch)
431
+ deleted += batch.length
432
+ }
433
+ } while (cursor !== '0')
434
+ return deleted
334
435
  }
335
436
 
336
437
  /**
337
- * Delete all keys in the currently selected database.
438
+ * Delete ALL keys in the currently selected database. DANGEROUS.
439
+ *
440
+ * This ignores the key prefix and removes every key in the database, including
441
+ * data owned by other logical stores (sessions, queues, other caches) that
442
+ * share the same Redis database. Prefer {@link Redis.clearPrefix} to delete
443
+ * only this store's keys. Never expose this to user-triggered code paths.
338
444
  *
339
445
  * @returns A promise that resolves once the database has been flushed.
340
446
  */