@donkeylabs/server 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/cache.md CHANGED
@@ -322,51 +322,44 @@ interface CacheAdapter {
322
322
  }
323
323
  ```
324
324
 
325
- ### Redis Adapter Example
325
+ ### Built-in Redis Adapter
326
+
327
+ A production-ready Redis adapter is included. Requires `ioredis` as a peer dependency (`bun add ioredis`).
326
328
 
327
329
  ```ts
328
- import { createCache, type CacheAdapter } from "./core/cache";
329
330
  import Redis from "ioredis";
331
+ import { RedisCacheAdapter } from "@donkeylabs/server/core";
330
332
 
331
- class RedisCacheAdapter implements CacheAdapter {
332
- constructor(private redis: Redis) {}
333
+ const redis = new Redis("redis://localhost:6379");
333
334
 
334
- async get<T>(key: string): Promise<T | null> {
335
- const value = await this.redis.get(key);
336
- return value ? JSON.parse(value) : null;
337
- }
335
+ const server = new AppServer({
336
+ cache: {
337
+ adapter: new RedisCacheAdapter(redis, { prefix: "myapp:" }),
338
+ },
339
+ });
338
340
 
339
- async set<T>(key: string, value: T, ttlMs?: number): Promise<void> {
340
- const serialized = JSON.stringify(value);
341
- if (ttlMs) {
342
- await this.redis.set(key, serialized, "PX", ttlMs);
343
- } else {
344
- await this.redis.set(key, serialized);
345
- }
346
- }
341
+ // Remember to disconnect on shutdown
342
+ server.onShutdown(() => redis.disconnect());
343
+ ```
347
344
 
348
- async delete(key: string): Promise<boolean> {
349
- const result = await this.redis.del(key);
350
- return result > 0;
351
- }
345
+ **Features:**
346
+ - JSON serialization for values
347
+ - `SET key val PX ttlMs` for TTL support
348
+ - `SCAN` (not `KEYS`) for production-safe key listing on large datasets
349
+ - Optional `prefix` for key namespace isolation in shared Redis instances
350
+ - With prefix: `clear()` uses SCAN + DEL only for prefixed keys
351
+ - Without prefix: `clear()` uses `FLUSHDB`
352
352
 
353
- async has(key: string): Promise<boolean> {
354
- return (await this.redis.exists(key)) === 1;
355
- }
353
+ ### Custom Redis Adapter Example
356
354
 
357
- async clear(): Promise<void> {
358
- await this.redis.flushdb();
359
- }
355
+ For custom requirements, implement `CacheAdapter` directly:
360
356
 
361
- async keys(pattern?: string): Promise<string[]> {
362
- return this.redis.keys(pattern ?? "*");
363
- }
364
- }
357
+ ```ts
358
+ import { type CacheAdapter } from "@donkeylabs/server/core";
365
359
 
366
- // Use Redis adapter
367
- const cache = createCache({
368
- adapter: new RedisCacheAdapter(new Redis()),
369
- });
360
+ class MyCustomCacheAdapter implements CacheAdapter {
361
+ // Implement get, set, delete, has, clear, keys
362
+ }
370
363
  ```
371
364
 
372
365
  ---
@@ -420,46 +420,41 @@ interface RateLimitAdapter {
420
420
  }
421
421
  ```
422
422
 
423
- ### Redis Adapter Example
423
+ ### Built-in Redis Adapter
424
+
425
+ A production-ready Redis adapter is included. Requires `ioredis` as a peer dependency (`bun add ioredis`).
424
426
 
425
427
  ```ts
426
- import { createRateLimiter, type RateLimitAdapter } from "./core/rate-limiter";
427
428
  import Redis from "ioredis";
429
+ import { RedisRateLimitAdapter } from "@donkeylabs/server/core";
428
430
 
429
- class RedisRateLimitAdapter implements RateLimitAdapter {
430
- constructor(private redis: Redis) {}
431
+ const redis = new Redis("redis://localhost:6379");
431
432
 
432
- async increment(key: string, windowMs: number): Promise<{ count: number; resetAt: Date }> {
433
- const now = Date.now();
434
- const windowKey = `${key}:${Math.floor(now / windowMs)}`;
433
+ const server = new AppServer({
434
+ rateLimiter: {
435
+ adapter: new RedisRateLimitAdapter(redis, { prefix: "myapp:" }),
436
+ },
437
+ });
435
438
 
436
- const count = await this.redis.incr(windowKey);
439
+ // Remember to disconnect on shutdown
440
+ server.onShutdown(() => redis.disconnect());
441
+ ```
437
442
 
438
- if (count === 1) {
439
- // Set expiry on first request in window
440
- await this.redis.pexpire(windowKey, windowMs);
441
- }
443
+ **Features:**
444
+ - Atomic Lua script for `INCR` + conditional `PEXPIRE` (prevents race conditions)
445
+ - Pipeline `GET` + `PTTL` in a single round-trip for `get()`
446
+ - Optional `prefix` for key namespace isolation in shared Redis instances
442
447
 
443
- const resetAt = new Date(Math.ceil(now / windowMs) * windowMs);
448
+ ### Custom Redis Adapter Example
444
449
 
445
- return { count, resetAt };
446
- }
450
+ For custom requirements, implement `RateLimitAdapter` directly:
447
451
 
