WebSocket heartbeat:用應用層 ping/pong 偵測並清掉死連線

文字解析 · 1 支影片 · 產生於 2026-10-01 00:54

🎧 語音解析📝 筆記✍️ 練習

🎧 語音解析 24m10s · TTS 講解與作者原聲交錯;點章節可跳。
邊聽邊看逐句講稿,點任一句從那裡開始
章節(13)

1. Outline

  1. 起點 · Keep Those WebSocket Connections Alive!
    從「死連線讓客戶永遠流失」出發,在 Node.js ws 範本上逐步實作 server 端 setInterval ping/terminate 與 client 端 setTimeout 看門狗,最後多開分頁驗證 pong 數等於連線數。

2. YouTuber 的思維推導

Keep Those WebSocket Connections Alive!

Covalence · 23m03s · 字幕 en · vision=on (input 指定 vision=true。整支是 VS Code 實作錄影,程式碼只出現在畫面上、口述常省略細節(變數名、型別宣告),看圖才能對上。) · YouTube

預估 vs 實際耗時
階段預估實際
fetch 6s 2s
segment 1m16s 1m58s
shot 57s 18s
analyze 9m00s 7m06s
render 5s –

作者從一個很具體的痛點出發:WebSocket 任一端掉線時,另一端常常不知道,訊息就一直送進黑洞——用客服工單系統當例子,代價是「永遠失去一個客戶」。既然問題是「不知道對方還在不在」,解法就是定期問一次:server 每隔固定時間對每條連線送一個 ping,client 收到就回一個 pong;一輪之內沒回的就當死掉。要落地這個想法,他先把既有範本的 socket 邏輯搬到獨立檔案(sockets/index.ts)當工作區,接著在 server 端建立三個零件:常數(HEARTBEAT_INTERVAL、HEARTBEAT_VALUE)、對每條連線標記 isAlive 的狀態位(連線時 true),以及一個 setInterval:每輪先把還是 false 的連線 terminate 掉,其餘設成 false 再 ping;收到 binary 且值相符的訊息就把 isAlive 設回 true。

這形成「true → false + ping → pong → true」的閉環。然後他把視角換到 client:client 沒辦法主動 ping server,但可以反過來「等 server 的 ping」——每次收到 ping 就重設一個比 server 間隔略長(5 + 1 秒緩衝)的 setTimeout,同時回一個 Uint8Array 的 pong;timeout 真的觸發代表 server 太久沒來,就主動 close 並交給重連邏輯。過程中兩次碰到 TypeScript 型別不允許加自訂屬性,分別用 module augmentation(server 的 ws 套件)與 interface extends + as 斷言(瀏覽器原生 WebSocket)解決;client 端還需要用 Object.prototype.toString.call 判斷收到的是 Blob(ping)還是一般訊息。

最後用三個分頁實測「pong 數 = 活著的連線數」,並臨時補上 server 關閉時 clearInterval 的清理。結論回到開頭:正式環境把間隔拉到 10–30 秒,overhead 很小但必要,因為另一個選項是失去客戶。

推理鏈:每段留給下一段的線索

每一列是一段,箭頭後的字是這段留給下一段的線索。點段落標題跳到該段。

  1. 死連線問題與 ping/pong 解法 → 解法定了:server 要定期 ping、client 要回 pong。但作者手上的範本把所有 socket 邏輯都塞在主檔 index.ts 裡,加 heartbeat 之前得先有個乾淨的地方寫——下一段先做搬家。
  2. 重構:socket 邏輯搬到 sockets/index.ts → socket 邏輯現在全在 configure 函式裡,也知道 wss.clients 是所有連線的集合。下一步要決定 heartbeat 的具體參數:多久 ping 一次、ping 送什麼、怎麼送。
  3. 設計 heartbeat:間隔、值、ping 函式 → ping 函式有了,但 server 還沒辦法記住「這條連線上一輪有沒有回」。下一段要給每條連線一個狀態位 isAlive,並解決 TypeScript 不讓你在 ws 套件的 WebSocket 上加屬性的問題。
  4. 標記 isAlive 並擴充 ws 型別 → 每條連線現在都有 isAlive 且一開始是 true。接下來要寫真正的 setInterval:每一輪怎麼用這個狀態位決定「誰該被踢掉、誰該被 ping」。
  5. server 端 interval:不活就 terminate → server 現在每輪會 ping 並把 isAlive 放下,但還沒有任何程式碼把它舉回 true——pong 到底長什麼樣、在哪裡接?下一段在 message 處理器裡補上另一半。
  6. 收 pong:binary 且值相符才算活著 → server 端閉環完成:ping 出去、pong 回來就續命、沒回來就踢掉。但 client 到現在什麼都還沒做——它收到 binary 的 ping 之後要回什麼?而且 client 自己也該察覺「server 太久沒 ping 我」。下一段換到瀏覽器端。
  7. client 端 heartbeat:timeout 加緩衝 → heartbeat() 現在會設一個逾時關閉的計時器,但每次收到 ping 都 setTimeout 會累積出一堆舊計時器,而且 TypeScript 還在對 ws.pingTimeout 畫紅線。下一段要先清掉舊 timeout,再解決瀏覽器原生 WebSocket 的型別問題。
  8. clearTimeout 與 WebSocketExt 型別 → heartbeat() 現在會清舊 timeout、設新 timeout,型別也安靜了。但它還沒回 pong 給 server——server 那邊等的是一個 data[0] === 1 的 binary 訊息,client 要怎麼在瀏覽器裡造出這種東西?
  9. 補上 pong:Uint8Array 送回 server → client 會回 pong 了,但要先能分辨「收到的是 ping 還是要顯示的訊息」才知道何時該呼叫 heartbeat()。瀏覽器收到 binary 時給的是什麼物件?下一段寫 isBinary()。
  10. isBinary:用 toString 判斷 Blob → 工具齊了:heartbeat() 負責計時與回 pong,isBinary() 負責分流。但它們都還沒被接到任何事件上——onmessage 收到 ping 時要呼叫 heartbeat(),onclose 時該把 timeout 清掉。下一段把零件接起來。
  11. 接上 onclose / onmessage → server 與 client 兩邊的程式都寫完了,但還沒真的跑過:多開幾條連線時 pong 數會不會對?關掉分頁後 server 會不會少算一個?下一段實測。
  12. 實測多連線與 server close 清理 → 機制驗證通過,最後回到一開始的取捨:示範用的 5 秒在正式環境該怎麼調、這個 overhead 到底值不值得?
  13. 總結:間隔調大,開銷小但必要

3. 逐段說明

Keep Those WebSocket Connections Alive!

1. 死連線問題與 ping/pong 解法 0:00–1:02

作者先講痛點:WebSocket 不管誰掉線,client 與 server 常常都不知道,訊息就這樣一直送進黑洞。他用客服工單系統當例子——客戶得自己猜要不要重新整理、整個支援流程重來,一個客戶就永遠流失了。結論是要用 ping/pong 機制定期確認每條連線還活著。

承上 前情提要裡「WebSocket 是長連線、雙向、事件驅動」這個背景在這裡被用上:正因為連線是長期掛著的,而不是像 HTTP 每次請求都重新建立,一旦底層 TCP 悄悄斷掉,應用層不會自動收到通知,才會有「以為還連著」的問題。

推理因為 WebSocket 兩端都可能在不知情的狀況下掉線,所以作者先用客服工單系統把代價講具體:客戶或客服其中一方斷線、雙方都沒察覺,訊息一直送卻永遠沒回音,客戶只能自己猜要不要重新整理、整個流程重來,結果是「永遠失去一個客戶」。問題的本質是「不知道對方還在不在」,所以解法自然是定期確認:用 ping/pong 機制,每隔一段時間問一次所有連線是否還活著。

