@hermit-org/stdio-to-sse 0.0.1-alpha.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.
@@ -0,0 +1,375 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { StdioSseServer, StdioSseClient } from "./index";
3
+
4
+ async function getFreePort(): Promise<number> {
5
+ // Use a random high port to avoid collisions during parallel test runs.
6
+ return 10000 + Math.floor(Math.random() * 30000);
7
+ }
8
+
9
+ describe("StdioSseServer", () => {
10
+ test("bridges stdin and stdout over HTTP POST -> SSE", async () => {
11
+ const port = await getFreePort();
12
+ const server = new StdioSseServer({ command: "cat", port });
13
+ const { url, stop } = await server.start();
14
+
15
+ try {
16
+ const client = new StdioSseClient({ url });
17
+ const results: string[] = [];
18
+
19
+ for await (const data of client.send("hello\nworld")) {
20
+ results.push(data);
21
+ }
22
+
23
+ expect(results).toEqual(["hello", "world"]);
24
+ } finally {
25
+ await stop();
26
+ }
27
+ });
28
+
29
+ test("supports a custom endpoint path", async () => {
30
+ const port = await getFreePort();
31
+ const server = new StdioSseServer({
32
+ command: "cat",
33
+ port,
34
+ endpoint: "/bridge",
35
+ });
36
+ const { url, stop } = await server.start();
37
+
38
+ try {
39
+ expect(url).toEndWith("/bridge");
40
+
41
+ const client = new StdioSseClient({ url });
42
+ const results: string[] = [];
43
+
44
+ for await (const data of client.send("ping")) {
45
+ results.push(data);
46
+ }
47
+
48
+ expect(results).toEqual(["ping"]);
49
+ } finally {
50
+ await stop();
51
+ }
52
+ });
53
+
54
+ test("returns 404 for unmatched paths", async () => {
55
+ const port = await getFreePort();
56
+ const server = new StdioSseServer({ command: "cat", port });
57
+ const { url, stop } = await server.start();
58
+
59
+ try {
60
+ const response = await fetch(`${url}/not-found`, { method: "POST" });
61
+ expect(response.status).toBe(404);
62
+ } finally {
63
+ await stop();
64
+ }
65
+ });
66
+
67
+ test("returns 404 for non-POST methods", async () => {
68
+ const port = await getFreePort();
69
+ const server = new StdioSseServer({ command: "cat", port });
70
+ const { url, stop } = await server.start();
71
+
72
+ try {
73
+ const response = await fetch(url, { method: "GET" });
74
+ expect(response.status).toBe(404);
75
+ } finally {
76
+ await stop();
77
+ }
78
+ });
79
+
80
+ test("responds to CORS preflight requests", async () => {
81
+ const port = await getFreePort();
82
+ const server = new StdioSseServer({ command: "cat", port });
83
+ const { url, stop } = await server.start();
84
+
85
+ try {
86
+ const response = await fetch(url, { method: "OPTIONS" });
87
+ expect(response.status).toBe(204);
88
+ expect(response.headers.get("access-control-allow-origin")).toBe("*");
89
+ } finally {
90
+ await stop();
91
+ }
92
+ });
93
+
94
+ test("forwards command arguments to the child process", async () => {
95
+ const port = await getFreePort();
96
+ const server = new StdioSseServer({
97
+ command: "node",
98
+ args: [
99
+ "-e",
100
+ "process.argv.slice(1).forEach(a => console.log(a))",
101
+ "alpha",
102
+ "beta",
103
+ ],
104
+ port,
105
+ });
106
+ const { url, stop } = await server.start();
107
+
108
+ try {
109
+ const client = new StdioSseClient({ url });
110
+ const results: string[] = [];
111
+
112
+ for await (const data of client.send("ignored")) {
113
+ results.push(data);
114
+ }
115
+
116
+ expect(results).toEqual(["alpha", "beta"]);
117
+ } finally {
118
+ await stop();
119
+ }
120
+ });
121
+
122
+ test("ends the stream when the child exits with a non-zero code", async () => {
123
+ const port = await getFreePort();
124
+ const server = new StdioSseServer({
125
+ command: "node",
126
+ args: ["-e", "console.log('ok'); process.exit(1)"],
127
+ port,
128
+ });
129
+ const { url, stop } = await server.start();
130
+
131
+ try {
132
+ const client = new StdioSseClient({ url });
133
+ const results: string[] = [];
134
+
135
+ for await (const data of client.send("")) {
136
+ results.push(data);
137
+ }
138
+
139
+ expect(results).toEqual(["ok"]);
140
+ } finally {
141
+ await stop();
142
+ }
143
+ });
144
+
145
+ test("ends the stream when the child crashes", async () => {
146
+ const port = await getFreePort();
147
+ const server = new StdioSseServer({
148
+ command: "node",
149
+ args: ["-e", "process.stdout.destroy()"],
150
+ port,
151
+ });
152
+ const { url, stop } = await server.start();
153
+
154
+ try {
155
+ const client = new StdioSseClient({ url });
156
+ const results: string[] = [];
157
+
158
+ for await (const data of client.send("")) {
159
+ results.push(data);
160
+ }
161
+
162
+ expect(results).toEqual([]);
163
+ } finally {
164
+ await stop();
165
+ }
166
+ });
167
+
168
+ test("does not deadlock when the child writes a large amount to stderr", async () => {
169
+ const port = await getFreePort();
170
+ const server = new StdioSseServer({
171
+ command: "node",
172
+ args: [
173
+ "-e",
174
+ "console.error('x'.repeat(1024 * 1024)); console.log('done')",
175
+ ],
176
+ port,
177
+ });
178
+ const { url, stop } = await server.start();
179
+
180
+ try {
181
+ const client = new StdioSseClient({ url });
182
+ const results: string[] = [];
183
+
184
+ for await (const data of client.send("")) {
185
+ results.push(data);
186
+ }
187
+
188
+ expect(results).toEqual(["done"]);
189
+ } finally {
190
+ await stop();
191
+ }
192
+ });
193
+
194
+ test("kills the child process when the client disconnects", async () => {
195
+ const port = await getFreePort();
196
+ const server = new StdioSseServer({
197
+ command: "node",
198
+ args: ["-e", "setInterval(() => console.log('tick'), 50)"],
199
+ port,
200
+ });
201
+ const { url, stop } = await server.start();
202
+
203
+ const { request } = await import("node:http");
204
+ const parsedUrl = new URL(url);
205
+
206
+ let destroyTimer: ReturnType<typeof setTimeout> | undefined;
207
+
208
+ const req = request(
209
+ {
210
+ hostname: parsedUrl.hostname,
211
+ port: parsedUrl.port,
212
+ path: parsedUrl.pathname,
213
+ method: "POST",
214
+ agent: false,
215
+ headers: { Connection: "close" },
216
+ },
217
+ (res) => {
218
+ res.on("data", () => {
219
+ // Once we receive the first byte, simulate an abrupt disconnect.
220
+ if (destroyTimer) clearTimeout(destroyTimer);
221
+ req.destroy();
222
+ });
223
+ },
224
+ );
225
+
226
+ req.end();
227
+
228
+ // Fallback destroy in case no data arrives.
229
+ destroyTimer = setTimeout(() => req.destroy(), 300);
230
+
231
+ // Wait for the server to clean up.
232
+ await new Promise((resolve) => setTimeout(resolve, 400));
233
+
234
+ // If the child process were not killed, the request handler would never
235
+ // finish and stop() would hang. Stopping successfully proves cleanup works.
236
+ await stop();
237
+ });
238
+
239
+ test("works with an empty request body", async () => {
240
+ const port = await getFreePort();
241
+ const server = new StdioSseServer({
242
+ command: "node",
243
+ args: [
244
+ "-e",
245
+ "process.stdin.resume(); process.stdin.on('end', () => console.log('empty'))",
246
+ ],
247
+ port,
248
+ });
249
+ const { url, stop } = await server.start();
250
+
251
+ try {
252
+ const client = new StdioSseClient({ url });
253
+ const results: string[] = [];
254
+
255
+ for await (const data of client.send("")) {
256
+ results.push(data);
257
+ }
258
+
259
+ expect(results).toEqual(["empty"]);
260
+ } finally {
261
+ await stop();
262
+ }
263
+ });
264
+
265
+ test("isolates concurrent requests", async () => {
266
+ const port = await getFreePort();
267
+ const server = new StdioSseServer({
268
+ command: "node",
269
+ args: ["-e", "process.stdin.on('data', d => process.stdout.write(d))"],
270
+ port,
271
+ });
272
+ const { url, stop } = await server.start();
273
+
274
+ try {
275
+ const clientA = new StdioSseClient({ url });
276
+ const clientB = new StdioSseClient({ url });
277
+
278
+ const [a, b] = await Promise.all([
279
+ (async () => {
280
+ const results: string[] = [];
281
+ for await (const data of clientA.send("aaa")) {
282
+ results.push(data);
283
+ }
284
+ return results;
285
+ })(),
286
+ (async () => {
287
+ const results: string[] = [];
288
+ for await (const data of clientB.send("bbb")) {
289
+ results.push(data);
290
+ }
291
+ return results;
292
+ })(),
293
+ ]);
294
+
295
+ expect(a).toEqual(["aaa"]);
296
+ expect(b).toEqual(["bbb"]);
297
+ } finally {
298
+ await stop();
299
+ }
300
+ });
301
+
302
+ test("terminates the child after the configured timeout", async () => {
303
+ const port = await getFreePort();
304
+ const server = new StdioSseServer({
305
+ command: "node",
306
+ args: ["-e", "setInterval(() => console.log('tick'), 50)"],
307
+ port,
308
+ timeout: 150,
309
+ });
310
+ const { url, stop } = await server.start();
311
+
312
+ try {
313
+ const client = new StdioSseClient({ url });
314
+ const results: string[] = [];
315
+
316
+ for await (const data of client.send("")) {
317
+ results.push(data);
318
+ }
319
+
320
+ // The child produces a frame every 50ms and is killed after 150ms.
321
+ expect(results.length).toBeGreaterThanOrEqual(1);
322
+ expect(results.length).toBeLessThanOrEqual(5);
323
+ } finally {
324
+ await stop();
325
+ }
326
+ });
327
+
328
+ test("streams a large number of lines without dropping frames", async () => {
329
+ const port = await getFreePort();
330
+ const server = new StdioSseServer({
331
+ command: "node",
332
+ args: [
333
+ "-e",
334
+ "for (let i = 0; i < 100; i++) console.log(String(i).padStart(3, '0'))",
335
+ ],
336
+ port,
337
+ });
338
+ const { url, stop } = await server.start();
339
+
340
+ try {
341
+ const client = new StdioSseClient({ url });
342
+ const results: string[] = [];
343
+
344
+ for await (const data of client.send("")) {
345
+ results.push(data);
346
+ }
347
+
348
+ expect(results).toEqual(
349
+ Array.from({ length: 100 }, (_, i) => String(i).padStart(3, "0")),
350
+ );
351
+ } finally {
352
+ await stop();
353
+ }
354
+ });
355
+
356
+ test("accepts a large request body", async () => {
357
+ const port = await getFreePort();
358
+ const server = new StdioSseServer({ command: "cat", port });
359
+ const { url, stop } = await server.start();
360
+
361
+ try {
362
+ const body = "x".repeat(1024 * 1024);
363
+ const client = new StdioSseClient({ url });
364
+ const results: string[] = [];
365
+
366
+ for await (const data of client.send(body)) {
367
+ results.push(data);
368
+ }
369
+
370
+ expect(results).toEqual([body]);
371
+ } finally {
372
+ await stop();
373
+ }
374
+ });
375
+ });
package/src/server.ts ADDED
@@ -0,0 +1,282 @@
1
+ import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
2
+ import { spawn, type ChildProcess } from "node:child_process";
3
+ import { createInterface } from "node:readline";
4
+ import { encodeSse, encodeSseKeepAlive } from "./sse";
5
+
6
+ /**
7
+ * Configuration for `StdioSseServer`.
8
+ *
9
+ * The server spawns a child process (`command` + `args`) and exposes an HTTP
10
+ * endpoint. Every POST request to that endpoint becomes one invocation: the
11
+ * request body is written to the child process stdin, and the child process
12
+ * stdout is streamed back to the client as SSE frames.
13
+ */
14
+ export interface StdioSseServerOptions {
15
+ /** Command to spawn (e.g. `"cat"`, `"python"`, `"node"`). */
16
+ command: string;
17
+ /** Arguments passed to the command. */
18
+ args?: string[];
19
+ /** Port to listen on. */
20
+ port?: number;
21
+ /** Hostname to bind to. */
22
+ hostname?: string;
23
+ /** HTTP endpoint path (default: `"/"`). */
24
+ endpoint?: string;
25
+ /** Enable permissive CORS headers (default: `true`). */
26
+ cors?: boolean;
27
+ /** Maximum time in milliseconds to wait for the child process (default: no timeout). */
28
+ timeout?: number;
29
+ /**
30
+ * Heartbeat interval in milliseconds (default: `30000`).
31
+ *
32
+ * The server emits SSE comment frames at this interval to keep the HTTP
33
+ * connection alive through proxies and detect half-open sockets.
34
+ */
35
+ heartbeatInterval?: number;
36
+ /**
37
+ * Optional hook called for every incoming request before the default
38
+ * endpoint logic runs. Return `true` (or a promise resolving to `true`) to
39
+ * indicate that the request has been fully handled and default routing
40
+ * should be skipped.
41
+ */
42
+ onRequest?: (
43
+ req: IncomingMessage,
44
+ res: ServerResponse,
45
+ ) => boolean | Promise<boolean>;
46
+ }
47
+
48
+ /** State returned after the server starts successfully. */
49
+ export interface StdioSseServerState {
50
+ /** Full URL of the exposed endpoint. */
51
+ url: string;
52
+ /** Stop the HTTP server. */
53
+ stop: () => Promise<void>;
54
+ }
55
+
56
+ /**
57
+ * HTTP server that forwards request bodies to a child process via stdin
58
+ * and streams the child process stdout back as SSE.
59
+ *
60
+ * This class is intentionally protocol-agnostic: it does not parse JSON-RPC,
61
+ * MCP, ACP, or any other message format. It simply bridges bytes. However, it
62
+ * does provide transport-level guarantees required by these protocols:
63
+ * - UTF-8 integrity of request and response bodies.
64
+ * - Line-delimited framing of stdout (one SSE data frame per line).
65
+ * - SSE keep-alive heartbeats to survive proxies and idle timeouts.
66
+ *
67
+ * Implementation is based on Node.js built-in modules so the package can run
68
+ * under Node.js as well as Bun.
69
+ */
70
+ export class StdioSseServer {
71
+ private server?: Server;
72
+
73
+ constructor(private readonly options: StdioSseServerOptions) {}
74
+
75
+ /**
76
+ * Start the HTTP server and spawn the configured child process on each request.
77
+ */
78
+ start(): Promise<StdioSseServerState> {
79
+ const {
80
+ command,
81
+ args = [],
82
+ port = 8080,
83
+ hostname = "0.0.0.0",
84
+ endpoint = "/",
85
+ cors = true,
86
+ timeout,
87
+ heartbeatInterval = 30000,
88
+ } = this.options;
89
+
90
+ // Normalize the endpoint path so matching is consistent.
91
+ const normalizedEndpoint =
92
+ endpoint === "/" ? "/" : endpoint.replace(/\/$/, "");
93
+
94
+ return new Promise((resolve, reject) => {
95
+ this.server = createServer(async (req, res) => {
96
+ if (this.options.onRequest) {
97
+ const handled = await this.options.onRequest(req, res);
98
+ if (handled) return;
99
+ }
100
+
101
+ let proc: ChildProcess | undefined;
102
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
103
+ let heartbeatId: ReturnType<typeof setInterval> | undefined;
104
+ let finished = false;
105
+
106
+ const finish = (): void => {
107
+ if (finished) return;
108
+ finished = true;
109
+
110
+ if (timeoutId) {
111
+ clearTimeout(timeoutId);
112
+ timeoutId = undefined;
113
+ }
114
+
115
+ if (heartbeatId) {
116
+ clearInterval(heartbeatId);
117
+ heartbeatId = undefined;
118
+ }
119
+
120
+ if (!res.writableEnded) {
121
+ res.end();
122
+ }
123
+
124
+ if (proc && !proc.killed) {
125
+ proc.kill();
126
+ }
127
+ };
128
+
129
+ try {
130
+ // Respond to CORS preflight requests when CORS is enabled.
131
+ if (cors && req.method === "OPTIONS") {
132
+ res.writeHead(204, {
133
+ "Access-Control-Allow-Origin": "*",
134
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
135
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
136
+ });
137
+ res.end();
138
+ return;
139
+ }
140
+
141
+ // Only POST requests to the configured endpoint are accepted.
142
+ if (req.method !== "POST" || req.url !== normalizedEndpoint) {
143
+ res.writeHead(404);
144
+ res.end("Not Found");
145
+ return;
146
+ }
147
+
148
+ const body = await readRequestBody(req);
149
+
150
+ // Spawn one child process per request. Each request is isolated.
151
+ // stderr is ignored to prevent the child from deadlocking when it
152
+ // writes large amounts of diagnostic output.
153
+ proc = spawn(command, args, {
154
+ stdio: ["pipe", "pipe", "ignore"],
155
+ });
156
+
157
+ // Forward the HTTP body to the child's stdin and close it so the child
158
+ // knows no more input is coming.
159
+ if (body) {
160
+ proc.stdin!.write(body);
161
+ }
162
+ proc.stdin!.end();
163
+
164
+ const headers: Record<string, string | string[]> = {
165
+ "Content-Type": "text/event-stream",
166
+ "Cache-Control": "no-cache",
167
+ Connection: "keep-alive",
168
+ };
169
+
170
+ if (cors) {
171
+ headers["Access-Control-Allow-Origin"] = "*";
172
+ }
173
+
174
+ res.writeHead(200, headers);
175
+
176
+ // Emit periodic SSE comment frames to keep the connection alive.
177
+ if (heartbeatInterval > 0) {
178
+ heartbeatId = setInterval(() => {
179
+ if (!res.writableEnded) {
180
+ res.write(encodeSseKeepAlive());
181
+ }
182
+ }, heartbeatInterval);
183
+ }
184
+
185
+ // If the client disconnects, terminate the child process to avoid
186
+ // orphan processes and wasted resources.
187
+ res.once("close", () => {
188
+ finish();
189
+ });
190
+
191
+ proc.once("error", (error) => {
192
+ if (!res.writableEnded) {
193
+ res.write(encodeSse(error.message, { event: "error" }));
194
+ }
195
+ finish();
196
+ });
197
+
198
+ proc.once("exit", () => {
199
+ finish();
200
+ });
201
+
202
+ if (timeout) {
203
+ timeoutId = setTimeout(() => {
204
+ if (!res.writableEnded) {
205
+ res.write(encodeSse("Request timed out", { event: "error" }));
206
+ }
207
+ proc?.kill("SIGKILL");
208
+ finish();
209
+ }, timeout);
210
+ }
211
+
212
+ // Read stdout line-by-line and re-emit each line as an SSE frame.
213
+ // `createInterface` handles UTF-8 boundary preservation internally,
214
+ // so multi-byte characters split across stdout chunks are not cut.
215
+ const rl = createInterface({
216
+ input: proc.stdout!,
217
+ crlfDelay: Infinity,
218
+ });
219
+
220
+ for await (const line of rl) {
221
+ if (res.writableEnded) break;
222
+ res.write(encodeSse(line));
223
+ }
224
+
225
+ finish();
226
+ } catch (error) {
227
+ finish();
228
+
229
+ if (!res.headersSent) {
230
+ res.writeHead(500);
231
+ }
232
+ if (!res.writableEnded) {
233
+ res.end(
234
+ error instanceof Error ? error.message : "Internal Server Error",
235
+ );
236
+ }
237
+ }
238
+ });
239
+
240
+ this.server.once("error", reject);
241
+
242
+ this.server.listen(port, hostname, () => {
243
+ this.server!.removeListener("error", reject);
244
+
245
+ const displayEndpoint =
246
+ normalizedEndpoint === "/" ? "" : normalizedEndpoint;
247
+ const protocol = "http";
248
+ const host = hostname === "0.0.0.0" ? "localhost" : hostname;
249
+ const url = `${protocol}://${host}:${port}${displayEndpoint}`;
250
+
251
+ resolve({
252
+ url,
253
+ stop: () =>
254
+ new Promise((resolveStop) => {
255
+ this.server!.close(() => resolveStop());
256
+ // Forcefully close any open connections so the server can shut
257
+ // down promptly, even if a client has disconnected abruptly.
258
+ this.server!.closeAllConnections?.();
259
+ }),
260
+ });
261
+ });
262
+ });
263
+ }
264
+ }
265
+
266
+ function readRequestBody(
267
+ req: import("node:http").IncomingMessage,
268
+ ): Promise<Buffer> {
269
+ return new Promise((resolve, reject) => {
270
+ const chunks: Buffer[] = [];
271
+
272
+ req.on("data", (chunk: Buffer) => {
273
+ chunks.push(chunk);
274
+ });
275
+
276
+ req.on("end", () => {
277
+ resolve(Buffer.concat(chunks));
278
+ });
279
+
280
+ req.on("error", reject);
281
+ });
282
+ }