@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/server",
3
- "version": "1.1.7",
3
+ "version": "1.1.8",
4
4
  "type": "module",
5
5
  "description": "Type-safe plugin system for building RPC-style APIs with Bun",
6
6
  "main": "./src/index.ts",
@@ -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
  */
@@ -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 typeEntries = prefixRoutes
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 allMethods = [...methodEntries, ...rawMethodEntries];
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
- const outputType = r.outputSource ?? "void";
459
- return `${indent}export namespace ${routeNs} {
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;