@donkeylabs/adapter-sveltekit 2.0.12 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/adapter-sveltekit",
3
- "version": "2.0.12",
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
 
package/src/vite.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  import type { Plugin, ViteDevServer } from "vite";
10
10
  import { spawn, type ChildProcess, exec } from "node:child_process";
11
11
  import { resolve, join } from "node:path";
12
- import { watch, type FSWatcher } from "node:fs";
12
+ import { watch, type FSWatcher, existsSync } from "node:fs";
13
13
  import { promisify } from "node:util";
14
14
 
15
15
  const execAsync = promisify(exec);
@@ -41,6 +41,19 @@ export interface DevPluginOptions {
41
41
  * @default "./src/server"
42
42
  */
43
43
  watchDir?: string;
44
+
45
+ /**
46
+ * Enable hot reload for route files (dev mode only).
47
+ * When a route file changes, the router will be reloaded without server restart.
48
+ * @default true
49
+ */
50
+ hotReloadRoutes?: boolean;
51
+
52
+ /**
53
+ * Glob patterns for route files to watch for hot reload.
54
+ * @default ["**\/routes\/**\/*.ts"]
55
+ */
56
+ routePatterns?: string[];
44
57
  }
45
58
 
46
59
  // Check if running with Bun runtime (bun --bun)
@@ -87,6 +100,8 @@ export function donkeylabsDev(options: DevPluginOptions = {}): Plugin {
87
100
  backendPort = 3001,
88
101
  watchTypes = true,
89
102
  watchDir = "./src/server",
103
+ hotReloadRoutes = true,
104
+ routePatterns = ["**/routes/**/*.ts", "**/routes/**/*.js"],
90
105
  } = options;
91
106
 
92
107
  // State for subprocess mode
