@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 +27 -34
- package/docs/rate-limiter.md +23 -28
- package/package.json +6 -2
- package/src/core/cache-adapter-redis.ts +113 -0
- package/src/core/index.ts +10 -0
- package/src/core/rate-limit-adapter-redis.ts +109 -0
package/docs/cache.md
CHANGED
|
@@ -322,51 +322,44 @@ interface CacheAdapter {
|
|
|
322
322
|
}
|
|
323
323
|
```
|
|
324
324
|
|
|
325
|
-
### Redis Adapter
|
|
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
|
-
|
|
332
|
-
constructor(private redis: Redis) {}
|
|
333
|
+
const redis = new Redis("redis://localhost:6379");
|
|
333
334
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
}
|
|
335
|
+
const server = new AppServer({
|
|
336
|
+
cache: {
|
|
337
|
+
adapter: new RedisCacheAdapter(redis, { prefix: "myapp:" }),
|
|
338
|
+
},
|
|
339
|
+
});
|
|
338
340
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
354
|
-
return (await this.redis.exists(key)) === 1;
|
|
355
|
-
}
|
|
353
|
+
### Custom Redis Adapter Example
|
|
356
354
|
|
|
357
|
-
|
|
358
|
-
await this.redis.flushdb();
|
|
359
|
-
}
|
|
355
|
+
For custom requirements, implement `CacheAdapter` directly:
|
|
360
356
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
}
|
|
364
|
-
}
|
|
357
|
+
```ts
|
|
358
|
+
import { type CacheAdapter } from "@donkeylabs/server/core";
|
|
365
359
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
});
|
|
360
|
+
class MyCustomCacheAdapter implements CacheAdapter {
|
|
361
|
+
// Implement get, set, delete, has, clear, keys
|
|
362
|
+
}
|
|
370
363
|
```
|
|
371
364
|
|
|
372
365
|
---
|
package/docs/rate-limiter.md
CHANGED
|
@@ -420,46 +420,41 @@ interface RateLimitAdapter {
|
|
|
420
420
|
}
|
|
421
421
|
```
|
|
422
422
|
|
|
423
|
-
### Redis Adapter
|
|
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
|
-
|
|
430
|
-
constructor(private redis: Redis) {}
|
|
431
|
+
const redis = new Redis("redis://localhost:6379");
|
|
431
432
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
433
|
+
const server = new AppServer({
|
|
434
|
+
rateLimiter: {
|
|
435
|
+
adapter: new RedisRateLimitAdapter(redis, { prefix: "myapp:" }),
|
|
436
|
+
},
|
|
437
|
+
});
|
|
435
438
|
|
|
436
|
-
|
|
439
|
+
// Remember to disconnect on shutdown
|
|
440
|
+
server.onShutdown(() => redis.disconnect());
|
|
441
|
+
```
|
|
437
442
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
448
|
+
### Custom Redis Adapter Example
|
|
444
449
|
|
|
445
|
-
|
|
446
|
-
}
|
|
450
|
+
For custom requirements, implement `RateLimitAdapter` directly:
|
|
447
451
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
}
|
|
452
|
+
```ts
|
|
453
|
+
import { type RateLimitAdapter } from "@donkeylabs/server/core";
|
|
451
454
|
|
|
452
|
-
|
|
453
|
-
|
|
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.
|
|
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
|
+
}
|