AI 補充作者沒解釋為什麼「掉線了卻不知道」會發生。原因是 WebSocket 建立在 TCP 上,而 TCP 在對方沒有正常送出 FIN/RST(例如手機切網路、筆電闔上、中間的 NAT 或 proxy 把閒置連線表項清掉)時,本端 socket 會一直停留在「已連線」狀態,直到你真的送資料且重試逾時(可能是好幾分鐘)才會報錯。應用層如果只是被動等訊息,就永遠不會知道。「ping/pong」在這支影片裡是**應用層**自己定義的一問一答,不是 WebSocket 協定內建的 ping/pong 控制訊框——這個差異在後面實作時會看得更清楚。

術語:dead connection

WebSocket

WebSocket 長連線

在一次 HTTP 握手後升級成的持久雙向通道,之後 server 與 client 都可以隨時主動送訊息。

和 HTTP 的請求/回應不同,WebSocket 連線建立後就一直掛著,直到某一方明確關閉。這正是它適合聊天、客服、即時通知的原因,也是它的弱點:連線「掛著」不等於「活著」,底層 TCP 可能已經斷了但兩端都沒被通知。Node.js 端常用的實作是 `ws` 套件,瀏覽器端則有原生的 `WebSocket` 物件。

相關術語: dead connection (會出現的問題)、ping/pong (用它偵測存活)

出處:第 1 段「死連線問題與 ping/pong 解法」

dead connection

死連線

底層已經斷掉、但應用程式仍以為還連著的 WebSocket 連線。

死連線的成因通常是對方沒有正常關閉(斷網、休眠、NAT 逾時),TCP 不會主動通知本端。後果有兩層:對送訊息的一方是「訊息進黑洞」;對 server 是 clients 清單越積越多、每次廣播都對死連線白送資料,拖慢整台 server。作者用「客服流程重來、客戶永遠流失」把使用者體驗那層代價講得很具體。

相關術語: WebSocket (發生於)、ping/pong (用它清掉)

出處:第 1 段「死連線問題與 ping/pong 解法」

ping/pong

心跳一問一答

一方定期送一個小訊息(ping),另一方收到就回一個(pong),一段時間沒回就當對方死掉。

WebSocket 協定本身有 ping/pong 控制訊框(opcode 0x9/0xA),瀏覽器會自動回 pong,但 JavaScript 端看不到也不能主動送。這支影片做的是**應用層**版本:用一般的 binary 訊息模擬 ping 與 pong,好處是 client 端也能察覺「server 太久沒 ping 我」,並且完全由自己控制格式與時間。

相關術語: dead connection (用來偵測)、WebSocket (跑在其上)

出處:第 1 段「死連線問題與 ping/pong 解法」

留給下一段 解法定了:server 要定期 ping、client 要回 pong。但作者手上的範本把所有 socket 邏輯都塞在主檔 index.ts 裡,加 heartbeat 之前得先有個乾淨的地方寫——下一段先做搬家。

2. 重構:socket 邏輯搬到 sockets/index.ts 1:03–3:26

從既有的 websocket 範本專案出發,先做整理:新建 sockets/index.ts,把原本塞在 index.ts 裡 app.listen 之後的所有 WebSocket 邏輯剪過去,包成 export default function configure(server: Server)。主檔改成 configureSockets(app.listen(...)),把 http Server 物件整個傳進去。這步只是搬家,還沒加 heartbeat。

重構完成後的兩個檔案關係:index.ts 只剩 configureSockets(app.listen(...)),socket 邏輯都在 sockets/index.ts 的 configure 裡;之後所有 server 端程式碼都寫在這個函式內
3:24 · 重構完成後的兩個檔案關係:index.ts 只剩 configureSockets(app.listen(...)),socket 邏輯都在 sockets/index.ts 的 configure 裡;之後所有 server 端程式碼都寫在這個函式內
承上 承接上一段「要有乾淨的地方寫 heartbeat」:作者先不加功能,把既有 websocket 範本裡的 socket 邏輯從主檔搬出來。

推理因為上一段決定要在既有範本上加 ping/pong,而範本目前所有東西都擠在主檔 index.ts 裡,所以作者先做純搬家:新建 sockets/index.ts,把原本 app.listen 之後的 WebSocket 邏輯全部剪過去,包成 export default function configure(server: Server);主檔改成 configureSockets(app.listen(...)),把 http Server 物件整個傳進去。這步不改行為,只是讓之後所有 server 端程式碼有一個固定的家。

AI 補充截圖(204 秒)補上口述沒講清楚的兩件事。第一,搬過去的不只是 connection 處理,還包含 s.on('upgrade') 裡的 wss.handleUpgrade 流程:server 是用 `new WebSocketServer({ noServer: true })` 建立、自己接管 HTTP upgrade 事件,所以 configure 才需要拿到整個 http Server 才能掛 'upgrade' 監聽。第二,既有的 message 處理已經是「收到訊息 → 對 wss.clients 裡 readyState === OPEN 的每個 client 廣播」,這個 forEach 迴圈之後會被 heartbeat 的 interval 直接仿照——先記住 wss.clients 這個集合。

術語:http.Server

http.Server

Node.js 的 HTTP 伺服器物件

app.listen() 的回傳值,代表實際在監聽 port 的那個 server,可以掛 'upgrade' 等底層事件。

Express 的 app 本身只是請求處理函式,真正監聽 port 的是 app.listen() 回傳的 http.Server。WebSocket 握手是一個帶 Upgrade 標頭的 HTTP 請求,所以要攔截它就得在 http.Server 上監聽 'upgrade' 事件。作者把這個物件整個傳進 configure(server: Server),型別從 'http' 模組 import。

相關術語: WebSocketServer (由它接管升級)

出處:第 2 段「重構:socket 邏輯搬到 sockets/index.ts」

WebSocketServer

ws 套件的伺服器端物件

ws 套件提供的類別,負責處理握手、維護 clients 集合、對每條新連線發出 'connection' 事件。

以 `{ noServer: true }` 建立時它不自己開 port,而是等你在 http.Server 的 'upgrade' 事件裡呼叫 wss.handleUpgrade(),成功後再 emit 'connection'。這樣做的好處是可以在升級前先做驗證(截圖裡有 socket.destroy() 的拒絕分支)。它的 `clients` 屬性是一個 Set,裝著目前所有連線,之後 heartbeat 就是遍歷這個集合。

相關術語: http.Server (掛在其上)、WebSocket (管理多個)

出處:第 2 段「重構:socket 邏輯搬到 sockets/index.ts」

留給下一段 socket 邏輯現在全在 configure 函式裡,也知道 wss.clients 是所有連線的集合。下一步要決定 heartbeat 的具體參數:多久 ping 一次、ping 送什麼、怎麼送。

3. 設計 heartbeat:間隔、值、ping 函式 3:27–5:50

作者說明機制:server 用 setInterval 每隔一段時間 ping 所有連線,期待收到 pong。有一點 overhead,但比堆一堆死連線便宜。定義 HEARTBEAT_INTERVAL(示範用 5 秒,正式建議 10–30 秒,看 client 可接受的最大延遲)與 HEARTBEAT_VALUE = 1(可以換成更不好猜的值),並寫 ping(ws) 函式:把 heartbeat value 以 binary 送出,不是字串或 JSON。

HEARTBEAT_INTERVAL、HEARTBEAT_VALUE 與 ping 函式的完整程式碼,尤其 send 時 { binary: true } 這個選項,口述只說「以 binary 送」
5:45 · HEARTBEAT_INTERVAL、HEARTBEAT_VALUE 與 ping 函式的完整程式碼,尤其 send 時 { binary: true } 這個選項,口述只說「以 binary 送」
承上 承接上一段留下的三個問題:多久 ping 一次、ping 送什麼、怎麼送。作者在剛搬好的 sockets/index.ts 頂部用兩個常數和一個函式回答。

推理因為上一段已經有了乾淨的工作區,所以作者先把機制講白:用 setInterval 每隔一段時間對每條連線 ping,期待收到 pong;這有一點 overhead,但比堆一堆死連線便宜。接著定義 HEARTBEAT_INTERVAL = 1000 * 5(示範用 5 秒,正式建議 10–30 秒,依 client 可接受的最大延遲決定)與 HEARTBEAT_VALUE = 1(可以換成更不好猜的值),並寫 ping(ws) 函式:ws.send(HEARTBEAT_VALUE, { binary: true }),強調是以 binary 送、不是字串或 JSON。

AI 補充為什麼堅持用 binary?因為之後 client 要能一眼分辨「這是 ping」還是「這是要顯示的訊息」,而一般聊天訊息是文字(字串或 JSON),把 ping 放在另一個訊框型別就不用去解析內容。截圖(345 秒)補上口述沒說的細節:`{ binary: true }` 是 ws 套件 send() 的選項,決定送出的 WebSocket 訊框是 binary 還是 text。不過這裡有個作者沒察覺的小坑:傳給 send 的是數字 1,ws 套件會先把數字轉成字串再包成訊框,所以實際送出去的位元組是 0x31(字元 '1'),不是 0x01。這支影片裡 client 只檢查「是不是 binary」不檢查值,所以還是能動;但如果 client 也想比對值,得改成 `ws.send(Buffer.from([HEARTBEAT_VALUE]), { binary: true })`。

術語:binary frame

heartbeat

心跳

定期發出的存活訊號;這裡指 server 每隔 HEARTBEAT_INTERVAL 對每條連線 ping 一次的整套機制。

「心跳」是 ping/pong(見第 1 段)的通稱:只要有固定節奏的一問一答,就叫 heartbeat。作者把間隔、值、ping 函式都冠上這個字。間隔的選擇是取捨:越短越快發現死連線但 overhead 越高;越長越省但使用者可能對著死連線多等幾十秒。10–30 秒是常見範圍。

相關術語: ping/pong (的具體實作)、setInterval (用它排程)

出處:第 3 段「設計 heartbeat:間隔、值、ping 函式」

setInterval

固定間隔重複執行

JavaScript 的計時器,每隔指定毫秒數重複呼叫一次函式,回傳一個可用來取消的 id。

和只執行一次的 setTimeout 相對。這裡的用法是每 HEARTBEAT_INTERVAL 毫秒遍歷一次所有連線。要注意它不會自己停:server 關閉時得用 clearInterval 手動清掉,否則會一直空轉。單位是毫秒,所以作者寫 1000 * 5 而不是 5。

相關術語: heartbeat (驅動)

出處:第 3 段「設計 heartbeat:間隔、值、ping 函式」

binary frame

二進位訊框

WebSocket 兩種資料訊框之一,內容是原始位元組而非 UTF-8 文字;ws 套件用 send(data, { binary: true }) 送出。

WebSocket 資料訊框分 text(opcode 0x1)與 binary(opcode 0x2)。收方可以在不解析內容的情況下知道是哪一種:Node.js 的 ws 在 'message' 事件給第二個參數 isBinary,瀏覽器則把 binary 收成 Blob(或 ArrayBuffer)。作者把 ping/pong 放在 binary、正常訊息放在 text,就是靠這個型別差異做分流。

相關術語: ping/pong (用它承載)

出處:第 3 段「設計 heartbeat:間隔、值、ping 函式」

見仁見智 5:09
「we're basically sending a one down and」
ws 套件的 send() 收到數字時會先 data.toString(),所以實際送出的 binary 內容是字元 '1'(0x31),不是數值 1(0x01)。影片裡 client 端只判斷是否為 Blob、不比對值,所以機制照常運作;但「送一個 1 下去、期待收到一個 1 回來」這句在位元組層面不成立,client 若真的檢查 data[0] === 1 會失敗。
依據: ws v8.x websocket.js send():`if (typeof data === 'number') data = data.toString();`
留給下一段 ping 函式有了,但 server 還沒辦法記住「這條連線上一輪有沒有回」。下一段要給每條連線一個狀態位 isAlive,並解決 TypeScript 不讓你在 ws 套件的 WebSocket 上加屬性的問題。

4. 標記 isAlive 並擴充 ws 型別 5:51–7:02

在 wss.on('connection') 拿到 socket 時,先設 ws.isAlive = true。TypeScript 抱怨 WebSocket 沒這個屬性,所以新建 typings/ws.d.ts:import WebSocket from 'ws',用 declare module 'ws' 擴充 interface WebSocket,加上 isAlive: boolean。作者習慣用被擴充的套件名來命名 d.ts 檔。

typings/ws.d.ts 的 module augmentation 寫法:declare module 'ws' + interface WebSocket { isAlive: boolean },這是 TypeScript 特有語法,只聽不容易寫對
6:55 · typings/ws.d.ts 的 module augmentation 寫法:declare module 'ws' + interface WebSocket { isAlive: boolean },這是 TypeScript 特有語法,只聽不容易寫對
承上 承接上一段「server 需要記住每條連線有沒有回 pong」:作者在 wss.on('connection') 拿到 socket 的那一刻先設 ws.isAlive = true。

推理因為上一段的 ping 只是「問」,還需要一個地方記「答了沒」,所以作者選擇最直接的做法:把狀態掛在連線物件本身,連線一建立就 ws.isAlive = true。TypeScript 立刻抱怨 ws 套件的 WebSocket 型別沒有 isAlive,於是新建 typings/ws.d.ts:import WebSocket from 'ws',用 declare module 'ws' 裡的 interface WebSocket { isAlive: boolean } 把屬性「合併」進原本的型別。作者習慣用被擴充的套件名來命名這個 d.ts 檔。

AI 補充為什麼把狀態掛在 socket 上而不是另外開一個 Map?因為 wss.clients 已經是所有連線的集合,之後 interval 遍歷時直接讀 client.isAlive 最省事,連線被 terminate 從集合移除時狀態也跟著消失,不用另外清理。截圖(415 秒)確認初始值是在 connection 回呼的第一行設定,位置在 ws.on('error') 與 ws.on('message') 之前。至於 TypeScript 那步:declare module 'ws' 裡再宣告一次同名 interface 會與套件原本的 interface **合併**而不是覆蓋,這叫 module augmentation;先 import 'ws' 是必要的,否則 TypeScript 會把 declare module 當成「宣告一個全新的模組」,型別就整個被蓋掉。

術語:declaration file

module augmentation

模組擴充

在 .d.ts 裡先 import 某個模組,再 declare module '同名' 並宣告同名 interface,讓新屬性合併進該模組原本的型別。

TypeScript 的 interface 有「宣告合併」特性:同一個作用域內同名 interface 的成員會被合併。module augmentation 就是把這個特性用在第三方套件上。關鍵是檔案頂部一定要有 import(讓檔案成為模組、讓 declare module 被視為擴充),否則會變成 ambient module 宣告,直接取代套件型別。這是修改「你不擁有的型別」最乾淨的方式,執行期完全沒有影響。

相關術語: declaration file (寫在其中)、WebSocket (擴充其型別)

出處:第 4 段「標記 isAlive 並擴充 ws 型別」

declaration file

型別宣告檔(.d.ts)