@@ -96,6 +111,7 @@ export function donkeylabsDev(options: DevPluginOptions = {}): Plugin {
96
111
  // State for in-process mode
97
112
  let appServer: any = null;
98
113
  let serverReady = false;
114
+ let viteServer: ViteDevServer | null = null;
99
115
 
100
116
  // State for file watcher
101
117
  let fileWatcher: FSWatcher | null = null;
@@ -103,11 +119,82 @@ export function donkeylabsDev(options: DevPluginOptions = {}): Plugin {
103
119
  let lastGenerationTime = 0;
104
120
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
105
121
 
122
+ // State for hot reload
123
+ let hotReloadTimer: ReturnType<typeof setTimeout> | null = null;
124
+ const HOT_RELOAD_DEBOUNCE_MS = 100;
125
+
106
126
  const COOLDOWN_MS = 2000;
107
127
  const DEBOUNCE_MS = 500;
108
128
 
109
129
  // Patterns to ignore (generated files)
110
- const IGNORED_PATTERNS = [/schema\.ts$/, /\.d\.ts$/];
130
+ const IGNORED_PATTERNS = [/schema\.ts$/, /\.d\.ts$/, /api\.ts$/];
131
+
132
+ // Check if a file matches route patterns
133
+ function isRouteFile(filename: string): boolean {
134
+ return routePatterns.some((pattern) => {
135
+ // Simple glob matching for common patterns
136
+ const regexPattern = pattern
137
+ .replace(/\*\*/g, ".*")
138
+ .replace(/\*/g, "[^/]*")
139
+ .replace(/\./g, "\\.");
140
+ return new RegExp(regexPattern).test(filename);
141
+ });
142
+ }
143
+
144
+ // Hot reload a route file
145
+ async function hotReloadRoute(filepath: string) {
146
+ if (!appServer || !serverReady || !viteServer || !hotReloadRoutes) return;
147
+
148
+ console.log("\x1b[36m[donkeylabs-dev]\x1b[0m Hot reloading route:", filepath);
149
+
150
+ try {
151
+ // Invalidate the module in Vite's cache
152
+ const mod = viteServer.moduleGraph.getModuleById(filepath);
153
+ if (mod) {
154
+ viteServer.moduleGraph.invalidateModule(mod);
155
+ }
156
+
157
+ // Re-import the module with cache busting
158
+ const timestamp = Date.now();
159
+ const moduleUrl = `${filepath}?t=${timestamp}`;
160
+
161
+ // Import the fresh module
162
+ const freshModule = await viteServer.ssrLoadModule(moduleUrl);
163
+
164
+ // Find the router export (could be named 'router', 'default', or end with 'Router')
165
+ let newRouter = freshModule.router || freshModule.default;
166
+ if (!newRouter) {
167
+ for (const key of Object.keys(freshModule)) {
168
+ if (key.endsWith("Router") && typeof freshModule[key]?.getRoutes === "function") {
169
+ newRouter = freshModule[key];
170
+ break;
171
+ }
172
+ }
173
+ }
174
+
175
+ if (newRouter && typeof newRouter.getRoutes === "function") {
176
+ // Get the router prefix
177
+ const prefix = newRouter.getPrefix?.() || "";
178
+ if (prefix) {
179
+ appServer.reloadRouter(prefix, newRouter);
180
+ console.log("\x1b[32m[donkeylabs-dev]\x1b[0m Route hot reload complete:", prefix);
181
+ } else {
182
+ // If no prefix, rebuild all routes
183
+ appServer.rebuildRouteMap();
184
+ console.log("\x1b[32m[donkeylabs-dev]\x1b[0m Route map rebuilt");
185
+ }
186
+ } else {
187
+ console.warn("\x1b[33m[donkeylabs-dev]\x1b[0m No router export found in:", filepath);
188
+ }
189
+ } catch (err: any) {
190
+ console.error("\x1b[31m[donkeylabs-dev]\x1b[0m Hot reload error:", err.message);
191
+ }
192
+ }
193
+
194
+ function debouncedHotReload(filepath: string) {
195
+ if (hotReloadTimer) clearTimeout(hotReloadTimer);
196
+ hotReloadTimer = setTimeout(() => hotReloadRoute(filepath), HOT_RELOAD_DEBOUNCE_MS);
197
+ }
111
198
 
112
199
  function shouldIgnoreFile(filename: string): boolean {
113
200
  return IGNORED_PATTERNS.some((pattern) => pattern.test(filename));
@@ -122,7 +209,7 @@ export function donkeylabsDev(options: DevPluginOptions = {}): Plugin {
122
209
  console.log("\x1b[36m[donkeylabs-dev]\x1b[0m Server files changed, regenerating types...");
123
210
 
124
211
  try {
125
- await execAsync("bunx donkeylabs generate");
212
+ await execAsync("bun run gen:types");
126
213
  console.log("\x1b[32m[donkeylabs-dev]\x1b[0m Types regenerated successfully");
127
214
  } catch (e: any) {
128
215
  console.error("\x1b[31m[donkeylabs-dev]\x1b[0m Error regenerating types:", e.message);
@@ -132,23 +219,65 @@ export function donkeylabsDev(options: DevPluginOptions = {}): Plugin {
132
219
  }
133
220
  }
134
221
 
222
+ async function ensureTypesGenerated() {
223
+ // Check if the client file exists (common locations)
224
+ const clientPaths = [
225
+ resolve(process.cwd(), "src/lib/api.ts"),
226
+ resolve(process.cwd(), "src/api.ts"),
227
+ ];
228
+
229
+ const clientExists = clientPaths.some((p) => existsSync(p));
230
+ if (clientExists) return;
231
+
232
+ console.log("\x1b[36m[donkeylabs-dev]\x1b[0m Generated client not found, running initial type generation...");
233
+ isGenerating = true;
234
+
235
+ try {
236
+ await execAsync("bun run gen:types");
237
+ console.log("\x1b[32m[donkeylabs-dev]\x1b[0m Initial types generated successfully");
238
+ } catch (e: any) {
239
+ console.warn("\x1b[33m[donkeylabs-dev]\x1b[0m Initial type generation failed:", e.message);
240
+ console.warn("\x1b[33m[donkeylabs-dev]\x1b[0m Run 'bun run gen:types' manually to generate types");
241
+ } finally {
242
+ isGenerating = false;
243
+ lastGenerationTime = Date.now();
244
+ }
245
+ }
246
+
135
247
  function debouncedRegenerate() {
136
248
  if (debounceTimer) clearTimeout(debounceTimer);
137
249
  debounceTimer = setTimeout(regenerateTypes, DEBOUNCE_MS);
138
250
  }
139
251
 
140
252
  function startFileWatcher() {
141
- if (!watchTypes || fileWatcher) return;
253
+ if (fileWatcher) return;
254
+ if (!watchTypes && !hotReloadRoutes) return;
142
255
 
143
256
  const watchPath = resolve(process.cwd(), watchDir);
144
257
  try {
145
258
  fileWatcher = watch(watchPath, { recursive: true }, (_eventType, filename) => {
146
259
  if (!filename) return;
147
- if (!filename.endsWith(".ts")) return;
260
+ if (!filename.endsWith(".ts") && !filename.endsWith(".js")) return;
148
261
  if (shouldIgnoreFile(filename)) return;
149
- debouncedRegenerate();
262
+
263
+ const fullPath = join(watchPath, filename);
264
+
265
+ // Check if this is a route file for hot reload
266
+ if (hotReloadRoutes && isRouteFile(filename)) {
267
+ debouncedHotReload(fullPath);
268
+ }
269
+
270
+ // Also trigger type regeneration
271
+ if (watchTypes) {
272
+ debouncedRegenerate();
273
+ }
150
274
  });
151
- console.log(`\x1b[36m[donkeylabs-dev]\x1b[0m Watching ${watchDir} for changes...`);
275
+
276
+ const features = [
277
+ watchTypes ? "type generation" : null,
278
+ hotReloadRoutes ? "hot reload" : null,
279
+ ].filter(Boolean).join(", ");
280
+ console.log(`\x1b[36m[donkeylabs-dev]\x1b[0m Watching ${watchDir} for ${features}...`);
152
281
  } catch (err) {
153
282
  console.warn(`\x1b[33m[donkeylabs-dev]\x1b[0m Could not watch ${watchDir}:`, err);
154
283
  }
@@ -156,6 +285,7 @@ export function donkeylabsDev(options: DevPluginOptions = {}): Plugin {
156
285
 
157
286
  function stopFileWatcher() {
158
287
  if (debounceTimer) clearTimeout(debounceTimer);
288
+ if (hotReloadTimer) clearTimeout(hotReloadTimer);
159
289
  if (fileWatcher) {
160
290
  fileWatcher.close();
161
291
  fileWatcher = null;
@@ -183,7 +313,13 @@ export function donkeylabsDev(options: DevPluginOptions = {}): Plugin {
183
313
  async configureServer(server: ViteDevServer) {
184
314
  const serverEntryResolved = resolve(process.cwd(), serverEntry);
185
315
 
186
- // Start file watcher for auto type regeneration
316
+ // Store vite server reference for hot reload
317
+ viteServer = server;
318
+
319
+ // Ensure types are generated on first start (if client file doesn't exist)
320
+ await ensureTypesGenerated();
321
+
322
+ // Start file watcher for auto type regeneration and hot reload
187
323
  startFileWatcher();
188
324
 
189
325
  if (isBunRuntime) {
@@ -576,6 +712,7 @@ export function donkeylabsDev(options: DevPluginOptions = {}): Plugin {
576
712
 
577
713
  async closeBundle() {
578
714
  stopFileWatcher();
715
+ viteServer = null;
579
716
  if (backendProcess) {
580
717
  backendProcess.kill();
581
718
  backendProcess = null;