448
- async get(key: string): Promise<{ count: number; resetAt: Date } | null> {
449
- // Implementation for getting current state
450
- }
452
+ ```ts
453
+ import { type RateLimitAdapter } from "@donkeylabs/server/core";
451
454
 
452
- async reset(key: string): Promise<void> {
453
- const keys = await this.redis.keys(`${key}:*`);
454
- if (keys.length > 0) {
455
- await this.redis.del(...keys);
456
- }
457
- }
455
+ class MyCustomRateLimitAdapter implements RateLimitAdapter {
456
+ // Implement increment, get, reset
458
457
  }
459
-
460
- const rateLimiter = createRateLimiter({
461
- adapter: new RedisRateLimitAdapter(new Redis()),
462
- });
463
458
  ```
464
459
 
465
460
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/server",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "type": "module",
5
5
  "description": "Type-safe plugin system for building RPC-style APIs with Bun",
6
6
  "main": "./src/index.ts",
@@ -75,7 +75,8 @@
75
75
  "@aws-sdk/s3-request-presigner": "^3.0.0",
76
76
  "@playwright/test": "^1.40.0",
77
77
  "pg": "^8.0.0",
78
- "mysql2": "^3.0.0"
78
+ "mysql2": "^3.0.0",
79
+ "ioredis": "^5.0.0"
79
80
  },
80
81
  "peerDependenciesMeta": {
81
82
  "@aws-sdk/client-s3": {
@@ -92,6 +93,9 @@
92
93
  },
93
94
  "mysql2": {
94
95
  "optional": true
96
+ },
97
+ "ioredis": {
98
+ "optional": true
95
99
  }
96
100
  },