副檔名 .d.ts、只含型別沒有實作的檔案,用來告訴 TypeScript 某些東西的型別長什麼樣。

d.ts 不會被編譯成 JavaScript,純粹給型別檢查用。常見用途有三種:套件附帶的型別、@types 套件、以及像這裡一樣自己補的擴充。放在 typings/ 之類的資料夾並確保 tsconfig 的 include 涵蓋它即可。作者的命名慣例是「擴充哪個套件就叫什麼」,所以是 ws.d.ts。

相關術語: module augmentation (承載)

出處:第 4 段「標記 isAlive 並擴充 ws 型別」

留給下一段 每條連線現在都有 isAlive 且一開始是 true。接下來要寫真正的 setInterval:每一輪怎麼用這個狀態位決定「誰該被踢掉、誰該被 ping」。

5. server 端 interval:不活就 terminate 7:03–8:06

在 connection 處理器外面宣告 const interval = setInterval(..., HEARTBEAT_INTERVAL)。每次觸發就遍歷 wss.clients(不管 readyState):如果 client.isAlive 是 false 就 client.terminate() 並 return;否則把 isAlive 設回 false 再 ping(client),等 client 回 pong。

setInterval 內的迴圈:先檢查 !isAlive → terminate,再 isAlive = false → ping。這個「先設 false 再 ping」的順序是整個機制的核心
8:04 · setInterval 內的迴圈:先檢查 !isAlive → terminate,再 isAlive = false → ping。這個「先設 false 再 ping」的順序是整個機制的核心
承上 承接上一段「每輪如何用 isAlive 決定踢誰、ping 誰」:作者在 connection 處理器外面宣告 const interval = setInterval(..., HEARTBEAT_INTERVAL)。

推理因為上一段已經讓每條連線帶著 isAlive,所以 interval 的邏輯可以很短:仿照 message 處理器那個 forEach,遍歷 wss.clients(但不管 readyState),如果 client.isAlive 是 false 就 client.terminate() 並 return;否則把 isAlive 設成 false,再 ping(client),等 client 回 pong 把它設回 true。作者特別把「先設 false 再 ping」講成一組動作。

AI 補充「先設 false 再 ping」的順序是整個機制的核心:這一輪把旗子放下,下一輪來檢查旗子有沒有被 pong 舉回來——所以判定一條連線死掉需要**一整個 interval 的時間**,不是收到 pong 的瞬間。也因此一條連線從斷掉到被踢,最多會延遲兩個 interval(斷在剛 ping 完之後)。為什麼不管 readyState?因為 heartbeat 的目的就是抓那些「readyState 還是 OPEN 但其實已經死了」的連線,用 readyState 篩選反而會漏掉目標。為什麼用 terminate() 而不是 close()?close() 會走正常的關閉握手、等對方回 close 訊框——對死連線來說永遠等不到;terminate() 直接銷毀底層 socket,立刻從 wss.clients 移除。截圖(484 秒)確認 interval 宣告在 wss.on('connection') 區塊之後、仍在 configure 函式內,所以它拿得到 wss。

terminate

強制終止連線

ws 套件的方法,不做關閉握手、直接銷毀底層 socket,適合用在已經沒有回應的連線上。

WebSocket 正常關閉是 close():送出 close 訊框、等對方也送一個、再關 TCP。對死連線這個等待永遠不會結束(ws 會在 30 秒後自己放棄)。terminate() 跳過全部流程,同步觸發 'close' 事件並從 clients 集合移除,正是 heartbeat 判定死掉後想要的效果。

相關術語: dead connection (用來清掉)、WebSocketServer (從其 clients 移除)

出處:第 5 段「server 端 interval:不活就 terminate」

readyState

連線狀態碼

WebSocket 物件的屬性,值為 CONNECTING(0)、OPEN(1)、CLOSING(2)、CLOSED(3)。

readyState 只反映本端知道的狀態,對方悄悄斷線時它仍是 OPEN——這正是死連線的定義。所以廣播訊息時用它篩選是對的(避免對正在關閉的連線 send 拋錯),但 heartbeat 遍歷時刻意不篩,因為要抓的就是「OPEN 但沒回應」的那些。

相關術語: dead connection (看不出)

出處:第 5 段「server 端 interval:不活就 terminate」

留給下一段 server 現在每輪會 ping 並把 isAlive 放下,但還沒有任何程式碼把它舉回 true——pong 到底長什麼樣、在哪裡接?下一段在 message 處理器裡補上另一半。

6. 收 pong:binary 且值相符才算活著 8:07–10:25

pong 會以 binary 的 message 進來。在 ws.on('message', (data, isBinary)) 裡:如果 isBinary 且 data[0] === HEARTBEAT_VALUE,就把 ws.isAlive 設回 true 並 log 'pong';否則才走原本廣播訊息的邏輯。也在 interval 裡 log 'firing interval' 方便觀察。作者總結整個循環:連線時 true → interval 設 false 並 ping → 收到 pong 設回 true;掉線就不會回 pong,下一輪就被 terminate、從 clients 清掉;不會有 race condition。

message 處理器裡 isBinary && data[0] === HEARTBEAT_VALUE 的判斷與 isAlive = true,對照 interval 那段 false → ping 的另一半
9:20 · message 處理器裡 isBinary && data[0] === HEARTBEAT_VALUE 的判斷與 isAlive = true,對照 interval 那段 false → ping 的另一半
承上 承接上一段「還沒有程式碼把 isAlive 舉回 true」:pong 會以 binary 訊息的形式進到 ws.on('message'),作者在那裡加分流。

推理因為上一段的 interval 只負責放旗子,所以作者回到 ws.on('message', (data, isBinary)):如果 isBinary 且 data[0] === HEARTBEAT_VALUE,就把 ws.isAlive 設回 true 並 log 'pong';否則才走原本廣播的邏輯。也在 interval 開頭 log 'firing interval' 方便觀察。然後他把整個循環講一遍:連線時 true → interval 設 false 並 ping → 收到 pong 設回 true;掉線就不會回 pong,下一輪被 terminate、從 clients 清掉;他認為不會有 race condition。

AI 補充data 在 ws 套件裡預設是 Buffer,data[0] 就是第一個位元組,所以能直接和 HEARTBEAT_VALUE 比對(作者先把它標成 any 省事)。這一段補上第 3 段留下的伏筆:這裡比對的是 client 送回來的 pong,而 client 之後會用 Uint8Array 放入數值 1,所以 data[0] === 1 成立——server 自己送出去的 ping 內容是不是 0x01 在這裡沒有影響。關於「不會有 race condition」:在單執行緒的 Node.js 裡,interval 回呼和 message 回呼不會同時執行,確實沒有並發衝突;但有一個時序邊界要知道——如果 client 回 pong 的來回時間超過一個 interval(例如間隔設太短或網路很慢),活著的連線也會被誤判為死。所以間隔不能小於預期的最差 RTT,這也是作者說正式環境用 10–30 秒的另一層理由。截圖(560 秒)是在 interval 開頭打 console.log 的瞬間,可以看到 interval 與 forEach 的最終結構。

Buffer

Node.js 的位元組陣列

Node.js 用來裝原始二進位資料的物件,可以用索引存取每個位元組;ws 的 'message' 事件預設就給 Buffer。

Buffer 是 Uint8Array 的子類別,所以 data[0] 讀出的是 0–255 的整數。ws 的 message 事件簽名是 (data, isBinary):text 訊框也會給 Buffer,只是 isBinary 為 false,要顯示文字得自己 toString()。這裡作者先確認 isBinary 再讀 data[0],避免把一則剛好以 '1' 開頭的文字訊息誤認成 pong。

