Skip to content

Commit bb07836

Browse files
authored
fix: support CRLF SSE frame parsing (#223)
1 parent 87230cf commit bb07836

2 files changed

Lines changed: 65 additions & 6 deletions

File tree

src/cli/transports/SSETransport.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,19 +63,29 @@ export function parseSSEFrames(buffer: string): {
6363
const frames: SSEFrame[] = []
6464
let pos = 0
6565

66-
// SSE frames are delimited by double newlines
67-
let idx: number
68-
while ((idx = buffer.indexOf('\n\n', pos)) !== -1) {
69-
const rawFrame = buffer.slice(pos, idx)
70-
pos = idx + 2
66+
// SSE frames are delimited by an empty line. Support LF and CRLF streams.
67+
const frameDelimiter = /\r?\n\r?\n/g
68+
frameDelimiter.lastIndex = pos
69+
70+
let delimiterMatch: RegExpExecArray | null
71+
while ((delimiterMatch = frameDelimiter.exec(buffer)) !== null) {
72+
const frameEnd = delimiterMatch.index
73+
const rawFrame = buffer.slice(pos, frameEnd)
74+
pos = frameEnd + delimiterMatch[0].length
7175

7276
// Skip empty frames
7377
if (!rawFrame.trim()) continue
7478

7579
const frame: SSEFrame = {}
7680
let isComment = false
7781

78-
for (const line of rawFrame.split('\n')) {
82+
for (const rawLine of rawFrame.split('\n')) {
83+
// Normalize CRLF lines in mixed-line-ending streams.
84+
const line =
85+
rawLine[rawLine.length - 1] === '\r'
86+
? rawLine.slice(0, -1)
87+
: rawLine
88+
7989
if (line.startsWith(':')) {
8090
// SSE comment (e.g., `:keepalive`)
8191
isComment = true
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { parseSSEFrames } from '../SSETransport.js'
3+
4+
describe('parseSSEFrames', () => {
5+
test('parses LF-delimited frames', () => {
6+
const input = 'event: client_event\ndata: {"ok":true}\n\n'
7+
const { frames, remaining } = parseSSEFrames(input)
8+
9+
expect(remaining).toBe('')
10+
expect(frames).toEqual([
11+
{
12+
event: 'client_event',
13+
data: '{"ok":true}',
14+
},
15+
])
16+
})
17+
18+
test('parses CRLF-delimited frames and strips trailing carriage returns', () => {
19+
const input =
20+
'event: client_event\r\ndata: {"ok":true}\r\nid: 7\r\n\r\nevent: keepalive\r\ndata: ping\r\n\r\n'
21+
const { frames, remaining } = parseSSEFrames(input)
22+
23+
expect(remaining).toBe('')
24+
expect(frames).toEqual([
25+
{
26+
event: 'client_event',
27+
data: '{"ok":true}',
28+
id: '7',
29+
},
30+
{
31+
event: 'keepalive',
32+
data: 'ping',
33+
},
34+
])
35+
})
36+
37+
test('keeps incomplete trailing frame in remaining buffer for CRLF streams', () => {
38+
const input = 'event: client_event\r\ndata: {"ok":true}\r\n\r\ndata: {"tail":1}\r\n'
39+
const { frames, remaining } = parseSSEFrames(input)
40+
41+
expect(frames).toEqual([
42+
{
43+
event: 'client_event',
44+
data: '{"ok":true}',
45+
},
46+
])
47+
expect(remaining).toBe('data: {"tail":1}\r\n')
48+
})
49+
})

0 commit comments

Comments
 (0)