@donkeylabs/server 1.1.3 → 1.1.7

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.3",
3
+ "version": "1.1.7",
4
4
  "type": "module",
5
5
  "description": "Type-safe plugin system for building RPC-style APIs with Bun",
6
6
  "main": "./src/index.ts",
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.");
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.
@@ -766,104 +813,137 @@ ${factoryFunction}
766
813
  }
767
814
  logger.info(`Loaded ${this.routeMap.size} RPC routes`);
768
815
 
769
- // 5. Start HTTP server
770
- Bun.serve({
771
- port: this.port,
772
- fetch: async (req, server) => {
773
- const url = new URL(req.url);
816
+ // 5. Start HTTP server with port retry logic
817
+ const fetchHandler = async (req: Request, server: ReturnType<typeof Bun.serve>) => {
818
+ const url = new URL(req.url);
774
819
 
775
- // Extract client IP
776
- const ip = extractClientIP(req, server.requestIP(req)?.address);
820
+ // Extract client IP
821
+ const ip = extractClientIP(req, server.requestIP(req)?.address);
777
822
 
778
- // Handle SSE endpoint
779
- if (url.pathname === "/sse" && req.method === "GET") {
780
- return this.handleSSE(req, ip);
781
- }
823
+ // Handle SSE endpoint
824
+ if (url.pathname === "/sse" && req.method === "GET") {
825
+ return this.handleSSE(req, ip);
826
+ }
782
827
 
783
- // Extract action from URL path (e.g., "auth.login")
784
- const actionName = url.pathname.slice(1);
828
+ // Extract action from URL path (e.g., "auth.login")
829
+ const actionName = url.pathname.slice(1);
785
830
 
786
- const route = this.routeMap.get(actionName);
787
- if (route) {
788
- const handlerType = route.handler || "typed";
831
+ const route = this.routeMap.get(actionName);
832
+ if (route) {
833
+ const handlerType = route.handler || "typed";
789
834
 
790
- // Handlers that accept GET requests (for browser compatibility)
791
- const getEnabledHandlers = ["stream", "sse", "html", "raw"];
835
+ // Handlers that accept GET requests (for browser compatibility)
836
+ const getEnabledHandlers = ["stream", "sse", "html", "raw"];
792
837
 
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
- }
838
+ // Check method based on handler type
839
+ if (req.method === "GET" && !getEnabledHandlers.includes(handlerType)) {
840
+ return new Response("Method Not Allowed", { status: 405 });
841
+ }
842
+ if (req.method !== "GET" && req.method !== "POST") {
843
+ return new Response("Method Not Allowed", { status: 405 });
844
+ }
845
+ const type = route.handler || "typed";
846
+
847
+ // First check core handlers
848
+ let handler = Handlers[type as keyof typeof Handlers];
849
+
850
+ // If not found, check plugin handlers
851
+ if (!handler) {
852
+ for (const config of this.manager.getPlugins()) {
853
+ if (config.handlers && config.handlers[type]) {
854
+ handler = config.handlers[type] as any;
855
+ break;
812
856
  }
813
857
  }
858
+ }
814
859
 
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;
860
+ if (handler) {
861
+ // Build context with core services and IP
862
+ const ctx: ServerContext = {
863
+ db: this.coreServices.db,
864
+ plugins: this.manager.getServices(),
865
+ core: this.coreServices,
866
+ errors: this.coreServices.errors, // Convenience access
867
+ config: this.coreServices.config,
868
+ ip,
869
+ requestId: crypto.randomUUID(),
870
+ };
871
+
872
+ // Get middleware stack for this route
873
+ const middlewareStack = route.middleware || [];
874
+
875
+ // Final handler execution
876
+ const finalHandler = async () => {
877
+ return await handler.execute(req, route, route.handle as any, ctx);
878
+ };
879
+
880
+ // Execute middleware chain, then handler - with HttpError handling
881
+ try {
882
+ if (middlewareStack.length > 0) {
883
+ return await this.executeMiddlewareChain(req, ctx, middlewareStack, finalHandler);
884
+ } else {
885
+ return await finalHandler();
855
886
  }
856
- } else {
857
- logger.error("Handler not found", { handler: type, route: actionName });
858
- return new Response("Handler Not Found", { status: 500 });
887
+ } catch (error) {
888
+ // Handle HttpError (thrown via ctx.errors.*)
889
+ if (error instanceof HttpError) {
890
+ logger.warn("HTTP error thrown", {
891
+ route: actionName,
892
+ status: error.status,
893
+ code: error.code,
894
+ message: error.message,
895
+ });
896
+ return Response.json(error.toJSON(), { status: error.status });
897
+ }
898
+ // Re-throw unknown errors
899
+ throw error;
859
900
  }
901
+ } else {
902
+ logger.error("Handler not found", { handler: type, route: actionName });
903
+ return new Response("Handler Not Found", { status: 500 });
860
904
  }
905
+ }
906
+
907
+ return new Response("Not Found", { status: 404 });
908
+ };
909
+
910
+ // Try to start server, retrying with different ports if port is in use
911
+ let currentPort = this.port;
912
+ let lastError: Error | null = null;
861
913
 
862
- return new Response("Not Found", { status: 404 });
914
+ for (let attempt = 0; attempt < this.maxPortAttempts; attempt++) {
915
+ try {
916
+ Bun.serve({
917
+ port: currentPort,
918
+ fetch: fetchHandler,
919
+ });
920
+ // Update the actual port we're running on
921
+ this.port = currentPort;
922
+ logger.info(`Server running at http://localhost:${this.port}`);
923
+ return;
924
+ } catch (error) {
925
+ const isPortInUse =
926
+ error instanceof Error &&
927
+ (error.message.includes("EADDRINUSE") ||
928
+ error.message.includes("address already in use") ||
929
+ error.message.includes("port") && error.message.includes("in use"));
930
+
931
+ if (isPortInUse && attempt < this.maxPortAttempts - 1) {
932
+ logger.warn(`Port ${currentPort} is already in use, trying port ${currentPort + 1}...`);
933
+ currentPort++;
934
+ lastError = error as Error;
935
+ } else {
936
+ throw error;
937
+ }
863
938
  }
864
- });
939
+ }
865
940
 
866
- logger.info(`Server running at http://localhost:${this.port}`);
941
+ // If we get here, all attempts failed
942
+ throw new Error(
943
+ `Failed to start server after ${this.maxPortAttempts} attempts. ` +
944
+ `Ports ${this.port}-${currentPort} are all in use. ` +
945
+ `Last error: ${lastError?.message}`
946
+ );
867
947
  }
868
948
 
869
949
  /**