Workers WebSockets:WebSocketPair 與 101 升級實戰 | Cloudflare 完整教學

2026/08/08
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,一端交還客戶端、一端留在伺服器
  • acceptmessage / 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 為 nullwebSocket: 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.jsonctriggers.crons 設定與 scheduled handler,教你的 Worker 學會看時鐘做事。

想深入官方 WebSocket API 細節,可以隨時參考 Cloudflare 官方 WebSockets 文件。準備好讓你的 Worker 開始「講電話」了嗎?我們下一篇《Cron Triggers 排程》見。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →