部落格
一個依賴:僅用 ws 套件建構生產級伺服器
我們的 Node.js 伺服器只有一個執行時期依賴。一個。這裡解釋我們為什麼做出這個選擇,以及它對可靠性、安全性和維護意味著什麼。
繼續查看該欄目下的產品與工程更新。
瀏覽此分類打開 Tactic Remote 伺服器的 package.json。在 dependencies 下,你只會看到一個條目:
就這些。一個套件。ws WebSocket 函式庫。其他一切——HTTP 伺服器、認證、速率限制、終端管理、檔案瀏覽、輸入驗證、程序執行——都基於 Node.js 內建模組。
這不是偶然的極簡主義。這是一個經過深思熟慮的工程決策,在可靠性、安全性以及使用我們軟體的使用者體驗方面都有實際的影響。
為什麼這很重要
這個伺服器執行在你的 Mac 上,管理你的 Claude Code 工作階段。它可以存取你的終端、檔案系統和正在執行的程序。你新增的每一個依賴都是:
- 一個攻擊面。 每個 npm 套件都是以你的權限執行的程式碼,由你不認識的人撰寫,帶有你未稽核過的傳遞性依賴。
- 一個維護負擔。 依賴需要更新。更新可能引入破壞性變更或漏洞。依賴越多,你花在
npm audit fix上的時間就越多,而不是在建構功能。 - 一個可靠性風險。 當依賴出問題(leftpad、colors、node-ipc),你的伺服器就會出問題。你使用者的開發工作流程就會中斷。
對於一個介於人類和 AI 之間、在使用者機器上撰寫程式碼的伺服器來說,依賴被攻破的代價是嚴重的。我們決定外部依賴的正確數量應該盡可能接近零。
不用依賴我們建構了什麼
HTTP 伺服器
Node.js 自帶 http.createServer()。它不是 Express,但我們只需要提供兩個 HTTP 端點:
兩個路由。沒有中介軟體鏈,沒有請求本體解析器,沒有路由函式庫。請求處理器是一個簡單的函式,用 === 檢查 URL 路徑,用 req.on('data') 讀取請求本體,然後呼叫對應的處理函式。
這是「可擴展」的嗎?不是。需要嗎?也不需要。這個伺服器只處理來自一台 iPhone 的一個連線。用路由函式庫純粹是工程表演。
認證
認證系統支援兩種請求標頭格式來驗證 API Key:
Sec-WebSocket-Protocol 回退的存在是因為 iOS URLSessionWebSocketTask 無法在升級請求中設定自訂標頭。這是一個有文件記錄的 iOS 限制——你可以設定協定標頭,但不能設定自訂標頭。與其引入一個繞過此限制的 WebSocket 函式庫,我們直接將協定標頭用作認證通道。
認證在連線時檢查一次。如果失敗,WebSocket 以代碼 1008(策略違規)關閉。沒有工作階段權杖,沒有重新整理流程,沒有 JWT 函式庫。一個靜態 API Key,用 === 比較。
速率限制
一個記憶體中的滑動視窗,40 行程式碼:
每個 IP 每分鐘 30 次失敗認證嘗試。超過後,伺服器回傳 429 Too Many Requests 並帶有 retryAfter 欄位。視窗過期後,計數器重設。
基於 Redis 的速率限制器會更複雜精細。但它也會新增一個 Redis 依賴。對於只處理一個用戶端的伺服器來說,一個記憶體中的 Map 加上定期清理就足夠了。
輸入驗證
每個使用者輸入在接觸系統之前都會被驗證。以下是完整的驗證面:
| 輸入 | 驗證 | 原因 |
|---|---|---|
| 工作階段名稱 | /^[a-zA-Z0-9_-]{1,64}$/ | 可能包含 shell 元字元的 tmux 工作階段名稱 |
| Claude 指令 | /^claude(\s+--?[a-zA-Z0-9-]+)*$/ | 只允許 claude 加可選旗標——無管線、重新導向、鏈式指令 |
| Tmux 按鍵名稱 | 18 個鍵的明確白名單 | 防止任意按鍵注入 |
| 檔案路徑 | path.resolve() + startsWith() | 防止目錄走訪 |
| 單個字元 | /^[a-zA-Z0-9!@#$%^&*()...]$/ | 只允許可列印 ASCII 字元用於輸入 |
沒有驗證函式庫。正規表示式和字串比較,行內且可讀。
程序執行
這是安全關鍵部分。伺服器代你執行 tmux 指令。如果這一層有漏洞,攻擊者就能在你的機器上執行任意指令。
我們的方案:child_process.spawn() 搭配 shell: false,封裝在一個所有外部指令都必須經過的單一函式中:
shell: false 意味著參數直接傳遞給程序,不經過 /bin/sh。沒有字串內插,沒有範本字面值展開,沒有 shell 注入的機會。如果你將 ["send-keys", "-t", sessionName, "-l", content] 作為 args 陣列傳遞,那就是 tmux 接收到的 argv。分號、管線、反引號、$()——它們都沒有特殊含義。
每個 tmux 操作都是對這個函式的薄封裝:
沒有 tmux 封裝函式庫。沒有終端工作階段的 ORM。只有 spawn 搭配明確的參數陣列。
路徑走訪保護
檔案瀏覽限制在主目錄樹內。敏感目錄始終被阻止:
逐段比對。不是對路徑字串使用 includes()——那樣會對名為 my-.ssh-backup 的目錄產生誤判。檢查會尋找與敏感目錄模式精確匹配的連續路徑段。
所有路徑在檢查之前都透過 path.resolve() 解析為絕對路徑。這可以消除 .. 走訪、符號連結攻擊和相對路徑技巧。
WebSocket 協定
ws 套件為我們提供了 WebSocket 伺服器。在此基礎上,我們建構了一個雙協定系統,同時處理我們最初的扁平訊息格式和較新的結構化格式。
為什麼有兩種協定
我們最初發佈的是扁平協定:
它能運作,但沒有請求關聯。如果用戶端連續發送兩個請求,回應會作為獨立的訊息到達,用戶端無法判斷哪個回應對應哪個請求。
新協定新增了請求 ID:
我們不能透過移除扁平格式來破壞現有用戶端。所以伺服器同時處理兩種格式:
iOS 應用程式的 WebSocketClient 在用戶端做了同樣的事情——先嘗試將收到的訊息解碼為新的 Frame 協定,如果失敗則回退到舊版 ServerMessage。
心跳
行動端 WebSocket 連線是脆弱的。手機會在 WiFi 和行動網路之間切換,進入睡眠狀態,進入訊號死角。我們需要快速偵測斷線連線,但不能中斷只是暫時中斷的連線。
45 秒間隔,2 次未回應容忍。一個用戶端最多可以在 90 秒內無回應才會被終止。這是為行動端調校的——典型的 WebSocket 心跳間隔是 15-30 秒,但行動用戶端在切換基地台或重新關聯 WiFi 時經常經歷 30-60 秒的網路切換。
我們同時發送 WebSocket 層級的 ping 框架(ws 函式庫自動處理)和應用層級的 {"type": "ping"} 訊息。應用層級的 ping 是為那些無法直接存取底層 WebSocket 框架的用戶端準備的(這是 iOS URLSessionWebSocketTask 的另一個限制)。
我們沒有的東西
坦誠地說說這個架構缺少什麼:
沒有叢集。 伺服器是單程序、單機器的。它無法水平擴展。但也不需要——它只服務一台 Mac 上的一個使用者。
沒有持久化。 如果伺服器重新啟動,速率限制器狀態和用戶端註冊表會遺失。tmux 工作階段能存活(它們是獨立程序),但伺服器的記憶體狀態不會。對我們的場景來說沒問題——用戶端會在幾秒內重新連線並重建狀態。
沒有優雅關閉協調。 伺服器停止時,WebSocket 連線是直接終止的,而不是排空的。同樣,對於單一使用者伺服器來說沒問題——用戶端會處理重新連線。
沒有請求日誌或 APM。 我們輸出到標準輸出。如果你需要更多,可以管線到檔案。沒有結構化日誌函式庫,沒有 OpenTelemetry 整合,沒有日誌聚合。
這些都是對多租戶雲端服務有意義的功能。對於執行在你桌上、服務你手機的伺服器來說,它們沒有意義。
維護現實
自上線以來,我們的依賴維護工作是:
- 當有安全修補時更新
ws - 沒了
沒有來自傳遞性依賴的 npm audit 警告。沒有我們不直接使用的函式庫的 Dependabot PR。沒有中介軟體框架主要版本升級帶來的破壞性變更。
當 ws 函式庫有漏洞時(自我們開始以來發生過兩次),我們更新一個套件。變更很小,風險可控,我們可以在五分鐘內稽核完差異。
對比一下典型的 Express + passport + helmet + compression + cors + body-parser 技術堆疊。每一個都帶來自己的依賴樹。一次 npm audit 可能標記數十個傳遞性漏洞,其中大部分是理論上的,但每個都需要調查。
這種方式什麼時候不適用
需要說清楚:這不是一個通用建議。單一依賴伺服器適用於以下場景:
- 你服務的是單個使用者或少量已知用戶端
- 你的 HTTP 表面很小(1-5 個路由)
- 你不需要資料庫存取、範本引擎或複雜路由
- 安全性更適合透過簡單性而非層疊函式庫來實現
- 你的團隊能夠熟練閱讀 Node.js 內建 API
如果你在建構一個有使用者帳號、OAuth、資料庫模型和 REST API 的 SaaS,請使用框架。Express 或 Fastify 搭配合適的 ORM 會為你節省數月的工作。「單一依賴」方案適用於依賴成本超過實作成本的系統。
對於 Tactic Remote 的伺服器——一個單一使用者、單一用途、在 tmux 和 iPhone 之間的 WebSocket 中繼——一個依賴就是正確的數量。
了解這個伺服器如何融入整體架構,參閱從手機遠端執行 Claude Code。完整的 WebSocket 協定參考,參閱快速入門指南。