相關術語: binary frame (收到後的型別)

出處:第 6 段「收 pong:binary 且值相符才算活著」

race condition

競態條件

兩個動作的相對順序不確定、而結果又依賴那個順序時產生的錯誤。

作者說這個設計不會有 race condition,指的是 isAlive 的讀寫只發生在 interval 回呼與 message 回呼裡,而 Node.js 事件迴圈一次只跑一個回呼,不會出現「一邊在讀一邊被改」的情況。真正要留意的不是並發而是時序:pong 必須在下一輪 interval 之前回來,否則會被誤殺。

相關術語: setInterval (由其節奏決定)

出處:第 6 段「收 pong:binary 且值相符才算活著」

留給下一段 server 端閉環完成:ping 出去、pong 回來就續命、沒回來就踢掉。但 client 到現在什麼都還沒做——它收到 binary 的 ping 之後要回什麼?而且 client 自己也該察覺「server 太久沒 ping 我」。下一段換到瀏覽器端。

7. client 端 heartbeat:timeout 加緩衝 10:26–13:26

到 public/app.js 寫 heartbeat() 函式(client 只有一個 ws,不用傳參數)。核心是 ws.pingTimeout = setTimeout(...),時間要跟 server 的間隔對上,但要多留一點緩衝:HEARTBEAT_TIMEOUT = 1000 * (5 + 1),把 5 秒與 1 秒 buffer 寫清楚而不是直接寫 6000,可讀性優先,這點運算成本可以忽略。timeout 真的觸發代表 server 太久沒 ping,就 ws.close(),接著是「要不要重連」的業務邏輯位置(自動重連或跳 modal 問使用者)。

HEARTBEAT_TIMEOUT = 1000 * (5 + 1) 的寫法與 setTimeout 內 ws.close() 加重連註解,口述時作者在 interval / timeout 命名上改來改去,看圖才知道最終版
12:50 · HEARTBEAT_TIMEOUT = 1000 * (5 + 1) 的寫法與 setTimeout 內 ws.close() 加重連註解,口述時作者在 interval / timeout 命名上改來改去,看圖才知道最終版
承上 承接上一段「client 要回 pong,也要能察覺 server 太久沒 ping」:作者切到 public 資料夾的前端程式,寫一個 heartbeat() 函式。

推理因為上一段的 server 已經會定期送 ping,所以 client 的角色是被動的:每次收到 ping 就「重新起算」一個計時器,計時器真的響了就代表 server 太久沒來。作者原本想把 ws 當參數傳進 heartbeat,但想到 client 同一時間只有一個 ws,就改成無參數函式。核心是 ws.pingTimeout = setTimeout(...),時間必須和 server 的間隔對上但要多留緩衝:HEARTBEAT_TIMEOUT = 1000 * (5 + 1),把「5 秒」和「1 秒 buffer」寫清楚而不是直接寫 6000,可讀性優先、運算成本可忽略。timeout 觸發時 ws.close(),之後就是「要不要重連」的業務邏輯(自動重連或跳 modal 問使用者)。

AI 補充為什麼 client 用 setTimeout 而 server 用 setInterval?因為 server 是主動方,要固定節奏出擊;client 是被動方,只需要一個「倒數計時器」,每次收到 ping 就歸零重來——這種「重設倒數」的模式用 setTimeout 最自然。為什麼要加緩衝?因為 server 的第 N 次 ping 和第 N+1 次 ping 中間隔 5 秒,再加上網路傳輸抖動,如果 client 的 timeout 也剛好 5 秒,正常的 ping 有機會晚個幾十毫秒到而被誤判;多 1 秒就吃掉這個抖動。這裡的 5 是複製 server 的常數值,兩邊沒有共用來源,改 server 時要記得同步改 client。截圖(770 秒)補上口述沒提的細節:前端檔案其實是 public/js/app.ts(TypeScript,不是段落摘要裡的 app.js),而且上面已經有一個 closeConnection() 函式在做 `if (!!ws) ws.close()`;ws.pingTimeout 在畫面上有紅色波浪線,那是 TypeScript 的抱怨,還沒處理。

術語:buffer (timing)

setTimeout

延遲一次執行

JavaScript 的計時器,指定毫秒後呼叫一次函式,回傳一個可用 clearTimeout 取消的 id。

和 setInterval(見第 3 段)相對,只跑一次。「每次事件來就 clearTimeout 再重新 setTimeout」是實作「閒置多久就觸發」的標準寫法,也叫 debounce 式的看門狗(watchdog)。這裡看門狗的事件是「收到 server 的 ping」,逾時動作是 ws.close()。

相關術語: setInterval (對照組)、heartbeat (client 端用它)

出處:第 7 段「client 端 heartbeat:timeout 加緩衝」

buffer (timing)

時間緩衝

在預期的等待時間上再多加一小段,吸收網路延遲與計時器誤差,避免正常情況被誤判為逾時。

client 的逾時必須嚴格大於 server 的間隔,否則兩個計時器幾乎同時到期,誰先誰後取決於網路與事件迴圈排程。作者的 1 秒是「任意」選的,原則是:緩衝要大於你預期的最差單程延遲加上計時器抖動。把它寫成 (5 + 1) 而不是 6,是讓讀程式的人看得出這個意圖。

相關術語: setTimeout (加在其上)

出處:第 7 段「client 端 heartbeat:timeout 加緩衝」

留給下一段 heartbeat() 現在會設一個逾時關閉的計時器,但每次收到 ping 都 setTimeout 會累積出一堆舊計時器,而且 TypeScript 還在對 ws.pingTimeout 畫紅線。下一段要先清掉舊 timeout,再解決瀏覽器原生 WebSocket 的型別問題。

8. clearTimeout 與 WebSocketExt 型別 13:27–15:11

heartbeat() 開頭先防呆:if (!ws) return;if (ws.pingTimeout) clearTimeout(ws.pingTimeout),再重設新的 timeout。原生 WebSocket 沒有 pingTimeout 屬性,作者提醒這算污染原生物件,可考慮加底線前綴。解法是在 typings/index.d.ts 定義 interface WebSocketExt extends WebSocket { pingTimeout: NodeJS.Timeout }(setTimeout 回傳型別),建立 ws 時 as WebSocketExt。

typings/index.d.ts 的 WebSocketExt 介面與 new WebSocket(...) as WebSocketExt,是 client 端型別擴充的最終寫法,和 server 端的 module augmentation 是兩種不同做法
15:08 · typings/index.d.ts 的 WebSocketExt 介面與 new WebSocket(...) as WebSocketExt,是 client 端型別擴充的最終寫法,和 server 端的 module augmentation 是兩種不同做法
承上 承接上一段的兩個未完成事項:舊 timeout 會累積、TypeScript 對 ws.pingTimeout 畫紅線。

推理因為上一段每次呼叫 heartbeat() 都會新建一個 timeout,所以作者在函式開頭先防呆:if (!ws) return;else if (!!ws.pingTimeout) clearTimeout(ws.pingTimeout),然後才設新的。接著處理紅線:原生 WebSocket 沒有 pingTimeout 屬性,作者承認這算污染原生物件,建議可以加底線前綴表示「這是我加的」。解法是在 typings/index.d.ts 定義 interface WebSocketExt extends WebSocket { pingTimeout: NodeJS.Timeout }(他 hover 看到 setTimeout 回傳型別是 NodeJS.Timeout 就照抄),建立連線時寫 new WebSocket(...) as WebSocketExt。

