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 網址。路徑帶著 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 頁面設定的網址發出的 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 的每一個訊框。