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 永远不会离开你的服务器。

Browser / apppusher-js · EchoHibiraws.hibira.ioYour serverpusher-http-*WebSocketprotocol 7HTTP APIwebhookschannel authorizationPOST socket_id + channel_name → {"auth": "key:signature"}

创建连接

客户端连到 App 的 WebSocket URL。路径带着 App key(公开信息,可以放在浏览器里),查找字符串则说明使用的协议版本和库:

wss://ws-hkg.hibira.io/app/APP_KEY?protocol=7&client=js&version=8.4.0&flash=false

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 都是这样编码,客户端库会替你解码。

ClientHibiraGET wss://ws.hibira.io/app/APP_KEY?protocol=7Upgrade: websocketpusher:connection_established{"socket_id":"123.456","activity_timeout":120}pusher:subscribe{"channel":"orders"}pusher_internal:subscription_succeeded"channel":"orders"order-shippedyour event, from your server120 s without a messagepusher:pingpusher:pong

消息格式

双向的每则消息都是一个 JSON 对象,包含 event 名称、通常还有 channel,以及 data。名称以 pusher: 或 pusher_internal: 开头的事件属于协议本身,其他名称都是应用程序事件。

保持连接

连接在协议层使用心跳。客户端连续 activity_timeout 秒(Hibira 为 120 秒)没收到任何消息时,会发送 pusher:ping,服务器回 pusher:pong。服务器对沉默的客户端也一样:沉默 120 秒后发送 pusher:ping,若 30 秒内没有任何消息,就以 4201 关闭连接,之后客户端会重新连接。如果用户和 Hibira 之间有 proxy 或负载均衡器,它的空闲超时必须超过 150 秒。

频道

频道名称的前缀决定类型。名称最长 164 个字符,可使用 A-Z a-z 0-9 _ - = @ , . ;。

订阅 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。

ClientYour serverHibiraconnectsocket_id "123.456"POST /pusher/authsocket_id, channel_name{"auth":"APP_KEY:HMAC…"}pusher:subscribe{"channel":"private-orders","auth":…}HMAC-SHA256(secret, "123.456:private-orders") matchespusher_internal:subscription_succeeded

服务器库一行就能完成(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:error 4009。
  • 发送者不会收到自己的事件。在 presence 频道上,接收者会拿到发送者的 user_id。
  • 每条连接每秒最多 10 则,超过的会被丢弃并回 pusher:error 4301。
{"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 送达,连接保持打开。

HTTP API

你的服务器向与 WebSocket 相同的主机、在 /apps/APP_ID/ 底下发出 HTTP 请求来发布和查找:

事件内容包含事件名称、频道,以及字符串形式的数据;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=529798e83b8523846d8314a6b2b6c517
import 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"}]}

信任 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 共用同一个主机。

客户端库以选项指定主机,此时 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 的每一个帧。