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 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, and memory consumption in MB. Does not require authentication.";
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
  }
@@ -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
- const responseSchema = api.swagger?.responseSchemas[action.name];
186
- if (responseSchema) {
187
- const schemaName = `${action.name.replace(/:/g, "_")}_Response`;
188
- components.schemas[schemaName] = responseSchema;
185
+
186
+ if (action.web?.streaming) {
187
+ // Streaming endpoints return SSE
189
188
  responses["200"] = {
190
- description: "successful operation",
189
+ description: "Server-Sent Events stream",
191
190
  content: {
192
- "application/json": {
193
- schema: { $ref: `#/components/schemas/${schemaName}` },
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,
@@ -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 = middlewareResponse.updatedResponse;
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";
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keryx",
3
- "version": "0.16.3",
3
+ "version": "0.17.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "license": "MIT",
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
 
@@ -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 Prettier).
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.
@@ -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
 
@@ -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({