Workers WebSockets:WebSocketPair 與 101 升級實戰 | Cloudflare 完整教學
上一篇《Streams 串流處理》讓資料「一直往下推」,但那是單向的——伺服器推、客戶端只讀。當你需要雙方隨時互相說話,做真正的即時互動(聊天室、多人協作、線上遊戲、即時通知),就得換上 WebSocket 這把武器。這一篇我們深入 Cloudflare Workers 的 WebSocket:從
Upgrade: websocket標頭與 101 升級握手、new WebSocketPair()建立 client/server 一對連線、server.accept()接受連線、到message/close事件的處理,親手寫一個雙向 echo 伺服器。也會講清楚為什麼無狀態的 Worker 撐不住多人廣播,該把棒子交給 Durable Objects。
前言
上一篇《Streams 串流處理》,我們學會了讓資料像水管一樣邊收邊送,還用 SSE(Server-Sent Events)做出「打字機」般的 LLM 逐字輸出。但我在那篇結尾埋了一個伏筆:SSE 有個天生的限制——它是單向的。伺服器可以源源不絕地往瀏覽器推,但瀏覽器沒辦法透過同一條通道「即時回話」。做即時通知、股價推播這種「伺服器單方面播報」的場景,SSE 綽綽有餘;可一旦你要做聊天室——雙方都要能隨時發言——SSE 就不夠了。
這時候登場的就是 WebSocket(網頁通訊端)。先給一句話定義:WebSocket 是一種在單一 TCP 連線上提供全雙工(full-duplex)雙向通訊的協定,連線一旦建立,客戶端與伺服器就能在任何時刻、隨時互相主動傳送訊息,直到其中一方關閉連線。 它不像傳統 HTTP 那樣「一問一答、答完就斷」,而是一直保持連線開著,兩端平等地說話。
打個比方:傳統 HTTP 請求像寄一封信——你寄出去(request)、對方回一封(response),然後這段對話就結束了,下次要問又得重新寄一封。SSE 像訂閱廣播電台——你打開收音機(開一條連線),電台一直播、你一直聽,但你沒法透過收音機跟主持人講話。WebSocket 則像打一通電話——接通之後,這條線一直開著,你講一句、對方回一句、對方也能突然插話,雙方隨時都能說,掛斷才結束。 即時互動要的就是這種「電話感」。
有趣的是,這條「電話線」是怎麼接通的?答案很巧妙:它是從一通普通的 HTTP 請求「升級」而來的——這就是本篇的第一個核心。
讀完本篇,你會掌握:
- WebSocket 升級握手——
Upgrade: websocket標頭、101 Switching Protocols狀態碼,一條 HTTP 連線如何「變身」成雙向通道 WebSocketPair與 client / server 兩端——new WebSocketPair()建立一對相連的 WebSocket,一端交還客戶端、一端留在伺服器accept與message/close事件——server.accept()接受連線,用事件監聽收訊息、回訊息、處理關閉- 無狀態 Worker 的限制與解法——為什麼單一 Worker 做不了多人廣播,何時該交給 Durable Objects 與 Hibernation(第 026 篇)
核心概念
WebSocket 升級握手:從 HTTP「變身」而來
WebSocket 連線不是憑空開一條新協定,而是借用一個普通的 HTTP 請求,中途「升級」協定。整個握手流程可以拆成三步:
① 客戶端送出 HTTP GET 請求,帶特殊標頭:
GET /ws HTTP/1.1
Upgrade: websocket ← 「我想升級成 WebSocket」
Connection: Upgrade
② 伺服器同意升級,回一個 101:
HTTP/1.1 101 Switching Protocols ← 「好,切換協定」
Upgrade: websocket
③ 這條原本的 HTTP 連線,此刻起「變身」成雙向 WebSocket 通道
⇅ client ←──── 隨時互傳訊息 ────→ server ⇅
關鍵在第二步的 101 Switching Protocols。這是 HTTP 協定裡專門用來表示「切換協定」的狀態碼——它不是錯誤,而是握手成功的正式訊號。伺服器一旦回了 101,這條連線就不再是 HTTP,而是一條全雙工的 WebSocket 通道。所以在 Workers 裡,你會看到這個很特別的回傳寫法(後面實作會反覆用到):
// WebSocket 升級的標準回應:status 必須是 101、body 必須是 null
return new Response(null, {
status: 101, // 101 = 切換協定,握手成功的訊號
webSocket: client, // 把要交還給客戶端的那一端 WebSocket 帶回去
});
status: 101 不能寫成 200(瀏覽器會認為握手失敗)、body 必須是 null(協定升級的回應沒有 HTTP body)、webSocket 欄位則帶入要交還給客戶端的那一端——這三者是 Workers WebSocket 的鐵律。
WebSocketPair:一對相連的 WebSocket
那個要「帶回去」的 client 又是從哪來的?這就是 Workers WebSocket 最核心的 API——WebSocketPair。
在瀏覽器端,你用 new WebSocket(url) 就能連上伺服器;但在伺服器端(也就是 Worker 裡),情況不同:Worker 需要同時掌握「連線的兩端」——一端要交還給發起連線的客戶端,另一端要留在自己手裡用來收發訊息。new WebSocketPair() 就是幹這件事的:它建立一對彼此相連的 WebSocket 物件,你往其中一端寫的訊息,會從另一端流出來。
// WebSocketPair 是一個類陣列物件,用 Object.values 取出兩端
const pair = new WebSocketPair();
const client = pair[0]; // 這一端交還給客戶端(放進 101 回應)
const server = pair[1]; // 這一端留在 Worker 裡,用來收發訊息
// 更常見的寫法:一行解構取出兩端
const [clientWS, serverWS] = Object.values(new WebSocketPair());
心智模型:WebSocketPair 就像一條電話線的兩支話筒。 一支(client)你透過 101 回應「寄」給遠方的客戶端,另一支(server)你自己拿著。你對著 server 說話(server.send(...)),客戶端那支話筒就聽得到;客戶端說話,你的 server 這端就會收到 message 事件。兩支話筒一接通,電話就打通了。
accept 與事件:接起電話、開始對話
拿到 server 這端後,還差最後一步才能開始通話——server.accept()。它的作用是「接受這條 WebSocket 連線」,告訴 Runtime:這端由我(Worker)親自處理訊息。呼叫 accept() 之後,你就能掛上事件監聽器來收發訊息了:
| 方法 / 事件 | 作用 |
|---|---|
server.accept() | 接受連線,開始由 Worker 直接處理此端的訊息 |
server.addEventListener("message", cb) | 收到客戶端訊息時觸發,event.data 是內容 |
server.addEventListener("close", cb) | 連線關閉時觸發 |
server.addEventListener("error", cb) | 連線出錯時觸發 |
server.send(data) | 主動送出字串或二進位訊息(單則上限 32 MiB) |
server.close(code?, reason?) | 主動關閉連線 |
把這串串起來,一個最小可用的伺服器端 WebSocket 就成形了:接受連線 → 監聽 message → 收到就 send 回去 → 監聽 close 做收尾。下一節就把它寫成完整、可執行的 Worker。
實作範例
概念講完,我們寫一個完整可執行的 echo 伺服器:客戶端傳什麼,伺服器就原樣(加個前綴)回傳,並示範連線關閉的處理。最後附上前端連線程式碼。
範例一:完整的 WebSocket echo 伺服器
// src/index.ts —— Workers WebSocket echo 伺服器
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
// ① 先檢查這是不是一個 WebSocket 升級請求
// 客戶端發起 WebSocket 連線時,一定會帶 Upgrade: websocket 標頭
const upgradeHeader = request.headers.get("Upgrade");
if (upgradeHeader !== "websocket") {
// 不是升級請求 → 回一個一般的 HTTP 回應(可放一個測試用 HTML 頁)
return new Response("此端點需要 WebSocket 連線(Upgrade: websocket)", {
status: 426, // 426 Upgrade Required:告訴客戶端要升級協定
});
}
// ② 建立一對相連的 WebSocket:client 交還客戶端、server 留在 Worker
const [client, server] = Object.values(new WebSocketPair());
// ③ 接受 server 這端,由 Worker 直接處理訊息
server.accept();
// ④ 監聽「收到訊息」事件:把訊息原樣回傳(echo)
server.addEventListener("message", (event: MessageEvent) => {
// event.data 可能是字串或 ArrayBuffer,這裡假設是文字
console.log("收到訊息:", event.data);
server.send(`Echo: ${event.data}`); // 原樣回給同一個客戶端
});
// ⑤ 監聽「連線關閉」事件:做收尾(記錄、清理資源)
server.addEventListener("close", (event: CloseEvent) => {
console.log(`連線關閉,code=${event.code}, reason=${event.reason}`);
// 若客戶端還沒完全關閉,補上 close 讓兩端乾淨收線
server.close(event.code, "伺服器已收線");
});
// ⑥ 監聽錯誤事件
server.addEventListener("error", (event: Event) => {
console.error("WebSocket 發生錯誤:", event);
});
// ⑦ 關鍵回應:status 101 + webSocket: client,完成升級握手
return new Response(null, {
status: 101,
webSocket: client,
});
},
} satisfies ExportedHandler<Env>;
逐步拆解這段程式碼:
- 第 ① 步(檢查 Upgrade 標頭):所有 WebSocket 連線都以帶
Upgrade: websocket標頭的 HTTP 請求發起。先檢查它,非升級請求就回一個一般 HTTP 回應(這裡用426 Upgrade Required)。這一步很重要——同一個 Worker 常常既要服務一般 HTTP、又要服務 WebSocket。 - 第 ②③ 步(建立並接受):
new WebSocketPair()給你一對話筒,server.accept()把server這支接起來。 - 第 ④ 步(echo 核心):
message事件的event.data就是客戶端傳來的內容,server.send(...)原樣回傳。注意:這裡是把訊息回給「同一個發送者」——這正是 echo,也是無狀態 Worker 唯一能穩妥做好的模式。 - 第 ⑤ 步(關閉收尾):
close事件帶有code(狀態碼)與reason(原因)。務必在這裡做清理,並補一個server.close()讓兩端乾淨收線。 - 第 ⑦ 步(升級回應):整段的靈魂——
status: 101、body 為null、webSocket: client。Cloudflare 看到這個回應,就把client那端交還給遠方的瀏覽器,電話正式接通。
範例二:前端如何連上這個 WebSocket
伺服器寫好了,前端用瀏覽器內建的 WebSocket 就能連。程式碼比伺服器端還簡單:
// 前端:連上 Worker 的 WebSocket 端點
// 注意協定用 wss://(加密),本機測試可用 ws://
const ws = new WebSocket("wss://your-worker.example.workers.dev/ws");
// 連線成功建立(握手完成、收到 101 之後觸發)
ws.addEventListener("open", () => {
console.log("WebSocket 已連線");
ws.send("Hello from browser!"); // 主動送第一則訊息
});
// 收到伺服器的訊息(此例會收到 "Echo: Hello from browser!")
ws.addEventListener("message", (event) => {
console.log("伺服器回傳:", event.data);
});
// 連線關閉
ws.addEventListener("close", (event) => {
console.log(`連線關閉,code=${event.code}`);
});
// 連線錯誤
ws.addEventListener("error", (err) => {
console.error("WebSocket 錯誤:", err);
});
前端這一半就是標準的瀏覽器 WebSocket API,跟連任何 WebSocket 伺服器都一樣——你甚至不會察覺後端是不是 Cloudflare Workers。用 wss://(加密的 WebSocket)而非 ws:// 是正式環境的必須;new WebSocket(url) 一呼叫,瀏覽器就自動送出帶 Upgrade: websocket 的握手請求,收到 101 後 open 事件觸發,這條電話就通了。
本機開發與測試
用 wrangler dev 在本機跑起來後,你可以在瀏覽器 DevTools 的 Console 貼上上面的前端程式碼(把 URL 換成本機的 ws://localhost:8787/ws)測試,或用 websocat 這類 CLI 工具:
# 用 wrangler 啟動本機開發伺服器
npx wrangler dev
# 另開一個終端,用 websocat 連上測試(需先安裝 websocat)
websocat ws://localhost:8787/ws
# 連上後隨便打一行字按 Enter,會收到 "Echo: <你打的字>"
常見錯誤與最佳實踐
坑一:忘記回 status: 101(或用了 200),握手失敗連線建立不起來。
這是最常見、也最讓人摸不著頭緒的錯誤。你 WebSocketPair 建好了、accept() 也呼叫了,但回應寫成 return new Response(null, { webSocket: client })(漏了 status)或 status: 200,前端的 WebSocket 就會直接觸發 error、連線建立不起來,而且錯誤訊息往往很含糊。正確做法:升級回應永遠是 new Response(null, { status: 101, webSocket: client })——status 是 101、body 是 null、webSocket 帶 client 端,三者一個都不能少、不能錯。
坑二:想在無狀態 Worker 裡「存住連線、做多人廣播」——這在單一 Worker 裡辦不到。
很多人第一直覺是:把每個 server WebSocket 塞進一個全域陣列 const sockets = [],收到訊息就 for (const s of sockets) s.send(msg) 廣播給所有人,聊天室不就成了?這在 Workers 上行不通。 原因是 Workers 是無狀態且分散在全球數百節點的:使用者 A 和使用者 B 的連線很可能落在不同節點的不同 Worker 實例上,實例之間不共享記憶體、處理完還可能被回收。你在 A 的實例裡存的那個全域陣列,B 的實例根本看不到——廣播自然失敗。單連線的 echo(回給發送者自己)沒問題,但「多條連線互相看見」就一定會失敗。
坑三:把「廣播」的失敗歸咎於程式碼 bug,卻沒意識到這是架構限制。
上面那個坑不是你程式碼寫錯,而是架構本質使然——你需要一個「單一、有狀態、能同時掌握所有連線」的協調者。正確做法:交給 Durable Objects。 Durable Objects 保證「同一個 ID 的實例全球只有一個」:讓每個聊天室對應一個唯一的 Durable Object,所有成員的 WebSocket 都連到同一個 DO 實例,由它統一持有連線清單並廣播。這才是 Workers 平台上做多人即時互動的正解。而當連線數很多、多數時間又閒置時,還要搭配 Hibernation(休眠)API:讓 DO 在沒訊息時卸載出記憶體、保持連線開著、有事件才喚醒,大幅降低長連線成本。這整套(Durable Objects + WebSocket Hibernation)我們會在第 026 篇 Durable Objects 專章深入。
坑四:忘記處理 close 事件、不呼叫 close(),連線與資源懸著。
WebSocket 是長連線,跟一問一答的 HTTP 不同——你必須主動管理它的生命週期。忘了監聽 close 事件做清理(例如從連線清單移除、釋放資源),或該關的時候不呼叫 server.close(),就會讓連線與相關狀態懸在那裡。正確做法:一定要監聽 close(和 error)事件並在其中收尾;主動關閉時用 server.close(code, reason) 帶上標準的關閉狀態碼與原因,讓兩端乾淨收線。
最佳實踐小結: 記住一條分水嶺——「回給自己」用 Worker,「發給大家」用 Durable Objects。單連線的 echo、代理、單純的雙向資料通道,普通 Worker 就能漂亮地用 WebSocketPair + accept + 101 搞定;一旦你需要「多條連線互相看見、廣播、共享狀態」,就別跟無狀態架構硬碰,果斷把協調交給 Durable Objects,長連線量大時再上 Hibernation。守住這條線,你的即時應用才能既正確又省成本。
小結
上一篇《Streams 串流處理》,我們讓資料像水管一樣邊收邊送、還用 SSE 做出單向的 LLM 逐字輸出。這一篇,我們把通道升級成雙向,深入了 Workers 的 WebSocket:
- WebSocket 升級握手——客戶端帶
Upgrade: websocket標頭發起,伺服器回101 Switching Protocols,一條 HTTP 連線就「變身」成全雙工的雙向通道。 WebSocketPair——new WebSocketPair()建立一對相連的 WebSocket,client端透過 101 回應交還瀏覽器、server端留在 Worker 收發訊息,像一條電話線的兩支話筒。accept與事件——server.accept()接受連線後,用message/close/error事件收發與收尾,send()主動送訊息,close()主動關閉。- 升級回應鐵律——
new Response(null, { status: 101, webSocket: client }),status 101、body null、webSocket 帶 client,三者缺一不可。 - 無狀態的限制與解法——單一 Worker 只能穩妥做 echo(回給發送者),因為實例分散、不共享記憶體;多人廣播必須交給 Durable Objects(單一有狀態協調者),長連線量大時再搭 Hibernation 省成本。
WebSocket 讓雙方「隨時互相說話」,補上了 SSE 單向的最後一塊拼圖。不過到目前為止,我們的 Worker 都是「被動」等請求上門——有人來訪才動作。但很多任務是主動、定時的:每天凌晨清理過期資料、每小時同步報表、每五分鐘拉一次匯率。這種「不靠人觸發、時間到就自己跑」的能力,就是下一篇的主題。下一篇《Cron Triggers 排程》,我們會深入 wrangler.jsonc 的 triggers.crons 設定與 scheduled handler,教你的 Worker 學會看時鐘做事。
想深入官方 WebSocket API 細節,可以隨時參考 Cloudflare 官方 WebSockets 文件。準備好讓你的 Worker 開始「講電話」了嗎?我們下一篇《Cron Triggers 排程》見。