@donkeylabs/server 0.3.0 → 0.3.1
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/LICENSE +1 -1
- package/docs/api-client.md +7 -7
- package/docs/cache.md +1 -74
- package/docs/core-services.md +4 -116
- package/docs/cron.md +1 -1
- package/docs/errors.md +2 -2
- package/docs/events.md +3 -98
- package/docs/handlers.md +13 -48
- package/docs/logger.md +3 -58
- package/docs/middleware.md +2 -2
- package/docs/plugins.md +13 -64
- package/docs/project-structure.md +4 -142
- package/docs/rate-limiter.md +4 -136
- package/docs/router.md +6 -14
- package/docs/sse.md +1 -99
- package/docs/sveltekit-adapter.md +420 -0
- package/package.json +6 -6
- package/registry.d.ts +15 -14
- package/src/core/cache.ts +0 -75
- package/src/core/cron.ts +3 -96
- package/src/core/errors.ts +78 -11
- package/src/core/events.ts +1 -47
- package/src/core/index.ts +0 -4
- package/src/core/jobs.ts +0 -112
- package/src/core/logger.ts +12 -79
- package/src/core/rate-limiter.ts +29 -108
- package/src/core/sse.ts +1 -84
- package/src/core.ts +13 -104
- package/src/generator/index.ts +551 -0
- package/src/handlers.ts +14 -110
- package/src/index.ts +19 -23
- package/src/middleware.ts +2 -5
- package/src/registry.ts +4 -0
- package/src/server.ts +354 -337
- package/README.md +0 -254
- package/cli/commands/dev.ts +0 -134
- package/cli/commands/generate.ts +0 -605
- package/cli/commands/init.ts +0 -205
- package/cli/commands/interactive.ts +0 -417
- package/cli/commands/plugin.ts +0 -192
- package/cli/commands/route.ts +0 -195
- package/cli/donkeylabs +0 -2
- package/cli/index.ts +0 -114
- package/docs/svelte-frontend.md +0 -324
- package/docs/testing.md +0 -438
- package/mcp/donkeylabs-mcp +0 -3238
- package/mcp/server.ts +0 -3238
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generator Building Blocks
|
|
3
|
+
*
|
|
4
|
+
* This module exports reusable functions for generating API clients.
|
|
5
|
+
* Adapters (like @donkeylabs/adapter-sveltekit) can import these functions
|
|
6
|
+
* and compose them with custom options to generate framework-specific clients.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ==========================================
|
|
10
|
+
// Types
|
|
11
|
+
// ==========================================
|
|
12
|
+
|
|
13
|
+
export interface RouteInfo {
|
|
14
|
+
name: string;
|
|
15
|
+
prefix: string;
|
|
16
|
+
routeName: string;
|
|
17
|
+
handler: "typed" | "raw" | string;
|
|
18
|
+
inputSource?: string;
|
|
19
|
+
outputSource?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface EventInfo {
|
|
23
|
+
name: string;
|
|
24
|
+
plugin: string;
|
|
25
|
+
schemaSource: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ClientConfigInfo {
|
|
29
|
+
plugin: string;
|
|
30
|
+
credentials?: "include" | "same-origin" | "omit";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface GeneratorConfig {
|
|
34
|
+
routes: RouteInfo[] | ExtractedRoute[];
|
|
35
|
+
events?: EventInfo[];
|
|
36
|
+
clientConfigs?: ClientConfigInfo[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ExtractedRoute {
|
|
40
|
+
name: string;
|
|
41
|
+
handler: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ClientGeneratorOptions {
|
|
45
|
+
/** Import statement for base class */
|
|
46
|
+
baseImport: string;
|
|
47
|
+
/** Base class name to extend */
|
|
48
|
+
baseClass: string;
|
|
49
|
+
/** Constructor parameters signature */
|
|
50
|
+
constructorSignature: string;
|
|
51
|
+
/** Constructor body implementation */
|
|
52
|
+
constructorBody: string;
|
|
53
|
+
/** Additional imports to include */
|
|
54
|
+
additionalImports?: string[];
|
|
55
|
+
/** Custom factory function (replaces default) */
|
|
56
|
+
factoryFunction?: string;
|
|
57
|
+
/** Additional class members to include */
|
|
58
|
+
additionalMembers?: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Default options for standard HTTP-only client */
|
|
62
|
+
export const defaultGeneratorOptions: ClientGeneratorOptions = {
|
|
63
|
+
baseImport: 'import { ApiClientBase, type ApiClientOptions } from "@donkeylabs/server/client";',
|
|
64
|
+
baseClass: "ApiClientBase<{}>",
|
|
65
|
+
constructorSignature: "baseUrl: string, options?: ApiClientOptions",
|
|
66
|
+
constructorBody: "super(baseUrl, options);",
|
|
67
|
+
factoryFunction: `export function createApiClient(baseUrl: string, options?: ApiClientOptions) {
|
|
68
|
+
return new ApiClient(baseUrl, options);
|
|
69
|
+
}`,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// ==========================================
|
|
73
|
+
// Utility Functions
|
|
74
|
+
// ==========================================
|
|
75
|
+
|
|
76
|
+
export function toPascalCase(str: string): string {
|
|
77
|
+
return str
|
|
78
|
+
.split(/[-_.]/)
|
|
79
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
80
|
+
.join("");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function toCamelCase(str: string): string {
|
|
84
|
+
const pascal = toPascalCase(str);
|
|
85
|
+
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Split string by delimiter, respecting nested brackets
|
|
90
|
+
*/
|
|
91
|
+
export function splitTopLevel(source: string, delimiter: string): string[] {
|
|
92
|
+
const result: string[] = [];
|
|
93
|
+
let current = "";
|
|
94
|
+
let depth = 0;
|
|
95
|
+
|
|
96
|
+
for (const char of source) {
|
|
97
|
+
if (char === "(" || char === "[" || char === "{") {
|
|
98
|
+
depth++;
|
|
99
|
+
current += char;
|
|
100
|
+
} else if (char === ")" || char === "]" || char === "}") {
|
|
101
|
+
depth--;
|
|
102
|
+
current += char;
|
|
103
|
+
} else if (char === delimiter && depth === 0) {
|
|
104
|
+
if (current.trim()) {
|
|
105
|
+
result.push(current.trim());
|
|
106
|
+
}
|
|
107
|
+
current = "";
|
|
108
|
+
} else {
|
|
109
|
+
current += char;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (current.trim()) {
|
|
114
|
+
result.push(current.trim());
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Extract content between balanced parentheses starting at a given position
|
|
122
|
+
*/
|
|
123
|
+
export function extractParenContent(source: string, startPos: number): string {
|
|
124
|
+
let depth = 0;
|
|
125
|
+
let start = -1;
|
|
126
|
+
let end = -1;
|
|
127
|
+
|
|
128
|
+
for (let i = startPos; i < source.length; i++) {
|
|
129
|
+
if (source[i] === "(") {
|
|
130
|
+
if (depth === 0) start = i;
|
|
131
|
+
depth++;
|
|
132
|
+
} else if (source[i] === ")") {
|
|
133
|
+
depth--;
|
|
134
|
+
if (depth === 0) {
|
|
135
|
+
end = i;
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (start !== -1 && end !== -1) {
|
|
142
|
+
return source.slice(start + 1, end);
|
|
143
|
+
}
|
|
144
|
+
return "";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ==========================================
|
|
148
|
+
// Zod to TypeScript Conversion
|
|
149
|
+
// ==========================================
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Parse object property definitions from Zod source
|
|
153
|
+
*/
|
|
154
|
+
function parseObjectProps(
|
|
155
|
+
propsSource: string
|
|
156
|
+
): { name: string; schema: string; optional: boolean }[] {
|
|
157
|
+
const props: { name: string; schema: string; optional: boolean }[] = [];
|
|
158
|
+
const entries = splitTopLevel(propsSource, ",");
|
|
159
|
+
|
|
160
|
+
for (const entry of entries) {
|
|
161
|
+
const colonIndex = entry.indexOf(":");
|
|
162
|
+
if (colonIndex === -1) continue;
|
|
163
|
+
|
|
164
|
+
const name = entry.slice(0, colonIndex).trim();
|
|
165
|
+
let schema = entry.slice(colonIndex + 1).trim();
|
|
166
|
+
|
|
167
|
+
const optional = schema.endsWith(".optional()");
|
|
168
|
+
if (optional) {
|
|
169
|
+
schema = schema.slice(0, -".optional()".length);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
props.push({ name, schema, optional });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return props;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Convert Zod schema source to TypeScript type string
|
|
180
|
+
*/
|
|
181
|
+
export function zodToTypeScript(zodSource: string | undefined): string {
|
|
182
|
+
if (!zodSource) return "unknown";
|
|
183
|
+
|
|
184
|
+
const typeMap: Record<string, string> = {
|
|
185
|
+
"z.string()": "string",
|
|
186
|
+
"z.number()": "number",
|
|
187
|
+
"z.boolean()": "boolean",
|
|
188
|
+
"z.null()": "null",
|
|
189
|
+
"z.undefined()": "undefined",
|
|
190
|
+
"z.void()": "void",
|
|
191
|
+
"z.any()": "any",
|
|
192
|
+
"z.unknown()": "unknown",
|
|
193
|
+
"z.never()": "never",
|
|
194
|
+
"z.date()": "Date",
|
|
195
|
+
"z.bigint()": "bigint",
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
if (typeMap[zodSource]) return typeMap[zodSource];
|
|
199
|
+
|
|
200
|
+
let source = zodSource;
|
|
201
|
+
let suffix = "";
|
|
202
|
+
|
|
203
|
+
if (source.endsWith(".optional()")) {
|
|
204
|
+
source = source.slice(0, -".optional()".length);
|
|
205
|
+
suffix = " | undefined";
|
|
206
|
+
} else if (source.endsWith(".nullable()")) {
|
|
207
|
+
source = source.slice(0, -".nullable()".length);
|
|
208
|
+
suffix = " | null";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// z.object({ ... })
|
|
212
|
+
if (source.startsWith("z.object(")) {
|
|
213
|
+
const innerContent = extractParenContent(source, 8);
|
|
214
|
+
const trimmed = innerContent.trim();
|
|
215
|
+
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
|
216
|
+
const propsSource = trimmed.slice(1, -1).trim();
|
|
217
|
+
const props = parseObjectProps(propsSource);
|
|
218
|
+
const typeProps = props
|
|
219
|
+
.map((p) => ` ${p.name}${p.optional ? "?" : ""}: ${zodToTypeScript(p.schema)};`)
|
|
220
|
+
.join("\n");
|
|
221
|
+
return `{\n${typeProps}\n}${suffix}`;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// z.array(...)
|
|
226
|
+
if (source.startsWith("z.array(")) {
|
|
227
|
+
const innerContent = extractParenContent(source, 7);
|
|
228
|
+
if (innerContent) {
|
|
229
|
+
return `${zodToTypeScript(innerContent.trim())}[]${suffix}`;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// z.enum([...])
|
|
234
|
+
const enumMatch = source.match(/z\.enum\s*\(\s*\[([^\]]+)\]\s*\)/);
|
|
235
|
+
if (enumMatch?.[1]) {
|
|
236
|
+
const values = enumMatch[1]
|
|
237
|
+
.split(",")
|
|
238
|
+
.map((v) => v.trim())
|
|
239
|
+
.filter(Boolean);
|
|
240
|
+
return values.join(" | ") + suffix;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// z.literal(...)
|
|
244
|
+
const literalMatch = source.match(/z\.literal\s*\(\s*([^)]+)\s*\)/);
|
|
245
|
+
if (literalMatch?.[1]) {
|
|
246
|
+
return literalMatch[1].trim() + suffix;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// z.union([...])
|
|
250
|
+
const unionMatch = source.match(/z\.union\s*\(\s*\[([^\]]+)\]\s*\)/);
|
|
251
|
+
if (unionMatch?.[1]) {
|
|
252
|
+
const schemas = splitTopLevel(unionMatch[1], ",");
|
|
253
|
+
return schemas.map((s) => zodToTypeScript(s.trim())).join(" | ") + suffix;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// z.record(...)
|
|
257
|
+
const recordMatch = source.match(/z\.record\s*\(\s*([^)]+)\s*\)/);
|
|
258
|
+
if (recordMatch?.[1]) {
|
|
259
|
+
const parts = splitTopLevel(recordMatch[1], ",");
|
|
260
|
+
if (parts.length === 2) {
|
|
261
|
+
return `Record<${zodToTypeScript(parts[0]?.trim())}, ${zodToTypeScript(parts[1]?.trim())}>${suffix}`;
|
|
262
|
+
}
|
|
263
|
+
return `Record<string, ${zodToTypeScript(recordMatch[1].trim())}>${suffix}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// z.tuple([...])
|
|
267
|
+
const tupleMatch = source.match(/z\.tuple\s*\(\s*\[([^\]]+)\]\s*\)/);
|
|
268
|
+
if (tupleMatch?.[1]) {
|
|
269
|
+
const schemas = splitTopLevel(tupleMatch[1], ",");
|
|
270
|
+
return `[${schemas.map((s) => zodToTypeScript(s.trim())).join(", ")}]${suffix}`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// z.string().min/max/email/etc
|
|
274
|
+
if (source.startsWith("z.string()")) return "string" + suffix;
|
|
275
|
+
if (source.startsWith("z.number()")) return "number" + suffix;
|
|
276
|
+
|
|
277
|
+
return "unknown" + suffix;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ==========================================
|
|
281
|
+
// Route Grouping
|
|
282
|
+
// ==========================================
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Group routes by prefix for namespace organization
|
|
286
|
+
*/
|
|
287
|
+
export function groupRoutesByPrefix(routes: RouteInfo[]): Map<string, RouteInfo[]> {
|
|
288
|
+
const groups = new Map<string, RouteInfo[]>();
|
|
289
|
+
|
|
290
|
+
for (const route of routes) {
|
|
291
|
+
const prefix = route.prefix || "_root";
|
|
292
|
+
if (!groups.has(prefix)) {
|
|
293
|
+
groups.set(prefix, []);
|
|
294
|
+
}
|
|
295
|
+
groups.get(prefix)!.push(route);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return groups;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ==========================================
|
|
302
|
+
// Client Code Generation
|
|
303
|
+
// ==========================================
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Generate client code from extracted routes (simple format)
|
|
307
|
+
* This is used by the CLI command which extracts routes by running the server
|
|
308
|
+
*/
|
|
309
|
+
export function generateClientFromRoutes(
|
|
310
|
+
routes: ExtractedRoute[],
|
|
311
|
+
options: Partial<ClientGeneratorOptions> = {}
|
|
312
|
+
): string {
|
|
313
|
+
const opts = { ...defaultGeneratorOptions, ...options };
|
|
314
|
+
|
|
315
|
+
// Group routes by namespace
|
|
316
|
+
const tree = new Map<string, Map<string, { method: string; fullName: string }[]>>();
|
|
317
|
+
|
|
318
|
+
for (const route of routes) {
|
|
319
|
+
const parts = route.name.split(".");
|
|
320
|
+
if (parts.length < 2) {
|
|
321
|
+
const ns = "";
|
|
322
|
+
if (!tree.has(ns)) tree.set(ns, new Map());
|
|
323
|
+
const rootMethods = tree.get(ns)!;
|
|
324
|
+
if (!rootMethods.has("")) rootMethods.set("", []);
|
|
325
|
+
rootMethods.get("")!.push({ method: parts[0]!, fullName: route.name });
|
|
326
|
+
} else if (parts.length === 2) {
|
|
327
|
+
const [ns, method] = parts;
|
|
328
|
+
if (!tree.has(ns!)) tree.set(ns!, new Map());
|
|
329
|
+
const nsMethods = tree.get(ns!)!;
|
|
330
|
+
if (!nsMethods.has("")) nsMethods.set("", []);
|
|
331
|
+
nsMethods.get("")!.push({ method: method!, fullName: route.name });
|
|
332
|
+
} else {
|
|
333
|
+
const [ns, sub, ...rest] = parts;
|
|
334
|
+
const method = rest.join(".");
|
|
335
|
+
if (!tree.has(ns!)) tree.set(ns!, new Map());
|
|
336
|
+
const nsMethods = tree.get(ns!)!;
|
|
337
|
+
if (!nsMethods.has(sub!)) nsMethods.set(sub!, []);
|
|
338
|
+
nsMethods.get(sub!)!.push({ method: method || sub!, fullName: route.name });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Generate method definitions
|
|
343
|
+
const namespaceBlocks: string[] = [];
|
|
344
|
+
|
|
345
|
+
for (const [namespace, subNamespaces] of tree) {
|
|
346
|
+
if (namespace === "") {
|
|
347
|
+
const rootMethods = subNamespaces.get("");
|
|
348
|
+
if (rootMethods && rootMethods.length > 0) {
|
|
349
|
+
for (const { method, fullName } of rootMethods) {
|
|
350
|
+
const methodName = method.replace(/-([a-z])/g, (_: string, c: string) => c.toUpperCase());
|
|
351
|
+
namespaceBlocks.push(` ${methodName} = (input: any) => this.request("${fullName}", input);`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const subBlocks: string[] = [];
|
|
358
|
+
for (const [sub, methods] of subNamespaces) {
|
|
359
|
+
if (sub === "") {
|
|
360
|
+
for (const { method, fullName } of methods) {
|
|
361
|
+
const methodName = method.replace(/-([a-z])/g, (_: string, c: string) => c.toUpperCase());
|
|
362
|
+
subBlocks.push(` ${methodName}: (input: any) => this.request("${fullName}", input)`);
|
|
363
|
+
}
|
|
364
|
+
} else {
|
|
365
|
+
const subMethods = methods.map(({ method, fullName }) => {
|
|
366
|
+
const methodName = method.replace(/-([a-z])/g, (_: string, c: string) => c.toUpperCase());
|
|
367
|
+
return ` ${methodName}: (input: any) => this.request("${fullName}", input)`;
|
|
368
|
+
});
|
|
369
|
+
subBlocks.push(` ${sub}: {\n${subMethods.join(",\n")}\n }`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
namespaceBlocks.push(` ${namespace} = {\n${subBlocks.join(",\n")}\n };`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Build additional imports
|
|
377
|
+
const additionalImportsStr = opts.additionalImports?.length
|
|
378
|
+
? "\n" + opts.additionalImports.join("\n")
|
|
379
|
+
: "";
|
|
380
|
+
|
|
381
|
+
// Build additional members
|
|
382
|
+
const additionalMembersStr = opts.additionalMembers?.length
|
|
383
|
+
? "\n\n" + opts.additionalMembers.join("\n\n")
|
|
384
|
+
: "";
|
|
385
|
+
|
|
386
|
+
return `// Auto-generated by donkeylabs generate
|
|
387
|
+
// DO NOT EDIT MANUALLY
|
|
388
|
+
|
|
389
|
+
${opts.baseImport}${additionalImportsStr}
|
|
390
|
+
|
|
391
|
+
export class ApiClient extends ${opts.baseClass} {
|
|
392
|
+
constructor(${opts.constructorSignature}) {
|
|
393
|
+
${opts.constructorBody}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
${namespaceBlocks.join("\n\n") || " // No routes defined"}${additionalMembersStr}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
${opts.factoryFunction}
|
|
400
|
+
`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Generate fully-typed client code with route types (advanced format)
|
|
405
|
+
* This is used by the standalone script which parses source files
|
|
406
|
+
*/
|
|
407
|
+
export function generateClientCode(
|
|
408
|
+
ctx: GeneratorConfig,
|
|
409
|
+
options: Partial<ClientGeneratorOptions> = {}
|
|
410
|
+
): string {
|
|
411
|
+
const { routes, events = [], clientConfigs = [] } = ctx;
|
|
412
|
+
const opts = { ...defaultGeneratorOptions, ...options };
|
|
413
|
+
|
|
414
|
+
// Check if routes are simple ExtractedRoute format
|
|
415
|
+
if (routes.length > 0 && !("prefix" in routes[0])) {
|
|
416
|
+
return generateClientFromRoutes(routes as ExtractedRoute[], options);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const routeInfos = routes as RouteInfo[];
|
|
420
|
+
const defaultCredentials =
|
|
421
|
+
clientConfigs.find((c) => c.credentials)?.credentials || "include";
|
|
422
|
+
|
|
423
|
+
const routeGroups = groupRoutesByPrefix(routeInfos);
|
|
424
|
+
|
|
425
|
+
// Generate route type definitions
|
|
426
|
+
const routeTypeBlocks: string[] = [];
|
|
427
|
+
const routeNamespaceBlocks: string[] = [];
|
|
428
|
+
|
|
429
|
+
for (const [prefix, prefixRoutes] of routeGroups) {
|
|
430
|
+
const namespaceName = prefix === "_root" ? "Root" : toPascalCase(prefix);
|
|
431
|
+
const methodName = prefix === "_root" ? "_root" : prefix;
|
|
432
|
+
|
|
433
|
+
const typeEntries = prefixRoutes
|
|
434
|
+
.filter((r) => r.handler === "typed")
|
|
435
|
+
.map((r) => {
|
|
436
|
+
const inputType = zodToTypeScript(r.inputSource);
|
|
437
|
+
const outputType = zodToTypeScript(r.outputSource);
|
|
438
|
+
return ` export type ${toPascalCase(r.routeName)}Input = ${inputType};
|
|
439
|
+
export type ${toPascalCase(r.routeName)}Output = ${outputType};`;
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
if (typeEntries.length > 0) {
|
|
443
|
+
routeTypeBlocks.push(` export namespace ${namespaceName} {
|
|
444
|
+
${typeEntries.join("\n\n")}
|
|
445
|
+
}`);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const methodEntries = prefixRoutes
|
|
449
|
+
.filter((r) => r.handler === "typed")
|
|
450
|
+
.map((r) => {
|
|
451
|
+
const inputType = `Routes.${namespaceName}.${toPascalCase(r.routeName)}Input`;
|
|
452
|
+
const outputType = `Routes.${namespaceName}.${toPascalCase(r.routeName)}Output`;
|
|
453
|
+
return ` ${toCamelCase(r.routeName)}: (input: ${inputType}, options?: RequestOptions): Promise<${outputType}> =>
|
|
454
|
+
this.request("${r.name}", input, options)`;
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
const rawMethodEntries = prefixRoutes
|
|
458
|
+
.filter((r) => r.handler === "raw")
|
|
459
|
+
.map((r) => {
|
|
460
|
+
return ` ${toCamelCase(r.routeName)}: (init?: RequestInit): Promise<Response> =>
|
|
461
|
+
this.rawRequest("${r.name}", init)`;
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
const allMethods = [...methodEntries, ...rawMethodEntries];
|
|
465
|
+
|
|
466
|
+
if (allMethods.length > 0) {
|
|
467
|
+
routeNamespaceBlocks.push(` ${methodName} = {
|
|
468
|
+
${allMethods.join(",\n\n")}
|
|
469
|
+
};`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Generate event types
|
|
474
|
+
const eventTypeEntries = events.map((e) => {
|
|
475
|
+
const type = zodToTypeScript(e.schemaSource);
|
|
476
|
+
return ` "${e.name}": ${type};`;
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
const eventTypesBlock =
|
|
480
|
+
eventTypeEntries.length > 0
|
|
481
|
+
? `export interface SSEEvents {
|
|
482
|
+
${eventTypeEntries.join("\n")}
|
|
483
|
+
}`
|
|
484
|
+
: `export interface SSEEvents {}`;
|
|
485
|
+
|
|
486
|
+
// Build additional imports
|
|
487
|
+
const additionalImportsStr = opts.additionalImports?.length
|
|
488
|
+
? "\n" + opts.additionalImports.join("\n")
|
|
489
|
+
: "";
|
|
490
|
+
|
|
491
|
+
return `// Auto-generated by scripts/generate-client.ts
|
|
492
|
+
// DO NOT EDIT MANUALLY
|
|
493
|
+
|
|
494
|
+
import {
|
|
495
|
+
ApiClientBase,
|
|
496
|
+
ApiError,
|
|
497
|
+
ValidationError,
|
|
498
|
+
type RequestOptions,
|
|
499
|
+
type ApiClientOptions,
|
|
500
|
+
type SSEOptions,
|
|
501
|
+
} from "./base";${additionalImportsStr}
|
|
502
|
+
|
|
503
|
+
// ============================================
|
|
504
|
+
// Route Types
|
|
505
|
+
// ============================================
|
|
506
|
+
|
|
507
|
+
export namespace Routes {
|
|
508
|
+
${routeTypeBlocks.join("\n\n") || " // No typed routes found"}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ============================================
|
|
512
|
+
// SSE Event Types
|
|
513
|
+
// ============================================
|
|
514
|
+
|
|
515
|
+
${eventTypesBlock}
|
|
516
|
+
|
|
517
|
+
// ============================================
|
|
518
|
+
// API Client
|
|
519
|
+
// ============================================
|
|
520
|
+
|
|
521
|
+
export interface ApiClientConfig extends ApiClientOptions {
|
|
522
|
+
baseUrl: string;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export class ApiClient extends ApiClientBase<SSEEvents> {
|
|
526
|
+
constructor(config: ApiClientConfig) {
|
|
527
|
+
super(config.baseUrl, {
|
|
528
|
+
credentials: "${defaultCredentials}",
|
|
529
|
+
...config,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ==========================================
|
|
534
|
+
// Route Namespaces
|
|
535
|
+
// ==========================================
|
|
536
|
+
|
|
537
|
+
${routeNamespaceBlocks.join("\n\n") || " // No routes defined"}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ============================================
|
|
541
|
+
// Factory Function
|
|
542
|
+
// ============================================
|
|
543
|
+
|
|
544
|
+
export function createApiClient(config: ApiClientConfig): ApiClient {
|
|
545
|
+
return new ApiClient(config);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Re-export base types for convenience
|
|
549
|
+
export { ApiError, ValidationError, type RequestOptions, type SSEOptions };
|
|
550
|
+
`;
|
|
551
|
+
}
|
package/src/handlers.ts
CHANGED
|
@@ -1,101 +1,6 @@
|
|
|
1
1
|
import type { RouteDefinition, ServerContext } from "./router";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
|
|
4
|
-
// ============================================
|
|
5
|
-
// Route and Handler Types for DX
|
|
6
|
-
// ============================================
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Route contract interface - generated routes export this shape
|
|
10
|
-
*/
|
|
11
|
-
export interface RouteContract {
|
|
12
|
-
input: any;
|
|
13
|
-
output: any;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Handler interface for model classes.
|
|
18
|
-
*
|
|
19
|
-
* @example
|
|
20
|
-
* import type { Handler } from "@donkeylabs/server";
|
|
21
|
-
* import type { Health } from "$server/routes";
|
|
22
|
-
*
|
|
23
|
-
* export class PingModel implements Handler<Health.Ping> {
|
|
24
|
-
* handle(input: Health.Ping.Input): Health.Ping.Output {
|
|
25
|
-
* return { status: "ok", timestamp: new Date().toISOString() };
|
|
26
|
-
* }
|
|
27
|
-
* }
|
|
28
|
-
*/
|
|
29
|
-
export interface Handler<T extends RouteContract> {
|
|
30
|
-
handle(input: T["input"]): T["output"] | Promise<T["output"]>;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Typed route definition.
|
|
35
|
-
* Use with generated route types: `const route: Route<Health.Ping> = { ... }`
|
|
36
|
-
*
|
|
37
|
-
* @example
|
|
38
|
-
* import { Health } from ".@donkeylabs/server/routes";
|
|
39
|
-
* import type { Route } from "@donkeylabs/server";
|
|
40
|
-
*
|
|
41
|
-
* export const pingRoute: Route<Health.Ping> = {
|
|
42
|
-
* input: Health.Ping.Input,
|
|
43
|
-
* output: Health.Ping.Output,
|
|
44
|
-
* handle: async (input, ctx) => new PingModel(ctx).handle(input),
|
|
45
|
-
* };
|
|
46
|
-
*/
|
|
47
|
-
export interface Route<T extends RouteContract> {
|
|
48
|
-
input: z.ZodType<T["input"]>;
|
|
49
|
-
output: z.ZodType<T["output"]>;
|
|
50
|
-
handle: (input: T["input"], ctx: ServerContext) => T["output"] | Promise<T["output"]>;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Route configuration with inferred types from zod schemas.
|
|
55
|
-
*/
|
|
56
|
-
export interface TypedRouteConfig<TInput, TOutput> {
|
|
57
|
-
input: z.ZodType<TInput>;
|
|
58
|
-
output: z.ZodType<TOutput>;
|
|
59
|
-
handle: (input: TInput, ctx: ServerContext) => TOutput | Promise<TOutput>;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export interface RawRouteConfig {
|
|
63
|
-
handle: (req: Request, ctx: ServerContext) => Response | Promise<Response>;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Create a typed route with full type inference from zod schemas.
|
|
68
|
-
*
|
|
69
|
-
* @example
|
|
70
|
-
* export const pingRoute = createRoute.typed({
|
|
71
|
-
* input: z.object({ echo: z.string().optional() }),
|
|
72
|
-
* output: z.object({ status: z.literal("ok"), timestamp: z.string() }),
|
|
73
|
-
* handle: async (input, ctx) => ({
|
|
74
|
-
* status: "ok",
|
|
75
|
-
* timestamp: new Date().toISOString(),
|
|
76
|
-
* }),
|
|
77
|
-
* });
|
|
78
|
-
*/
|
|
79
|
-
export const createRoute = {
|
|
80
|
-
typed<TInput, TOutput>(config: TypedRouteConfig<TInput, TOutput>) {
|
|
81
|
-
return {
|
|
82
|
-
...config,
|
|
83
|
-
_handler: "typed" as const,
|
|
84
|
-
};
|
|
85
|
-
},
|
|
86
|
-
|
|
87
|
-
raw(config: RawRouteConfig) {
|
|
88
|
-
return {
|
|
89
|
-
...config,
|
|
90
|
-
_handler: "raw" as const,
|
|
91
|
-
};
|
|
92
|
-
},
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
// ============================================
|
|
96
|
-
// Handler Runtimes
|
|
97
|
-
// ============================================
|
|
98
|
-
|
|
99
4
|
export interface HandlerRuntime<Fn extends Function = Function> {
|
|
100
5
|
execute(
|
|
101
6
|
req: Request,
|
|
@@ -133,39 +38,37 @@ export function createHandler<Fn extends Function>(
|
|
|
133
38
|
};
|
|
134
39
|
}
|
|
135
40
|
|
|
41
|
+
// ==========================================
|
|
42
|
+
// 1. Typed Handler (Default)
|
|
43
|
+
// ==========================================
|
|
136
44
|
export type TypedFn = (input: any, ctx: ServerContext) => Promise<any> | any;
|
|
137
45
|
export type TypedHandler = HandlerRuntime<TypedFn>;
|
|
138
46
|
|
|
139
47
|
export const TypedHandler: TypedHandler = {
|
|
140
48
|
async execute(req, def, handle, ctx) {
|
|
141
|
-
if (req.method !== "POST") {
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
let body: unknown = {};
|
|
146
|
-
try {
|
|
147
|
-
body = await req.json();
|
|
148
|
-
} catch {
|
|
149
|
-
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
|
150
|
-
}
|
|
49
|
+
if (req.method !== "POST") return new Response("Method Not Allowed", { status: 405 });
|
|
50
|
+
let body: any = {};
|
|
51
|
+
try { body = await req.json(); } catch(e) { return Response.json({error: "Invalid JSON"}, {status:400}); }
|
|
151
52
|
|
|
152
53
|
try {
|
|
153
54
|
const input = def.input ? def.input.parse(body) : body;
|
|
154
55
|
const result = await handle(input, ctx);
|
|
155
56
|
const output = def.output ? def.output.parse(result) : result;
|
|
156
57
|
return Response.json(output);
|
|
157
|
-
} catch (e) {
|
|
58
|
+
} catch (e: any) {
|
|
158
59
|
console.error(e);
|
|
159
60
|
if (e instanceof z.ZodError) {
|
|
160
61
|
return Response.json({ error: "Validation Failed", details: e.issues }, { status: 400 });
|
|
161
62
|
}
|
|
162
|
-
|
|
163
|
-
return Response.json({ error: message }, { status: 500 });
|
|
63
|
+
return Response.json({ error: e.message || "Internal Error" }, { status: 500 });
|
|
164
64
|
}
|
|
165
65
|
},
|
|
166
66
|
__signature: undefined as unknown as TypedFn
|
|
167
|
-
}
|
|
67
|
+
}
|
|
168
68
|
|
|
69
|
+
// ==========================================
|
|
70
|
+
// 2. Raw Handler
|
|
71
|
+
// ==========================================
|
|
169
72
|
export type RawFn = (req: Request, ctx: ServerContext) => Promise<Response> | Response;
|
|
170
73
|
export type RawHandler = HandlerRuntime<RawFn>;
|
|
171
74
|
|
|
@@ -174,9 +77,10 @@ export const RawHandler: RawHandler = {
|
|
|
174
77
|
return await handle(req, ctx);
|
|
175
78
|
},
|
|
176
79
|
__signature: undefined as unknown as RawFn
|
|
177
|
-
}
|
|
80
|
+
}
|
|
178
81
|
|
|
179
82
|
export const Handlers = {
|
|
180
83
|
typed: TypedHandler,
|
|
181
84
|
raw: RawHandler
|
|
182
85
|
};
|
|
86
|
+
|