本节目标:掌握 SSE(Server-Sent Events)的完整链路。你会知道它与 WebSocket 各自的适用边界、事件流的文本格式如何影响客户端解析、
EventSource的自动重连什么时候帮倒忙,以及当需要 POST 或自定义请求头时怎么用fetch+ReadableStream接管整个流。
10.2 SSE 与流式响应
上一节的消息协议是双向的:客户端发订阅指令,服务端推数据。但很多场景其实只有一半:行情报价、日志尾随、构建进度、大模型逐字输出——客户端只负责看。这时候 WebSocket 的双向能力用不上,反而带来握手升级、代理穿透、心跳保活一整串额外工作。
10.2.1 先选型:什么时候不该用 WebSocket
| 维度 | WebSocket | SSE |
|---|---|---|
| 方向 | 双向 | 仅服务端 → 客户端 |
| 传输 | 独立的 ws:// 协议,需 HTTP 升级握手 | 普通 HTTP 响应,text/event-stream |
| 数据格式 | 文本或二进制 | 仅 UTF-8 文本 |
| 自动重连 | 需自己实现 | 浏览器内置,带 Last-Event-ID |
| 请求方式 | 任意 | EventSource 仅 GET,无自定义头 |
| 代理/网关 | 需显式支持升级 | 与普通响应一致,只需关缓冲 |
| 浏览器连接数 | 每域名有上限(HTTP/1.1 约 6) | 同受此限制 |
结论很清晰:单向推送就选 SSE。只有三种情况必须回到 WebSocket——需要客户端高频上行(协同编辑、游戏操作)、需要二进制帧、或者需要跨域携带自定义鉴权头且不愿改造。站内对两者有更细的对比:WebSocket 与 SSE 实时架构对比 、实时通信协议选型 。
10.2.2 事件流的文本格式
SSE 没有二进制帧,整个协议就是一段纯文本,按「行」解析,空行表示一个事件结束:
event: progress
id: 42
retry: 3000
data: {"stage":"compile","percent":80}
data: 这是一条没有事件名的消息
data: 可以占多行,客户端会拼接成一个字符串
: 以冒号开头的是注释,常用来做保活
四条字段规则必须记牢:
| 字段 | 作用 | 注意 |
|---|---|---|
data | 消息体 | 多行 data 会以 \n 拼接;只有它决定是否派发事件 |
event | 事件名 | 缺省为 message;决定客户端监听哪个事件 |
id | 事件 id | 浏览器重连时通过 Last-Event-ID 请求头发回 |
retry | 重连毫秒数 | 服务端下发的建议值,覆盖浏览器默认的 3 秒 |
两个细节极易踩:行分隔符统一用 \n,但协议也接受 \r\n 与 \r,自己实现解析器时三种都要处理;冒号后有且仅有一个空格会被吃掉,data: x 拿到 x,data: x 拿到 x。
10.2.3 服务端:用 ReadableStream 输出
SSE 的响应是一个永不主动结束的 HTTP 响应。在 Web 标准的 Response 里,这意味着返回一个 ReadableStream:
const encoder = new TextEncoder();
function sseFrame(event: string, data: unknown, id?: number): Uint8Array {
const lines = [`event: ${event}`];
if (id !== undefined) lines.push(`id: ${id}`);
lines.push(`data: ${JSON.stringify(data)}`);
return encoder.encode(lines.join('\n') + '\n\n'); // 结尾必须有空行
}
function streamResponse(): Response {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
const timer = setInterval(() => {
controller.enqueue(sseFrame('tick', { ts: Date.now() }));
}, 1000);
// 客户端断开时清理,否则定时器会泄漏
return () => clearInterval(timer);
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no', // 让 Nginx 不要缓冲
},
});
}
三处响应头缺一不可。Content-Type 必须是 text/event-stream,否则浏览器不会进入流式模式;Cache-Control 里的 no-transform 阻止中间层压缩重写;X-Accel-Buffering: no 是 Nginx 特有的关缓冲开关,不写它的话整个响应会被攒够一个缓冲区才吐给客户端,表现为「流式变一次性」。代理层配置详见站内 Nginx 代理 WebSocket 与 SSE
。
在框架里写法更简洁。Hono 用 streamSSE 助手:
import { Hono } from 'hono';
import { streamSSE } from 'hono/streaming';
const app = new Hono();
app.get('/events', (c) => {
return streamSSE(c, async (stream) => {
let id = 0;
while (!stream.aborted) {
await stream.writeSSE({
event: 'progress',
data: JSON.stringify({ stage: 'compile', percent: id * 10 }),
id: String(id++),
});
await stream.sleep(1000);
}
});
});
stream.aborted 会在客户端断开时变为 true,是退出循环的正确信号——比在 close 事件里手工清理可靠。路由与中间件的整体结构见 《TypeScript编程实战》5.1 HTTP 服务与路由(Fastify / Hono)
。
10.2.4 客户端:EventSource 与其局限
浏览器内置的 EventSource 把重连、Last-Event-ID、帧解析全都做完了:
const source = new EventSource('/events');
source.addEventListener('progress', (event) => {
const data = JSON.parse(event.data); // event: MessageEvent<string>
console.log(data.percent);
});
source.addEventListener('open', () => console.log('connected'));
source.addEventListener('error', () => console.log('reconnecting...'));
它有两个硬伤:
一、只能发 GET,不能自定义请求头。 这意味着无法携带 Authorization。常见变通是把 token 放查询串(/events?token=...,会进日志,不安全)或依赖 Cookie(跨域时又要处理 SameSite)。
二、没有「重连完成」这个事件。 open 只在首次连接成功时触发一次,浏览器自动重连后不会再派发 open。想在重连后拉取断线期间漏掉的数据,只能靠 error + 计时器推断,非常别扭。
还有一点容易被误解:EventSource 的自动重连只在网络层失败时生效。如果服务端返回了 200 然后正常结束响应,浏览器会按 retry 重新请求;但如果服务端返回 4xx/5xx,浏览器会直接放弃并触发 error,不再重试。想让鉴权失败也能重试,就必须自己接管。
10.2.5 用 fetch + ReadableStream 接管整个流
需要 POST、需要自定义头、需要精细控制重连策略时,放弃 EventSource,自己读流:
async function connect(signal: AbortSignal) {
const res = await fetch('/events', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
Accept: 'text/event-stream',
},
body: JSON.stringify({ channels: ['orders', 'trades'] }),
signal,
});
if (!res.ok || !res.body) throw new Error(`SSE failed: ${res.status}`);
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
// 事件以空行分隔,最后一段可能不完整,留在 buffer 里
const chunks = buffer.split('\n\n');
buffer = chunks.pop() ?? '';
for (const chunk of chunks) dispatch(parseFrame(chunk));
}
}
这段代码里最重要的细节是缓冲区切分:网络分片不保证按事件边界到达,一个 data: 行可能被切成两半。必须用 buffer 累积、只在遇到 \n\n 时才切出完整事件,剩下的碎片留给下一轮。忘记这一点会得到「偶发 JSON.parse 失败」,且只在数据量大时复现。
配套的解析函数同样要处理多行 data 与注释行:
type SseFrame = { event: string; id?: string; data: string };
function parseFrame(raw: string): SseFrame | null {
const frame: SseFrame = { event: 'message', data: '' };
const dataLines: string[] = [];
for (const line of raw.split('\n')) {
if (line === '' || line.startsWith(':')) continue; // 空行与注释
const idx = line.indexOf(':');
const field = idx === -1 ? line : line.slice(0, idx);
const value = idx === -1 ? '' : line.slice(idx + 1).replace(/^ /, '');
if (field === 'data') dataLines.push(value);
else if (field === 'event') frame.event = value;
else if (field === 'id') frame.id = value;
}
if (dataLines.length === 0) return null; // 无 data 不派发事件
frame.data = dataLines.join('\n');
return frame;
}
TextDecoderStream 是浏览器内置的转换流,负责处理 UTF-8 字符跨分片被截断的情况——中文尤其容易中招。若在 Node 端处理流,等价物是 node:stream 的 TextDecoder,参见 Node.js 流与缓冲区
。
10.2.6 给事件契约加类型
EventSource 的监听器拿到的是 MessageEvent<string>,event.data 永远是字符串。类型安全要在解析之后补上,做法与上一节的判别联合完全一致,只是判别键换成了事件名:
import { z } from 'zod';
const schemas = {
progress: z.object({ stage: z.string(), percent: z.number() }),
done: z.object({ ok: z.boolean(), elapsedMs: z.number() }),
error: z.object({ code: z.number(), message: z.string() }),
} as const;
type EventName = keyof typeof schemas;
// 每个事件名映射到它自己的 payload 类型
type Payload<K extends EventName> = z.infer<(typeof schemas)[K]>;
function on<K extends EventName>(name: K, handler: (data: Payload<K>) => void) {
source.addEventListener(name, (e) => {
const parsed = schemas[name].safeParse(JSON.parse((e as MessageEvent).data));
if (parsed.success) handler(parsed.data as Payload<K>);
});
}
on('progress', (d) => console.log(d.percent)); // d 被精确推导为 { stage; percent }
on('progress', (d) => console.log(d.elapsedMs)); // 编译错误:属性不存在
这里 schemas 用 as const 固定住键集合,Payload<K> 通过索引访问把事件名映射到对应的 payload 类型,调用处就能获得精确推导。这套「用 schema 表驱动事件」的写法在客户端状态管理里同样常见,参见 《TypeScript编程实战》11.2 Hooks 类型与自定义 Hook
。
10.2.7 流式 JSON 与增量解析
大模型场景的响应常常是一个 JSON 对象被切成很多片段陆续发来({"text":"你 / 好,世 / 界"})。有两种处理策略:
| 策略 | 服务端 | 客户端 | 适用 |
|---|---|---|---|
| 逐事件 JSON | 每片都是完整 JSON | 直接 JSON.parse | 自定义协议,最省心 |
| JSON Patch 流 | 发增量补丁 | 应用补丁到本地状态 | 状态型界面 |
| 逐字符文本 | 发纯文本增量 | 字符串拼接 | 打字机效果 |
最实用的是第一种:让服务端把每个片段包成一个完整的事件,客户端就永远不需要处理半截 JSON。这也正是主流大模型 API 的做法——每个 data: 行都是独立可解析的 JSON,最后以 data: [DONE] 收尾。相关实践见站内 LLM 流式与实时输出
。
若确实要解析半截 JSON,不要试图补全括号,而是用增量 JSON 解析库,或者干脆改协议。手写「猜补全」的代码一定会在一周内变成维护噩梦。
10.2.8 三个必踩的坑
一、代理缓冲。 Nginx 默认 proxy_buffering on,会把响应攒起来。症状是本地开发一切正常、上线后变成「等 30 秒一次性吐出」。除了响应头里的 X-Accel-Buffering: no,还要在 Nginx 侧配 proxy_buffering off; proxy_read_timeout 3600s;。
二、HTTP/1.1 的并发连接上限。 同一域名下浏览器只允许约 6 条连接,SSE 会长期占住其中一条。多个标签页同时开 SSE,很容易把页面其他请求饿死。HTTP/2 多路复用可以缓解,但要注意服务端是否真的启用了 h2。
三、忘了关闭上游资源。 客户端断开后,ReadableStream 的 cancel、stream.aborted、或者 req.on('close') 必须被用来清理定时器与数据库游标。否则每次断线都泄漏一份资源,几小时后进程 OOM。优雅关闭时也要先停掉所有活跃流,见 《TypeScript编程实战》5.3 优雅关闭与健康检查
。
10.2.9 与本书其它章节的衔接
SSE 的事件契约与上一节的 WebSocket 消息协议共享同一套判别联合思路,见 《TypeScript编程实战》10.1 WebSocket 消息协议判别联合 ;流式数据的消费端缓存与失效策略见 《TypeScript编程实战》14.1 TanStack Query 类型推导 ;SSE 端点同样需要鉴权中间件,见 《TypeScript编程实战》5.2 中间件与请求上下文 。
站内延伸阅读:SSE 实时推送实践 、GraphQL 订阅与 SSE 、TypeScript 实时通信选型 、流式 JSON 响应处理 。
小结
SSE 的本质是「一条不结束的 HTTP 响应」:服务端返回 text/event-stream 并把事件按「字段行 + 空行」的格式写进流,客户端解析出来就是一组带事件名的消息。它的自动重连、Last-Event-ID 补偿、UTF-8 解码都由浏览器代劳,代价是只能单向、只能 GET、不能带自定义头。
一旦需要 POST、需要 Authorization、或需要对重连做精细控制,就换成 fetch + ReadableStream,自己按 \n\n 切分并处理跨分片的半截数据。契约类型仍然用判别联合表达,只是判别键从 type 换成了事件名。上线前务必确认三件事:响应头关掉了缓冲、代理配置关掉了缓冲、断线时上游资源被真正清理。
下一节处理这条连接的另一半问题:它会断、会假死、会在多实例部署下需要把一条消息发给所有人。心跳、重连与广播是长连接上线前必须补齐的三块拼图。
阅读导航:上一节:10.1 WebSocket 消息协议判别联合 · 下一节:10.3 心跳、重连与广播 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。