@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.
package/README.md ADDED
@@ -0,0 +1,163 @@
1
+ # `@hermit-org/stdio-to-sse`
2
+
3
+ [English](./README.md) | [中文](./README.zh-CN.md)
4
+
5
+ A tiny, protocol-agnostic bridge between a stdio program and an HTTP
6
+ POST → SSE endpoint.
7
+
8
+ - **Server**: spawns a child process and exposes an HTTP endpoint. Request
9
+ bodies are written to the child `stdin`; child `stdout` is streamed back as
10
+ [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events).
11
+ - **Client**: sends a POST request to such an endpoint and yields each SSE
12
+ payload as it arrives.
13
+
14
+ No assumption is made about the message format: JSON-RPC, MCP, ACP, plain text,
15
+ etc. are all treated as opaque byte streams.
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ bun add @hermit-org/stdio-to-sse
21
+ ```
22
+
23
+ > Requires Node.js 18+ or [Bun](https://bun.sh/). The implementation uses
24
+ > Node.js built-in modules only.
25
+
26
+ ## Running tests
27
+
28
+ ```bash
29
+ cd packages/stdio-to-sse
30
+ bun test
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ### Server
36
+
37
+ ```ts
38
+ import { StdioSseServer } from "@hermit-org/stdio-to-sse";
39
+
40
+ const server = new StdioSseServer({
41
+ command: "cat",
42
+ port: 8080,
43
+ });
44
+
45
+ const { url, stop } = await server.start();
46
+ console.log(`Bridge listening on ${url}`);
47
+
48
+ // Later:
49
+ await stop();
50
+ ```
51
+
52
+ ### Client
53
+
54
+ ```ts
55
+ import { StdioSseClient } from "@hermit-org/stdio-to-sse";
56
+
57
+ const client = new StdioSseClient({ url: "http://localhost:8080" });
58
+
59
+ for await (const payload of client.send("hello\nworld")) {
60
+ console.log(payload);
61
+ }
62
+ // => "hello"
63
+ // => "world"
64
+ ```
65
+
66
+ ### End-to-end example
67
+
68
+ ```ts
69
+ import { StdioSseServer, StdioSseClient } from "@hermit-org/stdio-to-sse";
70
+
71
+ const server = new StdioSseServer({ command: "cat", port: 8080 });
72
+ const { url, stop } = await server.start();
73
+
74
+ const client = new StdioSseClient({ url });
75
+ for await (const data of client.send("ping")) {
76
+ console.log(data);
77
+ }
78
+
79
+ await stop();
80
+ ```
81
+
82
+ ## API
83
+
84
+ ### `StdioSseServer`
85
+
86
+ ```ts
87
+ interface StdioSseServerOptions {
88
+ command: string; // command to spawn
89
+ args?: string[]; // command arguments
90
+ port?: number; // default: 8080
91
+ hostname?: string; // default: "0.0.0.0"
92
+ endpoint?: string; // default: "/"
93
+ cors?: boolean; // default: true
94
+ timeout?: number; // max time to wait for the child process, in ms
95
+ }
96
+
97
+ class StdioSseServer {
98
+ constructor(options: StdioSseServerOptions);
99
+ start(): Promise<{ url: string; stop: () => Promise<void> }>;
100
+ }
101
+ ```
102
+
103
+ ### `StdioSseClient`
104
+
105
+ ```ts
106
+ interface StdioSseClientOptions {
107
+ url: string;
108
+ }
109
+
110
+ class StdioSseClient {
111
+ constructor(options: StdioSseClientOptions);
112
+ send(input: string): AsyncGenerator<string>;
113
+ }
114
+ ```
115
+
116
+ ### SSE utilities
117
+
118
+ ```ts
119
+ import { encodeSse, parseSse } from "@hermit-org/stdio-to-sse";
120
+
121
+ const frame = encodeSse("hello");
122
+ // => "data: hello\n\n"
123
+
124
+ const { data, remainder } = parseSse(frame);
125
+ // data === ["hello"]
126
+ ```
127
+
128
+ ## How it works
129
+
130
+ 1. Client sends a `POST` request to the configured endpoint.
131
+ 2. Server spawns the configured child process.
132
+ 3. Request body is written to child `stdin`, then `stdin` is closed.
133
+ 4. Server reads child `stdout`, splits it by newlines, and encodes each line
134
+ as an SSE `data:` frame.
135
+ 5. Client decodes the SSE stream and yields each payload.
136
+
137
+ Each HTTP request gets its own child process, so requests are isolated.
138
+
139
+ ## Persistent gateway mode
140
+
141
+ For long-lived agents (ACP / MCP / JSON-RPC sessions), use the CLI's
142
+ `AcpGatewayServer` instead. It keeps a single child process alive and exposes
143
+ separate SSE (`/`) and stdin (`/send`) endpoints:
144
+
145
+ ```ts
146
+ import { AcpGatewayServer } from "@hermit-org/cli/src/lib/gateway";
147
+
148
+ const server = new AcpGatewayServer({
149
+ command: "npx",
150
+ args: ["codex", "--acp"],
151
+ port: 8787,
152
+ });
153
+ const { url, stop } = await server.start();
154
+ ```
155
+
156
+ ## Notes
157
+
158
+ - The child process should flush its stdout promptly; buffered output will
159
+ delay SSE frames.
160
+ - `StdioSseServer` captures `stderr` but currently discards it. The CLI gateway
161
+ forwards `stderr` as SSE `error` frames instead.
162
+ - Keep the bridge behind HTTPS in production; this library does not provide
163
+ authentication.
@@ -0,0 +1,154 @@
1
+ # `@hermit-org/stdio-to-sse`
2
+
3
+ [English](./README.md) | [中文](./README.zh-CN.md)
4
+
5
+ 一个轻量、与协议无关的桥接库,用于将基于 stdio 的程序暴露为 HTTP POST → SSE 端点。
6
+
7
+ - **服务端(Server)**:启动一个子进程并暴露 HTTP 端点。请求体会被写入子进程的 `stdin`;子进程的 `stdout` 则以 [Server-Sent Events](https://developer.mozilla.org/zh-CN/docs/Web/API/Server-sent_events) 的形式流式返回。
8
+ - **客户端(Client)**:向该端点发送 POST 请求,并在每个 SSE 数据帧到达时实时产出。
9
+
10
+ 本库不对消息格式做任何假设:JSON-RPC、MCP、ACP、纯文本等都被视为不透明字节流。
11
+
12
+ ## 安装
13
+
14
+ ```bash
15
+ bun add @hermit-org/stdio-to-sse
16
+ ```
17
+
18
+ > 需要 Node.js 18+ 或 [Bun](https://bun.sh/)。实现仅使用 Node.js 内置模块。
19
+
20
+ ## 运行测试
21
+
22
+ ```bash
23
+ cd packages/stdio-to-sse
24
+ bun test
25
+ ```
26
+
27
+ ## 使用方式
28
+
29
+ ### 服务端
30
+
31
+ ```ts
32
+ import { StdioSseServer } from "@hermit-org/stdio-to-sse";
33
+
34
+ const server = new StdioSseServer({
35
+ command: "cat",
36
+ port: 8080,
37
+ });
38
+
39
+ const { url, stop } = await server.start();
40
+ console.log(`Bridge 监听地址:${url}`);
41
+
42
+ // 关闭服务:
43
+ await stop();
44
+ ```
45
+
46
+ ### 客户端
47
+
48
+ ```ts
49
+ import { StdioSseClient } from "@hermit-org/stdio-to-sse";
50
+
51
+ const client = new StdioSseClient({ url: "http://localhost:8080" });
52
+
53
+ for await (const payload of client.send("hello\nworld")) {
54
+ console.log(payload);
55
+ }
56
+ // => "hello"
57
+ // => "world"
58
+ ```
59
+
60
+ ### 端到端示例
61
+
62
+ ```ts
63
+ import { StdioSseServer, StdioSseClient } from "@hermit-org/stdio-to-sse";
64
+
65
+ const server = new StdioSseServer({ command: "cat", port: 8080 });
66
+ const { url, stop } = await server.start();
67
+
68
+ const client = new StdioSseClient({ url });
69
+ for await (const data of client.send("ping")) {
70
+ console.log(data);
71
+ }
72
+
73
+ await stop();
74
+ ```
75
+
76
+ ## API 说明
77
+
78
+ ### `StdioSseServer`
79
+
80
+ ```ts
81
+ interface StdioSseServerOptions {
82
+ command: string; // 要启动的命令
83
+ args?: string[]; // 命令参数
84
+ port?: number; // 默认:8080
85
+ hostname?: string; // 默认:"0.0.0.0"
86
+ endpoint?: string; // 默认:"/"
87
+ cors?: boolean; // 默认:true
88
+ timeout?: number; // 子进程最长运行时间(毫秒)
89
+ }
90
+
91
+ class StdioSseServer {
92
+ constructor(options: StdioSseServerOptions);
93
+ start(): Promise<{ url: string; stop: () => Promise<void> }>;
94
+ }
95
+ ```
96
+
97
+ ### `StdioSseClient`
98
+
99
+ ```ts
100
+ interface StdioSseClientOptions {
101
+ url: string;
102
+ }
103
+
104
+ class StdioSseClient {
105
+ constructor(options: StdioSseClientOptions);
106
+ send(input: string): AsyncGenerator<string>;
107
+ }
108
+ ```
109
+
110
+ ### SSE 工具函数
111
+
112
+ ```ts
113
+ import { encodeSse, parseSse } from "@hermit-org/stdio-to-sse";
114
+
115
+ const frame = encodeSse("hello");
116
+ // => "data: hello\n\n"
117
+
118
+ const { data, remainder } = parseSse(frame);
119
+ // data === ["hello"]
120
+ ```
121
+
122
+ ## 工作原理
123
+
124
+ 1. 客户端向配置的端点发送 `POST` 请求。
125
+ 2. 服务端启动配置好的子进程。
126
+ 3. 请求体被写入子进程的 `stdin`,随后 `stdin` 被关闭。
127
+ 4. 服务端读取子进程的 `stdout`,按换行拆分,并将每一行编码为 SSE `data:` 帧。
128
+ 5. 客户端解码 SSE 流并逐帧产出数据内容。
129
+
130
+ 每个 HTTP 请求都会独立启动一个子进程,因此请求之间是相互隔离的。
131
+
132
+ ## 持久化 Gateway 模式
133
+
134
+ 如果需要长期运行的 Agent(ACP / MCP / JSON-RPC 会话),请使用 CLI 提供的
135
+ `AcpGatewayServer`。它会保持一个子进程长期存活,并分别暴露 SSE(`/`)和 stdin
136
+ (`/send`)端点:
137
+
138
+ ```ts
139
+ import { AcpGatewayServer } from "@hermit-org/cli/src/lib/gateway";
140
+
141
+ const server = new AcpGatewayServer({
142
+ command: "npx",
143
+ args: ["codex", "--acp"],
144
+ port: 8787,
145
+ });
146
+ const { url, stop } = await server.start();
147
+ ```
148
+
149
+ ## 注意事项
150
+
151
+ - 子进程应及时刷新 stdout;缓冲输出会延迟 SSE 帧的到达。
152
+ - `StdioSseServer` 会捕获 `stderr` 但直接丢弃。CLI 的 Gateway 则会将 `stderr`
153
+ 作为 SSE `error` 帧转发。
154
+ - 生产环境中请将该桥接服务置于 HTTPS 之后;本库不提供身份验证功能。
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@hermit-org/stdio-to-sse",
3
+ "version": "0.0.1-alpha.0",
4
+ "type": "module",
5
+ "module": "src/index.ts",
6
+ "react-native": "./src/index.native.ts",
7
+ "browser": "./src/index.native.ts",
8
+ "exports": {
9
+ ".": {
10
+ "react-native": "./src/index.native.ts",
11
+ "browser": "./src/index.native.ts",
12
+ "default": "./src/index.ts"
13
+ },
14
+ "./client": "./src/client.ts",
15
+ "./server": "./src/server.ts"
16
+ },
17
+ "scripts": {
18
+ "test": "bun test"
19
+ }
20
+ }
@@ -0,0 +1,191 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { createServer, type Server } from "node:http";
3
+ import { StdioSseClient } from "./index";
4
+
5
+ function startSseServer(
6
+ handler: (req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse) => void,
7
+ ): Promise<{ url: string; server: Server; stop: () => Promise<void> }> {
8
+ return new Promise((resolve, reject) => {
9
+ const server = createServer(handler);
10
+
11
+ server.once("error", reject);
12
+ server.listen(0, "127.0.0.1", () => {
13
+ server.removeListener("error", reject);
14
+ const address = server.address();
15
+ const port = typeof address === "object" && address ? address.port : 0;
16
+ resolve({
17
+ url: `http://127.0.0.1:${port}`,
18
+ server,
19
+ stop: () => new Promise((resolveStop) => server.close(() => resolveStop())),
20
+ });
21
+ });
22
+ });
23
+ }
24
+
25
+ async function readRequestBody(req: import("node:http").IncomingMessage): Promise<string> {
26
+ return new Promise((resolve, reject) => {
27
+ const chunks: Buffer[] = [];
28
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
29
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
30
+ req.on("error", reject);
31
+ });
32
+ }
33
+
34
+ describe("StdioSseClient", () => {
35
+ test("yields a single SSE data payload", async () => {
36
+ const { url, stop } = await startSseServer(async (req, res) => {
37
+ const body = await readRequestBody(req);
38
+ expect(body).toBe("ping");
39
+
40
+ res.writeHead(200, { "Content-Type": "text/event-stream" });
41
+ res.write("data: hello\n\n");
42
+ res.end();
43
+ });
44
+
45
+ try {
46
+ const client = new StdioSseClient({ url });
47
+ const results: string[] = [];
48
+
49
+ for await (const data of client.send("ping")) {
50
+ results.push(data);
51
+ }
52
+
53
+ expect(results).toEqual(["hello"]);
54
+ } finally {
55
+ await stop();
56
+ }
57
+ });
58
+
59
+ test("yields multiple SSE data payloads", async () => {
60
+ const { url, stop } = await startSseServer(async (_req, res) => {
61
+ res.writeHead(200, { "Content-Type": "text/event-stream" });
62
+ res.write("data: one\n\ndata: two\n\n");
63
+ res.end();
64
+ });
65
+
66
+ try {
67
+ const client = new StdioSseClient({ url });
68
+ const results: string[] = [];
69
+
70
+ for await (const data of client.send("")) {
71
+ results.push(data);
72
+ }
73
+
74
+ expect(results).toEqual(["one", "two"]);
75
+ } finally {
76
+ await stop();
77
+ }
78
+ });
79
+
80
+ test("handles an SSE frame split across multiple chunks", async () => {
81
+ const { url, stop } = await startSseServer(async (_req, res) => {
82
+ res.writeHead(200, { "Content-Type": "text/event-stream" });
83
+ res.write("data: hel");
84
+ res.write("lo\n\n");
85
+ res.end();
86
+ });
87
+
88
+ try {
89
+ const client = new StdioSseClient({ url });
90
+ const results: string[] = [];
91
+
92
+ for await (const data of client.send("")) {
93
+ results.push(data);
94
+ }
95
+
96
+ expect(results).toEqual(["hello"]);
97
+ } finally {
98
+ await stop();
99
+ }
100
+ });
101
+
102
+ test("reconstructs multi-line SSE payloads", async () => {
103
+ const { url, stop } = await startSseServer(async (_req, res) => {
104
+ res.writeHead(200, { "Content-Type": "text/event-stream" });
105
+ res.write("data: line1\ndata: line2\n\n");
106
+ res.end();
107
+ });
108
+
109
+ try {
110
+ const client = new StdioSseClient({ url });
111
+ const results: string[] = [];
112
+
113
+ for await (const data of client.send("")) {
114
+ results.push(data);
115
+ }
116
+
117
+ expect(results).toEqual(["line1\nline2"]);
118
+ } finally {
119
+ await stop();
120
+ }
121
+ });
122
+
123
+ test("handles an empty response body gracefully", async () => {
124
+ const { url, stop } = await startSseServer(async (_req, res) => {
125
+ res.writeHead(200);
126
+ res.end();
127
+ });
128
+
129
+ try {
130
+ const client = new StdioSseClient({ url });
131
+ const results: string[] = [];
132
+
133
+ for await (const data of client.send("")) {
134
+ results.push(data);
135
+ }
136
+
137
+ expect(results).toEqual([]);
138
+ } finally {
139
+ await stop();
140
+ }
141
+ });
142
+
143
+ test("throws when the server returns a non-2xx status", async () => {
144
+ const { url, stop } = await startSseServer(async (_req, res) => {
145
+ res.writeHead(404, { "Content-Type": "text/plain" });
146
+ res.end("not found");
147
+ });
148
+
149
+ try {
150
+ const client = new StdioSseClient({ url });
151
+ const iterator = client.send("");
152
+
153
+ await expect(iterator.next()).rejects.toThrow("HTTP error 404");
154
+ } finally {
155
+ await stop();
156
+ }
157
+ });
158
+
159
+ test("propagates network errors", async () => {
160
+ const client = new StdioSseClient({
161
+ url: "http://127.0.0.1:1",
162
+ });
163
+
164
+ await expect(async () => {
165
+ for await (const _ of client.send("")) {
166
+ // consume
167
+ }
168
+ }).toThrow();
169
+ });
170
+
171
+ test("accepts responses with non-SSE Content-Type", async () => {
172
+ const { url, stop } = await startSseServer(async (_req, res) => {
173
+ res.writeHead(200, { "Content-Type": "text/html" });
174
+ res.write("data: still-parsed\n\n");
175
+ res.end();
176
+ });
177
+
178
+ try {
179
+ const client = new StdioSseClient({ url });
180
+ const results: string[] = [];
181
+
182
+ for await (const data of client.send("")) {
183
+ results.push(data);
184
+ }
185
+
186
+ expect(results).toEqual(["still-parsed"]);
187
+ } finally {
188
+ await stop();
189
+ }
190
+ });
191
+ });
package/src/client.ts ADDED
@@ -0,0 +1,140 @@
1
+ import { parseSse } from "./sse";
2
+
3
+ /**
4
+ * Configuration for `StdioSseClient`.
5
+ */
6
+ export interface StdioSseClientOptions {
7
+ /** URL of the stdio-to-sse HTTP endpoint. */
8
+ url: string;
9
+ /** Optional headers to send with the POST request. */
10
+ headers?: Record<string, string>;
11
+ /**
12
+ * Maximum number of reconnection attempts when the stream drops unexpectedly
13
+ * (default: `0`). Each `send()` call is independent; reconnection only
14
+ * applies within a single call.
15
+ */
16
+ maxRetries?: number;
17
+ /**
18
+ * Initial backoff delay in milliseconds between retries (default: `1000`).
19
+ * Delays double on each attempt up to `maxRetryDelay`.
20
+ */
21
+ retryDelay?: number;
22
+ /** Maximum backoff delay in milliseconds (default: `30000`). */
23
+ maxRetryDelay?: number;
24
+ }
25
+
26
+ /**
27
+ * HTTP client that sends a request body to a stdio-to-sse server and yields
28
+ * each SSE payload as it arrives.
29
+ *
30
+ * Like the server, this client is protocol-agnostic: it only understands the
31
+ * SSE framing and returns the raw `data:` contents. It adds transport-level
32
+ * resilience through optional retries with exponential backoff.
33
+ */
34
+ export class StdioSseClient {
35
+ constructor(private readonly options: StdioSseClientOptions) {}
36
+
37
+ /**
38
+ * Send input to the server and asynchronously yield each SSE payload.
39
+ *
40
+ * @param input Text to write to the child process stdin.
41
+ * @yields Raw payloads received from the SSE stream.
42
+ */
43
+ async *send(input: string): AsyncGenerator<string> {
44
+ const {
45
+ url,
46
+ headers = {},
47
+ maxRetries = 0,
48
+ retryDelay = 1000,
49
+ maxRetryDelay = 30000,
50
+ } = this.options;
51
+
52
+ let attempt = 0;
53
+ let lastError: Error | undefined;
54
+
55
+ while (attempt <= maxRetries) {
56
+ try {
57
+ for await (const item of this.sendOnce(input, headers)) {
58
+ yield item;
59
+ }
60
+ return;
61
+ } catch (error) {
62
+ lastError = error instanceof Error ? error : new Error(String(error));
63
+ if (attempt >= maxRetries) break;
64
+
65
+ const delay = Math.min(retryDelay * 2 ** attempt, maxRetryDelay);
66
+ await sleep(delay);
67
+ attempt++;
68
+ }
69
+ }
70
+
71
+ throw lastError ?? new Error("SSE request failed");
72
+ }
73
+
74
+ private async *sendOnce(
75
+ input: string,
76
+ headers: Record<string, string>,
77
+ ): AsyncGenerator<string> {
78
+ const response = await fetch(this.options.url, {
79
+ method: "POST",
80
+ headers: {
81
+ "Content-Type": "text/plain",
82
+ Accept: "text/event-stream",
83
+ ...headers,
84
+ },
85
+ body: input,
86
+ });
87
+
88
+ if (!response.ok) {
89
+ const text = await response.text().catch(() => "");
90
+ throw new Error(
91
+ `HTTP error ${response.status}: ${text || response.statusText}`,
92
+ );
93
+ }
94
+
95
+ // React Native's fetch polyfill does not expose a streaming response body,
96
+ // and some environments may not have a global TextDecoder. Fall back to
97
+ // reading the whole response as text and then parsing the SSE frames.
98
+ if (!response.body || typeof TextDecoder === "undefined") {
99
+ const text = await response.text();
100
+ const { data } = parseSse(text);
101
+ for (const item of data) {
102
+ yield item;
103
+ }
104
+ return;
105
+ }
106
+
107
+ const reader = response.body.getReader();
108
+ const decoder = new TextDecoder();
109
+ let buffer = "";
110
+
111
+ try {
112
+ while (true) {
113
+ const { done, value } = await reader.read();
114
+ if (done) break;
115
+
116
+ buffer += decoder.decode(value, { stream: true });
117
+
118
+ // Parse complete frames and keep unfinished bytes for the next chunk.
119
+ const { data, remainder } = parseSse(buffer);
120
+ buffer = remainder;
121
+
122
+ for (const item of data) {
123
+ yield item;
124
+ }
125
+ }
126
+
127
+ // Flush any trailing data after the stream ends.
128
+ const { data } = parseSse(buffer);
129
+ for (const item of data) {
130
+ yield item;
131
+ }
132
+ } finally {
133
+ reader.releaseLock();
134
+ }
135
+ }
136
+ }
137
+
138
+ function sleep(ms: number): Promise<void> {
139
+ return new Promise((resolve) => setTimeout(resolve, ms));
140
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * React Native entry point for `@hermit-org/stdio-to-sse`.
3
+ *
4
+ * Metro's platform-specific extension resolution will prefer `.native.ts`
5
+ * over `.ts` when bundling for React Native. This entry re-exports only the
6
+ * client and the protocol utilities, avoiding Node.js built-in modules that
7
+ * the server implementation depends on.
8
+ */
9
+
10
+ export * from "./sse";
11
+ export * from "./client";
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Public API entry point for `@hermit-org/stdio-to-sse`.
3
+ *
4
+ * This package bridges a stdio-based program to an HTTP POST -> SSE endpoint.
5
+ * It exposes a server that spawns a child process and streams its stdout as
6
+ * Server-Sent Events, plus a client that consumes those events.
7
+ *
8
+ * The implementation uses Node.js built-in modules, so it runs under both
9
+ * Node.js (18+) and Bun.
10
+ *
11
+ * Types are defined alongside their implementations rather than in a central
12
+ * types file, so exports are re-exported per module below.
13
+ */
14
+
15
+ export * from "./sse";
16
+ export * from "./server";
17
+ export * from "./client";