@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/server",
3
- "version": "1.1.7",
3
+ "version": "1.1.9",
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
  */
@@ -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
 
@@ -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
@@ -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
- const outputType = r.outputSource ?? "void";
459
- return `${indent}export namespace ${routeNs} {
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;