《TypeScript编程实战》10.2 SSE 与流式响应

当服务端只需单向推送时,WebSocket 是过重的选择。本节讲透 SSE 的文本帧格式、id 与 retry 字段的作用、服务端如何用 ReadableStream 输出事件流、客户端 EventSource 的自动重连为何常常失效,以及需要 POST 与自定义请求头时如何改用 fetch 读取。还给出事件契约的类型化写法与流式 JSON 增量解析,并指出代理缓冲与超时这几个必踩的坑。

本节目标:掌握 SSE(Server-Sent Events)的完整链路。你会知道它与 WebSocket 各自的适用边界、事件流的文本格式如何影响客户端解析、EventSource 的自动重连什么时候帮倒忙,以及当需要 POST 或自定义请求头时怎么用 fetch + ReadableStream 接管整个流。

10.2 SSE 与流式响应

上一节的消息协议是双向的:客户端发订阅指令,服务端推数据。但很多场景其实只有一半:行情报价、日志尾随、构建进度、大模型逐字输出——客户端只负责看。这时候 WebSocket 的双向能力用不上,反而带来握手升级、代理穿透、心跳保活一整串额外工作。

10.2.1 先选型:什么时候不该用 WebSocket

维度WebSocketSSE
方向双向仅服务端 → 客户端
传输独立的 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 心跳、重连与广播 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes