@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 +1 -1
- package/src/core.ts +2 -4
- package/src/server.ts +164 -84
package/package.json
CHANGED
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
|
-
|
|
488
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
776
|
-
|
|
820
|
+
// Extract client IP
|
|
821
|
+
const ip = extractClientIP(req, server.requestIP(req)?.address);
|
|
777
822
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
823
|
+
// Handle SSE endpoint
|
|
824
|
+
if (url.pathname === "/sse" && req.method === "GET") {
|
|
825
|
+
return this.handleSSE(req, ip);
|
|
826
|
+
}
|
|
782
827
|
|
|
783
|
-
|
|
784
|
-
|
|
828
|
+
// Extract action from URL path (e.g., "auth.login")
|
|
829
|
+
const actionName = url.pathname.slice(1);
|
|
785
830
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
831
|
+
const route = this.routeMap.get(actionName);
|
|
832
|
+
if (route) {
|
|
833
|
+
const handlerType = route.handler || "typed";
|
|
789
834
|
|
|
790
|
-
|
|
791
|
-
|
|
835
|
+
// Handlers that accept GET requests (for browser compatibility)
|
|
836
|
+
const getEnabledHandlers = ["stream", "sse", "html", "raw"];
|
|
792
837
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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
|
-
}
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|