Node HTTP 服务与 SSE 流式

专栏:TypeScript 与 Node 地基 · 第 9 / 13 篇
NodeHTTPSSE

:::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 与普通响应的区别

图表(ts-http-sse.md)

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 会真的解析)。

三个生产级细节

  1. 绝不能对 SSE 响应做 gzip——压缩器要攒满缓冲区才输出,流式变成”卡住”。new-api 源码里那行警告注释(第 2 篇)说的就是这件事;
  2. 客户端断开要感知——req.on('close') 是清理定时器/上游连接的信号,否则泄漏;
  3. 代理链路要关缓冲——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 进来、原样或转换后流出。

常见踩坑

  1. 忘了 content-type: text/event-stream——客户端按普通响应处理,“永远等不到事件”;
  2. write 后不 flush——Node 默认不缓冲,但中间件/代理可能缓冲;
  3. 断开不清理——定时器与上游请求继续跑,内存与费用双重泄漏;
  4. 响应头写晚了——writeHead 必须在第一次 write 之前调用。

随堂练习(带验收标准)

  1. 跑通 SSE 端点,用 curl -N 观察逐字到达;
  2. 写一个浏览器页面(new EventSource('/v1/chat'))实时渲染。验收:断网/刷新后服务端日志显示 close 被触发;
  3. 进阶:把端点改成”每 200ms 从一个数组里发一条 OpenAI 格式的 chunk,最后发 [DONE]”——这就是第 11 篇 mini-agent 要消费的模拟上游。

← 返回文章列表