HTTP 升级 SSE(Server-Sent Events)
本文档包含:如何把普通 HTTP 请求变为 SSE、服务端/客户端示例、fetch 流式解析、代理配置与常见问题,可直接在项目中按需使用。
SSE 不需要像 WebSocket 一样“升级”,它本质是一个保持的普通 HTTP 响应(HTTP/1.1 长连接)。只要把响应头设为
text/event-stream
,并按 SSE 文本协议持续写入数据即可。
核心要点
- 不是升级协议,无需
Upgrade
握手。 - 关键响应头:
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache, no-transform
Connection: keep-alive
Access-Control-Allow-Origin: *
(跨域需要时)
- 消息格式(以空行
\n\n
作为分隔):- 基本:
data: <payload>\n\n
- 可选:
event: <name>\n
id: <id>\n
retry: <ms>\n
- 基本:
- 建议定期发送注释心跳:
: ping\n\n
,防止中间层超时断开。 - 客户端可用
EventSource
,或fetch + ReadableStream
自行解析。
服务端(Node/Express)示例:带心跳与可控结束
- import express from 'express';
- const app = express();
- app.get('/sse', (req, res) => {
- res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
- res.setHeader('Cache-Control', 'no-cache, no-transform');
- res.setHeader('Connection', 'keep-alive');
- // 设置 CORS 相关头
- res.setHeader('Access-Control-Allow-Origin', '*');
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
- )
- // 若使用了压缩中间件(compression),需对该路由禁用压缩
- // 设置状态码
- res.status(200);
- // 立即发送响应头,这个很重要,否则客户端可能会一直重试
- res.flushHeaders?.();
- // 心跳,防止链路被中间层回收
- const heartbeat = setInterval(() => {
- res.write(`: ping\n\n`);
- }, 15000);
- let id = 1;
- const timer = setInterval(() => {
- res.write(`id: ${id}\n`);
- res.write(`data: ${JSON.stringify({ time: Date.now() })}\n\n`);
- id++;
- // 示例:发到第 5 条后主动结束
- if (id > 5) endSSE('done');
- }, 3000);
- let closed = false;
- function endSSE(reason?: string) {
- if (closed) return;
- closed = true;
- clearInterval(timer);
- clearInterval(heartbeat);
- // 可选:先通知客户端“结束事件”
- res.write(`event: end\n`);
- res.write(`data: ${JSON.stringify({ reason: reason ?? 'normal' })}\n\n`);
- // 主动关闭响应 => SSE 连接关闭
- res.end();
- }
- // 客户端断开时清理
- req.on('close', () => {
- if (!closed) {
- clearInterval(timer);
- clearInterval(heartbeat);
- res.end();
- closed = true;
- }
- });
- });
- app.listen(3000, () => console.log('SSE server at http://localhost:3000/sse'));
JavaScript复制
要点回顾:
- 主动结束只需
res.end()
;可在此之前发送一个自定义event: end
方便客户端收尾。 - 心跳使用以冒号开头的注释行(不会被客户端
message
监听触发)。
客户端:EventSource 示例
使用场景: 页面上的表格数据需要服务端试试推送,直到用户离开页面。
- const es = new EventSource('http://localhost:3000/sse');
- es.addEventListener('end', (e) => {
- console.log('server end', e.data);
- // 收到结束事件后,可主动关闭本地连接(可选)
- es.close();
- });
- es.onmessage = (e) => {
- console.log('message', e.lastEventId, e.data);
- };
- es.onerror = (e) => {
- console.warn('sse error', e);
- // EventSource 会自动重连,间隔受服务端 `retry:` 影响
- };
JavaScript复制
客户端:fetch + ReadableStream 示例
适用于需要自定义请求头/鉴权或手动解析的场景,如 AI chat 通常需要通过 POST 的方式发送请求体,然后内容全部返回后结束当前 SSE 推送,等待用户发起新的 chat 请求。
- const res = await fetch('http://localhost:3000/sse', {
- headers: { Accept: 'text/event-stream' },
- // 若需要取消,可传入 signal: abortController.signal
- });
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const reader = res.body!.getReader();
- const decoder = new TextDecoder();
- let buffer = '';
- try {
- while (true) {
- const { value, done } = await reader.read();
- if (done) {
- console.log('stream closed');
- break;
- }
- buffer += decoder.decode(value, { stream: true });
- // 以 \n\n 分帧,解析行:event:/data:/id:/retry:
- let idx;
- while ((idx = buffer.indexOf('\n\n')) >= 0) {
- const raw = buffer.slice(0, idx);
- buffer = buffer.slice(idx + 2);
- const lines = raw.split('\n');
- let event = 'message';
- let data: string[] = [];
- let id: string | undefined;
- for (const line of lines) {
- if (!line || line.startsWith(':')) continue; // 注释/空行
- const [k, v = ''] = line.split(':', 2);
- const val = v.startsWith(' ') ? v.slice(1) : v;
- if (k === 'event') event = val;
- else if (k === 'data') data.push(val);
- else if (k === 'id') id = val;
- }
- const payload = data.join('\n');
- // 自行分发处理
- console.log('evt=', event, 'id=', id, 'data=', payload);
- }
- }
- } catch (err) {
- // AbortError/网络错误等会在这里抛出
- console.error('stream error', err);
- } finally {
- reader.releaseLock();
- }
JavaScript复制
何时 reader.read()
的 done
为 true
?
- 正常读完响应体:
Content-Length
指定的字节读尽;或chunked
传输收到终止块;或- 服务端/代理主动结束(调用
res.end()
或关闭响应)。 - 响应体为空:第一次
read()
就会{ done: true }
。
- 你主动关闭:
- 调用
reader.cancel()
或response.body.cancel()
后,后续read()
会返回done: true
(或当前 read Promise 直接 reject)。
- 调用
- 不会返回
done: true
的情况(而是抛错):AbortController
中止:read()
抛DOMException: AbortError
。- 网络/代理异常:
read()
通常抛TypeError
。
- 与 SSE:
- 只要链路不断开,
done
会一直为false
; - 服务端
res.end()
、代理超时回收、后端进程退出时,下一次read()
才会done: true
。
- 只要链路不断开,
反向代理与中间层配置(以 Nginx 为例)
- 关闭缓冲与延迟:
proxy_buffering off;
proxy_cache off;
- 延长超时:
proxy_read_timeout 1h;
keepalive_timeout 1h;
- 禁用压缩(避免分块被聚合/缓冲):
gzip off;
(或对该路径禁用)
- 定期心跳:服务端发送
: ping\n\n
注释行保持活跃。
常见问题排查
- 看不到任何消息:
- 是否为
text/event-stream
?是否每条消息以\n\n
结束? - 代理是否启用缓冲/压缩?(关闭)
- 是否为
- EventSource 自动重连太频繁:
- 服务端发送
retry: <ms>
指定重连间隔。
- 服务端发送
- CORS 报错:
- 设置
Access-Control-Allow-Origin
和必要的 CORS 头。
- 设置
- 与框架中间件冲突:
- 对 SSE 路由禁用 body parser、压缩和缓存相关中间件。
Checklist
- [ ] 路由响应头:
text/event-stream
、no-cache
、keep-alive
、CORS - [ ] 消息以
\n\n
结尾;必要时event:
、id:
、retry:
- [ ] 定时
: ping
心跳,如果服务端没有需要长时间执行的任务,可以不设置 - [ ] 代理关闭缓冲、延长超时、禁用压缩
- [ ] 需要时可发送
event: end
,随后res.end()
主动关闭