实时通讯 · 专项课 SSE 技术 从使用到原理

一节课摸透 SSE:从基本使用到踩坑与最佳实践

SSE 是什么 → 服务端四步实现 → 前端 EventSource → 两个大坑 → 最佳实践 → token 鉴权三方案 → 手写自定义客户端 → 第三方库选型 → 本质总结

本文依据 B 站视频《websocket 退位?sse 登场?》整理 | BV18Q4mzTEMq | 视频时长 36:50 | 代码按视频讲解思路整理

0课程地图

模块内容学习产出
概念SSE = 单向 WebSocket;基于 HTTP,资源占用小、开发简单知道什么时候该选 SSE
服务端Express 四步:HTTP 服务 / GET 接口 / 响应头 / write 推送能自己写一个 SSE 接口
前端new EventSource + onmessage / onopen / onclose三行代码接流
踩坑data: 开头格式;连接别乱断(自动重连机制)排掉最常卡住的问题
最佳实践event 协议多事件复用接口;组件卸载时 close把接口用到极致
鉴权query / cookie / 自定义请求头三方案生产环境能带 token
进阶手写客户端(fetch + 可读流 + TextDecoder);两大第三方库选型理解 SSE 本质、生产环境选对库
本课一句话目标:学完之后,你能像一个 SSE 老手一样——知道它是什么、怎么用、坑在哪、 生产环境怎么带 token,甚至能自己手写一个 SSE 客户端。

1SSE 是什么:一个"单向的 WebSocket"

最近前端群里很多同学在问 SSE,所以出了这节课。SSE 的中文全称是 Server-Sent Events(服务器推送事件)。

最简单的理解:SSE 就是一个"单向的 WebSocket"——它可以让服务端主动给前端发消息,不需要前端先发请求。

我们平时写请求是"前端请求服务端,服务端再回消息";而 SSE 反过来:服务端可以主动推给前端,前端不需要先发请求。区别就在于它只能服务端 → 前端,是单向的。

SSEWebSocket
方向单向(服务端 → 前端)双向(双方实时互发)
协议HTTP / HTTPS 系列(和普通请求一样)专门的 WS 协议
服务器资源远小于 WebSocket需维护 WS 连接池,开销大
服务端开发复杂度远低于 WebSocket需管理连接池、断开、重连

2为什么用 SSE:三个特点 + 适用场景

2.1 三个特点

2.2 什么时候适合用 SSE

很简单:你需要实时通讯,但这个实时通讯只需要后端给前端发消息,不需要前端主动实时发消息——SSE 就是很好的选择。典型场景:

📌 判断口诀:只要"服务端推、前端收",就用 SSE;需要"双方互发",才上 WebSocket。

3服务端开发:Node + Express 四步实现

用 Node + Express 开发一个 SSE 服务端,非常简单——前两步和普通接口一模一样,后两步是关键:

  1. 开启一个 HTTP 服务;
  2. 定义一个 GET 接口(一定要 GET,不要 POST);
  3. 设置响应头:Content-Type 为 text/event-stream(文本事件流),编码为 UTF-8——不设置编码会中文乱码,这一步非常重要;
  4. 往响应里 write 数据:每次写入就会发给前端,直到调用 end 结束响应断开连接。
// server.js —— SSE 服务端完整示例
const express = require('express')
const cors = require('cors')        // 解决跨域问题

const app = express()               // 创建 Express 实例
app.use(cors())                     // 使用 CORS 解决跨域

// GET 接口(一定要是 GET,不要 POST)
app.get('/api/sse', (req, res) => {
  // ① 设置响应头:文本事件流 + UTF-8 + 长连接
  res.writeHead(200, {
    'Content-Type': 'text/event-stream; charset=UTF-8',   // 关键:事件流 + 编码
    'Connection': 'keep-alive'                             // 默认就是 keep-alive,稳妥起见显式设置
  })

  // ② 通过 write 给前端发消息(每隔 1 秒发一个字)
  const words = ['你', '好', 'S', 'S', 'E', '!']
  let count = 0

  const timer = setInterval(() => {
    if (count < words.length) {
      res.write(`data: ${words[count]}\n\n`)   // 每次 write 前端就收到一个字
      count++
    } else {
      clearInterval(timer)                     // 发完了清除定时器,但连接保持
    }
  }, 1000)

  // 前端断开连接时
  req.on('close', () => {
    console.log('客户端断开连接')
    clearInterval(timer)
  })
})

