Node HTTP 服务与 SSE 流式
专栏:TypeScript 与 Node 地基 · 第 9 / 13 篇:::info 学习目标 完成本篇后你能够:用原生 Node 写出路由式 HTTP 服务;实现一个 SSE 流式端点并解释它的报文格式;说明为什么网关不能对响应做 gzip。 前置:第 3~4 篇完成。预计时长:75 分钟。 :::
框架(Express/gin)只是把原生 API 包了一层。这一篇直接用 Node 原生 http 模块写服务——之后再用任何框架,你都知道它在替你做什么。
最小 HTTP 服务
import { createServer } from 'node:http'
const server = createServer((req, res) => {
if (req.method === 'GET' && req.url === '/health') {
res.writeHead(200, { 'content-type': 'application/json' })
return res.end(JSON.stringify({ ok: true }))
}
res.writeHead(404)
res.end('not found')
})
server.listen(3000, () => console.log('listening on :3000'))
检查点:curl localhost:3000/health 返回 JSON;curl -i 能看到响应头。请求对象 req 是可读流,响应对象 res 是可写流——这个”流”的身份马上就是主角。
SSE 与普通响应的区别
SSE:AI 回复打字机效果的协议
SSE:AI 回复打字机效果的协议
SSE(Server-Sent Events)就是一个保持打开的 HTTP 响应,按固定格式持续写文本:
HTTP/1.1 200 OK
content-type: text/event-stream
cache-control: no-cache
data: {"text": "你"}
data: {"text": "好"}
data: [DONE]
格式规则:每条消息以 data: 开头、空行结尾;客户端用 EventSource(或手写解析)接收。OpenAI 风格的流式响应正是这个格式,最后一条 data: [DONE] 表示结束。
服务端实现(模拟一个逐字输出的 LLM):
server.on('request', (req, res) => {
if (req.url !== '/v1/chat') return
res.writeHead(200, {
'content-type': 'text/event-stream', // 声明这是 SSE
'cache-control': 'no-cache', // 不许中间层缓存
connection: 'keep-alive',
})
const words = ['你', '好', ',', '世', '界']
let i = 0
const timer = setInterval(() => {
if (i >= words.length) {
res.write('data: [DONE]\n\n')
clearInterval(timer)
return res.end()
}
res.write(`data: ${JSON.stringify({ text: words[i++] })}\n\n`)
}, 150)
// 客户端断开必须清理!否则定时器与连接泄漏
req.on('close', () => clearInterval(timer))
})
检查点:curl -N localhost:3000/v1/chat(-N 禁用 curl 自己的缓冲)应看到逐字到达。客户端侧用 fetch + ReadableStream 逐行解析 data: 即可(第 11 篇的 mini-agent 会真的解析)。
三个生产级细节
- 绝不能对 SSE 响应做 gzip——压缩器要攒满缓冲区才输出,流式变成”卡住”。new-api 源码里那行警告注释(第 2 篇)说的就是这件事;
- 客户端断开要感知——
req.on('close')是清理定时器/上游连接的信号,否则泄漏; - 代理链路要关缓冲——nginx/caddy 转发 SSE 需要
proxy_buffering off之类的配置,否则”打字机”变成”一次性到货”。
与其他主题的联系
- 第 3 篇的 await/异步是流式的语言基础;
for await (const chunk of stream)消费异步迭代器(dsh 的llm.stream()就是 AsyncIterable); - dsh 的 StreamScannerHandler(第 6 篇 dsh 专栏)就是生产级的 SSE 解析器——多了超时、心跳与协议转换;
- new-api 的 relay 层(new-api 专栏第 6 篇)做的是同一件事:上游 SSE 进来、原样或转换后流出。
常见踩坑
- 忘了
content-type: text/event-stream——客户端按普通响应处理,“永远等不到事件”; - write 后不 flush——Node 默认不缓冲,但中间件/代理可能缓冲;
- 断开不清理——定时器与上游请求继续跑,内存与费用双重泄漏;
- 响应头写晚了——
writeHead必须在第一次write之前调用。
随堂练习(带验收标准)
- 跑通 SSE 端点,用
curl -N观察逐字到达; - 写一个浏览器页面(
new EventSource('/v1/chat'))实时渲染。验收:断网/刷新后服务端日志显示 close 被触发; - 进阶:把端点改成”每 200ms 从一个数组里发一条 OpenAI 格式的 chunk,最后发
[DONE]”——这就是第 11 篇 mini-agent 要消费的模拟上游。