返回 CodeWhale
sse.ts
根目录 / extensions / vscode / src / sse.ts
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
113 lines TYPESCRIPT