// TODO: This probably needs some work. // The reason this exists is that Browser's native EventSource is rubbish: // 1. It does not allow passing of headers. // 2. It is browser-only (this implementation aims to work for Node also) // 3. It does not handle disconnects/reconnects gracefully // 4. It does not handle last-event-id (important for history). // 5. It does not allow a custom fetch function (probably not necessary, but there's a reason I'd rather support this which I'll explain some other time). // This might eventually be shifted out of XO/stack package and into its own. // Q: "Why not just leverage an existing package?" // A: They're all bad - the "go to" is a Microsoft (@azure/fetch-event-soruce) that is no longer maintained. // It does not support NodeJS (or Workers) and we cannot depend upon forks of it. export interface SSEOptions { fetch?: typeof fetch; headers?: Record; onMessage?: (event: SSEMessageEvent) => void; onError?: (error: Error) => void; onOpen?: () => void; onClose?: () => void; maxRetries?: number; retryDelay?: number; } export interface SSEMessageEvent { data: string; type: string; id: string | null; lastEventId: string; origin: string; } export interface SSEEvent { data: string; event: string; id: string | null; } export class SSEClient { private url: string; private options: Required> & { headers: Record; }; private abortController: AbortController | null; private retryCount: number; private lastEventId: string; private connected: boolean; constructor(url: string, options: SSEOptions = {}) { this.url = url; this.options = { fetch: globalThis.fetch, headers: {}, onMessage: () => {}, onError: () => {}, onOpen: () => {}, onClose: () => {}, maxRetries: 5, retryDelay: 3000, ...options, }; this.abortController = null; this.retryCount = 0; this.lastEventId = ''; this.connected = false; } public async connect(): Promise { if (this.connected) { return; } this.abortController = new AbortController(); try { const response = await this.options.fetch(this.url, { headers: { Accept: 'text/event-stream', 'Cache-Control': 'no-cache', 'Last-Event-ID': this.lastEventId, ...this.options.headers, }, signal: this.abortController.signal, }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } if (!response.body) { throw new Error('ReadableStream not supported'); } this.connected = true; this.options.onOpen(); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { value, done } = await reader.read(); if (done) { this.handleDisconnect(); break; } buffer += decoder.decode(value, { stream: true }); const lines = buffer.split(/\r\n|\r|\n/); buffer = this.processEventStream(lines); } } catch (error) { if (error instanceof Error && error.name === 'AbortError') { return; } this.options.onError( error instanceof Error ? error : new Error(String(error)), ); this.handleDisconnect(); if (this.retryCount < this.options.maxRetries) { this.retryCount++; setTimeout(() => this.connect(), this.options.retryDelay); } } } private processEventStream(lines: string[]): string { let event: SSEEvent = { data: '', event: 'message', id: null, }; for (let i = 0; i < lines.length - 1; i++) { const line = lines[i]; if (line.length === 0) { // Empty line means dispatch the event if (event.data.length > 0) { this.dispatchEvent(event); event = { data: '', event: 'message', id: null, }; } continue; } const [fieldName, ...fieldValue] = line.split(':'); const value = fieldValue.join(':').trim(); switch (fieldName) { case 'event': event.event = value; break; case 'data': event.data += value + '\n'; break; case 'id': event.id = value; this.lastEventId = value; break; case 'retry': { const retryInt = parseInt(value, 10); if (!isNaN(retryInt)) { this.options.retryDelay = retryInt; } break; } } } // Return the last incomplete line as the new buffer return lines[lines.length - 1]; } private dispatchEvent(event: SSEEvent): void { const messageEvent: SSEMessageEvent = { data: event.data.slice(0, -1), // Remove trailing newline type: event.event, id: event.id, lastEventId: this.lastEventId, origin: this.url, }; this.options.onMessage(messageEvent); } private handleDisconnect(): void { this.connected = false; this.options.onClose(); } public close(): void { if (this.abortController) { this.abortController.abort(); this.handleDisconnect(); } } }