app.listen(3000, () => console.log('SSE server running at :3000'))
✅ 关于断开:通过 res.end() 结束响应来断开,前端会自动重连。很多情况下我们根本不用 end 断开——断了前端会重连,而且我们希望连接长期保持方便随时发消息。

4前端使用:new EventSource 三步接流

前端使用更简单——EventSource 是现代浏览器自带的对象,window 里直接就有:

// index.html —— 前端三步接流
<script>
  // 第一步:创建 EventSource 对象,把接口地址给它,就连上后端了
  const eventSource = new EventSource('http://localhost:3000/api/sse')

  // 第二步:监听 onmessage —— 后端每发一个字过来就触发(核心)
  eventSource.onmessage = function (e) {
    console.log('收到:', e.data)     // e.data 就是后端发来的内容
  }

  // 其它事件:
  // eventSource.onopen   —— 建立连接的那一刻触发,只会触发一次
  // eventSource.onerror  —— 连接出错时触发(连接关闭时也会触发)
</script>

核心是 onmessage:后端每 write 一次,前端就触发一次,e.data 就是收到的内容。把每次收到的字拼起来,就是流式效果:

// 拼接实现流式打字效果
let message = ''
eventSource.onmessage = function (e) {
  message += e.data
  document.getElementById('output').textContent = message
}
截图位置:页面上一字一字蹦出"你好SSE!"的流式输出效果(和问 AI 很像)

5踩坑一:原生必须 data: 开头 + 空行结尾

老师先演示了一个"坑":服务器明明在逐步输出字符串,F12 网络里响应也能看到内容在出来,但前端的 onmessage 就是不触发,控制台什么都不打印。

⚠️ 原因:原生 EventSource 必须严格按照格式写入——必须是 data: 冒号开头,再是内容,再以 \n\n(斜杠 n 斜杠 n)结尾。只有这种格式才会触发 onmessage。
// 错误写法:onmessage 不会触发
res.write('你好')          // ❌ 没有 data: 开头,也没有 \n\n 结尾

// 正确写法:onmessage 才会触发
res.write('data: 你好\n\n')   // ✅ data: 开头 + \n\n 结尾

所以当你发现:调用了后端的 SSE 接口,F12 网络里能看到响应在逐步输出,但 onmessage 就是不触发——那就大概率是后端返回的格式不对。可以直接去问后端(很多后端也不懂这个格式)。

截图位置:F12 网络里响应逐步输出但 onmessage 不触发、修正为 data: 格式后正常接收的对比
📌 注意:这是"原生 EventSource"的要求,不是绝对的——你可以自定义协议(后面第 10 节讲)。

6踩坑二:连接别乱断(close / end / 状态码的区别)

用 SSE 一定要记住:服务端不能主动断开连接,前端也不要随便调用 close——一旦主动断开,就会导致无法重连,除非前端把页面关了重新打开,否则永远无法再主动发消息。

// 前端:不要随便调 close
eventSource.close()   // ⚠️ 一旦调用,除非重新创建,否则后端再也发不过来

// 后端:res.end() 断开 → 前端会自动重连
res.end()             // 断开后前端会自动重连,再发一遍

// 后端:通过状态码彻底断开 → 才不会重连(更具体的服务端细节)
res.writeHead(204)    // 204 No Content 等状态码表示"不会再重连"
⚠️ 结论:一般不建议断开连接。如果用普通的 res.end() 断开,前端会不断重连;真正的彻底断开要靠状态码。这两种要分清。
截图位置:服务端 res.end() 后"客户端断开连接"日志与前端自动重连、消息重新输出的效果

7最佳实践一:event 协议,一个接口承载多种消息

一个 SSE 接口实际上可以通过 event 协议承载不同的功能——只用一个接口完成很多事,做到最大程度复用。

前面讲了:data: 开头 + 空行结尾的是普通消息,触发 onmessage。还有一种协议——event: 开头,后面跟事件名:

// server.js —— 用 event 协议发送"自定义事件"
// 普通消息(data 开头)→ 触发 onmessage
res.write('data: 你好\n\n')

