From bb07836231fab6ea0ab034b6b889b8a91f3b990c Mon Sep 17 00:00:00 2001 From: Alan Date: Thu, 9 Apr 2026 21:52:28 +0800 Subject: [PATCH] fix: support CRLF SSE frame parsing (#223) --- src/cli/transports/SSETransport.ts | 22 ++++++--- .../transports/__tests__/SSETransport.test.ts | 49 +++++++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 src/cli/transports/__tests__/SSETransport.test.ts diff --git a/src/cli/transports/SSETransport.ts b/src/cli/transports/SSETransport.ts index 8808fe671..fd5b11f53 100644 --- a/src/cli/transports/SSETransport.ts +++ b/src/cli/transports/SSETransport.ts @@ -63,11 +63,15 @@ export function parseSSEFrames(buffer: string): { const frames: SSEFrame[] = [] let pos = 0 - // SSE frames are delimited by double newlines - let idx: number - while ((idx = buffer.indexOf('\n\n', pos)) !== -1) { - const rawFrame = buffer.slice(pos, idx) - pos = idx + 2 + // SSE frames are delimited by an empty line. Support LF and CRLF streams. + const frameDelimiter = /\r?\n\r?\n/g + frameDelimiter.lastIndex = pos + + let delimiterMatch: RegExpExecArray | null + while ((delimiterMatch = frameDelimiter.exec(buffer)) !== null) { + const frameEnd = delimiterMatch.index + const rawFrame = buffer.slice(pos, frameEnd) + pos = frameEnd + delimiterMatch[0].length // Skip empty frames if (!rawFrame.trim()) continue @@ -75,7 +79,13 @@ export function parseSSEFrames(buffer: string): { const frame: SSEFrame = {} let isComment = false - for (const line of rawFrame.split('\n')) { + for (const rawLine of rawFrame.split('\n')) { + // Normalize CRLF lines in mixed-line-ending streams. + const line = + rawLine[rawLine.length - 1] === '\r' + ? rawLine.slice(0, -1) + : rawLine + if (line.startsWith(':')) { // SSE comment (e.g., `:keepalive`) isComment = true diff --git a/src/cli/transports/__tests__/SSETransport.test.ts b/src/cli/transports/__tests__/SSETransport.test.ts new file mode 100644 index 000000000..40c27ca36 --- /dev/null +++ b/src/cli/transports/__tests__/SSETransport.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'bun:test' +import { parseSSEFrames } from '../SSETransport.js' + +describe('parseSSEFrames', () => { + test('parses LF-delimited frames', () => { + const input = 'event: client_event\ndata: {"ok":true}\n\n' + const { frames, remaining } = parseSSEFrames(input) + + expect(remaining).toBe('') + expect(frames).toEqual([ + { + event: 'client_event', + data: '{"ok":true}', + }, + ]) + }) + + test('parses CRLF-delimited frames and strips trailing carriage returns', () => { + const input = + 'event: client_event\r\ndata: {"ok":true}\r\nid: 7\r\n\r\nevent: keepalive\r\ndata: ping\r\n\r\n' + const { frames, remaining } = parseSSEFrames(input) + + expect(remaining).toBe('') + expect(frames).toEqual([ + { + event: 'client_event', + data: '{"ok":true}', + id: '7', + }, + { + event: 'keepalive', + data: 'ping', + }, + ]) + }) + + test('keeps incomplete trailing frame in remaining buffer for CRLF streams', () => { + const input = 'event: client_event\r\ndata: {"ok":true}\r\n\r\ndata: {"tail":1}\r\n' + const { frames, remaining } = parseSSEFrames(input) + + expect(frames).toEqual([ + { + event: 'client_event', + data: '{"ok":true}', + }, + ]) + expect(remaining).toBe('data: {"tail":1}\r\n') + }) +})