誌 開發日誌 做了什麼 · 試過什麼 · 否決了什麼 · 留下了什麼
已落地 雜記 2026-07+2026-09

兩種不用伺服器的連法

同一年做的兩個東西都拒絕把資料放到雲上,也都用 WebRTC。可是把連線架構攤開並排,形狀幾乎沒有一處相同。

AbstractTwo projects from the same year, both refusing to put data in the cloud, both on WebRTC. Laid side by side, almost nothing about their architectures matches.

黑白江湖跟柴米帳是同一年做的,一個是連線對戰的遊戲,一個是兩個人的記帳本。它們共用一條原則,能不用伺服器就不用,也共用同一種傳輸,WebRTC 的資料通道。

前陣子我把兩邊的連線架構攤開來擺在一起看,想確認一件事:這兩個「不用伺服器」,是不是同一件事。

結論是不是。它們共用結論,不共用理由,也不共用做法。

黑白江湖是怎麼連的

Cloudflare Worker · packages/signal 跟前端同一次 wrangler deploy 出去。靜態資產免費走,只有 /api/* 進 Worker。 DuelRoomDO 每房一個 · TTL 10 分 BoardDO 單例 · 公開榜 5 秒輪詢 ChatDO 單例 · 零 storage eslint 禁掉 fetch / window / document,也禁掉所有第三方 import。「只做撮合」是編譯期擋的。 offer / answer 客人坐下才現生 SDP ICE 等蒐集完才送一整包 STUN 一台,沒有 TURN 房主 權威 · 中繼 客人 客人 客人 客人 落子 轉發(跳過來源) hash 心跳 2 – 9 席 對局流量。房主收下、驗證、套用,再轉發給除了來源以外的每一條線。 備援線。開局 +4 秒才懶建,平常不走資料。sig 由房主轉發,他不解析 SDP。 繼位:房主掉線之後 房主掉線 凍結 25 秒 全網零寫入 選最小席次 純推導 · 零協商 rejoin → resume 整份日誌重播 接著打 回家兩條路:備援線 0.9–1.7 秒,或團聚房約 8 秒。團聚房用 reunionId 當房號,只走加密通道傳,位址本身就是憑證。
權威可以搬家,可是任何一瞬間只有一個。這是 mesh 被否決之後留下來的那句話。房主居中不是為了省事,是因為 AI 席的權威必須唯一,而且 NAT 失敗率在星型是線性、在 mesh 是平方。

我自己開了一台訊令的 Worker,上面掛三個 Durable Object。房間一個、公開榜一個、客棧廣播一個。

它只做撮合,不碰對局內容。這句話如果只是寫在文件裡,那就只是一句話,所以我在 eslint 設定裡把那個套件的 fetch、window、document 全部禁掉,也禁掉所有非 Cloudflare 的 import。要讓它偷偷連去哪裡,得先改設定檔,那件事會出現在 diff 裡。

連上之後就跟伺服器沒關係了。三到九個人是星型,房主當權威,收到落子、驗證、套用,再轉發給除了來源以外的每一條線。

最麻煩的是房主會掉線。我的解法是繼位,凍結窗先撐住,剩下的人裡選席次最小的接手。選誰這件事完全從日誌推導,每個人各自算,答案一定一樣,所以整個過程不用交換任何一則協商訊息。

沒連上的話就是失敗。沒有中繼的退路,只有一句文案叫你們連同一個 WiFi。

柴米帳是怎麼連的

Nostr 公共 relay ×5 47 個裡依 appId 洗牌取 5 別人的機器 · 我零基礎設施 只過加密 SDP 約 2KB 帳本不走這裡 手機 A PWA · 預快取整包 離線就能記 沒有帳號 · 沒有登入 手機 B PWA · 預快取整包 離線就能記 沒有帳號 · 沒有登入 IndexedDB 永久 · 刪除是墓碑 IndexedDB 永久 · 刪除是墓碑 WebRTC DataChannel 500 列一批,嚴格序列化送 併發會撞背壓逾時,訊息被靜默截斷 ICE 一樣是 Google STUN,一樣沒有 TURN 配對:面對面掃一次 QR 6 碼房號就是憑證 一次同步的全部內容 hello batch ×N done ack bye checkpoint 取兩邊 HLC 的較小值,而且是在 hello 當下取。同步期間寫的東西下次再送,合併本來就是冪等的。 done 會帶總列數。收到的列數不夠就不 ack,checkpoint 也不存,那一批下次重來。 合併是 HLC 加逐列後寫贏。定寬編碼所以字典序就是全序,墓碑不特別對待。 連完就散。房間離開,各自回到離線,下次見面再掃一次。
難的地方不在連上,在收斂。傳輸那層只有一支外殼檔案,協定是一個純函式的狀態機,真正吃掉大部分程式碼的是時鐘、合併,還有怎麼確定沒有靜默掉列。

這邊我一台伺服器都沒開。

訊令是去借 Nostr 的公共 relay,四十七個裡面依 appId 洗牌取五個。那些是別人的機器,所以 SDP 是加密過的,relay 看得到的只有 IP、連線時間、房間主題的雜湊,還有大概兩 KB 的密文。帳本本身從來不經過那裡。

配對是面對面掃一次 QR。那個 QR 刻意不是網址,因為 iOS 的系統相機看到網址會給一個「用 Safari 開啟」,按下去會把人丟進另一個儲存分區、空的帳本,然後把整本帳靜默倒進一個用完就丟的殼。

連上之後兩邊是對等的,沒有誰說了算。合併靠 HLC 加上逐列後寫贏,兩支手機各自算,結果會收斂到同一份。

攤開來比

分歧點:要不要為了介紹兩個人,而開一台自己的伺服器 自己開,然後自律 Worker + 3 個 DO eslint 在編譯期禁掉 fetch 去借,然後加密 Nostr 公共 relay ×5 AES-GCM,relay 看不到內容 黑白江湖 線上對戰 柴米帳 兩個人的記帳本 傳輸 手刻 RTCPeerConnection Trystero 包過的 STUN / TURN Google · 沒有 TURN Google · 沒有 TURN 拓樸 星型,2 到 9 人 一對一,兩支手機 誰是權威 房主,一瞬間只有一個 沒有,兩邊對等 狀態模型 鎖步重播動作日誌 HLC 加逐列後寫贏 對不上怎麼辦 終局,不編造勝負 收斂,本來就會對上 憑證是什麼 房號;繼位時是位址 掃出來的房號 連多久 一整局,全程掛著 幾秒,說完就散 資料留在哪 局散了就沒了 IndexedDB,永久 難的地方 傳輸 收斂 出錯的代價 這局重來 帳被靜默改壞 同一套 Service Worker,相反的決定 永不快取 bundle 預快取整包 線上遊戲重載拿新版比離線重要, 記帳的核心價值就是離線。
最底下那條是我當時寫在柴米帳 vite.config.ts 註解裡的原話,那時候順手把兩個決定並排寫了下來,現在看就是這整篇的縮影。

三件事

第一件,兩邊都用同一招解決「你是誰」。

沒有帳號、沒有登入。身分就是持有一個秘密位址。黑白江湖繼位用的 reunionId 是六十四位元,只走加密的資料通道傳出去;柴米帳的六碼房號是面對面掃出來的。兩邊的文件都明講「知道房號的人就能連」不算漏洞,那個房號就是憑證本身。

這兩個專案我是分開做的,最後各自走到同一個答案。

第二件,失敗都是靜默的。

沒有伺服器就沒有人回你一個五百。錯誤不會有堆疊、不會進日誌、不會有告警,只會顯示成「找不到對方」,或者更糟,什麼都不顯示。

所以兩邊的力氣都花在同一件事上。柴米帳那邊有列數計數器,收到的不夠就不 ack;寫入失敗就不存 checkpoint;還有一道空帳本的閘門。黑白江湖那邊有每手一次的 hash 心跳、pagehide 的幽靈認輸修正,還有把 bye 延後一個 macrotask 讓它逃得出去。

這是無伺服器真正的稅,不是省下來的那點機器錢。

第三件,分歧其實不在技術,在你願意信任誰。

遊戲那邊我自己開了一台,然後用編譯期的規則把自己綁住。想讓那個 Worker 連去別的地方,得先改 eslint 設定,而那件事會留在 diff 裡。

帳本那邊我不開,去借別人的公共設施,然後假設那些人不可信,所以先加密。代價是那條借來的路會因為別人的版本更新而斷掉,症狀只顯示成一句「找不到對方」。

一個是自己蓋、自己立規矩,一個是借用、可是假設對方不懷好意。這兩種都叫無伺服器,可是它們對「誰不可信」的答案不一樣。