@donkeylabs/server 1.1.4 → 1.1.8
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/package.json +1 -1
- package/src/client/base.ts +216 -0
- package/src/core.ts +2 -4
- package/src/generator/index.ts +79 -3
- package/src/server.ts +231 -96
package/package.json
CHANGED
package/src/client/base.ts
CHANGED
|
@@ -177,6 +177,23 @@ export interface SSEOptions {
|
|
|
177
177
|
reconnectDelay?: number;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
/**
|
|
181
|
+
* SSE subscription returned by route-specific SSE methods.
|
|
182
|
+
* Provides typed event handling for the route's defined events.
|
|
183
|
+
*/
|
|
184
|
+
export interface SSESubscription<TEvents extends Record<string, any>> {
|
|
185
|
+
/** Subscribe to a typed event. Returns unsubscribe function. */
|
|
186
|
+
on<E extends keyof TEvents>(event: E, handler: (data: TEvents[E]) => void): () => void;
|
|
187
|
+
/** Subscribe to an event once. Returns unsubscribe function. */
|
|
188
|
+
once<E extends keyof TEvents>(event: E, handler: (data: TEvents[E]) => void): () => void;
|
|
189
|
+
/** Remove all handlers for an event. */
|
|
190
|
+
off<E extends keyof TEvents>(event: E): void;
|
|
191
|
+
/** Close the SSE connection. */
|
|
192
|
+
close(): void;
|
|
193
|
+
/** Whether the connection is currently open. */
|
|
194
|
+
readonly connected: boolean;
|
|
195
|
+
}
|
|
196
|
+
|
|
180
197
|
// ============================================
|
|
181
198
|
// Base Client Implementation
|
|
182
199
|
// ============================================
|
|
@@ -268,10 +285,209 @@ export class ApiClientBase<TEvents extends Record<string, any> = Record<string,
|
|
|
268
285
|
});
|
|
269
286
|
}
|
|
270
287
|
|
|
288
|
+
/**
|
|
289
|
+
* Make a stream/html request with validated input, returns raw Response
|
|
290
|
+
*/
|
|
291
|
+
protected async streamRequest<TInput>(
|
|
292
|
+
route: string,
|
|
293
|
+
input: TInput
|
|
294
|
+
): Promise<Response> {
|
|
295
|
+
const fetchFn = this.options.fetch || fetch;
|
|
296
|
+
|
|
297
|
+
return fetchFn(`${this.baseUrl}/${route}`, {
|
|
298
|
+
method: "POST",
|
|
299
|
+
headers: {
|
|
300
|
+
"Content-Type": "application/json",
|
|
301
|
+
...this.options.headers,
|
|
302
|
+
},
|
|
303
|
+
credentials: this.options.credentials,
|
|
304
|
+
body: JSON.stringify(input),
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Upload form data with files
|
|
310
|
+
*/
|
|
311
|
+
protected async uploadFormData<TFields, TOutput>(
|
|
312
|
+
route: string,
|
|
313
|
+
fields: TFields,
|
|
314
|
+
files?: File[]
|
|
315
|
+
): Promise<TOutput> {
|
|
316
|
+
const fetchFn = this.options.fetch || fetch;
|
|
317
|
+
const formData = new FormData();
|
|
318
|
+
|
|
319
|
+
// Add fields as JSON values
|
|
320
|
+
for (const [key, value] of Object.entries(fields as Record<string, any>)) {
|
|
321
|
+
formData.append(key, typeof value === "string" ? value : JSON.stringify(value));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Add files
|
|
325
|
+
if (files) {
|
|
326
|
+
for (const file of files) {
|
|
327
|
+
formData.append("file", file);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const response = await fetchFn(`${this.baseUrl}/${route}`, {
|
|
332
|
+
method: "POST",
|
|
333
|
+
headers: {
|
|
334
|
+
...this.options.headers,
|
|
335
|
+
// Don't set Content-Type - browser will set it with boundary
|
|
336
|
+
},
|
|
337
|
+
credentials: this.options.credentials,
|
|
338
|
+
body: formData,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
if (!response.ok) {
|
|
342
|
+
const body = (await response.json().catch(() => ({}))) as Record<string, any>;
|
|
343
|
+
if (response.status === 400 && body.details?.issues) {
|
|
344
|
+
throw new ValidationError(body.details.issues);
|
|
345
|
+
}
|
|
346
|
+
throw new ApiError(response.status, body, body.message);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (response.status === 204) {
|
|
350
|
+
return undefined as TOutput;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return response.json() as Promise<TOutput>;
|
|
354
|
+
}
|
|
355
|
+
|
|
271
356
|
// ==========================================
|
|
272
357
|
// SSE Connection Methods
|
|
273
358
|
// ==========================================
|
|
274
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Connect to a specific SSE route endpoint.
|
|
362
|
+
* Used by generated client methods for .sse() routes.
|
|
363
|
+
* @returns SSE subscription with typed event handlers
|
|
364
|
+
*/
|
|
365
|
+
protected connectToSSERoute<TEvents extends Record<string, any>>(
|
|
366
|
+
route: string,
|
|
367
|
+
input: Record<string, any> = {},
|
|
368
|
+
options: Omit<SSEOptions, "endpoint" | "channels"> = {}
|
|
369
|
+
): SSESubscription<TEvents> {
|
|
370
|
+
const url = new URL(`${this.baseUrl}/${route}`);
|
|
371
|
+
|
|
372
|
+
// Add input as query params
|
|
373
|
+
for (const [key, value] of Object.entries(input)) {
|
|
374
|
+
if (value !== undefined && value !== null) {
|
|
375
|
+
url.searchParams.set(key, typeof value === "string" ? value : JSON.stringify(value));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const eventSource = new EventSource(url.toString(), {
|
|
380
|
+
withCredentials: true,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const handlers = new Map<string, Set<(data: any) => void>>();
|
|
384
|
+
let onConnectCallback: (() => void) | undefined = options.onConnect;
|
|
385
|
+
let onDisconnectCallback: (() => void) | undefined = options.onDisconnect;
|
|
386
|
+
let onErrorCallback: ((error: Event) => void) | undefined = options.onError;
|
|
387
|
+
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
388
|
+
const autoReconnect = options.autoReconnect ?? true;
|
|
389
|
+
const reconnectDelay = options.reconnectDelay ?? 3000;
|
|
390
|
+
|
|
391
|
+
const dispatchEvent = (eventName: string, rawData: string) => {
|
|
392
|
+
const eventHandlers = handlers.get(eventName);
|
|
393
|
+
if (!eventHandlers?.size) return;
|
|
394
|
+
|
|
395
|
+
let data: any;
|
|
396
|
+
try {
|
|
397
|
+
data = JSON.parse(rawData);
|
|
398
|
+
} catch {
|
|
399
|
+
data = rawData;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
for (const handler of eventHandlers) {
|
|
403
|
+
try {
|
|
404
|
+
handler(data);
|
|
405
|
+
} catch (error) {
|
|
406
|
+
console.error(`Error in SSE event handler for "${eventName}":`, error);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
const scheduleReconnect = () => {
|
|
412
|
+
if (reconnectTimeout) return;
|
|
413
|
+
reconnectTimeout = setTimeout(() => {
|
|
414
|
+
reconnectTimeout = null;
|
|
415
|
+
// Reconnect by creating new subscription
|
|
416
|
+
const newSub = this.connectToSSERoute<TEvents>(route, input, options);
|
|
417
|
+
// Transfer handlers
|
|
418
|
+
for (const [event, eventHandlers] of handlers) {
|
|
419
|
+
for (const handler of eventHandlers) {
|
|
420
|
+
newSub.on(event as keyof TEvents, handler);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}, reconnectDelay);
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
eventSource.onopen = () => {
|
|
427
|
+
onConnectCallback?.();
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
eventSource.onerror = (event) => {
|
|
431
|
+
onErrorCallback?.(event);
|
|
432
|
+
if (eventSource.readyState === 2) {
|
|
433
|
+
onDisconnectCallback?.();
|
|
434
|
+
if (autoReconnect) {
|
|
435
|
+
scheduleReconnect();
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
eventSource.onmessage = (event) => {
|
|
441
|
+
dispatchEvent("message", event.data);
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const subscription: SSESubscription<TEvents> = {
|
|
445
|
+
on: <E extends keyof TEvents>(event: E, handler: (data: TEvents[E]) => void) => {
|
|
446
|
+
const eventName = String(event);
|
|
447
|
+
let eventHandlers = handlers.get(eventName);
|
|
448
|
+
if (!eventHandlers) {
|
|
449
|
+
eventHandlers = new Set();
|
|
450
|
+
handlers.set(eventName, eventHandlers);
|
|
451
|
+
// Add listener to EventSource
|
|
452
|
+
if (eventName !== "message") {
|
|
453
|
+
eventSource.addEventListener(eventName, (e) => {
|
|
454
|
+
if ("data" in e) {
|
|
455
|
+
dispatchEvent(eventName, (e as SSEMessageEvent).data);
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
eventHandlers.add(handler as (data: any) => void);
|
|
461
|
+
return () => {
|
|
462
|
+
eventHandlers?.delete(handler as (data: any) => void);
|
|
463
|
+
};
|
|
464
|
+
},
|
|
465
|
+
once: <E extends keyof TEvents>(event: E, handler: (data: TEvents[E]) => void) => {
|
|
466
|
+
const unsubscribe = subscription.on(event, (data) => {
|
|
467
|
+
unsubscribe();
|
|
468
|
+
handler(data);
|
|
469
|
+
});
|
|
470
|
+
return unsubscribe;
|
|
471
|
+
},
|
|
472
|
+
off: <E extends keyof TEvents>(event: E) => {
|
|
473
|
+
handlers.delete(String(event));
|
|
474
|
+
},
|
|
475
|
+
close: () => {
|
|
476
|
+
if (reconnectTimeout) {
|
|
477
|
+
clearTimeout(reconnectTimeout);
|
|
478
|
+
reconnectTimeout = null;
|
|
479
|
+
}
|
|
480
|
+
eventSource.close();
|
|
481
|
+
onDisconnectCallback?.();
|
|
482
|
+
},
|
|
483
|
+
get connected() {
|
|
484
|
+
return eventSource.readyState === 1;
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
return subscription;
|
|
489
|
+
}
|
|
490
|
+
|
|
275
491
|
/**
|
|
276
492
|
* Connect to SSE endpoint for real-time updates
|
|
277
493
|
*/
|
package/src/core.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { sql, type Kysely } from "kysely";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
3
2
|
import { readdir } from "node:fs/promises";
|
|
4
3
|
import { join, dirname } from "node:path";
|
|
5
4
|
import { fileURLToPath } from "node:url";
|
|
@@ -484,9 +483,8 @@ export class PluginManager {
|
|
|
484
483
|
|
|
485
484
|
for (const service of coreServices) {
|
|
486
485
|
const migrationDir = join(coreMigrationsDir, service);
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
}
|
|
486
|
+
// runMigrationsForPlugin handles missing directories gracefully (ENOENT)
|
|
487
|
+
await this.runMigrationsForPlugin(`@core/${service}`, migrationDir);
|
|
490
488
|
}
|
|
491
489
|
|
|
492
490
|
console.log("Core migrations complete.");
|
package/src/generator/index.ts
CHANGED
|
@@ -440,7 +440,7 @@ export function generateClientCode(
|
|
|
440
440
|
const namespaceName = prefix === "_root" ? "Root" : toPascalCase(prefix);
|
|
441
441
|
const methodName = prefix === "_root" ? "_root" : prefix;
|
|
442
442
|
|
|
443
|
-
const
|
|
443
|
+
const typedTypeEntries = prefixRoutes
|
|
444
444
|
.filter((r) => r.handler === "typed")
|
|
445
445
|
.map((r) => {
|
|
446
446
|
const inputType = zodToTypeScript(r.inputSource);
|
|
@@ -453,6 +453,51 @@ export function generateClientCode(
|
|
|
453
453
|
export type ${routeNs} = { Input: ${routeNs}.Input; Output: ${routeNs}.Output };`;
|
|
454
454
|
});
|
|
455
455
|
|
|
456
|
+
const formDataTypeEntries = prefixRoutes
|
|
457
|
+
.filter((r) => r.handler === "formData")
|
|
458
|
+
.map((r) => {
|
|
459
|
+
const inputType = zodToTypeScript(r.inputSource);
|
|
460
|
+
const outputType = zodToTypeScript(r.outputSource);
|
|
461
|
+
const routeNs = toPascalCase(r.routeName);
|
|
462
|
+
return ` export namespace ${routeNs} {
|
|
463
|
+
export type Input = ${inputType};
|
|
464
|
+
export type Output = ${outputType};
|
|
465
|
+
}
|
|
466
|
+
export type ${routeNs} = { Input: ${routeNs}.Input; Output: ${routeNs}.Output };`;
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const streamTypeEntries = prefixRoutes
|
|
470
|
+
.filter((r) => r.handler === "stream" || r.handler === "html")
|
|
471
|
+
.map((r) => {
|
|
472
|
+
const inputType = zodToTypeScript(r.inputSource);
|
|
473
|
+
const routeNs = toPascalCase(r.routeName);
|
|
474
|
+
return ` export namespace ${routeNs} {
|
|
475
|
+
export type Input = ${inputType};
|
|
476
|
+
}
|
|
477
|
+
export type ${routeNs} = { Input: ${routeNs}.Input };`;
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
const sseTypeEntries = prefixRoutes
|
|
481
|
+
.filter((r) => r.handler === "sse")
|
|
482
|
+
.map((r) => {
|
|
483
|
+
const inputType = zodToTypeScript(r.inputSource);
|
|
484
|
+
const routeNs = toPascalCase(r.routeName);
|
|
485
|
+
// Generate Events type from eventsSource
|
|
486
|
+
const eventsEntries = r.eventsSource
|
|
487
|
+
? Object.entries(r.eventsSource)
|
|
488
|
+
.map(([eventName, eventType]) => ` "${eventName}": ${zodToTypeScript(eventType)};`)
|
|
489
|
+
.join("\n")
|
|
490
|
+
: "";
|
|
491
|
+
const eventsType = eventsEntries ? `{\n${eventsEntries}\n }` : "Record<string, unknown>";
|
|
492
|
+
return ` export namespace ${routeNs} {
|
|
493
|
+
export type Input = ${inputType};
|
|
494
|
+
export type Events = ${eventsType};
|
|
495
|
+
}
|
|
496
|
+
export type ${routeNs} = { Input: ${routeNs}.Input; Events: ${routeNs}.Events };`;
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
const typeEntries = [...typedTypeEntries, ...formDataTypeEntries, ...streamTypeEntries, ...sseTypeEntries];
|
|
500
|
+
|
|
456
501
|
if (typeEntries.length > 0) {
|
|
457
502
|
routeTypeBlocks.push(` export namespace ${namespaceName} {
|
|
458
503
|
${typeEntries.join("\n\n")}
|
|
@@ -475,7 +520,37 @@ ${typeEntries.join("\n\n")}
|
|
|
475
520
|
this.rawRequest("${r.name}", init)`;
|
|
476
521
|
});
|
|
477
522
|
|
|
478
|
-
const
|
|
523
|
+
const sseMethodEntries = prefixRoutes
|
|
524
|
+
.filter((r) => r.handler === "sse")
|
|
525
|
+
.map((r) => {
|
|
526
|
+
const inputType = r.inputSource
|
|
527
|
+
? `Routes.${namespaceName}.${toPascalCase(r.routeName)}.Input`
|
|
528
|
+
: "Record<string, any>";
|
|
529
|
+
const eventsType = `Routes.${namespaceName}.${toPascalCase(r.routeName)}.Events`;
|
|
530
|
+
return ` ${toCamelCase(r.routeName)}: (input: ${inputType}, options?: Omit<SSEOptions, "endpoint" | "channels">): SSESubscription<${eventsType}> =>
|
|
531
|
+
this.connectToSSERoute("${r.name}", input, options)`;
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
const streamMethodEntries = prefixRoutes
|
|
535
|
+
.filter((r) => r.handler === "stream" || r.handler === "html")
|
|
536
|
+
.map((r) => {
|
|
537
|
+
const inputType = r.inputSource
|
|
538
|
+
? `Routes.${namespaceName}.${toPascalCase(r.routeName)}.Input`
|
|
539
|
+
: "Record<string, any>";
|
|
540
|
+
return ` ${toCamelCase(r.routeName)}: (input: ${inputType}): Promise<Response> =>
|
|
541
|
+
this.streamRequest("${r.name}", input)`;
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
const formDataMethodEntries = prefixRoutes
|
|
545
|
+
.filter((r) => r.handler === "formData")
|
|
546
|
+
.map((r) => {
|
|
547
|
+
const inputType = `Routes.${namespaceName}.${toPascalCase(r.routeName)}.Input`;
|
|
548
|
+
const outputType = `Routes.${namespaceName}.${toPascalCase(r.routeName)}.Output`;
|
|
549
|
+
return ` ${toCamelCase(r.routeName)}: (fields: ${inputType}, files?: File[]): Promise<${outputType}> =>
|
|
550
|
+
this.uploadFormData("${r.name}", fields, files)`;
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
const allMethods = [...methodEntries, ...rawMethodEntries, ...sseMethodEntries, ...streamMethodEntries, ...formDataMethodEntries];
|
|
479
554
|
|
|
480
555
|
if (allMethods.length > 0) {
|
|
481
556
|
routeNamespaceBlocks.push(` ${methodName} = {
|
|
@@ -512,6 +587,7 @@ import {
|
|
|
512
587
|
type RequestOptions,
|
|
513
588
|
type ApiClientOptions,
|
|
514
589
|
type SSEOptions,
|
|
590
|
+
type SSESubscription,
|
|
515
591
|
} from "./base";${additionalImportsStr}
|
|
516
592
|
|
|
517
593
|
// ============================================
|
|
@@ -560,7 +636,7 @@ export function createApiClient(config: ApiClientConfig): ApiClient {
|
|
|
560
636
|
}
|
|
561
637
|
|
|
562
638
|
// Re-export base types for convenience
|
|
563
|
-
export { ApiError, ValidationError, type RequestOptions, type SSEOptions };
|
|
639
|
+
export { ApiError, ValidationError, type RequestOptions, type SSEOptions, type SSESubscription };
|
|
564
640
|
`;
|
|
565
641
|
}
|
|
566
642
|
|
package/src/server.ts
CHANGED
|
@@ -55,7 +55,10 @@ export interface TypeGenerationConfig {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
export interface ServerConfig {
|
|
58
|
+
/** Server port. Can also be set via PORT environment variable. Default: 3000 */
|
|
58
59
|
port?: number;
|
|
60
|
+
/** Maximum port attempts if port is in use. Default: 5 */
|
|
61
|
+
maxPortAttempts?: number;
|
|
59
62
|
db: CoreServices["db"];
|
|
60
63
|
config?: Record<string, any>;
|
|
61
64
|
/** Auto-generate client types on startup in dev mode */
|
|
@@ -83,6 +86,7 @@ export interface ServerConfig {
|
|
|
83
86
|
|
|
84
87
|
export class AppServer {
|
|
85
88
|
private port: number;
|
|
89
|
+
private maxPortAttempts: number;
|
|
86
90
|
private manager: PluginManager;
|
|
87
91
|
private routers: IRouter[] = [];
|
|
88
92
|
private routeMap: Map<string, RouteDefinition<keyof HandlerRegistry>> = new Map();
|
|
@@ -90,7 +94,10 @@ export class AppServer {
|
|
|
90
94
|
private typeGenConfig?: TypeGenerationConfig;
|
|
91
95
|
|
|
92
96
|
constructor(options: ServerConfig) {
|
|
93
|
-
|
|
97
|
+
// Port priority: explicit config > PORT env var > default 3000
|
|
98
|
+
const envPort = process.env.PORT ? parseInt(process.env.PORT, 10) : undefined;
|
|
99
|
+
this.port = options.port ?? envPort ?? 3000;
|
|
100
|
+
this.maxPortAttempts = options.maxPortAttempts ?? 5;
|
|
94
101
|
|
|
95
102
|
// Determine if we should use legacy databases
|
|
96
103
|
const useLegacy = options.useLegacyCoreDatabases ?? false;
|
|
@@ -257,6 +264,46 @@ export class AppServer {
|
|
|
257
264
|
return this.routeMap.has(routeName);
|
|
258
265
|
}
|
|
259
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Handle CLI type generation mode.
|
|
269
|
+
* Call this at the end of your server entry file after registering all routes.
|
|
270
|
+
* If DONKEYLABS_GENERATE=1 is set, outputs route metadata and exits.
|
|
271
|
+
* Otherwise, does nothing.
|
|
272
|
+
*
|
|
273
|
+
* @example
|
|
274
|
+
* ```ts
|
|
275
|
+
* server.use(routes);
|
|
276
|
+
* server.handleGenerateMode(); // Add this line at the end
|
|
277
|
+
* ```
|
|
278
|
+
*/
|
|
279
|
+
handleGenerateMode(): void {
|
|
280
|
+
if (process.env.DONKEYLABS_GENERATE === "1") {
|
|
281
|
+
this.outputRoutesForGeneration();
|
|
282
|
+
process.exit(0);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Output route metadata as JSON for CLI type generation.
|
|
288
|
+
* Called when DONKEYLABS_GENERATE=1 environment variable is set.
|
|
289
|
+
*/
|
|
290
|
+
private outputRoutesForGeneration(): void {
|
|
291
|
+
const routes = [];
|
|
292
|
+
|
|
293
|
+
for (const router of this.routers) {
|
|
294
|
+
for (const route of router.getRoutes()) {
|
|
295
|
+
routes.push({
|
|
296
|
+
name: route.name,
|
|
297
|
+
handler: route.handler || "typed",
|
|
298
|
+
inputType: route.input ? zodSchemaToTs(route.input) : undefined,
|
|
299
|
+
outputType: route.output ? zodSchemaToTs(route.output) : undefined,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
console.log(JSON.stringify({ routes }));
|
|
305
|
+
}
|
|
306
|
+
|
|
260
307
|
/**
|
|
261
308
|
* Generate client types from registered routes.
|
|
262
309
|
* Called automatically on startup in dev mode if generateTypes config is provided.
|
|
@@ -277,9 +324,10 @@ export class AppServer {
|
|
|
277
324
|
name: string;
|
|
278
325
|
prefix: string;
|
|
279
326
|
routeName: string;
|
|
280
|
-
handler: "typed" | "raw";
|
|
327
|
+
handler: "typed" | "raw" | "sse" | "stream" | "formData" | "html";
|
|
281
328
|
inputSource?: string;
|
|
282
329
|
outputSource?: string;
|
|
330
|
+
eventsSource?: Record<string, string>;
|
|
283
331
|
}> = [];
|
|
284
332
|
|
|
285
333
|
const routesWithoutOutput: string[] = [];
|
|
@@ -295,13 +343,23 @@ export class AppServer {
|
|
|
295
343
|
routesWithoutOutput.push(route.name);
|
|
296
344
|
}
|
|
297
345
|
|
|
346
|
+
// Extract SSE event schemas
|
|
347
|
+
let eventsSource: Record<string, string> | undefined;
|
|
348
|
+
if (route.handler === "sse" && route.events) {
|
|
349
|
+
eventsSource = {};
|
|
350
|
+
for (const [eventName, eventSchema] of Object.entries(route.events)) {
|
|
351
|
+
eventsSource[eventName] = zodSchemaToTs(eventSchema as any);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
298
355
|
routes.push({
|
|
299
356
|
name: route.name,
|
|
300
357
|
prefix,
|
|
301
358
|
routeName,
|
|
302
|
-
handler: (route.handler || "typed") as "typed" | "raw",
|
|
359
|
+
handler: (route.handler || "typed") as "typed" | "raw" | "sse" | "stream" | "formData" | "html",
|
|
303
360
|
inputSource: route.input ? zodSchemaToTs(route.input) : undefined,
|
|
304
361
|
outputSource: route.output ? zodSchemaToTs(route.output) : undefined,
|
|
362
|
+
eventsSource,
|
|
305
363
|
});
|
|
306
364
|
}
|
|
307
365
|
}
|
|
@@ -336,9 +394,10 @@ export class AppServer {
|
|
|
336
394
|
name: string;
|
|
337
395
|
prefix: string;
|
|
338
396
|
routeName: string;
|
|
339
|
-
handler: "typed" | "raw";
|
|
397
|
+
handler: "typed" | "raw" | "sse" | "stream" | "formData" | "html";
|
|
340
398
|
inputSource?: string;
|
|
341
399
|
outputSource?: string;
|
|
400
|
+
eventsSource?: Record<string, string>;
|
|
342
401
|
}>
|
|
343
402
|
): string {
|
|
344
403
|
const baseImport =
|
|
@@ -401,19 +460,43 @@ export function createApi(options?: ClientOptions) {
|
|
|
401
460
|
// Recursive function to generate Type definitions
|
|
402
461
|
function generateTypeBlock(node: RouteNode, indent: string): string {
|
|
403
462
|
const blocks: string[] = [];
|
|
404
|
-
|
|
463
|
+
|
|
405
464
|
// 1. Valid Input/Output types for routes at this level
|
|
406
465
|
if (node.routes.length > 0) {
|
|
407
466
|
const routeTypes = node.routes.map(r => {
|
|
408
|
-
if (r.handler !== "typed") return "";
|
|
409
467
|
const routeNs = toPascalCase(r.routeName);
|
|
410
468
|
const inputType = r.inputSource ?? "Record<string, never>";
|
|
411
|
-
|
|
412
|
-
|
|
469
|
+
|
|
470
|
+
if (r.handler === "typed" || r.handler === "formData") {
|
|
471
|
+
// typed and formData have Input and Output
|
|
472
|
+
const outputType = r.outputSource ?? "void";
|
|
473
|
+
return `${indent}export namespace ${routeNs} {
|
|
413
474
|
${indent} export type Input = Expand<${inputType}>;
|
|
414
475
|
${indent} export type Output = Expand<${outputType}>;
|
|
415
476
|
${indent}}
|
|
416
477
|
${indent}export type ${routeNs} = { Input: ${routeNs}.Input; Output: ${routeNs}.Output };`;
|
|
478
|
+
} else if (r.handler === "stream" || r.handler === "html") {
|
|
479
|
+
// stream and html have Input only (returns Response/string)
|
|
480
|
+
return `${indent}export namespace ${routeNs} {
|
|
481
|
+
${indent} export type Input = Expand<${inputType}>;
|
|
482
|
+
${indent}}
|
|
483
|
+
${indent}export type ${routeNs} = { Input: ${routeNs}.Input };`;
|
|
484
|
+
} else if (r.handler === "sse") {
|
|
485
|
+
// Generate Events type from eventsSource
|
|
486
|
+
const eventsEntries = r.eventsSource
|
|
487
|
+
? Object.entries(r.eventsSource)
|
|
488
|
+
.map(([eventName, eventType]) => `${indent} "${eventName}": ${eventType};`)
|
|
489
|
+
.join("\n")
|
|
490
|
+
: "";
|
|
491
|
+
const eventsType = eventsEntries ? `{\n${eventsEntries}\n${indent} }` : "Record<string, unknown>";
|
|
492
|
+
return `${indent}export namespace ${routeNs} {
|
|
493
|
+
${indent} export type Input = Expand<${inputType}>;
|
|
494
|
+
${indent} export type Events = Expand<${eventsType}>;
|
|
495
|
+
${indent}}
|
|
496
|
+
${indent}export type ${routeNs} = { Input: ${routeNs}.Input; Events: ${routeNs}.Events };`;
|
|
497
|
+
}
|
|
498
|
+
// raw handler has no types
|
|
499
|
+
return "";
|
|
417
500
|
}).filter(Boolean);
|
|
418
501
|
if (routeTypes.length) blocks.push(routeTypes.join("\n\n"));
|
|
419
502
|
}
|
|
@@ -434,15 +517,28 @@ ${indent}export type ${routeNs} = { Input: ${routeNs}.Input; Output: ${routeNs}.
|
|
|
434
517
|
const methods = node.routes.map(r => {
|
|
435
518
|
const methodName = toCamelCase(r.routeName);
|
|
436
519
|
// r.name is the full path e.g. "api.v1.users.get"
|
|
437
|
-
|
|
520
|
+
const pathParts = r.name.split(".");
|
|
521
|
+
const typePath = ["Routes", ...pathParts.slice(0, -1).map(toPascalCase), toPascalCase(r.routeName)];
|
|
522
|
+
|
|
438
523
|
if (r.handler === "typed") {
|
|
439
|
-
const pathParts = r.name.split(".");
|
|
440
|
-
const typePath = ["Routes", ...pathParts.slice(0, -1).map(toPascalCase), toPascalCase(r.routeName)];
|
|
441
524
|
const inputType = typePath.join(".") + ".Input";
|
|
442
525
|
const outputType = typePath.join(".") + ".Output";
|
|
443
|
-
|
|
444
526
|
return `${indent}${methodName}: (input: ${inputType}): Promise<${outputType}> => this.request("${r.name}", input)`;
|
|
527
|
+
} else if (r.handler === "formData") {
|
|
528
|
+
const inputType = typePath.join(".") + ".Input";
|
|
529
|
+
const outputType = typePath.join(".") + ".Output";
|
|
530
|
+
// formData needs to send multipart form data
|
|
531
|
+
return `${indent}${methodName}: (fields: ${inputType}, files?: File[]): Promise<${outputType}> => this.uploadFormData("${r.name}", fields, files)`;
|
|
532
|
+
} else if (r.handler === "stream" || r.handler === "html") {
|
|
533
|
+
// stream and html have validated input but return Response
|
|
534
|
+
const inputType = typePath.join(".") + ".Input";
|
|
535
|
+
return `${indent}${methodName}: (input: ${inputType}): Promise<Response> => this.streamRequest("${r.name}", input)`;
|
|
536
|
+
} else if (r.handler === "sse") {
|
|
537
|
+
const inputType = typePath.join(".") + ".Input";
|
|
538
|
+
const eventsType = typePath.join(".") + ".Events";
|
|
539
|
+
return `${indent}${methodName}: (input: ${inputType}, options?: Omit<SSEOptions, "endpoint" | "channels">): SSESubscription<${eventsType}> => this.connectToSSERoute("${r.name}", input, options)`;
|
|
445
540
|
} else {
|
|
541
|
+
// raw handler
|
|
446
542
|
return `${indent}${methodName}: (init?: RequestInit): Promise<Response> => this.rawRequest("${r.name}", init)`;
|
|
447
543
|
}
|
|
448
544
|
});
|
|
@@ -468,10 +564,16 @@ ${indent}export type ${routeNs} = { Input: ${routeNs}.Input; Output: ${routeNs}.
|
|
|
468
564
|
// rootNode children are top-level namespaces (api, health) -> Top Level Class Properties
|
|
469
565
|
const methodBlocks: string[] = [generateMethodBlock(rootNode, " ", "", true)];
|
|
470
566
|
|
|
567
|
+
// Check if we have any SSE routes to know if we need SSE type imports
|
|
568
|
+
const hasSSERoutes = routes.some(r => r.handler === "sse");
|
|
569
|
+
const sseImports = hasSSERoutes
|
|
570
|
+
? '\nimport { type SSEOptions, type SSESubscription } from "@donkeylabs/server/client";'
|
|
571
|
+
: "";
|
|
572
|
+
|
|
471
573
|
return `// Auto-generated by @donkeylabs/server
|
|
472
574
|
// DO NOT EDIT MANUALLY
|
|
473
575
|
|
|
474
|
-
${baseImport}
|
|
576
|
+
${baseImport}${sseImports}
|
|
475
577
|
|
|
476
578
|
// Utility type that forces TypeScript to expand types on hover
|
|
477
579
|
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
|
|
@@ -766,104 +868,137 @@ ${factoryFunction}
|
|
|
766
868
|
}
|
|
767
869
|
logger.info(`Loaded ${this.routeMap.size} RPC routes`);
|
|
768
870
|
|
|
769
|
-
// 5. Start HTTP server
|
|
770
|
-
Bun.serve
|
|
771
|
-
|
|
772
|
-
fetch: async (req, server) => {
|
|
773
|
-
const url = new URL(req.url);
|
|
871
|
+
// 5. Start HTTP server with port retry logic
|
|
872
|
+
const fetchHandler = async (req: Request, server: ReturnType<typeof Bun.serve>) => {
|
|
873
|
+
const url = new URL(req.url);
|
|
774
874
|
|
|
775
|
-
|
|
776
|
-
|
|
875
|
+
// Extract client IP
|
|
876
|
+
const ip = extractClientIP(req, server.requestIP(req)?.address);
|
|
777
877
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
878
|
+
// Handle SSE endpoint
|
|
879
|
+
if (url.pathname === "/sse" && req.method === "GET") {
|
|
880
|
+
return this.handleSSE(req, ip);
|
|
881
|
+
}
|
|
782
882
|
|
|
783
|
-
|
|
784
|
-
|
|
883
|
+
// Extract action from URL path (e.g., "auth.login")
|
|
884
|
+
const actionName = url.pathname.slice(1);
|
|
785
885
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
886
|
+
const route = this.routeMap.get(actionName);
|
|
887
|
+
if (route) {
|
|
888
|
+
const handlerType = route.handler || "typed";
|
|
789
889
|
|
|
790
|
-
|
|
791
|
-
|
|
890
|
+
// Handlers that accept GET requests (for browser compatibility)
|
|
891
|
+
const getEnabledHandlers = ["stream", "sse", "html", "raw"];
|
|
792
892
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
}
|
|
893
|
+
// Check method based on handler type
|
|
894
|
+
if (req.method === "GET" && !getEnabledHandlers.includes(handlerType)) {
|
|
895
|
+
return new Response("Method Not Allowed", { status: 405 });
|
|
896
|
+
}
|
|
897
|
+
if (req.method !== "GET" && req.method !== "POST") {
|
|
898
|
+
return new Response("Method Not Allowed", { status: 405 });
|
|
899
|
+
}
|
|
900
|
+
const type = route.handler || "typed";
|
|
901
|
+
|
|
902
|
+
// First check core handlers
|
|
903
|
+
let handler = Handlers[type as keyof typeof Handlers];
|
|
904
|
+
|
|
905
|
+
// If not found, check plugin handlers
|
|
906
|
+
if (!handler) {
|
|
907
|
+
for (const config of this.manager.getPlugins()) {
|
|
908
|
+
if (config.handlers && config.handlers[type]) {
|
|
909
|
+
handler = config.handlers[type] as any;
|
|
910
|
+
break;
|
|
812
911
|
}
|
|
813
912
|
}
|
|
913
|
+
}
|
|
814
914
|
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
}
|
|
842
|
-
} catch (error) {
|
|
843
|
-
// Handle HttpError (thrown via ctx.errors.*)
|
|
844
|
-
if (error instanceof HttpError) {
|
|
845
|
-
logger.warn("HTTP error thrown", {
|
|
846
|
-
route: actionName,
|
|
847
|
-
status: error.status,
|
|
848
|
-
code: error.code,
|
|
849
|
-
message: error.message,
|
|
850
|
-
});
|
|
851
|
-
return Response.json(error.toJSON(), { status: error.status });
|
|
852
|
-
}
|
|
853
|
-
// Re-throw unknown errors
|
|
854
|
-
throw error;
|
|
915
|
+
if (handler) {
|
|
916
|
+
// Build context with core services and IP
|
|
917
|
+
const ctx: ServerContext = {
|
|
918
|
+
db: this.coreServices.db,
|
|
919
|
+
plugins: this.manager.getServices(),
|
|
920
|
+
core: this.coreServices,
|
|
921
|
+
errors: this.coreServices.errors, // Convenience access
|
|
922
|
+
config: this.coreServices.config,
|
|
923
|
+
ip,
|
|
924
|
+
requestId: crypto.randomUUID(),
|
|
925
|
+
};
|
|
926
|
+
|
|
927
|
+
// Get middleware stack for this route
|
|
928
|
+
const middlewareStack = route.middleware || [];
|
|
929
|
+
|
|
930
|
+
// Final handler execution
|
|
931
|
+
const finalHandler = async () => {
|
|
932
|
+
return await handler.execute(req, route, route.handle as any, ctx);
|
|
933
|
+
};
|
|
934
|
+
|
|
935
|
+
// Execute middleware chain, then handler - with HttpError handling
|
|
936
|
+
try {
|
|
937
|
+
if (middlewareStack.length > 0) {
|
|
938
|
+
return await this.executeMiddlewareChain(req, ctx, middlewareStack, finalHandler);
|
|
939
|
+
} else {
|
|
940
|
+
return await finalHandler();
|
|
855
941
|
}
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
|
|
942
|
+
} catch (error) {
|
|
943
|
+
// Handle HttpError (thrown via ctx.errors.*)
|
|
944
|
+
if (error instanceof HttpError) {
|
|
945
|
+
logger.warn("HTTP error thrown", {
|
|
946
|
+
route: actionName,
|
|
947
|
+
status: error.status,
|
|
948
|
+
code: error.code,
|
|
949
|
+
message: error.message,
|
|
950
|
+
});
|
|
951
|
+
return Response.json(error.toJSON(), { status: error.status });
|
|
952
|
+
}
|
|
953
|
+
// Re-throw unknown errors
|
|
954
|
+
throw error;
|
|
859
955
|
}
|
|
956
|
+
} else {
|
|
957
|
+
logger.error("Handler not found", { handler: type, route: actionName });
|
|
958
|
+
return new Response("Handler Not Found", { status: 500 });
|
|
860
959
|
}
|
|
960
|
+
}
|
|
861
961
|
|
|
862
|
-
|
|
962
|
+
return new Response("Not Found", { status: 404 });
|
|
963
|
+
};
|
|
964
|
+
|
|
965
|
+
// Try to start server, retrying with different ports if port is in use
|
|
966
|
+
let currentPort = this.port;
|
|
967
|
+
let lastError: Error | null = null;
|
|
968
|
+
|
|
969
|
+
for (let attempt = 0; attempt < this.maxPortAttempts; attempt++) {
|
|
970
|
+
try {
|
|
971
|
+
Bun.serve({
|
|
972
|
+
port: currentPort,
|
|
973
|
+
fetch: fetchHandler,
|
|
974
|
+
});
|
|
975
|
+
// Update the actual port we're running on
|
|
976
|
+
this.port = currentPort;
|
|
977
|
+
logger.info(`Server running at http://localhost:${this.port}`);
|
|
978
|
+
return;
|
|
979
|
+
} catch (error) {
|
|
980
|
+
const isPortInUse =
|
|
981
|
+
error instanceof Error &&
|
|
982
|
+
(error.message.includes("EADDRINUSE") ||
|
|
983
|
+
error.message.includes("address already in use") ||
|
|
984
|
+
error.message.includes("port") && error.message.includes("in use"));
|
|
985
|
+
|
|
986
|
+
if (isPortInUse && attempt < this.maxPortAttempts - 1) {
|
|
987
|
+
logger.warn(`Port ${currentPort} is already in use, trying port ${currentPort + 1}...`);
|
|
988
|
+
currentPort++;
|
|
989
|
+
lastError = error as Error;
|
|
990
|
+
} else {
|
|
991
|
+
throw error;
|
|
992
|
+
}
|
|
863
993
|
}
|
|
864
|
-
}
|
|
994
|
+
}
|
|
865
995
|
|
|
866
|
-
|
|
996
|
+
// If we get here, all attempts failed
|
|
997
|
+
throw new Error(
|
|
998
|
+
`Failed to start server after ${this.maxPortAttempts} attempts. ` +
|
|
999
|
+
`Ports ${this.port}-${currentPort} are all in use. ` +
|
|
1000
|
+
`Last error: ${lastError?.message}`
|
|
1001
|
+
);
|
|
867
1002
|
}
|
|
868
1003
|
|
|
869
1004
|
/**
|