keryx 0.16.3 → 0.17.0
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/actions/status.ts +25 -1
- package/actions/swagger.ts +20 -7
- package/classes/Action.ts +4 -0
- package/classes/Connection.ts +10 -2
- package/classes/StreamingResponse.ts +165 -0
- package/index.ts +1 -0
- package/initializers/mcp.ts +30 -0
- package/package.json +1 -1
- package/servers/web.ts +24 -0
- package/util/oauthTemplates.ts +1 -1
- package/util/webCompression.ts +4 -0
- package/util/webResponse.ts +5 -0
- package/util/webSocket.ts +29 -0
package/actions/status.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
import { Action, api } from "../api";
|
|
3
4
|
import { HTTP_METHOD } from "../classes/Action";
|
|
@@ -6,7 +7,7 @@ import packageJSON from "../package.json";
|
|
|
6
7
|
export class Status implements Action {
|
|
7
8
|
name = "status";
|
|
8
9
|
description =
|
|
9
|
-
"Returns server health and runtime information including the server name, process ID, package version, uptime in milliseconds,
|
|
10
|
+
"Returns server health and runtime information including the server name, process ID, package version, uptime in milliseconds, memory consumption in MB, and dependency health checks for the database and Redis. Does not require authentication.";
|
|
10
11
|
inputs = z.object({});
|
|
11
12
|
web = { route: "/status", method: HTTP_METHOD.GET };
|
|
12
13
|
|
|
@@ -14,12 +15,35 @@ export class Status implements Action {
|
|
|
14
15
|
const consumedMemoryMB =
|
|
15
16
|
Math.round((process.memoryUsage().heapUsed / 1024 / 1024) * 100) / 100;
|
|
16
17
|
|
|
18
|
+
let databaseHealthy = false;
|
|
19
|
+
try {
|
|
20
|
+
if (api.db?.db) {
|
|
21
|
+
await api.db.db.execute(sql`SELECT NOW()`);
|
|
22
|
+
databaseHealthy = true;
|
|
23
|
+
}
|
|
24
|
+
} catch {}
|
|
25
|
+
|
|
26
|
+
let redisHealthy = false;
|
|
27
|
+
try {
|
|
28
|
+
if (api.redis?.redis) {
|
|
29
|
+
await api.redis.redis.ping();
|
|
30
|
+
redisHealthy = true;
|
|
31
|
+
}
|
|
32
|
+
} catch {}
|
|
33
|
+
|
|
34
|
+
const healthy = databaseHealthy && redisHealthy;
|
|
35
|
+
|
|
17
36
|
return {
|
|
18
37
|
name: api.process.name,
|
|
19
38
|
pid: api.process.pid,
|
|
20
39
|
version: packageJSON.version,
|
|
21
40
|
uptime: new Date().getTime() - api.bootTime,
|
|
22
41
|
consumedMemoryMB,
|
|
42
|
+
healthy,
|
|
43
|
+
checks: {
|
|
44
|
+
database: databaseHealthy,
|
|
45
|
+
redis: redisHealthy,
|
|
46
|
+
},
|
|
23
47
|
};
|
|
24
48
|
}
|
|
25
49
|
}
|
package/actions/swagger.ts
CHANGED
|
@@ -182,18 +182,31 @@ export class Swagger implements Action {
|
|
|
182
182
|
|
|
183
183
|
// Build responses - use generated schema if available
|
|
184
184
|
const responses = JSON.parse(JSON.stringify(swaggerResponses));
|
|
185
|
-
|
|
186
|
-
if (
|
|
187
|
-
|
|
188
|
-
components.schemas[schemaName] = responseSchema;
|
|
185
|
+
|
|
186
|
+
if (action.web?.streaming) {
|
|
187
|
+
// Streaming endpoints return SSE
|
|
189
188
|
responses["200"] = {
|
|
190
|
-
description: "
|
|
189
|
+
description: "Server-Sent Events stream",
|
|
191
190
|
content: {
|
|
192
|
-
"
|
|
193
|
-
schema: {
|
|
191
|
+
"text/event-stream": {
|
|
192
|
+
schema: { type: "string" },
|
|
194
193
|
},
|
|
195
194
|
},
|
|
196
195
|
};
|
|
196
|
+
} else {
|
|
197
|
+
const responseSchema = api.swagger?.responseSchemas[action.name];
|
|
198
|
+
if (responseSchema) {
|
|
199
|
+
const schemaName = `${action.name.replace(/:/g, "_")}_Response`;
|
|
200
|
+
components.schemas[schemaName] = responseSchema;
|
|
201
|
+
responses["200"] = {
|
|
202
|
+
description: "successful operation",
|
|
203
|
+
content: {
|
|
204
|
+
"application/json": {
|
|
205
|
+
schema: { $ref: `#/components/schemas/${schemaName}` },
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
197
210
|
}
|
|
198
211
|
|
|
199
212
|
// Add path/method
|
package/classes/Action.ts
CHANGED
|
@@ -81,6 +81,8 @@ export type ActionConstructorInputs = {
|
|
|
81
81
|
route?: RegExp | string;
|
|
82
82
|
/** HTTP method to bind the route to */
|
|
83
83
|
method?: HTTP_METHOD;
|
|
84
|
+
/** When true, Swagger documents this endpoint as returning `text/event-stream` instead of JSON */
|
|
85
|
+
streaming?: boolean;
|
|
84
86
|
};
|
|
85
87
|
|
|
86
88
|
/** Per-action timeout in ms (overrides global `config.server.web.actionTimeout`; 0 disables) */
|
|
@@ -136,6 +138,7 @@ export abstract class Action {
|
|
|
136
138
|
web?: {
|
|
137
139
|
route: RegExp | string;
|
|
138
140
|
method: HTTP_METHOD;
|
|
141
|
+
streaming?: boolean;
|
|
139
142
|
};
|
|
140
143
|
timeout?: number;
|
|
141
144
|
task?: {
|
|
@@ -152,6 +155,7 @@ export abstract class Action {
|
|
|
152
155
|
this.web = {
|
|
153
156
|
route: args.web?.route ?? `/${this.name}`,
|
|
154
157
|
method: args.web?.method ?? HTTP_METHOD.GET,
|
|
158
|
+
streaming: args.web?.streaming ?? false,
|
|
155
159
|
};
|
|
156
160
|
this.task = {
|
|
157
161
|
frequency: args.task?.frequency,
|
package/classes/Connection.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { RateLimitInfo } from "../middleware/rateLimit";
|
|
|
8
8
|
import { isSecret } from "../util/zodMixins";
|
|
9
9
|
import type { Action, ActionParams } from "./Action";
|
|
10
10
|
import { LogFormat } from "./Logger";
|
|
11
|
+
import { StreamingResponse } from "./StreamingResponse";
|
|
11
12
|
import { ErrorType, TypedError } from "./TypedError";
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -164,8 +165,15 @@ export class Connection<
|
|
|
164
165
|
formattedParams,
|
|
165
166
|
this,
|
|
166
167
|
);
|
|
167
|
-
if (middlewareResponse && middlewareResponse?.updatedResponse)
|
|
168
|
-
response
|
|
168
|
+
if (middlewareResponse && middlewareResponse?.updatedResponse) {
|
|
169
|
+
if (response instanceof StreamingResponse) {
|
|
170
|
+
logger.warn(
|
|
171
|
+
`Middleware cannot replace a StreamingResponse for action '${actionName}'`,
|
|
172
|
+
);
|
|
173
|
+
} else {
|
|
174
|
+
response = middlewareResponse.updatedResponse;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
169
177
|
}
|
|
170
178
|
}
|
|
171
179
|
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming response types for actions that need to send data incrementally.
|
|
3
|
+
* Actions return a `StreamingResponse` from `run()` to stream over HTTP (SSE or chunked),
|
|
4
|
+
* WebSocket (incremental messages), or MCP (logging messages + accumulated result).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const encoder = new TextEncoder();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Base class for streaming responses. Actions return this from `run()` to signal
|
|
11
|
+
* that the response should be streamed rather than JSON-serialized.
|
|
12
|
+
*
|
|
13
|
+
* Use the static factories `StreamingResponse.sse()` or `StreamingResponse.stream()`
|
|
14
|
+
* rather than constructing directly.
|
|
15
|
+
*/
|
|
16
|
+
export class StreamingResponse {
|
|
17
|
+
/** The underlying readable stream delivered to the HTTP response body. */
|
|
18
|
+
readonly stream: ReadableStream<Uint8Array>;
|
|
19
|
+
/** Content-Type header for this streaming response. */
|
|
20
|
+
readonly contentType: string;
|
|
21
|
+
/** Extra headers to include in the HTTP response. */
|
|
22
|
+
readonly headers: Record<string, string>;
|
|
23
|
+
/** Called when the stream closes (used internally for connection cleanup). */
|
|
24
|
+
onClose?: () => void;
|
|
25
|
+
|
|
26
|
+
constructor(
|
|
27
|
+
stream: ReadableStream<Uint8Array>,
|
|
28
|
+
contentType: string,
|
|
29
|
+
headers: Record<string, string> = {},
|
|
30
|
+
) {
|
|
31
|
+
this.stream = stream;
|
|
32
|
+
this.contentType = contentType;
|
|
33
|
+
this.headers = headers;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Create a Server-Sent Events streaming response. Returns an `SSEResponse`
|
|
38
|
+
* with `send()` and `close()` methods for writing SSE-formatted events.
|
|
39
|
+
*
|
|
40
|
+
* @param options - Optional extra headers to include in the response.
|
|
41
|
+
* @returns A new `SSEResponse` ready to send events.
|
|
42
|
+
*/
|
|
43
|
+
static sse(options?: { headers?: Record<string, string> }): SSEResponse {
|
|
44
|
+
return new SSEResponse(options?.headers);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Wrap an existing `ReadableStream` as a streaming response for binary or
|
|
49
|
+
* chunked transfer (e.g., file downloads, proxied responses).
|
|
50
|
+
*
|
|
51
|
+
* @param readableStream - The stream to deliver as the response body.
|
|
52
|
+
* @param options - Content type and extra headers.
|
|
53
|
+
* @returns A new `StreamingResponse` wrapping the provided stream.
|
|
54
|
+
*/
|
|
55
|
+
static stream(
|
|
56
|
+
readableStream: ReadableStream<Uint8Array>,
|
|
57
|
+
options?: {
|
|
58
|
+
contentType?: string;
|
|
59
|
+
headers?: Record<string, string>;
|
|
60
|
+
},
|
|
61
|
+
): StreamingResponse {
|
|
62
|
+
return new StreamingResponse(
|
|
63
|
+
readableStream,
|
|
64
|
+
options?.contentType ?? "application/octet-stream",
|
|
65
|
+
options?.headers ?? {},
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Convert this streaming response into a native `Response` object, merging
|
|
71
|
+
* the provided base headers (CORS, security, session cookie, etc.) with
|
|
72
|
+
* the streaming-specific headers.
|
|
73
|
+
*
|
|
74
|
+
* @param baseHeaders - Headers from `buildHeaders()` to merge in.
|
|
75
|
+
* @returns A native `Response` with the stream as its body.
|
|
76
|
+
*/
|
|
77
|
+
toResponse(baseHeaders: Record<string, string>): Response {
|
|
78
|
+
const mergedHeaders = { ...baseHeaders, ...this.headers };
|
|
79
|
+
mergedHeaders["Content-Type"] = this.contentType;
|
|
80
|
+
|
|
81
|
+
return new Response(this.stream, {
|
|
82
|
+
status: 200,
|
|
83
|
+
headers: mergedHeaders,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Server-Sent Events streaming response. Provides `send()` to emit SSE-formatted
|
|
90
|
+
* events and `close()` to end the stream.
|
|
91
|
+
*
|
|
92
|
+
* Events follow the SSE protocol: `event:`, `id:`, and `data:` fields separated
|
|
93
|
+
* by `\n`, with events delimited by `\n\n`.
|
|
94
|
+
*/
|
|
95
|
+
export class SSEResponse extends StreamingResponse {
|
|
96
|
+
private controller: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
97
|
+
private closed = false;
|
|
98
|
+
|
|
99
|
+
constructor(extraHeaders?: Record<string, string>) {
|
|
100
|
+
let capturedController:
|
|
101
|
+
| ReadableStreamDefaultController<Uint8Array>
|
|
102
|
+
| undefined;
|
|
103
|
+
|
|
104
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
105
|
+
start(controller) {
|
|
106
|
+
capturedController = controller;
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
super(stream, "text/event-stream", {
|
|
111
|
+
"Cache-Control": "no-cache",
|
|
112
|
+
Connection: "keep-alive",
|
|
113
|
+
...extraHeaders,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
this.controller = capturedController!;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Send an SSE event to the client.
|
|
121
|
+
*
|
|
122
|
+
* @param data - The event payload. Objects are JSON-serialized; strings are sent as-is
|
|
123
|
+
* (multiline strings are split into multiple `data:` lines per the SSE spec).
|
|
124
|
+
* @param options - Optional `event` type name and `id` for the event.
|
|
125
|
+
*/
|
|
126
|
+
send(data: string | object, options?: { event?: string; id?: string }): void {
|
|
127
|
+
if (this.closed) return;
|
|
128
|
+
|
|
129
|
+
let frame = "";
|
|
130
|
+
if (options?.event) frame += `event: ${options.event}\n`;
|
|
131
|
+
if (options?.id) frame += `id: ${options.id}\n`;
|
|
132
|
+
|
|
133
|
+
const payload = typeof data === "string" ? data : JSON.stringify(data);
|
|
134
|
+
for (const line of payload.split("\n")) {
|
|
135
|
+
frame += `data: ${line}\n`;
|
|
136
|
+
}
|
|
137
|
+
frame += "\n";
|
|
138
|
+
|
|
139
|
+
this.controller?.enqueue(encoder.encode(frame));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Send an error event and close the stream.
|
|
144
|
+
*
|
|
145
|
+
* @param error - Error message or object to send as an `error` event.
|
|
146
|
+
*/
|
|
147
|
+
sendError(error: string | object): void {
|
|
148
|
+
this.send(error, { event: "error" });
|
|
149
|
+
this.close();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Close the SSE stream. Fires the `onClose` callback for connection cleanup. */
|
|
153
|
+
close(): void {
|
|
154
|
+
if (this.closed) return;
|
|
155
|
+
this.closed = true;
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
this.controller?.close();
|
|
159
|
+
} catch (_e) {
|
|
160
|
+
// Controller may already be closed (e.g., client disconnected)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
this.onClose?.();
|
|
164
|
+
}
|
|
165
|
+
}
|
package/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ export type { ChannelMiddleware } from "./classes/Channel";
|
|
|
24
24
|
export { CHANNEL_NAME_PATTERN } from "./classes/Channel";
|
|
25
25
|
export { Connection } from "./classes/Connection";
|
|
26
26
|
export { LogLevel } from "./classes/Logger";
|
|
27
|
+
export { SSEResponse, StreamingResponse } from "./classes/StreamingResponse";
|
|
27
28
|
export { ErrorStatusCodes, ErrorType, TypedError } from "./classes/TypedError";
|
|
28
29
|
export type { KeryxConfig } from "./config";
|
|
29
30
|
export type { SessionData } from "./initializers/session";
|
package/initializers/mcp.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { api, logger } from "../api";
|
|
|
10
10
|
import { MCP_RESPONSE_FORMAT } from "../classes/Action";
|
|
11
11
|
import { Connection } from "../classes/Connection";
|
|
12
12
|
import { Initializer } from "../classes/Initializer";
|
|
13
|
+
import { StreamingResponse } from "../classes/StreamingResponse";
|
|
13
14
|
import { ErrorType, TypedError } from "../classes/TypedError";
|
|
14
15
|
import { config } from "../config";
|
|
15
16
|
import pkg from "../package.json";
|
|
@@ -430,6 +431,35 @@ function createMcpServer(): McpServer {
|
|
|
430
431
|
};
|
|
431
432
|
}
|
|
432
433
|
|
|
434
|
+
// For streaming responses, consume the stream and accumulate into a single result.
|
|
435
|
+
// Send incremental chunks as MCP logging messages for real-time visibility.
|
|
436
|
+
if (response instanceof StreamingResponse) {
|
|
437
|
+
const reader = response.stream.getReader();
|
|
438
|
+
const decoder = new TextDecoder();
|
|
439
|
+
let accumulated = "";
|
|
440
|
+
try {
|
|
441
|
+
while (true) {
|
|
442
|
+
const { done, value } = await reader.read();
|
|
443
|
+
if (done) break;
|
|
444
|
+
const chunk = decoder.decode(value);
|
|
445
|
+
accumulated += chunk;
|
|
446
|
+
try {
|
|
447
|
+
mcpServer.server.sendLoggingMessage({
|
|
448
|
+
level: "info",
|
|
449
|
+
data: chunk,
|
|
450
|
+
});
|
|
451
|
+
} catch (_e) {
|
|
452
|
+
// Logging message delivery is best-effort
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
} finally {
|
|
456
|
+
response.onClose?.();
|
|
457
|
+
}
|
|
458
|
+
return {
|
|
459
|
+
content: [{ type: "text" as const, text: accumulated }],
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
433
463
|
const format = action.mcp?.responseFormat ?? MCP_RESPONSE_FORMAT.JSON;
|
|
434
464
|
const text =
|
|
435
465
|
format === MCP_RESPONSE_FORMAT.MARKDOWN
|
package/package.json
CHANGED
package/servers/web.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { api, logger } from "../api";
|
|
|
7
7
|
import { type HTTP_METHOD } from "../classes/Action";
|
|
8
8
|
import { Connection } from "../classes/Connection";
|
|
9
9
|
import { Server } from "../classes/Server";
|
|
10
|
+
import { StreamingResponse } from "../classes/StreamingResponse";
|
|
10
11
|
import { ErrorStatusCodes, ErrorType, TypedError } from "../classes/TypedError";
|
|
11
12
|
import { config } from "../config";
|
|
12
13
|
import type { PubSubMessage } from "../initializers/pubsub";
|
|
@@ -170,6 +171,13 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
|
|
|
170
171
|
return; // upgrade the request to a WebSocket
|
|
171
172
|
|
|
172
173
|
const response = await this.handleHttpRequest(req, server, ip, id);
|
|
174
|
+
|
|
175
|
+
// SSE and other streaming responses: disable idle timeout and skip compression
|
|
176
|
+
if (response.headers.get("Content-Type")?.includes("text/event-stream")) {
|
|
177
|
+
server.timeout(req, 0);
|
|
178
|
+
return response;
|
|
179
|
+
}
|
|
180
|
+
|
|
173
181
|
return compressResponse(response, req);
|
|
174
182
|
}
|
|
175
183
|
|
|
@@ -408,6 +416,22 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
|
|
|
408
416
|
req.url,
|
|
409
417
|
);
|
|
410
418
|
|
|
419
|
+
// For streaming responses, defer connection cleanup until the stream closes
|
|
420
|
+
if (response instanceof StreamingResponse) {
|
|
421
|
+
response.onClose = () => {
|
|
422
|
+
connection.destroy();
|
|
423
|
+
api.observability.http.activeConnections.add(-1);
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
api.observability.http.requestsTotal.add(1, {
|
|
427
|
+
method: httpMethod,
|
|
428
|
+
route: actionName ?? "unknown",
|
|
429
|
+
status: "200",
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
return buildResponse(connection, response, 200, requestOrigin);
|
|
433
|
+
}
|
|
434
|
+
|
|
411
435
|
connection.destroy();
|
|
412
436
|
api.observability.http.activeConnections.add(-1);
|
|
413
437
|
|
package/util/oauthTemplates.ts
CHANGED
|
@@ -126,7 +126,7 @@ export function zodToFormFields(action: Action | undefined): FormField[] {
|
|
|
126
126
|
|
|
127
127
|
/**
|
|
128
128
|
* Pre-render an array of form fields into HTML strings.
|
|
129
|
-
* This avoids Mustache conditionals inside HTML tags (which breaks
|
|
129
|
+
* This avoids Mustache conditionals inside HTML tags (which breaks formatters).
|
|
130
130
|
* @param fields - Array of {@link FormField} objects.
|
|
131
131
|
* @param prefix - ID prefix for field elements (e.g., "signin" or "signup").
|
|
132
132
|
* @returns HTML string with label + input pairs.
|
package/util/webCompression.ts
CHANGED
|
@@ -74,6 +74,10 @@ export async function compressResponse(
|
|
|
74
74
|
// No body to compress
|
|
75
75
|
if (!response.body) return response;
|
|
76
76
|
|
|
77
|
+
// Never compress SSE streams
|
|
78
|
+
if (response.headers.get("Content-Type")?.includes("text/event-stream"))
|
|
79
|
+
return response;
|
|
80
|
+
|
|
77
81
|
// Already compressed
|
|
78
82
|
if (response.headers.get("Content-Encoding")) return response;
|
|
79
83
|
|
package/util/webResponse.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Connection } from "../classes/Connection";
|
|
2
|
+
import { StreamingResponse } from "../classes/StreamingResponse";
|
|
2
3
|
import { TypedError } from "../classes/TypedError";
|
|
3
4
|
import { config } from "../config";
|
|
4
5
|
import { buildCorsHeaders } from "../util/http";
|
|
@@ -77,6 +78,10 @@ export function buildResponse(
|
|
|
77
78
|
status = 200,
|
|
78
79
|
requestOrigin?: string,
|
|
79
80
|
) {
|
|
81
|
+
if (response instanceof StreamingResponse) {
|
|
82
|
+
return response.toResponse(buildHeaders(connection, requestOrigin));
|
|
83
|
+
}
|
|
84
|
+
|
|
80
85
|
if (response instanceof Response) {
|
|
81
86
|
return response;
|
|
82
87
|
}
|
package/util/webSocket.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { api, logger } from "../api";
|
|
|
3
3
|
import type { ActionParams } from "../classes/Action";
|
|
4
4
|
import { CHANNEL_NAME_PATTERN } from "../classes/Channel";
|
|
5
5
|
import type { Connection } from "../classes/Connection";
|
|
6
|
+
import { StreamingResponse } from "../classes/StreamingResponse";
|
|
6
7
|
import { ErrorType, TypedError } from "../classes/TypedError";
|
|
7
8
|
import { config } from "../config";
|
|
8
9
|
import type {
|
|
@@ -40,6 +41,34 @@ export async function handleWebsocketAction(
|
|
|
40
41
|
error: { ...buildErrorPayload(error) },
|
|
41
42
|
}),
|
|
42
43
|
);
|
|
44
|
+
} else if (response instanceof StreamingResponse) {
|
|
45
|
+
const reader = response.stream.getReader();
|
|
46
|
+
const decoder = new TextDecoder();
|
|
47
|
+
try {
|
|
48
|
+
while (true) {
|
|
49
|
+
const { done, value } = await reader.read();
|
|
50
|
+
if (done) break;
|
|
51
|
+
ws.send(
|
|
52
|
+
JSON.stringify({
|
|
53
|
+
messageId: formattedMessage.messageId,
|
|
54
|
+
streaming: true,
|
|
55
|
+
chunk: decoder.decode(value),
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
} finally {
|
|
60
|
+
try {
|
|
61
|
+
ws.send(
|
|
62
|
+
JSON.stringify({
|
|
63
|
+
messageId: formattedMessage.messageId,
|
|
64
|
+
streaming: false,
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
} catch (_e) {
|
|
68
|
+
// Connection may already be closed (e.g., client disconnected mid-stream)
|
|
69
|
+
}
|
|
70
|
+
response.onClose?.();
|
|
71
|
+
}
|
|
43
72
|
} else {
|
|
44
73
|
ws.send(
|
|
45
74
|
JSON.stringify({
|