@donkeylabs/server 1.1.7 → 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/generator/index.ts +79 -3
- package/src/server.ts +67 -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/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
|
@@ -324,9 +324,10 @@ export class AppServer {
|
|
|
324
324
|
name: string;
|
|
325
325
|
prefix: string;
|
|
326
326
|
routeName: string;
|
|
327
|
-
handler: "typed" | "raw";
|
|
327
|
+
handler: "typed" | "raw" | "sse" | "stream" | "formData" | "html";
|
|
328
328
|
inputSource?: string;
|
|
329
329
|
outputSource?: string;
|
|
330
|
+
eventsSource?: Record<string, string>;
|
|
330
331
|
}> = [];
|
|
331
332
|
|
|
332
333
|
const routesWithoutOutput: string[] = [];
|
|
@@ -342,13 +343,23 @@ export class AppServer {
|
|
|
342
343
|
routesWithoutOutput.push(route.name);
|
|
343
344
|
}
|
|
344
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
|
+
|
|
345
355
|
routes.push({
|
|
346
356
|
name: route.name,
|
|
347
357
|
prefix,
|
|
348
358
|
routeName,
|
|
349
|
-
handler: (route.handler || "typed") as "typed" | "raw",
|
|
359
|
+
handler: (route.handler || "typed") as "typed" | "raw" | "sse" | "stream" | "formData" | "html",
|
|
350
360
|
inputSource: route.input ? zodSchemaToTs(route.input) : undefined,
|
|
351
361
|
outputSource: route.output ? zodSchemaToTs(route.output) : undefined,
|
|
362
|
+
eventsSource,
|
|
352
363
|
});
|
|
353
364
|
}
|
|
354
365
|
}
|
|
@@ -383,9 +394,10 @@ export class AppServer {
|
|
|
383
394
|
name: string;
|
|
384
395
|
prefix: string;
|
|
385
396
|
routeName: string;
|
|
386
|
-
handler: "typed" | "raw";
|
|
397
|
+
handler: "typed" | "raw" | "sse" | "stream" | "formData" | "html";
|
|
387
398
|
inputSource?: string;
|
|
388
399
|
outputSource?: string;
|
|
400
|
+
eventsSource?: Record<string, string>;
|
|
389
401
|
}>
|
|
390
402
|
): string {
|
|
391
403
|
const baseImport =
|
|
@@ -448,19 +460,43 @@ export function createApi(options?: ClientOptions) {
|
|
|
448
460
|
// Recursive function to generate Type definitions
|
|
449
461
|
function generateTypeBlock(node: RouteNode, indent: string): string {
|
|
450
462
|
const blocks: string[] = [];
|
|
451
|
-
|
|
463
|
+
|
|
452
464
|
// 1. Valid Input/Output types for routes at this level
|
|
453
465
|
if (node.routes.length > 0) {
|
|
454
466
|
const routeTypes = node.routes.map(r => {
|
|
455
|
-
if (r.handler !== "typed") return "";
|
|
456
467
|
const routeNs = toPascalCase(r.routeName);
|
|
457
468
|
const inputType = r.inputSource ?? "Record<string, never>";
|
|
458
|
-
|
|
459
|
-
|
|
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} {
|
|
460
474
|
${indent} export type Input = Expand<${inputType}>;
|
|
461
475
|
${indent} export type Output = Expand<${outputType}>;
|
|
462
476
|
${indent}}
|
|
463
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 "";
|
|
464
500
|
}).filter(Boolean);
|
|
465
501
|
if (routeTypes.length) blocks.push(routeTypes.join("\n\n"));
|
|
466
502
|
}
|
|
@@ -481,15 +517,28 @@ ${indent}export type ${routeNs} = { Input: ${routeNs}.Input; Output: ${routeNs}.
|
|
|
481
517
|
const methods = node.routes.map(r => {
|
|
482
518
|
const methodName = toCamelCase(r.routeName);
|
|
483
519
|
// r.name is the full path e.g. "api.v1.users.get"
|
|
484
|
-
|
|
520
|
+
const pathParts = r.name.split(".");
|
|
521
|
+
const typePath = ["Routes", ...pathParts.slice(0, -1).map(toPascalCase), toPascalCase(r.routeName)];
|
|
522
|
+
|
|
485
523
|
if (r.handler === "typed") {
|
|
486
|
-
const pathParts = r.name.split(".");
|
|
487
|
-
const typePath = ["Routes", ...pathParts.slice(0, -1).map(toPascalCase), toPascalCase(r.routeName)];
|
|
488
524
|
const inputType = typePath.join(".") + ".Input";
|
|
489
525
|
const outputType = typePath.join(".") + ".Output";
|
|
490
|
-
|
|
491
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)`;
|
|
492
540
|
} else {
|
|
541
|
+
// raw handler
|
|
493
542
|
return `${indent}${methodName}: (init?: RequestInit): Promise<Response> => this.rawRequest("${r.name}", init)`;
|
|
494
543
|
}
|
|
495
544
|
});
|
|
@@ -515,10 +564,16 @@ ${indent}export type ${routeNs} = { Input: ${routeNs}.Input; Output: ${routeNs}.
|
|
|
515
564
|
// rootNode children are top-level namespaces (api, health) -> Top Level Class Properties
|
|
516
565
|
const methodBlocks: string[] = [generateMethodBlock(rootNode, " ", "", true)];
|
|
517
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
|
+
|
|
518
573
|
return `// Auto-generated by @donkeylabs/server
|
|
519
574
|
// DO NOT EDIT MANUALLY
|
|
520
575
|
|
|
521
|
-
${baseImport}
|
|
576
|
+
${baseImport}${sseImports}
|
|
522
577
|
|
|
523
578
|
// Utility type that forces TypeScript to expand types on hover
|
|
524
579
|
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
|