| 56 | * @internal exported for testing |
| 57 | */ |
| 58 | export function parseSSEFrames(buffer: string): { |
| 59 | frames: SSEFrame[] |
| 60 | remaining: string |
| 61 | } { |
| 62 | const frames: SSEFrame[] = [] |
| 63 | let pos = 0 |
| 64 | |
| 65 | // SSE frames are delimited by double newlines |
| 66 | let idx: number |
| 67 | while ((idx = buffer.indexOf('\n\n', pos)) !== -1) { |
| 68 | const rawFrame = buffer.slice(pos, idx) |
| 69 | pos = idx + 2 |
| 70 | |
| 71 | // Skip empty frames |
| 72 | if (!rawFrame.trim()) continue |
| 73 | |
| 74 | const frame: SSEFrame = {} |
| 75 | let isComment = false |
| 76 | |
| 77 | for (const line of rawFrame.split('\n')) { |
| 78 | if (line.startsWith(':')) { |
| 79 | // SSE comment (e.g., `:keepalive`) |
| 80 | isComment = true |
| 81 | continue |
| 82 | } |
| 83 | |
| 84 | const colonIdx = line.indexOf(':') |
| 85 | if (colonIdx === -1) continue |
| 86 | |
| 87 | const field = line.slice(0, colonIdx) |
| 88 | // Per SSE spec, strip one leading space after colon if present |
| 89 | const value = |
| 90 | line[colonIdx + 1] === ' ' |
| 91 | ? line.slice(colonIdx + 2) |
| 92 | : line.slice(colonIdx + 1) |
| 93 | |
| 94 | switch (field) { |
| 95 | case 'event': |
| 96 | frame.event = value |
| 97 | break |
| 98 | case 'id': |
| 99 | frame.id = value |
| 100 | break |
| 101 | case 'data': |
| 102 | // Per SSE spec, multiple data: lines are concatenated with \n |
| 103 | frame.data = frame.data ? frame.data + '\n' + value : value |
| 104 | break |
| 105 | // Ignore other fields (retry:, etc.) |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | // Only emit frames that have data (or are pure comments which reset liveness) |
| 110 | if (frame.data || isComment) { |
| 111 | frames.push(frame) |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | return { frames, remaining: buffer.slice(pos) } |