AI 補充為什麼不清舊 timeout 會出事?因為第一個 timeout 沒被取消的話,即使之後 ping 都正常到,6 秒後它還是會響並 ws.close()——連線會莫名其妙每 6 秒斷一次。所以「clear 再 set」不是優化而是正確性。型別那步和第 4 段是刻意的對照:server 端擴充的是 ws **套件**的型別,用 module augmentation 合併;client 端要擴充的是瀏覽器**內建**的 WebSocket,作者改用「宣告一個子介面 + as 斷言」——這不會改到全域的 WebSocket 型別,只有你明確斷言的那個變數才有 pingTimeout。兩種做法都只影響型別檢查,執行期一樣是直接在物件上加屬性。至於 NodeJS.Timeout:瀏覽器的 setTimeout 其實回傳 number,編輯器顯示 NodeJS.Timeout 是因為這個專案的前端 tsconfig 也看得到 @types/node,Node 的型別覆蓋了 DOM 的;照抄能過編譯,但更可攜的寫法是 ReturnType<typeof setTimeout>。截圖(908 秒)可以看到 timeout 回呼裡已補上 ws.close() 與重連註解,以及 as WebSocketExt 的位置。

術語:type assertioninterface extends

clearTimeout

取消尚未觸發的 setTimeout

傳入 setTimeout 回傳的 id,讓那個計時器不再觸發。

每個 setTimeout 都是獨立的計時器,重新 setTimeout 不會取代舊的。要做「重設倒數」一定是 clearTimeout(舊 id) 再 setTimeout(新)。對已經觸發或不存在的 id 呼叫 clearTimeout 是安全的,所以作者的 !!ws.pingTimeout 檢查嚴格說可以省,但寫出來意圖更清楚。

相關術語: setTimeout (取消)

出處:第 8 段「clearTimeout 與 WebSocketExt 型別」

type assertion

型別斷言(as)

用 `值 as 型別` 告訴 TypeScript「把這個值當成那個型別」,只影響檢查、不改執行期。

new WebSocket(...) 的型別是內建的 WebSocket,沒有 pingTimeout;寫 as WebSocketExt 之後,ws 這個變數在 TypeScript 眼中就有這個屬性。斷言只允許在「來源型別與目標型別有包含關係」時使用,WebSocketExt extends WebSocket 正好滿足。它與 module augmentation(見第 4 段)的差別:斷言是局部的、每個變數各自標;augmentation 是全域的、所有該型別的值都多出屬性。

相關術語: module augmentation (對照組)、interface extends (斷言的目標)

出處:第 8 段「clearTimeout 與 WebSocketExt 型別」

interface extends

介面繼承

宣告一個新 interface 包含另一個 interface 的所有成員再加上自己的,例如 WebSocketExt extends WebSocket。

這裡的 WebSocket 是瀏覽器 DOM lib 的內建型別。extends 之後 WebSocketExt 擁有原本所有方法與屬性,再多一個 pingTimeout。放在 typings/index.d.ts 而不是加 declare module,是因為內建型別屬於全域,作者不想動到全域,只想給自己那個變數用。

相關術語: type assertion (配合使用)、declaration file (寫在其中)

出處:第 8 段「clearTimeout 與 WebSocketExt 型別」

見仁見智 14:42
「actually see it is a node.js.timeout」
這段是跑在瀏覽器的前端程式,瀏覽器的 setTimeout 回傳 number;編輯器顯示 NodeJS.Timeout 是因為前端 tsconfig 同時載入了 @types/node,Node 的宣告蓋過 DOM 的。照抄 NodeJS.Timeout 在這個專案能過,但換到不含 @types/node 的前端專案會編譯失敗;可攜的寫法是 ReturnType<typeof setTimeout>。
依據: TypeScript lib.dom.d.ts:setTimeout(): number;@types/node globals.d.ts:setTimeout(): NodeJS.Timeout
留給下一段 heartbeat() 現在會清舊 timeout、設新 timeout,型別也安靜了。但它還沒回 pong 給 server——server 那邊等的是一個 data[0] === 1 的 binary 訊息,client 要怎麼在瀏覽器裡造出這種東西?

9. 補上 pong:Uint8Array 送回 server 15:12–16:40

作者發現 heartbeat() 還沒送 pong 就回頭補:建立 new Uint8Array(1),data[0] = HEARTBEAT_VALUE(client 也宣告一個 = 1 的常數),然後 ws.send(data)。至此 heartbeat 完成:設 timeout → 有舊的就清掉 → 重設 → 回 pong。若 server 一直沒 ping,timeout 就會觸發並手動 close 連線。

client 端完整的 heartbeat 函式:clearTimeout、setTimeout、Uint8Array pong 三件事都在同一個畫面裡
16:12 · client 端完整的 heartbeat 函式:clearTimeout、setTimeout、Uint8Array pong 三件事都在同一個畫面裡
承上 承接上一段「heartbeat() 還沒回 pong,server 等的是 data[0] === 1 的 binary」:作者原本要開始寫 isBinary,發現漏了這件事就回頭補。

推理因為上一段的 heartbeat() 只做了計時,而第 6 段的 server 要收到 binary 且第一個位元組等於 HEARTBEAT_VALUE 才會把 isAlive 舉回 true,所以作者在 heartbeat() 尾端造一個長度 1 的 Uint8Array,data[0] = HEARTBEAT_VALUE(client 也宣告一個 = 1 的常數,他認為放上面比較清楚),然後 ws.send(data)。至此 heartbeat 完成:設 timeout → 有舊的就清掉 → 重設 → 回 pong;若 server 一直沒 ping,timeout 觸發就手動 close,之後可接重連。

AI 補充為什麼用 Uint8Array 而不是直接 ws.send(1)?瀏覽器的 WebSocket.send() 只接受 string、Blob、ArrayBuffer 或 ArrayBufferView;傳數字會被轉成字串 '1' 以 text 訊框送出,server 端的 isBinary 就是 false,pong 會被當成一般訊息廣播出去。Uint8Array 是 ArrayBufferView,瀏覽器會自動以 binary 訊框送出,不需要像 ws 套件那樣指定 { binary: true }。這也呼應第 3 段那個位元組的問題:client 這邊放進去的是真正的數值 1(0x01),所以 server 的 data[0] === 1 才會成立。截圖(972 秒)是整個 heartbeat() 的最終樣貌:防呆與 clearTimeout、setTimeout 含 ws.close()、Uint8Array pong,三件事一起看比較容易對上「client 每收到一次 ping 做哪些事」。

Uint8Array

無號 8 位元整數陣列

JavaScript 的 typed array,每個元素是 0–255 的整數,底層是一段固定長度的 ArrayBuffer。

typed array 是瀏覽器裡操作原始位元組的標準工具。new Uint8Array(1) 配置 1 個位元組,data[0] = 1 寫入數值 1。WebSocket.send() 接受它並以 binary 訊框送出。Node.js 的 Buffer(見第 6 段)就是 Uint8Array 的子類別,所以 server 收到後用同樣的索引方式讀。

相關術語: Buffer (瀏覽器對應物)、binary frame (送出時的型別)

出處:第 9 段「補上 pong:Uint8Array 送回 server」

留給下一段 client 會回 pong 了,但要先能分辨「收到的是 ping 還是要顯示的訊息」才知道何時該呼叫 heartbeat()。瀏覽器收到 binary 時給的是什麼物件?下一段寫 isBinary()。

10. isBinary:用 toString 判斷 Blob 16:41–17:35

client 收到 message 要分辨是 binary 的 ping 還是要顯示的訊息。isBinary(obj) 的技巧:typeof obj === 'object' 且 Object.prototype.toString.call(obj) === '[object Blob]'。瀏覽器端 WebSocket 收到的 binary 預設是 Blob,所以是 Blob 就當 binary,否則是 JSON 或字串。

