@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 +1 -1
- package/src/generator/index.ts +217 -167
- package/src/vite.ts +145 -8
package/package.json
CHANGED
package/src/generator/index.ts
CHANGED
|
@@ -70,200 +70,250 @@ export function createApi(options?: ClientOptions) {
|
|
|
70
70
|
};
|
|
71
71
|
|
|
72
72
|
/**
|
|
73
|
-
*
|
|
73
|
+
* Namespace tree node for building nested client structure
|
|
74
74
|
*/
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
interface NamespaceTreeNode {
|
|
76
|
+
methods: { methodDef: string; typeDef: string }[];
|
|
77
|
+
children: Map<string, NamespaceTreeNode>;
|
|
78
|
+
}
|
|
77
79
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
const methodBlocks: string[] = [];
|
|
122
|
+
return tree;
|
|
123
|
+
}
|
|
112
124
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
.filter(
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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("
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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;
|