// 基于 fetch + ReadableStream 的轻量 SSE 客户端,带指数退避自动重连。 // // 原生 EventSource 无法携带自定义 header,只能把 token 放进 URL query, // 而 token 进 URL 会被网关/浏览器历史/Referer 记录,存在泄露风险。 // 这里用 fetch 手动读取 text/event-stream,token 走标准 Authorization header。 export interface SSEHandlers { onOpen?: () => void; onMessage?: (data: string) => void; onError?: (err: unknown) => void; // 连接断开、准备重连时回调,附带本次退避延迟(毫秒) onReconnect?: (delayMs: number) => void; } export interface SSEOptions { // 初始重连延迟(毫秒),默认 1000 baseDelayMs?: number; // 最大重连延迟(毫秒),默认 30000 maxDelayMs?: number; // 鉴权失败(401/403)时是否停止重连,默认 true stopOnAuthError?: boolean; } export interface SSEConnection { close: () => void; } const AUTH_ERROR_STATUSES = new Set([401, 403]); export function connectSSE( url: string, token: string, handlers: SSEHandlers, options: SSEOptions = {}, ): SSEConnection { const baseDelay = options.baseDelayMs ?? 1000; const maxDelay = options.maxDelayMs ?? 30000; const stopOnAuthError = options.stopOnAuthError ?? true; let controller = new AbortController(); let closed = false; let attempt = 0; let retryTimer: ReturnType | null = null; const scheduleReconnect = () => { if (closed) return; // 指数退避 + 抖动,封顶 maxDelay const backoff = Math.min(baseDelay * 2 ** attempt, maxDelay); const delay = backoff / 2 + Math.random() * (backoff / 2); attempt += 1; handlers.onReconnect?.(delay); retryTimer = setTimeout(() => { if (closed) return; controller = new AbortController(); void run(); }, delay); }; const run = async () => { try { const resp = await fetch(url, { method: 'GET', headers: { Accept: 'text/event-stream', ...(token ? { Authorization: `Bearer ${token}` } : {}), }, signal: controller.signal, }); if (!resp.ok || !resp.body) { handlers.onError?.(new Error(`SSE connect failed: ${resp.status}`)); if (stopOnAuthError && AUTH_ERROR_STATUSES.has(resp.status)) { closed = true; return; } scheduleReconnect(); return; } // 连接成功,重置退避计数 attempt = 0; handlers.onOpen?.(); const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let sep: number; while ((sep = buffer.indexOf('\n\n')) !== -1) { const rawEvent = buffer.slice(0, sep); buffer = buffer.slice(sep + 2); const data = parseEventData(rawEvent); if (data !== null) handlers.onMessage?.(data); } } // 流正常结束(服务端关闭),非主动 close 则尝试重连 if (!closed) scheduleReconnect(); } catch (err) { if (controller.signal.aborted || closed) return; handlers.onError?.(err); scheduleReconnect(); } }; void run(); return { close: () => { closed = true; if (retryTimer) clearTimeout(retryTimer); controller.abort(); }, }; } function parseEventData(rawEvent: string): string | null { // 只关心 data: 行,多行 data 用 \n 拼接,忽略注释(:)与其他字段 const dataLines = rawEvent .split('\n') .filter((line) => line.startsWith('data:')) .map((line) => line.slice(5).replace(/^ /, '')); if (dataLines.length === 0) return null; return dataLines.join('\n'); }