@donkeylabs/server 1.1.7 → 1.1.9
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/workflows.ts +14 -0
- package/src/generator/index.ts +79 -3
- package/src/server.ts +70 -12
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/workflows.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { Events } from "./events";
|
|
|
11
11
|
import type { Jobs } from "./jobs";
|
|
12
12
|
import type { SSE } from "./sse";
|
|
13
13
|
import type { z } from "zod";
|
|
14
|
+
import type { CoreServices } from "../core";
|
|
14
15
|
|
|
15
16
|
// Type helper for Zod schema inference
|
|
16
17
|
type ZodSchema = z.ZodTypeAny;
|
|
@@ -192,6 +193,8 @@ export interface WorkflowContext {
|
|
|
192
193
|
instance: WorkflowInstance;
|
|
193
194
|
/** Get a step result with type safety */
|
|
194
195
|
getStepResult<T = any>(stepName: string): T | undefined;
|
|
196
|
+
/** Core services (logger, events, cache, etc.) */
|
|
197
|
+
core: CoreServices;
|
|
195
198
|
}
|
|
196
199
|
|
|
197
200
|
// ============================================
|
|
@@ -501,6 +504,8 @@ export interface WorkflowsConfig {
|
|
|
501
504
|
sse?: SSE;
|
|
502
505
|
/** Poll interval for checking job completion (ms) */
|
|
503
506
|
pollInterval?: number;
|
|
507
|
+
/** Core services to pass to step handlers */
|
|
508
|
+
core?: CoreServices;
|
|
504
509
|
}
|
|
505
510
|
|
|
506
511
|
export interface Workflows {
|
|
@@ -518,6 +523,8 @@ export interface Workflows {
|
|
|
518
523
|
resume(): Promise<void>;
|
|
519
524
|
/** Stop the workflow service */
|
|
520
525
|
stop(): Promise<void>;
|
|
526
|
+
/** Set core services (called after initialization to resolve circular dependency) */
|
|
527
|
+
setCore(core: CoreServices): void;
|
|
521
528
|
}
|
|
522
529
|
|
|
523
530
|
// ============================================
|
|
@@ -529,6 +536,7 @@ class WorkflowsImpl implements Workflows {
|
|
|
529
536
|
private events?: Events;
|
|
530
537
|
private jobs?: Jobs;
|
|
531
538
|
private sse?: SSE;
|
|
539
|
+
private core?: CoreServices;
|
|
532
540
|
private definitions = new Map<string, WorkflowDefinition>();
|
|
533
541
|
private running = new Map<string, { timeout?: ReturnType<typeof setTimeout> }>();
|
|
534
542
|
private pollInterval: number;
|
|
@@ -538,9 +546,14 @@ class WorkflowsImpl implements Workflows {
|
|
|
538
546
|
this.events = config.events;
|
|
539
547
|
this.jobs = config.jobs;
|
|
540
548
|
this.sse = config.sse;
|
|
549
|
+
this.core = config.core;
|
|
541
550
|
this.pollInterval = config.pollInterval ?? 1000;
|
|
542
551
|
}
|
|
543
552
|
|
|
553
|
+
setCore(core: CoreServices): void {
|
|
554
|
+
this.core = core;
|
|
555
|
+
}
|
|
556
|
+
|
|
544
557
|
register(definition: WorkflowDefinition): void {
|
|
545
558
|
if (this.definitions.has(definition.name)) {
|
|
546
559
|
throw new Error(`Workflow "${definition.name}" is already registered`);
|
|
@@ -1099,6 +1112,7 @@ class WorkflowsImpl implements Workflows {
|
|
|
1099
1112
|
getStepResult: <T = any>(stepName: string): T | undefined => {
|
|
1100
1113
|
return steps[stepName] as T | undefined;
|
|
1101
1114
|
},
|
|
1115
|
+
core: this.core!,
|
|
1102
1116
|
};
|
|
1103
1117
|
}
|
|
1104
1118
|
|
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
|
@@ -166,6 +166,9 @@ export class AppServer {
|
|
|
166
166
|
websocket,
|
|
167
167
|
};
|
|
168
168
|
|
|
169
|
+
// Resolve circular dependency: workflows needs core for step handlers
|
|
170
|
+
workflows.setCore(this.coreServices);
|
|
171
|
+
|
|
169
172
|
this.manager = new PluginManager(this.coreServices);
|
|
170
173
|
this.typeGenConfig = options.generateTypes;
|
|
171
174
|
}
|
|
@@ -324,9 +327,10 @@ export class AppServer {
|
|
|
324
327
|
name: string;
|
|
325
328
|
prefix: string;
|
|
326
329
|
routeName: string;
|
|
327
|
-
handler: "typed" | "raw";
|
|
330
|
+
handler: "typed" | "raw" | "sse" | "stream" | "formData" | "html";
|
|
328
331
|
inputSource?: string;
|
|
329
332
|
outputSource?: string;
|
|
333
|
+
eventsSource?: Record<string, string>;
|
|
330
334
|
}> = [];
|
|
331
335
|
|
|
332
336
|
const routesWithoutOutput: string[] = [];
|
|
@@ -342,13 +346,23 @@ export class AppServer {
|
|
|
342
346
|
routesWithoutOutput.push(route.name);
|
|
343
347
|
}
|
|
344
348
|
|
|
349
|
+
// Extract SSE event schemas
|
|
350
|
+
let eventsSource: Record<string, string> | undefined;
|
|
351
|
+
if (route.handler === "sse" && route.events) {
|
|
352
|
+
eventsSource = {};
|
|
353
|
+
for (const [eventName, eventSchema] of Object.entries(route.events)) {
|
|
354
|
+
eventsSource[eventName] = zodSchemaToTs(eventSchema as any);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
345
358
|
routes.push({
|
|
346
359
|
name: route.name,
|
|
347
360
|
prefix,
|
|
348
361
|
routeName,
|
|
349
|
-
handler: (route.handler || "typed") as "typed" | "raw",
|
|
362
|
+
handler: (route.handler || "typed") as "typed" | "raw" | "sse" | "stream" | "formData" | "html",
|
|
350
363
|
inputSource: route.input ? zodSchemaToTs(route.input) : undefined,
|
|
351
364
|
outputSource: route.output ? zodSchemaToTs(route.output) : undefined,
|
|
365
|
+
eventsSource,
|
|
352
366
|
});
|
|
353
367
|
}
|
|
354
368
|
}
|
|
@@ -383,9 +397,10 @@ export class AppServer {
|
|
|
383
397
|
name: string;
|
|
384
398
|
prefix: string;
|
|
385
399
|
routeName: string;
|
|
386
|
-
handler: "typed" | "raw";
|
|
400
|
+
handler: "typed" | "raw" | "sse" | "stream" | "formData" | "html";
|
|
387
401
|
inputSource?: string;
|
|
388
402
|
outputSource?: string;
|
|
403
|
+
eventsSource?: Record<string, string>;
|
|
389
404
|
}>
|
|
390
405
|
): string {
|
|
391
406
|
const baseImport =
|
|
@@ -448,19 +463,43 @@ export function createApi(options?: ClientOptions) {
|
|
|
448
463
|
// Recursive function to generate Type definitions
|
|
449
464
|
function generateTypeBlock(node: RouteNode, indent: string): string {
|
|
450
465
|
const blocks: string[] = [];
|
|
451
|
-
|
|
466
|
+
|
|
452
467
|
// 1. Valid Input/Output types for routes at this level
|
|
453
468
|
if (node.routes.length > 0) {
|
|
454
469
|
const routeTypes = node.routes.map(r => {
|
|
455
|
-
if (r.handler !== "typed") return "";
|
|
456
470
|
const routeNs = toPascalCase(r.routeName);
|
|
457
471
|
const inputType = r.inputSource ?? "Record<string, never>";
|
|
458
|
-
|
|
459
|
-
|
|
472
|
+
|
|
473
|
+
if (r.handler === "typed" || r.handler === "formData") {
|
|
474
|
+
// typed and formData have Input and Output
|
|
475
|
+
const outputType = r.outputSource ?? "void";
|
|
476
|
+
return `${indent}export namespace ${routeNs} {
|
|
460
477
|
${indent} export type Input = Expand<${inputType}>;
|
|
461
478
|
${indent} export type Output = Expand<${outputType}>;
|
|
462
479
|
${indent}}
|
|
463
480
|
${indent}export type ${routeNs} = { Input: ${routeNs}.Input; Output: ${routeNs}.Output };`;
|
|
481
|
+
} else if (r.handler === "stream" || r.handler === "html") {
|
|
482
|
+
// stream and html have Input only (returns Response/string)
|
|
483
|
+
return `${indent}export namespace ${routeNs} {
|
|
484
|
+
${indent} export type Input = Expand<${inputType}>;
|
|
485
|
+
${indent}}
|
|
486
|
+
${indent}export type ${routeNs} = { Input: ${routeNs}.Input };`;
|
|
487
|
+
} else if (r.handler === "sse") {
|
|
488
|
+
// Generate Events type from eventsSource
|
|
489
|
+
const eventsEntries = r.eventsSource
|
|
490
|
+
? Object.entries(r.eventsSource)
|
|
491
|
+
.map(([eventName, eventType]) => `${indent} "${eventName}": ${eventType};`)
|
|
492
|
+
.join("\n")
|
|
493
|
+
: "";
|
|
494
|
+
const eventsType = eventsEntries ? `{\n${eventsEntries}\n${indent} }` : "Record<string, unknown>";
|
|
495
|
+
return `${indent}export namespace ${routeNs} {
|
|
496
|
+
${indent} export type Input = Expand<${inputType}>;
|
|
497
|
+
${indent} export type Events = Expand<${eventsType}>;
|
|
498
|
+
${indent}}
|
|
499
|
+
${indent}export type ${routeNs} = { Input: ${routeNs}.Input; Events: ${routeNs}.Events };`;
|
|
500
|
+
}
|
|
501
|
+
// raw handler has no types
|
|
502
|
+
return "";
|
|
464
503
|
}).filter(Boolean);
|
|
465
504
|
if (routeTypes.length) blocks.push(routeTypes.join("\n\n"));
|
|
466
505
|
}
|
|
@@ -481,15 +520,28 @@ ${indent}export type ${routeNs} = { Input: ${routeNs}.Input; Output: ${routeNs}.
|
|
|
481
520
|
const methods = node.routes.map(r => {
|
|
482
521
|
const methodName = toCamelCase(r.routeName);
|
|
483
522
|
// r.name is the full path e.g. "api.v1.users.get"
|
|
484
|
-
|
|
523
|
+
const pathParts = r.name.split(".");
|
|
524
|
+
const typePath = ["Routes", ...pathParts.slice(0, -1).map(toPascalCase), toPascalCase(r.routeName)];
|
|
525
|
+
|
|
485
526
|
if (r.handler === "typed") {
|
|
486
|
-
const pathParts = r.name.split(".");
|
|
487
|
-
const typePath = ["Routes", ...pathParts.slice(0, -1).map(toPascalCase), toPascalCase(r.routeName)];
|
|
488
527
|
const inputType = typePath.join(".") + ".Input";
|
|
489
528
|
const outputType = typePath.join(".") + ".Output";
|
|
490
|
-
|
|
491
529
|
return `${indent}${methodName}: (input: ${inputType}): Promise<${outputType}> => this.request("${r.name}", input)`;
|
|
530
|
+
} else if (r.handler === "formData") {
|
|
531
|
+
const inputType = typePath.join(".") + ".Input";
|
|
532
|
+
const outputType = typePath.join(".") + ".Output";
|
|
533
|
+
// formData needs to send multipart form data
|
|
534
|
+
return `${indent}${methodName}: (fields: ${inputType}, files?: File[]): Promise<${outputType}> => this.uploadFormData("${r.name}", fields, files)`;
|
|
535
|
+
} else if (r.handler === "stream" || r.handler === "html") {
|
|
536
|
+
// stream and html have validated input but return Response
|
|
537
|
+
const inputType = typePath.join(".") + ".Input";
|
|
538
|
+
return `${indent}${methodName}: (input: ${inputType}): Promise<Response> => this.streamRequest("${r.name}", input)`;
|
|
539
|
+
} else if (r.handler === "sse") {
|
|
540
|
+
const inputType = typePath.join(".") + ".Input";
|
|
541
|
+
const eventsType = typePath.join(".") + ".Events";
|
|
542
|
+
return `${indent}${methodName}: (input: ${inputType}, options?: Omit<SSEOptions, "endpoint" | "channels">): SSESubscription<${eventsType}> => this.connectToSSERoute("${r.name}", input, options)`;
|
|
492
543
|
} else {
|
|
544
|
+
// raw handler
|
|
493
545
|
return `${indent}${methodName}: (init?: RequestInit): Promise<Response> => this.rawRequest("${r.name}", init)`;
|
|
494
546
|
}
|
|
495
547
|
});
|
|
@@ -515,10 +567,16 @@ ${indent}export type ${routeNs} = { Input: ${routeNs}.Input; Output: ${routeNs}.
|
|
|
515
567
|
// rootNode children are top-level namespaces (api, health) -> Top Level Class Properties
|
|
516
568
|
const methodBlocks: string[] = [generateMethodBlock(rootNode, " ", "", true)];
|
|
517
569
|
|
|
570
|
+
// Check if we have any SSE routes to know if we need SSE type imports
|
|
571
|
+
const hasSSERoutes = routes.some(r => r.handler === "sse");
|
|
572
|
+
const sseImports = hasSSERoutes
|
|
573
|
+
? '\nimport { type SSEOptions, type SSESubscription } from "@donkeylabs/server/client";'
|
|
574
|
+
: "";
|
|
575
|
+
|
|
518
576
|
return `// Auto-generated by @donkeylabs/server
|
|
519
577
|
// DO NOT EDIT MANUALLY
|
|
520
578
|
|
|
521
|
-
${baseImport}
|
|
579
|
+
${baseImport}${sseImports}
|
|
522
580
|
|
|
523
581
|
// Utility type that forces TypeScript to expand types on hover
|
|
524
582
|
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
|