Object.prototype.toString.call(obj) === '[object Blob]' 這行寫法,口述「grab the toString of Object prototype and call with the context」很難還原
17:20 · Object.prototype.toString.call(obj) === '[object Blob]' 這行寫法,口述「grab the toString of Object prototype and call with the context」很難還原
承上 承接上一段「要先分辨收到的是 ping 還是一般訊息」:作者寫 isBinary(obj) 這個小工具。

推理因為上一段確定 server 的 ping 是 binary、一般訊息是文字,所以 client 只要判斷「這個 message.data 是不是 binary」。作者的技巧是:typeof obj === 'object' 且 Object.prototype.toString.call(obj) === '[object Blob]'——抓 Object.prototype 上的 toString,用 obj 當 this 去呼叫,等於對它做一次最原始的 toString。瀏覽器 WebSocket 收到的 binary 預設是 Blob,所以是 Blob 就當 binary,否則就是 JSON 或字串。

AI 補充為什麼要繞到 Object.prototype.toString 而不是直接 obj instanceof Blob?兩者在這裡都可以;作者的寫法是一個通用技巧,優點是不受物件自己覆寫 toString 影響、也能跨 iframe/realm 使用(instanceof 在跨 realm 時會失敗)。它回傳的是內建的「[object 型別標籤]」字串,所以能分辨 Blob、ArrayBuffer、Date 等等。要注意的前提:瀏覽器 WebSocket 的 binaryType 預設是 'blob';如果專案把 ws.binaryType 設成 'arraybuffer',這裡就得改比對 '[object ArrayBuffer]'。另外 typeof 檢查嚴格說是多餘的(Blob 一定是 object),但先擋掉 string 再做 toString 比對是合理的防禦。截圖(1040 秒)確認函式簽名是 isBinary(obj: any) 且比對字串是 '[object Blob]',中間有空格、B 大寫。

Blob

二進位大物件

瀏覽器用來裝一段不可變二進位資料的物件;WebSocket 收到 binary 訊框時預設就包成 Blob 交給你。

Blob 本身不能直接用索引讀位元組,要透過 blob.arrayBuffer() 或 FileReader 轉成 ArrayBuffer 才能看內容。這也是為什麼 client 端只判斷「是不是 Blob」而不像 server 那樣檢查 data[0]——要讀值得多一步非同步轉換,對 ping 來說沒必要。WebSocket 的 binaryType 屬性可以改成 'arraybuffer',收到的就會變成 ArrayBuffer。

相關術語: binary frame (瀏覽器端的收法)、Uint8Array (送出用它、收到是 Blob)

出處:第 10 段「isBinary:用 toString 判斷 Blob」

Object.prototype.toString.call

用原始 toString 取型別標籤

借用 Object.prototype 上未被覆寫的 toString,以目標物件為 this 呼叫,得到 '[object Xxx]' 形式的內建型別名稱。

每個物件都可能覆寫自己的 toString(例如 Date 回傳日期字串),但 Object.prototype.toString 一定回傳 '[object 標籤]',標籤來自內建的 Symbol.toStringTag 或物件類別。用 .call(obj) 是因為要指定 this。這是 lodash 等函式庫判斷型別的經典手法,在 typeof 只回 'object' 時特別有用。

相關術語: Blob (用來辨識)

出處:第 10 段「isBinary:用 toString 判斷 Blob」

留給下一段 工具齊了:heartbeat() 負責計時與回 pong,isBinary() 負責分流。但它們都還沒被接到任何事件上——onmessage 收到 ping 時要呼叫 heartbeat(),onclose 時該把 timeout 清掉。下一段把零件接起來。

11. 接上 onclose / onmessage 17:36–18:42

把工具接進事件:ws.onclose 裡若有 pingTimeout 就 clearTimeout,不再需要;ws.onmessage 裡 if (isBinary(message.data)) heartbeat() 否則照原本顯示訊息。heartbeat() 會回 pong 給 server,讓連線保持活著。client 端實作到此完成。

onclose 清 timeout 與 onmessage 的 isBinary → heartbeat 分流,是 client 端所有零件接起來的最終畫面
18:40 · onclose 清 timeout 與 onmessage 的 isBinary → heartbeat 分流,是 client 端所有零件接起來的最終畫面
承上 承接上一段「heartbeat() 與 isBinary() 還沒接到事件上」:作者回到建立 WebSocket 的地方把兩個事件補完。

推理因為上一段已經有了分流工具,所以接線很直接:在 'close' 事件裡若有 ws.pingTimeout 就 clearTimeout——連線都關了,倒數不再需要;在 'message' 事件裡 if (isBinary(msg.data)) heartbeat() 否則照原本 showMessage。heartbeat() 會重設倒數並回 pong 給 server,讓 server 那邊的 isAlive 續命。client 端實作到此完成。

AI 補充為什麼 close 時一定要清 timeout?因為如果連線是使用者自己關的(或 server 踢的),沒清掉的 timeout 6 秒後還是會響,對一個已關閉的 ws 再呼叫 close() 雖然不會拋錯,但重連的業務邏輯會被錯誤觸發。截圖(1120 秒)顯示的是最終接線:close 的 clearTimeout 放在 showMessage('WebSocket connection closed') 之後,message 的參數標成 MessageEvent<string>。這裡有一個作者沒處理的邊界:heartbeat() 只在收到第一個 ping 之後才會被呼叫,所以連線剛建立到第一個 ping 之間(最多一個 interval)client 沒有任何倒數在跑——如果 server 在這段時間就死了,client 永遠不會逾時。比較完整的做法是在 'open' 事件裡也呼叫一次 heartbeat(),讓看門狗從連線成功那一刻就開始計時。

MessageEvent

訊息事件物件

瀏覽器 WebSocket 'message' 事件傳給處理器的物件,實際內容在它的 data 屬性。

和 Node.js ws 套件直接給 (data, isBinary) 不同,瀏覽器給的是事件物件,資料要從 msg.data 取。data 的型別依訊框而定:text 訊框是 string,binary 訊框是 Blob 或 ArrayBuffer(看 binaryType)。作者標成 MessageEvent<string> 是為了 showMessage 那個分支方便,但 isBinary 分支收到的其實是 Blob,所以 isBinary 的參數才用 any。

相關術語: Blob (data 可能是)、Buffer (server 端對應物)

出處:第 11 段「接上 onclose / onmessage」

留給下一段 server 與 client 兩邊的程式都寫完了,但還沒真的跑過:多開幾條連線時 pong 數會不會對?關掉分頁後 server 會不會少算一個?下一段實測。

12. 實測多連線與 server close 清理 18:43–21:59

npm run dev 沒錯誤,開三個 localhost:3000 分頁。terminal 看到 firing interval 與 pong。中途發現漏了一件事:wss.on('close') 要 clearInterval(interval),不然 server 關了 interval 還一直跑。重啟後逐一開連線:一條連線一個 pong,三條就三個 pong;互相送訊息正常;關掉一個分頁後 pong 數減一,全關後 firing interval 但沒 pong。若真有掉線會自動 close,可再接重連邏輯。

terminal 顯示 firing interval 後跟著三個 pong,直接驗證「pong 數 = 活著的連線數」
20:40 · terminal 顯示 firing interval 後跟著三個 pong,直接驗證「pong 數 = 活著的連線數」
承上 承接上一段「兩邊都寫完但還沒跑過」:作者 npm run dev,開三個 localhost:3000 分頁驗證。

