@donkeylabs/server 0.1.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/README.md +15 -0
- package/cli/commands/generate.ts +461 -0
- package/cli/commands/init.ts +287 -0
- package/cli/commands/interactive.ts +223 -0
- package/cli/commands/plugin.ts +192 -0
- package/cli/donkeylabs +100 -0
- package/cli/index.ts +100 -0
- package/mcp/donkeylabs-mcp +3238 -0
- package/mcp/server.ts +3238 -0
- package/package.json +74 -0
- package/src/client/base.ts +481 -0
- package/src/client/index.ts +150 -0
- package/src/core/cache.ts +183 -0
- package/src/core/cron.ts +255 -0
- package/src/core/errors.ts +320 -0
- package/src/core/events.ts +163 -0
- package/src/core/index.ts +94 -0
- package/src/core/jobs.ts +334 -0
- package/src/core/logger.ts +131 -0
- package/src/core/rate-limiter.ts +193 -0
- package/src/core/sse.ts +210 -0
- package/src/core.ts +428 -0
- package/src/handlers.ts +87 -0
- package/src/harness.ts +70 -0
- package/src/index.ts +38 -0
- package/src/middleware.ts +34 -0
- package/src/registry.ts +13 -0
- package/src/router.ts +155 -0
- package/src/server.ts +233 -0
- package/templates/init/donkeylabs.config.ts.template +14 -0
- package/templates/init/index.ts.template +41 -0
- package/templates/plugin/index.ts.template +25 -0
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@donkeylabs/server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Type-safe plugin system for building RPC-style APIs with Bun",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"donkeylabs": "./cli/donkeylabs",
|
|
10
|
+
"donkeylabs-mcp": "./mcp/donkeylabs-mcp"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./src/index.ts",
|
|
15
|
+
"import": "./src/index.ts"
|
|
16
|
+
},
|
|
17
|
+
"./client": {
|
|
18
|
+
"types": "./src/client/base.ts",
|
|
19
|
+
"import": "./src/client/base.ts"
|
|
20
|
+
},
|
|
21
|
+
"./harness": {
|
|
22
|
+
"types": "./src/harness.ts",
|
|
23
|
+
"import": "./src/harness.ts"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"src",
|
|
28
|
+
"cli",
|
|
29
|
+
"mcp",
|
|
30
|
+
"templates",
|
|
31
|
+
"cli/donkeylabs",
|
|
32
|
+
"mcp/donkeylabs-mcp"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"gen:registry": "bun scripts/generate-registry.ts",
|
|
36
|
+
"gen:server": "bun scripts/generate-server.ts",
|
|
37
|
+
"gen:client": "bun scripts/generate-client.ts",
|
|
38
|
+
"cli": "bun cli/index.ts",
|
|
39
|
+
"test": "bun test",
|
|
40
|
+
"typecheck": "bun --bun tsc --noEmit",
|
|
41
|
+
"dev": "bun --watch src/index.ts",
|
|
42
|
+
"start": "bun src/index.ts",
|
|
43
|
+
"gen:types": "donkeylabs generate"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/bun": "latest",
|
|
47
|
+
"@types/prompts": "^2.4.9",
|
|
48
|
+
"kysely-codegen": "^0.19.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"typescript": "^5"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@modelcontextprotocol/sdk": "^1.25.2",
|
|
55
|
+
"kysely": "^0.28.9",
|
|
56
|
+
"kysely-bun-sqlite": "^0.4.0",
|
|
57
|
+
"picocolors": "^1.1.1",
|
|
58
|
+
"prompts": "^2.4.2",
|
|
59
|
+
"zod": "^4.3.5"
|
|
60
|
+
},
|
|
61
|
+
"keywords": [
|
|
62
|
+
"api",
|
|
63
|
+
"rpc",
|
|
64
|
+
"plugin",
|
|
65
|
+
"bun",
|
|
66
|
+
"typescript",
|
|
67
|
+
"type-safe"
|
|
68
|
+
],
|
|
69
|
+
"repository": {
|
|
70
|
+
"type": "git",
|
|
71
|
+
"url": "https://github.com/donkeylabs/server"
|
|
72
|
+
},
|
|
73
|
+
"license": "MIT"
|
|
74
|
+
}
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Client Base Runtime
|
|
3
|
+
*
|
|
4
|
+
* This file provides the runtime implementation for the generated API client.
|
|
5
|
+
* It handles HTTP requests, SSE connections, and typed event handling.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ============================================
|
|
9
|
+
// Type Declarations (for environments without DOM types)
|
|
10
|
+
// ============================================
|
|
11
|
+
|
|
12
|
+
type CredentialsMode = "include" | "same-origin" | "omit";
|
|
13
|
+
|
|
14
|
+
// EventSource type declarations for non-DOM environments
|
|
15
|
+
interface SSEMessageEvent {
|
|
16
|
+
data: string;
|
|
17
|
+
lastEventId: string;
|
|
18
|
+
origin: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type SSEEventHandler = (event: SSEMessageEvent | Event) => void;
|
|
22
|
+
|
|
23
|
+
declare class EventSource {
|
|
24
|
+
readonly readyState: number;
|
|
25
|
+
readonly url: string;
|
|
26
|
+
onopen: ((event: Event) => void) | null;
|
|
27
|
+
onmessage: ((event: SSEMessageEvent) => void) | null;
|
|
28
|
+
onerror: ((event: Event) => void) | null;
|
|
29
|
+
constructor(url: string, eventSourceInitDict?: { withCredentials?: boolean });
|
|
30
|
+
addEventListener(type: string, listener: SSEEventHandler): void;
|
|
31
|
+
removeEventListener(type: string, listener: SSEEventHandler): void;
|
|
32
|
+
close(): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ============================================
|
|
36
|
+
// Error Types
|
|
37
|
+
// ============================================
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* API Error response from the server.
|
|
41
|
+
* Matches the HttpError format from the server.
|
|
42
|
+
*/
|
|
43
|
+
export interface ApiErrorBody {
|
|
44
|
+
error: string;
|
|
45
|
+
message: string;
|
|
46
|
+
details?: Record<string, any>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Base API error class.
|
|
51
|
+
* Thrown when the server returns a non-2xx response.
|
|
52
|
+
*/
|
|
53
|
+
export class ApiError extends Error {
|
|
54
|
+
/** HTTP status code */
|
|
55
|
+
public readonly status: number;
|
|
56
|
+
/** Error code from server (e.g., "BAD_REQUEST", "NOT_FOUND") */
|
|
57
|
+
public readonly code: string;
|
|
58
|
+
/** Full response body */
|
|
59
|
+
public readonly body: ApiErrorBody | any;
|
|
60
|
+
/** Additional error details */
|
|
61
|
+
public readonly details?: Record<string, any>;
|
|
62
|
+
|
|
63
|
+
constructor(
|
|
64
|
+
status: number,
|
|
65
|
+
body: ApiErrorBody | any,
|
|
66
|
+
message?: string
|
|
67
|
+
) {
|
|
68
|
+
super(message || body?.message || `API Error: ${status}`);
|
|
69
|
+
this.name = "ApiError";
|
|
70
|
+
this.status = status;
|
|
71
|
+
this.body = body;
|
|
72
|
+
this.code = body?.error || "UNKNOWN_ERROR";
|
|
73
|
+
this.details = body?.details;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Check if this is a specific error type
|
|
78
|
+
*/
|
|
79
|
+
is(code: string): boolean {
|
|
80
|
+
return this.code === code;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Validation error (400 Bad Request with validation details).
|
|
86
|
+
* Contains detailed information about what failed validation.
|
|
87
|
+
*/
|
|
88
|
+
export class ValidationError extends ApiError {
|
|
89
|
+
/** Validation issue details */
|
|
90
|
+
public readonly validationDetails: Array<{ path: (string | number)[]; message: string }>;
|
|
91
|
+
|
|
92
|
+
constructor(details: Array<{ path: (string | number)[]; message: string }>) {
|
|
93
|
+
super(400, { error: "BAD_REQUEST", message: "Validation Failed", details }, "Validation Failed");
|
|
94
|
+
this.name = "ValidationError";
|
|
95
|
+
this.validationDetails = details;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Get errors for a specific field path
|
|
100
|
+
*/
|
|
101
|
+
getFieldErrors(fieldPath: string | string[]): string[] {
|
|
102
|
+
const path = Array.isArray(fieldPath) ? fieldPath : [fieldPath];
|
|
103
|
+
return this.validationDetails
|
|
104
|
+
.filter((issue) => {
|
|
105
|
+
if (issue.path.length !== path.length) return false;
|
|
106
|
+
return issue.path.every((p, i) => p === path[i]);
|
|
107
|
+
})
|
|
108
|
+
.map((issue) => issue.message);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Check if a specific field has errors
|
|
113
|
+
*/
|
|
114
|
+
hasFieldError(fieldPath: string | string[]): boolean {
|
|
115
|
+
return this.getFieldErrors(fieldPath).length > 0;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Convenience error type checks
|
|
121
|
+
*/
|
|
122
|
+
export const ErrorCodes = {
|
|
123
|
+
BAD_REQUEST: "BAD_REQUEST",
|
|
124
|
+
UNAUTHORIZED: "UNAUTHORIZED",
|
|
125
|
+
FORBIDDEN: "FORBIDDEN",
|
|
126
|
+
NOT_FOUND: "NOT_FOUND",
|
|
127
|
+
METHOD_NOT_ALLOWED: "METHOD_NOT_ALLOWED",
|
|
128
|
+
CONFLICT: "CONFLICT",
|
|
129
|
+
GONE: "GONE",
|
|
130
|
+
UNPROCESSABLE_ENTITY: "UNPROCESSABLE_ENTITY",
|
|
131
|
+
TOO_MANY_REQUESTS: "TOO_MANY_REQUESTS",
|
|
132
|
+
INTERNAL_SERVER_ERROR: "INTERNAL_SERVER_ERROR",
|
|
133
|
+
NOT_IMPLEMENTED: "NOT_IMPLEMENTED",
|
|
134
|
+
BAD_GATEWAY: "BAD_GATEWAY",
|
|
135
|
+
SERVICE_UNAVAILABLE: "SERVICE_UNAVAILABLE",
|
|
136
|
+
GATEWAY_TIMEOUT: "GATEWAY_TIMEOUT",
|
|
137
|
+
} as const;
|
|
138
|
+
|
|
139
|
+
// ============================================
|
|
140
|
+
// Request Options
|
|
141
|
+
// ============================================
|
|
142
|
+
|
|
143
|
+
export interface RequestOptions {
|
|
144
|
+
headers?: Record<string, string>;
|
|
145
|
+
signal?: AbortSignal;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface ApiClientOptions {
|
|
149
|
+
/** Default headers to include in all requests */
|
|
150
|
+
headers?: Record<string, string>;
|
|
151
|
+
/** Credentials mode for fetch (default: 'include' for HTTP-only cookies) */
|
|
152
|
+
credentials?: CredentialsMode;
|
|
153
|
+
/** Called when authentication state changes (login/logout) */
|
|
154
|
+
onAuthChange?: (authenticated: boolean) => void;
|
|
155
|
+
/** Custom fetch implementation (for testing or Node.js polyfills) */
|
|
156
|
+
fetch?: typeof fetch;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ============================================
|
|
160
|
+
// SSE Types
|
|
161
|
+
// ============================================
|
|
162
|
+
|
|
163
|
+
export interface SSEOptions {
|
|
164
|
+
/** Endpoint path for SSE connection (default: '/sse') */
|
|
165
|
+
endpoint?: string;
|
|
166
|
+
/** Channels to subscribe to on connect */
|
|
167
|
+
channels?: string[];
|
|
168
|
+
/** Called when connection is established */
|
|
169
|
+
onConnect?: () => void;
|
|
170
|
+
/** Called when connection is lost */
|
|
171
|
+
onDisconnect?: () => void;
|
|
172
|
+
/** Called on connection error */
|
|
173
|
+
onError?: (error: Event) => void;
|
|
174
|
+
/** Auto-reconnect on disconnect (default: true) */
|
|
175
|
+
autoReconnect?: boolean;
|
|
176
|
+
/** Reconnect delay in ms (default: 3000) */
|
|
177
|
+
reconnectDelay?: number;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ============================================
|
|
181
|
+
// Base Client Implementation
|
|
182
|
+
// ============================================
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Base API client with HTTP request handling and SSE support.
|
|
186
|
+
* Extended by the generated client with typed routes and events.
|
|
187
|
+
*/
|
|
188
|
+
export class ApiClientBase<TEvents extends Record<string, any> = Record<string, any>> {
|
|
189
|
+
protected readonly baseUrl: string;
|
|
190
|
+
protected readonly options: ApiClientOptions;
|
|
191
|
+
private eventSource: EventSource | null = null;
|
|
192
|
+
private eventHandlers: Map<string, Set<(data: any) => void>> = new Map();
|
|
193
|
+
private sseOptions: SSEOptions = {};
|
|
194
|
+
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
195
|
+
|
|
196
|
+
constructor(baseUrl: string, options: ApiClientOptions = {}) {
|
|
197
|
+
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
198
|
+
this.options = {
|
|
199
|
+
credentials: "include",
|
|
200
|
+
...options,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ==========================================
|
|
205
|
+
// HTTP Request Methods
|
|
206
|
+
// ==========================================
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Make a typed POST request to a route
|
|
210
|
+
*/
|
|
211
|
+
protected async request<TInput, TOutput>(
|
|
212
|
+
route: string,
|
|
213
|
+
input: TInput,
|
|
214
|
+
options: RequestOptions = {}
|
|
215
|
+
): Promise<TOutput> {
|
|
216
|
+
const fetchFn = this.options.fetch || fetch;
|
|
217
|
+
|
|
218
|
+
const response = await fetchFn(`${this.baseUrl}/${route}`, {
|
|
219
|
+
method: "POST",
|
|
220
|
+
headers: {
|
|
221
|
+
"Content-Type": "application/json",
|
|
222
|
+
...this.options.headers,
|
|
223
|
+
...options.headers,
|
|
224
|
+
},
|
|
225
|
+
credentials: this.options.credentials,
|
|
226
|
+
body: JSON.stringify(input),
|
|
227
|
+
signal: options.signal,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
if (!response.ok) {
|
|
231
|
+
const body = (await response.json().catch(() => ({}))) as Record<string, any>;
|
|
232
|
+
|
|
233
|
+
// Check for validation errors (from Zod validation)
|
|
234
|
+
// Server sends: { error: "BAD_REQUEST", message: "Validation Failed", details: { issues: [...] } }
|
|
235
|
+
if (response.status === 400 && body.details?.issues) {
|
|
236
|
+
throw new ValidationError(body.details.issues);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
throw new ApiError(response.status, body, body.message);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Handle empty responses (204 No Content)
|
|
243
|
+
if (response.status === 204) {
|
|
244
|
+
return undefined as TOutput;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return response.json() as Promise<TOutput>;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Make a raw request (for non-JSON endpoints)
|
|
252
|
+
*/
|
|
253
|
+
protected async rawRequest(
|
|
254
|
+
route: string,
|
|
255
|
+
init: RequestInit = {}
|
|
256
|
+
): Promise<Response> {
|
|
257
|
+
const fetchFn = this.options.fetch || fetch;
|
|
258
|
+
|
|
259
|
+
return fetchFn(`${this.baseUrl}/${route}`, {
|
|
260
|
+
...init,
|
|
261
|
+
headers: {
|
|
262
|
+
...this.options.headers,
|
|
263
|
+
...init.headers,
|
|
264
|
+
},
|
|
265
|
+
credentials: this.options.credentials,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ==========================================
|
|
270
|
+
// SSE Connection Methods
|
|
271
|
+
// ==========================================
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Connect to SSE endpoint for real-time updates
|
|
275
|
+
*/
|
|
276
|
+
connect(options: SSEOptions = {}): void {
|
|
277
|
+
if (this.eventSource) {
|
|
278
|
+
this.disconnect();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
this.sseOptions = {
|
|
282
|
+
endpoint: "/sse",
|
|
283
|
+
autoReconnect: true,
|
|
284
|
+
reconnectDelay: 3000,
|
|
285
|
+
...options,
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const url = new URL(`${this.baseUrl}${this.sseOptions.endpoint}`);
|
|
289
|
+
|
|
290
|
+
// Add channel subscriptions as query params
|
|
291
|
+
if (this.sseOptions.channels?.length) {
|
|
292
|
+
for (const channel of this.sseOptions.channels) {
|
|
293
|
+
url.searchParams.append("channel", channel);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
this.eventSource = new EventSource(url.toString(), {
|
|
298
|
+
withCredentials: true,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
this.eventSource.onopen = () => {
|
|
302
|
+
this.sseOptions.onConnect?.();
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
this.eventSource.onerror = (event) => {
|
|
306
|
+
this.sseOptions.onError?.(event);
|
|
307
|
+
|
|
308
|
+
// Handle reconnection (readyState 2 = CLOSED)
|
|
309
|
+
if (this.eventSource?.readyState === 2) {
|
|
310
|
+
this.sseOptions.onDisconnect?.();
|
|
311
|
+
|
|
312
|
+
if (this.sseOptions.autoReconnect) {
|
|
313
|
+
this.scheduleReconnect();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
// Listen for all events and dispatch to handlers
|
|
319
|
+
this.eventSource.onmessage = (event) => {
|
|
320
|
+
this.dispatchEvent("message", event.data);
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
// Set up listeners for specific event types
|
|
324
|
+
for (const eventName of this.eventHandlers.keys()) {
|
|
325
|
+
if (eventName !== "message") {
|
|
326
|
+
this.eventSource.addEventListener(eventName, (event) => {
|
|
327
|
+
if ("data" in event) {
|
|
328
|
+
this.dispatchEvent(eventName, event.data);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Disconnect from SSE endpoint
|
|
337
|
+
*/
|
|
338
|
+
disconnect(): void {
|
|
339
|
+
if (this.reconnectTimeout) {
|
|
340
|
+
clearTimeout(this.reconnectTimeout);
|
|
341
|
+
this.reconnectTimeout = null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (this.eventSource) {
|
|
345
|
+
this.eventSource.close();
|
|
346
|
+
this.eventSource = null;
|
|
347
|
+
this.sseOptions.onDisconnect?.();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Check if SSE is connected
|
|
353
|
+
*/
|
|
354
|
+
get connected(): boolean {
|
|
355
|
+
// readyState 1 = OPEN
|
|
356
|
+
return this.eventSource?.readyState === 1;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ==========================================
|
|
360
|
+
// Event Handling
|
|
361
|
+
// ==========================================
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Subscribe to a typed SSE event
|
|
365
|
+
* @returns Unsubscribe function
|
|
366
|
+
*/
|
|
367
|
+
on<E extends keyof TEvents>(
|
|
368
|
+
event: E,
|
|
369
|
+
handler: (data: TEvents[E]) => void
|
|
370
|
+
): () => void {
|
|
371
|
+
const eventName = String(event);
|
|
372
|
+
let handlers = this.eventHandlers.get(eventName);
|
|
373
|
+
|
|
374
|
+
if (!handlers) {
|
|
375
|
+
handlers = new Set();
|
|
376
|
+
this.eventHandlers.set(eventName, handlers);
|
|
377
|
+
|
|
378
|
+
// If already connected, add event listener
|
|
379
|
+
if (this.eventSource && eventName !== "message") {
|
|
380
|
+
this.eventSource.addEventListener(eventName, (event) => {
|
|
381
|
+
if ("data" in event) {
|
|
382
|
+
this.dispatchEvent(eventName, event.data);
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
handlers.add(handler as (data: any) => void);
|
|
389
|
+
|
|
390
|
+
// Return unsubscribe function
|
|
391
|
+
return () => {
|
|
392
|
+
handlers?.delete(handler as (data: any) => void);
|
|
393
|
+
if (handlers?.size === 0) {
|
|
394
|
+
this.eventHandlers.delete(eventName);
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Subscribe to an event once
|
|
401
|
+
*/
|
|
402
|
+
once<E extends keyof TEvents>(
|
|
403
|
+
event: E,
|
|
404
|
+
handler: (data: TEvents[E]) => void
|
|
405
|
+
): () => void {
|
|
406
|
+
const unsubscribe = this.on(event, (data) => {
|
|
407
|
+
unsubscribe();
|
|
408
|
+
handler(data);
|
|
409
|
+
});
|
|
410
|
+
return unsubscribe;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Remove all handlers for an event
|
|
415
|
+
*/
|
|
416
|
+
off<E extends keyof TEvents>(event: E): void {
|
|
417
|
+
this.eventHandlers.delete(String(event));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ==========================================
|
|
421
|
+
// Private Helpers
|
|
422
|
+
// ==========================================
|
|
423
|
+
|
|
424
|
+
private dispatchEvent(eventName: string, rawData: string): void {
|
|
425
|
+
const handlers = this.eventHandlers.get(eventName);
|
|
426
|
+
if (!handlers?.size) return;
|
|
427
|
+
|
|
428
|
+
let data: any;
|
|
429
|
+
try {
|
|
430
|
+
data = JSON.parse(rawData);
|
|
431
|
+
} catch {
|
|
432
|
+
data = rawData;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
for (const handler of handlers) {
|
|
436
|
+
try {
|
|
437
|
+
handler(data);
|
|
438
|
+
} catch (error) {
|
|
439
|
+
console.error(`Error in event handler for "${eventName}":`, error);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private scheduleReconnect(): void {
|
|
445
|
+
if (this.reconnectTimeout) return;
|
|
446
|
+
|
|
447
|
+
this.reconnectTimeout = setTimeout(() => {
|
|
448
|
+
this.reconnectTimeout = null;
|
|
449
|
+
this.connect(this.sseOptions);
|
|
450
|
+
}, this.sseOptions.reconnectDelay);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ============================================
|
|
455
|
+
// Type Helpers for Generated Client
|
|
456
|
+
// ============================================
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Extract the input type from a Zod schema
|
|
460
|
+
*/
|
|
461
|
+
export type ZodInput<T> = T extends { _input: infer I } ? I : never;
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Extract the output type from a Zod schema
|
|
465
|
+
*/
|
|
466
|
+
export type ZodOutput<T> = T extends { _output: infer O } ? O : never;
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Create a typed route method
|
|
470
|
+
*/
|
|
471
|
+
export type RouteMethod<TInput, TOutput> = (
|
|
472
|
+
input: TInput,
|
|
473
|
+
options?: RequestOptions
|
|
474
|
+
) => Promise<TOutput>;
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Create a route namespace from route definitions
|
|
478
|
+
*/
|
|
479
|
+
export type RouteNamespace<TRoutes extends Record<string, { input: any; output: any }>> = {
|
|
480
|
+
[K in keyof TRoutes]: RouteMethod<TRoutes[K]["input"], TRoutes[K]["output"]>;
|
|
481
|
+
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Auto-generated by scripts/generate-client.ts
|
|
2
|
+
// DO NOT EDIT MANUALLY
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ApiClientBase,
|
|
6
|
+
ApiError,
|
|
7
|
+
ValidationError,
|
|
8
|
+
type RequestOptions,
|
|
9
|
+
type ApiClientOptions,
|
|
10
|
+
type SSEOptions,
|
|
11
|
+
} from "./base";
|
|
12
|
+
|
|
13
|
+
// ============================================
|
|
14
|
+
// Route Types
|
|
15
|
+
// ============================================
|
|
16
|
+
|
|
17
|
+
export namespace Routes {
|
|
18
|
+
export namespace Users {
|
|
19
|
+
export type GetInput = {
|
|
20
|
+
id: number;
|
|
21
|
+
};
|
|
22
|
+
export type GetOutput = {
|
|
23
|
+
id: number;
|
|
24
|
+
name: string;
|
|
25
|
+
email: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type CreateInput = {
|
|
29
|
+
name: string;
|
|
30
|
+
email: string;
|
|
31
|
+
};
|
|
32
|
+
export type CreateOutput = {
|
|
33
|
+
id: number;
|
|
34
|
+
name: string;
|
|
35
|
+
email: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type ListInput = {
|
|
39
|
+
page?: number;
|
|
40
|
+
limit?: number;
|
|
41
|
+
};
|
|
42
|
+
export type ListOutput = {
|
|
43
|
+
users: {
|
|
44
|
+
id: number;
|
|
45
|
+
name: string;
|
|
46
|
+
}[];
|
|
47
|
+
total: number;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export namespace Orders {
|
|
52
|
+
export type CreateInput = {
|
|
53
|
+
items: {
|
|
54
|
+
productId: number;
|
|
55
|
+
quantity: number;
|
|
56
|
+
}[];
|
|
57
|
+
};
|
|
58
|
+
export type CreateOutput = {
|
|
59
|
+
orderId: string;
|
|
60
|
+
total: number;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ============================================
|
|
66
|
+
// SSE Event Types
|
|
67
|
+
// ============================================
|
|
68
|
+
|
|
69
|
+
export interface SSEEvents {}
|
|
70
|
+
|
|
71
|
+
// ============================================
|
|
72
|
+
// API Client
|
|
73
|
+
// ============================================
|
|
74
|
+
|
|
75
|
+
export interface ApiClientConfig extends ApiClientOptions {
|
|
76
|
+
/** Base URL of the API server */
|
|
77
|
+
baseUrl: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Typed API Client
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* ```ts
|
|
85
|
+
* const api = createApiClient({ baseUrl: "http://localhost:3000" });
|
|
86
|
+
*
|
|
87
|
+
* // Typed route calls
|
|
88
|
+
* const result = await api.users.get({ id: 1 });
|
|
89
|
+
*
|
|
90
|
+
* // SSE events
|
|
91
|
+
* api.connect();
|
|
92
|
+
* api.on("notifications.new", (data) => {
|
|
93
|
+
* console.log(data.message);
|
|
94
|
+
* });
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
export class ApiClient extends ApiClientBase<SSEEvents> {
|
|
98
|
+
constructor(config: ApiClientConfig) {
|
|
99
|
+
super(config.baseUrl, {
|
|
100
|
+
credentials: "include",
|
|
101
|
+
...config,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ==========================================
|
|
106
|
+
// Route Namespaces
|
|
107
|
+
// ==========================================
|
|
108
|
+
|
|
109
|
+
users = {
|
|
110
|
+
get: (input: Routes.Users.GetInput, options?: RequestOptions): Promise<Routes.Users.GetOutput> =>
|
|
111
|
+
this.request("users.get", input, options),
|
|
112
|
+
|
|
113
|
+
create: (input: Routes.Users.CreateInput, options?: RequestOptions): Promise<Routes.Users.CreateOutput> =>
|
|
114
|
+
this.request("users.create", input, options),
|
|
115
|
+
|
|
116
|
+
list: (input: Routes.Users.ListInput, options?: RequestOptions): Promise<Routes.Users.ListOutput> =>
|
|
117
|
+
this.request("users.list", input, options)
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
orders = {
|
|
121
|
+
create: (input: Routes.Orders.CreateInput, options?: RequestOptions): Promise<Routes.Orders.CreateOutput> =>
|
|
122
|
+
this.request("orders.create", input, options),
|
|
123
|
+
|
|
124
|
+
status: (init?: RequestInit): Promise<Response> =>
|
|
125
|
+
this.rawRequest("orders.status", init)
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ============================================
|
|
130
|
+
// Factory Function
|
|
131
|
+
// ============================================
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Create a new API client instance
|
|
135
|
+
*
|
|
136
|
+
* @param config - Client configuration
|
|
137
|
+
* @returns Typed API client
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* ```ts
|
|
141
|
+
* const api = createApiClient({ baseUrl: "http://localhost:3000" });
|
|
142
|
+
* const user = await api.users.get({ id: 1 });
|
|
143
|
+
* ```
|
|
144
|
+
*/
|
|
145
|
+
export function createApiClient(config: ApiClientConfig): ApiClient {
|
|
146
|
+
return new ApiClient(config);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Re-export base types for convenience
|
|
150
|
+
export { ApiError, ValidationError, type RequestOptions, type SSEOptions };
|