@donkeylabs/adapter-sveltekit 2.0.13 → 2.0.14

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/generator/index.ts +217 -167
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/adapter-sveltekit",
3
- "version": "2.0.13",
3
+ "version": "2.0.14",
4
4
  "type": "module",
5
5
  "description": "SvelteKit adapter for @donkeylabs/server - seamless SSR/browser API integration",
6
6
  "main": "./src/index.ts",
@@ -70,200 +70,250 @@ export function createApi(options?: ClientOptions) {
70
70
  };
71
71
 
72
72
  /**
73
- * Generate a fully-typed SvelteKit-compatible API client
73
+ * Namespace tree node for building nested client structure
74
74
  */
75
- function generateTypedSvelteKitClient(routes: RouteInfo[]): string {
76
- const opts = svelteKitGeneratorOptions;
75
+ interface NamespaceTreeNode {
76
+ methods: { methodDef: string; typeDef: string }[];
77
+ children: Map<string, NamespaceTreeNode>;
78
+ }
77
79
 
78
- // Check if all routes share a common prefix (e.g., "api.") - if so, strip it
79
- let routesToProcess = routes;
80
- let commonPrefix = "";
81
- if (routes.length > 0) {
82
- const firstPart = routes[0]?.name.split(".")[0];
83
- const allSharePrefix = firstPart && routes.every(r => r.name.startsWith(firstPart + "."));
84
- if (allSharePrefix && firstPart) {
85
- commonPrefix = firstPart;
86
- // Strip the common prefix from route names for client generation
87
- routesToProcess = routes.map(r => ({
88
- ...r,
89
- name: r.name.slice(firstPart.length + 1), // Remove "api." prefix
90
- prefix: r.prefix === firstPart ? "" : r.prefix.slice(firstPart.length + 1),
91
- }));
92
- }
93
- }
80
+ /**
81
+ * Build a nested tree structure from routes
82
+ * e.g., routes "api.counter.get", "api.cache.set" become:
83
+ * api -> { counter -> { get }, cache -> { set } }
84
+ */
85
+ function buildRouteTree(routes: RouteInfo[], commonPrefix: string): Map<string, NamespaceTreeNode> {
86
+ const tree = new Map<string, NamespaceTreeNode>();
94
87
 
95
- // Group routes by namespace
96
- const groups = new Map<string, RouteInfo[]>();
97
- for (const route of routesToProcess) {
88
+ for (const route of routes) {
89
+ // Get the path parts for nesting (e.g., "api.counter.get" -> ["api", "counter", "get"])
98
90
  const parts = route.name.split(".");
99
- const namespace = parts.length > 1 ? parts[0]! : "_root";
100
- if (!groups.has(namespace)) {
101
- groups.set(namespace, []);
91
+ const methodName = parts[parts.length - 1]!; // Last part is the method
92
+ const namespaceParts = parts.slice(0, -1); // Everything before is namespace path
93
+
94
+ if (namespaceParts.length === 0) {
95
+ // Root level method
96
+ if (!tree.has("_root")) {
97
+ tree.set("_root", { methods: [], children: new Map() });
98
+ }
99
+ tree.get("_root")!.methods.push(generateMethodAndType(route, methodName, "Root", commonPrefix));
100
+ continue;
101
+ }
102
+
103
+ // Navigate/create the tree path
104
+ let current = tree;
105
+ for (let i = 0; i < namespaceParts.length; i++) {
106
+ const part = namespaceParts[i]!;
107
+ if (!current.has(part)) {
108
+ current.set(part, { methods: [], children: new Map() });
109
+ }
110
+
111
+ if (i === namespaceParts.length - 1) {
112
+ // At the final namespace level - add the method here
113
+ const pascalNs = toPascalCase(namespaceParts.join("."));
114
+ current.get(part)!.methods.push(generateMethodAndType(route, methodName, pascalNs, commonPrefix));
115
+ } else {
116
+ // Continue traversing
117
+ current = current.get(part)!.children;
118
+ }
102
119
  }
103
- groups.get(namespace)!.push({
104
- ...route,
105
- routeName: parts.length > 1 ? parts.slice(1).join(".") : parts[0]!,
106
- });
107
120
  }
108
121
 
109
- // Generate type definitions
110
- const typeBlocks: string[] = [];
111
- const methodBlocks: string[] = [];
122
+ return tree;
123
+ }
112
124
 
113
- for (const [namespace, nsRoutes] of groups) {
114
- const pascalNs = namespace === "_root" ? "Root" : toPascalCase(namespace);
115
- const methodNs = namespace === "_root" ? "_root" : namespace;
116
-
117
- // Generate types for this namespace (typed, stream, sse, formData, html routes have input types)
118
- const typeEntries = nsRoutes
119
- .filter(r => ["typed", "stream", "sse", "formData", "html"].includes(r.handler))
120
- .map(r => {
121
- const pascalRoute = toPascalCase(r.routeName);
122
- // If inputSource starts with "z.", it's a Zod source string - convert it
123
- // Otherwise it's already a TypeScript type string from getTypedMetadata()
124
- const inputType = r.inputSource
125
- ? (r.inputSource.trim().startsWith("z.") ? zodToTypeScript(r.inputSource) : r.inputSource)
126
- : "Record<string, never>";
127
-
128
- // Handlers that don't have typed output (return Response or string directly)
129
- if (r.handler === "stream" || r.handler === "html") {
130
- return ` export namespace ${pascalRoute} {
125
+ /**
126
+ * Generate method definition and type definition for a route
127
+ */
128
+ function generateMethodAndType(
129
+ route: RouteInfo,
130
+ methodName: string,
131
+ pascalNs: string,
132
+ commonPrefix: string
133
+ ): { methodDef: string; typeDef: string } {
134
+ const camelMethod = toCamelCase(methodName);
135
+ const pascalRoute = toPascalCase(methodName);
136
+ const fullRouteName = route.name; // Already includes full path
137
+
138
+ // Generate input type
139
+ const inputType = route.inputSource
140
+ ? (route.inputSource.trim().startsWith("z.") ? zodToTypeScript(route.inputSource) : route.inputSource)
141
+ : "Record<string, never>";
142
+
143
+ // Generate type definition
144
+ let typeDef = "";
145
+ let methodDef = "";
146
+
147
+ if (route.handler === "stream" || route.handler === "html") {
148
+ typeDef = ` export namespace ${pascalRoute} {
131
149
  export type Input = Expand<${inputType}>;
132
150
  }
133
151
  export type ${pascalRoute} = { Input: ${pascalRoute}.Input };`;
134
- }
135
-
136
- // SSE routes - include Events type if eventsSource is present
137
- if (r.handler === "sse") {
138
- const eventsEntries = r.eventsSource
139
- ? Object.entries(r.eventsSource).map(([eventName, eventSchema]) => {
140
- const eventType = eventSchema.trim().startsWith("z.")
141
- ? zodToTypeScript(eventSchema)
142
- : eventSchema;
143
- return ` "${eventName}": Expand<${eventType}>;`;
144
- })
145
- : [];
146
- const eventsType = eventsEntries.length > 0
147
- ? `{\n${eventsEntries.join("\n")}\n }`
148
- : "Record<string, unknown>";
149
- return ` export namespace ${pascalRoute} {
152
+
153
+ if (route.handler === "stream") {
154
+ methodDef = `${camelMethod}: {
155
+ /** POST request with JSON body (programmatic) */
156
+ fetch: (input: Routes.${pascalNs}.${pascalRoute}.Input, options?: RequestOptions): Promise<Response> => this.streamRequest("${fullRouteName}", input, options),
157
+ /** GET URL for browser src attributes (video, img, download links) */
158
+ url: (input: Routes.${pascalNs}.${pascalRoute}.Input): string => this.streamUrl("${fullRouteName}", input),
159
+ /** GET request with query params */
160
+ get: (input: Routes.${pascalNs}.${pascalRoute}.Input, options?: RequestOptions): Promise<Response> => this.streamGet("${fullRouteName}", input, options),
161
+ }`;
162
+ } else {
163
+ const hasInput = route.inputSource;
164
+ methodDef = `${camelMethod}: (${hasInput ? `input: Routes.${pascalNs}.${pascalRoute}.Input` : ""}): Promise<string> => this.htmlRequest("${fullRouteName}"${hasInput ? ", input" : ""})`;
165
+ }
166
+ } else if (route.handler === "sse") {
167
+ const eventsEntries = route.eventsSource
168
+ ? Object.entries(route.eventsSource).map(([eventName, eventSchema]) => {
169
+ const eventType = eventSchema.trim().startsWith("z.")
170
+ ? zodToTypeScript(eventSchema)
171
+ : eventSchema;
172
+ return ` "${eventName}": Expand<${eventType}>;`;
173
+ })
174
+ : [];
175
+ const eventsType = eventsEntries.length > 0
176
+ ? `{\n${eventsEntries.join("\n")}\n }`
177
+ : "Record<string, unknown>";
178
+
179
+ typeDef = ` export namespace ${pascalRoute} {
150
180
  export type Input = Expand<${inputType}>;
151
181
  export type Events = ${eventsType};
152
182
  }
153
183
  export type ${pascalRoute} = { Input: ${pascalRoute}.Input; Events: ${pascalRoute}.Events };`;
154
- }
155
184
 
156
- // typed and formData have both Input and Output
157
- const outputType = r.outputSource
158
- ? (r.outputSource.trim().startsWith("z.") ? zodToTypeScript(r.outputSource) : r.outputSource)
159
- : "unknown";
160
- return ` export namespace ${pascalRoute} {
185
+ const hasInput = route.inputSource;
186
+ if (hasInput) {
187
+ methodDef = `${camelMethod}: (input: Routes.${pascalNs}.${pascalRoute}.Input, options?: SSEConnectionOptions): SSEConnection<Routes.${pascalNs}.${pascalRoute}.Events> => this.sseConnect("${fullRouteName}", input, options)`;
188
+ } else {
189
+ methodDef = `${camelMethod}: (options?: SSEConnectionOptions): SSEConnection<Routes.${pascalNs}.${pascalRoute}.Events> => this.sseConnect("${fullRouteName}", undefined, options)`;
190
+ }
191
+ } else if (route.handler === "raw") {
192
+ typeDef = ""; // Raw routes don't have types
193
+ methodDef = `${camelMethod}: (init?: RequestInit): Promise<Response> => this.rawRequest("${fullRouteName}", init)`;
194
+ } else if (route.handler === "formData") {
195
+ const outputType = route.outputSource
196
+ ? (route.outputSource.trim().startsWith("z.") ? zodToTypeScript(route.outputSource) : route.outputSource)
197
+ : "unknown";
198
+
199
+ typeDef = ` export namespace ${pascalRoute} {
161
200
  export type Input = Expand<${inputType}>;
162
201
  export type Output = Expand<${outputType}>;
163
202
  }
164
203
  export type ${pascalRoute} = { Input: ${pascalRoute}.Input; Output: ${pascalRoute}.Output };`;
165
- });
166
204
 
167
- if (typeEntries.length > 0) {
168
- typeBlocks.push(` export namespace ${pascalNs} {\n${typeEntries.join("\n\n")}\n }`);
205
+ methodDef = `${camelMethod}: (fields: Routes.${pascalNs}.${pascalRoute}.Input, files: File[]): Promise<Routes.${pascalNs}.${pascalRoute}.Output> => this.formDataRequest("${fullRouteName}", fields, files)`;
206
+ } else {
207
+ // typed handler (default)
208
+ const outputType = route.outputSource
209
+ ? (route.outputSource.trim().startsWith("z.") ? zodToTypeScript(route.outputSource) : route.outputSource)
210
+ : "unknown";
211
+
212
+ typeDef = ` export namespace ${pascalRoute} {
213
+ export type Input = Expand<${inputType}>;
214
+ export type Output = Expand<${outputType}>;
169
215
  }
216
+ export type ${pascalRoute} = { Input: ${pascalRoute}.Input; Output: ${pascalRoute}.Output };`;
170
217
 
171
- // Generate methods for this namespace
172
- const methodEntries = nsRoutes
173
- .filter(r => r.handler === "typed")
174
- .map(r => {
175
- const methodName = toCamelCase(r.routeName);
176
- const pascalRoute = toPascalCase(r.routeName);
177
- const inputType = `Routes.${pascalNs}.${pascalRoute}.Input`;
178
- const outputType = `Routes.${pascalNs}.${pascalRoute}.Output`;
179
- // Use original route name with prefix for the request
180
- const fullRouteName = commonPrefix ? `${commonPrefix}.${r.name}` : r.name;
181
- return ` ${methodName}: (input: ${inputType}, options?: RequestOptions): Promise<${outputType}> => this.request("${fullRouteName}", input, options)`;
182
- });
183
-
184
- const rawMethodEntries = nsRoutes
185
- .filter(r => r.handler === "raw")
186
- .map(r => {
187
- const methodName = toCamelCase(r.routeName);
188
- const fullRouteName = commonPrefix ? `${commonPrefix}.${r.name}` : r.name;
189
- return ` ${methodName}: (init?: RequestInit): Promise<Response> => this.rawRequest("${fullRouteName}", init)`;
190
- });
191
-
192
- const streamMethodEntries = nsRoutes
193
- .filter(r => r.handler === "stream")
194
- .map(r => {
195
- const methodName = toCamelCase(r.routeName);
196
- const pascalRoute = toPascalCase(r.routeName);
197
- const inputType = `Routes.${pascalNs}.${pascalRoute}.Input`;
198
- const fullRouteName = commonPrefix ? `${commonPrefix}.${r.name}` : r.name;
199
- // Stream routes provide three methods:
200
- // - fetch(input, options?): POST request (programmatic)
201
- // - url(input): GET URL for browser (video src, img src, download links)
202
- // - get(input, options?): GET fetch request
203
- return ` ${methodName}: {
204
- /** POST request with JSON body (programmatic) */
205
- fetch: (input: ${inputType}, options?: RequestOptions): Promise<Response> => this.streamRequest("${fullRouteName}", input, options),
206
- /** GET URL for browser src attributes (video, img, download links) */
207
- url: (input: ${inputType}): string => this.streamUrl("${fullRouteName}", input),
208
- /** GET request with query params */
209
- get: (input: ${inputType}, options?: RequestOptions): Promise<Response> => this.streamGet("${fullRouteName}", input, options),
210
- }`;
211
- });
212
-
213
- const sseMethodEntries = nsRoutes
214
- .filter(r => r.handler === "sse")
215
- .map(r => {
216
- const methodName = toCamelCase(r.routeName);
217
- const pascalRoute = toPascalCase(r.routeName);
218
- const hasInput = r.inputSource;
219
- const inputType = hasInput ? `Routes.${pascalNs}.${pascalRoute}.Input` : "Record<string, never>";
220
- const eventsType = `Routes.${pascalNs}.${pascalRoute}.Events`;
221
- const fullRouteName = commonPrefix ? `${commonPrefix}.${r.name}` : r.name;
222
- // SSE returns typed SSEConnection for type-safe event handling
223
- // With input: (input, options?) => sseConnect(route, input, options)
224
- // Without input: (options?) => sseConnect(route, undefined, options)
225
- if (hasInput) {
226
- return ` ${methodName}: (input: ${inputType}, options?: SSEConnectionOptions): SSEConnection<${eventsType}> => this.sseConnect("${fullRouteName}", input, options)`;
227
- } else {
228
- return ` ${methodName}: (options?: SSEConnectionOptions): SSEConnection<${eventsType}> => this.sseConnect("${fullRouteName}", undefined, options)`;
229
- }
230
- });
231
-
232
- const formDataMethodEntries = nsRoutes
233
- .filter(r => r.handler === "formData")
234
- .map(r => {
235
- const methodName = toCamelCase(r.routeName);
236
- const pascalRoute = toPascalCase(r.routeName);
237
- const inputType = r.inputSource ? `Routes.${pascalNs}.${pascalRoute}.Input` : "Record<string, any>";
238
- const outputType = r.outputSource ? `Routes.${pascalNs}.${pascalRoute}.Output` : "unknown";
239
- const fullRouteName = commonPrefix ? `${commonPrefix}.${r.name}` : r.name;
240
- return ` ${methodName}: (fields: ${inputType}, files: File[]): Promise<${outputType}> => this.formDataRequest("${fullRouteName}", fields, files)`;
241
- });
242
-
243
- const htmlMethodEntries = nsRoutes
244
- .filter(r => r.handler === "html")
245
- .map(r => {
246
- const methodName = toCamelCase(r.routeName);
247
- const pascalRoute = toPascalCase(r.routeName);
248
- const hasInput = r.inputSource;
249
- const inputType = hasInput ? `Routes.${pascalNs}.${pascalRoute}.Input` : "Record<string, never>";
250
- const fullRouteName = commonPrefix ? `${commonPrefix}.${r.name}` : r.name;
251
- return ` ${methodName}: (${hasInput ? `input: ${inputType}` : ""}): Promise<string> => this.htmlRequest("${fullRouteName}"${hasInput ? ", input" : ""})`;
252
- });
253
-
254
- const allMethods = [...methodEntries, ...rawMethodEntries, ...streamMethodEntries, ...sseMethodEntries, ...formDataMethodEntries, ...htmlMethodEntries];
255
- if (allMethods.length > 0) {
256
- if (namespace === "_root") {
257
- // Root-level methods go directly on the class
258
- for (const method of allMethods) {
259
- methodBlocks.push(method.replace(/^ /, " "));
260
- }
261
- } else {
262
- methodBlocks.push(` ${methodNs} = {\n${allMethods.join(",\n")}\n };`);
218
+ methodDef = `${camelMethod}: (input: Routes.${pascalNs}.${pascalRoute}.Input, options?: RequestOptions): Promise<Routes.${pascalNs}.${pascalRoute}.Output> => this.request("${fullRouteName}", input, options)`;
219
+ }
220
+
221
+ return { methodDef, typeDef };
222
+ }
223
+
224
+ /**
225
+ * Generate nested object code from a tree node
226
+ */
227
+ function generateNestedMethods(node: NamespaceTreeNode, indent: string = " "): string {
228
+ const parts: string[] = [];
229
+
230
+ // Add methods at this level
231
+ for (const { methodDef } of node.methods) {
232
+ // Indent each line of the method definition
233
+ const indented = methodDef.split("\n").map((line, i) =>
234
+ i === 0 ? `${indent}${line}` : `${indent}${line}`
235
+ ).join("\n");
236
+ parts.push(indented);
237
+ }
238
+
239
+ // Add nested namespaces
240
+ for (const [childName, childNode] of node.children) {
241
+ const childContent = generateNestedMethods(childNode, indent + " ");
242
+ parts.push(`${indent}${childName}: {\n${childContent}\n${indent}}`);
243
+ }
244
+
245
+ return parts.join(",\n");
246
+ }
247
+
248
+ /**
249
+ * Collect all type definitions from a tree
250
+ */
251
+ function collectTypeDefs(tree: Map<string, NamespaceTreeNode>, prefix: string = ""): Map<string, string[]> {
252
+ const result = new Map<string, string[]>();
253
+
254
+ for (const [name, node] of tree) {
255
+ const nsPath = prefix ? `${prefix}.${name}` : name;
256
+ const pascalNs = name === "_root" ? "Root" : toPascalCase(nsPath);
257
+
258
+ // Collect types from this node's methods
259
+ const typeDefs = node.methods
260
+ .map(m => m.typeDef)
261
+ .filter(t => t.length > 0);
262
+
263
+ if (typeDefs.length > 0) {
264
+ if (!result.has(pascalNs)) {
265
+ result.set(pascalNs, []);
266
+ }
267
+ result.get(pascalNs)!.push(...typeDefs);
268
+ }
269
+
270
+ // Recursively collect from children
271
+ const childTypes = collectTypeDefs(node.children, nsPath);
272
+ for (const [childNs, childDefs] of childTypes) {
273
+ if (!result.has(childNs)) {
274
+ result.set(childNs, []);
263
275
  }
276
+ result.get(childNs)!.push(...childDefs);
264
277
  }
265
278
  }
266
279
 
280
+ return result;
281
+ }
282
+
283
+ /**
284
+ * Generate a fully-typed SvelteKit-compatible API client
285
+ */
286
+ function generateTypedSvelteKitClient(routes: RouteInfo[]): string {
287
+ const opts = svelteKitGeneratorOptions;
288
+ const commonPrefix = ""; // We don't strip prefixes anymore - nested structure handles it
289
+
290
+ // Build nested tree structure from routes
291
+ const tree = buildRouteTree(routes, commonPrefix);
292
+
293
+ // Collect type definitions from tree
294
+ const typesByNamespace = collectTypeDefs(tree);
295
+ const typeBlocks: string[] = [];
296
+ for (const [nsName, typeDefs] of typesByNamespace) {
297
+ if (typeDefs.length > 0) {
298
+ typeBlocks.push(` export namespace ${nsName} {\n${typeDefs.join("\n\n")}\n }`);
299
+ }
300
+ }
301
+
302
+ // Generate method blocks from tree
303
+ const methodBlocks: string[] = [];
304
+ for (const [topLevel, node] of tree) {
305
+ if (topLevel === "_root") {
306
+ // Root level methods become direct class properties
307
+ for (const { methodDef } of node.methods) {
308
+ methodBlocks.push(` ${methodDef};`);
309
+ }
310
+ continue;
311
+ }
312
+
313
+ const content = generateNestedMethods(node, " ");
314
+ methodBlocks.push(` ${topLevel} = {\n${content}\n };`);
315
+ }
316
+
267
317
  return `// Auto-generated by donkeylabs generate
268
318
  // DO NOT EDIT MANUALLY
269
319