@donkeylabs/server 2.0.37 → 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/code-organization.md +424 -0
- package/docs/project-structure.md +37 -26
- package/docs/rate-limiter.md +23 -28
- package/docs/swift-adapter.md +293 -0
- package/docs/versioning.md +351 -0
- package/package.json +6 -2
- package/src/client/base.ts +18 -5
- package/src/core/cache-adapter-redis.ts +113 -0
- package/src/core/events.ts +54 -7
- package/src/core/health.ts +165 -0
- package/src/core/index.ts +22 -0
- package/src/core/jobs.ts +11 -4
- package/src/core/rate-limit-adapter-redis.ts +109 -0
- package/src/core/subprocess-bootstrap.ts +3 -0
- package/src/core/workflow-executor.ts +1 -0
- package/src/core/workflow-state-machine.ts +6 -5
- package/src/core/workflows.ts +3 -2
- package/src/core.ts +9 -1
- package/src/generator/index.ts +4 -0
- package/src/harness.ts +17 -5
- package/src/index.ts +21 -0
- package/src/router.ts +22 -2
- package/src/server.ts +458 -117
- package/src/versioning.ts +154 -0
package/src/client/base.ts
CHANGED
|
@@ -154,6 +154,8 @@ export interface ApiClientOptions {
|
|
|
154
154
|
onAuthChange?: (authenticated: boolean) => void;
|
|
155
155
|
/** Custom fetch implementation (for testing or Node.js polyfills) */
|
|
156
156
|
fetch?: typeof fetch;
|
|
157
|
+
/** API version to send via X-API-Version header */
|
|
158
|
+
apiVersion?: string;
|
|
157
159
|
}
|
|
158
160
|
|
|
159
161
|
// ============================================
|
|
@@ -225,6 +227,18 @@ export class ApiClientBase<TEvents extends Record<string, any> = Record<string,
|
|
|
225
227
|
/**
|
|
226
228
|
* Make a typed POST request to a route
|
|
227
229
|
*/
|
|
230
|
+
/** Build common headers including optional API version */
|
|
231
|
+
private buildHeaders(extra?: Record<string, string>): Record<string, string> {
|
|
232
|
+
const headers: Record<string, string> = {
|
|
233
|
+
...this.options.headers,
|
|
234
|
+
...extra,
|
|
235
|
+
};
|
|
236
|
+
if (this.options.apiVersion) {
|
|
237
|
+
headers["X-API-Version"] = this.options.apiVersion;
|
|
238
|
+
}
|
|
239
|
+
return headers;
|
|
240
|
+
}
|
|
241
|
+
|
|
228
242
|
protected async request<TInput, TOutput>(
|
|
229
243
|
route: string,
|
|
230
244
|
input: TInput,
|
|
@@ -236,8 +250,7 @@ export class ApiClientBase<TEvents extends Record<string, any> = Record<string,
|
|
|
236
250
|
method: "POST",
|
|
237
251
|
headers: {
|
|
238
252
|
"Content-Type": "application/json",
|
|
239
|
-
...this.options.headers,
|
|
240
|
-
...options.headers,
|
|
253
|
+
...this.buildHeaders(options.headers),
|
|
241
254
|
},
|
|
242
255
|
credentials: this.options.credentials,
|
|
243
256
|
body: JSON.stringify(input),
|
|
@@ -278,7 +291,7 @@ export class ApiClientBase<TEvents extends Record<string, any> = Record<string,
|
|
|
278
291
|
method: "POST",
|
|
279
292
|
...requestInit,
|
|
280
293
|
headers: {
|
|
281
|
-
...this.
|
|
294
|
+
...this.buildHeaders(),
|
|
282
295
|
...requestInit.headers,
|
|
283
296
|
},
|
|
284
297
|
credentials: this.options.credentials,
|
|
@@ -298,7 +311,7 @@ export class ApiClientBase<TEvents extends Record<string, any> = Record<string,
|
|
|
298
311
|
method: "POST",
|
|
299
312
|
headers: {
|
|
300
313
|
"Content-Type": "application/json",
|
|
301
|
-
...this.
|
|
314
|
+
...this.buildHeaders(),
|
|
302
315
|
},
|
|
303
316
|
credentials: this.options.credentials,
|
|
304
317
|
body: JSON.stringify(input),
|
|
@@ -331,7 +344,7 @@ export class ApiClientBase<TEvents extends Record<string, any> = Record<string,
|
|
|
331
344
|
const response = await fetchFn(`${this.baseUrl}/${route}`, {
|
|
332
345
|
method: "POST",
|
|
333
346
|
headers: {
|
|
334
|
-
...this.
|
|
347
|
+
...this.buildHeaders(),
|
|
335
348
|
// Don't set Content-Type - browser will set it with boundary
|
|
336
349
|
},
|
|
337
350
|
credentials: this.options.credentials,
|
|
@@ -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/events.ts
CHANGED
|
@@ -11,20 +11,33 @@ export interface Subscription {
|
|
|
11
11
|
unsubscribe(): void;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
export interface EventMetadata {
|
|
15
|
+
traceId?: string;
|
|
16
|
+
source?: string;
|
|
17
|
+
[key: string]: any;
|
|
18
|
+
}
|
|
19
|
+
|
|
14
20
|
export interface EventRecord {
|
|
15
21
|
event: string;
|
|
16
22
|
data: any;
|
|
17
23
|
timestamp: Date;
|
|
24
|
+
metadata?: EventMetadata;
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
export interface EventAdapter {
|
|
21
|
-
publish(event: string, data: any): Promise<void>;
|
|
28
|
+
publish(event: string, data: any, metadata?: EventMetadata): Promise<void>;
|
|
22
29
|
getHistory(event: string, limit?: number): Promise<EventRecord[]>;
|
|
30
|
+
/** Subscribe to events from other instances (for distributed adapters) */
|
|
31
|
+
subscribe?(callback: (event: string, data: any, metadata?: EventMetadata) => void): Promise<void>;
|
|
32
|
+
/** Stop the adapter and clean up resources */
|
|
33
|
+
stop?(): Promise<void>;
|
|
23
34
|
}
|
|
24
35
|
|
|
25
36
|
export interface EventsConfig {
|
|
26
37
|
adapter?: EventAdapter;
|
|
27
38
|
maxHistorySize?: number;
|
|
39
|
+
/** SSE service for auto-propagating distributed events to SSE clients */
|
|
40
|
+
sse?: import("./sse").SSE;
|
|
28
41
|
}
|
|
29
42
|
|
|
30
43
|
/**
|
|
@@ -41,11 +54,11 @@ export interface Events {
|
|
|
41
54
|
/**
|
|
42
55
|
* Emit a typed event (when EventRegistry is augmented)
|
|
43
56
|
*/
|
|
44
|
-
emit<K extends keyof EventRegistry>(event: K, data: EventRegistry[K]): Promise<void>;
|
|
57
|
+
emit<K extends keyof EventRegistry>(event: K, data: EventRegistry[K], metadata?: EventMetadata): Promise<void>;
|
|
45
58
|
/**
|
|
46
59
|
* Emit an untyped event (fallback for dynamic event names)
|
|
47
60
|
*/
|
|
48
|
-
emit<T = any>(event: string, data: T): Promise<void>;
|
|
61
|
+
emit<T = any>(event: string, data: T, metadata?: EventMetadata): Promise<void>;
|
|
49
62
|
|
|
50
63
|
/**
|
|
51
64
|
* Subscribe to a typed event (when EventRegistry is augmented)
|
|
@@ -67,6 +80,8 @@ export interface Events {
|
|
|
67
80
|
|
|
68
81
|
off(event: string, handler?: EventHandler): void;
|
|
69
82
|
getHistory(event: string, limit?: number): Promise<EventRecord[]>;
|
|
83
|
+
/** Stop the event bus and clean up resources */
|
|
84
|
+
stop(): Promise<void>;
|
|
70
85
|
}
|
|
71
86
|
|
|
72
87
|
// In-memory event adapter with history
|
|
@@ -78,11 +93,12 @@ export class MemoryEventAdapter implements EventAdapter {
|
|
|
78
93
|
this.maxHistorySize = maxHistorySize;
|
|
79
94
|
}
|
|
80
95
|
|
|
81
|
-
async publish(event: string, data: any): Promise<void> {
|
|
96
|
+
async publish(event: string, data: any, metadata?: EventMetadata): Promise<void> {
|
|
82
97
|
const record: EventRecord = {
|
|
83
98
|
event,
|
|
84
99
|
data,
|
|
85
100
|
timestamp: new Date(),
|
|
101
|
+
metadata,
|
|
86
102
|
};
|
|
87
103
|
|
|
88
104
|
this.history.push(record);
|
|
@@ -103,16 +119,41 @@ export class MemoryEventAdapter implements EventAdapter {
|
|
|
103
119
|
class EventsImpl implements Events {
|
|
104
120
|
private handlers = new Map<string, Set<EventHandler>>();
|
|
105
121
|
private adapter: EventAdapter;
|
|
122
|
+
private sse?: import("./sse").SSE;
|
|
123
|
+
private stopped = false;
|
|
106
124
|
|
|
107
125
|
constructor(config: EventsConfig = {}) {
|
|
108
126
|
this.adapter = config.adapter ?? new MemoryEventAdapter(config.maxHistorySize);
|
|
127
|
+
this.sse = config.sse;
|
|
128
|
+
|
|
129
|
+
// If adapter supports distributed subscriptions, set up the callback
|
|
130
|
+
if (this.adapter.subscribe) {
|
|
131
|
+
this.adapter.subscribe((event, data, metadata) => {
|
|
132
|
+
// Dispatch to local handlers without re-publishing to adapter
|
|
133
|
+
this.dispatchToLocalHandlers(event, data, metadata);
|
|
134
|
+
// Propagate to SSE clients so browser subscribers see distributed events
|
|
135
|
+
this.sse?.broadcastAll(event, data);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
109
138
|
}
|
|
110
139
|
|
|
111
|
-
async emit<T = any>(event: string, data: T): Promise<void> {
|
|
140
|
+
async emit<T = any>(event: string, data: T, metadata?: EventMetadata): Promise<void> {
|
|
141
|
+
if (this.stopped) return;
|
|
142
|
+
|
|
112
143
|
// Store in adapter (for history/persistence)
|
|
113
|
-
await this.adapter.publish(event, data);
|
|
144
|
+
await this.adapter.publish(event, data, metadata);
|
|
145
|
+
|
|
146
|
+
// Dispatch to local handlers
|
|
147
|
+
await this.dispatchToLocalHandlers(event, data, metadata);
|
|
148
|
+
}
|
|
114
149
|
|
|
115
|
-
|
|
150
|
+
/**
|
|
151
|
+
* Dispatch an event to locally registered handlers.
|
|
152
|
+
* Separated from emit() so distributed adapters can deliver remote events
|
|
153
|
+
* without re-publishing to the adapter.
|
|
154
|
+
*/
|
|
155
|
+
private async dispatchToLocalHandlers(event: string, data: any, _metadata?: EventMetadata): Promise<void> {
|
|
156
|
+
// Notify exact-match handlers
|
|
116
157
|
const eventHandlers = this.handlers.get(event);
|
|
117
158
|
if (eventHandlers) {
|
|
118
159
|
const promises: Promise<void>[] = [];
|
|
@@ -185,6 +226,12 @@ class EventsImpl implements Events {
|
|
|
185
226
|
return this.adapter.getHistory(event, limit);
|
|
186
227
|
}
|
|
187
228
|
|
|
229
|
+
async stop(): Promise<void> {
|
|
230
|
+
this.stopped = true;
|
|
231
|
+
await this.adapter.stop?.();
|
|
232
|
+
this.handlers.clear();
|
|
233
|
+
}
|
|
234
|
+
|
|
188
235
|
private matchPattern(event: string, pattern: string): boolean {
|
|
189
236
|
// Convert glob pattern to regex (e.g., "user.*" -> /^user\..*$/)
|
|
190
237
|
const regex = new RegExp(
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Core Health Check Service
|
|
2
|
+
// Provides liveness and readiness probes for production deployments
|
|
3
|
+
|
|
4
|
+
import type { Kysely } from "kysely";
|
|
5
|
+
|
|
6
|
+
export type HealthStatus = "healthy" | "degraded" | "unhealthy";
|
|
7
|
+
|
|
8
|
+
export interface HealthCheckResult {
|
|
9
|
+
status: HealthStatus;
|
|
10
|
+
message?: string;
|
|
11
|
+
latencyMs?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface HealthCheck {
|
|
15
|
+
name: string;
|
|
16
|
+
check: () => Promise<HealthCheckResult> | HealthCheckResult;
|
|
17
|
+
/** Whether failure of this check marks the service as unhealthy (default: true) */
|
|
18
|
+
critical?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface HealthResponse {
|
|
22
|
+
status: HealthStatus;
|
|
23
|
+
uptime: number;
|
|
24
|
+
timestamp: string;
|
|
25
|
+
checks: Record<string, HealthCheckResult>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface HealthConfig {
|
|
29
|
+
checks?: HealthCheck[];
|
|
30
|
+
/** Path for liveness probe (default: "/_health") */
|
|
31
|
+
livenessPath?: string;
|
|
32
|
+
/** Path for readiness probe (default: "/_ready") */
|
|
33
|
+
readinessPath?: string;
|
|
34
|
+
/** Timeout per check in ms (default: 5000) */
|
|
35
|
+
checkTimeout?: number;
|
|
36
|
+
/** Whether to register a built-in database check (default: true) */
|
|
37
|
+
dbCheck?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Health {
|
|
41
|
+
/** Register a health check */
|
|
42
|
+
register(check: HealthCheck): void;
|
|
43
|
+
/** Run all readiness checks */
|
|
44
|
+
check(): Promise<HealthResponse>;
|
|
45
|
+
/** Fast liveness probe (no external checks) */
|
|
46
|
+
liveness(isShuttingDown: boolean): HealthResponse;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
class HealthImpl implements Health {
|
|
50
|
+
private checks: HealthCheck[] = [];
|
|
51
|
+
private startTime = Date.now();
|
|
52
|
+
private checkTimeout: number;
|
|
53
|
+
|
|
54
|
+
constructor(config: HealthConfig = {}) {
|
|
55
|
+
this.checkTimeout = config.checkTimeout ?? 5000;
|
|
56
|
+
|
|
57
|
+
if (config.checks) {
|
|
58
|
+
for (const check of config.checks) {
|
|
59
|
+
this.checks.push(check);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
register(check: HealthCheck): void {
|
|
65
|
+
this.checks.push(check);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async check(): Promise<HealthResponse> {
|
|
69
|
+
const results: Record<string, HealthCheckResult> = {};
|
|
70
|
+
let overallStatus: HealthStatus = "healthy";
|
|
71
|
+
|
|
72
|
+
await Promise.all(
|
|
73
|
+
this.checks.map(async (check) => {
|
|
74
|
+
const start = Date.now();
|
|
75
|
+
try {
|
|
76
|
+
const result = await Promise.race([
|
|
77
|
+
Promise.resolve(check.check()),
|
|
78
|
+
new Promise<HealthCheckResult>((_, reject) =>
|
|
79
|
+
setTimeout(() => reject(new Error("Health check timed out")), this.checkTimeout)
|
|
80
|
+
),
|
|
81
|
+
]);
|
|
82
|
+
results[check.name] = {
|
|
83
|
+
...result,
|
|
84
|
+
latencyMs: result.latencyMs ?? Date.now() - start,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const isCritical = check.critical !== false;
|
|
88
|
+
if (result.status === "unhealthy" && isCritical) {
|
|
89
|
+
overallStatus = "unhealthy";
|
|
90
|
+
} else if (result.status === "degraded" && overallStatus !== "unhealthy") {
|
|
91
|
+
overallStatus = "degraded";
|
|
92
|
+
} else if (result.status === "unhealthy" && !isCritical && overallStatus !== "unhealthy") {
|
|
93
|
+
overallStatus = "degraded";
|
|
94
|
+
}
|
|
95
|
+
} catch (err) {
|
|
96
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
97
|
+
results[check.name] = {
|
|
98
|
+
status: "unhealthy",
|
|
99
|
+
message,
|
|
100
|
+
latencyMs: Date.now() - start,
|
|
101
|
+
};
|
|
102
|
+
const isCritical = check.critical !== false;
|
|
103
|
+
if (isCritical) {
|
|
104
|
+
overallStatus = "unhealthy";
|
|
105
|
+
} else if (overallStatus !== "unhealthy") {
|
|
106
|
+
overallStatus = "degraded";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
status: overallStatus,
|
|
114
|
+
uptime: Math.floor((Date.now() - this.startTime) / 1000),
|
|
115
|
+
timestamp: new Date().toISOString(),
|
|
116
|
+
checks: results,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
liveness(isShuttingDown: boolean): HealthResponse {
|
|
121
|
+
return {
|
|
122
|
+
status: isShuttingDown ? "unhealthy" : "healthy",
|
|
123
|
+
uptime: Math.floor((Date.now() - this.startTime) / 1000),
|
|
124
|
+
timestamp: new Date().toISOString(),
|
|
125
|
+
checks: {},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Create a built-in database health check.
|
|
132
|
+
*/
|
|
133
|
+
export function createDbHealthCheck(db: Kysely<any>): HealthCheck {
|
|
134
|
+
return {
|
|
135
|
+
name: "database",
|
|
136
|
+
critical: true,
|
|
137
|
+
check: async () => {
|
|
138
|
+
const start = Date.now();
|
|
139
|
+
try {
|
|
140
|
+
await db.selectFrom(db.dynamic.ref("sqlite_master") as any)
|
|
141
|
+
.select(db.dynamic.ref("1") as any)
|
|
142
|
+
.execute()
|
|
143
|
+
.catch(async () => {
|
|
144
|
+
// Fallback for non-SQLite databases
|
|
145
|
+
const { sql } = await import("kysely");
|
|
146
|
+
await sql`SELECT 1`.execute(db);
|
|
147
|
+
});
|
|
148
|
+
return {
|
|
149
|
+
status: "healthy" as const,
|
|
150
|
+
latencyMs: Date.now() - start,
|
|
151
|
+
};
|
|
152
|
+
} catch (err) {
|
|
153
|
+
return {
|
|
154
|
+
status: "unhealthy" as const,
|
|
155
|
+
message: err instanceof Error ? err.message : String(err),
|
|
156
|
+
latencyMs: Date.now() - start,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function createHealth(config?: HealthConfig): Health {
|
|
164
|
+
return new HealthImpl(config);
|
|
165
|
+
}
|
package/src/core/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ export {
|
|
|
21
21
|
export {
|
|
22
22
|
type Events,
|
|
23
23
|
type EventHandler,
|
|
24
|
+
type EventMetadata,
|
|
24
25
|
type Subscription,
|
|
25
26
|
type EventRecord,
|
|
26
27
|
type EventAdapter,
|
|
@@ -281,6 +282,16 @@ export {
|
|
|
281
282
|
export { LocalStorageAdapter } from "./storage-adapter-local";
|
|
282
283
|
export { S3StorageAdapter } from "./storage-adapter-s3";
|
|
283
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
|
+
|
|
284
295
|
export {
|
|
285
296
|
type Logs,
|
|
286
297
|
type LogSource,
|
|
@@ -302,3 +313,14 @@ export {
|
|
|
302
313
|
PersistentTransport,
|
|
303
314
|
type PersistentTransportConfig,
|
|
304
315
|
} from "./logs-transport";
|
|
316
|
+
|
|
317
|
+
export {
|
|
318
|
+
type Health,
|
|
319
|
+
type HealthCheck,
|
|
320
|
+
type HealthCheckResult,
|
|
321
|
+
type HealthConfig,
|
|
322
|
+
type HealthResponse,
|
|
323
|
+
type HealthStatus,
|
|
324
|
+
createHealth,
|
|
325
|
+
createDbHealthCheck,
|
|
326
|
+
} from "./health";
|
package/src/core/jobs.ts
CHANGED
|
@@ -56,6 +56,8 @@ export interface Job {
|
|
|
56
56
|
lastHeartbeat?: Date;
|
|
57
57
|
/** Current process state */
|
|
58
58
|
processState?: ExternalJobProcessState;
|
|
59
|
+
/** Trace ID for distributed tracing */
|
|
60
|
+
traceId?: string;
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
export interface JobHandler<T = any, R = any> {
|
|
@@ -66,6 +68,8 @@ export interface JobHandlerContext {
|
|
|
66
68
|
logger?: Logger;
|
|
67
69
|
emit?: (event: string, data?: Record<string, any>) => Promise<void>;
|
|
68
70
|
log?: (level: LogLevel, message: string, data?: Record<string, any>) => void;
|
|
71
|
+
/** Trace ID for distributed tracing */
|
|
72
|
+
traceId?: string;
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
/** Options for listing all jobs */
|
|
@@ -128,9 +132,9 @@ export interface Jobs {
|
|
|
128
132
|
/** Register an external job (Python, Go, Shell, etc.) */
|
|
129
133
|
registerExternal(name: string, config: ExternalJobConfig): void;
|
|
130
134
|
/** Enqueue a job (works for both in-process and external jobs) */
|
|
131
|
-
enqueue<T = any>(name: string, data: T, options?: { maxAttempts?: number }): Promise<string>;
|
|
135
|
+
enqueue<T = any>(name: string, data: T, options?: { maxAttempts?: number; traceId?: string }): Promise<string>;
|
|
132
136
|
/** Schedule a job to run at a specific time */
|
|
133
|
-
schedule<T = any>(name: string, data: T, runAt: Date, options?: { maxAttempts?: number }): Promise<string>;
|
|
137
|
+
schedule<T = any>(name: string, data: T, runAt: Date, options?: { maxAttempts?: number; traceId?: string }): Promise<string>;
|
|
134
138
|
/** Get a job by ID */
|
|
135
139
|
get(jobId: string): Promise<Job | null>;
|
|
136
140
|
/** Cancel a pending job */
|
|
@@ -339,7 +343,7 @@ class JobsImpl implements Jobs {
|
|
|
339
343
|
return this.externalConfigs.has(name);
|
|
340
344
|
}
|
|
341
345
|
|
|
342
|
-
async enqueue<T = any>(name: string, data: T, options: { maxAttempts?: number } = {}): Promise<string> {
|
|
346
|
+
async enqueue<T = any>(name: string, data: T, options: { maxAttempts?: number; traceId?: string } = {}): Promise<string> {
|
|
343
347
|
const isExternal = this.isExternalJob(name);
|
|
344
348
|
|
|
345
349
|
if (!isExternal && !this.handlers.has(name)) {
|
|
@@ -355,6 +359,7 @@ class JobsImpl implements Jobs {
|
|
|
355
359
|
maxAttempts: options.maxAttempts ?? this.defaultMaxAttempts,
|
|
356
360
|
external: isExternal || undefined,
|
|
357
361
|
processState: isExternal ? "spawning" : undefined,
|
|
362
|
+
traceId: options.traceId,
|
|
358
363
|
});
|
|
359
364
|
|
|
360
365
|
return job.id;
|
|
@@ -364,7 +369,7 @@ class JobsImpl implements Jobs {
|
|
|
364
369
|
name: string,
|
|
365
370
|
data: T,
|
|
366
371
|
runAt: Date,
|
|
367
|
-
options: { maxAttempts?: number } = {}
|
|
372
|
+
options: { maxAttempts?: number; traceId?: string } = {}
|
|
368
373
|
): Promise<string> {
|
|
369
374
|
const isExternal = this.isExternalJob(name);
|
|
370
375
|
|
|
@@ -382,6 +387,7 @@ class JobsImpl implements Jobs {
|
|
|
382
387
|
maxAttempts: options.maxAttempts ?? this.defaultMaxAttempts,
|
|
383
388
|
external: isExternal || undefined,
|
|
384
389
|
processState: isExternal ? "spawning" : undefined,
|
|
390
|
+
traceId: options.traceId,
|
|
385
391
|
});
|
|
386
392
|
|
|
387
393
|
return job.id;
|
|
@@ -1147,6 +1153,7 @@ class JobsImpl implements Jobs {
|
|
|
1147
1153
|
logger: scopedLogger,
|
|
1148
1154
|
emit,
|
|
1149
1155
|
log,
|
|
1156
|
+
traceId: job.traceId,
|
|
1150
1157
|
});
|
|
1151
1158
|
|
|
1152
1159
|
await this.adapter.update(job.id, {
|
|
@@ -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
|
+
}
|