worry_debate_game/frontend/src/api/client.ts(133 行)(同类实现:查资料/web/js/common.js 及 script.js 的 readSSEStream、shijing-v5-dev/src/hooks/useSSEStream.ts)
fetch + ReadableStream 读取服务端推送的 SSE,正确处理"半个事件包"和错误分级,并有 TypeScript 类型保证。
EventSource 只支持 GET;项目要 POST 发送参数,所以必须手动读流。而网络传输会随机切包,必须处理不完整的半包。
类型化事件协议(第 12-27 行)——把字符串事件名变成编译期可检查的联合类型:
export type ServerEvent =
| { type: 'session'; session_id: string; max_rounds: number }
| { type: 'worry'; text: string }
| { type: 'angel'; text: string }
| { type: 'interrupt'; by: string }
| { type: 'judge'; angel: number; demon: number; ... }
| { type: 'error'; message: string; retryable?: boolean };
流读取核心(第 77-107 行)——半包处理是关键:
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
let idx: number;
while ((idx = buffer.indexOf('\n\n')) >= 0) { // SSE 用空行分隔事件
flush(buffer.slice(0, idx)); // 完整事件才处理
buffer = buffer.slice(idx + 2);
}
}
if (buffer.trim()) flush(buffer); // 结尾残留也处理
错误分级(第 64-74 行)——把 FastAPI 的两种 detail 格式都变成人话:
if (!res.ok || !res.body) {
let msg = `请求失败(HTTP ${res.status})`;
try {
const data = await res.json();
if (typeof data?.detail === 'string') msg = data.detail;
else if (Array.isArray(data?.detail)) msg = '参数不合法:' + data.detail.map(d => d.msg).join(';');
} catch { /* ignore */ }
onEvent({ type: 'error', message: msg, retryable: res.status >= 500 });
return;
}
buffer 累积 + 按 \n\n 切分 + 残留留到下一轮——流式解析的必修课AbortSignal 支持:可取消进行中的请求(切页面/重新开始时用)retryable: status >= 500 告诉调用方"值不值得重试"onEvent 回调:解析与业务解耦,谁用谁传处理函数X-API-Key 请求头:把密钥交给用户自己保管,服务端不必存ServerEvent 换成你的泛型即可).test.ts 单测 + Puppeteer 验收脚本sse-client(npm):createSSEClient<T>(url, { onEvent, signal })EventSource(只支持 GET)、半包怎么处理、错误怎么分级event: xxx\ndata: json\n\n)@microsoft/fetch-event-source:成熟的 fetch-SSE 客户端,功能更全(自动重连等)eventsource-parser:只解析 SSE 文本流,和你的 flush() 等价第 09 课(流式输出)、第 13 课(前端工程化)、第 14 课(流式会话进阶)