从 server.ts 的 pixel-art 端点出发
blog 项目的 /pixel-art/paint 端点用 SSE 把 LLM 画像素画的每一步实时推送到浏览器。用户上传图片,服务器调用 LLM,LLM 每画一笔,服务器就推一个事件给前端。
普通 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 协议解析响应。
服务器往流里写的数据长这样:
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}
规则很简单:
data: 开头\n\n(空行)结尾: 开头的行是注释,客户端忽略你的代码用 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 只支持 GET 请求。你的像素画功能需要 POST 发送图片数据,所以只能用 fetch 手动解析 SSE 流。
: hb 的作用是什么?📚 延伸阅读:HTML Living Standard: Server-sent events(官方规范)
💬 有疑问?问我就好。