推理因為上一段完成了整個閉環,所以驗證的方式是看 terminal:每次 firing interval 後面跟幾個 pong。一開始先開一條連線,看到 firing interval 與一個 pong。中途他發現漏了一件事:wss.on('close') 要 clearInterval(interval),不然 server 關了 interval 還一直跑,補上後重啟。接著逐一開連線:一條一個 pong、兩條兩個、三條三個;互相送 test / test 2 都收得到;關掉一個分頁後 pong 減為兩個、再關剩一個、全關後只剩 firing interval 沒有 pong。結論:heartbeat 運作正常,若真有掉線會自動 close,可再接重連。

AI 補充「pong 數 = 活著的連線數」是一個很好的驗證指標,因為它同時證明三件事:server 的 ping 有送到每條連線、client 的 isBinary 分流正確、client 回的 pong 通過 server 的 data[0] 檢查。截圖(1240 秒)就是這個瞬間:firing interval 後跟著三個 pong,上方程式碼可以看到剛補的 wss.on('close') 區塊。關於 clearInterval:作者補這步是因為 setInterval(見第 3 段)不會自己停,WebSocketServer 關閉後 wss.clients 是空的,interval 每輪空轉不會出錯,但會讓 Node.js 程序不肯結束、也算一種洩漏。要注意這個實測只驗證了「正常關閉」的路徑——關分頁時瀏覽器會送 close 訊框,server 是靠 'close' 事件移除 client,不是靠 heartbeat 踢掉。真正的死連線(拔網路線)在影片裡沒有示範,要驗證它得讓 client 不回 pong,例如在 DevTools 把網路切成 offline 後等兩個 interval。

clearInterval

停止 setInterval

傳入 setInterval 回傳的 id,讓那個重複計時器不再觸發。

setInterval 會讓 Node.js 事件迴圈保持活著,程序不會自然結束。所以任何 setInterval 都該有對應的 clearInterval,放在資源生命週期結束的地方——這裡是 WebSocketServer 的 'close' 事件。這也是為什麼作者把 interval 存成 const interval:不存 id 就沒辦法取消。

相關術語: setInterval (取消)、clearTimeout (對照組)

出處:第 12 段「實測多連線與 server close 清理」

留給下一段 機制驗證通過,最後回到一開始的取捨:示範用的 5 秒在正式環境該怎麼調、這個 overhead 到底值不值得?

13. 總結:間隔調大,開銷小但必要 22:00–23:03

作者收尾:範本設 5 秒,實際用 10、20 或 30 秒看用途;heartbeat 不太會拖累 server,overhead 很小,而且是必要的——另一個選項是因為爛客服系統永遠失去一個客戶。預告之後還有更多 WebSocket 影片會繼續強化這個 server 範本。

承上 承接上一段留下的「5 秒在正式環境該怎麼調、overhead 值不值得」:作者收尾時把這兩個問題再答一次。

推理因為上一段已經證明機制能動,所以作者把注意力放回參數與取捨:範本設 5 秒是為了示範時看得到,實際用 10、20 或 30 秒看用途;heartbeat 每輪只是對每條連線送一個位元組,不太會拖累 server;而且它是必要的——另一個選項是回到第 1 段那個因為爛客服系統永遠失去一個客戶的情境。他預告之後還有更多 WebSocket 影片會繼續強化這個 server 範本。

AI 補充把整支影片的參數關係整理一下,方便自己調整:server 的 HEARTBEAT_INTERVAL 決定「多久發現死連線」(最壞兩個間隔),client 的 HEARTBEAT_TIMEOUT 必須大於 HEARTBEAT_INTERVAL 加上網路最差延遲,兩個常數目前分別寫在 server 與 client 檔案裡、沒有共用來源,改一邊要記得改另一邊。如果要進一步強化,常見的下一步有三個:連線 'open' 時就啟動 client 的看門狗(第 11 段提到的邊界)、把 server 的 ping 改成真正的位元組 Buffer.from([1])(第 3 段的小坑)、以及在 timeout 的重連註解處實作帶退避(exponential backoff)的自動重連。

留給下一段 總結收束

4. 總結

作者先用客服系統說明死連線的代價,確立解法是定期 ping/pong。重構後在 server 定義 HEARTBEAT_INTERVAL 與 ping 函式(binary 送出),給每條連線掛 isAlive 並用 module augmentation 讓 TypeScript 接受;setInterval 每輪「不活就 terminate、否則設 false 再 ping」,收到 binary 且值相符的 pong 就設回 true,形成閉環。接著換到瀏覽器端:heartbeat() 每次收到 ping 就 clearTimeout 再重設一個比 server 間隔多 1 秒緩衝的 setTimeout,逾時就 close;用 Uint8Array 回 pong,用 Object.prototype.toString 判斷 Blob 來分流 ping 與一般訊息,接上 onmessage / onclose。實測三個分頁看到 pong 數隨連線數增減,補上 wss close 時 clearInterval,最後建議正式環境把間隔拉到 10–30 秒。

勘誤總整理

確定錯誤/已過時見仁見智(取決於版本或情境)

段落原話(transcript 逐字)說明
3. 設計 heartbeat:間隔、值、ping 函式
5:09
「we're basically sending a one down and」ws 套件的 send() 收到數字時會先 data.toString(),所以實際送出的 binary 內容是字元 '1'(0x31),不是數值 1(0x01)。影片裡 client 端只判斷是否為 Blob、不比對值,所以機制照常運作;但「送一個 1 下去、期待收到一個 1 回來」這句在位元組層面不成立,client 若真的檢查 data[0] === 1 會失敗。
依據: ws v8.x websocket.js send():`if (typeof data === 'number') data = data.toString();`
8. clearTimeout 與 WebSocketExt 型別
14:42
「actually see it is a node.js.timeout」這段是跑在瀏覽器的前端程式,瀏覽器的 setTimeout 回傳 number;編輯器顯示 NodeJS.Timeout 是因為前端 tsconfig 同時載入了 @types/node,Node 的宣告蓋過 DOM 的。照抄 NodeJS.Timeout 在這個專案能過,但換到不含 @types/node 的前端專案會編譯失敗;可攜的寫法是 ReturnType<typeof setTimeout>。
依據: TypeScript lib.dom.d.ts:setTimeout(): number;@types/node globals.d.ts:setTimeout(): NodeJS.Timeout

5. 推薦三個下一步

1. 往下挖深:WebSocket 協定內建的 ping/pong 控制訊框

影片用的是應用層自訂的 ping/pong;ws 套件其實有 ws.ping() / 'pong' 事件走協定層控制訊框,不會混進 message,也不用自己判斷 binary。比較兩者可以理解為什麼瀏覽器端沒有 API 能發協定層 ping。

YouTube 搜尋:ws library ping pong heartbeat nodejs WebSocket ping pong control frame RFC 6455 ws isAlive terminate example

2. 往旁邊對照:Socket.IO 內建的 heartbeat 與自動重連

影片最後留了「重連邏輯自己寫」的空位;Socket.IO 把 pingInterval / pingTimeout 與 exponential backoff 重連都做好了,對照它的設計能看出手寫版少了哪些邊界處理(例如 open 時就啟動看門狗)。

YouTube 搜尋:Socket.IO pingInterval pingTimeout explained websocket reconnect exponential backoff javascript socket.io vs ws heartbeat

3. 往上應用:正式環境的 WebSocket 連線管理與擴展

heartbeat 解決單一 server 上的死連線;上線後還要面對 load balancer 的 idle timeout、多台 server 之間的連線狀態、以及間隔秒數對數萬連線的成本,這些才決定 10 還是 30 秒。

YouTube 搜尋:scale websockets production load balancer idle timeout websocket connection management at scale nginx websocket proxy_read_timeout heartbeat

📄 全部影片 · 主題區: 網路與 API 基礎