H3 WebSocket 与 SSE
H3 本身不实现 WebSocket 协议,它做的是胶水:把一条路由声明成 WebSocket 端点,再把连接交给 CrossWS 处理。好处是一套代码在 Node.js、Bun、Deno、Cloudflare Workers 上都能跑;代价是你要多装一个包、多配一个插件。
SSE 则完全不同——它是纯 HTTP,H3 内置了 EventStream 类,不依赖任何外部包。
前置:crossws 要单独装
crossws 在 package.json 里是 peerDependency(^0.4.12),不是普通依赖。所以只用 h3 不会自动带上它:
## npm
npm i crossws
## pnpm
pnpm add crossws
## 报 Cannot find module 'crossws' 就是这一步漏了
两步接上 WebSocket
import { H3, serve, defineWebSocketHandler } from "h3";
// 注意:插件从 crossws/server 导入,不是从 h3
import { plugin as ws } from "crossws/server";
const app = new H3();
// 第一步:把路由声明成 WebSocket 端点
app.get(
"/_ws",
defineWebSocketHandler({
message: (peer, message) => peer.send(message.text()),
}),
);
// 第二步:在 serve 时挂 crossws 插件
serve(app, { plugins: [ws()] });
漏掉任何一步都不会报错,只是连接静静失败:只写 defineWebSocketHandler 而没挂插件,升级请求会收到 426 Upgrade Required;只挂插件而没有 handler,则永远没有 hooks 可执行。
hooks 挂在请求上,不在响应上
这是官方文档花了一整段解释、也很容易看漏的设计:defineWebSocketHandler() 把 hooks 挂到 request(键是 Symbol.for("crossws.hooks")),而不是挂到它返回的响应上。
原因是响应在离开应用的过程中会被重建:路由规则(3.1)往里合并头、CORS 中间件改头、任何 new Response(res.body, res) 的中间件,都会产出一个新对象,而新对象不携带原对象上的自有属性。挂在响应上的 hooks 就这么没了。

