H3 WebSocket 与 SSE

2026-10-30 17:48 更新

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 就这么没了。

WebSocket hooks 挂在 request 上而非响应上

响应上确实也暴露了一个便捷属性 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 的 upgrade hook,或在中间件里提前返回 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 响应工具
以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号