| 1 | /** |
| 2 | * Minimal text/event-stream parser for `GET /v1/threads/{id}/events`. |
| 3 | * |
| 4 | * Pure and dependency-free: callers feed raw network chunks in, parsed frames |
| 5 | * come out. The Codewhale runtime encodes each event as a JSON object in the |
| 6 | * frame data, so a frame with parseable JSON yields one RuntimeEvent; frames |
| 7 | * without JSON (heartbeats, comments) yield nothing. |
| 8 | */ |
| 9 | |
| 10 | export interface RuntimeEvent { |
| 11 | seq: number; |
| 12 | previousSeq?: number; |
| 13 | event: string; |
| 14 | threadId?: string; |
| 15 | turnId?: string; |
| 16 | itemId?: string; |
| 17 | timestamp?: string; |
| 18 | payload: unknown; |
| 19 | } |
| 20 | |
| 21 | export class SseParser { |
| 22 | private buffer = ""; |
| 23 | |
| 24 | /** Feed one raw chunk; returns the complete events it finished. */ |
| 25 | push(chunk: string): RuntimeEvent[] { |
| 26 | this.buffer += chunk; |
| 27 | const events: RuntimeEvent[] = []; |
| 28 | let boundary = this.nextBoundary(); |
| 29 | while (boundary !== -1) { |
| 30 | const frame = this.buffer.slice(0, boundary.index); |
| 31 | this.buffer = this.buffer.slice(boundary.index + boundary.length); |
| 32 | const event = parseFrame(frame); |
| 33 | if (event) { |
| 34 | events.push(event); |
| 35 | } |
| 36 | boundary = this.nextBoundary(); |
| 37 | } |
| 38 | return events; |
| 39 | } |
| 40 | |
| 41 | private nextBoundary(): { index: number; length: number } | -1 { |
| 42 | const lf = this.buffer.indexOf("\n\n"); |
| 43 | const crlf = this.buffer.indexOf("\r\n\r\n"); |
| 44 | if (lf === -1 && crlf === -1) { |
| 45 | return -1; |
| 46 | } |
| 47 | if (crlf === -1 || (lf !== -1 && lf < crlf)) { |
| 48 | return { index: lf, length: 2 }; |
| 49 | } |
| 50 | return { index: crlf, length: 4 }; |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | export function parseFrame(frame: string): RuntimeEvent | undefined { |
| 55 | let data = ""; |
| 56 | for (const rawLine of frame.split(/\r?\n/)) { |
| 57 | if (rawLine === "" || rawLine.startsWith(":")) { |
| 58 | continue; |
| 59 | } |
| 60 | const colon = rawLine.indexOf(":"); |
| 61 | const field = colon === -1 ? rawLine : rawLine.slice(0, colon); |
| 62 | let value = colon === -1 ? "" : rawLine.slice(colon + 1); |
| 63 | if (value.startsWith(" ")) { |
| 64 | value = value.slice(1); |
| 65 | } |
| 66 | if (field === "data") { |
| 67 | data += (data ? "\n" : "") + value; |
| 68 | } |
| 69 | // `event`, `id`, and `retry` are ignored: the runtime envelope carries the |
| 70 | // event name in its own `event` field, which is the stable contract. |
| 71 | } |
| 72 | if (!data) { |
| 73 | return undefined; |
| 74 | } |
| 75 | return readEvent(data); |
| 76 | } |
| 77 | |
| 78 | function readEvent(data: string): RuntimeEvent | undefined { |
| 79 | let body: unknown; |
| 80 | try { |
| 81 | body = JSON.parse(data); |
| 82 | } catch { |
| 83 | return undefined; |
| 84 | } |
| 85 | if (!body || typeof body !== "object") { |
| 86 | return undefined; |
| 87 | } |
| 88 | const record = body as Record<string, unknown>; |
| 89 | const seq = readNumber(record.seq); |
| 90 | const event = readString(record.event); |
| 91 | if (seq === undefined || !event) { |
| 92 | return undefined; |
| 93 | } |
| 94 | return { |
| 95 | seq, |
| 96 | previousSeq: readNumber(record.previous_seq), |
| 97 | event, |
| 98 | threadId: readString(record.thread_id), |
| 99 | turnId: readString(record.turn_id), |
| 100 | itemId: readString(record.item_id), |
| 101 | timestamp: readString(record.timestamp) ?? readString(record.created_at), |
| 102 | payload: record.payload, |
| 103 | }; |
| 104 | } |
| 105 | |
| 106 | function readString(value: unknown): string | undefined { |
| 107 | return typeof value === "string" ? value : undefined; |
| 108 | } |
| 109 | |
| 110 | function readNumber(value: unknown): number | undefined { |
| 111 | return typeof value === "number" && Number.isFinite(value) ? value : undefined; |
| 112 | } |
| 113 |