From 2b26e522e99ea8f912589ecf26a952c4bf7b0e32 Mon Sep 17 00:00:00 2001 From: Yueyu Hoshizora Date: Sat, 10 Oct 2026 20:50:55 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat(client):=20localize=20server=20error?= =?UTF-8?q?=20codes=20to=20=E7=B9=81=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/client/README.md | 2 ++ packages/client/src/index.ts | 17 ++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/client/README.md b/packages/client/README.md index b8fdee4..44515a5 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -22,6 +22,8 @@ 每人一個 PeerConnection,觀眾 recvonly;上台才索取麥克風(mono、48 kHz)、轉 sendrecv。權限失敗仍可收聽;請下台、修正權限後再上台。`getStats()` 回傳 inbound/outbound 的 codec、clockRate、channels、bitrateKbps、packetsLost、jitter、rtt、lossPercent,inbound 另有 `bufferMs`(收到的音訊在瀏覽器 jitter buffer 等待播放的平均毫秒數,網路抖動越大越高);位元率、inbound 的 lossPercent 與 bufferMs 從兩次呼叫間的差量計算(首次沒有),outbound 的 lossPercent 為伺服器最近一次回報的掉包比例;jitter/rtt 單位秒,瀏覽器未提供的欄位保持 undefined。觀眾可定期呼叫它顯示自己的連線狀態。 +伺服器錯誤一律是固定的英文 generic 訊息;client 依 `code` 換成繁中(匯出的 `ERROR_MESSAGES`),所以 `error` 事件與被拒絕的 Promise 的 `StageError.message` 可直接顯示。要換其他語言就依 `error.code` 自行對照。 + ## 音訊裝置與麥克風測試 - `listAudioDevices(): Promise`:回傳 `{ inputs: MediaDeviceInfo[], outputs: MediaDeviceInfo[] }`,授權前裝置名稱可能為空。取得權限後會觸發 `micready`(detail 為 undefined),可重新整理清單;裝置插拔可監聽 `navigator.mediaDevices` 的 `devicechange`。 diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 4d8dcee..04dc5d1 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -15,6 +15,21 @@ export class StageError extends Error { this.code = code; } } +/** 繁中 text for server error codes; the server only sends fixed generic English (AGENTS.md §S8), so clients localize by `code`. */ +export const ERROR_MESSAGES: Record = { + bad_request: '請求格式錯誤。', + unknown_type: '不支援的操作。', + not_joined: '尚未加入房間。', + already_joined: '已在房間中。', + unauthorized: '無法加入房間,請確認房間 ID 與代碼。', + forbidden: '沒有權限執行此操作。', + not_found: '找不到對象。', + conflict: '目前狀態無法執行此操作。', + room_full: '房間已滿。', + stage_full: '舞台已滿。', + rate_limited: '操作過於頻繁,請稍後再試。', + internal: '伺服器發生錯誤。', +}; export interface StageClientOptions { url: string; reconnect?: { initialDelayMs: number; maxDelayMs: number }; @@ -429,7 +444,7 @@ export class StageClient extends EventTarget { switch (message.type) { case 'ok': this.finish(message.requestId); break; case 'error': { - const error = new StageError(message.code, message.message); + const error = new StageError(message.code, ERROR_MESSAGES[message.code] ?? message.message); if (message.requestId) this.finish(message.requestId, error); this.emit('error', error); break; From d410e8be0f25a4c60674b9bd5953151e3d30817d Mon Sep 17 00:00:00 2001 From: Yueyu Hoshizora Date: Sat, 10 Oct 2026 20:51:24 +0800 Subject: [PATCH 2/8] feat(config): rtc.mediaWorkers auto sizes media workers by CPU count --- README.md | 2 +- config.example.yaml | 2 +- src/config.ts | 11 ++++++++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ab982e1..b62b724 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Windows 用 `.\stage.ps1 `,指令相同;Windows 無法對背景 nod | `limits` | `maxRooms`、`maxConnections`、`maxConnectionsPerIp`(同一位址同時連線,0=不限;NAT 或反向代理後多人共用位址,設定前先估算,代理後須開 `server.trustProxy`)、`joinTimeoutMs`(連上後未開房/進房的逾時,0=不限)、`maxSpeakersPerRoom`(8,主控以外的台上者;主控席位另計)、`maxAudiencePerRoom`(300)、`maxFrameBytes`(64KB)、`controlPerSecond`(20)、`handRaiseIntervalMs`(10000)、`icePerSecond`(30)、`nameMaxLength`(32)、`codeMaxLength`(16)、`sdpMaxLength` | | `rooms` | `codeLength`(8)、`controllerGraceMs`(主控斷線寬限 60000)、`participantGraceMs`(其他人斷線寬限 15000;期間保留席位、台位、舉手順位與 PeerConnection,音訊不中斷;0=立即移除)、`heartbeatIntervalMs`、`presenceBroadcastMs`(觀眾進出合併 `room:state` 廣播的時間窗,250;進場者本人仍立即收到自己的 snapshot)、`qualityIntervalMs`(台上者連線品質回報間隔,2000;0=停用)、`createToken`(非空時 `room:create` 須帶相同 `token`,否則 `unauthorized`;空字串=任何人可開房,對外部署建議設定) | | `audio` | `sampleRate`(48000;只接受 Opus 原生取樣率)、`frameMs`(20)、`codecWorkers`(Opus 編解碼 worker 數,房間平均分配到各 worker)、`opus.{vbr,minBitrate,maxBitrate,bitrate,complexity}`、`opus.fec`/`opus.packetLossPercent`(下行 in-band FEC 與預期掉包率)、`opus.dtx`(靜音不送包)、`lowTier.{enabled,bitrate,packetLossPercent,enterLossPercent,exitLossPercent}`(RTCP 接收報告顯示持續掉包的觀眾改收第二路共用混音:較低位元率+較多 FEC,掉包回落後切回)、`mixer.{maxBufferedFrames,limiterThreshold}`、`mixer.latencyTargetMs`(僅供 `bench-mixer`/`load-test` 當驗收門檻)、`mixer.speakingThreshold`/`speakingHoldMs`(說話指示的 RMS 門檻與釋放延遲)、`jitter.playoutFrames`(每路上行預緩衝幀數)、`jitter.reorderPackets`(亂序容忍包數,超過即判定遺失並補幀)、`noiseFilter.{enabled,highPassHz,gateThreshold,gateHoldMs,gateFloor}`(伺服器端上行降噪:高通濾掉低頻雜音+噪音門壓低說話間隙的背景音;瀏覽器端另開 `noiseSuppression`)、`loudness.{enabled,targetRms,maxGainDb,speechRms,adaptMs}`(伺服器端每路音量正規化:依說話時的平均音量把各發言者拉到相近大小,增益上限 ±maxGainDb) | -| `rtc` | `iceServers`(下發給瀏覽器的靜態 STUN)、`serverIceServers`(伺服器端 ICE)、`portRange`(`[]` 或 `[min, max]`)、`mediaWorkers`(承載 PeerConnection 的 worker thread 數,預設 2 對應 300 聽眾;0=主執行緒)、`turn.{urls,secret,ttlSeconds}`(coturn `use-auth-secret` 短期憑證,每次進房以 HMAC 簽發;`urls: []` 停用) | +| `rtc` | `iceServers`(下發給瀏覽器的靜態 STUN)、`serverIceServers`(伺服器端 ICE)、`portRange`(`[]` 或 `[min, max]`)、`mediaWorkers`(承載 PeerConnection 的 worker thread 數,預設 2 對應 300 聽眾;0=主執行緒;`auto`=CPU 核心數 − 1 − `audio.codecWorkers`,限制在 2–8)、`turn.{urls,secret,ttlSeconds}`(coturn `use-auth-secret` 短期憑證,每次進房以 HMAC 簽發;`urls: []` 停用) | | `log` | `level`:`debug`/`info`/`warn`/`error`(房間代碼、token、憑證、SDP 一律不入日誌) | 對外部署:`allowInsecure: false`、`host: 0.0.0.0`、填 `tls`;開放 `rtc.portRange` 的 UDP;設定 `rtc.turn`(coturn 需 `use-auth-secret` 與相同的 `static-auth-secret`)。憑證檔更新(含 ACME 工具的改名/symlink 替換)後約 2 秒自動重載,不需重啟;新憑證載入失敗時沿用舊憑證並記 error。 diff --git a/config.example.yaml b/config.example.yaml index caa8426..cf8ecc0 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -105,7 +105,7 @@ rtc: # 0=全在主執行緒:werift 每送一包約 70µs CPU,單一執行緒約在 250–300 位聽眾時吃滿 20ms tick, # 大批進場時的 DTLS 握手也會卡住混音時鐘;1 條 worker 一樣會被 300 位聽眾吃滿而延誤上行轉送。 # 預設 2 對應每房 300 聽眾的裁示上限(與 audio.codecWorkers 合計勿超過核心數;核心少時調低), - # 用 load-test 驗證。 + # 用 load-test 驗證。auto=核心數 − 1 − codecWorkers,至少 2、至多 8。 mediaWorkers: 2 # 短期 TURN 憑證(coturn:use-auth-secret + static-auth-secret)。每次進房以 HMAC 簽發, # 帳密逾 ttlSeconds 失效;secret 只放 config.yaml,不入庫、不入日誌。urls 空陣列=停用。 diff --git a/src/config.ts b/src/config.ts index ac127ec..d4ac88b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,5 @@ import { readFileSync } from 'node:fs'; +import { availableParallelism } from 'node:os'; import { parse } from 'yaml'; import type { IceServerConfig } from '../shared/protocol.ts'; @@ -167,6 +168,14 @@ function iceServers(v: unknown, path: string): IceServerConfig[] { }); } +/** + * `rtc.mediaWorkers: auto`: cores left after the main thread and the codec workers, never below the + * 2 that the 300-listener cap needs (load-test), capped at 8 to bound idle threads on large hosts. + */ +export function autoMediaWorkers(codecWorkers: number, cores = availableParallelism()): number { + return Math.min(8, Math.max(2, cores - 1 - codecWorkers)); +} + const LOOPBACK: Record = { '127.0.0.1': true, '::1': true, localhost: true }; export function parseConfig(raw: unknown): AppConfig { @@ -327,7 +336,7 @@ export function parseConfig(raw: unknown): AppConfig { iceServers: iceServers(t.iceServers, 'rtc.iceServers'), serverIceServers: iceServers(t.serverIceServers, 'rtc.serverIceServers'), portRange, - mediaWorkers: int(t, 'mediaWorkers', 'rtc', 0, 64), + mediaWorkers: t.mediaWorkers === 'auto' ? autoMediaWorkers(audio.codecWorkers) : int(t, 'mediaWorkers', 'rtc', 0, 64), turn, }; From 01ab9d8f5d95ffba9ef01e165ddbac5b7582b57c Mon Sep 17 00:00:00 2001 From: Yueyu Hoshizora Date: Sat, 10 Oct 2026 20:55:29 +0800 Subject: [PATCH 3/8] feat: room text chat and emoji reactions --- PLAN.md | 3 +- README.md | 13 +++++--- config.example.yaml | 4 +++ packages/client/README.md | 2 ++ packages/client/src/element.ts | 11 ++++--- packages/client/src/index.ts | 43 +++++++++++++++++++++++--- shared/protocol.ts | 29 ++++++++++++++++++ src/config.ts | 12 ++++++++ src/ws/hub.ts | 31 +++++++++++++++++-- src/ws/rateLimit.ts | 31 +++++++++++++------ src/ws/validate.ts | 27 +++++++++++++++- test/hub.test.ts | 33 ++++++++++++++++++++ test/server.test.ts | 21 ++++++++++++- web/src/main.ts | 56 ++++++++++++++++++++++++++++++++-- web/src/styles.css | 13 ++++++++ 15 files changed, 299 insertions(+), 30 deletions(-) diff --git a/PLAN.md b/PLAN.md index 6c38dad..cac55cf 100644 --- a/PLAN.md +++ b/PLAN.md @@ -34,7 +34,7 @@ - 交付必要文件四件(README/AGENTS/CLAUDE/PLAN)後才收尾實作。 ### 範圍外 -- 影片、螢幕分享、文字聊天室。 +- 影片、螢幕分享。(文字聊天與表情反應已於 2026-10-10 依需求加入,見十二-22。) - 錄音存檔與回放、直播轉推(RTMP/HLS)。 - 帳號系統與持久化(使用者、舞台、操作紀錄均記憶體保存,重啟即失)。 - 套件發布(npm):只開源倉庫供 clone,不發套件(見假設 6)。 @@ -319,6 +319,7 @@ N 路混音管線(decode→mix→encode)、不含自己的台上混音、lim 19. **連線准入**:`limits.joinTimeoutMs` 內未開房/進房的連線以 1008 關閉;`limits.maxConnectionsPerIp` 限制同一位址同時連線(反向代理後 `server.trustProxy` 改讀 `X-Forwarded-For`)。 20. **程序層錯誤**:`unhandledRejection` 只記 error 不中止(werift 關閉 peer 後的計時器可能漏 rejection,Node 預設會讓整個 process 連同所有房間一起死);`uncaughtException` 走優雅停機(`room:closed {reason:'shutdown'}`)後以非零碼退出。TLS 憑證檔變更後約 2 秒自動 `setSecureContext`,失敗則沿用舊憑證。 21. **Web Component**:`/lib/stage-element.js` 註冊 ``(shadow DOM 小工具:狀態、錯誤、啟用音訊、舉手、台上靜音/下台),進入文件即進房、移除即以 1000 離開;client 事件以 `stage-` 派發,`element.client` 暴露底層 `StageClient` 供主控功能;它從穩定檔名 `stage-client.js` 匯入,嵌入頁同時使用兩者時只載入一份。 +22. **文字聊天與表情反應**(2026-10-10 依需求加入):C→S `chat:send {text}`、`reaction {emoji}`;S→C `chat {messageId, participantId, name, text, sentAt}`、`reaction {participantId, name, emoji}`、`chat:history {messages}`(進房與回座時補送最近 `rooms.chatHistory` 則,記憶體保存)。聊天內容比照 name 清洗(保留換行、最多連續兩個空行)後 HTML 轉義,長度 ≤ `limits.chatMaxLength`;emoji 限 `REACTIONS` 白名單。每連線聊天每 `limits.chatIntervalMs`、反應每 `limits.reactionIntervalMs` 至多一次,超過比照 `hand:raise` 以 1008 斷線,兩值經 `hello.limits` 下發供 client 自行節流。聊天內容不入日誌。伺服器錯誤訊息維持固定英文,client 依 `code` 換成繁中(`ERROR_MESSAGES`)。 --- diff --git a/README.md b/README.md index b62b724..fe231eb 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,8 @@ Windows 用 `.\stage.ps1 `,指令相同;Windows 無法對背景 nod | 區塊 | 重點欄位 | |---|---| | `server` | `host`、`port`、`wsPath`;`allowInsecure`(明文 ws,僅限 loopback host);`tls.certFile`/`keyFile`;`static` 靜態掛載(`/` → `web/dist`,`/lib/` → `packages/client/dist` 附 CORS);`metrics.{enabled,token}`(`GET /metrics`,對外 host 須設 token);`trustProxy`(反向代理後以 `X-Forwarded-For` 計算每 IP 連線) | -| `limits` | `maxRooms`、`maxConnections`、`maxConnectionsPerIp`(同一位址同時連線,0=不限;NAT 或反向代理後多人共用位址,設定前先估算,代理後須開 `server.trustProxy`)、`joinTimeoutMs`(連上後未開房/進房的逾時,0=不限)、`maxSpeakersPerRoom`(8,主控以外的台上者;主控席位另計)、`maxAudiencePerRoom`(300)、`maxFrameBytes`(64KB)、`controlPerSecond`(20)、`handRaiseIntervalMs`(10000)、`icePerSecond`(30)、`nameMaxLength`(32)、`codeMaxLength`(16)、`sdpMaxLength` | -| `rooms` | `codeLength`(8)、`controllerGraceMs`(主控斷線寬限 60000)、`participantGraceMs`(其他人斷線寬限 15000;期間保留席位、台位、舉手順位與 PeerConnection,音訊不中斷;0=立即移除)、`heartbeatIntervalMs`、`presenceBroadcastMs`(觀眾進出合併 `room:state` 廣播的時間窗,250;進場者本人仍立即收到自己的 snapshot)、`qualityIntervalMs`(台上者連線品質回報間隔,2000;0=停用)、`createToken`(非空時 `room:create` 須帶相同 `token`,否則 `unauthorized`;空字串=任何人可開房,對外部署建議設定) | +| `limits` | `maxRooms`、`maxConnections`、`maxConnectionsPerIp`(同一位址同時連線,0=不限;NAT 或反向代理後多人共用位址,設定前先估算,代理後須開 `server.trustProxy`)、`joinTimeoutMs`(連上後未開房/進房的逾時,0=不限)、`maxSpeakersPerRoom`(8,主控以外的台上者;主控席位另計)、`maxAudiencePerRoom`(300)、`maxFrameBytes`(64KB)、`controlPerSecond`(20)、`handRaiseIntervalMs`(10000)、`icePerSecond`(30)、`nameMaxLength`(32)、`codeMaxLength`(16)、`sdpMaxLength`、`chatMaxLength`(500)、`chatIntervalMs`(1000)、`reactionIntervalMs`(250) | +| `rooms` | `codeLength`(8)、`controllerGraceMs`(主控斷線寬限 60000)、`participantGraceMs`(其他人斷線寬限 15000;期間保留席位、台位、舉手順位與 PeerConnection,音訊不中斷;0=立即移除)、`heartbeatIntervalMs`、`presenceBroadcastMs`(觀眾進出合併 `room:state` 廣播的時間窗,250;進場者本人仍立即收到自己的 snapshot)、`qualityIntervalMs`(台上者連線品質回報間隔,2000;0=停用)、`chatHistory`(每房保留並補送給新進房者的聊天則數,50;0=不保留)、`createToken`(非空時 `room:create` 須帶相同 `token`,否則 `unauthorized`;空字串=任何人可開房,對外部署建議設定) | | `audio` | `sampleRate`(48000;只接受 Opus 原生取樣率)、`frameMs`(20)、`codecWorkers`(Opus 編解碼 worker 數,房間平均分配到各 worker)、`opus.{vbr,minBitrate,maxBitrate,bitrate,complexity}`、`opus.fec`/`opus.packetLossPercent`(下行 in-band FEC 與預期掉包率)、`opus.dtx`(靜音不送包)、`lowTier.{enabled,bitrate,packetLossPercent,enterLossPercent,exitLossPercent}`(RTCP 接收報告顯示持續掉包的觀眾改收第二路共用混音:較低位元率+較多 FEC,掉包回落後切回)、`mixer.{maxBufferedFrames,limiterThreshold}`、`mixer.latencyTargetMs`(僅供 `bench-mixer`/`load-test` 當驗收門檻)、`mixer.speakingThreshold`/`speakingHoldMs`(說話指示的 RMS 門檻與釋放延遲)、`jitter.playoutFrames`(每路上行預緩衝幀數)、`jitter.reorderPackets`(亂序容忍包數,超過即判定遺失並補幀)、`noiseFilter.{enabled,highPassHz,gateThreshold,gateHoldMs,gateFloor}`(伺服器端上行降噪:高通濾掉低頻雜音+噪音門壓低說話間隙的背景音;瀏覽器端另開 `noiseSuppression`)、`loudness.{enabled,targetRms,maxGainDb,speechRms,adaptMs}`(伺服器端每路音量正規化:依說話時的平均音量把各發言者拉到相近大小,增益上限 ±maxGainDb) | | `rtc` | `iceServers`(下發給瀏覽器的靜態 STUN)、`serverIceServers`(伺服器端 ICE)、`portRange`(`[]` 或 `[min, max]`)、`mediaWorkers`(承載 PeerConnection 的 worker thread 數,預設 2 對應 300 聽眾;0=主執行緒;`auto`=CPU 核心數 − 1 − `audio.codecWorkers`,限制在 2–8)、`turn.{urls,secret,ttlSeconds}`(coturn `use-auth-secret` 短期憑證,每次進房以 HMAC 簽發;`urls: []` 停用) | | `log` | `level`:`debug`/`info`/`warn`/`error`(房間代碼、token、憑證、SDP 一律不入日誌) | @@ -101,23 +101,28 @@ JSON frame,型別定義在 [`shared/protocol.ts`](shared/protocol.ts)。每個 | `participant:kick {targetId}` | 主控把人踢出房間(對方收到 `kicked` 後連線被關閉、不會自動重連);要防止對方再進來,接著更換代碼 | | `mic:gain {targetId, gainDb}` | 主控調整某人在混音中的音量(±20 dB,0=不調整;疊加在自動音量正規化之上),離開舞台後再上台仍保留;所有人在 `room:state` 的 `gainDb` 看到 | | `room:rotate-code` | 主控更換房間代碼(限需代碼的房間);舊代碼/邀請連結立即失效,已在房內的人不受影響 | +| `chat:send {text}` | 聊天(任何已進房者;換行保留、控制字元移除後 HTML 轉義,≤ `limits.chatMaxLength` 字元) | +| `reaction {emoji}` | 表情反應,只接受 `REACTIONS`(👏 ❤️ 😂 😮 👍 🎉) | | `rtc:offer`/`rtc:ice {payload}` | WebRTC 信令(一律由 client 發 offer) | | `ping` | 回 `pong` | | Server → Client | 說明 | |---|---| -| `hello` | `{protocol, serverVersion, limits: {controlPerSecond, icePerSecond, handRaiseIntervalMs}}`:client 依 `limits` 自行節流與舉手冷卻 | +| `hello` | `{protocol, serverVersion, limits: {controlPerSecond, icePerSecond, handRaiseIntervalMs, chatIntervalMs, reactionIntervalMs}}`:client 依 `limits` 自行節流與舉手冷卻 | | `room:state` | 個人化 snapshot(每次變動重送);每人帶 `connected`(ws 斷線、席位保留中為 false);`code`、`audience` 名單只給主控,`resumeToken` 只給本人 | | `room:created`、`room:closed` | 開房(只有建立者收到 code)、關房(全員離房);伺服器停止時 `room:closed` 帶 `reason: 'shutdown'`,連線隨後以 close code 1001 關閉 | | `kicked` | `{roomId}`:你被主控踢出,伺服器隨即以 close code 4001 關閉連線 | | `rtc:config` | 瀏覽器用的 ICE servers(設定 `rtc.turn` 時含該參與者專屬的短期 TURN 憑證) | | `speaking` | `{participantIds}`:目前在混音中有聲的參與者(VAD+釋放延遲),集合變動時才送 | | `quality` | `{participants: [{participantId, uplinkLossPercent?, downlinkLossPercent?, rttMs?}]}`:每 `qualityIntervalMs` 回報所有上行者的連線品質(上行掉包為該區間伺服器實際遺失比例,下行掉包取自對方 RTCP 接收報告),只送給主控與台上者 | +| `chat` | `{messageId, participantId, name, text, sentAt}`:廣播給全房 | +| `chat:history` | `{messages}`:進房(含斷線回座)時補送最近 `rooms.chatHistory` 則;沒有訊息時不送 | +| `reaction` | `{participantId, name, emoji}`:廣播給全房,不保留 | | `role:update`、`hand:raise`/`hand:withdraw`、`stage:invite`、`stage:joined`、`stage:left`、`control:transferred`、`mic:muted`/`mic:unmuted` | 事件廣播 | | `rtc:answer`、`rtc:ice` | 伺服器端信令 | | `status` | `waiting`/`live` | -限制:frame ≤ 64KB;控制訊息 ≤ 20/s、`rtc:ice` ≤ 30/s;超過即以 close code 1008 斷線。房間不存在或代碼錯誤一律回 `unauthorized`。名稱以 HTML 轉義形式傳送。client 以 close code 1000 關閉=主動離開(立即移出房間);其他關閉碼或斷線=保留席位至寬限期結束(主控一律保留 `controllerGraceMs`)。 +限制:frame ≤ 64KB;控制訊息 ≤ 20/s、`rtc:ice` ≤ 30/s;聊天每 `chatIntervalMs`(1 秒)、反應每 `reactionIntervalMs`(250 ms)最多 1 次;超過即以 close code 1008 斷線。房間不存在或代碼錯誤一律回 `unauthorized`。名稱與聊天內容以 HTML 轉義形式傳送。client 以 close code 1000 關閉=主動離開(立即移出房間);其他關閉碼或斷線=保留席位至寬限期結束(主控一律保留 `controllerGraceMs`)。 ## 整合到其他網站(client 函式庫) diff --git a/config.example.yaml b/config.example.yaml index cf8ecc0..bdfcc38 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -41,6 +41,9 @@ limits: sdpMaxLength: 16384 joinTimeoutMs: 30000 # 連上 ws 後逾此時間仍未開房/進房即以 1008 斷線(0=不限;可調整) maxConnectionsPerIp: 0 # 同一位址同時連線上限(0=不限)。學校/公司 NAT 或未設 server.trustProxy 的反向代理會讓大量使用者共用一個位址,設定前請估算(可調整) + chatMaxLength: 500 # 聊天訊息字元上限(清洗後;可調整) + chatIntervalMs: 1000 # 每連線兩則聊天訊息的最短間隔;過快以 1008 斷線(client 依 hello.limits 自行節流;可調整) + reactionIntervalMs: 250 # 每連線兩次表情反應的最短間隔(同上;可調整) rooms: codeLength: 8 # 房間代碼長度(可調整) @@ -49,6 +52,7 @@ rooms: heartbeatIntervalMs: 30000 # ws ping 間隔 presenceBroadcastMs: 250 # 觀眾進出時合併 room:state 廣播的時間窗(0=即時;大量同時進場時避免 N² 廣播,可調整) qualityIntervalMs: 2000 # 每隔多久把台上者的連線品質(上下行掉包、RTT)送給主控與台上者(0=停用,可調整) + chatHistory: 50 # 每房保留的最近聊天訊息數,新進房者會收到(0=不保留;可調整) createToken: "" # 非空時建立房間須附此權杖(防止匿名占滿 maxRooms);只放 config.yaml,對外部署建議設定 audio: diff --git a/packages/client/README.md b/packages/client/README.md index 44515a5..d29ec03 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -18,6 +18,8 @@ `createRoom({name, roomName?, codeRequired?, token?})` 回傳 `{roomId, code?}`;伺服器設定 `rooms.createToken` 時須傳入 `token`。加入後用 `raiseHand` / `withdrawHand`,在台用 `mute` / `unmute` / `leaveStage`;主控用 `approve(id)`、`reject(id)`、`returnToStage()`、`transferControl(id)`、`forceMute(id)`、`forceUnmute(id)`、`setGain(id, gainDb)`(調整此人在混音中的音量,±`MAX_GAIN_DB` dB,0 還原;目前值見 `ParticipantView.gainDb`)、`removeFromStage(id)`、`kick(id)`(踢出房間)、`rotateCode()`(更換房間代碼,新代碼見主控的 `state.code`)、`closeRoom()`。`disconnect()` 停止自動重連。請求在伺服器 `ok` 後完成,失敗拋出有 `code` 的 `StageError`;逾時為 15 秒(另加本機排程等待)。舉手間隔依伺服器設定(`rateLimits.handRaiseIntervalMs`,預設 10 秒),`handCooldownMs` 可供倒數 UI 使用。 +聊天與反應(任何已進房者):`sendChat(text)`、`react(emoji)`(`emoji` 取自匯出的 `REACTIONS`)。兩者都依伺服器下發的 `rateLimits.chatIntervalMs`/`reactionIntervalMs` 在本機先擋,過快時拒絕並拋出 `StageError('rate_limited')`,不會送出而被伺服器斷線。`chatMessages` 為目前保留的訊息(含進房補送的歷史,最多 200 則);事件 `chat`(單則 `{messageId, participantId, name, text, sentAt}`)、`chathistory`(`{messages}`,進房或回座時整批取代)、`reaction`(`{participantId, name, emoji}`)。`name`、`text` 同樣先 `decodeName()` 再以 `textContent` 顯示。 + `state`、`me`、`status`、`speaking`(目前說話中的 participantId 集合)為即時 getters。`on(type, listener)` 回傳取消訂閱函式;事件也可透過 EventTarget 的 `addEventListener` 使用。事件:`state`、`status`、`created`、`closed`(`{roomId, reason?}`;`reason: 'shutdown'` 表示伺服器停止或重啟,而非主控關房)、`kicked`(被主控踢出;已斷線且不會自動重連)、`hand`、`invite`、`stagejoined`、`stageleft`、`transferred`、`mic`、`role`、`speaking`(說話中集合變動)、`quality`(主控與台上者每隔數秒收到所有上行者的 `{participantId, uplinkLossPercent?, downlinkLossPercent?, rttMs?}`)、`error`、`micerror`、`micready`、`audioblocked`。狀態為 waiting/live/reconnecting/disconnected。名稱先用 `decodeName()` 還原伺服器的五種 HTML entities,再以 `textContent` 顯示,勿使用 innerHTML。 每人一個 PeerConnection,觀眾 recvonly;上台才索取麥克風(mono、48 kHz)、轉 sendrecv。權限失敗仍可收聽;請下台、修正權限後再上台。`getStats()` 回傳 inbound/outbound 的 codec、clockRate、channels、bitrateKbps、packetsLost、jitter、rtt、lossPercent,inbound 另有 `bufferMs`(收到的音訊在瀏覽器 jitter buffer 等待播放的平均毫秒數,網路抖動越大越高);位元率、inbound 的 lossPercent 與 bufferMs 從兩次呼叫間的差量計算(首次沒有),outbound 的 lossPercent 為伺服器最近一次回報的掉包比例;jitter/rtt 單位秒,瀏覽器未提供的欄位保持 undefined。觀眾可定期呼叫它顯示自己的連線狀態。 diff --git a/packages/client/src/element.ts b/packages/client/src/element.ts index 26d521d..687a053 100644 --- a/packages/client/src/element.ts +++ b/packages/client/src/element.ts @@ -1,10 +1,11 @@ import { StageClient, decodeName, type StageEventMap } from './index.ts'; -/** Every StageClient event, re-dispatched from the element as `stage-`. */ -const EVENTS: (keyof StageEventMap)[] = [ - 'state', 'status', 'created', 'closed', 'kicked', 'hand', 'invite', 'stagejoined', 'stageleft', - 'transferred', 'mic', 'role', 'error', 'micerror', 'micready', 'audioblocked', 'speaking', 'quality', -]; +/** Every StageClient event, re-dispatched from the element as `stage-`; a Record so a new event cannot be missed. */ +const EVENTS = Object.keys({ + state: true, status: true, created: true, closed: true, kicked: true, hand: true, invite: true, stagejoined: true, stageleft: true, + transferred: true, mic: true, role: true, error: true, micerror: true, micready: true, audioblocked: true, speaking: true, quality: true, + chat: true, chathistory: true, reaction: true, +} satisfies Record) as (keyof StageEventMap)[]; const STATUS_LABELS = { waiting: '等待發言', live: '直播中', reconnecting: '重新連線中', disconnected: '未連線' }; const STYLE = ` diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 04dc5d1..b49562f 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,6 +1,6 @@ -import type { ClientMessageMap, ClientMessageType, ErrorCode, IceCandidatePayload, ParticipantView, RateLimits, RoomStatePayload, ServerMessage, ServerMessageMap } from '../../../shared/protocol.ts'; -export type { ParticipantView, RoomStatePayload, Role, StageStatus, ErrorCode, IceServerConfig, ConnectionQuality } from '../../../shared/protocol.ts'; -export { MAX_GAIN_DB } from '../../../shared/protocol.ts'; +import type { ChatMessage, ClientMessageMap, ClientMessageType, ErrorCode, IceCandidatePayload, ParticipantView, RateLimits, Reaction, RoomStatePayload, ServerMessage, ServerMessageMap } from '../../../shared/protocol.ts'; +export type { ParticipantView, RoomStatePayload, Role, StageStatus, ErrorCode, IceServerConfig, ConnectionQuality, ChatMessage, Reaction } from '../../../shared/protocol.ts'; +export { MAX_GAIN_DB, REACTIONS } from '../../../shared/protocol.ts'; export function decodeName(value: string): string { const entities: Record = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" }; @@ -114,12 +114,19 @@ export interface StageEventMap { speaking: { participantIds: string[] }; /** Connection quality of everyone publishing (controller and on-stage participants only). */ quality: ServerMessageMap['quality']; + /** One new chat message (text and name are HTML-escaped: render with textContent after `decodeName`). */ + chat: ChatMessage; + /** Recent chat replayed on join or rejoin; replaces `chatMessages`. */ + chathistory: { messages: ChatMessage[] }; + reaction: ServerMessageMap['reaction']; } interface Pending { resolve: () => void; reject: (error: StageError) => void; timer: ReturnType } type JoinOptions = { roomId: string; code?: string; name: string; resumeToken?: string }; const REQUEST_TIMEOUT = 15000; /** Until the server's `hello` says otherwise: the config.example.yaml defaults. */ -const DEFAULT_LIMITS: RateLimits = { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 }; +const DEFAULT_LIMITS: RateLimits = { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 }; +/** Client-side cap on kept chat messages; the server's own history is usually shorter. */ +const MAX_CHAT_MESSAGES = 200; const KEEPALIVE_INTERVAL_MS = 15000; /** Two missed keepalive rounds. */ const KEEPALIVE_TIMEOUT_MS = 2 * KEEPALIVE_INTERVAL_MS + 5000; @@ -159,6 +166,9 @@ export class StageClient extends EventTarget { private answerTimer: ReturnType | undefined; private limits: RateLimits = DEFAULT_LIMITS; private lastHandAt = -Infinity; + private lastChatAt = -Infinity; + private lastReactionAt = -Infinity; + private chatLog: ChatMessage[] = []; private nextControlAt = 0; private nextIceAt = 0; /** Latest scheduled send of any kind: the server processes frames in arrival order, so ICE must never overtake its offer. */ @@ -191,6 +201,8 @@ export class StageClient extends EventTarget { get me(): ParticipantView | null { return this.snapshot?.me ?? null; } /** Participants currently audible in the mix (server voice activity). */ get speaking(): ReadonlySet { return this.speakingIds; } + /** Chat messages since joining (plus replayed history), oldest first. */ + get chatMessages(): readonly ChatMessage[] { return this.chatLog; } get handCooldownMs(): number { return Math.max(0, this.limits.handRaiseIntervalMs - (Date.now() - this.lastHandAt)); } /** Limits the connected server enforces per connection (from `hello`; defaults before connecting). */ get rateLimits(): RateLimits { return this.limits; } @@ -356,6 +368,18 @@ export class StageClient extends EventTarget { this.lastHandAt = Date.now(); return this.request('hand:raise', {}); } + /** Send a chat message (≤ `limits.chatMaxLength` characters on the server; paced by `rateLimits.chatIntervalMs`). */ + sendChat(text: string): Promise { + if (Date.now() - this.lastChatAt < this.limits.chatIntervalMs) return Promise.reject(new StageError('rate_limited', '訊息傳送過快,請稍候。')); + this.lastChatAt = Date.now(); + return this.request('chat:send', { text }); + } + /** Send an emoji reaction from `REACTIONS`; paced by `rateLimits.reactionIntervalMs`. */ + react(emoji: Reaction): Promise { + if (Date.now() - this.lastReactionAt < this.limits.reactionIntervalMs) return Promise.reject(new StageError('rate_limited', '反應送出過快,請稍候。')); + this.lastReactionAt = Date.now(); + return this.request('reaction', { emoji }); + } withdrawHand(): Promise { return this.request('hand:withdraw', {}); } approve(id: string): Promise { return this.request('stage:approve', { targetId: id }); } reject(id: string): Promise { return this.request('stage:reject', { targetId: id }); } @@ -387,6 +411,7 @@ export class StageClient extends EventTarget { this.session = null; this.snapshot = null; this.speakingIds = new Set(); + this.chatLog = []; this.stopKeepalive(); this.socket?.close(1000); this.socket = null; @@ -487,6 +512,7 @@ export class StageClient extends EventTarget { this.session = null; this.snapshot = null; this.speakingIds = new Set(); + this.chatLog = []; clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; this.destroyMedia(); @@ -504,6 +530,15 @@ export class StageClient extends EventTarget { case 'role:update': this.emit('role', { participantId: message.participantId, role: message.role, reason: message.reason }); break; case 'speaking': this.speakingIds = new Set(message.participantIds); this.emit('speaking', { participantIds: message.participantIds }); break; case 'quality': this.emit('quality', { participants: message.participants }); break; + case 'chat': { + const { type: _type, ...chat } = message; + this.chatLog.push(chat); + if (this.chatLog.length > MAX_CHAT_MESSAGES) this.chatLog.shift(); + this.emit('chat', chat); + break; + } + case 'chat:history': this.chatLog = message.messages.slice(-MAX_CHAT_MESSAGES); this.emit('chathistory', { messages: this.chatLog }); break; + case 'reaction': this.emit('reaction', { participantId: message.participantId, name: message.name, emoji: message.emoji }); break; } } private scheduleReconnect(): void { diff --git a/shared/protocol.ts b/shared/protocol.ts index 795f79f..ea3b4f6 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -30,6 +30,10 @@ * - C→S `mic:gain {targetId, gainDb}` (controller): manual level trim for one participant's * voice in the mix, on top of automatic loudness normalization; kept while they stay in the * room and shown to everyone as `ParticipantView.gainDb`. + * - C→S `chat:send {text}` / S→C `chat` (ChatMessage): room text chat for everyone joined; text is + * sanitized and HTML-escaped like names. The last `rooms.chatHistory` messages arrive as + * `chat:history` right after joining. + * - C→S `reaction {emoji}` / S→C `reaction {participantId, emoji}`: fire-and-forget emoji from REACTIONS. */ export const PROTOCOL_VERSION = 1; @@ -40,6 +44,22 @@ export const SERVER_PEER_ID = 'server'; /** `mic:gain` accepts gains within ±MAX_GAIN_DB. */ export const MAX_GAIN_DB = 20; +/** The only emoji `reaction` accepts. */ +export const REACTIONS = ['👏', '❤️', '😂', '😮', '👍', '🎉'] as const; +export type Reaction = (typeof REACTIONS)[number]; + +export interface ChatMessage { + /** Increases per room; unique within the room. */ + messageId: number; + participantId: string; + /** Sender name at send time, HTML-escaped. */ + name: string; + /** HTML-escaped. */ + text: string; + /** ms since epoch */ + sentAt: number; +} + export type Role = 'controller' | 'speaker' | 'audience'; export type StageStatus = 'waiting' | 'live' | 'reconnecting'; @@ -117,6 +137,8 @@ export interface RateLimits { controlPerSecond: number; icePerSecond: number; handRaiseIntervalMs: number; + chatIntervalMs: number; + reactionIntervalMs: number; } export type StageLeftReason = 'leave' | 'removed'; @@ -162,6 +184,9 @@ export interface ServerMessageMap { pong: Record; kicked: { roomId: string }; quality: { participants: ConnectionQuality[] }; + chat: ChatMessage; + 'chat:history': { messages: ChatMessage[] }; + reaction: { participantId: string; name: string; emoji: Reaction }; } export type ServerMessageType = keyof ServerMessageMap; @@ -193,6 +218,8 @@ export interface ClientMessageMap { 'rtc:offer': { requestId: string; payload: SessionDescriptionPayload }; 'rtc:answer': { requestId: string; payload: SessionDescriptionPayload }; 'rtc:ice': { requestId: string; payload: IceCandidatePayload | null }; + 'chat:send': { requestId: string; text: string }; + reaction: { requestId: string; emoji: Reaction }; ping: Record; } @@ -223,5 +250,7 @@ export const CLIENT_MESSAGE_TYPES: readonly ClientMessageType[] = [ 'rtc:offer', 'rtc:answer', 'rtc:ice', + 'chat:send', + 'reaction', 'ping', ]; diff --git a/src/config.ts b/src/config.ts index d4ac88b..f7bdbfb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -38,6 +38,12 @@ export interface AppConfig { joinTimeoutMs: number; /** Concurrent ws connections per client address (0 = unlimited). */ maxConnectionsPerIp: number; + /** Longest chat message in characters after sanitizing. */ + chatMaxLength: number; + /** Minimum gap between one connection's chat messages (violation closes with 1008, like hand:raise). */ + chatIntervalMs: number; + /** Minimum gap between one connection's reactions. */ + reactionIntervalMs: number; }; rooms: { codeLength: number; @@ -49,6 +55,8 @@ export interface AppConfig { presenceBroadcastMs: number; /** How often publishers' connection quality goes to the controller and stage (0 = never). */ qualityIntervalMs: number; + /** Chat messages kept per room and replayed to joiners (0 = no history). */ + chatHistory: number; /** Non-empty: `room:create` must carry this token (empty = anyone may create rooms). */ createToken: string; }; @@ -228,6 +236,9 @@ export function parseConfig(raw: unknown): AppConfig { sdpMaxLength: int(l, 'sdpMaxLength', 'limits', 256), joinTimeoutMs: int(l, 'joinTimeoutMs', 'limits', 0), maxConnectionsPerIp: int(l, 'maxConnectionsPerIp', 'limits', 0), + chatMaxLength: int(l, 'chatMaxLength', 'limits', 1, 2000), + chatIntervalMs: int(l, 'chatIntervalMs', 'limits', 0), + reactionIntervalMs: int(l, 'reactionIntervalMs', 'limits', 0), }; const r = obj(root.rooms, 'rooms'); @@ -238,6 +249,7 @@ export function parseConfig(raw: unknown): AppConfig { heartbeatIntervalMs: int(r, 'heartbeatIntervalMs', 'rooms', 1000), presenceBroadcastMs: int(r, 'presenceBroadcastMs', 'rooms', 0, 10000), qualityIntervalMs: int(r, 'qualityIntervalMs', 'rooms', 0, 60000), + chatHistory: int(r, 'chatHistory', 'rooms', 0, 1000), createToken: str(r, 'createToken', 'rooms'), }; if (rooms.codeLength > limits.codeMaxLength) throw new ConfigError('rooms.codeLength: exceeds limits.codeMaxLength'); diff --git a/src/ws/hub.ts b/src/ws/hub.ts index 69a22a3..427bb66 100644 --- a/src/ws/hub.ts +++ b/src/ws/hub.ts @@ -2,6 +2,7 @@ import { randomBytes, randomInt, timingSafeEqual } from 'node:crypto'; import { PROTOCOL_VERSION, SERVER_PEER_ID, + type ChatMessage, type ClientMessage, type ConnectionQuality, type IceCandidatePayload, @@ -70,6 +71,9 @@ interface RoomRuntime { qualityTimer: unknown; /** Uplink counters at the previous report, so each report covers one interval. */ readonly uplinkCounts: Map; + /** Last `rooms.chatHistory` messages, oldest first. */ + readonly chat: ChatMessage[]; + nextMessageId: number; lastStatus: 'waiting' | 'live'; closed: boolean; } @@ -120,8 +124,13 @@ export class StageHub { } attach(session: Session): void { - const { controlPerSecond, icePerSecond, handRaiseIntervalMs } = this.deps.config.limits; - session.send({ type: 'hello', protocol: PROTOCOL_VERSION, serverVersion: this.deps.serverVersion, limits: { controlPerSecond, icePerSecond, handRaiseIntervalMs } }); + const { controlPerSecond, icePerSecond, handRaiseIntervalMs, chatIntervalMs, reactionIntervalMs } = this.deps.config.limits; + session.send({ + type: 'hello', + protocol: PROTOCOL_VERSION, + serverVersion: this.deps.serverVersion, + limits: { controlPerSecond, icePerSecond, handRaiseIntervalMs, chatIntervalMs, reactionIntervalMs }, + }); } /** Whether the session has created or joined a room (and not been evicted since). */ @@ -228,6 +237,8 @@ export class StageHub { stateTimer: undefined, qualityTimer: undefined, uplinkCounts: new Map(), + chat: [], + nextMessageId: 1, lastStatus: room.status, closed: false, }; @@ -283,6 +294,7 @@ export class StageHub { session.send({ type: 'rtc:config', iceServers: iceServersFor(this.deps.config.rtc, participantId, this.now()) }); session.send({ type: 'room:state', ...room.snapshot(participantId) }); + if (rt.chat.length) session.send({ type: 'chat:history', messages: rt.chat }); this.presenceChanged(rt); session.send({ type: 'ok', requestId: msg.requestId }); } @@ -349,6 +361,21 @@ export class StageHub { session.send({ type: 'ok', requestId: msg.requestId }); this.deps.log.info('room code rotated', { roomId: room.roomId }); return; + case 'chat:send': { + const message: ChatMessage = { messageId: rt.nextMessageId++, participantId: pid, name: room.get(pid)!.name, text: msg.text, sentAt: now }; + const keep = this.deps.config.rooms.chatHistory; + if (keep > 0) { + rt.chat.push(message); + if (rt.chat.length > keep) rt.chat.shift(); + } + this.broadcast(rt, { type: 'chat', ...message }); + session.send({ type: 'ok', requestId: msg.requestId }); + return; + } + case 'reaction': + this.broadcast(rt, { type: 'reaction', participantId: pid, name: room.get(pid)!.name, emoji: msg.emoji }); + session.send({ type: 'ok', requestId: msg.requestId }); + return; case 'rtc:answer': // The server never sends offers, so an answer is always out of protocol. throw new StageError('bad_request', 'unexpected rtc:answer'); diff --git a/src/ws/rateLimit.ts b/src/ws/rateLimit.ts index 748a80c..3ee6fcd 100644 --- a/src/ws/rateLimit.ts +++ b/src/ws/rateLimit.ts @@ -20,30 +20,43 @@ class TokenBucket { } } +/** At most one event per `intervalMs`. */ +class Interval { + private readonly intervalMs: number; + private last = Number.NEGATIVE_INFINITY; + constructor(intervalMs: number) { + this.intervalMs = intervalMs; + } + take(now: number): boolean { + if (now - this.last < this.intervalMs) return false; + this.last = now; + return true; + } +} + /** * Per-connection limits (AGENTS.md §S4): control ≤ N/s, `rtc:ice` ≤ M/s, - * `hand:raise` at most once per interval. Any violation → caller disconnects. + * `hand:raise`, `chat:send` and `reaction` at most once per their interval. Any violation → caller disconnects. */ export class ConnectionRateLimiter { private readonly control: TokenBucket; private readonly ice: TokenBucket; - private readonly handIntervalMs: number; - private lastHandRaise = Number.NEGATIVE_INFINITY; + private readonly spaced: Partial>; constructor(limits: AppConfig['limits'], now: number) { this.control = new TokenBucket(limits.controlPerSecond, now); this.ice = new TokenBucket(limits.icePerSecond, now); - this.handIntervalMs = limits.handRaiseIntervalMs; + this.spaced = { + 'hand:raise': new Interval(limits.handRaiseIntervalMs), + 'chat:send': new Interval(limits.chatIntervalMs), + reaction: new Interval(limits.reactionIntervalMs), + }; } /** Returns false if the message exceeds a limit. */ allow(type: ClientMessageType, now: number): boolean { if (type === 'rtc:ice') return this.ice.take(now); if (!this.control.take(now)) return false; - if (type === 'hand:raise') { - if (now - this.lastHandRaise < this.handIntervalMs) return false; - this.lastHandRaise = now; - } - return true; + return this.spaced[type]?.take(now) ?? true; } } diff --git a/src/ws/validate.ts b/src/ws/validate.ts index 7972e0a..2e7a79a 100644 --- a/src/ws/validate.ts +++ b/src/ws/validate.ts @@ -1,15 +1,17 @@ import { CLIENT_MESSAGE_TYPES, MAX_GAIN_DB, + REACTIONS, type ClientMessage, type ClientMessageType, type IceCandidatePayload, + type Reaction, type SessionDescriptionPayload, } from '../../shared/protocol.ts'; import type { AppConfig } from '../config.ts'; import { StageError } from '../model/errors.ts'; -export type ValidationLimits = Pick; +export type ValidationLimits = Pick; /** Failed validation; `requestId` is echoed when it could be read safely. */ export class ValidationError extends StageError { @@ -38,6 +40,22 @@ export function sanitizeName(raw: unknown, maxLength: number): string | undefine return cleaned.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c] ?? c); } +/** Chat text: like names, but line breaks survive (normalized to `\n`, at most two in a row). */ +export function sanitizeChat(raw: unknown, maxLength: number): string | undefined { + if (typeof raw !== 'string') return undefined; + const cleaned = raw + .normalize('NFC') + .replace(/\r\n?|[\u2028\u2029]/g, '\n') + .split('\n') + .map((line) => line.replace(UNSAFE_CHARS_RE, '').trimEnd()) + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); + const length = [...cleaned].length; + if (length === 0 || length > maxLength) return undefined; + return cleaned.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c] ?? c); +} + /** * Parse and whitelist one inbound frame. Builds fresh objects field by field, * so unknown properties never reach the core. @@ -127,6 +145,13 @@ export function parseClientMessage(text: string, limits: ValidationLimits): Clie // One decimal is far below audibility; keeps the shared state free of float noise. return { type: t, requestId: rid, targetId: id('targetId'), gainDb: Math.round((gainDb as number) * 10) / 10 }; } + case 'chat:send': + return { type: t, requestId: rid, text: sanitizeChat(m.text, limits.chatMaxLength) ?? bad('text') }; + case 'reaction': { + const emoji = m.emoji; + if (typeof emoji !== 'string' || !(REACTIONS as readonly string[]).includes(emoji)) bad('emoji'); + return { type: t, requestId: rid, emoji: emoji as Reaction }; + } case 'rtc:offer': case 'rtc:answer': return { type: t, requestId: rid, payload: description(m.payload, t === 'rtc:offer' ? 'offer' : 'answer', limits, bad) }; diff --git a/test/hub.test.ts b/test/hub.test.ts index 49f4639..e56e6f8 100644 --- a/test/hub.test.ts +++ b/test/hub.test.ts @@ -588,3 +588,36 @@ describe('ephemeral TURN credentials', () => { assert.notEqual(usernames[0], usernames[1]); }); }); + +describe('chat and reactions', () => { + it('broadcasts chat to the room, keeps only the last rooms.chatHistory and replays them to joiners', async () => { + const h = harness(testConfig((c) => { c.rooms.chatHistory = 2; })); + const host = await h.create('Host'); + const alice = await h.join(host.roomId, host.code, 'Alice'); + for (const text of ['one', 'two', 'three']) assert.equal(await h.req(alice.s, { type: 'chat:send', text }), 'ok'); + const seen = host.s.all('chat'); + assert.deepEqual(seen.map((m) => [m.participantId, m.name, m.text]), [[alice.id, 'Alice', 'one'], [alice.id, 'Alice', 'two'], [alice.id, 'Alice', 'three']]); + assert.ok(seen[0]!.messageId < seen[1]!.messageId && seen[1]!.messageId < seen[2]!.messageId, 'ids increase'); + const bob = await h.join(host.roomId, host.code, 'Bob'); + assert.deepEqual(bob.s.last('chat:history')!.messages.map((m) => m.text), ['two', 'three']); + }); + + it('sends no history when there is none or it is disabled', async () => { + const h = harness(testConfig((c) => { c.rooms.chatHistory = 0; })); + const host = await h.create(); + assert.equal(await h.req(host.s, { type: 'chat:send', text: 'hi' }), 'ok'); + const late = await h.join(host.roomId, host.code, 'Late'); + assert.equal(late.s.all('chat:history').length, 0); + }); + + it('broadcasts reactions with the sender name and needs a joined session', async () => { + const h = harness(); + const host = await h.create('Host'); + const alice = await h.join(host.roomId, host.code, 'Alice'); + assert.equal(await h.req(alice.s, { type: 'reaction', emoji: '👏' }), 'ok'); + assert.deepEqual(host.s.last('reaction'), { type: 'reaction', participantId: alice.id, name: 'Alice', emoji: '👏' }); + const stranger = new FakeSession(); + h.hub.attach(stranger); + assert.equal(await h.req(stranger, { type: 'chat:send', text: 'hi' }), 'not_joined'); + }); +}); diff --git a/test/server.test.ts b/test/server.test.ts index 0bf6fab..174fce8 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -96,7 +96,7 @@ describe('ws server boundary', () => { await c.open(); const hello = await c.waitFor((m) => m.type === 'hello'); assert.ok(hello.type === 'hello'); - assert.deepEqual(hello.limits, { controlPerSecond: 5, icePerSecond: 30, handRaiseIntervalMs: 10000 }, 'client paces itself by the advertised limits'); + assert.deepEqual(hello.limits, { controlPerSecond: 5, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 }, 'client paces itself by the advertised limits'); c.send({ type: 'nuke', requestId: 'r1' }); assert.deepEqual(await c.reply('r1'), { type: 'error', requestId: 'r1', code: 'unknown_type', message: 'Unknown message type' }); c.send({ type: 'join', requestId: 'r2', roomId: 'x', name: 'a'.repeat(33) }); @@ -151,6 +151,25 @@ describe('ws server boundary', () => { host.ws.close(); }); + it('sanitizes chat text, rejects unknown emoji and disconnects chat faster than the interval', async () => { + const host = new Client(url); + await host.open(); + host.send({ type: 'room:create', requestId: 'c', name: 'H' }); + const st = await host.waitFor((m) => m.type === 'room:state'); + assert.ok(st.type === 'room:state'); + host.send({ type: 'reaction', requestId: 'e1', emoji: '💩' }); + assert.equal((await host.reply('e1')).type, 'error'); + host.send({ type: 'chat:send', requestId: 'm0', text: ' \u202e\n\n\n ' }); + assert.equal((await host.reply('m0')).type, 'error', 'blank after stripping is rejected'); + host.send({ type: 'chat:send', requestId: 'm1', text: 'hi\u200b\r\n\r\n\r\nthere ' }); + assert.equal((await host.reply('m1')).type, 'ok'); + const chat = await host.waitFor((m) => m.type === 'chat'); + assert.ok(chat.type === 'chat'); + assert.equal(chat.text, '<b>hi</b>\n\nthere'); + host.send({ type: 'chat:send', requestId: 'm2', text: 'again' }); + assert.equal(await host.closed, 1008); + }); + it('closes connections that send frames over the size limit', async () => { const c = new Client(url); await c.open(); diff --git a/web/src/main.ts b/web/src/main.ts index 00798aa..3a16d0a 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,5 +1,5 @@ -import { MAX_GAIN_DB, StageClient, decodeName } from '../../packages/client/src/index.ts'; -import type { ConnectionQuality, MicTest, ParticipantView, RoomStatePayload } from '../../packages/client/src/index.ts'; +import { MAX_GAIN_DB, REACTIONS, StageClient, decodeName } from '../../packages/client/src/index.ts'; +import type { ChatMessage, ConnectionQuality, MicTest, ParticipantView, RoomStatePayload } from '../../packages/client/src/index.ts'; import './styles.css'; const app = document.querySelector('#app')!; @@ -170,6 +170,7 @@ function field(form: HTMLFormElement, label: string, name: string, value = '', r return input; } function landing(): void { + chatList.replaceChildren(); cooldownButton = null; brand.textContent = 'Stage.js 音訊舞台'; const intro = element('section', 'intro'); @@ -300,7 +301,7 @@ function renderRoom(state: RoomStatePayload): void { if (state.audience.length === 0) audience.append(element('p', 'muted', '目前沒有觀眾。')); audience.append(list); } else audience.append(element('p', 'muted', '觀眾名單僅提供給主控。')); - grid.append(audience); + grid.append(audience, chatPanel); const top = element('div', 'landing-grid'); top.append(summary, controls); content.replaceChildren(top, grid); updateCooldown(); renderNotices(); @@ -372,6 +373,55 @@ function updateCooldown(): void { const returnButton = content.querySelector('[data-return-stage]'); if (returnButton) returnButton.disabled = client.status === 'reconnecting'; } +/** Chat lives outside `renderRoom` so a state update keeps the draft, the focus and the scroll position. */ +const chatPanel = element('section', 'panel chat'); +const chatList = element('ol', 'chat-log'); +chatList.setAttribute('aria-live', 'polite'); +const reactionFeed = element('div', 'reaction-feed'); +reactionFeed.setAttribute('aria-hidden', 'true'); +const reactionBar = element('div', 'actions reactions'); +for (const emoji of REACTIONS) { + const react = button(emoji, () => client.react(emoji)); + react.setAttribute('aria-label', `送出反應 ${emoji}`); + reactionBar.append(react); +} +const chatForm = element('form', 'chat-form'); +const chatInput = element('textarea'); +chatInput.rows = 2; chatInput.placeholder = '輸入訊息,Enter 送出,Shift+Enter 換行'; +chatInput.setAttribute('aria-label', '聊天訊息'); +chatForm.append(chatInput, element('button', 'primary', '送出')); +chatForm.onsubmit = (event) => { + event.preventDefault(); + const text = chatInput.value.trim(); + if (!text) return; + void run(async () => { await client.sendChat(text); chatInput.value = ''; }, chatForm.querySelector('button')!); +}; +chatInput.onkeydown = (event) => { + if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { event.preventDefault(); chatForm.requestSubmit(); } +}; +chatPanel.append(element('h2', '', '聊天'), reactionFeed, chatList, reactionBar, chatForm); +function chatItem(message: ChatMessage): HTMLLIElement { + const item = element('li'); + const time = new Date(message.sentAt).toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit' }); + item.append(element('strong', '', decodeName(message.name)), element('time', 'muted', time), element('p', '', decodeName(message.text))); + if (message.participantId === client.me?.participantId) item.classList.add('mine'); + return item; +} +function appendChat(messages: readonly ChatMessage[], replace: boolean): void { + const atBottom = chatList.scrollHeight - chatList.scrollTop - chatList.clientHeight < 40; + if (replace) chatList.replaceChildren(); + for (const message of messages) chatList.append(chatItem(message)); + while (chatList.childElementCount > 200) chatList.firstElementChild!.remove(); + if (atBottom || replace) chatList.scrollTop = chatList.scrollHeight; +} +client.on('chat', ({ detail }) => appendChat([detail], false)); +client.on('chathistory', ({ detail }) => appendChat(detail.messages, true)); +client.on('reaction', ({ detail }) => { + const bubble = element('span', 'reaction', `${detail.emoji} ${decodeName(detail.name)}`); + reactionFeed.append(bubble); + while (reactionFeed.childElementCount > 12) reactionFeed.firstElementChild!.remove(); + setTimeout(() => bubble.remove(), 3000); +}); client.on('state', ({ detail }) => renderRoom(detail)); client.on('status', ({ detail }) => { status.textContent = statusLabels[detail]; updateCooldown(); }); client.on('created', ({ detail }) => { createdRoom = detail; }); diff --git a/web/src/styles.css b/web/src/styles.css index 5dd99bd..537e836 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -57,6 +57,19 @@ button.danger { color: var(--danger); border-color: var(--danger); } .audio-settings select { width: 100%; min-width: 0; } .mic-level-label { display: grid; gap: 8px; margin-top: 16px; } .mic-level { width: 100%; height: 24px; accent-color: var(--accent); } +.chat { display: grid; gap: 10px; position: relative; } +.chat-log { margin: 0; padding: 0; list-style: none; max-height: 320px; overflow-y: auto; } +.chat-log li { padding: 8px 0; border-top: 1px solid var(--border); } +.chat-log li.mine strong { color: var(--accent); } +.chat-log time { margin-left: 8px; font-size: .85rem; } +.chat-log p { margin: 4px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; } +.chat-form { display: grid; grid-template-columns: 1fr auto; gap: 8px; } +.chat-form textarea { resize: vertical; min-width: 0; font: inherit; } +.reactions button { min-width: 44px; font-size: 1.2rem; } +.reaction-feed { display: flex; flex-wrap: wrap; gap: 6px; min-height: 1.5em; } +.reaction { padding: 2px 8px; border-radius: 99px; background: var(--panel); border: 1px solid var(--border); animation: reaction-fade 3s ease-out forwards; } +@keyframes reaction-fade { 0%, 70% { opacity: 1; } 100% { opacity: 0; } } +@media (prefers-reduced-motion: reduce) { .reaction { animation: none; } } @media (min-width: 760px) { #app { padding: 24px; } .landing-grid, .stage-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } From ec60d83d533420b68e41f871eafd13e6ba3bb463 Mon Sep 17 00:00:00 2001 From: Yueyu Hoshizora Date: Sat, 10 Oct 2026 21:02:26 +0800 Subject: [PATCH 4/8] feat: record the room mix to Ogg/Opus files --- .gitignore | 2 + AGENTS.md | 1 + PLAN.md | 3 +- README.md | 2 + config.example.yaml | 7 ++ packages/client/README.md | 2 + packages/client/src/element.ts | 2 +- packages/client/src/index.ts | 3 + scripts/werift-loopback.ts | 13 ++- shared/protocol.ts | 11 +++ src/config.ts | 18 +++- src/index.ts | 11 ++- src/model/room.ts | 15 +++ src/recording/oggOpus.ts | 134 ++++++++++++++++++++++++++ src/recording/recorder.ts | 41 ++++++++ src/transport/MediaTransport.ts | 8 ++ src/transport/MockMediaTransport.ts | 8 ++ src/transport/WeriftMediaTransport.ts | 42 ++++++-- src/ws/hub.ts | 61 ++++++++++++ src/ws/validate.ts | 2 + test/helpers.ts | 5 +- test/hub.test.ts | 56 +++++++++++ test/oggOpus.test.ts | 75 ++++++++++++++ web/src/main.ts | 6 ++ web/src/styles.css | 1 + 25 files changed, 515 insertions(+), 14 deletions(-) create mode 100644 src/recording/oggOpus.ts create mode 100644 src/recording/recorder.ts create mode 100644 test/oggOpus.test.ts diff --git a/.gitignore b/.gitignore index f90f831..92e562d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ config.yaml *.key *.crt .DS_Store +# 錄音檔(recording.dir 預設值) +recordings/ diff --git a/AGENTS.md b/AGENTS.md index 98a80a1..94a9411 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,7 @@ node scripts/load-test.ts # 全端壓測:自起伺服器,K 發言者 | `src/transport/jitter.ts` | 上行 RTP 重排(`audio.jitter.reorderPackets`);遺失包以 `null` 送解碼器,由下一包的 in-band FEC 還原,沒有 FEC 時走 libopus PLC | | `src/transport/MockMediaTransport.ts` | 測試/無 WebRTC 開發用 | | `src/mixer/` | `RoomMixer`(N 路疊加、mix-minus-self、每路 playout 預緩衝/underrun 重緩衝/漂移排空/常駐延遲收斂、預配置 ring buffer、緩衝上限)、`MixerClock`(全部房間共用一個 tick 時鐘)、`noiseFilter`、`loudness`(每路音量正規化)、`limiter` | +| `src/recording/` | `oggOpus.ts`(RFC 7845 Ogg/Opus 封裝,`null` 幀補 TOC-only 封包)、`recorder.ts`(寫 `recording.dir`);封包來自 `MediaTransport.setRecording`,即共用的完整混音編碼,不另開 encoder | | `packages/client/` | 可嵌入的瀏覽器 ESM 函式庫 `StageClient`;`src/element.ts`=Web Component ``(入口 `stage-element.ts` 自動註冊) | | `web/` | 完整範例前端(建於 client 之上) | | `src/metrics.ts` | Prometheus 文字輸出、跨房共用的 `MixerCounters`、event loop/記憶體取樣;`/metrics` 由 `server.metrics` 控制(對外 host 必須設 token) | diff --git a/PLAN.md b/PLAN.md index cac55cf..ecf2868 100644 --- a/PLAN.md +++ b/PLAN.md @@ -35,7 +35,7 @@ ### 範圍外 - 影片、螢幕分享。(文字聊天與表情反應已於 2026-10-10 依需求加入,見十二-22。) -- 錄音存檔與回放、直播轉推(RTMP/HLS)。 +- 回放、直播轉推(RTMP/HLS)。(伺服器端 Ogg/Opus 錄音已於 2026-10-10 依需求加入,見十二-23。) - 帳號系統與持久化(使用者、舞台、操作紀錄均記憶體保存,重啟即失)。 - 套件發布(npm):只開源倉庫供 clone,不發套件(見假設 6)。 - 其他網站的整合案本身(各站程式);本專案交付 client 函式庫+範例前端。 @@ -320,6 +320,7 @@ N 路混音管線(decode→mix→encode)、不含自己的台上混音、lim 20. **程序層錯誤**:`unhandledRejection` 只記 error 不中止(werift 關閉 peer 後的計時器可能漏 rejection,Node 預設會讓整個 process 連同所有房間一起死);`uncaughtException` 走優雅停機(`room:closed {reason:'shutdown'}`)後以非零碼退出。TLS 憑證檔變更後約 2 秒自動 `setSecureContext`,失敗則沿用舊憑證。 21. **Web Component**:`/lib/stage-element.js` 註冊 ``(shadow DOM 小工具:狀態、錯誤、啟用音訊、舉手、台上靜音/下台),進入文件即進房、移除即以 1000 離開;client 事件以 `stage-` 派發,`element.client` 暴露底層 `StageClient` 供主控功能;它從穩定檔名 `stage-client.js` 匯入,嵌入頁同時使用兩者時只載入一份。 22. **文字聊天與表情反應**(2026-10-10 依需求加入):C→S `chat:send {text}`、`reaction {emoji}`;S→C `chat {messageId, participantId, name, text, sentAt}`、`reaction {participantId, name, emoji}`、`chat:history {messages}`(進房與回座時補送最近 `rooms.chatHistory` 則,記憶體保存)。聊天內容比照 name 清洗(保留換行、最多連續兩個空行)後 HTML 轉義,長度 ≤ `limits.chatMaxLength`;emoji 限 `REACTIONS` 白名單。每連線聊天每 `limits.chatIntervalMs`、反應每 `limits.reactionIntervalMs` 至多一次,超過比照 `hand:raise` 以 1008 斷線,兩值經 `hello.limits` 下發供 client 自行節流。聊天內容不入日誌。伺服器錯誤訊息維持固定英文,client 依 `code` 換成繁中(`ERROR_MESSAGES`)。 +23. **錄音**(2026-10-10 依需求加入):C→S `recording:start`/`recording:stop`(主控限定,伺服器 `recording.enabled` 才可用,否則 `forbidden`;重複開始/停止為 `conflict`)。`MediaTransport.setRecording(roomId, sink)` 把觀眾那一路完整混音的**既有共用編碼**依混音順序交給 sink(沒人收聽完整混音時才額外加一份編碼),不另開 encoder;靜音略過編碼、codec 落後丟幀或編碼失敗的幀以 `null` 交付,寫檔時補成只有 TOC 的零長度幀(解碼端 PLC),時間軸不位移。`src/recording/oggOpus.ts` 依 RFC 7845 封裝(mono、pre-skip 0、每頁至多 50 包且 ≤255 lacing),`recorder.ts` 寫 `/-.opus`;寫檔失敗只記 error、不影響房間。`room:state` 增 `recording`(錄音中,所有人都看得到)與 `recordingAvailable`。單次錄音逾 `recording.maxDurationMinutes` 自動停止;關房、停機時停止並等檔案寫完。 --- diff --git a/README.md b/README.md index fe231eb..e0ca600 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ Windows 用 `.\stage.ps1 `,指令相同;Windows 無法對背景 nod | `rooms` | `codeLength`(8)、`controllerGraceMs`(主控斷線寬限 60000)、`participantGraceMs`(其他人斷線寬限 15000;期間保留席位、台位、舉手順位與 PeerConnection,音訊不中斷;0=立即移除)、`heartbeatIntervalMs`、`presenceBroadcastMs`(觀眾進出合併 `room:state` 廣播的時間窗,250;進場者本人仍立即收到自己的 snapshot)、`qualityIntervalMs`(台上者連線品質回報間隔,2000;0=停用)、`chatHistory`(每房保留並補送給新進房者的聊天則數,50;0=不保留)、`createToken`(非空時 `room:create` 須帶相同 `token`,否則 `unauthorized`;空字串=任何人可開房,對外部署建議設定) | | `audio` | `sampleRate`(48000;只接受 Opus 原生取樣率)、`frameMs`(20)、`codecWorkers`(Opus 編解碼 worker 數,房間平均分配到各 worker)、`opus.{vbr,minBitrate,maxBitrate,bitrate,complexity}`、`opus.fec`/`opus.packetLossPercent`(下行 in-band FEC 與預期掉包率)、`opus.dtx`(靜音不送包)、`lowTier.{enabled,bitrate,packetLossPercent,enterLossPercent,exitLossPercent}`(RTCP 接收報告顯示持續掉包的觀眾改收第二路共用混音:較低位元率+較多 FEC,掉包回落後切回)、`mixer.{maxBufferedFrames,limiterThreshold}`、`mixer.latencyTargetMs`(僅供 `bench-mixer`/`load-test` 當驗收門檻)、`mixer.speakingThreshold`/`speakingHoldMs`(說話指示的 RMS 門檻與釋放延遲)、`jitter.playoutFrames`(每路上行預緩衝幀數)、`jitter.reorderPackets`(亂序容忍包數,超過即判定遺失並補幀)、`noiseFilter.{enabled,highPassHz,gateThreshold,gateHoldMs,gateFloor}`(伺服器端上行降噪:高通濾掉低頻雜音+噪音門壓低說話間隙的背景音;瀏覽器端另開 `noiseSuppression`)、`loudness.{enabled,targetRms,maxGainDb,speechRms,adaptMs}`(伺服器端每路音量正規化:依說話時的平均音量把各發言者拉到相近大小,增益上限 ±maxGainDb) | | `rtc` | `iceServers`(下發給瀏覽器的靜態 STUN)、`serverIceServers`(伺服器端 ICE)、`portRange`(`[]` 或 `[min, max]`)、`mediaWorkers`(承載 PeerConnection 的 worker thread 數,預設 2 對應 300 聽眾;0=主執行緒;`auto`=CPU 核心數 − 1 − `audio.codecWorkers`,限制在 2–8)、`turn.{urls,secret,ttlSeconds}`(coturn `use-auth-secret` 短期憑證,每次進房以 HMAC 簽發;`urls: []` 停用) | +| `recording` | `enabled`(主控可否錄音,預設 false)、`dir`(`.opus` 存放目錄,相對路徑以專案根目錄為準)、`maxDurationMinutes`(單次錄音上限,逾時自動停止) | | `log` | `level`:`debug`/`info`/`warn`/`error`(房間代碼、token、憑證、SDP 一律不入日誌) | 對外部署:`allowInsecure: false`、`host: 0.0.0.0`、填 `tls`;開放 `rtc.portRange` 的 UDP;設定 `rtc.turn`(coturn 需 `use-auth-secret` 與相同的 `static-auth-secret`)。憑證檔更新(含 ACME 工具的改名/symlink 替換)後約 2 秒自動重載,不需重啟;新憑證載入失敗時沿用舊憑證並記 error。 @@ -103,6 +104,7 @@ JSON frame,型別定義在 [`shared/protocol.ts`](shared/protocol.ts)。每個 | `room:rotate-code` | 主控更換房間代碼(限需代碼的房間);舊代碼/邀請連結立即失效,已在房內的人不受影響 | | `chat:send {text}` | 聊天(任何已進房者;換行保留、控制字元移除後 HTML 轉義,≤ `limits.chatMaxLength` 字元) | | `reaction {emoji}` | 表情反應,只接受 `REACTIONS`(👏 ❤️ 😂 😮 👍 🎉) | +| `recording:start`/`recording:stop` | 主控開始/停止錄音(伺服器 `recording.enabled` 時):把觀眾聽到的完整混音存成 `/-.opus`(Ogg/Opus,直接封裝共用編碼、不另外編碼;沒有任何人在台上時混音器不出幀,那段不寫入);錄音中所有人的 `room:state.recording` 為 true | | `rtc:offer`/`rtc:ice {payload}` | WebRTC 信令(一律由 client 發 offer) | | `ping` | 回 `pong` | diff --git a/config.example.yaml b/config.example.yaml index bdfcc38..2e5fbb3 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -118,5 +118,12 @@ rtc: secret: "" # 與 coturn static-auth-secret 相同(≥16 字元) ttlSeconds: 86400 # 建議長於最長場次:瀏覽器在 allocation refresh 時沿用同一組帳密(可調整) +recording: + # 主控可錄下房間的完整混音(觀眾聽到的那一路),存成 Ogg/Opus(.opus);直接封裝共用編碼、不另外編碼。 + # 錄音期間所有人的 room:state 都帶 recording: true,前端會顯示錄音中。 + enabled: false + dir: recordings # 相對路徑以專案根目錄為準;檔名 -.opus + maxDurationMinutes: 240 # 單次錄音上限,逾時自動停止(防止塞滿磁碟;可調整) + log: level: info # debug | info | warn | error diff --git a/packages/client/README.md b/packages/client/README.md index d29ec03..7ab212c 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -20,6 +20,8 @@ 聊天與反應(任何已進房者):`sendChat(text)`、`react(emoji)`(`emoji` 取自匯出的 `REACTIONS`)。兩者都依伺服器下發的 `rateLimits.chatIntervalMs`/`reactionIntervalMs` 在本機先擋,過快時拒絕並拋出 `StageError('rate_limited')`,不會送出而被伺服器斷線。`chatMessages` 為目前保留的訊息(含進房補送的歷史,最多 200 則);事件 `chat`(單則 `{messageId, participantId, name, text, sentAt}`)、`chathistory`(`{messages}`,進房或回座時整批取代)、`reaction`(`{participantId, name, emoji}`)。`name`、`text` 同樣先 `decodeName()` 再以 `textContent` 顯示。 +錄音(主控,伺服器開啟 `recording.enabled` 時 `state.recordingAvailable` 為 true):`startRecording()`、`stopRecording()`。錄音中所有人的 `state.recording` 為 true,嵌入時請明顯標示(`` 會在狀態列顯示「● 錄音中」)。 + `state`、`me`、`status`、`speaking`(目前說話中的 participantId 集合)為即時 getters。`on(type, listener)` 回傳取消訂閱函式;事件也可透過 EventTarget 的 `addEventListener` 使用。事件:`state`、`status`、`created`、`closed`(`{roomId, reason?}`;`reason: 'shutdown'` 表示伺服器停止或重啟,而非主控關房)、`kicked`(被主控踢出;已斷線且不會自動重連)、`hand`、`invite`、`stagejoined`、`stageleft`、`transferred`、`mic`、`role`、`speaking`(說話中集合變動)、`quality`(主控與台上者每隔數秒收到所有上行者的 `{participantId, uplinkLossPercent?, downlinkLossPercent?, rttMs?}`)、`error`、`micerror`、`micready`、`audioblocked`。狀態為 waiting/live/reconnecting/disconnected。名稱先用 `decodeName()` 還原伺服器的五種 HTML entities,再以 `textContent` 顯示,勿使用 innerHTML。 每人一個 PeerConnection,觀眾 recvonly;上台才索取麥克風(mono、48 kHz)、轉 sendrecv。權限失敗仍可收聽;請下台、修正權限後再上台。`getStats()` 回傳 inbound/outbound 的 codec、clockRate、channels、bitrateKbps、packetsLost、jitter、rtt、lossPercent,inbound 另有 `bufferMs`(收到的音訊在瀏覽器 jitter buffer 等待播放的平均毫秒數,網路抖動越大越高);位元率、inbound 的 lossPercent 與 bufferMs 從兩次呼叫間的差量計算(首次沒有),outbound 的 lossPercent 為伺服器最近一次回報的掉包比例;jitter/rtt 單位秒,瀏覽器未提供的欄位保持 undefined。觀眾可定期呼叫它顯示自己的連線狀態。 diff --git a/packages/client/src/element.ts b/packages/client/src/element.ts index 687a053..e3204aa 100644 --- a/packages/client/src/element.ts +++ b/packages/client/src/element.ts @@ -154,7 +154,7 @@ export class StageElement extends HTMLElement { const state = client?.state ?? null; if (state && me) { const where = me.onStage ? (me.muted ? '台上(已靜音)' : '台上') : me.handRaised ? '已舉手' : '收聽中'; - this.status.textContent = `${decodeName(state.name)} · ${STATUS_LABELS[client!.status]} · ${where}`; + this.status.textContent = `${decodeName(state.name)} · ${STATUS_LABELS[client!.status]} · ${where}${state.recording ? ' · ● 錄音中' : ''}`; } else if (!client) { this.status.textContent = this.getAttribute('room') && this.getAttribute('name') ? '' : '請設定 room 與 name 屬性。'; } diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index b49562f..d17db10 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -401,6 +401,9 @@ export class StageClient extends EventTarget { setGain(id: string, gainDb: number): Promise { return this.request('mic:gain', { targetId: id, gainDb }); } /** Controller only: issue a new room code (code-protected rooms); people already inside stay. */ rotateCode(): Promise { return this.request('room:rotate-code', {}); } + /** Controller only, when `state.recordingAvailable`: record the room's mix on the server (`state.recording` turns true for everyone). */ + startRecording(): Promise { return this.request('recording:start', {}); } + stopRecording(): Promise { return this.request('recording:stop', {}); } closeRoom(): Promise { return this.request('room:close', {}); } async disconnect(): Promise { this.intentional = true; diff --git a/scripts/werift-loopback.ts b/scripts/werift-loopback.ts index 1da24a3..d7ba43e 100644 --- a/scripts/werift-loopback.ts +++ b/scripts/werift-loopback.ts @@ -23,6 +23,9 @@ try { transport.addPublisher('room', 'speaker', pcm => mixer.push('speaker', pcm)); transport.addPublisher('room', 'blocked', () => { rejectedFrames++; }); transport.setMixedStream('room', mixer); + // The recording taps the shared full-mix encode: it must decode to the speaker's audio. + const recorded: (Uint8Array | null)[] = []; + transport.setRecording('room', packet => recorded.push(packet)); const tracks = new Map(); for (const id of ['speaker', 'audience', 'blocked']) { const client = new RTCPeerConnection({ codecs: { audio: [opusCodec(config.audio)], video: [] }, iceServers: [] }); clients.set(id, client); @@ -59,6 +62,14 @@ try { assert.ok(audienceEnergy > 0.001, `audience audio silent (${audienceEnergy})`); assert.ok(selfEnergy < 0.00001, `mix-minus leaked own audio (${selfEnergy})`); assert.equal(rejectedFrames, 0); + transport.setRecording('room', null); + const recordedFrames = recorded.length; + const recordDecoder = new OpusDecoder(config.audio.sampleRate); + let recordedEnergy = 0, recordedSamples = 0; + for (const packet of recorded) if (packet) for (const s of recordDecoder.decode(packet)) { recordedEnergy += s * s; recordedSamples++; } + recordedEnergy /= Math.max(1, recordedSamples); + assert.ok(recordedFrames > 20 && recorded.filter(Boolean).length > 20, `recording got only ${recordedFrames} frames`); + assert.ok(recordedEnergy > 0.001, `recording silent (${recordedEnergy})`); const blockedStats = await clients.get('blocked')!.getStats(); let blockedPacketsSent = 0; for (const stat of blockedStats.values()) { @@ -106,7 +117,7 @@ try { audienceEnergy = 0; await sleep(600); assert.ok(received > audienceBefore + 10 && audienceEnergy > 0.001, `audience lost the speaker after their ICE restart (${received - audienceBefore} packets, energy ${audienceEnergy})`); - console.log(`PASS mediaWorkers=${config.rtc.mediaWorkers} packets=${received} audienceEnergy=${audienceEnergy.toFixed(6)} selfEnergy=${selfEnergy.toFixed(6)} blockedPacketsSent=${blockedPacketsSent} blockedFrames=${rejectedFrames}; renegotiation promotedFrames=${promotedFrames}; iceRestart packetsAfter=${received - packetsBeforeRestart} PASS`); + console.log(`PASS mediaWorkers=${config.rtc.mediaWorkers} packets=${received} audienceEnergy=${audienceEnergy.toFixed(6)} selfEnergy=${selfEnergy.toFixed(6)} blockedPacketsSent=${blockedPacketsSent} blockedFrames=${rejectedFrames}; recording frames=${recordedFrames} energy=${recordedEnergy.toFixed(6)}; renegotiation promotedFrames=${promotedFrames}; iceRestart packetsAfter=${received - packetsBeforeRestart} PASS`); } catch (error) { console.error('FAIL', error); process.exitCode = 1; } finally { clearInterval(timer); mixer.stop(); diff --git a/shared/protocol.ts b/shared/protocol.ts index ea3b4f6..b36a6dc 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -34,6 +34,9 @@ * sanitized and HTML-escaped like names. The last `rooms.chatHistory` messages arrive as * `chat:history` right after joining. * - C→S `reaction {emoji}` / S→C `reaction {participantId, emoji}`: fire-and-forget emoji from REACTIONS. + * - C→S `recording:start` / `recording:stop` (controller, server `recording.enabled`): records the + * audience mix to an Ogg/Opus file on the server. `room:state.recording` tells everyone it is on; + * `recordingAvailable` whether the server allows it at all. */ export const PROTOCOL_VERSION = 1; @@ -95,6 +98,10 @@ export interface RoomStatePayload { codeRequired: boolean; limits: { maxSpeakers: number; maxAudience: number }; status: Exclude; + /** The room's mix is being recorded on the server; clients must show it. */ + recording: boolean; + /** The server allows recording (`recording.enabled`). */ + recordingAvailable: boolean; me: ParticipantView; /** Controller only: full audience list (role `audience`, plus off-stage controller is in `me`). */ audience?: ParticipantView[]; @@ -220,6 +227,8 @@ export interface ClientMessageMap { 'rtc:ice': { requestId: string; payload: IceCandidatePayload | null }; 'chat:send': { requestId: string; text: string }; reaction: { requestId: string; emoji: Reaction }; + 'recording:start': { requestId: string }; + 'recording:stop': { requestId: string }; ping: Record; } @@ -252,5 +261,7 @@ export const CLIENT_MESSAGE_TYPES: readonly ClientMessageType[] = [ 'rtc:ice', 'chat:send', 'reaction', + 'recording:start', + 'recording:stop', 'ping', ]; diff --git a/src/config.ts b/src/config.ts index f7bdbfb..20e0237 100644 --- a/src/config.ts +++ b/src/config.ts @@ -121,6 +121,14 @@ export interface AppConfig { /** Ephemeral TURN (coturn use-auth-secret); empty `urls` disables it. */ turn: { urls: string[]; secret: string; ttlSeconds: number }; }; + recording: { + /** Whether controllers may record their room's mix. */ + enabled: boolean; + /** Where `.opus` files go; relative paths resolve against the repo root. */ + dir: string; + /** A recording stops on its own after this long, bounding disk use. */ + maxDurationMinutes: number; + }; log: { level: 'debug' | 'info' | 'warn' | 'error' }; } @@ -352,11 +360,19 @@ export function parseConfig(raw: unknown): AppConfig { turn, }; + const rc = obj(root.recording, 'recording'); + const recording: AppConfig['recording'] = { + enabled: bool(rc, 'enabled', 'recording'), + dir: str(rc, 'dir', 'recording'), + maxDurationMinutes: int(rc, 'maxDurationMinutes', 'recording', 1, 24 * 60), + }; + if (recording.enabled && recording.dir === '') throw new ConfigError('recording.dir: required when recording is enabled'); + const lg = obj(root.log, 'log'); const level = str(lg, 'level', 'log'); if (!['debug', 'info', 'warn', 'error'].includes(level)) throw new ConfigError('log.level: debug|info|warn|error'); - return { server, limits, rooms, audio, rtc, log: { level: level as AppConfig['log']['level'] } }; + return { server, limits, rooms, audio, rtc, recording, log: { level: level as AppConfig['log']['level'] } }; } export function loadConfig(path: string): AppConfig { diff --git a/src/index.ts b/src/index.ts index 1e7c23a..a1fd8df 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,12 @@ import { readFileSync } from 'node:fs'; import { basename, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { ConfigError, loadConfig } from './config.ts'; +import { ConfigError, loadConfig, samplesPerFrame } from './config.ts'; import { createLogger } from './log.ts'; import { MixerCounters, processSamples, renderPrometheus } from './metrics.ts'; import { MixerClock } from './mixer/MixerClock.ts'; import { RoomMixer } from './mixer/RoomMixer.ts'; +import { startRecording } from './recording/recorder.ts'; import { WeriftMediaTransport } from './transport/WeriftMediaTransport.ts'; import { StageHub } from './ws/hub.ts'; import { createStageServer } from './ws/server.ts'; @@ -62,6 +63,14 @@ const stageHub = new StageHub({ mixer.start(mixerClock); return mixer; }, + startRecording: (roomId) => startRecording({ + dir: resolve(root, config.recording.dir), + roomId, + frameSamples: samplesPerFrame(audio), + vendor: `Stage.js ${version}`, + now: Date.now(), + log, + }), }); hub = stageHub; diff --git a/src/model/room.ts b/src/model/room.ts index 1505ecf..ff5ab3d 100644 --- a/src/model/room.ts +++ b/src/model/room.ts @@ -70,6 +70,9 @@ export class Room { /** On-stage participant ids in stage-entry order (controller included while on stage). */ readonly speakers = new Set(); readonly handQueue: string[] = []; + readonly recordingAvailable: boolean; + /** The mix is being recorded; the hub owns the file, the room owns the state everyone sees. */ + recording = false; constructor(init: { roomId: string; @@ -77,6 +80,7 @@ export class Room { code: string; codeRequired: boolean; limits: RoomLimits; + recordingAvailable?: boolean; now: number; controller: NewParticipant; }) { @@ -85,6 +89,7 @@ export class Room { this.code = init.code; this.codeRequired = init.codeRequired; this.limits = init.limits; + this.recordingAvailable = init.recordingAvailable ?? false; this.createdAt = init.now; const c = init.controller; this.participants.set(c.participantId, { @@ -184,6 +189,14 @@ export class Room { this.code = code; } + /** Controller turns recording on or off; the hub starts or stops the file around this. */ + setRecording(byId: string, on: boolean): void { + this.requireController(byId); + if (!this.recordingAvailable) throw new StageError('forbidden', 'recording disabled on this server'); + if (this.recording === on) throw new StageError('conflict', on ? 'already recording' : 'not recording'); + this.recording = on; + } + // ─────────────────────────────── hands ─────────────────────────────── raiseHand(participantId: string): RoomEvent[] { @@ -387,6 +400,8 @@ export class Room { codeRequired: this.codeRequired, limits: { maxSpeakers: this.limits.maxSpeakers, maxAudience: this.limits.maxAudience }, status: this.status, + recording: this.recording, + recordingAvailable: this.recordingAvailable, }; } diff --git a/src/recording/oggOpus.ts b/src/recording/oggOpus.ts new file mode 100644 index 0000000..1717a72 --- /dev/null +++ b/src/recording/oggOpus.ts @@ -0,0 +1,134 @@ +/** + * Minimal Ogg Opus muxer (RFC 7845) for one mono stream of fixed-duration packets: the room's + * already-encoded full mix, so recording costs no extra encode. + */ + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + let r = i << 24; + for (let k = 0; k < 8; k++) r = r & 0x80000000 ? (r << 1) ^ 0x04c11db7 : r << 1; + table[i] = r >>> 0; + } + return table; +})(); + +/** Ogg's CRC-32: polynomial 0x04c11db7, unreflected, zero initial value and no final xor. */ +export function oggCrc(data: Uint8Array): number { + let crc = 0; + for (const byte of data) crc = ((crc << 8) ^ CRC_TABLE[((crc >>> 24) ^ byte) & 0xff]!) >>> 0; + return crc; +} + +const FLAG_BOS = 0x02; +const FLAG_EOS = 0x04; +/** Flush at least once a second of 20 ms packets, so a crash loses little and pages stay small. */ +const PACKETS_PER_PAGE = 50; + +export interface OggOpusOptions { + /** Samples (at 48 kHz) each packet covers. */ + frameSamples: number; + serial: number; + /** OpusTags vendor string. */ + vendor: string; +} + +export class OggOpusWriter { + private readonly write: (chunk: Uint8Array) => void; + private readonly frameSamples: number; + private readonly serial: number; + private pageSeq = 0; + private granule = 0n; + private pending: Uint8Array[] = []; + private pendingSegments = 0; + /** TOC byte of the last real packet: concealment packets reuse its mode and frame size. */ + private lastToc: number | undefined; + private closed = false; + + constructor(write: (chunk: Uint8Array) => void, options: OggOpusOptions) { + this.write = write; + this.frameSamples = options.frameSamples; + this.serial = options.serial >>> 0; + const head = new Uint8Array(19); + const view = new DataView(head.buffer); + head.set(new TextEncoder().encode('OpusHead')); + head[8] = 1; // version + head[9] = 1; // channels + view.setUint16(10, 0, true); // pre-skip: the shared encoder has been running, so nothing to trim + view.setUint32(12, 48000, true); // original input rate + view.setInt16(16, 0, true); // output gain + head[18] = 0; // mapping family 0 (mono/stereo) + this.page([head], FLAG_BOS, 0n); + const vendor = new TextEncoder().encode(options.vendor); + const tags = new Uint8Array(8 + 4 + vendor.length + 4); + tags.set(new TextEncoder().encode('OpusTags')); + new DataView(tags.buffer).setUint32(8, vendor.length, true); + tags.set(vendor, 12); + new DataView(tags.buffer).setUint32(12 + vendor.length, 0, true); + this.page([tags], 0, 0n); + } + + /** + * Append one frame. `null` (or empty: DTX) marks a frame with no packet; it becomes a TOC-only + * packet with a zero-length frame, which decoders conceal, so the timeline never shifts. + * Frames before the first real packet are dropped: there is no TOC to copy yet. + */ + packet(packet: Uint8Array | null): void { + if (this.closed) return; + if (packet && packet.length > 0) this.lastToc = packet[0]!; + else if (this.lastToc === undefined) return; + else packet = Uint8Array.of(this.lastToc & 0xfc); + this.push(packet); + } + + /** Write the final page (end of stream). Further packets are ignored. */ + close(): void { + if (this.closed) return; + this.closed = true; + // Pages are flushed only when the next packet would not fit, so once a real packet arrived there + // is always one pending here: the EOS flag lands on a page with data and adds no extra frame. + if (this.pending.length) this.flush(FLAG_EOS); + } + + private push(packet: Uint8Array): void { + const segments = Math.floor(packet.length / 255) + 1; + if (this.pending.length >= PACKETS_PER_PAGE || this.pendingSegments + segments > 255) this.flush(0); + this.pending.push(packet); + this.pendingSegments += segments; + this.granule += BigInt(this.frameSamples); + } + + /** A page's granule position is the end of its last packet, i.e. everything pushed so far. */ + private flush(flags: number): void { + const packets = this.pending; + this.pending = []; + this.pendingSegments = 0; + this.page(packets, flags, this.granule); + } + + /** One page holding whole packets; callers keep the lacing table within 255 segments. */ + private page(packets: Uint8Array[], flags: number, granule: bigint): void { + const lacing: number[] = []; + for (const p of packets) { + let left = p.length; + while (left >= 255) { lacing.push(255); left -= 255; } + lacing.push(left); + } + if (lacing.length > 255) throw new RangeError('Ogg page exceeds 255 segments'); + const bodyLength = packets.reduce((n, p) => n + p.length, 0); + const page = new Uint8Array(27 + lacing.length + bodyLength); + const view = new DataView(page.buffer); + page.set([0x4f, 0x67, 0x67, 0x53]); // "OggS" + page[4] = 0; + page[5] = flags; + view.setBigUint64(6, granule, true); + view.setUint32(14, this.serial, true); + view.setUint32(18, this.pageSeq++, true); + page[26] = lacing.length; + page.set(lacing, 27); + let at = 27 + lacing.length; + for (const p of packets) { page.set(p, at); at += p.length; } + view.setUint32(22, oggCrc(page), true); + this.write(page); + } +} diff --git a/src/recording/recorder.ts b/src/recording/recorder.ts new file mode 100644 index 0000000..210d648 --- /dev/null +++ b/src/recording/recorder.ts @@ -0,0 +1,41 @@ +import { randomInt } from 'node:crypto'; +import { createWriteStream, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Logger } from '../log.ts'; +import type { MixPacketSink } from '../transport/MediaTransport.ts'; +import { OggOpusWriter } from './oggOpus.ts'; + +export interface Recording { + readonly sink: MixPacketSink; + /** File name inside the recording directory. */ + readonly file: string; + /** Ends the stream and resolves once the file is flushed and closed. */ + stop(): Promise; +} + +/** Opens `/-.opus` for one room's full mix. */ +export function startRecording(opts: { dir: string; roomId: string; frameSamples: number; vendor: string; now: number; log: Logger }): Recording { + mkdirSync(opts.dir, { recursive: true }); + const file = `${opts.roomId}-${new Date(opts.now).toISOString().replace(/[:.]/g, '-')}.opus`; + const stream = createWriteStream(join(opts.dir, file), { flags: 'wx' }); + let failed = false; + stream.on('error', (err) => { + // Disk full or unwritable: stop writing, keep the room running. + if (!failed) opts.log.error('recording write failed', { roomId: opts.roomId, file, error: err.message }); + failed = true; + }); + const writer = new OggOpusWriter((chunk) => { if (!failed) stream.write(chunk); }, { frameSamples: opts.frameSamples, serial: randomInt(2 ** 32), vendor: opts.vendor }); + return { + sink: (packet) => writer.packet(packet), + file, + stop: () => { + writer.close(); + // 'close' also follows a write error (the stream destroys itself), unlike end()'s callback. + const { promise, resolve } = Promise.withResolvers(); + if (stream.closed) resolve(); + else stream.once('close', () => resolve()); + stream.end(); + return promise; + }, + }; +} diff --git a/src/transport/MediaTransport.ts b/src/transport/MediaTransport.ts index f576ebd..622d67a 100644 --- a/src/transport/MediaTransport.ts +++ b/src/transport/MediaTransport.ts @@ -32,6 +32,12 @@ export interface MixFrame { export type MixFrameListener = (frame: MixFrame) => void; +/** + * Receives the room's encoded full mix (the audience downlink), one Opus packet per mixer frame, + * in order. `null` = this frame has no packet (skipped while silent, shed, or failed to encode). + */ +export type MixPacketSink = (packet: Uint8Array | null) => void; + /** Mixer output a transport can subscribe to. */ export interface MixedPcmSource { /** Returns an unsubscribe function. */ @@ -90,6 +96,8 @@ export interface MediaTransport { /** Attach (or detach with null) the room mixer output; encoded to Opus and sent to subscribers. */ setMixedStream(roomId: string, source: MixedPcmSource | null): void; + /** Also deliver the room's encoded full mix to `sink` (null stops); encoded once, shared with the audience. */ + setRecording(roomId: string, sink: MixPacketSink | null): void; /** Downlink: subscribers receive `frame.minus(id) ?? frame.full`. */ subscribe(roomId: string, participantId: string): void; unsubscribe(roomId: string, participantId: string): void; diff --git a/src/transport/MockMediaTransport.ts b/src/transport/MockMediaTransport.ts index fb1a16d..b35e1d0 100644 --- a/src/transport/MockMediaTransport.ts +++ b/src/transport/MockMediaTransport.ts @@ -5,6 +5,7 @@ import type { MediaTransport, MediaTransportCallbacks, MixedPcmSource, + MixPacketSink, NegotiationPolicy, TransportStats, } from './MediaTransport.ts'; @@ -24,6 +25,8 @@ interface MockRoom { muted: Set; subscribers: Set; unsubscribeMix?: () => void; + /** Gets one fake packet per mixed frame: `[0xf8, silent ? 0 : 1]` (a CELT 20 ms TOC byte, then a marker). */ + recording?: MixPacketSink; } /** @@ -89,9 +92,14 @@ export class MockMediaTransport implements MediaTransport { r.unsubscribeMix?.(); r.unsubscribeMix = source?.onFrame((frame) => { for (const id of r.subscribers) r.peers.get(id)?.received.push((frame.minus(id) ?? frame.full).slice()); + r.recording?.(Uint8Array.of(0xf8, frame.silent ? 0 : 1)); }); } + setRecording(roomId: string, sink: MixPacketSink | null): void { + this.room(roomId).recording = sink ?? undefined; + } + subscribe(roomId: string, participantId: string): void { this.room(roomId).subscribers.add(participantId); } diff --git a/src/transport/WeriftMediaTransport.ts b/src/transport/WeriftMediaTransport.ts index db90233..4b5f9e0 100644 --- a/src/transport/WeriftMediaTransport.ts +++ b/src/transport/WeriftMediaTransport.ts @@ -3,7 +3,7 @@ import { samplesPerFrame } from '../config.ts'; import type { Logger } from '../log.ts'; import type { MetricSample } from '../metrics.ts'; import type { IceCandidatePayload, SessionDescriptionPayload } from '../../shared/protocol.ts'; -import type { AudioFrameHandler, MediaTransport, MediaTransportCallbacks, MixedPcmSource, MixFrame, NegotiationPolicy, TransportStats } from './MediaTransport.ts'; +import type { AudioFrameHandler, MediaTransport, MediaTransportCallbacks, MixedPcmSource, MixFrame, MixPacketSink, NegotiationPolicy, TransportStats } from './MediaTransport.ts'; import { CodecPool, type EncodeItem } from './codecPool.ts'; import { RtpReorderBuffer } from './jitter.ts'; import { MediaShard } from './mediaShards.ts'; @@ -28,6 +28,8 @@ interface FanoutPlan { /** One empty payload per frame: what hosts get instead of an encode while the room is silent. */ silence: Uint8Array[]; targets: [PeerHost, [id: string, frame: number][]][]; + /** Index of the full-mix frame for the recording, -1 when not recording. */ + recordAt: number; } interface Room { id: string; @@ -47,6 +49,9 @@ interface Room { silentTicks: number; /** This tick's routing, reused across ticks; compared against `plan`. */ scratch: { ids: string[]; hosts: PeerHost[]; keys: string[]; pcms: Float32Array[] }; + /** Recording sink of the full mix; packets reach it in mixer order through `recordTail`. */ + recording: MixPacketSink | undefined; + recordTail: Promise; detach?: () => void; } /** Encoder keys of the audience mixes; participant ids (base64url) key their mix-minus encoders. */ @@ -116,7 +121,7 @@ export class WeriftMediaTransport implements MediaTransport { if (!room) { room = { id, peers: new Map(), publishers: new Map(), subscribers: new Set(), uplinks: new Map(), downlinkLoss: new Map(), lowTier: new Set(), silentFrames: new Map(), - plan: undefined, silentTicks: 0, scratch: { ids: [], hosts: [], keys: [], pcms: [] }, + plan: undefined, silentTicks: 0, scratch: { ids: [], hosts: [], keys: [], pcms: [] }, recording: undefined, recordTail: Promise.resolve(), }; this.rooms.set(id, room); } @@ -205,6 +210,11 @@ export class WeriftMediaTransport implements MediaTransport { room.detach?.(); room.detach = undefined; if (source) room.detach = source.onFrame(frame => this.sendFrame(room, frame)); } + setRecording(roomId: string, sink: MixPacketSink | null): void { + const room = this.room(roomId); + room.recording = sink ?? undefined; + room.plan = undefined; + } private sendFrame(room: Room, frame: MixFrame): void { const plan = this.fanout(room, frame); if (!plan.frames.length) return; @@ -213,18 +223,31 @@ export class WeriftMediaTransport implements MediaTransport { if (this.config.audio.opus.dtx && room.silentTicks > SKIP_SILENCE_AFTER_MS / this.config.audio.frameMs && !this.codecs.backlog(room.id)) { this.counters.silentSkipped++; for (const [host, list] of plan.targets) host.send({ roomId: room.id, payloads: plan.silence, targets: list }); + this.record(room, plan, undefined); return; } // Shed frames instead of queueing unbounded latency when the room's worker falls behind. if (this.codecs.backlog(room.id) >= this.config.audio.mixer.maxBufferedFrames) { this.counters.shedFrames++; this.log.warn('Codec worker behind; dropping mixed frame', { roomId: room.id, seq: frame.seq }); + this.record(room, plan, undefined); return; } - this.codecs.encode(room.id, plan.frames).then(payloads => { + const encoded = this.codecs.encode(room.id, plan.frames); + encoded.then(payloads => { if (this.rooms.get(room.id) !== room) return; for (const [host, list] of plan.targets) host.send({ roomId: room.id, payloads, targets: list }); }, (err: Error) => { this.counters.encodeFailures++; this.log.warn('Mixed frame encode failed', { roomId: room.id, error: err.message }); }); + this.record(room, plan, encoded); + } + /** Queue this frame's full-mix packet (none when `encoded` is undefined or fails) behind the previous ones. */ + private record(room: Room, plan: FanoutPlan, encoded: Promise | undefined): void { + const sink = room.recording; + if (!sink || plan.recordAt < 0) return; + const at = plan.recordAt; + const previous = room.recordTail; + const packet = encoded ? encoded.then(payloads => payloads[at] ?? null, () => null) : Promise.resolve(null); + room.recordTail = Promise.all([packet, previous]).then(([p]) => { if (room.recording === sink) sink(p); }); } /** This tick's routing; the cached plan when nothing about it changed. */ private fanout(room: Room, frame: MixFrame): FanoutPlan { @@ -240,7 +263,7 @@ export class WeriftMediaTransport implements MediaTransport { } ids.length = hosts.length = keys.length = pcms.length = n; const cached = room.plan; - if (cached && this.planMatches(cached, n, ids, hosts, keys, pcms)) return cached; + if (cached && this.planMatches(cached, n, ids, hosts, keys, pcms, frame.full)) return cached; const frames: EncodeItem[] = []; const frameOf: number[] = []; const index = new Map(); @@ -254,16 +277,21 @@ export class WeriftMediaTransport implements MediaTransport { if (!list) { list = []; targets.set(hosts[i]!, list); } list.push([ids[i]!, at]); } - room.plan = { ids: ids.slice(), hosts: hosts.slice(), frameOf, frames, silence: frames.map(() => new Uint8Array(0)), targets: [...targets] }; + let recordAt = -1; + if (room.recording) { + // The recording is the audience mix: share its encode, or add it when nobody listens to it. + recordAt = index.get(FULL_MIX) ?? frames.push({ key: FULL_MIX, pcm: frame.full, low: false }) - 1; + } + room.plan = { ids: ids.slice(), hosts: hosts.slice(), frameOf, frames, silence: frames.map(() => new Uint8Array(0)), targets: [...targets], recordAt }; return room.plan; } - private planMatches(plan: FanoutPlan, n: number, ids: string[], hosts: PeerHost[], keys: string[], pcms: Float32Array[]): boolean { + private planMatches(plan: FanoutPlan, n: number, ids: string[], hosts: PeerHost[], keys: string[], pcms: Float32Array[], full: Float32Array): boolean { if (plan.ids.length !== n) return false; for (let i = 0; i < n; i++) { const item = plan.frames[plan.frameOf[i]!]!; if (plan.ids[i] !== ids[i] || plan.hosts[i] !== hosts[i] || item.key !== keys[i] || item.pcm !== pcms[i]) return false; } - return true; + return plan.recordAt < 0 || plan.frames[plan.recordAt]!.pcm === full; } /** * A source's own mix-minus, or undefined once it has been silent for SHARE_SILENT_AFTER_MS: its diff --git a/src/ws/hub.ts b/src/ws/hub.ts index 427bb66..bcc4a97 100644 --- a/src/ws/hub.ts +++ b/src/ws/hub.ts @@ -16,6 +16,7 @@ import { ERROR_MESSAGES, InvariantViolation, StageError } from '../model/errors. import { Room, type RoomEvent } from '../model/room.ts'; import { offerSendsAudio, summarizeOffer } from '../rtc/sdp.ts'; import { iceServersFor } from '../rtc/turn.ts'; +import type { Recording } from '../recording/recorder.ts'; import type { MediaTransport, MixedPcmSource } from '../transport/MediaTransport.ts'; import { SerialQueue } from './serialQueue.ts'; @@ -46,6 +47,8 @@ export interface HubDeps { serverVersion: string; /** Returns a running mixer for a new room. */ createMixer(roomId: string): Mixer; + /** Opens a recording file for a room; absent = recording unavailable whatever the config says. */ + startRecording?(roomId: string): Recording; now?: () => number; setTimer?: (fn: () => void, ms: number) => unknown; clearTimer?: (handle: unknown) => void; @@ -74,6 +77,8 @@ interface RoomRuntime { /** Last `rooms.chatHistory` messages, oldest first. */ readonly chat: ChatMessage[]; nextMessageId: number; + /** Open recording file and its `recording.maxDurationMinutes` stop timer. */ + recording: { file: Recording; timer: unknown } | undefined; lastStatus: 'waiting' | 'live'; closed: boolean; } @@ -100,6 +105,8 @@ export class StageHub { private readonly now: () => number; private readonly setTimer: (fn: () => void, ms: number) => unknown; private readonly clearTimer: (handle: unknown) => void; + /** Recording files still flushing; shutdown waits for them. */ + private readonly closingRecordings = new Set>(); constructor(deps: HubDeps) { this.deps = deps; @@ -200,6 +207,7 @@ export class StageHub { async shutdown(): Promise { for (const rt of [...this.rooms.values()]) await rt.queue.run(() => this.closeRoom(rt, 'shutdown', true)); + await Promise.all(this.closingRecordings); } // ─────────────────────────────── commands ─────────────────────────────── @@ -222,6 +230,7 @@ export class StageHub { code, codeRequired, limits: { maxSpeakers: config.limits.maxSpeakersPerRoom, maxAudience: config.limits.maxAudiencePerRoom }, + recordingAvailable: config.recording.enabled && this.deps.startRecording !== undefined, now: this.now(), controller, }); @@ -239,6 +248,7 @@ export class StageHub { uplinkCounts: new Map(), chat: [], nextMessageId: 1, + recording: undefined, lastStatus: room.status, closed: false, }; @@ -376,6 +386,22 @@ export class StageHub { this.broadcast(rt, { type: 'reaction', participantId: pid, name: room.get(pid)!.name, emoji: msg.emoji }); session.send({ type: 'ok', requestId: msg.requestId }); return; + case 'recording:start': { + room.setRecording(pid, true); + try { + this.startRecording(rt); + } catch (err) { + room.recording = false; + throw err; + } + events = []; + break; + } + case 'recording:stop': + room.setRecording(pid, false); + this.stopRecording(rt); + events = []; + break; case 'rtc:answer': // The server never sends offers, so an answer is always out of protocol. throw new StageError('bad_request', 'unexpected rtc:answer'); @@ -387,6 +413,40 @@ export class StageHub { if (msg.type === 'control:transfer') this.deps.log.info('control transferred', { roomId: room.roomId, fromId: pid, toId: msg.targetId }); } + private startRecording(rt: RoomRuntime): void { + const { roomId } = rt.room; + let file: Recording; + try { + file = this.deps.startRecording!(roomId); + } catch (err) { + this.deps.log.error('recording failed to start', { roomId, error: err instanceof Error ? err.message : String(err) }); + throw new StageError('internal', 'recording open failed'); + } + const timer = this.setTimer(() => { + void rt.queue.run(() => { + if (rt.closed || rt.recording?.file !== file) return; + rt.room.recording = false; + this.stopRecording(rt); + this.deps.log.info('recording reached its time limit', { roomId }); + this.broadcastState(rt); + }).catch((err) => this.internalFailure(rt, err)); + }, this.deps.config.recording.maxDurationMinutes * 60_000); + rt.recording = { file, timer }; + this.deps.transport.setRecording(roomId, file.sink); + this.deps.log.info('recording started', { roomId, file: file.file }); + } + + private stopRecording(rt: RoomRuntime): void { + const recording = rt.recording; + if (!recording) return; + rt.recording = undefined; + this.clearTimer(recording.timer); + this.deps.transport.setRecording(rt.room.roomId, null); + const closing = recording.file.stop().finally(() => this.closingRecordings.delete(closing)); + this.closingRecordings.add(closing); + this.deps.log.info('recording stopped', { roomId: rt.room.roomId, file: recording.file.file }); + } + private newCode(): string { let code = ''; for (let i = 0; i < this.deps.config.rooms.codeLength; i++) code += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)]; @@ -671,6 +731,7 @@ export class StageHub { } rt.sessions.clear(); rt.publishers.clear(); + this.stopRecording(rt); this.deps.transport.setMixedStream(roomId, null); this.deps.transport.closeRoom(roomId); rt.mixer.stop(); diff --git a/src/ws/validate.ts b/src/ws/validate.ts index 2e7a79a..acfe650 100644 --- a/src/ws/validate.ts +++ b/src/ws/validate.ts @@ -130,6 +130,8 @@ export function parseClientMessage(text: string, limits: ValidationLimits): Clie case 'stage:leave': case 'mic:mute': case 'mic:unmute': + case 'recording:start': + case 'recording:stop': return { type: t, requestId: rid }; case 'stage:approve': case 'stage:reject': diff --git a/test/helpers.ts b/test/helpers.ts index da12cce..83b8909 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -6,7 +6,7 @@ import { parseConfig, type AppConfig } from '../src/config.ts'; import { silentLogger } from '../src/log.ts'; import { RoomMixer } from '../src/mixer/RoomMixer.ts'; import { MockMediaTransport } from '../src/transport/MockMediaTransport.ts'; -import { StageHub, type Session } from '../src/ws/hub.ts'; +import { StageHub, type HubDeps, type Session } from '../src/ws/hub.ts'; export function testConfig(patch?: (c: AppConfig) => void): AppConfig { const config = parseConfig(parse(readFileSync(new URL('../config.example.yaml', import.meta.url), 'utf8'))); @@ -57,7 +57,7 @@ type DistributiveOmit = T extends unknown ? Omit let rid = 0; -export function harness(config = testConfig()): Harness { +export function harness(config = testConfig(), extra?: Pick): Harness { const timers: Harness['timers'] = []; const clock = { now: 1_000_000 }; const mixers = new Map(); @@ -86,6 +86,7 @@ export function harness(config = testConfig()): Harness { mixers.set(roomId, m); return m; }, + ...extra, }); const h: Harness = { hub, diff --git a/test/hub.test.ts b/test/hub.test.ts index e56e6f8..a9a19aa 100644 --- a/test/hub.test.ts +++ b/test/hub.test.ts @@ -621,3 +621,59 @@ describe('chat and reactions', () => { assert.equal(await h.req(stranger, { type: 'chat:send', text: 'hi' }), 'not_joined'); }); }); + +describe('recording', () => { + function recorderHarness(enabled = true) { + const files: { roomId: string; packets: (Uint8Array | null)[]; stopped: boolean }[] = []; + const h = harness(testConfig((c) => { c.recording.enabled = enabled; c.recording.maxDurationMinutes = 1; }), { + startRecording: (roomId) => { + const file = { roomId, packets: [] as (Uint8Array | null)[], stopped: false }; + files.push(file); + return { file: `${roomId}.opus`, sink: (p) => { if (!file.stopped) file.packets.push(p); }, stop: async () => { file.stopped = true; } }; + }, + }); + return { h, files }; + } + + it('lets only the controller record, shows it to everyone and feeds the mix to the file until stopped', async () => { + const { h, files } = recorderHarness(); + const host = await h.create(); + const aud = await h.join(host.roomId, host.code, 'Alice'); + assert.equal(aud.s.last('room:state')!.recordingAvailable, true); + assert.equal(await h.req(aud.s, { type: 'recording:start' }), 'forbidden'); + assert.equal(await h.req(host.s, { type: 'recording:stop' }), 'conflict', 'nothing to stop'); + assert.equal(await h.req(host.s, { type: 'recording:start' }), 'ok'); + assert.equal(aud.s.last('room:state')!.recording, true, 'the audience is told'); + assert.equal(await h.req(host.s, { type: 'recording:start' }), 'conflict'); + // Frames exist only while someone feeds the mixer: the controller publishes. + assert.equal(await h.req(host.s, { type: 'rtc:offer', payload: SENDRECV_OFFER }), 'ok'); + h.mixers.get(host.roomId)!.tick(); + h.mixers.get(host.roomId)!.tick(); + assert.equal(files[0]!.packets.length, 2, 'one packet per mixed frame'); + assert.equal(await h.req(host.s, { type: 'recording:stop' }), 'ok'); + assert.equal(files[0]!.stopped, true); + assert.equal(aud.s.last('room:state')!.recording, false); + h.mixers.get(host.roomId)!.tick(); + assert.equal(files[0]!.packets.length, 2, 'nothing after stop'); + }); + + it('stops on its own after maxDurationMinutes and when the room closes', async () => { + const { h, files } = recorderHarness(); + const host = await h.create(); + assert.equal(await h.req(host.s, { type: 'recording:start' }), 'ok'); + h.timers.find((t) => !t.cleared && t.ms === 60_000)!.fn(); + await nextTurn(); + assert.equal(files[0]!.stopped, true); + assert.equal(host.s.last('room:state')!.recording, false); + assert.equal(await h.req(host.s, { type: 'recording:start' }), 'ok', 'can record again'); + assert.equal(await h.req(host.s, { type: 'room:close' }), 'ok'); + assert.equal(files[1]!.stopped, true); + }); + + it('is unavailable when the server disables it', async () => { + const { h } = recorderHarness(false); + const host = await h.create(); + assert.equal(host.s.last('room:state')!.recordingAvailable, false); + assert.equal(await h.req(host.s, { type: 'recording:start' }), 'forbidden'); + }); +}); diff --git a/test/oggOpus.test.ts b/test/oggOpus.test.ts new file mode 100644 index 0000000..175e616 --- /dev/null +++ b/test/oggOpus.test.ts @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { OggOpusWriter, oggCrc } from '../src/recording/oggOpus.ts'; + +interface Page { flags: number; granule: bigint; seq: number; serial: number; packets: Uint8Array[] } + +/** Splits a stream into pages, checking capture pattern and CRC, and rebuilds packets from lacing. */ +function parse(bytes: Uint8Array): Page[] { + const pages: Page[] = []; + let at = 0; + while (at < bytes.length) { + const view = new DataView(bytes.buffer, bytes.byteOffset + at); + assert.equal(String.fromCharCode(...bytes.subarray(at, at + 4)), 'OggS'); + const segments = bytes[at + 26]!; + const lacing = bytes.subarray(at + 27, at + 27 + segments); + const length = 27 + segments + lacing.reduce((n, l) => n + l, 0); + const page = bytes.slice(at, at + length); + const crc = view.getUint32(22, true); + new DataView(page.buffer).setUint32(22, 0, true); + assert.equal(oggCrc(page), crc, 'page CRC'); + const packets: Uint8Array[] = []; + let body = at + 27 + segments, size = 0; + for (const l of lacing) { + size += l; + if (l < 255) { packets.push(bytes.slice(body, body + size)); body += size; size = 0; } + } + pages.push({ flags: bytes[at + 5]!, granule: view.getBigUint64(6, true), seq: view.getUint32(18, true), serial: view.getUint32(14, true), packets }); + at += length; + } + return pages; +} + +function record(feed: (w: OggOpusWriter) => void): Page[] { + const chunks: Uint8Array[] = []; + const w = new OggOpusWriter((c) => chunks.push(c), { frameSamples: 960, serial: 7, vendor: 'test' }); + feed(w); + w.close(); + // A plain copy: Buffer#slice would alias the pool and break the CRC check. + return parse(new Uint8Array(Buffer.concat(chunks))); +} + +describe('Ogg Opus writer', () => { + it('writes OpusHead and OpusTags pages, then audio with granule = samples so far and EOS last', () => { + const pages = record((w) => { for (let i = 0; i < 120; i++) w.packet(Uint8Array.of(0xf8, i)); }); + assert.equal(pages[0]!.flags, 0x02); + assert.equal(String.fromCharCode(...pages[0]!.packets[0]!.subarray(0, 8)), 'OpusHead'); + assert.equal(pages[0]!.packets[0]![9], 1, 'mono'); + assert.equal(String.fromCharCode(...pages[1]!.packets[0]!.subarray(0, 8)), 'OpusTags'); + const audio = pages.slice(2); + assert.deepEqual(audio.flatMap((p) => p.packets).map((p) => p[1]), Array.from({ length: 120 }, (_, i) => i)); + let total = 0n; + for (const p of audio) { total += BigInt(p.packets.length) * 960n; assert.equal(p.granule, total); } + assert.equal(audio.at(-1)!.flags, 0x04); + assert.deepEqual(pages.map((p) => p.seq), pages.map((_, i) => i)); + assert.ok(pages.every((p) => p.serial === 7)); + }); + + it('conceals missing frames with TOC-only packets after the first real one and drops them before', () => { + const pages = record((w) => { w.packet(null); w.packet(Uint8Array.of(0xf9, 1, 2)); w.packet(null); w.packet(new Uint8Array(0)); }); + assert.deepEqual(pages.slice(2).flatMap((p) => p.packets).map((p) => [...p]), [[0xf9, 1, 2], [0xf8], [0xf8]]); + assert.equal(pages.at(-1)!.granule, 3n * 960n); + }); + + it('keeps pages within 255 lacing segments for large packets', () => { + const big = new Uint8Array(1275).fill(1); + big[0] = 0xf8; + const pages = record((w) => { for (let i = 0; i < 60; i++) w.packet(big); }); + assert.equal(pages.slice(2).reduce((n, p) => n + p.packets.length, 0), 60); + assert.ok(pages.slice(2).every((p) => p.packets.every((x) => x.length === 1275))); + }); + + it('writes no audio pages for a recording that never got a packet', () => { + assert.equal(record(() => {}).length, 2); + }); +}); diff --git a/web/src/main.ts b/web/src/main.ts index 3a16d0a..3104131 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -239,6 +239,7 @@ function renderRoom(state: RoomStatePayload): void { const controller = state.me.role === 'controller'; const summary = element('section', 'panel room-summary'); summary.append(element('h2', '', '房間資訊'), element('p', '', `房間 ID:${state.roomId}`)); + if (state.recording) summary.append(element('p', 'badge recording', '● 錄音中:此房間的混音正在被錄下')); const roomCode = state.code ?? (createdRoom?.roomId === state.roomId ? createdRoom.code : undefined); if (controller) { summary.append(element('p', '', state.codeRequired ? `房間代碼:${roomCode ?? '未提供'}` : '不需要房間代碼')); @@ -259,6 +260,11 @@ function renderRoom(state: RoomStatePayload): void { await client.rotateCode(); invitation = '已更換房間代碼,請重新分享邀請連結。'; renderNotices(); })); + if (state.recordingAvailable) summary.append(state.recording + ? button('停止錄音', () => client.stopRecording(), 'danger') + : button('開始錄音', async () => { + if (window.confirm('錄音會把所有人聽到的混音存到伺服器,房內每個人都會看到「錄音中」。確定開始?')) await client.startRecording(); + })); } const controls = element('section', 'panel'); controls.append(element('h2', '', `你好,${decodeName(state.me.name)}`), element('p', '', `${roleLabels[state.me.role]}${state.me.forceMuted ? ' · 主控已鎖定靜音' : ''}`), connectionLine); diff --git a/web/src/styles.css b/web/src/styles.css index 537e836..01025f9 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -42,6 +42,7 @@ button.danger { color: var(--danger); border-color: var(--danger); } .gain input { width: 140px; min-height: 44px; padding: 0; border: 0; background: none; accent-color: var(--accent); } .badge { display: inline-flex; align-items: center; padding: 4px 9px; border: 1px solid var(--border); border-radius: 99px; color: var(--muted); font-size: .85rem; } .badge.quality { color: var(--danger); border-color: var(--danger); cursor: help; } +.badge.recording { color: var(--danger); border-color: var(--danger); } .muted { color: var(--muted); } .banner { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; border: 1px solid var(--accent); background: var(--panel); border-radius: 12px; padding: 14px; margin: 0 0 16px; } .error { border-color: var(--danger); color: var(--danger); } From ab119d419721bc44e1332819da6202d7390055d0 Mon Sep 17 00:00:00 2001 From: Yueyu Hoshizora Date: Sat, 10 Oct 2026 21:15:00 +0800 Subject: [PATCH 5/8] feat: persist rooms and resume them after a server restart --- AGENTS.md | 1 + PLAN.md | 3 +- README.md | 5 +- config.example.yaml | 7 ++ packages/client/src/index.ts | 12 ++- shared/protocol.ts | 3 +- src/config.ts | 17 +++- src/index.ts | 31 +++++- src/model/room.ts | 74 +++++++++++++++ src/persistence.ts | 48 ++++++++++ src/ws/hub.ts | 178 +++++++++++++++++++++++++++-------- src/ws/server.ts | 7 +- test/hub.test.ts | 71 ++++++++++++++ web/src/main.ts | 8 +- 14 files changed, 415 insertions(+), 50 deletions(-) create mode 100644 src/persistence.ts diff --git a/AGENTS.md b/AGENTS.md index 94a9411..2c51d74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,7 @@ node scripts/load-test.ts # 全端壓測:自起伺服器,K 發言者 | `src/transport/MockMediaTransport.ts` | 測試/無 WebRTC 開發用 | | `src/mixer/` | `RoomMixer`(N 路疊加、mix-minus-self、每路 playout 預緩衝/underrun 重緩衝/漂移排空/常駐延遲收斂、預配置 ring buffer、緩衝上限)、`MixerClock`(全部房間共用一個 tick 時鐘)、`noiseFilter`、`loudness`(每路音量正規化)、`limiter` | | `src/recording/` | `oggOpus.ts`(RFC 7845 Ogg/Opus 封裝,`null` 幀補 TOC-only 封包)、`recorder.ts`(寫 `recording.dir`);封包來自 `MediaTransport.setRecording`,即共用的完整混音編碼,不另開 encoder | +| `src/persistence.ts` | `persistence.stateFile` 讀寫(原子 rename、mode 600、過期不採用);內容由 `StageHub.exportState()`/`restore()` 產生與重建,含房間代碼與 resumeToken,屬機密 | | `packages/client/` | 可嵌入的瀏覽器 ESM 函式庫 `StageClient`;`src/element.ts`=Web Component ``(入口 `stage-element.ts` 自動註冊) | | `web/` | 完整範例前端(建於 client 之上) | | `src/metrics.ts` | Prometheus 文字輸出、跨房共用的 `MixerCounters`、event loop/記憶體取樣;`/metrics` 由 `server.metrics` 控制(對外 host 必須設 token) | diff --git a/PLAN.md b/PLAN.md index ecf2868..7d26fe6 100644 --- a/PLAN.md +++ b/PLAN.md @@ -36,7 +36,7 @@ ### 範圍外 - 影片、螢幕分享。(文字聊天與表情反應已於 2026-10-10 依需求加入,見十二-22。) - 回放、直播轉推(RTMP/HLS)。(伺服器端 Ogg/Opus 錄音已於 2026-10-10 依需求加入,見十二-23。) -- 帳號系統與持久化(使用者、舞台、操作紀錄均記憶體保存,重啟即失)。 +- 帳號系統。(房間狀態可選擇存檔、重啟後恢復,2026-10-10 依需求加入,見十二-24;預設仍只存在記憶體。) - 套件發布(npm):只開源倉庫供 clone,不發套件(見假設 6)。 - 其他網站的整合案本身(各站程式);本專案交付 client 函式庫+範例前端。 @@ -321,6 +321,7 @@ N 路混音管線(decode→mix→encode)、不含自己的台上混音、lim 21. **Web Component**:`/lib/stage-element.js` 註冊 ``(shadow DOM 小工具:狀態、錯誤、啟用音訊、舉手、台上靜音/下台),進入文件即進房、移除即以 1000 離開;client 事件以 `stage-` 派發,`element.client` 暴露底層 `StageClient` 供主控功能;它從穩定檔名 `stage-client.js` 匯入,嵌入頁同時使用兩者時只載入一份。 22. **文字聊天與表情反應**(2026-10-10 依需求加入):C→S `chat:send {text}`、`reaction {emoji}`;S→C `chat {messageId, participantId, name, text, sentAt}`、`reaction {participantId, name, emoji}`、`chat:history {messages}`(進房與回座時補送最近 `rooms.chatHistory` 則,記憶體保存)。聊天內容比照 name 清洗(保留換行、最多連續兩個空行)後 HTML 轉義,長度 ≤ `limits.chatMaxLength`;emoji 限 `REACTIONS` 白名單。每連線聊天每 `limits.chatIntervalMs`、反應每 `limits.reactionIntervalMs` 至多一次,超過比照 `hand:raise` 以 1008 斷線,兩值經 `hello.limits` 下發供 client 自行節流。聊天內容不入日誌。伺服器錯誤訊息維持固定英文,client 依 `code` 換成繁中(`ERROR_MESSAGES`)。 23. **錄音**(2026-10-10 依需求加入):C→S `recording:start`/`recording:stop`(主控限定,伺服器 `recording.enabled` 才可用,否則 `forbidden`;重複開始/停止為 `conflict`)。`MediaTransport.setRecording(roomId, sink)` 把觀眾那一路完整混音的**既有共用編碼**依混音順序交給 sink(沒人收聽完整混音時才額外加一份編碼),不另開 encoder;靜音略過編碼、codec 落後丟幀或編碼失敗的幀以 `null` 交付,寫檔時補成只有 TOC 的零長度幀(解碼端 PLC),時間軸不位移。`src/recording/oggOpus.ts` 依 RFC 7845 封裝(mono、pre-skip 0、每頁至多 50 包且 ≤255 lacing),`recorder.ts` 寫 `/-.opus`;寫檔失敗只記 error、不影響房間。`room:state` 增 `recording`(錄音中,所有人都看得到)與 `recordingAvailable`。單次錄音逾 `recording.maxDurationMinutes` 自動停止;關房、停機時停止並等檔案寫完。 +24. **重啟後恢復**(2026-10-10 依需求加入):`persistence.stateFile` 非空時,hub 每 `saveIntervalMs` 在狀態有變動時(`stateRevision`)把所有房間(`Room.snapshotForRestart()`:代碼、參與者與 resumeToken、台位、舉手順序、靜音與增益;聊天紀錄)原子寫入 JSON(mode 600),停機時再寫一次。此時停機**不送** `room:closed`,連線以 1001 關閉,client 視同斷線自動重連;新程序啟動時 `Room.restore()` 逐欄重建並以目前設定的上限重驗不變量(不符者略過並記 warning),所有人標為斷線、保留席位 `restoreGraceMs`(主控取它與 `controllerGraceMs` 較大者),期間以 resumeToken `join` 回座,逾時照一般斷線規則移除或自動移交、無人回來即關房。`hello.instance` 每次啟動不同,client 回座時發現變了就重建 PeerConnection(舊的對端已不存在)。錄音不跨重啟:停機時停止並寫完檔案。狀態檔超過 `restoreGraceMs` 未更新即不採用。 --- diff --git a/README.md b/README.md index e0ca600..33d81d1 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ Windows 用 `.\stage.ps1 `,指令相同;Windows 無法對背景 nod | `audio` | `sampleRate`(48000;只接受 Opus 原生取樣率)、`frameMs`(20)、`codecWorkers`(Opus 編解碼 worker 數,房間平均分配到各 worker)、`opus.{vbr,minBitrate,maxBitrate,bitrate,complexity}`、`opus.fec`/`opus.packetLossPercent`(下行 in-band FEC 與預期掉包率)、`opus.dtx`(靜音不送包)、`lowTier.{enabled,bitrate,packetLossPercent,enterLossPercent,exitLossPercent}`(RTCP 接收報告顯示持續掉包的觀眾改收第二路共用混音:較低位元率+較多 FEC,掉包回落後切回)、`mixer.{maxBufferedFrames,limiterThreshold}`、`mixer.latencyTargetMs`(僅供 `bench-mixer`/`load-test` 當驗收門檻)、`mixer.speakingThreshold`/`speakingHoldMs`(說話指示的 RMS 門檻與釋放延遲)、`jitter.playoutFrames`(每路上行預緩衝幀數)、`jitter.reorderPackets`(亂序容忍包數,超過即判定遺失並補幀)、`noiseFilter.{enabled,highPassHz,gateThreshold,gateHoldMs,gateFloor}`(伺服器端上行降噪:高通濾掉低頻雜音+噪音門壓低說話間隙的背景音;瀏覽器端另開 `noiseSuppression`)、`loudness.{enabled,targetRms,maxGainDb,speechRms,adaptMs}`(伺服器端每路音量正規化:依說話時的平均音量把各發言者拉到相近大小,增益上限 ±maxGainDb) | | `rtc` | `iceServers`(下發給瀏覽器的靜態 STUN)、`serverIceServers`(伺服器端 ICE)、`portRange`(`[]` 或 `[min, max]`)、`mediaWorkers`(承載 PeerConnection 的 worker thread 數,預設 2 對應 300 聽眾;0=主執行緒;`auto`=CPU 核心數 − 1 − `audio.codecWorkers`,限制在 2–8)、`turn.{urls,secret,ttlSeconds}`(coturn `use-auth-secret` 短期憑證,每次進房以 HMAC 簽發;`urls: []` 停用) | | `recording` | `enabled`(主控可否錄音,預設 false)、`dir`(`.opus` 存放目錄,相對路徑以專案根目錄為準)、`maxDurationMinutes`(單次錄音上限,逾時自動停止) | +| `persistence` | `stateFile`(非空時把房間存成 JSON、重啟後恢復;含房間代碼與 resumeToken,檔案權限 600,勿入庫;空字串停用)、`saveIntervalMs`(有變動時多久寫一次,停機時另寫一次)、`restoreGraceMs`(恢復後等參與者回來的時間;停機超過此時間的狀態檔不採用) | | `log` | `level`:`debug`/`info`/`warn`/`error`(房間代碼、token、憑證、SDP 一律不入日誌) | 對外部署:`allowInsecure: false`、`host: 0.0.0.0`、填 `tls`;開放 `rtc.portRange` 的 UDP;設定 `rtc.turn`(coturn 需 `use-auth-secret` 與相同的 `static-auth-secret`)。憑證檔更新(含 ACME 工具的改名/symlink 替換)後約 2 秒自動重載,不需重啟;新憑證載入失敗時沿用舊憑證並記 error。 @@ -110,9 +111,9 @@ JSON frame,型別定義在 [`shared/protocol.ts`](shared/protocol.ts)。每個 | Server → Client | 說明 | |---|---| -| `hello` | `{protocol, serverVersion, limits: {controlPerSecond, icePerSecond, handRaiseIntervalMs, chatIntervalMs, reactionIntervalMs}}`:client 依 `limits` 自行節流與舉手冷卻 | +| `hello` | `{protocol, serverVersion, instance, limits: {controlPerSecond, icePerSecond, handRaiseIntervalMs, chatIntervalMs, reactionIntervalMs}}`:client 依 `limits` 自行節流與舉手冷卻;`instance` 每次伺服器啟動都不同,回座時發現它變了就重建 PeerConnection | | `room:state` | 個人化 snapshot(每次變動重送);每人帶 `connected`(ws 斷線、席位保留中為 false);`code`、`audience` 名單只給主控,`resumeToken` 只給本人 | -| `room:created`、`room:closed` | 開房(只有建立者收到 code)、關房(全員離房);伺服器停止時 `room:closed` 帶 `reason: 'shutdown'`,連線隨後以 close code 1001 關閉 | +| `room:created`、`room:closed` | 開房(只有建立者收到 code)、關房(全員離房);伺服器停止時 `room:closed` 帶 `reason: 'shutdown'`,連線隨後以 close code 1001 關閉(開啟 `persistence` 時不送 `room:closed`,只以 1001 關閉,client 自動重連回座) | | `kicked` | `{roomId}`:你被主控踢出,伺服器隨即以 close code 4001 關閉連線 | | `rtc:config` | 瀏覽器用的 ICE servers(設定 `rtc.turn` 時含該參與者專屬的短期 TURN 憑證) | | `speaking` | `{participantIds}`:目前在混音中有聲的參與者(VAD+釋放延遲),集合變動時才送 | diff --git a/config.example.yaml b/config.example.yaml index 2e5fbb3..9e3920d 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -125,5 +125,12 @@ recording: dir: recordings # 相對路徑以專案根目錄為準;檔名 -.opus maxDurationMinutes: 240 # 單次錄音上限,逾時自動停止(防止塞滿磁碟;可調整) +persistence: + # 非空時把房間狀態(含房間代碼與 resumeToken,屬機密,檔案權限 600)存到此 JSON 檔,重啟後恢復: + # 停機時不關房,client 自動重連並以 resumeToken 回座(台位、舉手順位、聊天紀錄保留,音訊連線重建)。 + stateFile: "" # 例:.run/stage-state.json(相對路徑以專案根目錄為準;空字串=停用) + saveIntervalMs: 2000 # 狀態有變動時多久寫一次(停機時另寫一次;可調整) + restoreGraceMs: 120000 # 恢復後等參與者回來的時間;停機比這更久的狀態檔不採用(可調整) + log: level: info # debug | info | warn | error diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index d17db10..bf6a7f9 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -179,6 +179,9 @@ export class StageClient extends EventTarget { private lastHeardAt = 0; private iceServers: RTCIceServer[] = []; private iceRestartTimer: ReturnType | undefined; + private serverInstance: string | undefined; + /** Set when we reconnected to a restarted server: the next snapshot rebuilds the PeerConnection. */ + private mediaStale = false; constructor(options: StageClientOptions) { super(); @@ -297,7 +300,12 @@ export class StageClient extends EventTarget { this.lastHeardAt = Date.now(); try { const message = JSON.parse(String(event.data)) as ServerMessage; - if (message.type === 'hello') { this.limits = message.limits; ready = true; clearTimeout(timer); this.startKeepalive(ws); resolve(); } + if (message.type === 'hello') { + // A different server process (restart with persisted rooms): our PeerConnection's other end is gone. + if (this.serverInstance !== undefined && this.serverInstance !== message.instance) this.mediaStale = true; + this.serverInstance = message.instance; + this.limits = message.limits; ready = true; clearTimeout(timer); this.startKeepalive(ws); resolve(); + } void this.receive(message).catch((error: unknown) => this.report(error)); } catch { this.report(new StageError('bad_request', '收到無法解析的伺服器訊息。')); } }; @@ -490,7 +498,7 @@ export class StageClient extends EventTarget { // First snapshot of a (new) identity → fresh PeerConnection. A resumed seat keeps the one that // survived the ws drop unless the network side died meanwhile. const pcState = this.pc?.connectionState; - if (!this.pc || previousId !== state.me.participantId || pcState === 'failed' || pcState === 'closed') this.createMedia(); + if (!this.pc || this.mediaStale || previousId !== state.me.participantId || pcState === 'failed' || pcState === 'closed') { this.mediaStale = false; this.createMedia(); } this.syncMedia(); break; } diff --git a/shared/protocol.ts b/shared/protocol.ts index b36a6dc..561ab30 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -167,7 +167,8 @@ export type ErrorCode = // ───────────────────────────── Server → Client ───────────────────────────── export interface ServerMessageMap { - hello: { protocol: number; serverVersion: string; limits: RateLimits }; + /** `instance` changes on every server start: a client resuming its seat across a restart knows to rebuild its PeerConnection. */ + hello: { protocol: number; serverVersion: string; instance: string; limits: RateLimits }; 'room:state': RoomStatePayload; 'room:created': { roomId: string; code?: string }; 'room:closed': { roomId: string; reason?: 'shutdown' }; diff --git a/src/config.ts b/src/config.ts index 20e0237..a209b9a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -129,6 +129,14 @@ export interface AppConfig { /** A recording stops on its own after this long, bounding disk use. */ maxDurationMinutes: number; }; + persistence: { + /** JSON file the rooms are saved to and restored from; empty disables persistence. */ + stateFile: string; + /** How often a changed state is written (also written on shutdown). */ + saveIntervalMs: number; + /** Restored participants get this long to reconnect; a file older than this is ignored. */ + restoreGraceMs: number; + }; log: { level: 'debug' | 'info' | 'warn' | 'error' }; } @@ -368,11 +376,18 @@ export function parseConfig(raw: unknown): AppConfig { }; if (recording.enabled && recording.dir === '') throw new ConfigError('recording.dir: required when recording is enabled'); + const ps = obj(root.persistence, 'persistence'); + const persistence: AppConfig['persistence'] = { + stateFile: str(ps, 'stateFile', 'persistence'), + saveIntervalMs: int(ps, 'saveIntervalMs', 'persistence', 500, 600_000), + restoreGraceMs: int(ps, 'restoreGraceMs', 'persistence', 1000), + }; + const lg = obj(root.log, 'log'); const level = str(lg, 'level', 'log'); if (!['debug', 'info', 'warn', 'error'].includes(level)) throw new ConfigError('log.level: debug|info|warn|error'); - return { server, limits, rooms, audio, rtc, recording, log: { level: level as AppConfig['log']['level'] } }; + return { server, limits, rooms, audio, rtc, recording, persistence, log: { level: level as AppConfig['log']['level'] } }; } export function loadConfig(path: string): AppConfig { diff --git a/src/index.ts b/src/index.ts index a1fd8df..206c619 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { MixerCounters, processSamples, renderPrometheus } from './metrics.ts'; import { MixerClock } from './mixer/MixerClock.ts'; import { RoomMixer } from './mixer/RoomMixer.ts'; import { startRecording } from './recording/recorder.ts'; +import { loadState, saveState } from './persistence.ts'; import { WeriftMediaTransport } from './transport/WeriftMediaTransport.ts'; import { StageHub } from './ws/hub.ts'; import { createStageServer } from './ws/server.ts'; @@ -74,6 +75,25 @@ const stageHub = new StageHub({ }); hub = stageHub; +const stateFile = config.persistence.stateFile && resolve(root, config.persistence.stateFile); +let saveTimer: NodeJS.Timeout | undefined; +let saving: Promise = Promise.resolve(); +if (stateFile) { + const saved = loadState(stateFile, config.persistence.restoreGraceMs, Date.now(), log); + if (saved) log.info('rooms restored from state file', { restored: stageHub.restore(saved), saved: saved.rooms.length }); + let savedRevision = -1; + const save = () => { + const revision = stageHub.stateRevision; + if (revision === savedRevision) return; + savedRevision = revision; + saving = saving.then(() => saveState(stateFile, stageHub.exportState())).catch((err: Error) => log.error('state file write failed', { error: err.message })); + }; + // Written right away too, so a restored-but-unchanged state is not lost to an early crash. + save(); + saveTimer = setInterval(save, config.persistence.saveIntervalMs); + saveTimer.unref(); +} + const processMetrics = processSamples(); const server = createStageServer({ config, @@ -96,7 +116,13 @@ const stop = async (signal: string, code = 0) => { if (stopping) return; stopping = true; log.info('shutting down', { signal }); - await server.close(); + clearInterval(saveTimer); + // With persistence, rooms survive the restart: clients just reconnect and resume their seats. + await server.close(Boolean(stateFile)); + if (stateFile) { + await saving; + await saveState(stateFile, stageHub.exportState()).catch((err: Error) => log.error('state file write failed', { error: err.message })); + } await transport.close(); process.exit(code); }; @@ -104,7 +130,8 @@ process.on('SIGINT', () => void stop('SIGINT')); process.on('SIGTERM', () => void stop('SIGTERM')); // A stray rejection (typically a WebRTC stack timer firing after a peer closed) is logged, not fatal: // Node's default would take every room down. An uncaught exception may have left state inconsistent, -// so the rooms are told the server is going away and the process exits non-zero for the supervisor. +// so the process stops (rooms closed, or saved for restore — each one is re-validated then) and exits +// non-zero for the supervisor. process.on('unhandledRejection', (reason) => { log.error('unhandled rejection', { error: reason instanceof Error ? reason.stack ?? reason.message : String(reason) }); }); diff --git a/src/model/room.ts b/src/model/room.ts index ff5ab3d..93f7dc3 100644 --- a/src/model/room.ts +++ b/src/model/room.ts @@ -53,6 +53,21 @@ export type RoomEvent = Extract< } >; +/** Everything needed to rebuild a room after a restart (`persistence.stateFile`). Holds the code and resume tokens: secret. */ +export interface RoomSnapshot { + roomId: string; + name: string; + code: string; + codeRequired: boolean; + createdAt: number; + controllerId: string; + participants: Omit[]; + speakers: string[]; + handQueue: string[]; +} + +const ROLES: Record = { controller: true, speaker: true, audience: true }; + /** * One stage. Pure, synchronous state machine: every method validates fully * before mutating (fail-closed), then re-checks invariants. No I/O here — the @@ -109,6 +124,65 @@ export class Room { this.assertInvariants(); } + /** + * Rebuild a persisted room with everyone disconnected (they reclaim seats with their resume + * tokens). Fields are re-read one by one and invariants re-checked against today's limits, so a + * damaged file or tightened config fails here instead of corrupting a live room. + */ + static restore(s: RoomSnapshot, limits: RoomLimits, recordingAvailable: boolean): Room { + const bad = (why: string): never => { throw new InvariantViolation(`restore: ${why}`); }; + const text = (v: unknown, what: string): string => (typeof v === 'string' ? v : bad(what)); + const num = (v: unknown, what: string): number => (typeof v === 'number' && Number.isFinite(v) ? v : bad(what)); + const flag = (v: unknown, what: string): boolean => (typeof v === 'boolean' ? v : bad(what)); + if (!Array.isArray(s.participants) || !Array.isArray(s.speakers) || !Array.isArray(s.handQueue)) bad('lists'); + const participants: Participant[] = s.participants.map((p) => ({ + participantId: text(p.participantId, 'participantId'), + name: text(p.name, 'name'), + resumeToken: text(p.resumeToken, 'resumeToken'), + joinedAt: num(p.joinedAt, 'joinedAt'), + role: ROLES[p.role] ? p.role : bad('role'), + onStage: flag(p.onStage, 'onStage'), + stageSince: num(p.stageSince, 'stageSince'), + handRaised: flag(p.handRaised, 'handRaised'), + selfMuted: flag(p.selfMuted, 'selfMuted'), + forceMuted: flag(p.forceMuted, 'forceMuted'), + gainDb: num(p.gainDb, 'gainDb'), + connected: false, + })); + const controller = participants.find((p) => p.participantId === s.controllerId) ?? bad('controller missing'); + const room = new Room({ + roomId: text(s.roomId, 'roomId'), + name: text(s.name, 'name'), + code: text(s.code, 'code'), + codeRequired: flag(s.codeRequired, 'codeRequired'), + limits, + recordingAvailable, + now: num(s.createdAt, 'createdAt'), + controller, + }); + room.participants.clear(); + room.speakers.clear(); + for (const p of participants) room.participants.set(p.participantId, p); + for (const id of s.speakers) room.speakers.add(text(id, 'speaker')); + for (const id of s.handQueue) room.handQueue.push(text(id, 'hand')); + room.assertInvariants(); + return room; + } + + snapshotForRestart(): RoomSnapshot { + return { + roomId: this.roomId, + name: this.name, + code: this.code, + codeRequired: this.codeRequired, + createdAt: this.createdAt, + controllerId: this.controllerId, + participants: [...this.participants.values()].map(({ connected: _, ...p }) => p), + speakers: [...this.speakers], + handQueue: [...this.handQueue], + }; + } + get audienceCount(): number { let n = 0; for (const p of this.participants.values()) if (p.role === 'audience') n++; diff --git a/src/persistence.ts b/src/persistence.ts new file mode 100644 index 0000000..368c3d8 --- /dev/null +++ b/src/persistence.ts @@ -0,0 +1,48 @@ +import { readFileSync } from 'node:fs'; +import { mkdir, rename, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import type { Logger } from './log.ts'; +import type { PersistedState } from './ws/hub.ts'; + +/** + * Reads the saved rooms. A missing file is a normal first start; an unreadable, foreign or + * too-old file is logged and ignored (nobody's seat would have survived that long anyway). + */ +export function loadState(file: string, maxAgeMs: number, now: number, log: Logger): PersistedState | undefined { + let text: string; + try { + text = readFileSync(file, 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') log.error('state file unreadable', { error: (err as Error).message }); + return undefined; + } + let state: unknown; + try { + state = JSON.parse(text); + } catch { + log.error('state file is not valid JSON, ignored'); + return undefined; + } + const s = state as Partial | null; + if (!s || s.version !== 1 || typeof s.savedAt !== 'number' || !Array.isArray(s.rooms)) { + log.error('state file has an unknown format, ignored'); + return undefined; + } + if (now - s.savedAt > maxAgeMs) { + log.info('state file older than persistence.restoreGraceMs, ignored', { ageMs: now - s.savedAt }); + return undefined; + } + return s as PersistedState; +} + +/** + * Writes atomically (temp file, then rename) so a crash mid-write leaves the previous state. + * Mode 600: the file holds room codes and resume tokens. Async, so the mixer clock is not stalled + * by the disk; only the JSON.stringify runs on the main thread. + */ +export async function saveState(file: string, state: PersistedState): Promise { + await mkdir(dirname(file), { recursive: true }); + const tmp = `${file}.tmp`; + await writeFile(tmp, JSON.stringify(state), { mode: 0o600 }); + await rename(tmp, file); +} diff --git a/src/ws/hub.ts b/src/ws/hub.ts index bcc4a97..deb3a19 100644 --- a/src/ws/hub.ts +++ b/src/ws/hub.ts @@ -13,7 +13,7 @@ import type { AppConfig } from '../config.ts'; import type { Logger } from '../log.ts'; import type { MetricSample } from '../metrics.ts'; import { ERROR_MESSAGES, InvariantViolation, StageError } from '../model/errors.ts'; -import { Room, type RoomEvent } from '../model/room.ts'; +import { Room, type RoomEvent, type RoomSnapshot } from '../model/room.ts'; import { offerSendsAudio, summarizeOffer } from '../rtc/sdp.ts'; import { iceServersFor } from '../rtc/turn.ts'; import type { Recording } from '../recording/recorder.ts'; @@ -83,6 +83,19 @@ interface RoomRuntime { closed: boolean; } +export interface PersistedRoom { + room: RoomSnapshot; + chat: ChatMessage[]; + nextMessageId: number; +} + +/** `persistence.stateFile` contents. */ +export interface PersistedState { + version: 1; + savedAt: number; + rooms: PersistedRoom[]; +} + const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; /** Application close code (RFC 6455 private range) telling the client it was removed, not dropped. */ const CLOSE_KICKED = 4001; @@ -107,6 +120,12 @@ export class StageHub { private readonly clearTimer: (handle: unknown) => void; /** Recording files still flushing; shutdown waits for them. */ private readonly closingRecordings = new Set>(); + /** Bumped on every change worth saving (state broadcasts, chat, rooms added or removed). */ + private revision = 0; + /** Rooms suspended by `shutdown(true)`; still part of the final `exportState`. */ + private readonly retired: PersistedRoom[] = []; + /** Identifies this process in `hello`, so resuming clients notice a restart. */ + private readonly instance = randomBytes(8).toString('base64url'); constructor(deps: HubDeps) { this.deps = deps; @@ -136,6 +155,7 @@ export class StageHub { type: 'hello', protocol: PROTOCOL_VERSION, serverVersion: this.deps.serverVersion, + instance: this.instance, limits: { controlPerSecond, icePerSecond, handRaiseIntervalMs, chatIntervalMs, reactionIntervalMs }, }); } @@ -205,11 +225,101 @@ export class StageHub { }).catch((err) => this.internalFailure(rt, err)); } - async shutdown(): Promise { - for (const rt of [...this.rooms.values()]) await rt.queue.run(() => this.closeRoom(rt, 'shutdown', true)); + /** + * Stop serving. `keepRooms` (persistence on): rooms are saved by the caller and will be restored, + * so nobody is told the room closed; their sockets just drop and the clients reconnect and resume. + */ + async shutdown(keepRooms = false): Promise { + for (const rt of [...this.rooms.values()]) { + await rt.queue.run(() => { + if (!keepRooms) { this.closeRoom(rt, 'shutdown', true); return; } + if (rt.recording) { rt.room.recording = false; this.stopRecording(rt); } + // Taken before teardown so the caller's final save still sees the room. + this.retired.push(this.persistRoom(rt)); + for (const s of rt.sessions.values()) this.bindings.delete(s); + this.teardownRoom(rt); + this.deps.log.info('room suspended for restart', { roomId: rt.room.roomId }); + }); + } await Promise.all(this.closingRecordings); } + /** Changes whenever persisted state may have changed; the saver writes only when it moves. */ + get stateRevision(): number { + return this.revision; + } + + /** Rooms as they stand, for `persistence.stateFile` (secret: holds codes and resume tokens). */ + exportState(): PersistedState { + const rooms = [...this.rooms.values()].filter((rt) => !rt.closed).map((rt) => this.persistRoom(rt)); + return { version: 1, savedAt: this.now(), rooms: [...rooms, ...this.retired] }; + } + + /** + * Rebuild saved rooms with everyone disconnected for `persistence.restoreGraceMs`, then resumed or + * removed exactly like a dropped connection. Returns how many rooms came back; bad rooms are skipped. + */ + restore(state: PersistedState): number { + const { config, log } = this.deps; + let restored = 0; + for (const saved of state.rooms) { + try { + if (this.rooms.size >= config.limits.maxRooms) throw new StageError('room_full', 'max rooms'); + const room = Room.restore(saved.room, { maxSpeakers: config.limits.maxSpeakersPerRoom, maxAudience: config.limits.maxAudiencePerRoom }, this.recordingAvailable); + if (this.rooms.has(room.roomId)) throw new StageError('conflict', 'duplicate room'); + const rt = this.addRuntime(room); + rt.chat.push(...(Array.isArray(saved.chat) ? saved.chat.slice(-config.rooms.chatHistory) : [])); + rt.nextMessageId = typeof saved.nextMessageId === 'number' ? saved.nextMessageId : 1; + const grace = config.persistence.restoreGraceMs; + for (const p of room.participants.values()) { + const controller = p.role === 'controller'; + this.holdSeat(rt, p.participantId, controller, controller ? Math.max(grace, config.rooms.controllerGraceMs) : grace); + } + restored++; + log.info('room restored', { roomId: room.roomId, participants: room.participants.size }); + } catch (err) { + log.warn('room not restored', { roomId: typeof saved.room?.roomId === 'string' ? saved.room.roomId : undefined, error: err instanceof Error ? err.message : String(err) }); + } + } + return restored; + } + + private persistRoom(rt: RoomRuntime): PersistedRoom { + return { room: rt.room.snapshotForRestart(), chat: rt.chat.slice(), nextMessageId: rt.nextMessageId }; + } + + private get recordingAvailable(): boolean { + return this.deps.config.recording.enabled && this.deps.startRecording !== undefined; + } + + /** Registers a room with its mixer and media wiring; no sessions yet. */ + private addRuntime(room: Room): RoomRuntime { + const mixer = this.deps.createMixer(room.roomId); + const rt: RoomRuntime = { + room, + queue: new SerialQueue(), + mixer, + sessions: new Map(), + publishers: new Set(), + graceTimer: undefined, + graceTimers: new Map(), + stateTimer: undefined, + qualityTimer: undefined, + uplinkCounts: new Map(), + chat: [], + nextMessageId: 1, + recording: undefined, + lastStatus: room.status, + closed: false, + }; + this.rooms.set(room.roomId, rt); + this.revision++; + this.deps.transport.setMixedStream(room.roomId, mixer); + // Fired from the mixer clock, outside the room queue: read-only fan-out, so no queueing needed. + mixer.onSpeaking((participantIds) => { if (!rt.closed) this.broadcast(rt, { type: 'speaking', participantIds }); }); + return rt; + } + // ─────────────────────────────── commands ─────────────────────────────── private createRoom(session: Session, msg: Extract): void { @@ -230,32 +340,12 @@ export class StageHub { code, codeRequired, limits: { maxSpeakers: config.limits.maxSpeakersPerRoom, maxAudience: config.limits.maxAudiencePerRoom }, - recordingAvailable: config.recording.enabled && this.deps.startRecording !== undefined, + recordingAvailable: this.recordingAvailable, now: this.now(), controller, }); - const mixer = this.deps.createMixer(roomId); - const rt: RoomRuntime = { - room, - queue: new SerialQueue(), - mixer, - sessions: new Map([[controller.participantId, session]]), - publishers: new Set(), - graceTimer: undefined, - graceTimers: new Map(), - stateTimer: undefined, - qualityTimer: undefined, - uplinkCounts: new Map(), - chat: [], - nextMessageId: 1, - recording: undefined, - lastStatus: room.status, - closed: false, - }; - this.rooms.set(roomId, rt); - transport.setMixedStream(roomId, mixer); - // Fired from the mixer clock, outside the room queue: read-only fan-out, so no queueing needed. - mixer.onSpeaking((participantIds) => { if (!rt.closed) this.broadcast(rt, { type: 'speaking', participantIds }); }); + const rt = this.addRuntime(room); + rt.sessions.set(controller.participantId, session); this.bindings.set(session, { roomId, participantId: controller.participantId }); log.info('room created', { roomId, participantId: controller.participantId, codeRequired }); @@ -376,6 +466,7 @@ export class StageHub { const keep = this.deps.config.rooms.chatHistory; if (keep > 0) { rt.chat.push(message); + this.revision++; if (rt.chat.length > keep) rt.chat.shift(); } this.broadcast(rt, { type: 'chat', ...message }); @@ -561,11 +652,7 @@ export class StageHub { const controller = p.role === 'controller'; const graceMs = controller ? controllerGraceMs : participantGraceMs; if (controller || (graceMs > 0 && !left)) { - p.connected = false; - const timer = this.setTimer(() => { - void rt.queue.run(() => (controller ? this.expireController(rt, pid) : this.expireParticipant(rt, pid))).catch((err) => this.internalFailure(rt, err)); - }, graceMs); - if (controller) rt.graceTimer = timer; else rt.graceTimers.set(pid, timer); + this.holdSeat(rt, pid, controller, graceMs); this.deps.log.info('participant disconnected, grace started', { roomId: room.roomId, participantId: pid, role: p.role }); if (p.onStage) this.broadcastState(rt); else this.presenceChanged(rt); return; @@ -698,7 +785,17 @@ export class StageHub { }, delay); } + /** Keeps a disconnected participant's seat for `graceMs`; a `join` with their resume token cancels it. */ + private holdSeat(rt: RoomRuntime, pid: string, controller: boolean, graceMs: number): void { + rt.room.get(pid)!.connected = false; + const timer = this.setTimer(() => { + void rt.queue.run(() => (controller ? this.expireController(rt, pid) : this.expireParticipant(rt, pid))).catch((err) => this.internalFailure(rt, err)); + }, graceMs); + if (controller) rt.graceTimer = timer; else rt.graceTimers.set(pid, timer); + } + private broadcastState(rt: RoomRuntime): void { + this.revision++; // Supersedes a pending coalesced broadcast. if (rt.stateTimer !== undefined) { this.clearTimer(rt.stateTimer); rt.stateTimer = undefined; } // Views shared by all recipients are built and serialized once; only `me` & co. are per session. @@ -717,6 +814,18 @@ export class StageHub { private closeRoom(rt: RoomRuntime, reason: string, shutdown = false): void { if (rt.closed) return; + const { roomId } = rt.room; + const closed: ServerMessage = shutdown ? { type: 'room:closed', roomId, reason: 'shutdown' } : { type: 'room:closed', roomId }; + for (const s of rt.sessions.values()) { + s.send(closed); + this.bindings.delete(s); + } + this.teardownRoom(rt); + this.deps.log.info('room closed', { roomId, reason }); + } + + /** Releases timers, recording, media and the registry entry; sessions must already be unbound. */ + private teardownRoom(rt: RoomRuntime): void { rt.closed = true; const { roomId } = rt.room; if (rt.graceTimer !== undefined) this.clearTimer(rt.graceTimer); @@ -724,11 +833,6 @@ export class StageHub { rt.graceTimers.clear(); if (rt.stateTimer !== undefined) this.clearTimer(rt.stateTimer); if (rt.qualityTimer !== undefined) this.clearTimer(rt.qualityTimer); - const closed: ServerMessage = shutdown ? { type: 'room:closed', roomId, reason: 'shutdown' } : { type: 'room:closed', roomId }; - for (const s of rt.sessions.values()) { - s.send(closed); - this.bindings.delete(s); - } rt.sessions.clear(); rt.publishers.clear(); this.stopRecording(rt); @@ -736,7 +840,7 @@ export class StageHub { this.deps.transport.closeRoom(roomId); rt.mixer.stop(); this.rooms.delete(roomId); - this.deps.log.info('room closed', { roomId, reason }); + this.revision++; } // ─────────────────────────────── errors ─────────────────────────────── diff --git a/src/ws/server.ts b/src/ws/server.ts index 38be475..6f9e15a 100644 --- a/src/ws/server.ts +++ b/src/ws/server.ts @@ -17,7 +17,8 @@ import { ValidationError, parseClientMessage } from './validate.ts'; export interface StageServer { listen(): Promise; - close(): Promise; + /** `keepRooms`: rooms will be restored after a restart, so clients are not told they closed (see `StageHub.shutdown`). */ + close(keepRooms?: boolean): Promise; } export interface StageServerDeps { @@ -232,11 +233,11 @@ export function createStageServer(deps: StageServerDeps): StageServer { }); return promise; }, - close: async () => { + close: async (keepRooms = false) => { clearInterval(heartbeat); clearTimeout(reloadTimer); for (const w of watchers) w.close(); - await hub.shutdown(); + await hub.shutdown(keepRooms); // A graceful close flushes the `room:closed` frames queued above; terminate() could drop them. const drained = Promise.all([...sessions].map((s) => once(s.ws, 'close'))); for (const s of sessions) s.close(CLOSE_GOING_AWAY, 'server shutdown'); diff --git a/test/hub.test.ts b/test/hub.test.ts index a9a19aa..35d7105 100644 --- a/test/hub.test.ts +++ b/test/hub.test.ts @@ -677,3 +677,74 @@ describe('recording', () => { assert.equal(await h.req(host.s, { type: 'recording:start' }), 'forbidden'); }); }); + +describe('persistence across a restart', () => { + async function savedRoom() { + const config = testConfig((c) => { c.persistence.restoreGraceMs = 120_000; }); + const before = harness(config); + const host = await before.create('Host'); + const sp = await before.join(host.roomId, host.code, 'Sam'); + const aud = await before.join(host.roomId, host.code, 'Ann'); + await before.req(sp.s, { type: 'hand:raise' }); + await before.req(host.s, { type: 'stage:approve', targetId: sp.id }); + await before.req(aud.s, { type: 'hand:raise' }); + await before.req(aud.s, { type: 'chat:send', text: 'hello' }); + const tokens = { host: host.s.last('room:state')!.resumeToken!, sp: sp.s.last('room:state')!.resumeToken!, aud: aud.s.last('room:state')!.resumeToken! }; + await before.hub.shutdown(true); + assert.equal(host.s.all('room:closed').length, 0, 'nobody is told the room closed'); + // Round-trip through JSON like the state file does. + const state = JSON.parse(JSON.stringify(before.hub.exportState())); + return { config, state, host, sp, aud, tokens }; + } + + it('restores seats, stage, hands and chat; people resume with their tokens', async () => { + const { config, state, host, sp, aud, tokens } = await savedRoom(); + const after = harness(config); + assert.equal(after.hub.restore(state), 1); + const resume = async (name: string, resumeToken: string) => { + const s = new FakeSession(); + after.hub.attach(s); + assert.equal(await after.req(s, { type: 'join', roomId: host.roomId, code: host.code, name, resumeToken }), 'ok'); + return s; + }; + const hs = await resume('Host', tokens.host); + const ss = await resume('Sam', tokens.sp); + const as = await resume('Ann', tokens.aud); + const st = as.last('room:state')!; + assert.equal(hs.last('room:state')!.me.participantId, host.id); + assert.equal(ss.last('room:state')!.me.participantId, sp.id); + assert.equal(st.me.participantId, aud.id); + assert.deepEqual(st.speakers.map((p) => p.participantId), [host.id, sp.id]); + assert.deepEqual(st.hands.map((p) => p.participantId), [aud.id]); + assert.deepEqual(as.last('chat:history')!.messages.map((m) => m.text), ['hello']); + assert.ok(st.speakers.every((p) => p.connected)); + assert.equal(await after.req(ss, { type: 'rtc:offer', payload: SENDRECV_OFFER }), 'ok', 'restored speaker may publish again'); + }); + + it('drops participants who do not come back within restoreGraceMs and closes an abandoned room', async () => { + const { config, state, host, tokens } = await savedRoom(); + const after = harness(config); + after.hub.restore(state); + const s = new FakeSession(); + after.hub.attach(s); + assert.equal(await after.req(s, { type: 'join', roomId: host.roomId, code: host.code, name: 'Host', resumeToken: tokens.host }), 'ok'); + for (const t of after.timers.filter((x) => !x.cleared && x.ms === 120_000)) t.fn(); + await nextTurn(); + const st = s.last('room:state')!; + assert.deepEqual(st.speakers.map((p) => p.participantId), [host.id], 'the others are gone'); + assert.equal(st.hands.length, 0); + + const nobody = harness(config); + nobody.hub.restore(state); + for (const t of nobody.timers.filter((x) => !x.cleared)) t.fn(); + await nextTurn(); + assert.equal(nobody.hub.roomCount, 0, 'no one returned: room closed'); + }); + + it('skips a saved room that no longer fits the configured limits', async () => { + const { state } = await savedRoom(); + const after = harness(testConfig((c) => { c.limits.maxAudiencePerRoom = 1; })); + assert.equal(after.hub.restore(state), 0); + assert.equal(after.hub.roomCount, 0); + }); +}); diff --git a/web/src/main.ts b/web/src/main.ts index 3104131..4d14da6 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -429,7 +429,13 @@ client.on('reaction', ({ detail }) => { setTimeout(() => bubble.remove(), 3000); }); client.on('state', ({ detail }) => renderRoom(detail)); -client.on('status', ({ detail }) => { status.textContent = statusLabels[detail]; updateCooldown(); }); +let lastStatus = client.status; +client.on('status', ({ detail }) => { + // Failed reconnect attempts leave "cannot connect" behind; once back in the room it no longer applies. + if (lastStatus === 'reconnecting' && (detail === 'live' || detail === 'waiting')) errorBox.hidden = true; + lastStatus = detail; + status.textContent = statusLabels[detail]; updateCooldown(); +}); client.on('created', ({ detail }) => { createdRoom = detail; }); client.on('closed', ({ detail }) => { createdRoom = null; micNotice = ''; invitation = detail.reason === 'shutdown' ? '伺服器維護或重新啟動,房間已關閉;請稍後重新建立或加入。' : '房間已關閉,歡迎建立或加入其他舞台。'; audioBlocked = false; landing(); }); client.on('kicked', () => { createdRoom = null; micNotice = ''; invitation = '你已被主控移出房間。'; audioBlocked = false; landing(); }); From bd9c1deab3c6c5a853b798129387aa57d5a96129 Mon Sep 17 00:00:00 2001 From: Yueyu Hoshizora Date: Sat, 10 Oct 2026 21:17:08 +0800 Subject: [PATCH 6/8] feat(metrics): encode and negotiation latency histograms, Grafana dashboard --- README.md | 2 +- deploy/grafana-dashboard.json | 433 ++++++++++++++++++++++++++ src/metrics.ts | 38 ++- src/transport/WeriftMediaTransport.ts | 7 +- src/ws/hub.ts | 6 +- test/transport.test.ts | 2 +- 6 files changed, 477 insertions(+), 11 deletions(-) create mode 100644 deploy/grafana-dashboard.json diff --git a/README.md b/README.md index 33d81d1..7a0a7f7 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ Windows 用 `.\stage.ps1 `,指令相同;Windows 無法對背景 nod 對外部署:`allowInsecure: false`、`host: 0.0.0.0`、填 `tls`;開放 `rtc.portRange` 的 UDP;設定 `rtc.turn`(coturn 需 `use-auth-secret` 與相同的 `static-auth-secret`)。憑證檔更新(含 ACME 工具的改名/symlink 替換)後約 2 秒自動重載,不需重啟;新憑證載入失敗時沿用舊憑證並記 error。 -監控:`GET /healthz` 回 `ok`;`GET /metrics` 回 Prometheus 文字格式(`Authorization: Bearer `),包含房間/連線/發言者數、上下行封包與補幀數、DTX 省略幀、全房無聲時跳過編碼的幀數(`opus.dtx` 開啟且台上全員靜音或沒有上行超過 1 秒時,混音不再編碼、直接以 DTX 處理)、low tier 聽眾數、codec backlog 與丟幀、mixer underrun/漂移丟幀/tick 延遲、event loop delay 與記憶體。 +監控:`GET /healthz` 回 `ok`;`GET /metrics` 回 Prometheus 文字格式(`Authorization: Bearer `),包含房間/連線/發言者數、上下行封包與補幀數、DTX 省略幀、全房無聲時跳過編碼的幀數(`opus.dtx` 開啟且台上全員靜音或沒有上行超過 1 秒時,混音不再編碼、直接以 DTX 處理)、low tier 聽眾數、codec backlog 與丟幀、mixer underrun/漂移丟幀/tick 延遲、event loop delay 與記憶體,以及兩個 histogram:`stage_mix_encode_seconds`(混音 tick 到下行編碼完成,伺服器自身在下行延遲中的份額)與 `stage_negotiate_seconds`(套用 offer 到產生 answer)。[`deploy/grafana-dashboard.json`](deploy/grafana-dashboard.json) 是對應的 Grafana dashboard(匯入時選 Prometheus 資料來源)。 ## WebSocket 協定摘要 diff --git a/deploy/grafana-dashboard.json b/deploy/grafana-dashboard.json new file mode 100644 index 0000000..296d25d --- /dev/null +++ b/deploy/grafana-dashboard.json @@ -0,0 +1,433 @@ +{ + "title": "Stage.js", + "uid": "stage-js", + "schemaVersion": 39, + "version": 1, + "editable": true, + "time": { + "from": "now-1h", + "to": "now" + }, + "refresh": "30s", + "tags": [ + "stage-js" + ], + "templating": { + "list": [ + { + "name": "datasource", + "label": "Prometheus", + "type": "datasource", + "query": "prometheus" + } + ] + }, + "panels": [ + { + "type": "timeseries", + "title": "房間與連線", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 0, + "y": 0, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "short" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "stage_rooms", + "legendFormat": "rooms", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "stage_participants", + "legendFormat": "participants", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "stage_sessions", + "legendFormat": "ws sessions", + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "stage_publishers", + "legendFormat": "publishers", + "refId": "D" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "stage_media_peers", + "legendFormat": "media peers", + "refId": "E" + } + ] + }, + { + "type": "timeseries", + "title": "下行編碼延遲(tick → 編碼完成)", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 12, + "y": 0, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "s" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "histogram_quantile(0.5, sum by (le) (rate(stage_mix_encode_seconds_bucket[1m])))", + "legendFormat": "p50", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "histogram_quantile(0.95, sum by (le) (rate(stage_mix_encode_seconds_bucket[1m])))", + "legendFormat": "p95", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "histogram_quantile(0.99, sum by (le) (rate(stage_mix_encode_seconds_bucket[1m])))", + "legendFormat": "p99", + "refId": "C" + } + ] + }, + { + "type": "timeseries", + "title": "混音時鐘", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 0, + "y": 8, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "ops" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "rate(stage_mixer_late_ticks_total[1m])", + "legendFormat": "late ticks/s", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "rate(stage_mixer_underruns_total[1m])", + "legendFormat": "underruns/s", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "rate(stage_mixer_dropped_frames_total[1m])", + "legendFormat": "dropped frames/s", + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "rate(stage_mix_frames_shed_total[1m])", + "legendFormat": "shed frames/s", + "refId": "D" + } + ] + }, + { + "type": "timeseries", + "title": "Event loop 延遲", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 12, + "y": 8, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "ms" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "stage_event_loop_delay_p99_ms", + "legendFormat": "p99 (since start)", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "stage_event_loop_delay_max_ms", + "legendFormat": "max (since start)", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "stage_mixer_max_tick_lag_ms", + "legendFormat": "max mixer tick lag", + "refId": "C" + } + ] + }, + { + "type": "timeseries", + "title": "信令:套用 offer 到產生 answer", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 0, + "y": 16, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "s" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "histogram_quantile(0.5, sum by (le) (rate(stage_negotiate_seconds_bucket[5m])))", + "legendFormat": "p50", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "histogram_quantile(0.95, sum by (le) (rate(stage_negotiate_seconds_bucket[5m])))", + "legendFormat": "p95", + "refId": "B" + } + ] + }, + { + "type": "timeseries", + "title": "下行品質", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 12, + "y": 16, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "short" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "stage_downlink_low_tier", + "legendFormat": "listeners on low tier", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "rate(stage_uplink_concealed_total[1m]) / clamp_min(rate(stage_uplink_packets_total[1m]), 1)", + "legendFormat": "uplink concealed ratio", + "refId": "B" + } + ] + }, + { + "type": "timeseries", + "title": "CPU 與記憶體", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 0, + "y": 24, + "w": 24, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "short" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "rate(stage_process_cpu_seconds_total[1m])", + "legendFormat": "CPU cores", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "stage_process_rss_bytes / 1e9", + "legendFormat": "RSS GB", + "refId": "B" + } + ] + } + ] +} diff --git a/src/metrics.ts b/src/metrics.ts index abbb214..f5f387b 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -1,19 +1,43 @@ import { monitorEventLoopDelay } from 'node:perf_hooks'; -export interface MetricSample { - name: string; - help: string; - type: 'counter' | 'gauge'; - value: number; -} +export type MetricSample = + | { name: string; help: string; type: 'counter' | 'gauge'; value: number } + | { name: string; help: string; type: 'histogram'; histogram: Histogram }; /** Prometheus text exposition format (0.0.4). */ export function renderPrometheus(samples: MetricSample[]): string { let out = ''; - for (const s of samples) out += `# HELP ${s.name} ${s.help}\n# TYPE ${s.name} ${s.type}\n${s.name} ${Number.isFinite(s.value) ? s.value : 0}\n`; + for (const s of samples) { + out += `# HELP ${s.name} ${s.help}\n# TYPE ${s.name} ${s.type}\n`; + if (s.type !== 'histogram') { out += `${s.name} ${Number.isFinite(s.value) ? s.value : 0}\n`; continue; } + const h = s.histogram; + let cumulative = 0; + h.bounds.forEach((le, i) => { cumulative += h.counts[i]!; out += `${s.name}_bucket{le="${le}"} ${cumulative}\n`; }); + out += `${s.name}_bucket{le="+Inf"} ${h.count}\n${s.name}_sum ${h.sum}\n${s.name}_count ${h.count}\n`; + } return out; } +/** Fixed-bucket histogram (seconds); `observe` allocates nothing, so it can sit on per-frame paths. */ +export class Histogram { + readonly bounds: readonly number[]; + /** Per-bucket (not cumulative) counts; the last slot is beyond the largest bound. */ + readonly counts: number[]; + sum = 0; + count = 0; + constructor(bounds: readonly number[]) { + this.bounds = bounds; + this.counts = new Array(bounds.length + 1).fill(0); + } + observe(value: number): void { + let i = 0; + while (i < this.bounds.length && value > this.bounds[i]!) i++; + this.counts[i]!++; + this.sum += value; + this.count++; + } +} + /** Monotonic counters shared by every room mixer (rooms come and go; totals must not drop). */ export class MixerCounters { ticks = 0; diff --git a/src/transport/WeriftMediaTransport.ts b/src/transport/WeriftMediaTransport.ts index 4b5f9e0..f036eb8 100644 --- a/src/transport/WeriftMediaTransport.ts +++ b/src/transport/WeriftMediaTransport.ts @@ -1,7 +1,7 @@ import type { AppConfig } from '../config.ts'; import { samplesPerFrame } from '../config.ts'; import type { Logger } from '../log.ts'; -import type { MetricSample } from '../metrics.ts'; +import { Histogram, type MetricSample } from '../metrics.ts'; import type { IceCandidatePayload, SessionDescriptionPayload } from '../../shared/protocol.ts'; import type { AudioFrameHandler, MediaTransport, MediaTransportCallbacks, MixedPcmSource, MixFrame, MixPacketSink, NegotiationPolicy, TransportStats } from './MediaTransport.ts'; import { CodecPool, type EncodeItem } from './codecPool.ts'; @@ -84,6 +84,8 @@ export class WeriftMediaTransport implements MediaTransport { private readonly codecs: CodecPool; private readonly hosts: PeerHost[]; private readonly counters = { uplinkPackets: 0, uplinkConcealed: 0, shedFrames: 0, encodeFailures: 0, silentSkipped: 0 }; + /** Mixer tick → encoded payloads back from the codec worker: the server's own share of downlink latency. */ + private readonly encodeLatency = new Histogram([0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.25]); /** `createHosts` replaces the configured werift hosts (tests drive the fan-out through fakes). */ constructor(config: AppConfig, callbacks: MediaTransportCallbacks, log: Logger, createHosts?: (events: PeerHostEvents) => PeerHost[]) { this.config = config; this.callbacks = callbacks; this.log = log; @@ -114,6 +116,7 @@ export class WeriftMediaTransport implements MediaTransport { { name: 'stage_mix_frames_shed_total', help: 'Mixed frames dropped because a codec worker fell behind.', type: 'counter', value: c.shedFrames }, { name: 'stage_encode_failures_total', help: 'Mixed frames whose encode failed.', type: 'counter', value: c.encodeFailures }, { name: 'stage_mix_frames_silent_skipped_total', help: 'Mixed frames not encoded because nobody had been audible for a while (sent as DTX).', type: 'counter', value: c.silentSkipped }, + { name: 'stage_mix_encode_seconds', help: 'Time from a mixer tick to its encoded downlink payloads (codec worker round trip).', type: 'histogram', histogram: this.encodeLatency }, ]; } private room(id: string): Room { @@ -233,8 +236,10 @@ export class WeriftMediaTransport implements MediaTransport { this.record(room, plan, undefined); return; } + const started = performance.now(); const encoded = this.codecs.encode(room.id, plan.frames); encoded.then(payloads => { + this.encodeLatency.observe((performance.now() - started) / 1000); if (this.rooms.get(room.id) !== room) return; for (const [host, list] of plan.targets) host.send({ roomId: room.id, payloads, targets: list }); }, (err: Error) => { this.counters.encodeFailures++; this.log.warn('Mixed frame encode failed', { roomId: room.id, error: err.message }); }); diff --git a/src/ws/hub.ts b/src/ws/hub.ts index deb3a19..c1e906c 100644 --- a/src/ws/hub.ts +++ b/src/ws/hub.ts @@ -11,7 +11,7 @@ import { } from '../../shared/protocol.ts'; import type { AppConfig } from '../config.ts'; import type { Logger } from '../log.ts'; -import type { MetricSample } from '../metrics.ts'; +import { Histogram, type MetricSample } from '../metrics.ts'; import { ERROR_MESSAGES, InvariantViolation, StageError } from '../model/errors.ts'; import { Room, type RoomEvent, type RoomSnapshot } from '../model/room.ts'; import { offerSendsAudio, summarizeOffer } from '../rtc/sdp.ts'; @@ -126,6 +126,7 @@ export class StageHub { private readonly retired: PersistedRoom[] = []; /** Identifies this process in `hello`, so resuming clients notice a restart. */ private readonly instance = randomBytes(8).toString('base64url'); + private readonly negotiateLatency = new Histogram([0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5]); constructor(deps: HubDeps) { this.deps = deps; @@ -146,6 +147,7 @@ export class StageHub { { name: 'stage_participants', help: 'Participants in all rooms, including disconnected ones within grace.', type: 'gauge', value: participants }, { name: 'stage_sessions', help: 'Connected ws sessions bound to a room.', type: 'gauge', value: sessions }, { name: 'stage_publishers', help: 'Participants whose uplink feeds a mixer.', type: 'gauge', value: publishers }, + { name: 'stage_negotiate_seconds', help: 'Time to apply a client offer and produce the answer (includes worker round trip).', type: 'histogram', histogram: this.negotiateLatency }, ]; } @@ -599,7 +601,9 @@ export class StageHub { }); let answer: SessionDescriptionPayload; try { + const started = performance.now(); answer = await transport.negotiate(roomId, pid, msg.payload, { allowUplink }); + this.negotiateLatency.observe((performance.now() - started) / 1000); } catch (err) { // A bad offer is that client's problem, not a reason to tear down the room. log.warn('negotiation failed', { roomId, participantId: pid, error: String(err) }); diff --git a/test/transport.test.ts b/test/transport.test.ts index 5e01f2f..3d66549 100644 --- a/test/transport.test.ts +++ b/test/transport.test.ts @@ -88,7 +88,7 @@ describe('downlink fan-out', () => { t = await tick(false); assert.ok(t.payloads.every((p) => p.length === 0), 'silent room: empty payloads (DTX) instead of encoding'); const skipped = (await transport.metrics()).find((m) => m.name === 'stage_mix_frames_silent_skipped_total')!; - assert.equal(skipped.value, 1); + assert.ok(skipped.type === 'counter' && skipped.value === 1); t = await tick(true); assert.ok(t.payloads.some((p) => p.length > 2), 'encodes again as soon as someone speaks'); assert.ok(!t.same('a', 'b'), 'and the speaker is back on its own mix-minus'); From f1c1301a2a305ad74016b3a48db87f90c8adf0f8 Mon Sep 17 00:00:00 2001 From: Yueyu Hoshizora Date: Sat, 10 Oct 2026 21:20:16 +0800 Subject: [PATCH 7/8] test(load): soak mode with periodic metrics sampling and heap-growth gate --- README.md | 5 +++-- scripts/load-test.ts | 39 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7a0a7f7..be730a0 100644 --- a/README.md +++ b/README.md @@ -165,10 +165,11 @@ JSON frame,型別定義在 [`shared/protocol.ts`](shared/protocol.ts)。每個 ```bash npm run typecheck # 伺服器+client+web -npm test # 控制面流程 1–4、不變量、斷線寬限與回座、信令與政策競態、ws 邊界(驗證、限流、尺寸、轉義、靜態檔、准入)、混音器與時鐘、下行分級/共用編碼/靜音 DTX、worker 重生 -node scripts/werift-loopback.ts # 真 werift 端到端(含觀眾→發言者重協商、client 端 ICE restart) +npm test # 控制面流程 1–4、不變量、斷線寬限與回座、重啟恢復、聊天/反應/錄音、信令與政策競態、ws 邊界(驗證、限流、尺寸、轉義、靜態檔、准入)、混音器與時鐘、下行分級/共用編碼/靜音 DTX、Ogg/Opus 封裝、worker 重生 +node scripts/werift-loopback.ts # 真 werift 端到端(含錄音取用共用編碼、觀眾→發言者重協商、client 端 ICE restart) node scripts/bench-mixer.ts # 混音壓測:3/8 發言者 × 300 訂閱者 node scripts/load-test.ts # 全端壓測:3 發言者+298 werift 觀眾(--speakers/--audience/--seconds/--procs/--url) +node scripts/load-test.ts --seconds 3600 --sample-seconds 60 --max-heap-growth-mb 50 # 長時間 soak:每分鐘記 RSS/heap/late ticks/編碼 p99,heap 成長超過門檻即失敗 npm audit ``` diff --git a/scripts/load-test.ts b/scripts/load-test.ts index 7a0e927..fc050fc 100644 --- a/scripts/load-test.ts +++ b/scripts/load-test.ts @@ -4,6 +4,10 @@ * latency of periodic beeps and the server's /metrics. * * node scripts/load-test.ts [--speakers 3] [--audience 298] [--seconds 20] [--procs 4] [--media-workers N] [--url ws://…/ws] + * + * Soak run: add `--sample-seconds 60` (one /metrics line per interval: RSS, heap, late ticks, shed + * frames, encode p99) and optionally `--max-heap-growth-mb 50`, which fails the run when the V8 + * heap grows more than that between the first and the last sample. E.g. `--seconds 3600 --sample-seconds 60`. */ import assert from 'node:assert/strict'; import { fork, spawn, type ChildProcess } from 'node:child_process'; @@ -24,7 +28,7 @@ const { values: args } = parseArgs({ options: { speakers: { type: 'string', default: '3' }, audience: { type: 'string' }, seconds: { type: 'string', default: '20' }, procs: { type: 'string', default: '4' }, url: { type: 'string' }, child: { type: 'boolean', default: false }, - 'media-workers': { type: 'string' }, + 'media-workers': { type: 'string' }, 'sample-seconds': { type: 'string' }, 'max-heap-growth-mb': { type: 'string' }, }, }); const config = loadConfig('config.example.yaml'); @@ -39,6 +43,16 @@ const BED_AMPLITUDE = 0.005; const ONSET_ENERGY = 0.002; const wallMs = () => performance.timeOrigin + performance.now(); const cpuSeconds = () => { const c = process.cpuUsage(); return (c.user + c.system) / 1e6; }; +/** Unlabeled sample value from Prometheus text. */ +const metric = (text: string, name: string) => text.match(new RegExp(`^${name} (\\S+)$`, 'm'))?.[1] ?? 'n/a'; +/** Upper bound of the histogram bucket holding quantile `q` (what histogram_quantile would interpolate within). */ +function bucketQuantile(text: string, name: string, q: number): string { + const buckets = [...text.matchAll(new RegExp(`^${name}_bucket\\{le="([^"]+)"\\} (\\S+)$`, 'gm'))].map((m) => [m[1]!, Number(m[2])] as const); + const total = buckets.at(-1)?.[1] ?? 0; + if (!total) return 'n/a'; + const hit = buckets.find(([, count]) => count >= q * total); + return hit ? `≤${hit[0]}s` : 'n/a'; +} type ChildInit = { url: string; roomId: string; code: string; count: number; index: number; probes: boolean }; type ChildReport = { peers: number; connected: number; packets: number; lost: number; onsets: number[]; cpuSeconds: number }; @@ -191,9 +205,21 @@ async function main(): Promise { let seq = 0, ts = 0, frame = 0; const t0 = Math.ceil(wallMs() / 1000) * 1000 + 1000; const end = t0 + seconds * 1000; + const sampleMs = Number(args['sample-seconds'] ?? 0) * 1000; + const heapSamples: number[] = []; + let nextSample = sampleMs ? t0 + sampleMs : Infinity; let next = performance.now(); while (wallMs() < end) { const now = wallMs(); + if (now >= nextSample) { + nextSample += sampleMs; + // Not awaited: the send loop must keep its 20 ms cadence. + void fetch(metricsUrl).then((r) => r.text()).then((text) => { + const heap = Number(metric(text, 'stage_process_heap_used_bytes')); + heapSamples.push(heap); + console.log(`[${((now - t0) / 1000).toFixed(0)}s] rss ${(Number(metric(text, 'stage_process_rss_bytes')) / 1e6).toFixed(0)}MB heap ${(heap / 1e6).toFixed(1)}MB late ticks ${metric(text, 'stage_mixer_late_ticks_total')} shed ${metric(text, 'stage_mix_frames_shed_total')} encode p99 ${bucketQuantile(text, 'stage_mix_encode_seconds', 0.99)}`); + }, (err: Error) => console.log(`metrics sample failed: ${err.message}`)); + } const phase = now < t0 ? -1 : (now - t0) % 1000; const beeping = phase >= 0 && phase < 120; if (beeping && (beeps.length === 0 || now - beeps.at(-1)! > 500)) beeps.push(now); @@ -214,19 +240,28 @@ async function main(): Promise { const total = reports.reduce((a, r) => ({ peers: a.peers + r.peers, connected: a.connected + r.connected, packets: a.packets + r.packets, lost: a.lost + r.lost }), { peers: 0, connected: 0, packets: 0, lost: 0 }); const latencies = reports.flatMap((r) => r.onsets).map((at) => at - beeps.filter((b) => b <= at).at(-1)!).filter((l) => l >= 0 && l < 1000).sort((a, b) => a - b); const pct = (p: number) => latencies[Math.min(latencies.length - 1, Math.floor(latencies.length * p))]?.toFixed(0) ?? 'n/a'; - const pick = (name: string) => metrics.match(new RegExp(`^${name} (\\S+)$`, 'm'))?.[1] ?? 'n/a'; + const pick = (name: string) => metric(metrics, name); + const heapGrowthMb = heapSamples.length >= 2 ? (heapSamples.at(-1)! - heapSamples[0]!) / 1e6 : undefined; console.table({ audience: `${total.connected}/${total.peers} connected`, downlinkLoss: `${(100 * total.lost / Math.max(1, total.packets + total.lost)).toFixed(3)}% (${total.lost}/${total.packets + total.lost})`, beepLatencyMs: `p50 ${pct(0.5)} · p95 ${pct(0.95)} · max ${latencies.length ? latencies.at(-1)!.toFixed(0) : 'n/a'} (n=${latencies.length})`, serverEventLoopMs: `p99 ${pick('stage_event_loop_delay_p99_ms')} · max ${pick('stage_event_loop_delay_max_ms')}`, mixer: `late ticks ${pick('stage_mixer_late_ticks_total')} · max lag ${pick('stage_mixer_max_tick_lag_ms')}ms · underruns ${pick('stage_mixer_underruns_total')}`, + encode: `p50 ${bucketQuantile(metrics, 'stage_mix_encode_seconds', 0.5)} · p99 ${bucketQuantile(metrics, 'stage_mix_encode_seconds', 0.99)}`, + negotiate: `p50 ${bucketQuantile(metrics, 'stage_negotiate_seconds', 0.5)} · p95 ${bucketQuantile(metrics, 'stage_negotiate_seconds', 0.95)}`, shedFrames: pick('stage_mix_frames_shed_total'), serverCpuSeconds: pick('stage_process_cpu_seconds_total'), + ...(heapGrowthMb !== undefined && { heapGrowthMb: heapGrowthMb.toFixed(1) }), // The harness shares the machine: when it needs most cores, server numbers are pessimistic. harnessCpuSeconds: (reports.reduce((n, r) => n + r.cpuSeconds, 0) + cpuSeconds()).toFixed(1), }); if (total.connected < total.peers || latencies.length === 0 || Number(pct(0.95)) > config.audio.mixer.latencyTargetMs) process.exitCode = 1; + const maxGrowth = args['max-heap-growth-mb']; + if (maxGrowth !== undefined && heapGrowthMb !== undefined && heapGrowthMb > Number(maxGrowth)) { + console.log(`heap grew ${heapGrowthMb.toFixed(1)}MB > --max-heap-growth-mb ${maxGrowth}`); + process.exitCode = 1; + } } finally { server?.child.kill('SIGTERM'); } From 94286296aacc49dfb261120c990b28f015cf4178 Mon Sep 17 00:00:00 2001 From: Yueyu Hoshizora Date: Wed, 21 Oct 2026 20:50:34 +0800 Subject: [PATCH 8/8] test: align server delivery hello contract with chat and restart identity --- scripts/check-server-package.ts | 8 +++++--- scripts/fixtures/server-package/server.mjs | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/check-server-package.ts b/scripts/check-server-package.ts index 75db9bd..f5c7df9 100644 --- a/scripts/check-server-package.ts +++ b/scripts/check-server-package.ts @@ -227,9 +227,11 @@ export async function checkServerPackage(options: ServerPackageCheckOptions = {} socket!.once('close', () => reject(new Error('WebSocket closed before hello'))); }); const limits = config.limits as Record; - assert.deepEqual(await run(hello, 'WebSocket hello', requestTimeout), { - type: 'hello', protocol: 1, serverVersion: manifest.version, - limits: { controlPerSecond: limits.controlPerSecond, icePerSecond: limits.icePerSecond, handRaiseIntervalMs: limits.handRaiseIntervalMs }, + const greeting = await run(hello, 'WebSocket hello', requestTimeout); + assert.ok(record(greeting) && typeof greeting.instance === 'string' && /^[A-Za-z0-9_-]{11}$/.test(greeting.instance), 'WebSocket hello contract: instance'); + assert.deepEqual(greeting, { + type: 'hello', protocol: 1, serverVersion: manifest.version, instance: greeting.instance, + limits: { controlPerSecond: limits.controlPerSecond, icePerSecond: limits.icePerSecond, handRaiseIntervalMs: limits.handRaiseIntervalMs, chatIntervalMs: limits.chatIntervalMs, reactionIntervalMs: limits.reactionIntervalMs }, }, 'WebSocket hello contract'); assert.equal(socket.readyState, WebSocket.OPEN, 'Hello socket must remain open until SIGTERM'); assert.ok(!closed && child.kill('SIGTERM'), 'Expected to signal the running owned server'); diff --git a/scripts/fixtures/server-package/server.mjs b/scripts/fixtures/server-package/server.mjs index 300f87a..4f52b2c 100644 --- a/scripts/fixtures/server-package/server.mjs +++ b/scripts/fixtures/server-package/server.mjs @@ -67,7 +67,7 @@ wss.on('connection', ws => { if (scenario === 'ws-overflow') { ws.send('x'.repeat(65537)); return; } if (scenario === 'ws-malformed') { ws.send('{bad JSON'); return; } const { version } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); - ws.send(JSON.stringify({ type: 'hello', protocol: 1, serverVersion: scenario === 'wrong-hello' ? 'wrong' : version, limits: { controlPerSecond: config.limits.controlPerSecond, icePerSecond: config.limits.icePerSecond, handRaiseIntervalMs: config.limits.handRaiseIntervalMs } })); + ws.send(JSON.stringify({ type: 'hello', instance: 'test-server', protocol: 1, serverVersion: scenario === 'wrong-hello' ? 'wrong' : version, limits: { controlPerSecond: config.limits.controlPerSecond, icePerSecond: config.limits.icePerSecond, handRaiseIntervalMs: config.limits.handRaiseIntervalMs, chatIntervalMs: config.limits.chatIntervalMs, reactionIntervalMs: config.limits.reactionIntervalMs } })); }); await new Promise(resolve => server.listen(config.server.port, config.server.host, resolve)); const port = server.address().port;