// 自定义事件(event 冒号开头 + 事件名)→ 触发 addEventListener('事件名')
res.write('event: newMessage\n')          // 事件名:newMessage(可以自定义)
res.write('data: 你有一条未读消息\n\n')    // 该事件携带的内容

event: 开头写的内容不会触发 onmessage,而是触发前端对应的 addEventListener 监听——监听的名字就是 event 后面跟的事件名:

// 前端:监听自定义事件
eventSource.addEventListener('newMessage', function (e) {
  console.log('收到新消息:', e.data)   // 只有 event: newMessage 的消息走这里
})

// 普通消息仍然走 onmessage
eventSource.onmessage = function (e) {
  console.log('普通消息:', e.data)
}

在 F12 的网络面板里看 EventStream,会发现:普通消息的类型是 message,而这条自定义消息的类型是 newMessage——所以它没有走 onmessage,而是走到了 newMessage 监听。

📌 扩展思路:既然能定义 newMessage,就能定义无数个事件——一个接口完成很多功能,比如订单消息、通知消息、状态变更消息全走这一个 SSE 连接。
截图位置:F12 EventStream 面板中普通消息类型为 message、自定义消息类型为 newMessage 的对比

8最佳实践二:组件卸载时关闭连接

写过 Vue 的同学都知道:定时器、全局监听在组件卸载(onUnmounted)时要去掉。SSE 连接也一样——退出页面、进入另一个页面时,要记得关闭连接,否则连接一直挂着:

// Vue 组件:卸载时关闭 SSE 连接
import { onMounted, onUnmounted } from 'vue'

let eventSource = null

onMounted(() => {
  eventSource = new EventSource('http://localhost:3000/api/sse')
  eventSource.onmessage = (e) => { /* 处理消息 */ }
})

onUnmounted(() => {
  eventSource && eventSource.close()   // 记得关掉,避免连接泄漏
})
✅ 非常简单但很重要:用完关闭,和清理定时器是一个道理。

9踩坑三:token 鉴权怎么带(三种方案)

现实工作中接口往往需要 token 鉴权。但原生 EventSource 有一个致命问题:无法修改请求头!而 token 一般就带在请求头上。怎么办?三种方案:

方案一:token 带在请求地址上(不安全)

// 把 token 拼到地址上,后端通过 query 解析
const eventSource = new EventSource(
  `http://localhost:3000/api/sse?token=${token}`
)
⚠️ 不安全:别人看到你的请求地址就知道你的 token 了。原生 EventSource 里你只能这样带 token——但很不安全。

方案二:用 cookie 携带 token(withCredentials)

// 传第二个参数:withCredentials: true,请求就会带上 cookie
const eventSource = new EventSource('http://localhost:3000/api/sse', {
  withCredentials: true   // 是否携带凭证(cookie)
})

把 token 写入 cookie,设置 withCredentials 后,请求头里就会带上 Cookie,后端从 cookie 里取。但注意:这要求后端走 cookie 鉴权;如果后端走的是 JWT(更常见的 token 鉴权),cookie 可能不好读取。

方案三:必须带请求头 → 自定义 SSE 客户端

如果你必须像常规请求一样把 token 放在请求头里,那就要自定义你自己的 SSE 客户端了(见下一节)。另外还有一个兼容性原因:原生 EventSource 完全不支持 IE,为了兼容也常常自定义。

方案做法安全性适用
query 带 tokentoken 拼在地址上❌ 不安全临时演示
cookie 携带withCredentials: true✅ 较安全后端走 cookie 鉴权
请求头携带自定义 SSE 客户端✅ 安全后端走 JWT / 需兼容 IE

10进阶:手写自定义 SSE 客户端

10.1 核心思路:SSE 本质是 HTTP 请求

SSE 本质上就是一个 HTTP 请求,只不过它的 Content-Type 是流。既然是 HTTP 请求,就可以用 fetch——fetch 允许我们设置请求头,token 就能带上了:

// mySSE.js —— 手写自定义 SSE 客户端(可带请求头)
class MySSE {
  constructor(url, options = {}) {
    this.url = url
    this.headers = options.headers || {}
    this.onmessage = () => {}          // 默认空方法,使用者可覆盖
  }

