@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,108 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { encodeSse, parseSse } from "./sse";
3
+
4
+ describe("encodeSse", () => {
5
+ test("encodes a single-line payload", () => {
6
+ expect(encodeSse("hello")).toBe("data: hello\n\n");
7
+ });
8
+
9
+ test("encodes a multi-line payload", () => {
10
+ expect(encodeSse("hello\nworld")).toBe("data: hello\ndata: world\n\n");
11
+ });
12
+
13
+ test("includes event name when provided", () => {
14
+ expect(encodeSse("hello", { event: "message" })).toBe(
15
+ "event: message\ndata: hello\n\n",
16
+ );
17
+ });
18
+
19
+ test("includes id and retry when provided", () => {
20
+ expect(encodeSse("hello", { id: "1", retry: 3000 })).toBe(
21
+ "id: 1\nretry: 3000\ndata: hello\n\n",
22
+ );
23
+ });
24
+
25
+ test("returns an empty data frame for an empty payload", () => {
26
+ expect(encodeSse("")).toBe("data: \n\n");
27
+ });
28
+ });
29
+
30
+ describe("parseSse", () => {
31
+ test("parses a single complete frame", () => {
32
+ const { data, remainder } = parseSse("data: hello\n\n");
33
+ expect(data).toEqual(["hello"]);
34
+ expect(remainder).toBe("");
35
+ });
36
+
37
+ test("parses multiple complete frames", () => {
38
+ const { data, remainder } = parseSse(
39
+ "data: hello\n\ndata: world\n\n",
40
+ );
41
+ expect(data).toEqual(["hello", "world"]);
42
+ expect(remainder).toBe("");
43
+ });
44
+
45
+ test("keeps incomplete bytes in the remainder", () => {
46
+ const { data, remainder } = parseSse("data: hello\n");
47
+ expect(data).toEqual([]);
48
+ expect(remainder).toBe("data: hello\n");
49
+ });
50
+
51
+ test("reconstructs multi-line payloads", () => {
52
+ const { data, remainder } = parseSse(
53
+ "data: line1\ndata: line2\n\n",
54
+ );
55
+ expect(data).toEqual(["line1\nline2"]);
56
+ expect(remainder).toBe("");
57
+ });
58
+
59
+ test("ignores non-data fields", () => {
60
+ const { data, remainder } = parseSse(
61
+ "event: message\ndata: hello\n\n",
62
+ );
63
+ expect(data).toEqual(["hello"]);
64
+ expect(remainder).toBe("");
65
+ });
66
+
67
+ test("parses an empty data field", () => {
68
+ const { data, remainder } = parseSse("data: \n\n");
69
+ expect(data).toEqual([""]);
70
+ expect(remainder).toBe("");
71
+ });
72
+
73
+ test("parses complete and partial frames in the same buffer", () => {
74
+ const { data, remainder } = parseSse(
75
+ "data: hello\n\ndata: wor",
76
+ );
77
+ expect(data).toEqual(["hello"]);
78
+ expect(remainder).toBe("data: wor");
79
+ });
80
+
81
+ test("handles reversed field order and extracts only data", () => {
82
+ const { data, remainder } = parseSse(
83
+ "id: 42\nevent: msg\ndata: hello\nretry: 3000\n\n",
84
+ );
85
+ expect(data).toEqual(["hello"]);
86
+ expect(remainder).toBe("");
87
+ });
88
+
89
+ test("drops event, id, and retry fields from the payload", () => {
90
+ const { data, remainder } = parseSse(
91
+ "event: ping\nid: 7\nretry: 1000\ndata: payload\n\n",
92
+ );
93
+ expect(data).toEqual(["payload"]);
94
+ expect(remainder).toBe("");
95
+ });
96
+
97
+ test("handles CRLF terminators", () => {
98
+ const { data, remainder } = parseSse("data: hello\r\n\r\ndata: world\r\n\r\n");
99
+ expect(data).toEqual(["hello", "world"]);
100
+ expect(remainder).toBe("");
101
+ });
102
+
103
+ test("ignores comment frames", () => {
104
+ const { data, remainder } = parseSse(":keep-alive\n\ndata: hello\n\n");
105
+ expect(data).toEqual(["hello"]);
106
+ expect(remainder).toBe("");
107
+ });
108
+ });
package/src/sse.ts ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Low-level Server-Sent Events (SSE) encoding and decoding utilities.
3
+ *
4
+ * The protocol used here is the standard W3C SSE format:
5
+ * data: <line 1>\n
6
+ * data: <line 2>\n
7
+ * \n
8
+ *
9
+ * Multi-line payloads are split so each line is prefixed with `data: `.
10
+ * A blank line terminates the frame.
11
+ *
12
+ * In addition to the basic framing helpers, this module provides:
13
+ * - Heartbeat (comment) frame generation for proxy/connection stability.
14
+ * - JSON-RPC 2.0 line-delimited framing helpers that align with ACP/MCP
15
+ * stdio conventions.
16
+ */
17
+
18
+ /** Optional metadata attached to an SSE frame. */
19
+ export interface SseFrameOptions {
20
+ event?: string;
21
+ id?: string;
22
+ retry?: number;
23
+ }
24
+
25
+ /**
26
+ * Encode a plain string payload into a complete SSE text frame.
27
+ *
28
+ * @param data Payload to send.
29
+ * @param options Optional event name, id, or retry timing.
30
+ * @returns The encoded SSE frame, ready to be written to a response stream.
31
+ */
32
+ export function encodeSse(data: string, options?: SseFrameOptions): string {
33
+ let frame = "";
34
+
35
+ if (options?.id !== undefined) {
36
+ frame += `id: ${options.id}\n`;
37
+ }
38
+
39
+ if (options?.event) {
40
+ frame += `event: ${options.event}\n`;
41
+ }
42
+
43
+ if (options?.retry !== undefined) {
44
+ frame += `retry: ${options.retry}\n`;
45
+ }
46
+
47
+ // SSE allows multi-line data by repeating the `data:` field.
48
+ for (const line of data.split("\n")) {
49
+ frame += `data: ${line}\n`;
50
+ }
51
+
52
+ // The empty line marks the end of the frame.
53
+ frame += "\n";
54
+ return frame;
55
+ }
56
+
57
+ /**
58
+ * Encode a keep-alive SSE comment frame.
59
+ *
60
+ * Comment frames are ignored by EventSource consumers but keep the TCP
61
+ * connection alive through proxies and load balancers that would otherwise
62
+ * drop idle connections.
63
+ */
64
+ export function encodeSseKeepAlive(comment = "keep-alive"): string {
65
+ return `:${comment}\n\n`;
66
+ }
67
+
68
+ /** Result of parsing a raw SSE byte buffer. */
69
+ export interface ParsedSseFrames {
70
+ /** Complete data payloads extracted from fully received frames. */
71
+ data: string[];
72
+ /** Unfinished trailing bytes that should be kept for the next parse call. */
73
+ remainder: string;
74
+ }
75
+
76
+ /**
77
+ * Parse a chunk of SSE text into complete frames.
78
+ *
79
+ * Handles both LF (`\n\n`) and CRLF (`\r\n\r\n`) frame terminators, comment
80
+ * lines (which are ignored), and multi-line `data:` fields.
81
+ *
82
+ * @param buffer Raw SSE text received so far.
83
+ * @returns Parsed payloads plus any trailing bytes that do not yet form a complete frame.
84
+ */
85
+ export function parseSse(buffer: string): ParsedSseFrames {
86
+ const data: string[] = [];
87
+
88
+ // Normalize CRLF to LF first, then split on blank lines.
89
+ const normalized = buffer.replace(/\r\n/g, "\n");
90
+ const parts = normalized.split("\n\n");
91
+ const remainder = parts.pop() ?? "";
92
+
93
+ for (const part of parts) {
94
+ const lines = part.split("\n");
95
+ const payload: string[] = [];
96
+
97
+ for (const line of lines) {
98
+ // Skip comment frames (lines starting with `:`).
99
+ if (line.startsWith(":")) {
100
+ continue;
101
+ }
102
+ if (line.startsWith("data: ")) {
103
+ payload.push(line.slice(6));
104
+ }
105
+ }
106
+
107
+ if (payload.length > 0) {
108
+ data.push(payload.join("\n"));
109
+ }
110
+ }
111
+
112
+ return { data, remainder };
113
+ }
114
+
115
+ /**
116
+ * Split a raw byte/text stream buffer into JSON-RPC 2.0 messages using the
117
+ * newline-delimited framing used by ACP/MCP over stdio.
118
+ *
119
+ * Multi-byte UTF-8 characters that are split across chunk boundaries are kept
120
+ * in the remainder and completed on the next call. Pass the returned
121
+ * `remainder` back as the next `buffer` argument together with the new chunk.
122
+ */
123
+ export function parseJsonRpcLines(
124
+ buffer: Uint8Array,
125
+ textDecoder: TextDecoder,
126
+ ): {
127
+ messages: unknown[];
128
+ remainder: Uint8Array;
129
+ } {
130
+ // Scan for newline boundaries without decoding so that we can keep partial
131
+ // multi-byte sequences intact.
132
+ const lines: Uint8Array[] = [];
133
+ let start = 0;
134
+
135
+ for (let i = 0; i < buffer.length; i++) {
136
+ if (buffer[i] === 0x0a) {
137
+ // \n
138
+ const end = i > start && buffer[i - 1] === 0x0d ? i - 1 : i;
139
+ if (end > start) {
140
+ lines.push(buffer.subarray(start, end));
141
+ }
142
+ start = i + 1;
143
+ }
144
+ }
145
+
146
+ const remainder = buffer.subarray(start);
147
+
148
+ const messages: unknown[] = [];
149
+ for (const line of lines) {
150
+ const text = textDecoder.decode(line);
151
+ if (!text.trim()) continue;
152
+ try {
153
+ messages.push(JSON.parse(text));
154
+ } catch {
155
+ // Discard malformed lines; this matches the line-delimited framing
156
+ // expectation that each non-empty line is valid JSON.
157
+ }
158
+ }
159
+
160
+ return { messages, remainder };
161
+ }
162
+
163
+ /**
164
+ * Encode a JSON-RPC 2.0 message into a single newline-terminated UTF-8 line.
165
+ */
166
+ export function encodeJsonRpcLine(message: unknown): string {
167
+ return `${JSON.stringify(message)}\n`;
168
+ }