@donkeylabs/server 1.1.4 → 1.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/server",
3
- "version": "1.1.4",
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
  */
package/src/core.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { sql, type Kysely } from "kysely";
2
- import { existsSync } from "node:fs";
3
2
  import { readdir } from "node:fs/promises";
4
3
  import { join, dirname } from "node:path";
5
4
  import { fileURLToPath } from "node:url";
@@ -484,9 +483,8 @@ export class PluginManager {
484
483
 
485
484
  for (const service of coreServices) {
486
485
  const migrationDir = join(coreMigrationsDir, service);
487
- if (existsSync(migrationDir)) {
488
- await this.runMigrationsForPlugin(`@core/${service}`, migrationDir);
489
- }
486
+ // runMigrationsForPlugin handles missing directories gracefully (ENOENT)
487
+ await this.runMigrationsForPlugin(`@core/${service}`, migrationDir);
490
488
  }
491
489
 
492
490
  console.log("Core migrations complete.");
@@ -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
@@ -55,7 +55,10 @@ export interface TypeGenerationConfig {
55
55
  }
56
56
 
57
57
  export interface ServerConfig {
58
+ /** Server port. Can also be set via PORT environment variable. Default: 3000 */
58
59
  port?: number;
60
+ /** Maximum port attempts if port is in use. Default: 5 */
61
+ maxPortAttempts?: number;
59
62
  db: CoreServices["db"];
60
63
  config?: Record<string, any>;
61
64
  /** Auto-generate client types on startup in dev mode */
@@ -83,6 +86,7 @@ export interface ServerConfig {
83
86
 
84
87
  export class AppServer {
85
88
  private port: number;
89
+ private maxPortAttempts: number;
86
90
  private manager: PluginManager;
87
91
  private routers: IRouter[] = [];
88
92
  private routeMap: Map<string, RouteDefinition<keyof HandlerRegistry>> = new Map();
@@ -90,7 +94,10 @@ export class AppServer {
90
94
  private typeGenConfig?: TypeGenerationConfig;
91
95
 
92
96
  constructor(options: ServerConfig) {
93
- this.port = options.port ?? 3000;
97
+ // Port priority: explicit config > PORT env var > default 3000
98
+ const envPort = process.env.PORT ? parseInt(process.env.PORT, 10) : undefined;
99
+ this.port = options.port ?? envPort ?? 3000;
100
+ this.maxPortAttempts = options.maxPortAttempts ?? 5;
94
101
 
95
102
  // Determine if we should use legacy databases
96
103
  const useLegacy = options.useLegacyCoreDatabases ?? false;
@@ -257,6 +264,46 @@ export class AppServer {
257
264
  return this.routeMap.has(routeName);
258
265
  }
259
266
 
267
+ /**
268
+ * Handle CLI type generation mode.
269
+ * Call this at the end of your server entry file after registering all routes.
270
+ * If DONKEYLABS_GENERATE=1 is set, outputs route metadata and exits.
271
+ * Otherwise, does nothing.
272
+ *
273
+ * @example
274
+ * ```ts
275
+ * server.use(routes);
276
+ * server.handleGenerateMode(); // Add this line at the end
277
+ * ```
278
+ */
279
+ handleGenerateMode(): void {
280
+ if (process.env.DONKEYLABS_GENERATE === "1") {
281
+ this.outputRoutesForGeneration();
282
+ process.exit(0);
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Output route metadata as JSON for CLI type generation.
288
+ * Called when DONKEYLABS_GENERATE=1 environment variable is set.
289
+ */
290
+ private outputRoutesForGeneration(): void {
291
+ const routes = [];
292
+
293
+ for (const router of this.routers) {
294
+ for (const route of router.getRoutes()) {
295
+ routes.push({
296
+ name: route.name,
297
+ handler: route.handler || "typed",
298
+ inputType: route.input ? zodSchemaToTs(route.input) : undefined,
299
+ outputType: route.output ? zodSchemaToTs(route.output) : undefined,
300
+ });
301
+ }
302
+ }
303
+
304
+ console.log(JSON.stringify({ routes }));
305
+ }
306
+
260
307
  /**
261
308
  * Generate client types from registered routes.
262
309
  * Called automatically on startup in dev mode if generateTypes config is provided.
@@ -277,9 +324,10 @@ export class AppServer {
277
324
  name: string;
278
325
  prefix: string;
279
326
  routeName: string;
280
- handler: "typed" | "raw";
327
+ handler: "typed" | "raw" | "sse" | "stream" | "formData" | "html";
281
328
  inputSource?: string;
282
329
  outputSource?: string;
330
+ eventsSource?: Record<string, string>;
283
331
  }> = [];
284
332
 
285
333
  const routesWithoutOutput: string[] = [];
@@ -295,13 +343,23 @@ export class AppServer {
295
343
  routesWithoutOutput.push(route.name);
296
344
  }
297
345
 
346
+ // Extract SSE event schemas
347
+ let eventsSource: Record<string, string> | undefined;
348
+ if (route.handler === "sse" && route.events) {
349
+ eventsSource = {};
350
+ for (const [eventName, eventSchema] of Object.entries(route.events)) {
351
+ eventsSource[eventName] = zodSchemaToTs(eventSchema as any);
352
+ }
353
+ }
354
+
298
355
  routes.push({
299
356
  name: route.name,
300
357
  prefix,
301
358
  routeName,
302
- handler: (route.handler || "typed") as "typed" | "raw",
359
+ handler: (route.handler || "typed") as "typed" | "raw" | "sse" | "stream" | "formData" | "html",
303
360
  inputSource: route.input ? zodSchemaToTs(route.input) : undefined,
304
361
  outputSource: route.output ? zodSchemaToTs(route.output) : undefined,
362
+ eventsSource,
305
363
  });
306
364
  }
307
365
  }
@@ -336,9 +394,10 @@ export class AppServer {
336
394
  name: string;
337
395
  prefix: string;
338
396
  routeName: string;
339
- handler: "typed" | "raw";
397
+ handler: "typed" | "raw" | "sse" | "stream" | "formData" | "html";
340
398
  inputSource?: string;
341
399
  outputSource?: string;
400
+ eventsSource?: Record<string, string>;
342
401
  }>
343
402
  ): string {
344
403
  const baseImport =
@@ -401,19 +460,43 @@ export function createApi(options?: ClientOptions) {
401
460
  // Recursive function to generate Type definitions
402
461
  function generateTypeBlock(node: RouteNode, indent: string): string {
403
462
  const blocks: string[] = [];
404
-
463
+
405
464
  // 1. Valid Input/Output types for routes at this level
406
465
  if (node.routes.length > 0) {
407
466
  const routeTypes = node.routes.map(r => {
408
- if (r.handler !== "typed") return "";
409
467
  const routeNs = toPascalCase(r.routeName);
410
468
  const inputType = r.inputSource ?? "Record<string, never>";
411
- const outputType = r.outputSource ?? "void";
412
- 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} {
413
474
  ${indent} export type Input = Expand<${inputType}>;
414
475
  ${indent} export type Output = Expand<${outputType}>;
415
476
  ${indent}}
416
477
  ${indent}export type ${routeNs} = { Input: ${routeNs}.Input; Output: ${routeNs}.Output };`;
478
+ } else if (r.handler === "stream" || r.handler === "html") {
479
+ // stream and html have Input only (returns Response/string)
480
+ return `${indent}export namespace ${routeNs} {
481
+ ${indent} export type Input = Expand<${inputType}>;
482
+ ${indent}}
483
+ ${indent}export type ${routeNs} = { Input: ${routeNs}.Input };`;
484
+ } else if (r.handler === "sse") {
485
+ // Generate Events type from eventsSource
486
+ const eventsEntries = r.eventsSource
487
+ ? Object.entries(r.eventsSource)
488
+ .map(([eventName, eventType]) => `${indent} "${eventName}": ${eventType};`)
489
+ .join("\n")
490
+ : "";
491
+ const eventsType = eventsEntries ? `{\n${eventsEntries}\n${indent} }` : "Record<string, unknown>";
492
+ return `${indent}export namespace ${routeNs} {
493
+ ${indent} export type Input = Expand<${inputType}>;
494
+ ${indent} export type Events = Expand<${eventsType}>;
495
+ ${indent}}
496
+ ${indent}export type ${routeNs} = { Input: ${routeNs}.Input; Events: ${routeNs}.Events };`;
497
+ }
498
+ // raw handler has no types
499
+ return "";
417
500
  }).filter(Boolean);
418
501
  if (routeTypes.length) blocks.push(routeTypes.join("\n\n"));
419
502
  }
@@ -434,15 +517,28 @@ ${indent}export type ${routeNs} = { Input: ${routeNs}.Input; Output: ${routeNs}.
434
517
  const methods = node.routes.map(r => {
435
518
  const methodName = toCamelCase(r.routeName);
436
519
  // r.name is the full path e.g. "api.v1.users.get"
437
-
520
+ const pathParts = r.name.split(".");
521
+ const typePath = ["Routes", ...pathParts.slice(0, -1).map(toPascalCase), toPascalCase(r.routeName)];
522
+
438
523
  if (r.handler === "typed") {
439
- const pathParts = r.name.split(".");
440
- const typePath = ["Routes", ...pathParts.slice(0, -1).map(toPascalCase), toPascalCase(r.routeName)];
441
524
  const inputType = typePath.join(".") + ".Input";
442
525
  const outputType = typePath.join(".") + ".Output";
443
-
444
526
  return `${indent}${methodName}: (input: ${inputType}): Promise<${outputType}> => this.request("${r.name}", input)`;
527
+ } else if (r.handler === "formData") {
528
+ const inputType = typePath.join(".") + ".Input";
529
+ const outputType = typePath.join(".") + ".Output";
530
+ // formData needs to send multipart form data
531
+ return `${indent}${methodName}: (fields: ${inputType}, files?: File[]): Promise<${outputType}> => this.uploadFormData("${r.name}", fields, files)`;
532
+ } else if (r.handler === "stream" || r.handler === "html") {
533
+ // stream and html have validated input but return Response
534
+ const inputType = typePath.join(".") + ".Input";
535
+ return `${indent}${methodName}: (input: ${inputType}): Promise<Response> => this.streamRequest("${r.name}", input)`;
536
+ } else if (r.handler === "sse") {
537
+ const inputType = typePath.join(".") + ".Input";
538
+ const eventsType = typePath.join(".") + ".Events";
539
+ return `${indent}${methodName}: (input: ${inputType}, options?: Omit<SSEOptions, "endpoint" | "channels">): SSESubscription<${eventsType}> => this.connectToSSERoute("${r.name}", input, options)`;
445
540
  } else {
541
+ // raw handler
446
542
  return `${indent}${methodName}: (init?: RequestInit): Promise<Response> => this.rawRequest("${r.name}", init)`;
447
543
  }
448
544
  });
@@ -468,10 +564,16 @@ ${indent}export type ${routeNs} = { Input: ${routeNs}.Input; Output: ${routeNs}.
468
564
  // rootNode children are top-level namespaces (api, health) -> Top Level Class Properties
469
565
  const methodBlocks: string[] = [generateMethodBlock(rootNode, " ", "", true)];
470
566
 
567
+ // Check if we have any SSE routes to know if we need SSE type imports
568
+ const hasSSERoutes = routes.some(r => r.handler === "sse");
569
+ const sseImports = hasSSERoutes
570
+ ? '\nimport { type SSEOptions, type SSESubscription } from "@donkeylabs/server/client";'
571
+ : "";
572
+
471
573
  return `// Auto-generated by @donkeylabs/server
472
574
  // DO NOT EDIT MANUALLY
473
575
 
474
- ${baseImport}
576
+ ${baseImport}${sseImports}
475
577
 
476
578
  // Utility type that forces TypeScript to expand types on hover
477
579
  type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
@@ -766,104 +868,137 @@ ${factoryFunction}
766
868
  }
767
869
  logger.info(`Loaded ${this.routeMap.size} RPC routes`);
768
870
 
769
- // 5. Start HTTP server
770
- Bun.serve({
771
- port: this.port,
772
- fetch: async (req, server) => {
773
- const url = new URL(req.url);
871
+ // 5. Start HTTP server with port retry logic
872
+ const fetchHandler = async (req: Request, server: ReturnType<typeof Bun.serve>) => {
873
+ const url = new URL(req.url);
774
874
 
775
- // Extract client IP
776
- const ip = extractClientIP(req, server.requestIP(req)?.address);
875
+ // Extract client IP
876
+ const ip = extractClientIP(req, server.requestIP(req)?.address);
777
877
 
778
- // Handle SSE endpoint
779
- if (url.pathname === "/sse" && req.method === "GET") {
780
- return this.handleSSE(req, ip);
781
- }
878
+ // Handle SSE endpoint
879
+ if (url.pathname === "/sse" && req.method === "GET") {
880
+ return this.handleSSE(req, ip);
881
+ }
782
882
 
783
- // Extract action from URL path (e.g., "auth.login")
784
- const actionName = url.pathname.slice(1);
883
+ // Extract action from URL path (e.g., "auth.login")
884
+ const actionName = url.pathname.slice(1);
785
885
 
786
- const route = this.routeMap.get(actionName);
787
- if (route) {
788
- const handlerType = route.handler || "typed";
886
+ const route = this.routeMap.get(actionName);
887
+ if (route) {
888
+ const handlerType = route.handler || "typed";
789
889
 
790
- // Handlers that accept GET requests (for browser compatibility)
791
- const getEnabledHandlers = ["stream", "sse", "html", "raw"];
890
+ // Handlers that accept GET requests (for browser compatibility)
891
+ const getEnabledHandlers = ["stream", "sse", "html", "raw"];
792
892
 
793
- // Check method based on handler type
794
- if (req.method === "GET" && !getEnabledHandlers.includes(handlerType)) {
795
- return new Response("Method Not Allowed", { status: 405 });
796
- }
797
- if (req.method !== "GET" && req.method !== "POST") {
798
- return new Response("Method Not Allowed", { status: 405 });
799
- }
800
- const type = route.handler || "typed";
801
-
802
- // First check core handlers
803
- let handler = Handlers[type as keyof typeof Handlers];
804
-
805
- // If not found, check plugin handlers
806
- if (!handler) {
807
- for (const config of this.manager.getPlugins()) {
808
- if (config.handlers && config.handlers[type]) {
809
- handler = config.handlers[type] as any;
810
- break;
811
- }
893
+ // Check method based on handler type
894
+ if (req.method === "GET" && !getEnabledHandlers.includes(handlerType)) {
895
+ return new Response("Method Not Allowed", { status: 405 });
896
+ }
897
+ if (req.method !== "GET" && req.method !== "POST") {
898
+ return new Response("Method Not Allowed", { status: 405 });
899
+ }
900
+ const type = route.handler || "typed";
901
+
902
+ // First check core handlers
903
+ let handler = Handlers[type as keyof typeof Handlers];
904
+
905
+ // If not found, check plugin handlers
906
+ if (!handler) {
907
+ for (const config of this.manager.getPlugins()) {
908
+ if (config.handlers && config.handlers[type]) {
909
+ handler = config.handlers[type] as any;
910
+ break;
812
911
  }
813
912
  }
913
+ }
814
914
 
815
- if (handler) {
816
- // Build context with core services and IP
817
- const ctx: ServerContext = {
818
- db: this.coreServices.db,
819
- plugins: this.manager.getServices(),
820
- core: this.coreServices,
821
- errors: this.coreServices.errors, // Convenience access
822
- config: this.coreServices.config,
823
- ip,
824
- requestId: crypto.randomUUID(),
825
- };
826
-
827
- // Get middleware stack for this route
828
- const middlewareStack = route.middleware || [];
829
-
830
- // Final handler execution
831
- const finalHandler = async () => {
832
- return await handler.execute(req, route, route.handle as any, ctx);
833
- };
834
-
835
- // Execute middleware chain, then handler - with HttpError handling
836
- try {
837
- if (middlewareStack.length > 0) {
838
- return await this.executeMiddlewareChain(req, ctx, middlewareStack, finalHandler);
839
- } else {
840
- return await finalHandler();
841
- }
842
- } catch (error) {
843
- // Handle HttpError (thrown via ctx.errors.*)
844
- if (error instanceof HttpError) {
845
- logger.warn("HTTP error thrown", {
846
- route: actionName,
847
- status: error.status,
848
- code: error.code,
849
- message: error.message,
850
- });
851
- return Response.json(error.toJSON(), { status: error.status });
852
- }
853
- // Re-throw unknown errors
854
- throw error;
915
+ if (handler) {
916
+ // Build context with core services and IP
917
+ const ctx: ServerContext = {
918
+ db: this.coreServices.db,
919
+ plugins: this.manager.getServices(),
920
+ core: this.coreServices,
921
+ errors: this.coreServices.errors, // Convenience access
922
+ config: this.coreServices.config,
923
+ ip,
924
+ requestId: crypto.randomUUID(),
925
+ };
926
+
927
+ // Get middleware stack for this route
928
+ const middlewareStack = route.middleware || [];
929
+
930
+ // Final handler execution
931
+ const finalHandler = async () => {
932
+ return await handler.execute(req, route, route.handle as any, ctx);
933
+ };
934
+
935
+ // Execute middleware chain, then handler - with HttpError handling
936
+ try {
937
+ if (middlewareStack.length > 0) {
938
+ return await this.executeMiddlewareChain(req, ctx, middlewareStack, finalHandler);
939
+ } else {
940
+ return await finalHandler();
855
941
  }
856
- } else {
857
- logger.error("Handler not found", { handler: type, route: actionName });
858
- return new Response("Handler Not Found", { status: 500 });
942
+ } catch (error) {
943
+ // Handle HttpError (thrown via ctx.errors.*)
944
+ if (error instanceof HttpError) {
945
+ logger.warn("HTTP error thrown", {
946
+ route: actionName,
947
+ status: error.status,
948
+ code: error.code,
949
+ message: error.message,
950
+ });
951
+ return Response.json(error.toJSON(), { status: error.status });
952
+ }
953
+ // Re-throw unknown errors
954
+ throw error;
859
955
  }
956
+ } else {
957
+ logger.error("Handler not found", { handler: type, route: actionName });
958
+ return new Response("Handler Not Found", { status: 500 });
860
959
  }
960
+ }
861
961
 
862
- return new Response("Not Found", { status: 404 });
962
+ return new Response("Not Found", { status: 404 });
963
+ };
964
+
965
+ // Try to start server, retrying with different ports if port is in use
966
+ let currentPort = this.port;
967
+ let lastError: Error | null = null;
968
+
969
+ for (let attempt = 0; attempt < this.maxPortAttempts; attempt++) {
970
+ try {
971
+ Bun.serve({
972
+ port: currentPort,
973
+ fetch: fetchHandler,
974
+ });
975
+ // Update the actual port we're running on
976
+ this.port = currentPort;
977
+ logger.info(`Server running at http://localhost:${this.port}`);
978
+ return;
979
+ } catch (error) {
980
+ const isPortInUse =
981
+ error instanceof Error &&
982
+ (error.message.includes("EADDRINUSE") ||
983
+ error.message.includes("address already in use") ||
984
+ error.message.includes("port") && error.message.includes("in use"));
985
+
986
+ if (isPortInUse && attempt < this.maxPortAttempts - 1) {
987
+ logger.warn(`Port ${currentPort} is already in use, trying port ${currentPort + 1}...`);
988
+ currentPort++;
989
+ lastError = error as Error;
990
+ } else {
991
+ throw error;
992
+ }
863
993
  }
864
- });
994
+ }
865
995
 
866
- logger.info(`Server running at http://localhost:${this.port}`);
996
+ // If we get here, all attempts failed
997
+ throw new Error(
998
+ `Failed to start server after ${this.maxPortAttempts} attempts. ` +
999
+ `Ports ${this.port}-${currentPort} are all in use. ` +
1000
+ `Last error: ${lastError?.message}`
1001
+ );
867
1002
  }
868
1003
 
869
1004
  /**