  async init() {
    // ① 用 fetch 请求接口(GET),自定义请求头(可以带 token 了!)
    const res = await fetch(this.url, {
      method: 'GET',
      headers: {
        'Content-Type': 'text/event-stream; charset=UTF-8',
        ...this.headers        // 比如 { token: 'xxx' }
      }
    })

    // ② 拿到可读流
    const reader = res.body.getReader()      // res.body 是 ReadableStream 可读流
    const decoder = new TextDecoder()        // 文本解码器:把字节转为文本

    // ③ 循环读取流
    while (true) {
      const { done, value } = await reader.read()   // done: 是否读完;value: 本次读到的字节
      if (done) break

      const text = decoder.decode(value, { stream: true })   // Uint8Array → 文本
      this._parse(text)
    }
  }

  _parse(text) {
    // ④ 自定义协议:比如规定以 "A:" 开头才解析
    if (text.startsWith('A:')) {
      this.onmessage(text)
    }
    // 否则不处理——协议完全由你自定义
  }
}

export default MySSE

关键点拆解:

10.2 自定义协议:完全由你说了算

理解了原理就明白了:原生 EventSource 要求 data: 开头,只是它源码里这么规定的。自定义客户端时,协议完全可以自己定——比如规定以 A: 开头才解析:

// 使用自定义 SSE
const sse = new MySSE('http://localhost:3000/api/sse', {
  headers: { token: 'my-token' }        // ✅ token 带在请求头上了
})

sse.onmessage = function (text) {
  console.log('收到:', text)            // 只有 A: 开头的内容才会走到这里
}

sse.init()

对应后端改成自定义协议:

// server.js —— 配合自定义协议:以 A: 开头
res.write('A: 你好\n\n')    // 自定义协议:A: 开头
截图位置:自定义 SSE 请求头中携带 token 成功、以及 A: 协议触发 onmessage 的效果
⚠️ 兼容性注意:自定义 SSE 用的是 fetch,低版本 IE 对 fetch 支持较差——可以用 Axios 请求替代 fetch,原理一样。

11偷懒方案:event-source-polyfill 与 @microsoft/fetch-event-source

手搓比较麻烦,而且自己搓的可能不好用。这么明显的问题肯定有库解决,目前最常用的有两个:event-source-polyfill@microsoft/fetch-event-source(微软官方维护)。

11.1 event-source-polyfill:和原生长得一模一样

// 安装
npm install event-source-polyfill
// 使用:和原生 EventSource 一模一样,还支持自定义请求头
import { EventSourcePolyfill } from 'event-source-polyfill'

const eventSource = new EventSourcePolyfill('http://localhost:3000/api/sse', {
  headers: {
    token: 'my-token'      // ✅ 允许在请求头携带 token
  }
})

eventSource.onmessage = function (e) {
  console.log('收到:', e.data)
}

它的使用方式和原生 EventSource 一模一样,什么都不用改——只不过内部用 fetch(或其它请求方式)实现了一遍 SSE。它的核心定位是polyfill(兼容垫片):原生 EventSource 完全不支持 IE,用它可以在旧浏览器里也能跑起来。

11.2 @microsoft/fetch-event-source:基于 fetch 的"加强版"

这是微软官方维护的 SSE 库(GitHub: Azure/fetch-event-source),完全基于 Fetch API 实现——它把 fetch() 的全部能力都开放给了 SSE:

// 安装
npm install @microsoft/fetch-event-source
// 基本使用:GET + 自定义请求头带 token
import { fetchEventSource } from '@microsoft/fetch-event-source'

const ctrl = new AbortController()          // 用 AbortController 控制断开

fetchEventSource('http://localhost:3000/api/sse', {
  method: 'GET',
  headers: {
    'Authorization': 'Bearer my-token'      // ✅ 请求头带 token(常规写法)
  },
  signal: ctrl.signal,                      // ✅ 可随时主动断开:ctrl.abort()

  // 握手阶段:连接建立后先校验状态码 / 响应头
  onopen(res) {
    if (res.ok && res.headers.get('content-type')?.includes('text/event-stream')) {
      console.log('✅ SSE 连接建立成功')
    } else {
      console.error('❌ 连接异常,状态码:', res.status)
    }
  },

  onmessage(ev) {
    console.log('收到:', ev.data)           // 与原生 EventSource 兼容,仍是 data: 协议
  },

  onclose() {
    console.log('连接关闭')
  },

  onerror(err) {
    console.error('错误:', err)             // 细粒度:可区分 HTTP 错误 / 响应类型异常 / 流中断
  },
})