响应上确实也暴露了一个便捷属性 res.crossws,但只在没人重建过它时有效。所以:
import { getWebSocketHooks } from "crossws";
import { serve } from "h3";
// 只有自己写 resolve 时才需要这段;默认不用管
serve(app, {
plugins: [
ws({
// 从 request 读 hooks,而不是从响应读
resolve: (req) => app.fetch(req).then(() => getWebSocketHooks(req)),
}),
],
});
默认情况下 CrossWS 会先调一次应用的 fetch,把 hooks 读走。这个细节有个正向副作用:普通的路由中间件对升级请求同样生效,所以鉴权可以直接写成 app.use("/_ws", ...),不必在 hooks 里重做一遍。
一条路由同时服务 HTTP 与 WebSocket
非升级请求默认得到 426 Upgrade Required。给 defineWebSocketHandler 传第二个参数,就能让同一路由也服务普通 HTTP:
import { defineWebSocketHandler, html } from "h3";
// 第二个参数是普通 HTTP handler,只在非升级请求时执行
app.get(
"/chat",
defineWebSocketHandler(
{
open(peer) {
// 新连接进来:发欢迎语并加入频道
peer.send("welcome");
peer.subscribe("chat");
},
message(peer, message) {
// 文本为 ping 时回 pong,否则广播给同频道所有人
if (message.text() === "ping") {
peer.send("pong");
return;
}
peer.publish("chat", `${peer} ${message}`);
},
close(peer) {
// 断开:退订并通知其他人
peer.unsubscribe("chat");
peer.publish("chat", `${peer} left`);
},
},
// 非升级请求拿到的页面(html 标签模板由 h3 导出)
() => html`<h1>用 ws 客户端连接此地址</h1>`,
),
);
三个重载值得记住:
| 写法 | 适用 |
|---|---|
defineWebSocketHandler(hooks) |
纯 WebSocket 端点,非升级请求 426 |
defineWebSocketHandler(async (event) => hooks) |
hooks 需要按请求动态决定(如按用户权限裁剪) |
defineWebSocketHandler(hooks, http) |
同一路由兼顾 HTML 页面与 WebSocket |
第二个重载很实用:hooks 工厂是异步的,defineWebSocketHandler 会等它 resolve 后再挂到请求上,所以可以在里面查一次数据库决定这个连接能订阅哪些频道。
⚠️ 想拒绝握手(比如 token 不对),不要用第二个参数的 http handler——它只处理非升级请求。正确做法是 crossws 的
upgradehook,或在中间件里提前返回 401。
SSE:不装任何包就能用
SSE 是「服务器单向推送」的轻量方案,走普通 HTTP,天然支持断线重连。H3 的 EventStream 类继承自 HTTPResponse,所以直接 return 就行:
import { H3, serve, EventStream } from "h3";
const app = new H3().get("/sse", (event) => {
const stream = new EventStream(event);
// 每秒推一条;SSE 会自动补 data: 前缀和空行分隔
const timer = setInterval(() => stream.push(`now ${Date.now()}`), 1000);
// 客户端断开时清理定时器,否则内存和句柄会一直涨
stream.onClosed(() => clearInterval(timer));
return stream;
});
serve(app);
push 接受四种形态:单条字符串、字符串数组、单个消息对象、消息对象数组。数组会被合并成一次写,比分多次写更省;高频场景应该批量化。
消息对象支持 SSE 规范的四个字段:
| 字段 | 作用 |
|---|---|
data |
正文,多行会自动拆成多个 data: 行 |
event |
事件名,客户端用 addEventListener("名字") 订阅 |
id |
事件 ID,客户端会记住并在重连时用 Last-Event-ID 回传 |
retry |
重连间隔(毫秒),整数才生效 |
🚀 一个规范级细节:
id传空字符串会重置客户端的 last event ID,而不传id则保持不变。要做「客户端从哪个点续传」的功能,这两个行为差别很关键。
除了 push,EventStream 还有几个方法:
| 方法 | 用途 |
|---|---|
pushComment(text) |
发 SSE 注释行(: ...),常用于保活,客户端不触发事件 |
pause() / resume() |
暂停/恢复推送;暂停期间的数据会缓存,resume 时一次冲出 |
flush() |
手动把缓存冲出 |
close() |
主动关闭流 |
onClosed(cb) |
注册关闭回调,客户端断开与主动关闭都会触发 |
慢客户端是 SSE 最典型的坑:push 会等 writer.write 完成,客户端读得慢就会阻塞。用 pause() 把缓冲攒起来、到阈值再 resume(),比无脑 push 稳得多。
WebSocket 还是 SSE
| 维度 | WebSocket | SSE |
|---|---|---|
| 方向 | 双向 | 仅服务器到客户端 |
| 依赖 | 需要 crossws 与 serve 插件 |
内置,零依赖 |
| 协议 | 升级握手 | 普通 HTTP |
| 断线重连 | 要自己写 | 浏览器自动重连,可续传 |
| 适用 | 聊天、协同编辑、游戏 | 进度条、日志流、通知推送 |
判断标准很实在:客户端需要往回发东西吗? 需要就 WebSocket,不需要就 SSE——后者省掉一整个依赖和握手逻辑。
排查表
| 现象 | 最可能原因 | 处理 |
|---|---|---|
| 升级请求返回 426 | 没在 serve 挂 plugins: [ws()] |
补上插件 |
报 Cannot find module 'crossws' |
peer 依赖没装 | npm i crossws |
| 加了 CORS 中间件后连接失败 | hooks 被响应重建弄丢 | 自定义 resolve 时从 request 读 hooks |
SSE 的 onClosed 不触发 |
只测了正常返回,没测客户端断开 | 用浏览器关闭标签页验证 |
| SSE 内存持续增长 | 定时器没在 onClosed 里清理 |
见上文示例 |
| 慢客户端把服务拖住 | 无节制 push 造成背压 |
用 pause/resume 批量冲 |
| 客户端重连后收到重复数据 | 没设 id,无法续传 |
给每条消息带递增 id |
延伸阅读
- 路由与中间件对升级请求的作用范围:见 2.3 中间件
- 路由规则(CORS、缓存)与 hooks 的关系:见 3.1 路由规则入门
- 流式响应的通用写法:见 7.6 流式响应
onDispose与资源清理:见 6.4 响应工具

TRAE-AI编程


更多建议: