@donkeylabs/server 0.3.1 → 0.4.0
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 +3 -6
- package/src/generator/index.ts +23 -8
- package/src/generator/zod-to-ts.ts +114 -0
- package/src/index.ts +11 -1
- package/src/router.ts +47 -1
- package/src/server.ts +269 -0
package/package.json
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@donkeylabs/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Type-safe plugin system for building RPC-style APIs with Bun",
|
|
6
6
|
"main": "./src/index.ts",
|
|
7
7
|
"types": "./src/index.ts",
|
|
8
|
-
"bin": {
|
|
9
|
-
"donkeylabs-mcp": "./mcp/server.ts"
|
|
10
|
-
},
|
|
11
8
|
"exports": {
|
|
12
9
|
".": {
|
|
13
10
|
"types": "./src/index.ts",
|
|
@@ -27,11 +24,11 @@
|
|
|
27
24
|
},
|
|
28
25
|
"./context": {
|
|
29
26
|
"types": "./context.d.ts"
|
|
30
|
-
}
|
|
27
|
+
},
|
|
28
|
+
"./docs/*": "./docs/*"
|
|
31
29
|
},
|
|
32
30
|
"files": [
|
|
33
31
|
"src",
|
|
34
|
-
"mcp",
|
|
35
32
|
"docs",
|
|
36
33
|
"context.d.ts",
|
|
37
34
|
"registry.d.ts",
|
package/src/generator/index.ts
CHANGED
|
@@ -312,30 +312,38 @@ export function generateClientFromRoutes(
|
|
|
312
312
|
): string {
|
|
313
313
|
const opts = { ...defaultGeneratorOptions, ...options };
|
|
314
314
|
|
|
315
|
+
// Check if all routes share a common prefix (e.g., "api.") - if so, skip it
|
|
316
|
+
// Common prefix stripping is disabled to respect explicit router nesting
|
|
317
|
+
const routesToProcess = routes;
|
|
318
|
+
|
|
315
319
|
// Group routes by namespace
|
|
316
320
|
const tree = new Map<string, Map<string, { method: string; fullName: string }[]>>();
|
|
317
321
|
|
|
318
|
-
for (const route of
|
|
322
|
+
for (const route of routesToProcess) {
|
|
323
|
+
// Find original route name for the actual request
|
|
324
|
+
const originalRoute = routes.find(r => r.name.endsWith(route.name));
|
|
325
|
+
const fullName = originalRoute?.name || route.name;
|
|
326
|
+
|
|
319
327
|
const parts = route.name.split(".");
|
|
320
328
|
if (parts.length < 2) {
|
|
321
329
|
const ns = "";
|
|
322
330
|
if (!tree.has(ns)) tree.set(ns, new Map());
|
|
323
331
|
const rootMethods = tree.get(ns)!;
|
|
324
332
|
if (!rootMethods.has("")) rootMethods.set("", []);
|
|
325
|
-
rootMethods.get("")!.push({ method: parts[0]!, fullName
|
|
333
|
+
rootMethods.get("")!.push({ method: parts[0]!, fullName });
|
|
326
334
|
} else if (parts.length === 2) {
|
|
327
335
|
const [ns, method] = parts;
|
|
328
336
|
if (!tree.has(ns!)) tree.set(ns!, new Map());
|
|
329
337
|
const nsMethods = tree.get(ns!)!;
|
|
330
338
|
if (!nsMethods.has("")) nsMethods.set("", []);
|
|
331
|
-
nsMethods.get("")!.push({ method: method!, fullName
|
|
339
|
+
nsMethods.get("")!.push({ method: method!, fullName });
|
|
332
340
|
} else {
|
|
333
341
|
const [ns, sub, ...rest] = parts;
|
|
334
342
|
const method = rest.join(".");
|
|
335
343
|
if (!tree.has(ns!)) tree.set(ns!, new Map());
|
|
336
344
|
const nsMethods = tree.get(ns!)!;
|
|
337
345
|
if (!nsMethods.has(sub!)) nsMethods.set(sub!, []);
|
|
338
|
-
nsMethods.get(sub!)!.push({ method: method || sub!, fullName
|
|
346
|
+
nsMethods.get(sub!)!.push({ method: method || sub!, fullName });
|
|
339
347
|
}
|
|
340
348
|
}
|
|
341
349
|
|
|
@@ -435,8 +443,12 @@ export function generateClientCode(
|
|
|
435
443
|
.map((r) => {
|
|
436
444
|
const inputType = zodToTypeScript(r.inputSource);
|
|
437
445
|
const outputType = zodToTypeScript(r.outputSource);
|
|
438
|
-
|
|
439
|
-
export
|
|
446
|
+
const routeNs = toPascalCase(r.routeName);
|
|
447
|
+
return ` export namespace ${routeNs} {
|
|
448
|
+
export type Input = ${inputType};
|
|
449
|
+
export type Output = ${outputType};
|
|
450
|
+
}
|
|
451
|
+
export type ${routeNs} = { Input: ${routeNs}.Input; Output: ${routeNs}.Output };`;
|
|
440
452
|
});
|
|
441
453
|
|
|
442
454
|
if (typeEntries.length > 0) {
|
|
@@ -448,8 +460,8 @@ ${typeEntries.join("\n\n")}
|
|
|
448
460
|
const methodEntries = prefixRoutes
|
|
449
461
|
.filter((r) => r.handler === "typed")
|
|
450
462
|
.map((r) => {
|
|
451
|
-
const inputType = `Routes.${namespaceName}.${toPascalCase(r.routeName)}Input`;
|
|
452
|
-
const outputType = `Routes.${namespaceName}.${toPascalCase(r.routeName)}Output`;
|
|
463
|
+
const inputType = `Routes.${namespaceName}.${toPascalCase(r.routeName)}.Input`;
|
|
464
|
+
const outputType = `Routes.${namespaceName}.${toPascalCase(r.routeName)}.Output`;
|
|
453
465
|
return ` ${toCamelCase(r.routeName)}: (input: ${inputType}, options?: RequestOptions): Promise<${outputType}> =>
|
|
454
466
|
this.request("${r.name}", input, options)`;
|
|
455
467
|
});
|
|
@@ -549,3 +561,6 @@ export function createApiClient(config: ApiClientConfig): ApiClient {
|
|
|
549
561
|
export { ApiError, ValidationError, type RequestOptions, type SSEOptions };
|
|
550
562
|
`;
|
|
551
563
|
}
|
|
564
|
+
|
|
565
|
+
// Re-export runtime Zod to TypeScript converter
|
|
566
|
+
export { zodSchemaToTs } from "./zod-to-ts";
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Convert a Zod schema to TypeScript type string at runtime
|
|
5
|
+
* Uses Zod's internal _def to introspect the schema
|
|
6
|
+
*/
|
|
7
|
+
export function zodSchemaToTs(schema: z.ZodType<any>): string {
|
|
8
|
+
return convertZodType(schema);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function convertZodType(schema: z.ZodType<any>): string {
|
|
12
|
+
const def = (schema as any)._def;
|
|
13
|
+
const typeName = def?.typeName;
|
|
14
|
+
|
|
15
|
+
switch (typeName) {
|
|
16
|
+
case "ZodString":
|
|
17
|
+
return "string";
|
|
18
|
+
|
|
19
|
+
case "ZodNumber":
|
|
20
|
+
return "number";
|
|
21
|
+
|
|
22
|
+
case "ZodBoolean":
|
|
23
|
+
return "boolean";
|
|
24
|
+
|
|
25
|
+
case "ZodDate":
|
|
26
|
+
return "Date";
|
|
27
|
+
|
|
28
|
+
case "ZodUndefined":
|
|
29
|
+
return "undefined";
|
|
30
|
+
|
|
31
|
+
case "ZodNull":
|
|
32
|
+
return "null";
|
|
33
|
+
|
|
34
|
+
case "ZodAny":
|
|
35
|
+
return "any";
|
|
36
|
+
|
|
37
|
+
case "ZodUnknown":
|
|
38
|
+
return "unknown";
|
|
39
|
+
|
|
40
|
+
case "ZodVoid":
|
|
41
|
+
return "void";
|
|
42
|
+
|
|
43
|
+
case "ZodNever":
|
|
44
|
+
return "never";
|
|
45
|
+
|
|
46
|
+
case "ZodLiteral":
|
|
47
|
+
const value = def.value;
|
|
48
|
+
return typeof value === "string" ? `"${value}"` : String(value);
|
|
49
|
+
|
|
50
|
+
case "ZodArray":
|
|
51
|
+
const itemType = convertZodType(def.type);
|
|
52
|
+
return `${itemType}[]`;
|
|
53
|
+
|
|
54
|
+
case "ZodObject":
|
|
55
|
+
const shape = def.shape();
|
|
56
|
+
const props = Object.entries(shape).map(([key, value]) => {
|
|
57
|
+
const propSchema = value as z.ZodType<any>;
|
|
58
|
+
const isOptional = (propSchema as any)._def?.typeName === "ZodOptional";
|
|
59
|
+
const innerType = isOptional
|
|
60
|
+
? convertZodType((propSchema as any)._def.innerType)
|
|
61
|
+
: convertZodType(propSchema);
|
|
62
|
+
return ` ${key}${isOptional ? "?" : ""}: ${innerType};`;
|
|
63
|
+
});
|
|
64
|
+
return `{\n${props.join("\n")}\n}`;
|
|
65
|
+
|
|
66
|
+
case "ZodOptional":
|
|
67
|
+
return convertZodType(def.innerType);
|
|
68
|
+
|
|
69
|
+
case "ZodNullable":
|
|
70
|
+
return `${convertZodType(def.innerType)} | null`;
|
|
71
|
+
|
|
72
|
+
case "ZodDefault":
|
|
73
|
+
return convertZodType(def.innerType);
|
|
74
|
+
|
|
75
|
+
case "ZodUnion":
|
|
76
|
+
const options = def.options.map((opt: z.ZodType<any>) => convertZodType(opt));
|
|
77
|
+
return options.join(" | ");
|
|
78
|
+
|
|
79
|
+
case "ZodEnum":
|
|
80
|
+
return def.values.map((v: string) => `"${v}"`).join(" | ");
|
|
81
|
+
|
|
82
|
+
case "ZodNativeEnum":
|
|
83
|
+
return "number | string"; // Simplified
|
|
84
|
+
|
|
85
|
+
case "ZodRecord":
|
|
86
|
+
const keyType = def.keyType ? convertZodType(def.keyType) : "string";
|
|
87
|
+
const valueType = convertZodType(def.valueType);
|
|
88
|
+
return `Record<${keyType}, ${valueType}>`;
|
|
89
|
+
|
|
90
|
+
case "ZodTuple":
|
|
91
|
+
const items = def.items.map((item: z.ZodType<any>) => convertZodType(item));
|
|
92
|
+
return `[${items.join(", ")}]`;
|
|
93
|
+
|
|
94
|
+
case "ZodPromise":
|
|
95
|
+
return `Promise<${convertZodType(def.type)}>`;
|
|
96
|
+
|
|
97
|
+
case "ZodEffects":
|
|
98
|
+
// .transform(), .refine(), etc - use the inner schema
|
|
99
|
+
return convertZodType(def.schema);
|
|
100
|
+
|
|
101
|
+
case "ZodLazy":
|
|
102
|
+
// Lazy schemas - try to resolve
|
|
103
|
+
return convertZodType(def.getter());
|
|
104
|
+
|
|
105
|
+
case "ZodIntersection":
|
|
106
|
+
const left = convertZodType(def.left);
|
|
107
|
+
const right = convertZodType(def.right);
|
|
108
|
+
return `${left} & ${right}`;
|
|
109
|
+
|
|
110
|
+
default:
|
|
111
|
+
// Fallback for unknown types
|
|
112
|
+
return "unknown";
|
|
113
|
+
}
|
|
114
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,17 @@
|
|
|
4
4
|
export { AppServer, type ServerConfig } from "./server";
|
|
5
5
|
|
|
6
6
|
// Router
|
|
7
|
-
export {
|
|
7
|
+
export {
|
|
8
|
+
createRouter,
|
|
9
|
+
defineRoute,
|
|
10
|
+
type Router,
|
|
11
|
+
type RouteBuilder,
|
|
12
|
+
type ServerContext,
|
|
13
|
+
type IRouter,
|
|
14
|
+
type IRouteBuilder,
|
|
15
|
+
type IMiddlewareBuilder,
|
|
16
|
+
type TypedRouteConfig,
|
|
17
|
+
} from "./router";
|
|
8
18
|
|
|
9
19
|
// Handlers
|
|
10
20
|
export {
|
package/src/router.ts
CHANGED
|
@@ -36,10 +36,18 @@ export type RouteDefinition<
|
|
|
36
36
|
: HandlerRegistry[T]["__signature"];
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
+
export interface HandlerClass<I = any, O = any> {
|
|
40
|
+
new (ctx: ServerContext): { handle(input: I): Promise<O> | O };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isHandlerClass(fn: any): fn is HandlerClass {
|
|
44
|
+
return typeof fn === 'function' && fn.prototype && typeof fn.prototype.handle === 'function';
|
|
45
|
+
}
|
|
46
|
+
|
|
39
47
|
export interface TypedRouteConfig<I = any, O = any> {
|
|
40
48
|
input?: z.ZodType<I>;
|
|
41
49
|
output?: z.ZodType<O>;
|
|
42
|
-
handle: (input: I, ctx: ServerContext) => Promise<O> | O
|
|
50
|
+
handle: ((input: I, ctx: ServerContext) => Promise<O> | O) | HandlerClass<I, O>;
|
|
43
51
|
}
|
|
44
52
|
|
|
45
53
|
export interface RouteMetadata {
|
|
@@ -69,6 +77,10 @@ export class RouteBuilder<TRouter extends Router> implements IRouteBuilderBase<T
|
|
|
69
77
|
) {}
|
|
70
78
|
|
|
71
79
|
typed<I, O>(config: TypedRouteConfig<I, O>): TRouter {
|
|
80
|
+
if (isHandlerClass(config.handle)) {
|
|
81
|
+
const HandlerClass = config.handle;
|
|
82
|
+
config.handle = (input, ctx) => new HandlerClass(ctx).handle(input);
|
|
83
|
+
}
|
|
72
84
|
return this.router.addRoute(this.name, "typed", config, this._middleware);
|
|
73
85
|
}
|
|
74
86
|
|
|
@@ -156,6 +168,23 @@ export class Router implements IRouter {
|
|
|
156
168
|
}));
|
|
157
169
|
}
|
|
158
170
|
|
|
171
|
+
/** Get route metadata with TypeScript type strings for code generation */
|
|
172
|
+
getTypedMetadata(): Array<{
|
|
173
|
+
name: string;
|
|
174
|
+
handler: string;
|
|
175
|
+
inputType?: string;
|
|
176
|
+
outputType?: string;
|
|
177
|
+
}> {
|
|
178
|
+
// Dynamic import to avoid circular deps
|
|
179
|
+
const { zodSchemaToTs } = require("./generator/zod-to-ts");
|
|
180
|
+
return this.getRoutes().map(route => ({
|
|
181
|
+
name: route.name,
|
|
182
|
+
handler: route.handler,
|
|
183
|
+
inputType: route.input ? zodSchemaToTs(route.input) : undefined,
|
|
184
|
+
outputType: route.output ? zodSchemaToTs(route.output) : undefined,
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
|
|
159
188
|
getPrefix(): string {
|
|
160
189
|
return this.prefix;
|
|
161
190
|
}
|
|
@@ -177,3 +206,20 @@ function createMiddlewareBuilderProxy<TRouter extends Router>(router: TRouter):
|
|
|
177
206
|
}
|
|
178
207
|
|
|
179
208
|
export const createRouter = (prefix?: string): IRouter => new Router(prefix);
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Define a route with type inference for input/output schemas.
|
|
212
|
+
*
|
|
213
|
+
* @example
|
|
214
|
+
* export const myRoute = defineRoute({
|
|
215
|
+
* input: z.object({ name: z.string() }),
|
|
216
|
+
* output: z.object({ greeting: z.string() }),
|
|
217
|
+
* handle: async ({ name }, ctx) => {
|
|
218
|
+
* return { greeting: `Hello ${name}` };
|
|
219
|
+
* }
|
|
220
|
+
* });
|
|
221
|
+
*/
|
|
222
|
+
export function defineRoute<I, O>(config: TypedRouteConfig<I, O>): TypedRouteConfig<I, O> {
|
|
223
|
+
return config;
|
|
224
|
+
}
|
|
225
|
+
|
package/src/server.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
2
4
|
import { PluginManager, type CoreServices, type ConfiguredPlugin } from "./core";
|
|
3
5
|
import { type IRouter, type RouteDefinition, type ServerContext } from "./router";
|
|
4
6
|
import { Handlers } from "./handlers";
|
|
@@ -23,11 +25,29 @@ import {
|
|
|
23
25
|
type RateLimiterConfig,
|
|
24
26
|
type ErrorsConfig,
|
|
25
27
|
} from "./core/index";
|
|
28
|
+
import { zodSchemaToTs } from "./generator/zod-to-ts";
|
|
29
|
+
|
|
30
|
+
export interface TypeGenerationConfig {
|
|
31
|
+
/** Output path for generated client types (e.g., "./src/lib/api.ts") */
|
|
32
|
+
output: string;
|
|
33
|
+
/** Custom base import for the client */
|
|
34
|
+
baseImport?: string;
|
|
35
|
+
/** Custom base class name */
|
|
36
|
+
baseClass?: string;
|
|
37
|
+
/** Constructor signature (e.g., "baseUrl: string, options?: ApiClientOptions") */
|
|
38
|
+
constructorSignature?: string;
|
|
39
|
+
/** Constructor body (e.g., "super(baseUrl, options);") */
|
|
40
|
+
constructorBody?: string;
|
|
41
|
+
/** Factory function code (optional, replaces default createApi) */
|
|
42
|
+
factoryFunction?: string;
|
|
43
|
+
}
|
|
26
44
|
|
|
27
45
|
export interface ServerConfig {
|
|
28
46
|
port?: number;
|
|
29
47
|
db: CoreServices["db"];
|
|
30
48
|
config?: Record<string, any>;
|
|
49
|
+
/** Auto-generate client types on startup in dev mode */
|
|
50
|
+
generateTypes?: TypeGenerationConfig;
|
|
31
51
|
// Core service configurations
|
|
32
52
|
logger?: LoggerConfig;
|
|
33
53
|
cache?: CacheConfig;
|
|
@@ -45,6 +65,7 @@ export class AppServer {
|
|
|
45
65
|
private routers: IRouter[] = [];
|
|
46
66
|
private routeMap: Map<string, RouteDefinition> = new Map();
|
|
47
67
|
private coreServices: CoreServices;
|
|
68
|
+
private typeGenConfig?: TypeGenerationConfig;
|
|
48
69
|
|
|
49
70
|
constructor(options: ServerConfig) {
|
|
50
71
|
this.port = options.port ?? 3000;
|
|
@@ -73,6 +94,7 @@ export class AppServer {
|
|
|
73
94
|
};
|
|
74
95
|
|
|
75
96
|
this.manager = new PluginManager(this.coreServices);
|
|
97
|
+
this.typeGenConfig = options.generateTypes;
|
|
76
98
|
}
|
|
77
99
|
|
|
78
100
|
/**
|
|
@@ -169,6 +191,247 @@ export class AppServer {
|
|
|
169
191
|
return this.routeMap.has(routeName);
|
|
170
192
|
}
|
|
171
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Generate client types from registered routes.
|
|
196
|
+
* Called automatically on startup in dev mode if generateTypes config is provided.
|
|
197
|
+
*/
|
|
198
|
+
private async generateTypes(): Promise<void> {
|
|
199
|
+
if (!this.typeGenConfig) return;
|
|
200
|
+
|
|
201
|
+
const { logger } = this.coreServices;
|
|
202
|
+
const isDev = process.env.NODE_ENV !== "production";
|
|
203
|
+
|
|
204
|
+
if (!isDev) {
|
|
205
|
+
logger.debug("Skipping type generation in production mode");
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Collect all route metadata
|
|
210
|
+
const routes: Array<{
|
|
211
|
+
name: string;
|
|
212
|
+
prefix: string;
|
|
213
|
+
routeName: string;
|
|
214
|
+
handler: "typed" | "raw";
|
|
215
|
+
inputSource?: string;
|
|
216
|
+
outputSource?: string;
|
|
217
|
+
}> = [];
|
|
218
|
+
|
|
219
|
+
for (const router of this.routers) {
|
|
220
|
+
for (const route of router.getRoutes()) {
|
|
221
|
+
const parts = route.name.split(".");
|
|
222
|
+
const routeName = parts[parts.length - 1] || route.name;
|
|
223
|
+
const prefix = parts.slice(0, -1).join(".");
|
|
224
|
+
|
|
225
|
+
routes.push({
|
|
226
|
+
name: route.name,
|
|
227
|
+
prefix,
|
|
228
|
+
routeName,
|
|
229
|
+
handler: (route.handler || "typed") as "typed" | "raw",
|
|
230
|
+
inputSource: route.input ? zodSchemaToTs(route.input) : undefined,
|
|
231
|
+
outputSource: route.output ? zodSchemaToTs(route.output) : undefined,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Generate the client code
|
|
237
|
+
const code = this.generateClientCode(routes);
|
|
238
|
+
|
|
239
|
+
// Write to output file
|
|
240
|
+
const outputDir = dirname(this.typeGenConfig.output);
|
|
241
|
+
await mkdir(outputDir, { recursive: true });
|
|
242
|
+
await writeFile(this.typeGenConfig.output, code);
|
|
243
|
+
|
|
244
|
+
logger.info(`Generated API client types`, { output: this.typeGenConfig.output, routes: routes.length });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Generate client code from route metadata.
|
|
249
|
+
*/
|
|
250
|
+
private generateClientCode(
|
|
251
|
+
routes: Array<{
|
|
252
|
+
name: string;
|
|
253
|
+
prefix: string;
|
|
254
|
+
routeName: string;
|
|
255
|
+
handler: "typed" | "raw";
|
|
256
|
+
inputSource?: string;
|
|
257
|
+
outputSource?: string;
|
|
258
|
+
}>
|
|
259
|
+
): string {
|
|
260
|
+
const baseImport =
|
|
261
|
+
this.typeGenConfig?.baseImport ??
|
|
262
|
+
'import { UnifiedApiClientBase, type ClientOptions } from "@donkeylabs/adapter-sveltekit/client";';
|
|
263
|
+
const baseClass = this.typeGenConfig?.baseClass ?? "UnifiedApiClientBase";
|
|
264
|
+
const constructorSignature =
|
|
265
|
+
this.typeGenConfig?.constructorSignature ?? "options?: ClientOptions";
|
|
266
|
+
const constructorBody =
|
|
267
|
+
this.typeGenConfig?.constructorBody ?? "super(options);";
|
|
268
|
+
const defaultFactory = `/**
|
|
269
|
+
* Create an API client instance
|
|
270
|
+
*/
|
|
271
|
+
export function createApi(options?: ClientOptions) {
|
|
272
|
+
return new ApiClient(options);
|
|
273
|
+
}`;
|
|
274
|
+
const factoryFunction = this.typeGenConfig?.factoryFunction ?? defaultFactory;
|
|
275
|
+
|
|
276
|
+
// Helper functions
|
|
277
|
+
const toPascalCase = (str: string): string =>
|
|
278
|
+
str
|
|
279
|
+
.split(/[._-]/)
|
|
280
|
+
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
281
|
+
.join("");
|
|
282
|
+
|
|
283
|
+
const toCamelCase = (str: string): string => {
|
|
284
|
+
const pascal = toPascalCase(str);
|
|
285
|
+
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
// Common prefix stripping is disabled to respect explicit router nesting (e.g. api.health)
|
|
289
|
+
const routesToProcess = routes;
|
|
290
|
+
const commonPrefix = "";
|
|
291
|
+
|
|
292
|
+
// Build recursive tree for nested routes
|
|
293
|
+
type RouteNode = {
|
|
294
|
+
children: Map<string, RouteNode>;
|
|
295
|
+
routes: typeof routes;
|
|
296
|
+
};
|
|
297
|
+
const rootNode: RouteNode = { children: new Map(), routes: [] };
|
|
298
|
+
|
|
299
|
+
for (const route of routesToProcess) {
|
|
300
|
+
const parts = route.name.split(".");
|
|
301
|
+
let currentNode = rootNode;
|
|
302
|
+
// Navigate/Build tree
|
|
303
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
304
|
+
const part = parts[i]!;
|
|
305
|
+
if (!currentNode.children.has(part)) {
|
|
306
|
+
currentNode.children.set(part, { children: new Map(), routes: [] });
|
|
307
|
+
}
|
|
308
|
+
currentNode = currentNode.children.get(part)!;
|
|
309
|
+
}
|
|
310
|
+
// Add route to the leaf node (last part is the method name)
|
|
311
|
+
currentNode.routes.push({
|
|
312
|
+
...route,
|
|
313
|
+
routeName: parts[parts.length - 1]! // precise method name
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Recursive function to generate Type definitions
|
|
318
|
+
function generateTypeBlock(node: RouteNode, indent: string): string {
|
|
319
|
+
const blocks: string[] = [];
|
|
320
|
+
|
|
321
|
+
// 1. Valid Input/Output types for routes at this level
|
|
322
|
+
if (node.routes.length > 0) {
|
|
323
|
+
const routeTypes = node.routes.map(r => {
|
|
324
|
+
if (r.handler !== "typed") return "";
|
|
325
|
+
const routeNs = toPascalCase(r.routeName);
|
|
326
|
+
const inputType = r.inputSource ?? "Record<string, never>";
|
|
327
|
+
const outputType = r.outputSource ?? "unknown";
|
|
328
|
+
return `${indent}export namespace ${routeNs} {
|
|
329
|
+
${indent} export type Input = Expand<${inputType}>;
|
|
330
|
+
${indent} export type Output = Expand<${outputType}>;
|
|
331
|
+
${indent}}
|
|
332
|
+
${indent}export type ${routeNs} = { Input: ${routeNs}.Input; Output: ${routeNs}.Output };`;
|
|
333
|
+
}).filter(Boolean);
|
|
334
|
+
if (routeTypes.length) blocks.push(routeTypes.join("\n\n"));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// 2. Nested namespaces
|
|
338
|
+
for (const [name, child] of node.children) {
|
|
339
|
+
const nsName = toPascalCase(name);
|
|
340
|
+
blocks.push(`${indent}export namespace ${nsName} {\n${generateTypeBlock(child, indent + " ")}\n${indent}}`);
|
|
341
|
+
}
|
|
342
|
+
return blocks.join("\n\n");
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Recursive function to generate Client Methods
|
|
346
|
+
function generateMethodBlock(node: RouteNode, indent: string, parentPath: string, isTopLevel: boolean): string {
|
|
347
|
+
const blocks: string[] = [];
|
|
348
|
+
|
|
349
|
+
// 1. Methods at this level
|
|
350
|
+
const methods = node.routes.map(r => {
|
|
351
|
+
const methodName = toCamelCase(r.routeName);
|
|
352
|
+
// r.name is the full path e.g. "api.v1.users.get"
|
|
353
|
+
|
|
354
|
+
if (r.handler === "typed") {
|
|
355
|
+
const pathParts = r.name.split(".");
|
|
356
|
+
const typePath = ["Routes", ...pathParts.slice(0, -1).map(toPascalCase), toPascalCase(r.routeName)];
|
|
357
|
+
const inputType = typePath.join(".") + ".Input";
|
|
358
|
+
const outputType = typePath.join(".") + ".Output";
|
|
359
|
+
|
|
360
|
+
return `${indent}${methodName}: (input: ${inputType}): Promise<${outputType}> => this.request("${r.name}", input)`;
|
|
361
|
+
} else {
|
|
362
|
+
return `${indent}${methodName}: (init?: RequestInit): Promise<Response> => this.rawRequest("${r.name}", init)`;
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
if (methods.length) blocks.push(methods.join(",\n"));
|
|
366
|
+
|
|
367
|
+
// 2. Nested Objects
|
|
368
|
+
for (const [name, child] of node.children) {
|
|
369
|
+
const camelName = toCamelCase(name);
|
|
370
|
+
const separator = isTopLevel ? " = " : ": ";
|
|
371
|
+
const terminator = isTopLevel ? ";" : "";
|
|
372
|
+
// For top level, we output `name = { ... };`
|
|
373
|
+
// For nested, we output `name: { ... }` (comma handled by join)
|
|
374
|
+
|
|
375
|
+
blocks.push(`${indent}${camelName}${separator}{\n${generateMethodBlock(child, indent + " ", "", false)}\n${indent}}${terminator}`);
|
|
376
|
+
}
|
|
377
|
+
// Top level blocks are separated by nothing (class members). Nested by comma.
|
|
378
|
+
// Wait, blocks.join needs care.
|
|
379
|
+
// If isTopLevel, join with "\n\n". If nested, join with ",\n".
|
|
380
|
+
return blocks.join(isTopLevel ? "\n\n" : ",\n");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const typeBlocks: string[] = [generateTypeBlock(rootNode, " ")];
|
|
384
|
+
// rootNode children are top-level namespaces (api, health) -> Top Level Class Properties
|
|
385
|
+
const methodBlocks: string[] = [generateMethodBlock(rootNode, " ", "", true)];
|
|
386
|
+
|
|
387
|
+
return `// Auto-generated by @donkeylabs/server
|
|
388
|
+
// DO NOT EDIT MANUALLY
|
|
389
|
+
|
|
390
|
+
${baseImport}
|
|
391
|
+
|
|
392
|
+
// Utility type that forces TypeScript to expand types on hover
|
|
393
|
+
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Handler interface for implementing route handlers in model classes.
|
|
397
|
+
* @example
|
|
398
|
+
* class CounterModel implements Handler<Routes.Counter.get> {
|
|
399
|
+
* handle(input: Routes.Counter.get.Input): Routes.Counter.get.Output {
|
|
400
|
+
* return { count: 0 };
|
|
401
|
+
* }
|
|
402
|
+
* }
|
|
403
|
+
*/
|
|
404
|
+
export interface Handler<T extends { Input: any; Output: any }> {
|
|
405
|
+
handle(input: T["Input"]): T["Output"] | Promise<T["Output"]>;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Re-export server context for model classes
|
|
409
|
+
export { type ServerContext as AppContext } from "@donkeylabs/server";
|
|
410
|
+
|
|
411
|
+
// ============================================
|
|
412
|
+
// Route Types
|
|
413
|
+
// ============================================
|
|
414
|
+
|
|
415
|
+
export namespace Routes {
|
|
416
|
+
${typeBlocks.join("\n\n") || " // No typed routes found"}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ============================================
|
|
420
|
+
// API Client
|
|
421
|
+
// ============================================
|
|
422
|
+
|
|
423
|
+
export class ApiClient extends ${baseClass} {
|
|
424
|
+
constructor(${constructorSignature}) {
|
|
425
|
+
${constructorBody}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
${methodBlocks.join("\n\n") || " // No routes defined"}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
${factoryFunction}
|
|
432
|
+
`;
|
|
433
|
+
}
|
|
434
|
+
|
|
172
435
|
/**
|
|
173
436
|
* Initialize server without starting HTTP server.
|
|
174
437
|
* Used by adapters (e.g., SvelteKit) that manage their own HTTP server.
|
|
@@ -176,6 +439,9 @@ export class AppServer {
|
|
|
176
439
|
async initialize(): Promise<void> {
|
|
177
440
|
const { logger } = this.coreServices;
|
|
178
441
|
|
|
442
|
+
// Auto-generate types in dev mode if configured
|
|
443
|
+
await this.generateTypes();
|
|
444
|
+
|
|
179
445
|
await this.manager.migrate();
|
|
180
446
|
await this.manager.init();
|
|
181
447
|
|
|
@@ -387,6 +653,9 @@ export class AppServer {
|
|
|
387
653
|
async start() {
|
|
388
654
|
const { logger } = this.coreServices;
|
|
389
655
|
|
|
656
|
+
// Auto-generate types in dev mode if configured
|
|
657
|
+
await this.generateTypes();
|
|
658
|
+
|
|
390
659
|
// 1. Run migrations
|
|
391
660
|
await this.manager.migrate();
|
|
392
661
|
|