它最大的杀手锏是:可以使用 POST + body——这对 AI 流式对话场景特别关键,因为很多 AI 接口要求 POST 请求、把 prompt 放在 body 里传:

// AI 对话场景:POST + body 传 prompt,流式接收回复
import { fetchEventSource } from '@microsoft/fetch-event-source'

fetchEventSource('https://api.example.com/chat', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer my-token'
  },
  body: JSON.stringify({ message: '你好,请介绍下自己' }),
  onmessage(ev) {
    // 逐字/逐句拼接 AI 回复,实现打字机效果
    console.log('AI 回复:', ev.data)
  },
})

其它实用特性:openWhenHidden: true 可在页面切到后台时保持连接不断;支持自定义 retry 策略和自定义 fetch 实现;TypeScript 类型完整。

11.3 两库对比:哪个更好?

维度event-source-polyfill@microsoft/fetch-event-source
定位兼容垫片(polyfill)基于 fetch 的加强版客户端
API 形态与原生 EventSource 一模一样(new EventSourcePolyfill)函数式 fetchEventSource(url, options)
自定义请求头✅ 支持(headers 选项)✅ 支持(且支持标准 Authorization 写法)
请求方法 / body❌ 只能 GET,不能带 body✅ 支持 POST / body(AI 接口刚需)
主动断开close()✅ AbortController,控制更精细
握手阶段校验一般✅ onopen 中校验状态码、content-type
错误处理较弱(只有 onerror)✅ 细粒度:HTTP 错误 / 响应类型异常 / 流关闭
后台保持连接✅ openWhenHidden: true
旧浏览器兼容✅ 强(IE 都能用)⚠️ 依赖 fetch,旧浏览器需自行处理
维护方社区(Yaffle)✅ 微软官方(Azure)
📌 结论:现代项目优先选 @microsoft/fetch-event-source——能带请求头、能 POST、能主动断开、错误处理细,还能对接 AI 流式接口,是目前社区的主流推荐;event-source-polyfill 的唯一优势是兼容 IE 等老浏览器,如果你要兼容旧环境才考虑它。
⚠️ 两个库都和原生一样遵循 data: 开头的标准协议(@microsoft/fetch-event-source 内部就是按 Event Stream 格式解析的)——想自定义协议(比如 A: 开头)还是只能手搓。

12SSE 的本质 & 术语表 & 附录完整代码

12.1 SSE 的本质

通过这一系列的自定义和使用,可以发现 SSE 的本质:

📌 SSE 本质上就是一个 GET HTTP 请求——只不过返回的东西是一个字节流(可读流), 以可读流的方式给前端按字节逐个传输文本。期间 HTTP 连接一直连着,服务端可以主动通过这个连接推消息, 前端需要按流解析(读流 → 解码 → 按协议解析)。

术语表

术语定义
SSEServer-Sent Events,服务器推送事件:基于 HTTP 的单向实时推送技术,服务端可主动给前端发消息
EventSource浏览器自带对象,前端接收 SSE 的核心 API(onmessage / onopen / onerror)
text/event-streamSSE 接口的响应头 Content-Type,标识"文本事件流"
data: 协议SSE 消息的标准格式:以 "data: " 开头、以 "\n\n" 结尾,原生 EventSource 才触发 onmessage
event: 协议自定义事件:以 "event: 事件名" 开头,触发 addEventListener(事件名),不触发 onmessage
自动重连服务端 res.end() 断开后前端会自动重连;前端 close() 或服务端状态码断开则不会
可读流ReadableStream,SSE 响应的 body 形态;通过 getReader().read() 循环读取
TextDecoder把 Uint8Array 字节数组解码为文本的对象(decode 方法)
withCredentialsEventSource 第二个参数,设为 true 时请求携带 cookie
event-source-polyfillSSE 兼容垫片库:API 与原生 EventSource 一致,支持请求头携带 token、兼容 IE 等旧浏览器
@microsoft/fetch-event-source微软官方 SSE 库:基于 Fetch API 实现,支持任意请求方法/头/body(可 POST)、AbortController 主动断开、onopen 握手校验与细粒度错误处理

附录 A:服务端完整代码(server.js)

// ========== server.js —— SSE 服务端完整代码 ==========
const express = require('express')
const cors = require('cors')

const app = express()
app.use(cors())

