Server Sent Events(SSE)是一种基于 HTTP 的单向通信机制(服务端→客户端),很适合需要服务器单向实时推送的场景,相比 WebSocket 的双向推送,它更轻量。典型应用案例有:
- 物流跟踪:实时显示包裹位置信息
- 价格看板:每秒推送 XX 的最新价格
- AI 应用中的内容流式输出,这大概是 SSE 被大多数开发者熟知的原因
比如 deepseek 的聊天流式输出用的就是 SSE
核心特征
SSE 是基于 HTTP 的,并不是新的技术,只是在普通的 HTTP 请求加了相关规范。规范如下:
- Content-Type 值为
text/event-stream
- 长连接,基于HTTP的持久化连接特性,Connection值为
keep-alive
- 数据格式规范,服务器必须按照规范发送数据(
data:
、id:
、retry:
字段),不按照规范服务端不会出错,但是客户端实现方案都是按照规范解析数据的。 - 支持自动重连,客户端解析数据中的
retry
字段来实现
在控制台中可以看到数据是一点一点传输来的
也可以通过 EventStream 面板查看可视化的数据
概念的东西总是晦涩难懂,下面通过代码示例来讲解。
服务端和客户端分别讲两种实现方式:
- 服务端:Express 和 Nest
- 客户端:原生 EventSource 和 @microsoft/fetch-event-source
服务端 基于 Express 实现
jsx
import express from "express";
const app = express();
app.get("/sse", (req, res) => {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
const sendData = () => {
const data = JSON.stringify({ time: new Date().toISOString() });
res.write(`data: ${data}\n\n`); // 注意格式要求
};
const timer = setInterval(sendData, 1000);
req.on("close", () => {
clearInterval(timer);
res.end();
});
});
app.listen(3000, () => console.log("Server running on port 3000"));
注意响应头设置,可以对照上文核心特征看。
HTTP/1.1 是默认开启持久连接的,一般不需要设置,手动设置一般是为了处理兼容性问题,比如客户端 HTTP 版本过低。
再次可见 SSE 并不是新的技术,只是在普通的 HTTP 连接上加了相关规范。
客户端
客户端实现的重点是:
- 流式处理数据
- 解析规范的数据格式
EventSource
jsx
const es = new EventSource('http://localhost:3000/sse');
es.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
console.log('Received:', data);
});
es.addEventListener('error', (err) => {
console.error('Error:', err);
})
EventSource已经内置了重连逻辑,不需要处理retry数据。
执行new EventSource()
就会发送一个 GET 请求创建连接,具体用法可以见文档,这个 API 并不复杂,所以它的局限性也有很多:
- 不能自定义请求头
- 只支持 GET 连接
- 自动重连逻辑不可以定制
替代方案有基于fetch封装的 @microsoft/fetch-event-source
fetch-event-source
jsx
import { fetchEventSource } from '@microsoft/fetch-event-source';
fetchEventSource('http://localhost:3000/sse', {
onopen(res){},
onmessage(event) {
console.log('Event:', event.event, 'Data:', event.data);
},
onclose() {
console.log('Connection closed');
},
onerror(err) {
console.error('Fatal error:', err);
}
})
用法很简单,第二个参数基于 fetch 原生参数扩展了4 种事件回调:
- onopen
- onmessage
- onclose
- onerror
所以用法和 fetch 一样,设置相应回调接收数据即可,几乎所有场景都可以定制化,相比 EventSource 功能丰富的多。
源码分析
接下来看一下部分源码,看它怎么接收并解析数据。
重点就是getBytes
和 getLines
、getMessages
第一步:读取数据
jsx
export async function getBytes(stream: ReadableStream<Uint8Array>, onChunk: (arr: Uint8Array) => void) {
const reader = stream.getReader();
let result;
while (!(result = await reader.read()).done) {
onChunk(result.value);
}
}
getBytes 的逻辑是读取响应数据,传给内部 onChunk 处理(也就是 getLines 返回的函数)。
传入的 stream 是 fetch 返回的 response.body,我们知道 response.body 就是一个可读流,可以看一下 TS 的类型定义。
所以就可以按照读取可读流内容的方式来做,这就解答了如何读取服务端响应的数据。
第二步第 1 部分:将数据拆分为 line
首先是把流式数据分成 field: value 这样的 line,这也是 SSE 规范的数据格式。
具体做法就是以 \n 或 \r 为单位拆分,然后以冒号为分界解析出每行的 field 和 value,然后传给内部 onLine (也就是 getMessage 返回的函数)。
代码就是字符串的处理,逻辑较细但不难,有兴趣可以看下代码,我加了注释
jsx
/**
* 将任意chunk解析成EventSource line buffers.
* 每行应该是"field: value"格式,并以\r、\n或\r\n结尾
*/
function getLines(
onLine: (line: Uint8Array, fieldLength: number) => void
) {
// 存储正在处理的数据缓冲区
let buffer: Uint8Array | undefined;
// 当前读取位置的指针
let position: number;
// 当前行中field部分的长度(冒号前的长度)
let fieldLength: number;
// 标记是否需要丢弃尾随的换行符
let discardTrailingNewline = false;
return function onChunk(arr: Uint8Array) {
if (buffer === undefined) {
// 如果缓冲区为空,直接使用新的数组
buffer = arr;
position = 0;
fieldLength = -1;
} else {
// 如果还在解析之前的chunk,将新数据加到现有buffer中
buffer = concat(buffer, arr);
}
const bufLength = buffer.length;
// 当前正在处理的行的起始位置
let lineStart = 0;
// 持续处理缓冲区中的数据,直到处理完所有完整的行
while (position < bufLength) {
if (discardTrailingNewline) {
// 如果上一个字符是\r,检查是否需要跳过后续的\n
if (buffer[position] === ControlChars.NewLine) {
lineStart = ++position;
}
discardTrailingNewline = false;
}
// 向前查找直到找到行尾
let lineEnd = -1; // \r 或 \n 字符的位置
for (; position < bufLength && lineEnd === -1; ++position) {
switch (buffer[position]) {
case ControlChars.Colon:
// 记录每行第一个冒号的位置,用于分隔field和value
if (fieldLength === -1) {
fieldLength = position - lineStart;
}
break;
case ControlChars.CarriageReturn:
// 如果遇到\r,标记需要检查后续的\n
discardTrailingNewline = true;
lineEnd = position;
break;
case ControlChars.NewLine:
// 找到行尾
lineEnd = position;
break;
}
}
if (lineEnd === -1) {
// 已到达buffer末尾但行未结束
// 等待下一个数据块继续解析
break;
}
// 找到行尾,调用回调函数处理该行
onLine(buffer.subarray(lineStart, lineEnd), fieldLength);
lineStart = position;
fieldLength = -1;
}
if (lineStart === bufLength) {
// 所有数据都已处理完毕,清空buffer
buffer = undefined;
} else if (lineStart !== 0) {
// 创建一个从lineStart开始的新视图,避免在获取新数据时复制之前已处理的行
buffer = buffer.subarray(lineStart);
position -= lineStart;
}
};
第二步第 2 部分:以 line 为单位创建message
getLines 拆分出的数据还是Unit8Array
格式,在这一步要通过TextDecoder
解码数据,然后拼凑成message,然后再调用 onmessage,message 的格式如下:
jsx
{
data: "",
event: "",
id: "",
retry: "",
}
有没有熟悉的感觉,data、id、retry都是 SSE 规范的数据格式
jsx
function newMessage(): EventSourceMessage {
return {
data: "",
event: "",
id: "",
retry: undefined,
};
}
function getMessages(
onId: (id: string) => void,
onRetry: (retry: number) => void,
onMessage?: (msg: EventSourceMessage) => void
) {
let message = newMessage();
const decoder = new TextDecoder();
return function onLine(line: Uint8Array, fieldLength: number) {
if (line.length === 0) {
onMessage?.(message);
message = newMessage();
} else if (fieldLength > 0) {
const field = decoder.decode(line.subarray(0, fieldLength));
const valueOffset =
fieldLength + (line[fieldLength + 1] === ControlChars.Space ? 2 : 1);
const value = decoder.decode(line.subarray(valueOffset));
switch (field) {
case "data":
message.data = message.data ? message.data + "\n" + value : value; // otherwise,
break;
case "event":
message.event = value;
break;
case "id":
onId((message.id = value));
break;
case "retry":
const retry = parseInt(value, 10);
if (!isNaN(retry)) {
onRetry((message.retry = retry));
}
break;
}
}
};
}
重试机制
fetch-event-source 没有提供 onretry 事件,重试机制是内部已经做了的。
当请求出错,并且不是通过 signal 取消请求时,会重试,默认重试时间的设置有三种,优先级递减:
- onerror 返回的数字
- 服务端传的 retry 字段
- 默认 1 秒
也提供了取消重试的方法,当用户传入的 onerror 报错时,不再重试。
这个方法还挺妙的,一般我们会通过在 onerror 里手动 throw Error 来取消重试。
服务端 基于 Nest 实现
Nest 提供了 Sse 装饰器,所以实现起来非常简单
返回数据要求是 rxjs 的 Observable
格式,Nest 内部集成了 rxjs,大量逻辑都是基于此实现的。
源码分析
这部分需要有具备一些前置知识:
- node 的 Stream API
- Nest 的基本实现原理
首先 Sse 装饰器会给 Controller 中的处理方法加上 SSE_METADATA
元数据。后续处理会根据此走 SSE 相关逻辑。
重点就在 responseController.sse
中,具体步骤如下:
- 检查响应值是否是 rxjs 的
Observable
格式 - 创建
SseStream
并 pipe 到 response 上,这是一个Transform
流,这里是重点 ,做了以下两件事:- 设置响应头(Content-Type等)
- 将响应数据转为 SSE 规范的字符串
- 用 rxjs 的 API 订阅响应数据(Observable对象),每次接收到数据都 pipe 到 SseStream
就是这个流程,通过流程图来看:
来看代码,以下代码在源码基础上做了简化:
jsx
public sse(result, response, request) {
// 确保result是一个Observable
if (!isObservable(result)) {
throw new ReferenceError(
'You must return an Observable stream to use Server-Sent Events (SSE).',
);
}
// 创建SSE流并将其pipe到响应对象
const stream = new SseStream(request);
stream.pipe(response);
// 订阅Observable,处理消息流
const subscription = result
.pipe(
// 将消息转换为标准的MessageEvent格式
map((message): MessageEvent => {
if (isObject(message)) {
return message as MessageEvent;
}
return { data: message as object | string };
}),
concatMap(message => {
return new Promise<void>(resolve =>
stream.writeMessage(message, () => resolve()),
);
}),
)
.subscribe({
// 当Observable完成时结束响应
complete: () => {
response.end();
},
});
// 监听请求关闭事件,清理资源
request.on('close', () => {
subscription.unsubscribe();
if (!stream.writableEnded) {
stream.end();
}
});
}
这部分代码是比较易读的,rxjs 订阅的数据通过 writeMessage 写给 SseStream。
jsx
class SseStream extends Transform {
private lastEventId: number = null;
constructor(req?: IncomingMessage) {
super({ objectMode: true });
// 处理req相关逻辑
}
pipe(destination) {
if (destination.writeHead) {
destination.writeHead(200, {
'Content-Type': 'text/event-stream',
Connection: 'keep-alive',
'Cache-Control':
'private, no-cache, no-store, must-revalidate, max-age=0, no-transform',
Pragma: 'no-cache',
Expire: '0',
'X-Accel-Buffering': 'no',
});
destination.flushHeaders();
}
destination.write('\n');
return super.pipe(destination);
}
/**
* Transform流的核心转换方法
* 将消息对象转换为SSE格式的字符串
*/
_transform(
message: MessageEvent,
encoding: string,
callback: (error?: Error | null, data?: any) => void,
) {
let data = message.type ? `event: ${message.type}\n` : '';
data += message.id ? `id: ${message.id}\n` : '';
data += message.retry ? `retry: ${message.retry}\n` : '';
data += message.data ? toDataString(message.data) : '';
data += '\n';
this.push(data);
callback();
}
writeMessage(
message: MessageEvent,
cb: (error: Error | null | undefined) => void,
) {
// 如果消息没有ID,则自动生成
if (!message.id) {
this.lastEventId++;
message.id = this.lastEventId.toString();
}
this.write(message, 'utf-8');
}
}
SseStream 重写了 pipe 方法,我们在上面看到 SseStream 是 pipe 给 response 的,所以这里的逻辑就是给 response 加上响应头,再调原生的 pipe。
SseStream 核心逻辑在 _transform
方法中,按照 SSE 规范生成字符串。
结束
服务端和客户端都提供了两种实现示例,也做了源码解读,可以很清晰的看到 SSE 的原理,通篇看下来其实能感觉到,SSE 就是一种规范。