@donkeylabs/adapter-sveltekit 1.0.1 → 1.1.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/package.json +1 -1
- package/src/client/index.ts +280 -0
- package/src/generator/index.ts +90 -4
- package/src/vite.ts +96 -27
package/package.json
CHANGED
package/src/client/index.ts
CHANGED
|
@@ -24,6 +24,116 @@ export interface SSESubscription {
|
|
|
24
24
|
unsubscribe: () => void;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Type-safe SSE connection wrapper.
|
|
29
|
+
* Provides typed event handlers with automatic JSON parsing.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```ts
|
|
33
|
+
* const connection = api.notifications.subscribe({ userId: "123" });
|
|
34
|
+
*
|
|
35
|
+
* // Typed event handler - returns unsubscribe function
|
|
36
|
+
* const unsubscribe = connection.on("notification", (data) => {
|
|
37
|
+
* console.log(data.message); // Fully typed!
|
|
38
|
+
* });
|
|
39
|
+
*
|
|
40
|
+
* // Later: unsubscribe from this specific handler
|
|
41
|
+
* unsubscribe();
|
|
42
|
+
*
|
|
43
|
+
* // Close entire connection
|
|
44
|
+
* connection.close();
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export class SSEConnection<TEvents extends Record<string, any> = Record<string, any>> {
|
|
48
|
+
private eventSource: EventSource;
|
|
49
|
+
private handlers = new Map<string, Set<(data: any) => void>>();
|
|
50
|
+
|
|
51
|
+
constructor(url: string) {
|
|
52
|
+
this.eventSource = new EventSource(url);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Register a typed event handler.
|
|
57
|
+
* @returns Unsubscribe function to remove this specific handler
|
|
58
|
+
*/
|
|
59
|
+
on<K extends keyof TEvents>(
|
|
60
|
+
event: K & string,
|
|
61
|
+
handler: (data: TEvents[K]) => void
|
|
62
|
+
): () => void {
|
|
63
|
+
if (!this.handlers.has(event)) {
|
|
64
|
+
this.handlers.set(event, new Set());
|
|
65
|
+
|
|
66
|
+
// Add EventSource listener for this event type
|
|
67
|
+
this.eventSource.addEventListener(event, (e: MessageEvent) => {
|
|
68
|
+
const handlers = this.handlers.get(event);
|
|
69
|
+
if (handlers) {
|
|
70
|
+
let data: any;
|
|
71
|
+
try {
|
|
72
|
+
data = JSON.parse(e.data);
|
|
73
|
+
} catch {
|
|
74
|
+
data = e.data;
|
|
75
|
+
}
|
|
76
|
+
for (const h of handlers) {
|
|
77
|
+
h(data);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
this.handlers.get(event)!.add(handler);
|
|
84
|
+
|
|
85
|
+
// Return unsubscribe function
|
|
86
|
+
return () => {
|
|
87
|
+
const handlers = this.handlers.get(event);
|
|
88
|
+
if (handlers) {
|
|
89
|
+
handlers.delete(handler);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Register error handler
|
|
96
|
+
*/
|
|
97
|
+
onError(handler: (event: Event) => void): () => void {
|
|
98
|
+
this.eventSource.onerror = handler;
|
|
99
|
+
return () => {
|
|
100
|
+
this.eventSource.onerror = null;
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Register open handler (connection established)
|
|
106
|
+
*/
|
|
107
|
+
onOpen(handler: (event: Event) => void): () => void {
|
|
108
|
+
this.eventSource.onopen = handler;
|
|
109
|
+
return () => {
|
|
110
|
+
this.eventSource.onopen = null;
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get connection state
|
|
116
|
+
*/
|
|
117
|
+
get readyState(): number {
|
|
118
|
+
return this.eventSource.readyState;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Check if connected
|
|
123
|
+
*/
|
|
124
|
+
get connected(): boolean {
|
|
125
|
+
return this.eventSource.readyState === EventSource.OPEN;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Close the SSE connection
|
|
130
|
+
*/
|
|
131
|
+
close(): void {
|
|
132
|
+
this.eventSource.close();
|
|
133
|
+
this.handlers.clear();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
27
137
|
/**
|
|
28
138
|
* Base class for unified API clients.
|
|
29
139
|
* Extend this class with your generated route methods.
|
|
@@ -104,6 +214,176 @@ export class UnifiedApiClientBase {
|
|
|
104
214
|
});
|
|
105
215
|
}
|
|
106
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Make a stream request (validated input, Response output).
|
|
219
|
+
* For streaming, binary data, or custom content-type responses.
|
|
220
|
+
*
|
|
221
|
+
* By default uses POST with JSON body. For browser compatibility
|
|
222
|
+
* (video src, image src, download links), use streamUrl() instead.
|
|
223
|
+
*/
|
|
224
|
+
protected async streamRequest<TInput>(
|
|
225
|
+
route: string,
|
|
226
|
+
input: TInput,
|
|
227
|
+
options?: RequestOptions
|
|
228
|
+
): Promise<Response> {
|
|
229
|
+
const url = `${this.baseUrl}/${route}`;
|
|
230
|
+
const fetchFn = this.customFetch ?? fetch;
|
|
231
|
+
|
|
232
|
+
const response = await fetchFn(url, {
|
|
233
|
+
method: "POST",
|
|
234
|
+
headers: {
|
|
235
|
+
"Content-Type": "application/json",
|
|
236
|
+
...options?.headers,
|
|
237
|
+
},
|
|
238
|
+
body: JSON.stringify(input),
|
|
239
|
+
signal: options?.signal,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Unlike typed requests, we return the raw Response
|
|
243
|
+
// Error handling is left to the caller
|
|
244
|
+
return response;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Get the URL for a stream endpoint (for browser src attributes).
|
|
249
|
+
* Returns a URL with query params that can be used in:
|
|
250
|
+
* - <video src={url}>
|
|
251
|
+
* - <img src={url}>
|
|
252
|
+
* - <a href={url} download>
|
|
253
|
+
* - window.open(url)
|
|
254
|
+
*/
|
|
255
|
+
protected streamUrl<TInput>(route: string, input?: TInput): string {
|
|
256
|
+
let url = `${this.baseUrl}/${route}`;
|
|
257
|
+
|
|
258
|
+
if (input && typeof input === "object") {
|
|
259
|
+
const params = new URLSearchParams();
|
|
260
|
+
for (const [key, value] of Object.entries(input)) {
|
|
261
|
+
params.set(key, typeof value === "string" ? value : JSON.stringify(value));
|
|
262
|
+
}
|
|
263
|
+
url += `?${params.toString()}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return url;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Fetch a stream via GET with query params.
|
|
271
|
+
* Alternative to streamRequest() for cases where GET is preferred.
|
|
272
|
+
*/
|
|
273
|
+
protected async streamGet<TInput>(
|
|
274
|
+
route: string,
|
|
275
|
+
input?: TInput,
|
|
276
|
+
options?: RequestOptions
|
|
277
|
+
): Promise<Response> {
|
|
278
|
+
const url = this.streamUrl(route, input);
|
|
279
|
+
const fetchFn = this.customFetch ?? fetch;
|
|
280
|
+
|
|
281
|
+
return fetchFn(url, {
|
|
282
|
+
method: "GET",
|
|
283
|
+
headers: options?.headers,
|
|
284
|
+
signal: options?.signal,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Connect to an SSE endpoint.
|
|
290
|
+
* Returns a typed SSEConnection for handling server-sent events.
|
|
291
|
+
*/
|
|
292
|
+
protected sseConnect<TInput, TEvents extends Record<string, any> = Record<string, any>>(
|
|
293
|
+
route: string,
|
|
294
|
+
input?: TInput
|
|
295
|
+
): SSEConnection<TEvents> {
|
|
296
|
+
let url = `${this.baseUrl}/${route}`;
|
|
297
|
+
|
|
298
|
+
// Add input as query params for GET request
|
|
299
|
+
if (input && typeof input === "object") {
|
|
300
|
+
const params = new URLSearchParams();
|
|
301
|
+
for (const [key, value] of Object.entries(input)) {
|
|
302
|
+
params.set(key, typeof value === "string" ? value : JSON.stringify(value));
|
|
303
|
+
}
|
|
304
|
+
url += `?${params.toString()}`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return new SSEConnection<TEvents>(url);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Make a formData request (file uploads with validated fields).
|
|
312
|
+
*/
|
|
313
|
+
protected async formDataRequest<TFields, TOutput>(
|
|
314
|
+
route: string,
|
|
315
|
+
fields: TFields,
|
|
316
|
+
files: File[],
|
|
317
|
+
options?: RequestOptions
|
|
318
|
+
): Promise<TOutput> {
|
|
319
|
+
const url = `${this.baseUrl}/${route}`;
|
|
320
|
+
const fetchFn = this.customFetch ?? fetch;
|
|
321
|
+
|
|
322
|
+
const formData = new FormData();
|
|
323
|
+
|
|
324
|
+
// Add fields
|
|
325
|
+
if (fields && typeof fields === "object") {
|
|
326
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
327
|
+
formData.append(key, typeof value === "string" ? value : JSON.stringify(value));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Add files
|
|
332
|
+
for (const file of files) {
|
|
333
|
+
formData.append("file", file);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const response = await fetchFn(url, {
|
|
337
|
+
method: "POST",
|
|
338
|
+
headers: options?.headers,
|
|
339
|
+
body: formData,
|
|
340
|
+
signal: options?.signal,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
if (!response.ok) {
|
|
344
|
+
const error = await response.json().catch(() => ({ error: "Unknown error" }));
|
|
345
|
+
throw new Error(error.message || error.error || `HTTP ${response.status}`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return response.json();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Make an HTML request (returns HTML string).
|
|
353
|
+
*/
|
|
354
|
+
protected async htmlRequest<TInput>(
|
|
355
|
+
route: string,
|
|
356
|
+
input?: TInput,
|
|
357
|
+
options?: RequestOptions
|
|
358
|
+
): Promise<string> {
|
|
359
|
+
let url = `${this.baseUrl}/${route}`;
|
|
360
|
+
const fetchFn = this.customFetch ?? fetch;
|
|
361
|
+
|
|
362
|
+
// Add input as query params for GET request
|
|
363
|
+
if (input && typeof input === "object") {
|
|
364
|
+
const params = new URLSearchParams();
|
|
365
|
+
for (const [key, value] of Object.entries(input)) {
|
|
366
|
+
params.set(key, typeof value === "string" ? value : JSON.stringify(value));
|
|
367
|
+
}
|
|
368
|
+
url += `?${params.toString()}`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const response = await fetchFn(url, {
|
|
372
|
+
method: "GET",
|
|
373
|
+
headers: {
|
|
374
|
+
Accept: "text/html",
|
|
375
|
+
...options?.headers,
|
|
376
|
+
},
|
|
377
|
+
signal: options?.signal,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
if (!response.ok) {
|
|
381
|
+
throw new Error(`HTTP ${response.status}`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return response.text();
|
|
385
|
+
}
|
|
386
|
+
|
|
107
387
|
/**
|
|
108
388
|
* SSE (Server-Sent Events) subscription.
|
|
109
389
|
* Only works in the browser.
|
package/src/generator/index.ts
CHANGED
|
@@ -33,7 +33,7 @@ function isRouteInfo(route: RouteInfo | ExtractedRoute): route is RouteInfo {
|
|
|
33
33
|
/** SvelteKit-specific generator options */
|
|
34
34
|
export const svelteKitGeneratorOptions: ClientGeneratorOptions = {
|
|
35
35
|
baseImport:
|
|
36
|
-
'import { UnifiedApiClientBase, type ClientOptions } from "@donkeylabs/adapter-sveltekit/client";',
|
|
36
|
+
'import { UnifiedApiClientBase, SSEConnection, type ClientOptions } from "@donkeylabs/adapter-sveltekit/client";',
|
|
37
37
|
baseClass: "UnifiedApiClientBase",
|
|
38
38
|
constructorSignature: "options?: ClientOptions",
|
|
39
39
|
constructorBody: "super(options);",
|
|
@@ -114,9 +114,9 @@ function generateTypedSvelteKitClient(routes: RouteInfo[]): string {
|
|
|
114
114
|
const pascalNs = namespace === "_root" ? "Root" : toPascalCase(namespace);
|
|
115
115
|
const methodNs = namespace === "_root" ? "_root" : namespace;
|
|
116
116
|
|
|
117
|
-
// Generate types for this namespace
|
|
117
|
+
// Generate types for this namespace (typed, stream, sse, formData, html routes have input types)
|
|
118
118
|
const typeEntries = nsRoutes
|
|
119
|
-
.filter(r =>
|
|
119
|
+
.filter(r => ["typed", "stream", "sse", "formData", "html"].includes(r.handler))
|
|
120
120
|
.map(r => {
|
|
121
121
|
const pascalRoute = toPascalCase(r.routeName);
|
|
122
122
|
// If inputSource starts with "z.", it's a Zod source string - convert it
|
|
@@ -124,6 +124,36 @@ function generateTypedSvelteKitClient(routes: RouteInfo[]): string {
|
|
|
124
124
|
const inputType = r.inputSource
|
|
125
125
|
? (r.inputSource.trim().startsWith("z.") ? zodToTypeScript(r.inputSource) : r.inputSource)
|
|
126
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} {
|
|
131
|
+
export type Input = Expand<${inputType}>;
|
|
132
|
+
}
|
|
133
|
+
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} {
|
|
150
|
+
export type Input = Expand<${inputType}>;
|
|
151
|
+
export type Events = ${eventsType};
|
|
152
|
+
}
|
|
153
|
+
export type ${pascalRoute} = { Input: ${pascalRoute}.Input; Events: ${pascalRoute}.Events };`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// typed and formData have both Input and Output
|
|
127
157
|
const outputType = r.outputSource
|
|
128
158
|
? (r.outputSource.trim().startsWith("z.") ? zodToTypeScript(r.outputSource) : r.outputSource)
|
|
129
159
|
: "unknown";
|
|
@@ -159,7 +189,63 @@ function generateTypedSvelteKitClient(routes: RouteInfo[]): string {
|
|
|
159
189
|
return ` ${methodName}: (init?: RequestInit): Promise<Response> => this.rawRequest("${fullRouteName}", init)`;
|
|
160
190
|
});
|
|
161
191
|
|
|
162
|
-
const
|
|
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): POST request (programmatic)
|
|
201
|
+
// - url(input): GET URL for browser (video src, img src, download links)
|
|
202
|
+
// - get(input): GET fetch request
|
|
203
|
+
return ` ${methodName}: {
|
|
204
|
+
/** POST request with JSON body (programmatic) */
|
|
205
|
+
fetch: (input: ${inputType}): Promise<Response> => this.streamRequest("${fullRouteName}", input),
|
|
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}): Promise<Response> => this.streamGet("${fullRouteName}", input),
|
|
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
|
+
return ` ${methodName}: (${hasInput ? `input: ${inputType}` : ""}): SSEConnection<${eventsType}> => this.sseConnect("${fullRouteName}"${hasInput ? ", input" : ""})`;
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const formDataMethodEntries = nsRoutes
|
|
227
|
+
.filter(r => r.handler === "formData")
|
|
228
|
+
.map(r => {
|
|
229
|
+
const methodName = toCamelCase(r.routeName);
|
|
230
|
+
const pascalRoute = toPascalCase(r.routeName);
|
|
231
|
+
const inputType = r.inputSource ? `Routes.${pascalNs}.${pascalRoute}.Input` : "Record<string, any>";
|
|
232
|
+
const outputType = r.outputSource ? `Routes.${pascalNs}.${pascalRoute}.Output` : "unknown";
|
|
233
|
+
const fullRouteName = commonPrefix ? `${commonPrefix}.${r.name}` : r.name;
|
|
234
|
+
return ` ${methodName}: (fields: ${inputType}, files: File[]): Promise<${outputType}> => this.formDataRequest("${fullRouteName}", fields, files)`;
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const htmlMethodEntries = nsRoutes
|
|
238
|
+
.filter(r => r.handler === "html")
|
|
239
|
+
.map(r => {
|
|
240
|
+
const methodName = toCamelCase(r.routeName);
|
|
241
|
+
const pascalRoute = toPascalCase(r.routeName);
|
|
242
|
+
const hasInput = r.inputSource;
|
|
243
|
+
const inputType = hasInput ? `Routes.${pascalNs}.${pascalRoute}.Input` : "Record<string, never>";
|
|
244
|
+
const fullRouteName = commonPrefix ? `${commonPrefix}.${r.name}` : r.name;
|
|
245
|
+
return ` ${methodName}: (${hasInput ? `input: ${inputType}` : ""}): Promise<string> => this.htmlRequest("${fullRouteName}"${hasInput ? ", input" : ""})`;
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const allMethods = [...methodEntries, ...rawMethodEntries, ...streamMethodEntries, ...sseMethodEntries, ...formDataMethodEntries, ...htmlMethodEntries];
|
|
163
249
|
if (allMethods.length > 0) {
|
|
164
250
|
if (namespace === "_root") {
|
|
165
251
|
// Root-level methods go directly on the class
|
package/src/vite.ts
CHANGED
|
@@ -112,13 +112,14 @@ export function donkeylabsDev(options: DevPluginOptions = {}): Plugin {
|
|
|
112
112
|
// In-process request handler
|
|
113
113
|
const inProcessMiddleware = async (req: any, res: any, next: any) => {
|
|
114
114
|
const url = req.url || "/";
|
|
115
|
+
const urlObj = new URL(url, "http://localhost");
|
|
116
|
+
const pathname = urlObj.pathname;
|
|
115
117
|
|
|
116
|
-
// Handle SSE
|
|
117
|
-
if (req.method === "GET" &&
|
|
118
|
+
// Handle SSE endpoint
|
|
119
|
+
if (req.method === "GET" && pathname === "/sse") {
|
|
118
120
|
if (!serverReady || !appServer) return next();
|
|
119
121
|
|
|
120
|
-
const
|
|
121
|
-
const channels = fullUrl.searchParams.get("channels")?.split(",").filter(Boolean) || [];
|
|
122
|
+
const channels = urlObj.searchParams.get("channels")?.split(",").filter(Boolean) || [];
|
|
122
123
|
const lastEventId = req.headers["last-event-id"] || undefined;
|
|
123
124
|
|
|
124
125
|
const { client, response } = appServer.getCore().sse.addClient({ lastEventId });
|
|
@@ -159,32 +160,91 @@ export function donkeylabsDev(options: DevPluginOptions = {}): Plugin {
|
|
|
159
160
|
return; // Don't call next()
|
|
160
161
|
}
|
|
161
162
|
|
|
162
|
-
// Handle API routes (POST
|
|
163
|
-
if (req.method === "POST" && /^\/[a-zA-Z][a-zA-Z0-9_.]*$/.test(
|
|
163
|
+
// Handle API routes (GET or POST for route names like /routeName.action)
|
|
164
|
+
if ((req.method === "GET" || req.method === "POST") && /^\/[a-zA-Z][a-zA-Z0-9_.]*$/.test(pathname)) {
|
|
164
165
|
if (!serverReady || !appServer) return next();
|
|
165
166
|
|
|
166
|
-
const routeName =
|
|
167
|
+
const routeName = pathname.slice(1);
|
|
167
168
|
if (!appServer.hasRoute(routeName)) return next();
|
|
168
169
|
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
170
|
+
// Build a proper Request object to pass to handleRequest
|
|
171
|
+
const buildRequest = async (): Promise<Request> => {
|
|
172
|
+
const fullUrl = `http://localhost${url}`;
|
|
173
|
+
const headers = new Headers();
|
|
174
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
175
|
+
if (typeof value === "string") {
|
|
176
|
+
headers.set(key, value);
|
|
177
|
+
} else if (Array.isArray(value)) {
|
|
178
|
+
for (const v of value) headers.append(key, v);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (req.method === "POST") {
|
|
183
|
+
// Collect body for POST
|
|
184
|
+
const chunks: Buffer[] = [];
|
|
185
|
+
for await (const chunk of req) {
|
|
186
|
+
chunks.push(chunk);
|
|
187
|
+
}
|
|
188
|
+
const body = Buffer.concat(chunks);
|
|
189
|
+
return new Request(fullUrl, {
|
|
190
|
+
method: "POST",
|
|
191
|
+
headers,
|
|
192
|
+
body,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
176
195
|
|
|
177
|
-
|
|
196
|
+
return new Request(fullUrl, { method: "GET", headers });
|
|
197
|
+
};
|
|
178
198
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
199
|
+
try {
|
|
200
|
+
const request = await buildRequest();
|
|
201
|
+
const ip = req.socket?.remoteAddress || "127.0.0.1";
|
|
202
|
+
|
|
203
|
+
// Use handleRequest which properly handles all handler types (typed, raw, stream, sse, html)
|
|
204
|
+
const response = await appServer.handleRequest(
|
|
205
|
+
request,
|
|
206
|
+
routeName,
|
|
207
|
+
ip,
|
|
208
|
+
{ corsHeaders: { "Access-Control-Allow-Origin": "*" } }
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
if (!response) {
|
|
212
|
+
return next();
|
|
186
213
|
}
|
|
187
|
-
|
|
214
|
+
|
|
215
|
+
// Stream the response back
|
|
216
|
+
res.statusCode = response.status;
|
|
217
|
+
for (const [key, value] of response.headers) {
|
|
218
|
+
res.setHeader(key, value);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Handle body streaming
|
|
222
|
+
if (response.body) {
|
|
223
|
+
const reader = response.body.getReader();
|
|
224
|
+
const pump = async () => {
|
|
225
|
+
try {
|
|
226
|
+
while (true) {
|
|
227
|
+
const { done, value } = await reader.read();
|
|
228
|
+
if (done) {
|
|
229
|
+
res.end();
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
res.write(value);
|
|
233
|
+
}
|
|
234
|
+
} catch {
|
|
235
|
+
res.end();
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
await pump();
|
|
239
|
+
} else {
|
|
240
|
+
res.end();
|
|
241
|
+
}
|
|
242
|
+
} catch (err: any) {
|
|
243
|
+
console.error("[donkeylabs-dev] Request error:", err);
|
|
244
|
+
res.statusCode = err.status || 500;
|
|
245
|
+
res.setHeader("Content-Type", "application/json");
|
|
246
|
+
res.end(JSON.stringify({ error: err.message || "Internal error" }));
|
|
247
|
+
}
|
|
188
248
|
|
|
189
249
|
return; // Don't call next()
|
|
190
250
|
}
|
|
@@ -286,10 +346,13 @@ export function donkeylabsDev(options: DevPluginOptions = {}): Plugin {
|
|
|
286
346
|
}, 10000);
|
|
287
347
|
});
|
|
288
348
|
|
|
289
|
-
// Proxy middleware
|
|
349
|
+
// Proxy middleware - handles GET and POST for API routes
|
|
290
350
|
const proxyMiddleware = (req: any, res: any, next: any) => {
|
|
291
351
|
const url = req.url || "/";
|
|
292
|
-
const
|
|
352
|
+
const urlObj = new URL(url, "http://localhost");
|
|
353
|
+
const pathname = urlObj.pathname;
|
|
354
|
+
// API routes are GET or POST to paths like /routeName.action
|
|
355
|
+
const isApiRoute = (req.method === "GET" || req.method === "POST") && /^\/[a-zA-Z][a-zA-Z0-9_.]*$/.test(pathname);
|
|
293
356
|
|
|
294
357
|
if (!isApiRoute) return next();
|
|
295
358
|
|
|
@@ -298,7 +361,7 @@ export function donkeylabsDev(options: DevPluginOptions = {}): Plugin {
|
|
|
298
361
|
{
|
|
299
362
|
hostname: "localhost",
|
|
300
363
|
port: backendPort,
|
|
301
|
-
path: url,
|
|
364
|
+
path: url, // Include query string
|
|
302
365
|
method: req.method,
|
|
303
366
|
headers: { ...req.headers, host: `localhost:${backendPort}` },
|
|
304
367
|
},
|
|
@@ -308,6 +371,7 @@ export function donkeylabsDev(options: DevPluginOptions = {}): Plugin {
|
|
|
308
371
|
for (const [k, v] of Object.entries(proxyRes.headers)) {
|
|
309
372
|
if (v) res.setHeader(k, v);
|
|
310
373
|
}
|
|
374
|
+
// Stream response back (works for binary/streaming responses)
|
|
311
375
|
proxyRes.pipe(res);
|
|
312
376
|
}
|
|
313
377
|
);
|
|
@@ -318,7 +382,12 @@ export function donkeylabsDev(options: DevPluginOptions = {}): Plugin {
|
|
|
318
382
|
res.end(JSON.stringify({ error: "Backend unavailable" }));
|
|
319
383
|
});
|
|
320
384
|
|
|
321
|
-
|
|
385
|
+
// For POST, pipe the body; for GET, just end
|
|
386
|
+
if (req.method === "POST") {
|
|
387
|
+
req.pipe(proxyReq);
|
|
388
|
+
} else {
|
|
389
|
+
proxyReq.end();
|
|
390
|
+
}
|
|
322
391
|
});
|
|
323
392
|
};
|
|
324
393
|
|