// SSE 接口(GET)
app.get('/api/sse', (req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream; charset=UTF-8',
    'Connection': 'keep-alive'
  })

  const words = ['你', '好', 'S', 'S', 'E', '!']
  let count = 0

  const timer = setInterval(() => {
    if (count < words.length) {
      res.write(`data: ${words[count]}\n\n`)
      count++
    } else {
      clearInterval(timer)
    }
  }, 1000)

  req.on('close', () => {
    console.log('客户端断开连接')
    clearInterval(timer)
  })
})

// 也可以用 event 协议发送自定义事件
// res.write('event: newMessage\ndata: 你有一条未读消息\n\n')

app.listen(3000, () => console.log('SSE server running at :3000'))

附录 B:前端完整代码(index.html)

// ========== index.html —— SSE 前端完整代码 ==========
// 1. 基础使用
const eventSource = new EventSource('http://localhost:3000/api/sse')

let message = ''
eventSource.onmessage = function (e) {
  message += e.data                       // 拼接实现流式效果
  document.getElementById('output').textContent = message
}

// 2. 监听自定义事件(配合后端 event: 协议)
eventSource.addEventListener('newMessage', function (e) {
  console.log('新消息:', e.data)
})

// 3. 组件卸载时关闭(Vue onUnmounted 里)
eventSource.close()

// 4. 携带 cookie(withCredentials)
// const es = new EventSource(url, { withCredentials: true })

// 5. 使用 event-source-polyfill 携带请求头(兼容旧浏览器)
// import { EventSourcePolyfill } from 'event-source-polyfill'
// const es = new EventSourcePolyfill(url, { headers: { token: 'xxx' } })

// 6. 使用 @microsoft/fetch-event-source(现代项目推荐,可 POST)
// import { fetchEventSource } from '@microsoft/fetch-event-source'
// fetchEventSource(url, { headers: { token: 'xxx' }, onmessage(e) { /* ... */ } })

附录 C:自定义 SSE 客户端(mySSE.js)

// ========== mySSE.js —— 自定义 SSE 客户端完整代码 ==========
class MySSE {
  constructor(url, options = {}) {
    this.url = url
    this.headers = options.headers || {}
    this.onmessage = () => {}
  }

  async init() {
    const res = await fetch(this.url, {
      method: 'GET',
      headers: {
        'Content-Type': 'text/event-stream; charset=UTF-8',
        ...this.headers
      }
    })

    const reader = res.body.getReader()    // 可读流
    const decoder = new TextDecoder()      // 字节 → 文本

    while (true) {
      const { done, value } = await reader.read()
      if (done) break

      const text = decoder.decode(value, { stream: true })
      // 自定义协议:以 "A:" 开头才解析
      if (text.startsWith('A:')) {
        this.onmessage(text)
      }
    }
  }
}

// 使用:
// const sse = new MySSE('http://localhost:3000/api/sse', {
//   headers: { token: 'my-token' }
// })
// sse.onmessage = (text) => console.log('收到:', text)
// sse.init()

export default MySSE

附录 D:@microsoft/fetch-event-source 完整示例(含 AI 流式对话)

// ========== fetch-event-source 完整示例 ==========
// 安装:npm install @microsoft/fetch-event-source
import { fetchEventSource } from '@microsoft/fetch-event-source'

// 场景一:普通 SSE 推送(GET + 请求头带 token + 主动断开)
const ctrl = new AbortController()

fetchEventSource('http://localhost:3000/api/sse', {
  method: 'GET',
  headers: { 'Authorization': 'Bearer my-token' },
  signal: ctrl.signal,
  onopen(res) {
    if (res.ok) console.log('连接建立成功')
  },
  onmessage(ev) {
    console.log('收到:', ev.data)
  },
  onerror(err) {
    console.error('出错:', err)
  },
})

// 某处需要断开连接:
ctrl.abort()

// 场景二:AI 流式对话(POST + body,很多 AI 接口的刚需)
fetchEventSource('https://api.example.com/chat', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer my-token'
  },
  body: JSON.stringify({ message: '你好' }),
  openWhenHidden: true,               // 切后台也保持连接
  onmessage(ev) {
    if (ev.data === '[DONE]') return  // 约定结束标记
    appendToScreen(ev.data)           // 逐字拼接,打字机效果
  },
})