第一课:读懂你项目里的 SSE

从 server.ts 的 pixel-art 端点出发

你的项目在做什么

blog 项目的 /pixel-art/paint 端点用 SSE 把 LLM 画像素画的每一步实时推送到浏览器。用户上传图片,服务器调用 LLM,LLM 每画一笔,服务器就推一个事件给前端。

SSE 的本质:一个不关闭的 HTTP 响应

普通 HTTP 请求:客户端问 → 服务器答 → 连接关闭。

SSE:客户端问 → 服务器答 但不关闭连接 → 有新数据就继续推。

你的服务器代码就是这样做的:

// server.ts 第 1202 行
return new Response(stream, {
  headers: {
    "Content-Type": "text/event-stream",  // ← 告诉浏览器:这是 SSE 流
    "Cache-Control": "no-cache",          // ← 不要缓存
    "Connection": "keep-alive",           // ← 保持连接
    "X-Accel-Buffering": "no",           // ← 禁用代理缓冲(Caddy/Nginx)
  },
});
关键点:text/event-stream 是 SSE 的 MIME 类型。浏览器看到这个就知道要按 SSE 协议解析响应。

数据格式:SSE 事件帧

服务器往流里写的数据长这样:

data: {"type":"connected"}

data: {"type":"step","step":1}
data: {"type":"paint","x":3,"y":5,"color":"#ff0000"}

: hb                          ← 冒号开头是注释,客户端忽略

data: {"type":"step","step":2}
data: {"type":"done","step":2}

规则很简单:

服务器怎么写的

你的代码用 ReadableStream 构造一个流,通过 controller.enqueue() 往里塞数据:

const encoder = new TextEncoder();
const stream = new ReadableStream({
  async start(controller) {
    const send = (data) => {
      // data: {JSON}\n\n  ← 这就是 SSE 事件帧
      controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
    };

    send({ type: "connected" });     // 推第一条消息
    // ... LLM 每画一步 ...
    send({ type: "paint", x, y, color });
    send({ type: "done", step });
    controller.close();              // 关闭流
  },
});

心跳机制

LLM 调用可能很慢,中间没数据推送时,代理服务器(Caddy)可能因为空闲断开连接。你的代码用注释行做心跳:

const heartbeat = () => {
  controller.enqueue(encoder.encode(`: hb\n\n`));  // 注释行,客户端忽略
};
hbTimer = setInterval(heartbeat, 3000);  // 每 3 秒发一次
为什么用注释?因为 EventSource API 会忽略 : 开头的行。发心跳不会触发任何事件回调,但能保持连接活跃。

客户端怎么读的

你的前端没有用 EventSource API,而是用 fetch + ReadableStream.getReader() 手动解析:

const res = await fetch('/pixel-art/paint', {
  method: 'POST',
  body: JSON.stringify({ image: imageBase64 }),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });

  // 按 \n\n 分割事件帧
  let sep;
  while ((sep = buffer.indexOf('\n\n')) !== -1) {
    const frame = buffer.slice(0, sep);
    buffer = buffer.slice(sep + 2);

    // 取 data: 开头的行,忽略 : 开头的注释
    for (const line of frame.split('\n')) {
      if (line.startsWith(':')) continue;     // 跳过心跳
      if (line.startsWith('data:')) {
        const evt = JSON.parse(line.slice(5));
        handleEvent(evt);                      // 处理事件
      }
    }
  }
}
为什么不用 EventSource?因为 EventSource 只支持 GET 请求。你的像素画功能需要 POST 发送图片数据,所以只能用 fetch 手动解析 SSE 流。

小测验

1. SSE 事件帧中,什么表示"这条消息结束了"?

2. 你的项目为什么用 fetch 而不是 EventSource?

3. 心跳注释行 : hb 的作用是什么?


📚 延伸阅读:HTML Living Standard: Server-sent events(官方规范)
💬 有疑问?问我就好。