Pusher 协议详解:连接、频道、验证与 Webhook
Pusher Channels 协议(第 7 版)如何运作:WebSocket 握手、消息格式、各类频道及其签名、客户端事件、错误代码、HTTP API 与 Webhook,以及 Hibira 如何实现。
Pusher 协议是什么
Pusher Channels 协议是 pusher-js、Laravel Echo 与官方 Pusher 服务器库和实时服务沟通时使用的一组 WebSocket 消息与 HTTP 调用。它有公开文档且相当稳定,目前版本是 7。Hibira 是实现这套协议、兼容 Pusher 的独立服务,所以只要换掉主机地址,这些库就能直接使用。
协议分成三个部分:
- WebSocket 连接。 浏览器与 App 开一条 WebSocket,在上面订阅频道、接收事件。
- HTTP API。 你的服务器用签名过的 HTTP 请求发布事件、查询频道。客户端永远不通过它发布。
- Webhook。 服务在频道有了订阅者或变空、presence 成员加入或离开、客户端发送事件时通知你的服务器。
另外还有一个调用发生在你自己的客户端和你自己的服务器之间:客户端加入 private 或 presence 频道之前,要由你的服务器用 App secret 替这个请求签名,而 secret 永远不会离开你的服务器。
创建连接
客户端连到 App 的 WebSocket URL。路径带着 App key(公开信息,可以放在浏览器里),查找字符串则说明使用的协议版本和库:
wss://ws-hkg.hibira.io/app/APP_KEY?protocol=7&client=js&version=8.4.0&flash=false| 参数 | 意义 |
|---|---|
APP_KEY | 控制台上的 App key。key 不存在时连接会以代码 4001 关闭。 |
protocol | 协议版本。Hibira 接受 5、6、7;没有带会以 4008 关闭,不支持的版本以 4007 关闭。 |
client、version | 库名称与版本,用于记录和统计。 |
WebSocket 打开后,服务器会发送 pusher:connection_established。它的 data 包含 socket ID(在签名和 HTTP API 中用来识别这条连接)以及以秒为单位的 activity timeout:
{"event":"pusher:connection_established","data":"{\"socket_id\":\"123.456\",\"activity_timeout\":120}"}注意 data 是装着 JSON 的字符串,不是 JSON 对象。服务器发送的每个事件的 data 都是这样编码,客户端库会替你解码。
消息格式
双向的每则消息都是一个 JSON 对象,包含 event 名称、通常还有 channel,以及 data。名称以 pusher: 或 pusher_internal: 开头的事件属于协议本身,其他名称都是应用程序事件。
| 事件 | 方向 | 用途 |
|---|---|---|
pusher:connection_established | 服务器 → 客户端 | 连接就绪,带有 socket ID。 |
pusher:error | 服务器 → 客户端 | 带 code 与 message 的错误,见下方错误代码。 |
pusher:ping / pusher:pong | 双向 | 心跳。 |
pusher:subscribe | 客户端 → 服务器 | 加入频道;private 和 presence 频道要附签名。 |
pusher:unsubscribe | 客户端 → 服务器 | 离开频道。 |
pusher_internal:subscription_succeeded | 服务器 → 客户端 | 订阅成功;presence 频道会附上成员名单。 |
pusher:subscription_error | 服务器 → 客户端 | 订阅被拒,例如 AuthError。 |
pusher_internal:member_added / pusher_internal:member_removed | 服务器 → 客户端 | 有用户加入或离开 presence 频道。 |
pusher:signin / pusher:signin_success | 双向 | 用户验证(登录),见下文。 |
pusher:cache_miss | 服务器 → 客户端 | cache 频道目前没有缓存内容。 |
pusher_internal:subscription_count | 服务器 → 客户端 | 开启订阅计数时,频道目前的订阅 socket 数。 |
保持连接
连接在协议层使用心跳。客户端连续 activity_timeout 秒(Hibira 为 120 秒)没收到任何消息时,会发送 pusher:ping,服务器回 pusher:pong。服务器对沉默的客户端也一样:沉默 120 秒后发送 pusher:ping,若 30 秒内没有任何消息,就以 4201 关闭连接,之后客户端会重新连接。如果用户和 Hibira 之间有 proxy 或负载均衡器,它的空闲超时必须超过 150 秒。
频道
频道名称的前缀决定类型。名称最长 164 个字符,可使用 A-Z a-z 0-9 _ - = @ , . ;。
| 频道 | 前缀 | 需要签名 | 客户端事件 |
|---|---|---|---|
| Public | 无,例如 orders | 否 | 否 |
| Private | private- | 是 | 开启后可用 |
| Presence | presence- | 是,含用户数据 | 开启后可用 |
| 加密 | private-encrypted- | 是 | 否 |
| Cache | cache-、private-cache-、presence-cache-、private-encrypted-cache- | 同其基本类型 | 同其基本类型 |
| Server-to-user | #server-to-user-USER_ID | 仅限已登录的该用户 | 否 |
| 大型 presence(Hibira) | presence-large- | 是,含用户数据 | 开启后可用 |
订阅 public 频道只要一则消息,服务器以 pusher_internal:subscription_succeeded 回应:
{"event":"pusher:subscribe","data":{"channel":"orders"}}接着你的服务器用 HTTP API 发布到 orders,每个订阅者都会收到事件:
{"event":"order-shipped","channel":"orders","data":"{\"id\":1042}"}授权 private 与 presence 频道
private 频道需要证明你的服务器允许这条连接加入。客户端库会带着 socket ID 和频道名称询问你的验证端点。你的服务器检查用户的 session 后返回签名:以 App secret 为密钥,对 socket_id:channel_name 计算 HMAC-SHA256 的十六进制值,前面加上 App key。
服务器库一行就能完成(Node 的 pusher.authorizeChannel(socketId, channel)、PHP 的 $pusher->authorizeChannel()、Laravel 的 Broadcast::channel())。自己动手写则是这样:
import crypto from 'node:crypto';
// POST /pusher/auth with socket_id and channel_name, after checking the session
function authorize(socketId, channel) {
const signature = crypto
.createHmac('sha256', process.env.PUSHER_APP_SECRET)
.update(`${socketId}:${channel}`)
.digest('hex');
return { auth: `${process.env.PUSHER_APP_KEY}:${signature}` };
}客户端把结果连同订阅一起发送:
{"event":"pusher:subscribe","data":{"channel":"private-orders","auth":"APP_KEY:81065a8a0a6e7e1f501edc52ef55314aa125075065593e6e9bed5e538ec3d12f"}}签名错误时会收到类型为 AuthError 的 pusher:subscription_error,连接本身保持打开。
Presence 频道
presence 频道还要说明用户是谁。验证响应多了 channel_data,这是一个含 user_id 和可选 user_info 的 JSON 字符串,签名也涵盖它:要签的字符串是 socket_id:channel_name:channel_data。
{"auth":"APP_KEY:SIGNATURE","channel_data":"{\"user_id\":\"42\",\"user_info\":{\"name\":\"Mei\"}}"}之后 pusher_internal:subscription_succeeded 会带上成员(ids、hash 和 count),用户来去时再收到 member_added 与 member_removed。同一位用户开多个分页只算一次。Hibira 和 Pusher 一样,每个 presence 频道最多 100 位成员、user_info 最多 1 KB。更大的观众群可以用 presence-large- 频道(Hibira 的扩充):它发送人数与 100 位成员的样本,加入与离开则定期批量送达。
加密频道
在 private-encrypted- 频道上,服务器库会用由频道名称和你的加密主密钥衍生出的密钥(NaCl secretbox)加密每个事件的数据。验证响应也会以 shared_secret 返回该频道的密钥,让客户端能解密。Hibira 以 private 频道的方式授权订阅,并原样转送密文,无法读取内容。加密频道不允许客户端事件。
用户验证
除了频道之外,连接也可以以用户身份登录。客户端发送 pusher:signin,附上 user_data(至少含 id 的 JSON 字符串)以及对 socket_id::user::user_data 签名的 auth。收到 pusher:signin_success 后,你的服务器就能把事件送给这位用户的所有连接(sendToUser,经由 #server-to-user-USER_ID 送达),也能关闭其全部连接(terminate_connections)。
客户端事件
客户端可以把事件直接送给同一频道的其他订阅者,不必绕经你的服务器:输入中提示、光标位置、游戏操作都适用。规则如下:
- 事件名称以
client-开头。 - 只能在客户端已加入的 private 和 presence 频道上使用,public 和加密频道都不行。
- 必须在 App 设置中开启客户端事件,否则服务器会回
pusher:error4009。 - 发送者不会收到自己的事件。在 presence 频道上,接收者会拿到发送者的
user_id。 - 每条连接每秒最多 10 则,超过的会被丢弃并回
pusher:error4301。
{"event":"client-typing","channel":"private-chat-7","data":{"user":"Mei"}}你的服务器可以通过 client_event webhook 追踪客户端事件。
Cache 频道
cache 频道会记住最后一个事件 30 分钟,并在每位新订阅者的 subscription_succeeded 之后立刻发送,让页面不必询问你的服务器就能显示目前状态。没有缓存内容时,客户端会收到 pusher:cache_miss,你的服务器则收到 cache_miss webhook,可以趁机发布目前的值。
错误与关闭代码
4000 到 4299 的代码会关闭连接,范围告诉客户端下一步该怎么做。4300 以上的代码以 pusher:error 送达,连接保持打开。
| 范围 | 客户端行为 |
|---|---|
| 4000–4099 | 不要用相同设置重新连接。 |
| 4100–4199 | 退避一段时间后重新连接。 |
| 4200–4299 | 立刻重新连接。 |
| 4300–4399 | 只是错误,连接保持打开。 |
| 代码 | Hibira 何时发送 |
|---|---|
| 4001 | App key 不存在。 |
| 4003 | App 已停用。 |
| 4004 | 帐户超过方案的连接上限。 |
| 4007 | 不支持此协议版本。 |
| 4008 | 没有提供协议版本。 |
| 4009 | 不允许:来源不在 App 的允许清单、连接被终止,或客户端事件不被允许。 |
| 4100 | 容量已满,或客户端读取太慢;请退避后重试。 |
| 4200 | 服务器正在重新启动,请立刻重新连接。 |
| 4201 | 没有及时收到 pong。 |
| 4301 | 达到客户端事件速率上限或每日消息额度。 |
HTTP API
你的服务器向与 WebSocket 相同的主机、在 /apps/APP_ID/ 底下发出 HTTP 请求来发布和查找:
| 请求 | 用途 |
|---|---|
POST /apps/APP_ID/events | 把一个事件发布到最多 100 个频道。 |
POST /apps/APP_ID/batch_events | 一次请求发布最多 10 个事件。 |
GET /apps/APP_ID/channels | 列出有订阅者的频道,可按前缀筛选,并附订阅数。 |
GET /apps/APP_ID/channels/CHANNEL | 单一频道的状态。 |
GET /apps/APP_ID/channels/CHANNEL/users | presence 频道上的用户。 |
POST /apps/APP_ID/users/USER_ID/terminate_connections | 关闭某位已登录用户的所有连接。 |
事件内容包含事件名称、频道,以及字符串形式的数据;socket_id 会排除触发事件的那条连接,让发送者不会看到自己的变更两次。事件数据最大 10 KB。
{"name":"order-shipped","channels":["orders"],"data":"{\"id\":1042}","socket_id":"123.456"}请求签名
每个请求都带着 auth_key、auth_timestamp(Unix 秒数,与服务器时钟相差需在 600 秒内)、auth_version=1.0、有内容的请求还要 body_md5,以及 auth_signature。签名是以 App secret 为密钥,对“方法、路径、按名称排序的其他参数”以换行连接后计算的 HMAC-SHA256 十六进制值:
POST
/apps/123/events
auth_key=APP_KEY&auth_timestamp=1792458481&auth_version=1.0&body_md5=529798e83b8523846d8314a6b2b6c517import crypto from 'node:crypto';
const body = JSON.stringify({ name: 'order-shipped', channels: ['orders'], data: '{"id":1042}' });
const params = {
auth_key: process.env.PUSHER_APP_KEY,
auth_timestamp: Math.floor(Date.now() / 1000),
auth_version: '1.0',
body_md5: crypto.createHash('md5').update(body).digest('hex'),
};
const query = Object.keys(params).sort().map((k) => `${k}=${params[k]}`).join('&');
const path = `/apps/${process.env.PUSHER_APP_ID}/events`;
const signature = crypto
.createHmac('sha256', process.env.PUSHER_APP_SECRET)
.update(`POST\n${path}\n${query}`)
.digest('hex');
await fetch(`https://${process.env.PUSHER_HOST}${path}?${query}&auth_signature=${signature}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
});实际上,官方服务器库(Node 的 pusher、pusher/pusher-php-server、Python 的 pusher、pusher-http-go 等)只要设置 host 选项,就会替你签名。
Webhook
Webhook 是 Hibira 向你在 App 页面设置的URL发出的 POST 请求。每个请求都有签名:X-Pusher-Key 是 App key,X-Pusher-Signature 是以 App secret 为密钥、对原始内容计算的 HMAC-SHA256 十六进制值。事件最多累积一秒批量发送,每次最多 100 个。
{"time_ms":1792458481958,"events":[{"name":"channel_occupied","channel":"orders"}]}| 事件 | 何时发送 |
|---|---|
channel_occupied / channel_vacated | 频道有了第一位订阅者,或失去最后一位。 |
member_added / member_removed | 用户的第一个 socket 加入 presence 频道,或最后一个离开。 |
client_event | 客户端发送 client- 事件。 |
cache_miss | 客户端订阅了没有缓存内容的 cache 频道。 |
subscription_count | 开启计数时,频道的订阅数有变化。 |
信任 webhook 之前先检查签名,并以恒定时间比较:
import crypto from 'node:crypto';
function verify(rawBody, signature) {
const expected = crypto.createHmac('sha256', process.env.PUSHER_APP_SECRET).update(rawBody).digest('hex');
return signature?.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}Hibira 如何对应这套协议
每个 App 位于一个集群,就像 Pusher 的集群一样,它的客户端与服务器都连到该集群的主机。WebSocket 和 HTTP API 共用同一个主机。
| 集群 | 地区 | 主机 |
|---|---|---|
hkg | 香港 | ws-hkg.hibira.io(也可用 ws.hibira.io) |
tyo | 东京 | ws-tyo.hibira.io(规划中) |
sin | 新加坡 | ws-sin.hibira.io(规划中) |
客户端库以选项指定主机,此时 cluster 选项会被忽略:
const pusher = new Pusher('APP_KEY', {
cluster: 'hkg',
wsHost: 'ws-hkg.hibira.io',
forceTLS: true,
enabledTransports: ['ws', 'wss'],
});不需修改就能使用的有:pusher-js、Laravel Echo 与 Laravel Broadcasting、官方服务器库、官方 iOS 与 Android 库、可设置主机的 Flutter 和 Unity 社区客户端,以及兼容 Beams 的推送通知。迁移现有 App 只需要换主机和新的凭证,请参考从 Pusher 迁移和集成指南。想实时观察协议,可以打开 WebSocket 测试工具,它会显示连到你的 App 的每一个帧。