@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/adapter-sveltekit",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
4
4
  "type": "module",
5
5
  "description": "SvelteKit adapter for @donkeylabs/server - seamless SSR/browser API integration",
6
6
  "main": "./src/index.ts",
@@ -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.
@@ -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 => r.handler === "typed")
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 allMethods = [...methodEntries, ...rawMethodEntries];
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" && url.startsWith("/sse")) {
118
+ // Handle SSE endpoint
119
+ if (req.method === "GET" && pathname === "/sse") {
118
120
  if (!serverReady || !appServer) return next();
119
121
 
120
- const fullUrl = new URL(url, "http://localhost");
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 only)
163
- if (req.method === "POST" && /^\/[a-zA-Z][a-zA-Z0-9_.]*$/.test(url)) {
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 = url.slice(1);
167
+ const routeName = pathname.slice(1);
167
168
  if (!appServer.hasRoute(routeName)) return next();
168
169
 
169
- // Collect body
170
- let body = "";
171
- req.on("data", (chunk: any) => (body += chunk));
172
- req.on("end", async () => {
173
- try {
174
- const input = body ? JSON.parse(body) : {};
175
- const ip = req.socket?.remoteAddress || "127.0.0.1";
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
- const result = await appServer.callRoute(routeName, input, ip);
196
+ return new Request(fullUrl, { method: "GET", headers });
197
+ };
178
198
 
179
- res.setHeader("Content-Type", "application/json");
180
- res.setHeader("Access-Control-Allow-Origin", "*");
181
- res.end(JSON.stringify(result));
182
- } catch (err: any) {
183
- res.statusCode = err.status || 500;
184
- res.setHeader("Content-Type", "application/json");
185
- res.end(JSON.stringify({ error: err.message || "Internal error" }));
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 isApiRoute = req.method === "POST" && /^\/[a-zA-Z][a-zA-Z0-9_.]*$/.test(url);
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
- req.pipe(proxyReq);
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