97
101
  "dependencies": {
@@ -0,0 +1,113 @@
1
+ // Redis Cache Adapter
2
+ // Production-ready cache backend using Redis (via ioredis)
3
+
4
+ import type { CacheAdapter } from "./cache";
5
+
6
+ export interface RedisCacheAdapterConfig {
7
+ /** Key prefix for namespace isolation in shared Redis instances */
8
+ prefix?: string;
9
+ }
10
+
11
+ /**
12
+ * Redis-backed cache adapter using ioredis.
13
+ *
14
+ * Constructor takes a pre-built ioredis client (typed as `any` to avoid
15
+ * requiring ioredis types at compile time — same pattern as S3StorageAdapter).
16
+ * User manages connection lifecycle (connect/disconnect in onShutdown).
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * import Redis from "ioredis";
21
+ * import { RedisCacheAdapter } from "@donkeylabs/server/core";
22
+ *
23
+ * const redis = new Redis("redis://localhost:6379");
24
+ * const server = new AppServer({
25
+ * cache: { adapter: new RedisCacheAdapter(redis, { prefix: "myapp:" }) },
26
+ * });
27
+ * ```
28
+ */
29
+ export class RedisCacheAdapter implements CacheAdapter {
30
+ private redis: any;
31
+ private prefix: string;
32
+
33
+ constructor(redis: any, config: RedisCacheAdapterConfig = {}) {
34
+ this.redis = redis;
35
+ this.prefix = config.prefix ?? "";
36
+ }
37
+
38
+ private prefixKey(key: string): string {
39
+ return this.prefix + key;
40
+ }
41
+
42
+ private stripPrefix(key: string): string {
43
+ if (this.prefix && key.startsWith(this.prefix)) {
44
+ return key.slice(this.prefix.length);
45
+ }
46
+ return key;
47
+ }
48
+
49
+ async get<T>(key: string): Promise<T | null> {
50
+ const raw = await this.redis.get(this.prefixKey(key));
51
+ if (raw === null || raw === undefined) return null;
52
+ return JSON.parse(raw) as T;
53
+ }
54
+
55
+ async set<T>(key: string, value: T, ttlMs?: number): Promise<void> {
56
+ const serialized = JSON.stringify(value);
57
+ if (ttlMs && ttlMs > 0) {
58
+ await this.redis.set(this.prefixKey(key), serialized, "PX", ttlMs);
59
+ } else {
60
+ await this.redis.set(this.prefixKey(key), serialized);
61
+ }
62
+ }
63
+
64
+ async delete(key: string): Promise<boolean> {
65
+ const result = await this.redis.del(this.prefixKey(key));
66
+ return result > 0;
67
+ }
68
+
69
+ async has(key: string): Promise<boolean> {
70
+ return (await this.redis.exists(this.prefixKey(key))) === 1;
71
+ }
72
+
73
+ async clear(): Promise<void> {
74
+ if (this.prefix) {
75
+ // With prefix: SCAN + DEL only prefixed keys (production-safe)
76
+ const keys = await this.scanKeys(this.prefix + "*");
77
+ if (keys.length > 0) {
78
+ await this.redis.del(...keys);
79
+ }
80
+ } else {
81
+ await this.redis.flushdb();
82
+ }
83
+ }
84
+
85
+ async keys(pattern?: string): Promise<string[]> {
86
+ const redisPattern = this.prefix + (pattern ?? "*");
87
+ const keys = await this.scanKeys(redisPattern);
88
+ return keys.map((k: string) => this.stripPrefix(k));
89
+ }
90
+
91
+ /**
92
+ * Uses SCAN (not KEYS) for production safety on large datasets.
93
+ * Iterates cursor until exhausted.
94
+ */
95
+ private async scanKeys(pattern: string): Promise<string[]> {
96
+ const results: string[] = [];
97
+ let cursor = "0";
98
+
99
+ do {
100
+ const [nextCursor, keys] = await this.redis.scan(
101
+ cursor,
102
+ "MATCH",
103
+ pattern,
104
+ "COUNT",
105
+ 100,
106
+ );
107
+ cursor = nextCursor;
108
+ results.push(...keys);
109
+ } while (cursor !== "0");
110
+
111
+ return results;
112
+ }
113
+ }
package/src/core/index.ts CHANGED
@@ -282,6 +282,16 @@ export {
282
282
  export { LocalStorageAdapter } from "./storage-adapter-local";
283
283
  export { S3StorageAdapter } from "./storage-adapter-s3";
284
284
 
285
+ export {
286
+ RedisCacheAdapter,
287
+ type RedisCacheAdapterConfig,
288
+ } from "./cache-adapter-redis";
289
+
290
+ export {
291
+ RedisRateLimitAdapter,
292
+ type RedisRateLimitAdapterConfig,
293
+ } from "./rate-limit-adapter-redis";
294
+
285
295
  export {
286
296
  type Logs,
287
297
  type LogSource,
@@ -0,0 +1,109 @@
1
+ // Redis Rate Limit Adapter
2
+ // Production-ready rate limiting backend using Redis (via ioredis)
3
+
4
+ import type { RateLimitAdapter } from "./rate-limiter";
5
+
6
+ export interface RedisRateLimitAdapterConfig {
7
+ /** Key prefix for namespace isolation in shared Redis instances */
8
+ prefix?: string;
9
+ }
10
+
11
+ /**
12
+ * Redis-backed rate limit adapter using ioredis.
13
+ *
14
+ * Uses a Lua script for atomic INCR + conditional PEXPIRE to prevent
15
+ * race conditions where a key is incremented but the expire fails.
16
+ *
17
+ * Constructor takes a pre-built ioredis client (typed as `any` to avoid
18
+ * requiring ioredis types at compile time — same pattern as S3StorageAdapter).
19
+ * User manages connection lifecycle (connect/disconnect in onShutdown).
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * import Redis from "ioredis";
24
+ * import { RedisRateLimitAdapter } from "@donkeylabs/server/core";
25
+ *
26
+ * const redis = new Redis("redis://localhost:6379");
27
+ * const server = new AppServer({
28
+ * rateLimiter: { adapter: new RedisRateLimitAdapter(redis, { prefix: "myapp:" }) },
29
+ * });
30
+ * ```
31
+ */
32
+ export class RedisRateLimitAdapter implements RateLimitAdapter {
33
+ private redis: any;
34
+ private prefix: string;
35
+
36
+ /**
37
+ * Lua script for atomic increment + conditional expire.
38
+ * KEYS[1] = rate limit key
39
+ * ARGV[1] = window TTL in milliseconds
40
+ *
41
+ * Returns [count, ttl_remaining_ms]:
42
+ * - count: current count after increment
43
+ * - ttl_remaining_ms: remaining TTL in milliseconds
44
+ */
45
+ private static readonly INCREMENT_SCRIPT = `
46
+ local count = redis.call('INCR', KEYS[1])
47
+ if count == 1 then
48
+ redis.call('PEXPIRE', KEYS[1], ARGV[1])
49
+ end
50
+ local ttl = redis.call('PTTL', KEYS[1])
51
+ return {count, ttl}
52
+ `;
53
+
54
+ constructor(redis: any, config: RedisRateLimitAdapterConfig = {}) {
55
+ this.redis = redis;
56
+ this.prefix = config.prefix ?? "";
57
+ }
58
+
59
+ private prefixKey(key: string): string {
60
+ return this.prefix + key;
61
+ }
62
+
63
+ async increment(
64
+ key: string,
65
+ windowMs: number,
66
+ ): Promise<{ count: number; resetAt: Date }> {
67
+ const prefixed = this.prefixKey(key);
68
+ const [count, ttl] = await this.redis.eval(
69
+ RedisRateLimitAdapter.INCREMENT_SCRIPT,
70
+ 1,
71
+ prefixed,
72
+ windowMs,
73
+ );
74
+
75
+ // ttl is remaining time in ms; derive resetAt from it
76
+ const resetAt = new Date(Date.now() + Math.max(ttl, 0));
77
+ return { count, resetAt };
78
+ }
79
+
80
+ async get(key: string): Promise<{ count: number; resetAt: Date } | null> {
81
+ const prefixed = this.prefixKey(key);
82
+
83
+ // Pipeline GET + PTTL in a single round-trip
84
+ const pipeline = this.redis.pipeline();
85
+ pipeline.get(prefixed);
86
+ pipeline.pttl(prefixed);
87
+ const results = await pipeline.exec();
88
+
89
+ const [getErr, rawCount] = results[0];
90
+ const [pttlErr, ttl] = results[1];
91
+
92
+ if (getErr || pttlErr) {
93
+ throw getErr || pttlErr;
94
+ }
95
+
96
+ if (rawCount === null) return null;
97
+
98
+ const count = parseInt(rawCount, 10);
99
+ if (isNaN(count)) return null;
100
+
101
+ // PTTL returns -2 if key doesn't exist, -1 if no expiry
102
+ const resetAt = new Date(Date.now() + Math.max(ttl, 0));
103
+ return { count, resetAt };
104
+ }
105
+
106
+ async reset(key: string): Promise<void> {
107
+ await this.redis.del(this.prefixKey(key));
108
+ }
109
+ }