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..2c51d74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,8 @@ 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 | +| `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 f8a8998..3b0bc9e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -34,9 +34,9 @@ - 交付必要文件四件(README/AGENTS/CLAUDE/PLAN)後才收尾實作。 ### 範圍外 -- 影片、螢幕分享、文字聊天室。 -- 錄音存檔與回放、直播轉推(RTMP/HLS)。 -- 帳號系統與持久化(使用者、舞台、操作紀錄均記憶體保存,重啟即失)。 +- 影片、螢幕分享。(文字聊天與表情反應已於 2026-10-10 依需求加入,見十二-22。) +- 回放、直播轉推(RTMP/HLS)。(伺服器端 Ogg/Opus 錄音已於 2026-10-10 依需求加入,見十二-23。) +- 帳號系統。(房間狀態可選擇存檔、重啟後恢復,2026-10-10 依需求加入,見十二-24;預設仍只存在記憶體。) - 套件發布(npm):只開源倉庫供 clone,不發套件(見假設 6)。 - 其他網站的整合案本身(各站程式);本專案交付 client 函式庫+範例前端。 @@ -920,3 +920,9 @@ N 路混音管線(decode→mix→encode)、不含自己的台上混音、lim 250. **Windows restart 先驗證替代啟動條件(第230批,2026-10-17):** immutable229 的 Windows restart 直接 Stop-Stage 後 Start-Stage,Node/建置入口/設定缺失等可預先偵測的問題會在舊程序停止後才失敗;Linux 已有 built checker 預檢。新增獨立 Restart-Stage,先檢查 Node 及原生命令退出狀態、已建置 server/check-config.js 檔案、沿用 Initialize-Config,再不帶 positional config 參數執行已建置檢查器,成功才使用既有 stop/start。保留原始 STAGE_CONFIG、專案根目錄相對路徑與非零退出結果;不改其他命令、一般 PID 身分/並行/stop 政策,不保證零停機、runtime 資源或檢查後檔案未變更。原生 Windows fixtures 比較 immutable baseline 與候選,必須在 finally 前觀察失敗候選保留舊程序/PID且沒有 stop 或替代 launch,成功重啟則確認受控程序替換,並涵蓋 Node 輸出24卻非零與 checker 失敗。沿用免安裝 Windows job,以受控 checker 驗證命令順序/參數/CWD/環境與退出語意;實際 built checker/schema 由原 Linux gates 驗證,不稱原生完整設定/server驗收。不變更 CI 設定、依賴、SDK/server/media/protocol。須獨立 hash-bound 審查、本地完整 gates 與 exact-head Ubuntu/Windows CI 終態成功,所有 HOLD 及 browser/AT/ICE/硬體音訊限制保持。 + +251. **文字聊天與表情反應**(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 自行節流。聊天內容不入日誌。伺服器錯誤訊息維持固定英文;SDK 沿用 main 的原始錯誤契約,UI 沿用既有繁中提示。 + +252. **錄音**(2026-10-10 依需求加入):C→S `recording:start`/`recording:stop`(主控限定,伺服器 `recording.enabled` 才可用,否則 `forbidden`;重複開始/停止為 `conflict`)。`MediaTransport.setRecording(roomId, sink)` 把觀眾那一路完整混音的**既有共用編碼**依混音順序交給 sink(沒人收聽完整混音時才額外加一份編碼),不另開 encoder;靜音略過編碼、codec 落後丟幀或編碼失敗的幀以 `null` 交付,寫檔時補成保留原 Opus 幀數的零長度幀(解碼端 PLC),時間軸不位移。`src/recording/oggOpus.ts` 依 RFC 7845 封裝(mono、pre-skip 0、每頁至多 50 包且 ≤255 lacing),`recorder.ts` 寫 `/-.opus`;寫檔失敗只記 error、不影響房間。`room:state` 增 `recording`(錄音中,所有人都看得到)與 `recordingAvailable`。單次錄音逾 `recording.maxDurationMinutes` 自動停止;關房、停機時停止並等檔案寫完。 + +253. **重啟後恢復**(2026-10-10 依需求加入):`persistence.stateFile` 非空時,hub 每 `saveIntervalMs` 定期刷新(包含安靜房間;慢速寫入時不累積待寫工作)把所有房間(`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 2cb3408..79fa6aa 100644 --- a/README.md +++ b/README.md @@ -124,14 +124,18 @@ 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 連線);`httpShutdownDrainMs`(最後 HTTP 排空期限,0–60000 ms,詳見監控與停機) | -| `limits` | `maxRooms`、`maxConnections`、`maxConnectionsPerIp`(同一位址同時連線,0=不限;NAT 或反向代理後多人共用位址,設定前先估算,代理後須開 `server.trustProxy`)、`joinTimeoutMs`(連上後未開房/進房的逾時,0=不限)、`maxSpeakersPerRoom`(8,主控以外的台上者;主控席位另計)、`maxAudiencePerRoom`(300)、`maxFrameBytes`(64KB)、`maxSendBufferBytes`(每條 ws 待送位元組上限,含下一則訊息;範例 1 MiB,舊設定省略時採 `maxFrameBytes × 16`)、`maxPendingMessages`/`maxPendingBytes`(每條 ws 等待處理的工作數/位元組上限,詳見下方輸入工作佇列)、`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)、`maxSendBufferBytes`(每條 ws 待送位元組上限,含下一則訊息;範例 1 MiB,舊設定省略時採 `maxFrameBytes × 16`)、`maxPendingMessages`/`maxPendingBytes`(每條 ws 等待處理的工作數/位元組上限,詳見下方輸入工作佇列)、`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]`)、`mediaWorkerTimeoutMs`(worker RPC 逾時,預設/舊設定省略時 30000,1–300000 ms;不適用於 `mediaWorkers: 0`)、`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]`)、`mediaWorkerTimeoutMs`(worker RPC 逾時,預設/舊設定省略時 30000,1–300000 ms;不適用於 `mediaWorkers: 0`)、`mediaWorkers`(承載 PeerConnection 的 worker thread 數,預設 2 對應 300 聽眾;0=主執行緒;`auto`=核心數 − 1 − 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 一律不入日誌) | `rooms.createToken` 最多 256 個 UTF-16 code unit,與既有 `room:create.token` 的 wire 驗證上限一致;啟動及離線設定檢查會提早拒絕超限設定,錯誤只指出欄位與上限,不回顯權杖。舊版雖可載入更長值,正常控制訊息無法提交該值完成認證。此檢查不 trim、正規化或截斷:空字串維持停用建立權杖,非空值(含空白)仍須精確匹配;補充平面字元計兩個 code unit,UTF-8 位元組數不是此上限。設定仍必須提供字串;不變更其他認證、frame 限制或既有公開錯誤。 +舊設定省略新增聊天欄位時,採範例值;省略 `recording`/`persistence` 整節時預設停用,因此升級不會自動錄音或寫入房間資料。 + ### 對外部署 `allowInsecure: false`、`host: 0.0.0.0`、填 `tls`;開放 `rtc.portRange` 的 UDP;設定 `rtc.turn`(coturn 需 `use-auth-secret` 與相同的 `static-auth-secret`)。憑證檔更新(含 ACME 工具的改名/symlink 替換)後約 2 秒自動重載,不需重啟;新憑證載入失敗時沿用舊憑證並記 error。 @@ -178,6 +182,8 @@ logger 會遞迴遮蔽物件/陣列中的 `code`、`resumeToken`、`token`、` 一般未上台、未舉手觀眾的名稱只對本人與目前主控可見;沒有等待中的合併廣播或房間狀態變更時,改名只刷新這兩位的個人化 snapshot。其他人的資料未變,因此不再收到這次冗餘 state/觸發其既有接收流程。台上者、舉手者、主控,以及有 pending 廣播的情況仍沿用全房更新;沒有新增週期廣播。受控 301 席/20 舉手工作量中,一次此類改名的 state frames 從 301 降為 2;1 個台上席的 JSON state bytes 為 1,319,402→62,426,9 席為 1,746,570→63,834。這些數字不含 ACK、WebSocket framing 或壓縮,也不代表 CPU、音訊或容量量測。 +監控:`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 協定摘要 無法解析的 WebSocket upgrade request target 會收到固定、空內容的 HTTP 400 並關閉該 socket,不建立 session,也不把原始 target 或 parser error 寫入診斷/回應。合法 URL 仍沿用既有 path、連線容量與握手檢查;停機中的 upgrade 仍優先回 503。這是輸入邊界的解析失敗隔離,不新增 Origin/認證政策或遠端服務驗證。 @@ -200,18 +206,24 @@ JSON frame,型別定義在 [`shared/protocol.ts`](shared/protocol.ts)。每個 | `mic:gain {targetId, gainDb}` | 主控調整某人在混音中的音量(±20 dB,0=不調整;疊加在自動音量正規化之上),離開舞台後再上台仍保留;所有人在 `room:state` 的 `gainDb` 看到 | | `room:rename {name}` | 主控更新房間名稱;沿用進房名稱清洗與設定長度,改變時全房收到個人化 state,再回請求者 ACK;同名只刷新請求者,仍須主控權限 | | `room:rotate-code` | 主控更換房間代碼(限需代碼的房間);舊代碼/邀請連結立即失效,既有連線保留。重連仍驗證代碼;主控 SDK 更新已收到的新代碼,其他成員未取得新代碼時無法以舊代碼重連 | +| `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` | | Server → Client | 說明 | |---|---| -| `hello` | `{protocol, serverVersion, limits: {controlPerSecond, icePerSecond, handRaiseIntervalMs}}`: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+釋放延遲);成功建立/加入/恢復連線時,在房間快照之後、ACK 之前送目前集合(可為空),之後只在集合變動時廣播 | | `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` | 事件廣播 | | `hand:rejected {roomId, participantId}` | 成功婉拒後,僅向當前在線申請者發送的暫時通知;在其更新快照之後,不保存或重播 | | `rtc:answer`、`rtc:ice` | 伺服器端信令 | @@ -419,6 +431,7 @@ node scripts/bench-control-plane.ts --hands # 真實本機 WebSocket+Mock 媒 node scripts/bench-audience-count.ts # 觀眾計數 getter 的有界比較 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/config.example.yaml b/config.example.yaml index 0a9461e..0a52754 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -47,6 +47,9 @@ limits: sdpMaxLength: 16384 joinTimeoutMs: 30000 # 連上 ws 後逾此時間仍未開房/進房即以 1008 斷線(0=不限;上限 2147483647) maxConnectionsPerIp: 0 # 同一位址同時連線上限(0=不限)。學校/公司 NAT 或未設 server.trustProxy 的反向代理會讓大量使用者共用一個位址,設定前請估算(可調整) + chatMaxLength: 500 # 聊天訊息字元上限(清洗後;可調整) + chatIntervalMs: 1000 # 每連線兩則聊天訊息的最短間隔;過快以 1008 斷線(client 依 hello.limits 自行節流;可調整) + reactionIntervalMs: 250 # 每連線兩次表情反應的最短間隔(同上;可調整) rooms: codeLength: 8 # 房間代碼長度(可調整) @@ -55,6 +58,7 @@ rooms: heartbeatIntervalMs: 30000 # ws ping 間隔(1000~2147483647) presenceBroadcastMs: 250 # 觀眾進出時合併 room:state 廣播的時間窗(0=即時;大量同時進場時避免 N² 廣播,可調整) qualityIntervalMs: 2000 # 每隔多久把台上者的連線品質(上下行掉包、RTT)送給主控與台上者(0=停用,可調整) + chatHistory: 50 # 每房保留的最近聊天訊息數,新進房者會收到(0=不保留;可調整) createToken: "" # 非空時建立房間須附此權杖;最多 256 UTF-16 code units,不 trim;只放 config.yaml,對外部署建議設定 audio: @@ -111,7 +115,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。 mediaWorkerTimeoutMs: 30000 # media worker RPC 逾時即重生該 worker、釋放待回覆工作;該 worker 的音訊連線需重建(可調整) mediaWorkers: 2 # 短期 TURN 憑證(coturn:use-auth-secret + static-auth-secret)。每次進房以 HMAC 簽發, @@ -121,5 +125,19 @@ 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 # 單次錄音上限,逾時自動停止(防止塞滿磁碟;可調整) + +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/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/packages/client/README.md b/packages/client/README.md index 25e8295..8b61019 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -24,6 +24,10 @@ ## 房間操作 +聊天與反應(任何已進房者):`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,嵌入時請明顯標示(`` 會在狀態列顯示「● 錄音中」)。 + `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)`(踢出房間)、`setRoomName(name)`(修改房間名稱)、`rotateCode()`(更換房間代碼,新代碼見主控的 `state.code`)、`closeRoom()`。`disconnect()` 停止自動重連。請求在伺服器 `ok` 後完成,失敗拋出有 `code` 的 `StageError`;逾時預設為 15 秒(可用 `requestTimeoutMs` 調整;另加本機排程等待)。舉手間隔依伺服器設定(`rateLimits.handRaiseIntervalMs`,預設 10 秒),`handCooldownMs` 可供倒數 UI 使用。 `createRoom`/`join` 在通過即時本機容量檢查後、任何等待前複製該次參數。hello、pacing 或 ACK 等待期間修改原物件,不會改變這次送出的欄位或成功後保存的回座 roomId/code/name;下一次明確呼叫會讀取新的值。支援的參數皆為字串/布林等基本值,採淺層複製,不凍結或修改呼叫端物件,也不更改驗證、修剪或錯誤順序。主控已收到的權威房間代碼仍依下述規則更新。 @@ -165,6 +169,7 @@ WebSocket 建構失敗、hello 逾時或尚未進房的連線關閉,也會釋 - 自訂 `audioElement` 的既有 `volume`/`muted` 原樣保留;getters 即時讀取該元件,因此外部變更也會反映。音量支援依瀏覽器而異(部分行動瀏覽器僅能使用系統音量)。不會繞過自動播放政策,仍須處理 `audioblocked`/`unlockAudio()`。 - 完整範例的「音訊設定」提供收聽音量滑桿與獨立的靜音收聽按鈕。 + ## 音訊裝置與麥克風測試 每人一個 PeerConnection,觀眾 recvonly;上台才索取麥克風(mono、48 kHz)、轉 sendrecv。權限失敗仍可收聽;修正權限/裝置後可明確呼叫 `retryMicrophone()`,或下台後重新上台。 diff --git a/packages/client/src/element.ts b/packages/client/src/element.ts index d88692d..8e8b459 100644 --- a/packages/client/src/element.ts +++ b/packages/client/src/element.ts @@ -1,10 +1,11 @@ import { StageClient, decodeName, MAX_GAIN_DB, type ConnectionQuality, type MicTest, 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', 'handrejected', '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, handrejected: 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 = ` @@ -2899,7 +2900,7 @@ export class StageElement extends HTMLElement { const raised = handIndex < 0 ? '已舉手' : `已舉手(${client!.status === 'reconnecting' ? '上次' : ''}佇列第 ${handIndex + 1} 位)`; const where = me.onStage ? (me.forceMuted ? '台上(主控已鎖定靜音)' : me.muted ? '台上(已靜音)' : '台上') : me.handRaised ? raised : '收聽中'; - 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 (this.joinPhase) { this.status.textContent = this.joinPhase === 'connecting' ? '連線中…' : '正在加入房間…'; } else if (!client) { diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 07907ea..4f05242 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 = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" }; @@ -146,6 +146,11 @@ export interface StageEventMap { speaking: { participantIds: string[] }; /** Full replacement of available publisher reports, possibly empty; controller and stage 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 StatsCounters { timestamp: number; bytes?: number; received?: number; lost?: number; bufferDelay?: number; emitted?: number } const finite = (value: unknown): number | undefined => typeof value === 'number' && Number.isFinite(value) ? value : undefined; @@ -157,7 +162,9 @@ type JoinOptions = { roomId: string; code?: string; name: string; resumeToken?: type AcceptedName = { readonly roomId: string; readonly participantId: string; readonly revision: number; readonly name: string }; const DEFAULT_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; @@ -215,6 +222,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. */ @@ -227,6 +237,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(); @@ -266,6 +279,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 - (performance.now() - this.lastHandAt)); } /** Limits the connected server enforces per connection (from `hello`; defaults before connecting). */ get rateLimits(): RateLimits { return this.limits; } @@ -512,7 +527,12 @@ export class StageClient extends EventTarget { this.lastHeardAt = performance.now(); try { const message = JSON.parse(String(event.data)) as ServerMessage; - if (message.type === 'hello') { this.limits = message.limits; ready = true; clearTimeout(timer); clearHello(); 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); clearHello(); this.startKeepalive(ws); resolve(); + } void this.receive(message).catch((error: unknown) => this.report(error)); } catch { this.report(new StageError('bad_request', '收到無法解析的伺服器訊息。')); } }; @@ -650,6 +670,18 @@ export class StageClient extends EventTarget { this.lastHandAt = performance.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 (performance.now() - this.lastChatAt < this.limits.chatIntervalMs) return Promise.reject(new StageError('rate_limited', '訊息傳送過快,請稍候。')); + this.lastChatAt = performance.now(); + return this.request('chat:send', { text }); + } + /** Send an emoji reaction from `REACTIONS`; paced by `rateLimits.reactionIntervalMs`. */ + react(emoji: Reaction): Promise { + if (performance.now() - this.lastReactionAt < this.limits.reactionIntervalMs) return Promise.reject(new StageError('rate_limited', '反應送出過快,請稍候。')); + this.lastReactionAt = performance.now(); + return this.request('reaction', { emoji }); + } /** Change only your own display name; the server sanitizes it and publishes authoritative state. */ setName(name: string): Promise { return this.request('participant:rename', { name }); } /** Update the room display name (controller only); the server snapshot is authoritative. */ @@ -675,6 +707,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; @@ -693,6 +728,7 @@ export class StageClient extends EventTarget { this.snapshot = null; this.lastHandAt = -Infinity; this.speakingIds = new Set(); + this.chatLog = []; this.stopKeepalive(); const socket = this.socket; this.socket = null; @@ -799,7 +835,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 || this.pcParticipantId !== state.me.participantId || pcState === 'failed' || pcState === 'closed') this.createMedia(); + if (!this.pc || this.mediaStale || this.pcParticipantId !== state.me.participantId || pcState === 'failed' || pcState === 'closed') { this.mediaStale = false; this.createMedia(); } this.syncMedia(); break; } @@ -832,6 +868,7 @@ export class StageClient extends EventTarget { } // Either way the seat is gone: forget the session so the socket close does not trigger a rejoin. case 'room:closed': case 'kicked': + this.chatLog = []; await this.disconnect(); if (message.type === 'kicked') this.emit('kicked', { roomId: message.roomId }); else this.emit('closed', message.reason ? { roomId: message.roomId, reason: message.reason } : { roomId: message.roomId }); @@ -855,6 +892,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/packages/client/test/answerOwnership.test.ts b/packages/client/test/answerOwnership.test.ts index 3b0aa9d..112b09c 100644 --- a/packages/client/test/answerOwnership.test.ts +++ b/packages/client/test/answerOwnership.test.ts @@ -65,7 +65,7 @@ class Peer { } function state(id: string): RoomStatePayload { return { roomId: 'room', name: 'room', controllerId: 'host', speakers: [], hands: [], audienceCount: 1, - codeRequired: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', + codeRequired: false, recording: false, recordingAvailable: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', me: { participantId: id, name: id, role: 'audience', onStage: false, handRaised: false, muted: false, forceMuted: false, gainDb: 0, connected: true, joinedAt: 0 } }; } @@ -94,8 +94,8 @@ async function fixture(t: TestContext) { }); const connected = client.connect(); const socket = Socket.instances[0]!; - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await connected; let acknowledgedOffers = 0; async function acknowledgeOffer() { diff --git a/packages/client/test/audioRetry.test.ts b/packages/client/test/audioRetry.test.ts index 7f92096..9debeac 100644 --- a/packages/client/test/audioRetry.test.ts +++ b/packages/client/test/audioRetry.test.ts @@ -82,12 +82,12 @@ class Peer { } function state(id = 'listener', status: 'waiting' | 'live' = 'live', onStage = false): RoomStatePayload { return { roomId: 'room', name: 'Room', controllerId: 'host', speakers: [], hands: [], audienceCount: 1, - codeRequired: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status, + codeRequired: false, recording: false, recordingAvailable: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status, me: { participantId: id, name: id, role: onStage ? 'speaker' : 'audience', onStage, handRaised: false, muted: false, forceMuted: false, gainDb: 0, connected: true, joinedAt: 0 } }; } -const hello = (socket: Socket) => socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }); +const hello = (socket: Socket) => socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); async function fixture(t: TestContext, options: { beforeJoinAck?: boolean; status?: 'waiting' | 'live'; speaker?: boolean; noState?: boolean } = {}) { Socket.instances = []; Peer.instances = []; let captures = 0, plays = 0; diff --git a/packages/client/test/autoplayOwnership.test.ts b/packages/client/test/autoplayOwnership.test.ts index 78641f3..6c040f7 100644 --- a/packages/client/test/autoplayOwnership.test.ts +++ b/packages/client/test/autoplayOwnership.test.ts @@ -35,7 +35,7 @@ class Peer { } function state(id: string): RoomStatePayload { return { roomId: 'room', name: 'room', controllerId: 'host', speakers: [], hands: [], audienceCount: 1, - codeRequired: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', + codeRequired: false, recording: false, recordingAvailable: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', me: { participantId: id, name: id, role: 'audience', onStage: false, handRaised: false, muted: false, forceMuted: false, gainDb: 0, connected: true, joinedAt: 0 } }; } diff --git a/packages/client/test/clock.test.ts b/packages/client/test/clock.test.ts index 8ca0302..a18a8b6 100644 --- a/packages/client/test/clock.test.ts +++ b/packages/client/test/clock.test.ts @@ -12,7 +12,7 @@ class Socket { onclose: (() => void) | null = null; constructor(_url: string) { Socket.instances.push(this); - queueMicrotask(() => this.onmessage?.({ data: JSON.stringify({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }) })); + queueMicrotask(() => this.onmessage?.({ data: JSON.stringify({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }) })); } send(data: string) { this.sent.push(data); @@ -89,7 +89,7 @@ describe('monotonic client intervals', () => { client['createMedia'] = () => {}; client['syncMedia'] = () => {}; const state = (participantId: string): RoomStatePayload => ({ roomId: 'room', name: 'Room', controllerId: 'host', speakers: [], hands: [], audienceCount: 1, - codeRequired: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', + codeRequired: false, recording: false, recordingAvailable: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', me: { participantId, name: 'Guest', role: 'audience', onStage: false, handRaised: false, muted: false, forceMuted: false, gainDb: 0, connected: true, joinedAt: 0 }, }); diff --git a/packages/client/test/closingSocketReplacement.test.ts b/packages/client/test/closingSocketReplacement.test.ts index 662069d..e7f9c49 100644 --- a/packages/client/test/closingSocketReplacement.test.ts +++ b/packages/client/test/closingSocketReplacement.test.ts @@ -22,8 +22,8 @@ class Socket { deliver(message: unknown) { this.onmessage?.({ data: JSON.stringify(message) }); } hello(controlPerSecond = 20) { this.readyState = Socket.OPEN; - this.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond, icePerSecond: 30, handRaiseIntervalMs: 10000 } }); + this.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); } // Native close() does not dispatch close until the handshake completes. close() { this.readyState = 2; } @@ -62,7 +62,7 @@ const code = (expected: StageError['code']) => (error: unknown) => error instanc function assertCode(error: unknown, expected: StageError['code']) { assert.ok(code(expected)(error), `${String(error)} should be ${expected}`); } function snapshot(roomId = 'old-room', id = 'guest'): RoomStatePayload { return { roomId, name: 'Room', controllerId: 'host', speakers: [], hands: [], audienceCount: 1, - codeRequired: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', resumeToken: 'resume', + codeRequired: false, recording: false, recordingAvailable: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', resumeToken: 'resume', me: { participantId: id, name: 'Guest', role: 'audience', onStage: false, handRaised: false, muted: false, forceMuted: false, gainDb: 0, connected: true, joinedAt: 0 } }; } diff --git a/packages/client/test/connectionCleanup.test.ts b/packages/client/test/connectionCleanup.test.ts index 8378bc6..3d874c0 100644 --- a/packages/client/test/connectionCleanup.test.ts +++ b/packages/client/test/connectionCleanup.test.ts @@ -15,7 +15,7 @@ class Socket { constructor(_url: string) { if (Socket.failConstruction) throw new Error('invalid URL'); Socket.instances.push(this); } send(data: string) { const request = JSON.parse(data); if (request.requestId) queueMicrotask(() => this.deliver({ type: 'ok', requestId: request.requestId })); } deliver(message: unknown) { this.onmessage?.({ data: JSON.stringify(message) }); } - hello() { this.readyState = 1; this.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }); } + hello() { this.readyState = 1; this.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); } close() { this.readyState = 3; if (!this.deferClose) this.onclose?.(); } } class Audio { diff --git a/packages/client/test/connectionUrl.test.ts b/packages/client/test/connectionUrl.test.ts index 76dd367..23c31dd 100644 --- a/packages/client/test/connectionUrl.test.ts +++ b/packages/client/test/connectionUrl.test.ts @@ -30,8 +30,8 @@ class Socket { deliver(message: unknown) { this.onmessage?.({ data: JSON.stringify(message) }); } hello() { this.readyState = Socket.OPEN; - this.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }); + this.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); } close() { this.readyState = 3; this.onclose?.(); } } @@ -69,7 +69,7 @@ function latestJoin(socket: Socket): Request { return request; } function snapshot(suffix: string): RoomStatePayload { - return { roomId: entry(suffix).roomId, name: `Synthetic room ${suffix}`, codeRequired: true, + return { roomId: entry(suffix).roomId, name: `Synthetic room ${suffix}`, codeRequired: true, recording: false, recordingAvailable: false, status: 'live', controllerId: 'synthetic-controller', audienceCount: 1, speakers: [], hands: [], resumeToken: `synthetic-seat-${suffix}`, limits: { maxSpeakers: 8, maxAudience: 300 }, me: { participantId: `synthetic-self-${suffix}`, name: entry(suffix).name, role: 'audience', diff --git a/packages/client/test/creationOwnership.test.ts b/packages/client/test/creationOwnership.test.ts index 3d5e3d1..c6b4056 100644 --- a/packages/client/test/creationOwnership.test.ts +++ b/packages/client/test/creationOwnership.test.ts @@ -34,7 +34,7 @@ class Peer { const created: Creation = { roomId: 'room', code: 'test-code' }; const state: RoomStatePayload = { roomId: 'room', name: 'Room', controllerId: 'host', speakers: [], hands: [], audienceCount: 0, - codeRequired: true, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', + codeRequired: true, recording: false, recordingAvailable: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', me: { participantId: 'host', name: 'Host', role: 'controller', onStage: false, handRaised: false, muted: false, forceMuted: false, gainDb: 0, connected: true, joinedAt: 0 }, }; @@ -42,7 +42,7 @@ const flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve() const observe = (promise: Promise): Promise> => promise.then( value => ({ ok: true, value }), (error: unknown) => ({ ok: false, error }), ); -const hello = (socket: Socket) => socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }); +const hello = (socket: Socket) => socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); const acknowledge = (socket: Socket, request: RequestMessage) => socket.deliver({ type: 'ok', requestId: request.requestId }); const rejectJoined = (socket: Socket, request: RequestMessage) => socket.deliver({ type: 'error', requestId: request.requestId, code: 'already_joined', message: 'Already joined.' }); function assertError(outcome: Outcome, code: StageError['code']): void { diff --git a/packages/client/test/element.test.ts b/packages/client/test/element.test.ts index d607806..947533e 100644 --- a/packages/client/test/element.test.ts +++ b/packages/client/test/element.test.ts @@ -107,7 +107,7 @@ function click(button: HTMLButtonElement): void { button.onclick?.call(button, { function roomState(onStage = false, roomId = 'one'): RoomStatePayload { return { roomId, name: 'Room & Hall', controllerId: 'host', speakers: [], hands: [], audienceCount: 1, - codeRequired: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', + codeRequired: false, recording: false, recordingAvailable: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', me: { participantId: 'me', name: 'Listener', role: onStage ? 'speaker' : 'audience', onStage, handRaised: false, muted: false, forceMuted: false, gainDb: 0, connected: true, joinedAt: 0 }, }; @@ -187,8 +187,8 @@ function socketJoiningWidget(t: TestContext, attributes: Record deliver(message: ServerMessage): void { this.onmessage?.({ data: JSON.stringify(message) }); } hello(): void { this.readyState = Socket.OPEN; - this.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }); + this.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); } close(): void { this.readyState = 3; this.onclose?.(); } } diff --git a/packages/client/test/elementHandQueueSearch.test.ts b/packages/client/test/elementHandQueueSearch.test.ts index 283fecc..a441c16 100644 --- a/packages/client/test/elementHandQueueSearch.test.ts +++ b/packages/client/test/elementHandQueueSearch.test.ts @@ -99,8 +99,8 @@ function socketJoiningWidget(t: TestContext, attributes: Record deliver(message: ServerMessage): void { this.onmessage?.({ data: JSON.stringify(message) }); } hello(): void { this.readyState = Socket.OPEN; - this.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }); + this.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); } close(): void { this.readyState = 3; this.onclose?.(); } } diff --git a/packages/client/test/elementMicTest.test.ts b/packages/client/test/elementMicTest.test.ts index 9f5d3cf..2f0ae95 100644 --- a/packages/client/test/elementMicTest.test.ts +++ b/packages/client/test/elementMicTest.test.ts @@ -102,7 +102,7 @@ export function byId(element: Widget, id: string): HostNode { } export const flush = async (): Promise => { for (let i = 0; i < 35; i++) await Promise.resolve(); }; export function state(overrides: Partial = {}): RoomStatePayload { return { roomId: 'same-room', name: '測試舞台', controllerId: 'host', speakers: [], hands: [], audienceCount: 1, - codeRequired: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', resumeToken: 'test-resume', + codeRequired: false, recording: false, recordingAvailable: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', resumeToken: 'test-resume', me: { participantId: 'guest', name: '測試聽眾', role: 'audience', onStage: false, handRaised: false, muted: false, forceMuted: false, gainDb: 0, connected: true, joinedAt: 0 }, ...overrides }; } @@ -136,7 +136,7 @@ export async function widgetFixture(t: TestContext, initial = state()) { const until = async (check: () => boolean, reason: string): Promise => { for (let count = 0; count < 100 && !check(); count++) await tick(); assert.ok(check(), reason); }; const joinLatest = async (snapshot = initial) => { await flush(); const socket = StageSocket.instances.at(-1)!; assert.ok(socket); - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'fixture', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'fixture', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await until(() => socket.sent.some(message => message.type === 'join'), 'actual SDK join'); const join = socket.sent.find(message => message.type === 'join')!; socket.deliver({ type: 'room:state', ...snapshot }); socket.deliver({ type: 'ok', requestId: join.requestId }); @@ -483,7 +483,7 @@ it('same-client explicit SDK reentry restores widget listeners only on current a const h = await widgetFixture(t); await h.client.disconnect(); await flush(); assert.equal(getEventListeners(h.pageDocument, 'visibilitychange').length, 0); assert.equal(getEventListeners(h.pageWindow, 'pagehide').length, 0); const connected = h.client.connect(); await flush(); - StageSocket.instances.at(-1)!.deliver({ type: 'hello', protocol: 1, serverVersion: 'fixture', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + StageSocket.instances.at(-1)!.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'fixture', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await connected; const joined = h.client.join({ roomId: 'same-room', name: 'Restored' }); await h.joinLatest(); await joined; assert.equal(getEventListeners(h.pageDocument, 'visibilitychange').length, 1); assert.equal(h.captures.length, 0); await active(h); }); diff --git a/packages/client/test/elementPublicRoster.test.ts b/packages/client/test/elementPublicRoster.test.ts index ef0df21..beb015d 100644 --- a/packages/client/test/elementPublicRoster.test.ts +++ b/packages/client/test/elementPublicRoster.test.ts @@ -153,7 +153,7 @@ function replacement(client: StageClient, update: Partial = {} const old = client.state!; return { roomId: old.roomId, name: old.name, controllerId: old.controllerId, speakers: old.speakers.map(person => ({ ...person })), hands: [], audienceCount: old.audienceCount, - codeRequired: false, limits: { ...old.limits }, status: old.status, me: { ...old.me }, ...update }; + codeRequired: false, recording: false, recordingAvailable: false, limits: { ...old.limits }, status: old.status, me: { ...old.me }, ...update }; } async function receive(client: StageClient, state: RoomStatePayload): Promise { await client['receive']({ type: 'room:state', ...state }); await drain(); diff --git a/packages/client/test/elementPublicRosterRetirement.test.ts b/packages/client/test/elementPublicRosterRetirement.test.ts index 7af125c..c98dcd2 100644 --- a/packages/client/test/elementPublicRosterRetirement.test.ts +++ b/packages/client/test/elementPublicRosterRetirement.test.ts @@ -85,7 +85,7 @@ async function widgetFixture(t: TestContext, initial = baseState()) { const until = async (check: () => boolean, reason: string): Promise => { for (let count = 0; count < 100 && !check(); count++) await tick(); assert.ok(check(), reason); }; const joinLatest = async (snapshot = initial) => { await flush(); const socket = StageSocket.instances.at(-1)!; assert.ok(socket); - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'fixture', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'fixture', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await until(() => socket.sent.some(message => message.type === 'join'), 'actual SDK join'); const join = socket.sent.find(message => message.type === 'join')!; socket.deliver({ type: 'room:state', ...snapshot }); socket.deliver({ type: 'ok', requestId: join.requestId }); diff --git a/packages/client/test/entryOptions.test.ts b/packages/client/test/entryOptions.test.ts index 18b4618..51290a7 100644 --- a/packages/client/test/entryOptions.test.ts +++ b/packages/client/test/entryOptions.test.ts @@ -22,8 +22,8 @@ class Socket { deliver(message: unknown) { this.onmessage?.({ data: JSON.stringify(message) }); } hello() { this.readyState = Socket.OPEN; - this.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }); + this.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); } close() { this.readyState = 3; this.onclose?.(); } } @@ -53,7 +53,7 @@ const entryType = (operation: Operation) => operation === 'join' ? 'join' : 'roo function inputs(operation: Operation, suffix = 'A', optional = true): EntryOptions { return operation === 'join' ? { roomId: `room-${suffix}`, name: `Name ${suffix}`, ...(optional ? { code: `code-${suffix}` } : {}) } - : { name: `Name ${suffix}`, ...(optional ? { roomName: `Room ${suffix}`, codeRequired: true, token: `token-${suffix}` } : {}) }; + : { name: `Name ${suffix}`, ...(optional ? { roomName: `Room ${suffix}`, codeRequired: true, recording: false, recordingAvailable: false, token: `token-${suffix}` } : {}) }; } function fields(request: Request): Omit { const { requestId: _, ...payload } = request; @@ -71,7 +71,7 @@ function assertError(outcome: Outcome, code: StageError['code']) { assert.equal(outcome.error.code, code); } function snapshot(role: RoomStatePayload['me']['role'], code?: string): RoomStatePayload { - return { roomId: 'room-A', name: 'Room A', codeRequired: true, code, status: 'live', + return { roomId: 'room-A', name: 'Room A', codeRequired: true, recording: false, recordingAvailable: false, code, status: 'live', controllerId: 'self', audienceCount: 1, speakers: [], hands: [], resumeToken: 'seat-A', limits: { maxSpeakers: 8, maxAudience: 300 }, me: { participantId: 'self', name: 'Name A', role, onStage: false, handRaised: false, diff --git a/packages/client/test/eventSubscription.test.ts b/packages/client/test/eventSubscription.test.ts index a6b3486..3fb63d9 100644 --- a/packages/client/test/eventSubscription.test.ts +++ b/packages/client/test/eventSubscription.test.ts @@ -30,8 +30,8 @@ async function fixture(t: TestContext) { } }); const connected = client.connect(), socket = Socket.latest; - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'event-test', - limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'event-test', + limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await connected; const hand = (participantId = 'participant', raised = true) => socket.deliver({ type: raised ? 'hand:raise' : 'hand:withdraw', participantId }); return { client, hand }; diff --git a/packages/client/test/handAdmission.test.ts b/packages/client/test/handAdmission.test.ts index f521a18..8ae3187 100644 --- a/packages/client/test/handAdmission.test.ts +++ b/packages/client/test/handAdmission.test.ts @@ -52,7 +52,7 @@ class Peer { const state: RoomStatePayload = { roomId: 'room', name: 'Room', controllerId: 'host', speakers: [], hands: [], audienceCount: 1, - codeRequired: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', resumeToken: 'test-resume-token', + codeRequired: false, recording: false, recordingAvailable: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', resumeToken: 'test-resume-token', me: { participantId: 'guest', name: 'Guest', role: 'audience', onStage: false, handRaised: false, muted: false, forceMuted: false, gainDb: 0, connected: true, joinedAt: 0 }, }; @@ -65,8 +65,8 @@ function assertError(error: unknown, code: StageError['code'], message?: string) } function hello(socket: Socket, handRaiseIntervalMs = 10000): void { socket.readyState = Socket.OPEN; - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { chatIntervalMs: 1000, reactionIntervalMs: 250, controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs } }); } function accounting(client: StageClient) { return { pacing: [client['nextControlAt'], client['nextIceAt'], client['lastSendAt']], diff --git a/packages/client/test/handRejection.test.ts b/packages/client/test/handRejection.test.ts index b57524e..9a550ad 100644 --- a/packages/client/test/handRejection.test.ts +++ b/packages/client/test/handRejection.test.ts @@ -23,8 +23,8 @@ class Socket { deliver(message: ServerMessage) { this.onmessage?.({ data: JSON.stringify(message) }); } hello() { this.readyState = Socket.OPEN; - this.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }); + this.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); } close() { this.readyState = 3; this.onclose?.(); } } @@ -57,7 +57,7 @@ function snapshot(options: Partial> const me: RoomStatePayload['me'] = { participantId: 'self', name: 'Self', role: 'audience', onStage: false, handRaised: false, muted: false, forceMuted: false, gainDb: 0, connected: true, joinedAt: 0, ...options.me }; return { roomId: options.roomId ?? 'room', name: 'Room', controllerId: 'host', speakers: [], - hands: me.handRaised ? [me] : [], audienceCount: 1, codeRequired: false, status: options.status ?? 'live', + hands: me.handRaised ? [me] : [], audienceCount: 1, codeRequired: false, recording: false, recordingAvailable: false, status: options.status ?? 'live', resumeToken: `seat-${me.participantId}`, limits: { maxSpeakers: 8, maxAudience: 300 }, me }; } const deliverState = (socket: Socket, state: RoomStatePayload) => socket.deliver({ type: 'room:state', ...state }); diff --git a/packages/client/test/helloCancellation.test.ts b/packages/client/test/helloCancellation.test.ts index a7232c0..c652ea0 100644 --- a/packages/client/test/helloCancellation.test.ts +++ b/packages/client/test/helloCancellation.test.ts @@ -23,8 +23,8 @@ class Socket { deliver(message: unknown): void { this.onmessage?.({ data: JSON.stringify(message) }); } hello(): void { this.readyState = Socket.OPEN; - this.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }); + this.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); } close(): void { this.closeCalls++; diff --git a/packages/client/test/helloCancellationIntegration.test.ts b/packages/client/test/helloCancellationIntegration.test.ts index 807ee06..db599d2 100644 --- a/packages/client/test/helloCancellationIntegration.test.ts +++ b/packages/client/test/helloCancellationIntegration.test.ts @@ -34,8 +34,8 @@ it('cancels a real pre-hello connection before its paused WebSocket close handsh this.on('error', error => socketErrors.push(error)); } } - const hello = { type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }; + const hello = { type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }; const server = new WebSocketServer({ host: '127.0.0.1', port: 0 }); let client: StageClient | undefined; server.on('connection', socket => { diff --git a/packages/client/test/iceErrorOwnership.test.ts b/packages/client/test/iceErrorOwnership.test.ts index 86a9ffb..6177d71 100644 --- a/packages/client/test/iceErrorOwnership.test.ts +++ b/packages/client/test/iceErrorOwnership.test.ts @@ -60,12 +60,12 @@ class Peer { } function state(id: string): RoomStatePayload { return { roomId: 'room', name: 'Room', controllerId: 'host', speakers: [], hands: [], audienceCount: 1, - codeRequired: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', + codeRequired: false, recording: false, recordingAvailable: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', me: { participantId: id, name: id, role: 'audience', onStage: false, handRaised: false, muted: false, forceMuted: false, gainDb: 0, connected: true, joinedAt: 0 } }; } -const hello = (socket: Socket) => socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }); +const hello = (socket: Socket) => socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); const candidate = { candidate: 'candidate:0', sdpMid: '0', sdpMLineIndex: 0 }; function localIce(peer: Peer) { assert.ok(peer.onicecandidate); diff --git a/packages/client/test/keepalivePacing.test.ts b/packages/client/test/keepalivePacing.test.ts index 9cbe7c1..4c5e518 100644 --- a/packages/client/test/keepalivePacing.test.ts +++ b/packages/client/test/keepalivePacing.test.ts @@ -62,9 +62,9 @@ describe('SDK keepalive shares control pacing', () => { clients.push(client); return client; } function hello(socket: Socket, controlPerSecond = 1, icePerSecond = 30): void { - const limits: RateLimits = { controlPerSecond, icePerSecond, handRaiseIntervalMs: 10000 }; + const limits: RateLimits = { controlPerSecond, icePerSecond, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 }; socket.limiter = new ConnectionRateLimiter({ ...testConfig().limits, ...limits }, now); - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits }); } async function connect(client: StageClient, controlPerSecond = 1, icePerSecond = 30): Promise { const ready = client.connect(), socket = Socket.instances.at(-1)!; diff --git a/packages/client/test/mediaLifecycle.test.ts b/packages/client/test/mediaLifecycle.test.ts index 2c116d6..7e95da3 100644 --- a/packages/client/test/mediaLifecycle.test.ts +++ b/packages/client/test/mediaLifecycle.test.ts @@ -37,7 +37,7 @@ function stream() { } function state(onStage: boolean, id: string): RoomStatePayload { return { roomId: id, name: id, controllerId: 'host', speakers: [], hands: [], audienceCount: 0, - codeRequired: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', + codeRequired: false, recording: false, recordingAvailable: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', me: { participantId: id, name: id, role: onStage ? 'speaker' : 'audience', onStage, handRaised: false, muted: false, forceMuted: false, gainDb: 0, connected: true, joinedAt: 0 } }; } diff --git a/packages/client/test/micEnded.test.ts b/packages/client/test/micEnded.test.ts index 60e7c28..1045d56 100644 --- a/packages/client/test/micEnded.test.ts +++ b/packages/client/test/micEnded.test.ts @@ -33,7 +33,7 @@ function replayListeners(source: ReturnType, callbacks: ReturnTyp } function state(onStage: boolean, muted = false): RoomStatePayload { return { roomId: 'room', name: 'Room', controllerId: 'host', speakers: [], hands: [], audienceCount: 1, - codeRequired: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', resumeToken: 'test-resume-token', + codeRequired: false, recording: false, recordingAvailable: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', resumeToken: 'test-resume-token', me: { participantId: 'guest', name: 'Guest', role: onStage ? 'speaker' : 'audience', onStage, handRaised: false, muted, forceMuted: false, gainDb: 0, connected: true, joinedAt: 0 } }; } @@ -50,8 +50,8 @@ class Socket { onclose: (() => void) | null = null; constructor() { Socket.instances.push(this); - queueMicrotask(() => this.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } })); + queueMicrotask(() => this.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } })); } deliver(message: ServerMessage) { if (message.type === 'room:state') { this.onStage = message.me.onStage; this.snapshot = message; } diff --git a/packages/client/test/micMeterOption.test.ts b/packages/client/test/micMeterOption.test.ts index 9b9808a..9a0aca4 100644 --- a/packages/client/test/micMeterOption.test.ts +++ b/packages/client/test/micMeterOption.test.ts @@ -16,7 +16,7 @@ function stream() { } function state(onStage: boolean, muted = false): RoomStatePayload { return { roomId: 'room', name: 'Room', controllerId: 'host', speakers: [], hands: [], audienceCount: 1, - codeRequired: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', resumeToken: 'test-resume-token', + codeRequired: false, recording: false, recordingAvailable: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', resumeToken: 'test-resume-token', me: { participantId: 'guest', name: 'Guest', role: onStage ? 'speaker' : 'audience', onStage, handRaised: false, muted, forceMuted: false, gainDb: 0, connected: true, joinedAt: 0 } }; } @@ -32,8 +32,8 @@ class Socket { onclose: (() => void) | null = null; constructor() { Socket.instances.push(this); - queueMicrotask(() => this.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } })); + queueMicrotask(() => this.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } })); } deliver(message: ServerMessage) { if (message.type === 'room:state') { this.onStage = message.me.onStage; this.snapshot = message; } diff --git a/packages/client/test/micTestEnded.test.ts b/packages/client/test/micTestEnded.test.ts index fa2be98..6e47859 100644 --- a/packages/client/test/micTestEnded.test.ts +++ b/packages/client/test/micTestEnded.test.ts @@ -54,7 +54,7 @@ class Context { } function state(onStage: boolean): RoomStatePayload { return { roomId: 'room', name: 'Room', controllerId: 'host', speakers: [], hands: [], audienceCount: 1, - codeRequired: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', resumeToken: 'test-resume-token', + codeRequired: false, recording: false, recordingAvailable: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', resumeToken: 'test-resume-token', me: { participantId: 'guest', name: 'Guest', role: onStage ? 'speaker' : 'audience', onStage, handRaised: false, muted: false, forceMuted: false, gainDb: 0, connected: true, joinedAt: 0 } }; } @@ -68,8 +68,8 @@ class Socket { onclose: (() => void) | null = null; constructor() { Socket.instances.push(this); - queueMicrotask(() => this.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } })); + queueMicrotask(() => this.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } })); } deliver(message: ServerMessage) { if (message.type === 'room:state') this.onStage = message.me.onStage; diff --git a/packages/client/test/participantRename.test.ts b/packages/client/test/participantRename.test.ts index 6810eaa..bd835b6 100644 --- a/packages/client/test/participantRename.test.ts +++ b/packages/client/test/participantRename.test.ts @@ -25,8 +25,8 @@ class Socket { deliver(message: ServerMessage) { this.onmessage?.({ data: JSON.stringify(message) }); } hello() { this.readyState = Socket.OPEN; - this.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }); + this.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); } close() { this.readyState = 3; this.onclose?.(); } } @@ -57,7 +57,7 @@ const flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve() function snapshot(name = 'Self', options: { roomId?: string; participantId?: string; status?: 'live' | 'waiting'; onStage?: boolean } = {}): RoomStatePayload { const { roomId = 'room', participantId = 'self', status = 'live', onStage = false } = options; return { roomId, name: 'Room', controllerId: 'host', speakers: [], hands: [], audienceCount: onStage ? 0 : 1, - codeRequired: false, status, resumeToken: `seat-${participantId}`, limits: { maxSpeakers: 8, maxAudience: 300 }, + codeRequired: false, recording: false, recordingAvailable: false, status, resumeToken: `seat-${participantId}`, limits: { maxSpeakers: 8, maxAudience: 300 }, me: { participantId, name, role: onStage ? 'speaker' : 'audience', onStage, handRaised: false, muted: false, forceMuted: false, gainDb: 0, connected: true, joinedAt: 0 } }; } diff --git a/packages/client/test/playback.test.ts b/packages/client/test/playback.test.ts index 603876e..ef0e965 100644 --- a/packages/client/test/playback.test.ts +++ b/packages/client/test/playback.test.ts @@ -23,7 +23,7 @@ class FakeSocket { onclose: (() => void) | null = null; constructor(_url: string) { FakeSocket.instances.push(this); - queueMicrotask(() => this.onmessage?.({ data: JSON.stringify({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }) })); + queueMicrotask(() => this.onmessage?.({ data: JSON.stringify({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }) })); } send(data: string) { this.sent.push(data); diff --git a/packages/client/test/reconnect.test.ts b/packages/client/test/reconnect.test.ts index 9ca086d..23e7448 100644 --- a/packages/client/test/reconnect.test.ts +++ b/packages/client/test/reconnect.test.ts @@ -16,7 +16,7 @@ class Socket { close() { this.readyState = 3; if (!this.deferClose) this.onclose?.(); } } const flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve(); }; -const hello = (socket: Socket) => socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }); +const hello = (socket: Socket) => socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); function setup(t: TestContext) { Socket.instances = []; const originals = new Map(['window', 'WebSocket'].map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); diff --git a/packages/client/test/reconnectOptions.test.ts b/packages/client/test/reconnectOptions.test.ts index 32c9216..dad73d3 100644 --- a/packages/client/test/reconnectOptions.test.ts +++ b/packages/client/test/reconnectOptions.test.ts @@ -18,7 +18,7 @@ class Socket { const MAX_DELAY = 2147483647; const flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve(); }; -const hello = (socket: Socket) => socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }); +const hello = (socket: Socket) => socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); const invalidValues: unknown[] = [NaN, Infinity, -Infinity, -1, -Number.MIN_VALUE, MAX_DELAY + 1, Number.MAX_VALUE, '500', null, undefined, true, false, {}, [], 1n, Symbol('delay')]; describe('client reconnect options', () => { diff --git a/packages/client/test/requestCapacity.test.ts b/packages/client/test/requestCapacity.test.ts index db514c8..78a7ba8 100644 --- a/packages/client/test/requestCapacity.test.ts +++ b/packages/client/test/requestCapacity.test.ts @@ -35,14 +35,14 @@ class Peer { const state: RoomStatePayload = { roomId: 'room', name: 'Room', controllerId: 'host', speakers: [], hands: [], audienceCount: 1, - codeRequired: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', + codeRequired: false, recording: false, recordingAvailable: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', me: { participantId: 'guest', name: 'Guest', role: 'audience', onStage: false, handRaised: false, muted: false, forceMuted: false, gainDb: 0, connected: true, joinedAt: 0 }, }; const flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve(); }; const settle = (request: Promise): Promise => request.then(() => undefined, (error: unknown) => error); -const hello = (socket: Socket) => socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }); +const hello = (socket: Socket) => socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); function assertError(error: unknown, code: StageError['code']): void { assert.ok(error instanceof StageError); assert.equal(error.code, code); diff --git a/packages/client/test/roomCodeResume.test.ts b/packages/client/test/roomCodeResume.test.ts index 56cbf2e..907f472 100644 --- a/packages/client/test/roomCodeResume.test.ts +++ b/packages/client/test/roomCodeResume.test.ts @@ -35,13 +35,13 @@ class Peer { } const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); }; -const hello = (socket: Socket) => socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }); +const hello = (socket: Socket) => socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); function state(options: { roomId?: string; code?: string; role?: RoomStatePayload['me']['role']; codeRequired?: boolean; status?: RoomStatePayload['status']; resumeToken?: string } = {}): RoomStatePayload { const { roomId = 'room', code, role = 'controller', codeRequired = true, status = 'live', resumeToken = 'seat-token' } = options; return { roomId, name: roomId, controllerId: role === 'controller' ? 'self' : 'other', speakers: [], hands: [], - audienceCount: role === 'audience' ? 1 : 0, codeRequired, status, code, resumeToken, + audienceCount: role === 'audience' ? 1 : 0, codeRequired, status, code, resumeToken, recording: false, recordingAvailable: false, limits: { maxSpeakers: 8, maxAudience: 300 }, me: { participantId: 'self', name: 'Self', role, onStage: false, handRaised: false, muted: false, forceMuted: false, gainDb: 0, connected: true, joinedAt: 0 } }; diff --git a/packages/client/test/roomRename.test.ts b/packages/client/test/roomRename.test.ts index 3fb8d6e..727b26e 100644 --- a/packages/client/test/roomRename.test.ts +++ b/packages/client/test/roomRename.test.ts @@ -25,8 +25,8 @@ class Socket { deliver(message: ServerMessage) { this.onmessage?.({ data: JSON.stringify(message) }); } hello() { this.readyState = Socket.OPEN; - this.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }); + this.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); } close() { this.readyState = 3; this.onclose?.(); } } @@ -62,7 +62,7 @@ function snapshot(name = 'Original room', options: { roomId?: string; participan muted: false, forceMuted: false, gainDb: 3, connected: true, joinedAt: 123 }; return { roomId, name, controllerId: role === 'controller' ? participantId : 'host', speakers: onStage ? [me] : [], hands: [], audienceCount: role === 'audience' ? 1 : 0, - codeRequired: true, status, resumeToken: `seat-${participantId}`, limits: { maxSpeakers: 8, maxAudience: 300 }, + codeRequired: true, recording: false, recordingAvailable: false, status, resumeToken: `seat-${participantId}`, limits: { maxSpeakers: 8, maxAudience: 300 }, ...(role === 'controller' ? { code: 'room-code', audience: [] } : {}), me }; } const deliverState = (socket: Socket, state: RoomStatePayload) => socket.deliver({ type: 'room:state', ...state }); diff --git a/packages/client/test/stageExitCapture.test.ts b/packages/client/test/stageExitCapture.test.ts index 8de0bc3..03d32da 100644 --- a/packages/client/test/stageExitCapture.test.ts +++ b/packages/client/test/stageExitCapture.test.ts @@ -16,7 +16,7 @@ function stream() { } function state(onStage: boolean, muted = false): RoomStatePayload { return { roomId: 'room', name: 'Room', controllerId: 'host', speakers: [], hands: [], audienceCount: 1, - codeRequired: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', resumeToken: 'test-resume-token', + codeRequired: false, recording: false, recordingAvailable: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', resumeToken: 'test-resume-token', me: { participantId: 'guest', name: 'Guest', role: onStage ? 'speaker' : 'audience', onStage, handRaised: false, muted, forceMuted: false, gainDb: 0, connected: true, joinedAt: 0 } }; } @@ -32,8 +32,8 @@ class Socket { onclose: (() => void) | null = null; constructor() { Socket.instances.push(this); - queueMicrotask(() => this.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } })); + queueMicrotask(() => this.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } })); } deliver(message: ServerMessage) { if (message.type === 'room:state') { this.onStage = message.me.onStage; this.snapshot = message; } diff --git a/packages/client/test/stalledReconnect.test.ts b/packages/client/test/stalledReconnect.test.ts index bcdb8cc..8a636f2 100644 --- a/packages/client/test/stalledReconnect.test.ts +++ b/packages/client/test/stalledReconnect.test.ts @@ -67,8 +67,8 @@ for (const stall of ['hello', 'resume ACK'] as const) { }); if (attempt === 2 && stall === 'hello') { socket.pause(); return; } socket.send(JSON.stringify({ - type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 }, + type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 }, })); }); try { diff --git a/packages/client/test/timeouts.test.ts b/packages/client/test/timeouts.test.ts index 89287f9..cb98221 100644 --- a/packages/client/test/timeouts.test.ts +++ b/packages/client/test/timeouts.test.ts @@ -42,7 +42,7 @@ describe('configurable client request deadlines', () => { async function connect(client: StageClient) { const ready = client.connect(); const socket = Socket.instances.at(-1)!; - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await ready; return socket; } function peer(client: StageClient) { 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; 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'); } 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 f4ad249..7481e83 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -35,6 +35,13 @@ * - 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. + * - 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; @@ -45,6 +52,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'; @@ -80,6 +103,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[]; @@ -122,6 +149,8 @@ export interface RateLimits { controlPerSecond: number; icePerSecond: number; handRaiseIntervalMs: number; + chatIntervalMs: number; + reactionIntervalMs: number; } export type StageLeftReason = 'leave' | 'removed'; @@ -143,7 +172,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' }; @@ -170,6 +200,9 @@ export interface ServerMessageMap { kicked: { roomId: string }; /** Full replacement of available publisher reports; an empty set clears previous reports. */ quality: { participants: ConnectionQuality[] }; + chat: ChatMessage; + 'chat:history': { messages: ChatMessage[] }; + reaction: { participantId: string; name: string; emoji: Reaction }; } export type ServerMessageType = keyof ServerMessageMap; @@ -203,6 +236,10 @@ 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 }; + 'recording:start': { requestId: string }; + 'recording:stop': { requestId: string }; ping: Record; } @@ -235,5 +272,9 @@ export const CLIENT_MESSAGE_TYPES: readonly ClientMessageType[] = [ 'rtc:offer', 'rtc:answer', 'rtc:ice', + 'chat:send', + 'reaction', + 'recording:start', + 'recording:stop', 'ping', ]; diff --git a/src/config.ts b/src/config.ts index 038c1c7..bb2df08 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,5 @@ import { readFileSync } from 'node:fs'; +import { availableParallelism } from 'node:os'; import { LineCounter, parse, YAMLParseError } from 'yaml'; import type { IceServerConfig } from '../shared/protocol.ts'; @@ -44,6 +45,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; @@ -55,6 +62,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; }; @@ -121,6 +130,22 @@ 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; + }; + persistence: { + /** JSON file the rooms are saved to and restored from; empty disables persistence. */ + stateFile: string; + /** How often state and liveness are 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' }; } @@ -176,6 +201,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)); +} + // Node timer delays above this value normalize to 1ms instead of a longer wait. const MAX_TIMER_DELAY_MS = 2_147_483_647; const LOOPBACK: Record = { '127.0.0.1': true, '::1': true, localhost: true }; @@ -243,6 +276,9 @@ export function parseConfig(raw: unknown): AppConfig { sdpMaxLength: int(l, 'sdpMaxLength', 'limits', 256), joinTimeoutMs: int(l, 'joinTimeoutMs', 'limits', 0, MAX_TIMER_DELAY_MS), maxConnectionsPerIp: int(l, 'maxConnectionsPerIp', 'limits', 0), + chatMaxLength: l.chatMaxLength === undefined ? 500 : int(l, 'chatMaxLength', 'limits', 1, 2000), + chatIntervalMs: l.chatIntervalMs === undefined ? 1000 : int(l, 'chatIntervalMs', 'limits', 0), + reactionIntervalMs: l.reactionIntervalMs === undefined ? 250 : int(l, 'reactionIntervalMs', 'limits', 0), }; const r = obj(root.rooms, 'rooms'); @@ -253,6 +289,7 @@ export function parseConfig(raw: unknown): AppConfig { heartbeatIntervalMs: int(r, 'heartbeatIntervalMs', 'rooms', 1000, MAX_TIMER_DELAY_MS), presenceBroadcastMs: int(r, 'presenceBroadcastMs', 'rooms', 0, 10000), qualityIntervalMs: int(r, 'qualityIntervalMs', 'rooms', 0, 60000), + chatHistory: r.chatHistory === undefined ? 50 : int(r, 'chatHistory', 'rooms', 0, 1000), createToken: str(r, 'createToken', 'rooms'), }; if (rooms.codeLength > limits.codeMaxLength) throw new ConfigError('rooms.codeLength: exceeds limits.codeMaxLength'); @@ -353,16 +390,31 @@ 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), mediaWorkerTimeoutMs: t.mediaWorkerTimeoutMs === undefined ? 30000 : int(t, 'mediaWorkerTimeoutMs', 'rtc', 1, 300000), turn, }; + const rc = obj(root.recording === undefined ? { enabled: false, dir: 'recordings', maxDurationMinutes: 240 } : 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 ps = obj(root.persistence === undefined ? { stateFile: '', saveIntervalMs: 2000, restoreGraceMs: 120000 } : 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, MAX_TIMER_DELAY_MS), + }; + 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, 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 9255016..0d75347 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,8 @@ 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 { loadState, saveState } from './persistence.ts'; import { WeriftMediaTransport } from './transport/WeriftMediaTransport.ts'; import { StageHub } from './ws/hub.ts'; import { createStageServer } from './ws/server.ts'; @@ -62,9 +64,38 @@ const stageHub = new StageHub({ mixer.start(mixerClock); return mixer; }, + startRecording: (roomId) => startRecording({ + dir: resolve(root, config.recording.dir), + roomId, + frameSamples: audio.frameMs * 48, + vendor: `Stage.js ${version}`, + now: Date.now(), + log, + }), }); 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 pendingSave = false; + const save = () => { + // Refresh liveness even for quiet rooms; bound slow-disk work to one pending snapshot. + if (pendingSave) return; + pendingSave = true; + saving = saveState(stateFile, stageHub.exportState()) + .catch((error: unknown) => log.error('state file write failed', { error })) + .finally(() => { pendingSave = false; }); + }; + // 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 = config.server.metrics.enabled ? processSamples() : () => []; const server = createStageServer({ config, @@ -90,12 +121,18 @@ const stop = async (signal: string, code = 0) => { if (stopping) return; stopping = true; log.info('shutting down', { signal }); + clearInterval(saveTimer); // One failed cleanup phase must not skip the remaining resources or the requested exit. - try { await server.close(); } + try { await server.close(Boolean(stateFile)); } catch (error) { exitCode ||= 1; log.error('server shutdown failed', { error }); } + if (stateFile) { + await saving; + try { await saveState(stateFile, stageHub.exportState()); } + catch (error) { exitCode ||= 1; log.error('state file write failed', { error }); } + } try { await transport.close(); } catch (error) { exitCode ||= 1; @@ -107,7 +144,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 }); }); 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/model/room.ts b/src/model/room.ts index 4cb3edf..11dcf8e 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 @@ -71,6 +86,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; @@ -78,6 +96,7 @@ export class Room { code: string; codeRequired: boolean; limits: RoomLimits; + recordingAvailable?: boolean; now: number; controller: NewParticipant; }) { @@ -86,6 +105,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, { @@ -105,6 +125,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: Object.hasOwn(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 { // Valid rooms contain one controller; every other non-audience member is an on-stage speaker. return this.participants.size - this.speakerCount - 1; @@ -202,6 +281,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[] { @@ -404,6 +491,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/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/recording/oggOpus.ts b/src/recording/oggOpus.ts new file mode 100644 index 0000000..ca34891 --- /dev/null +++ b/src/recording/oggOpus.ts @@ -0,0 +1,139 @@ +/** + * 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 concealment: Uint8Array | 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) { + const toc = packet[0]!; + const code = toc & 3; + const count = code === 0 ? 1 : code === 3 ? (packet[1]! & 63) : 2; + // Code 3 CBR with zero bytes per frame preserves the original packet duration. + this.concealment = count === 1 ? Uint8Array.of(toc & 0xfc) : Uint8Array.of((toc & 0xfc) | 3, count); + } else if (!this.concealment) return; + else packet = this.concealment; + 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 5fbc6c1..53ac1bf 100644 --- a/src/transport/WeriftMediaTransport.ts +++ b/src/transport/WeriftMediaTransport.ts @@ -1,9 +1,9 @@ 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, 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. */ @@ -79,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; @@ -109,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 { @@ -116,7 +124,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); } @@ -206,6 +214,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; @@ -214,18 +227,33 @@ 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 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 }); }); + 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 { @@ -241,7 +269,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(); @@ -255,16 +283,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 2d0ed37..31d7b79 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, @@ -10,11 +11,12 @@ 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 Participant, type RoomEvent } from '../model/room.ts'; +import { Room, type Participant, 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'; import type { MediaTransport, MixedPcmSource } from '../transport/MediaTransport.ts'; import { SerialQueue } from './serialQueue.ts'; @@ -45,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; @@ -75,10 +79,28 @@ interface RoomRuntime { qualityInFlight: boolean; /** 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; + /** Open recording file and its `recording.maxDurationMinutes` stop timer. */ + recording: { file: Recording; timer: unknown } | undefined; lastStatus: 'waiting' | 'live'; 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; @@ -103,6 +125,15 @@ 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>(); + /** 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'); + 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; @@ -123,12 +154,19 @@ 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 }, ]; } 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, + instance: this.instance, + limits: { controlPerSecond, icePerSecond, handRaiseIntervalMs, chatIntervalMs, reactionIntervalMs }, + }); } /** Whether the session has created or joined a room (and not been evicted since). */ @@ -196,8 +234,106 @@ 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); + } + + /** Revision for control-state observers; snapshots also refresh periodically for liveness. */ + 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(...(config.rooms.chatHistory > 0 && 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 }); + } + } + 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 Map(), + speakingIds: [], + stateTimerDelay: undefined, + qualityInFlight: false, + 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) return; + rt.speakingIds = [...participantIds]; + this.broadcast(rt, { type: 'speaking', participantIds }); + }); + return rt; } // ─────────────────────────────── commands ─────────────────────────────── @@ -220,35 +356,12 @@ export class StageHub { code, codeRequired, limits: { maxSpeakers: config.limits.maxSpeakersPerRoom, maxAudience: config.limits.maxAudiencePerRoom }, + 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 Map(), - speakingIds: [], - graceTimer: undefined, - graceTimers: new Map(), - stateTimer: undefined, - stateTimerDelay: undefined, - qualityTimer: undefined, - qualityInFlight: false, - uplinkCounts: new Map(), - lastStatus: room.status, - closed: false, - }; - this.rooms.set(roomId, rt); - transport.setMixedStream(roomId, mixer); - // Fired from the mixer clock: update the room-local baseline and fan out without queueing. - mixer.onSpeaking((participantIds) => { - if (rt.closed) return; - rt.speakingIds = [...participantIds]; - 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 }); @@ -298,6 +411,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); this.sendSpeaking(rt, session); session.send({ type: 'ok', requestId: msg.requestId }); @@ -383,11 +497,44 @@ export class StageHub { break; case 'room:rotate-code': room.rotateCode(pid, this.newCode()); + this.revision++; // Only the controller's snapshot carries the code. session.send({ type: 'room:state', ...room.snapshot(pid) }); 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); + this.revision++; + 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 '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'); @@ -414,6 +561,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 }); + 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)]; @@ -477,7 +658,9 @@ export class StageHub { }); let answer: SessionDescriptionPayload; try { + const started = performance.now(); answer = await transport.negotiate(roomId, pid, msg.payload, { allowUplink: negotiation.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: err }); @@ -532,11 +715,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; @@ -702,7 +881,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; rt.stateTimerDelay = undefined; } // Views shared by all recipients are built and serialized once; only `me` & co. are per session. @@ -721,6 +910,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); @@ -728,19 +929,15 @@ 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); rt.speakingIds = []; this.deps.transport.setMixedStream(roomId, null); 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/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/server.ts b/src/ws/server.ts index 412ea4a..ccdaab0 100644 --- a/src/ws/server.ts +++ b/src/ws/server.ts @@ -19,7 +19,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 { @@ -279,7 +280,7 @@ export function createStageServer(deps: StageServerDeps): StageServer { }); return promise; }, - close: () => { + close: (keepRooms = false) => { if (closing) return closing; stopping = true; for (const session of sessions) session.stopInput(); @@ -287,7 +288,7 @@ export function createStageServer(deps: StageServerDeps): StageServer { clearInterval(heartbeat); clearTimeout(reloadTimer); for (const w of watchers) w.close(); - try { await hub.shutdown(); } + try { await hub.shutdown(keepRooms); } finally { // A graceful close flushes the `room:closed` frames queued above; terminate() could drop them. const drained = Promise.all([...sessions].map((s) => new Promise(resolve => s.ws.once('close', () => resolve())))); diff --git a/src/ws/validate.ts b/src/ws/validate.ts index ce94e7b..d9a1935 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 { @@ -44,6 +46,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. @@ -118,6 +136,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 'participant:rename': case 'room:rename': @@ -136,6 +156,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/cliShutdown.test.ts b/test/cliShutdown.test.ts index 61f365d..594d5c9 100644 --- a/test/cliShutdown.test.ts +++ b/test/cliShutdown.test.ts @@ -29,7 +29,7 @@ function fixture(serverClose: Close = async () => {}, transportClose: Close = as }; // Evaluate the exact production tail, without importing startup or exposing the real process. const { stop } = runInNewContext(stripTypeScriptTypes(`${shutdownSource}\n({ stop });`), { - process, log, Error, + process, log, Error, clearInterval, saveTimer: undefined, stateFile: '', server: { close() { calls.push('server.close'); return serverClose(); } }, transport: { close() { calls.push('transport.close'); return transportClose(); } }, }, { timeout: 1000, filename: 'cli-shutdown.vm.js' }) as { stop: Stop }; diff --git a/test/closeDuringOffer.test.ts b/test/closeDuringOffer.test.ts index 62c2c3d..6dda3e2 100644 --- a/test/closeDuringOffer.test.ts +++ b/test/closeDuringOffer.test.ts @@ -290,7 +290,7 @@ for (const normal of [false, true]) { await f.until(() => f.finished.has('late-join') && (f.detachCounts.get(guest.session) ?? 0) >= 1 && !f.h.hub.isJoined(guest.session), 'late join binding detached after drain'); await host.client.ok({ type: 'mic:mute', requestId: 'room-barrier' }); const state = host.client.state(); - assert.equal(f.h.hub.metrics().find(sample => sample.name === 'stage_sessions')!.value, 1); + assert.equal(f.h.hub.metrics().filter(sample => sample.type !== 'histogram').find(sample => sample.name === 'stage_sessions')!.value, 1); if (normal) assert.deepEqual(state.audience, []); else { assert.equal(state.audience?.length, 1); @@ -320,7 +320,7 @@ it('cleans a late room-create binding after a synthetic pre-dispatch delay', { t release.resolve(); await f.until(() => f.finished.has('late-create') && (f.detachCounts.get(creator.session) ?? 0) >= 1 && !f.h.hub.isJoined(creator.session), 'late create binding detached after drain'); assert.equal(f.h.hub.roomCount, 1, 'controller disconnect still uses controller grace'); - assert.equal(f.h.hub.metrics().find(sample => sample.name === 'stage_sessions')!.value, 0); + assert.equal(f.h.hub.metrics().filter(sample => sample.type !== 'histogram').find(sample => sample.name === 'stage_sessions')!.value, 0); assert.equal(f.h.timers.filter(timer => !timer.cleared).length, 1, 'the disconnected controller has one grace timer'); }); diff --git a/test/fixtures/embedSdkApp.ts b/test/fixtures/embedSdkApp.ts index 0e20e4f..638670a 100644 --- a/test/fixtures/embedSdkApp.ts +++ b/test/fixtures/embedSdkApp.ts @@ -19,7 +19,7 @@ interface StageTrack { enabled: boolean; stopped: boolean; readyState: string; s interface StageStream { getTracks(): StageTrack[]; getAudioTracks(): StageTrack[] } export function actualState(overrides: Partial = {}): RoomStatePayload { return { roomId: 'same-room', name: '測試舞台', controllerId: 'guest', speakers: [], hands: [], audienceCount: 0, - codeRequired: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', resumeToken: 'test-resume', + codeRequired: false, recording: false, recordingAvailable: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', resumeToken: 'test-resume', me: { participantId: 'guest', name: '測試主控', role: 'controller', onStage: false, handRaised: false, muted: false, forceMuted: false, gainDb: 0, connected: true, joinedAt: 0, ...overrides } }; } @@ -168,7 +168,7 @@ export async function actualApp(t: TestContext, options: { deferCapture?: boolea calls: () => calls, ready: () => ready, async join(overrides: Partial = {}) { form.submit(); await flush(); const socket = StageSocket.instances.at(-1)!; - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await until(() => socket.sent.some(message => message.type === 'join'), 'join request'); const request = socket.sent.find(message => message.type === 'join')!; socket.deliver({ type: 'room:state', ...actualState(overrides) }); socket.deliver({ type: 'ok', requestId: request.requestId }); @@ -182,4 +182,3 @@ export async function actualApp(t: TestContext, options: { deferCapture?: boolea }, }; } - diff --git a/test/fixtures/sampleApp.ts b/test/fixtures/sampleApp.ts index 56712eb..56453ae 100644 --- a/test/fixtures/sampleApp.ts +++ b/test/fixtures/sampleApp.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { stripTypeScriptTypes } from 'node:module'; import { runInNewContext } from 'node:vm'; -import { MAX_GAIN_DB } from '../../shared/protocol.ts'; +import { MAX_GAIN_DB, REACTIONS } from '../../shared/protocol.ts'; import type { ParticipantView, RoomStatePayload } from '../../shared/protocol.ts'; /** Bounded DOM fixture; browser/CSS/permission behavior is not modeled. */ @@ -269,7 +269,7 @@ export function roomState(me: Partial = {}): RoomStatePayload { const person: ParticipantView = { participantId: 'same-person', name: '測試聽眾', role: 'audience', onStage: false, handRaised: false, muted: false, forceMuted: false, gainDb: 0, connected: true, joinedAt: 1, ...me }; return { roomId: 'same-room', name: '測試舞台', controllerId: 'controller', speakers: person.onStage ? [person] : [], - hands: person.handRaised ? [person] : [], audienceCount: person.onStage ? 0 : 1, codeRequired: false, + hands: person.handRaised ? [person] : [], audienceCount: person.onStage ? 0 : 1, codeRequired: false, recording: false, recordingAvailable: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: person.onStage ? 'live' : 'waiting', me: person }; } export async function flushSample(): Promise { for (let i = 0; i < 16; i++) await Promise.resolve(); } @@ -306,8 +306,8 @@ function mountSampleApp(options: Sample querySelector: (selector: string) => documentRoot.querySelector(selector) }; const source = options.source ?? readFileSync(new URL('../../web/src/main.ts', import.meta.url), 'utf8'); const imports = [ - "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 lines = source.split('\n'); assert.deepEqual(lines.slice(0, 3), imports, 'review the fixture when sample imports change'); @@ -327,7 +327,7 @@ function mountSampleApp(options: Sample StageClient: function (clientOptions: { url: string }) { constructions++; assert.equal(clientOptions.url, options.expectedSdkUrl ?? 'wss://sample.test/ws'); return sdk; }, - decodeName: options.decodeName ?? ((value: string) => value), MAX_GAIN_DB, URL, URLSearchParams, Error, AbortController, + decodeName: options.decodeName ?? ((value: string) => value), MAX_GAIN_DB, REACTIONS, URL, URLSearchParams, Error, AbortController, performance: { now: () => clock.now }, setTimeout: clock.setTimeout, clearTimeout: clock.clear, setInterval: clock.setInterval, clearInterval: clock.clear, }, { timeout: 1000, filename: 'web/src/main.ts', contextCodeGeneration: { strings: false, wasm: false } }); diff --git a/test/helpers.ts b/test/helpers.ts index aa6d878..f28915d 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -6,7 +6,7 @@ import { parseConfig, type AppConfig } from '../src/config.ts'; import { silentLogger, type Logger } 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,9 @@ type DistributiveOmit = T extends unknown ? Omit let rid = 0; -export function harness(config = testConfig(), log: Logger = silentLogger): Harness { +export function harness(config = testConfig(), logOrExtra: Logger | Pick = silentLogger): Harness { + const log = 'info' in logOrExtra ? logOrExtra : silentLogger; + const extra = 'info' in logOrExtra ? undefined : logOrExtra; const timers: Harness['timers'] = []; const clock = { now: 1_000_000 }; const mixers = new Map(); @@ -86,6 +88,7 @@ export function harness(config = testConfig(), log: Logger = silentLogger): Harn mixers.set(roomId, m); return m; }, + ...extra, }); const h: Harness = { hub, diff --git a/test/hub.test.ts b/test/hub.test.ts index fb5362e..d386a80 100644 --- a/test/hub.test.ts +++ b/test/hub.test.ts @@ -599,3 +599,191 @@ 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'); + }); +}); + +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'); + }); +}); + +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(); + 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); + }); +}); + +it('rotated admission codes stay revoked after a persisted restart', async () => { + const before = harness(); + const host = await before.create(); + const revision = before.hub.stateRevision; + assert.equal(await before.req(host.s, { type: 'room:rotate-code' }), 'ok'); + assert.ok(before.hub.stateRevision > revision); + const code = host.s.last('room:state')!.code!; + const after = harness(); + assert.equal(after.hub.restore(JSON.parse(JSON.stringify(before.hub.exportState()))), 1); + const guest = new FakeSession(); after.hub.attach(guest); + assert.equal(await after.req(guest, { type: 'join', roomId: host.roomId, code: host.code, name: 'Guest' }), 'unauthorized'); + assert.equal(await after.req(guest, { type: 'join', roomId: host.roomId, code, name: 'Guest' }), 'ok'); + await before.hub.shutdown(); await after.hub.shutdown(); +}); + +it('disabling chat history discards persisted chat before replay and the next save', async () => { + const before = harness(); const host = await before.create(); + await before.req(host.s, { type: 'chat:send', text: 'old private history' }); + const after = harness(testConfig(c => { c.rooms.chatHistory = 0; })); + assert.equal(after.hub.restore(JSON.parse(JSON.stringify(before.hub.exportState()))), 1); + const guest = await after.join(host.roomId, host.code, 'Guest'); + assert.equal(guest.s.all('chat:history').length, 0); + assert.deepEqual(after.hub.exportState().rooms[0]!.chat, []); + await before.hub.shutdown(); await after.hub.shutdown(); +}); diff --git a/test/negotiationOwnership.test.ts b/test/negotiationOwnership.test.ts index a56b09a..11a068d 100644 --- a/test/negotiationOwnership.test.ts +++ b/test/negotiationOwnership.test.ts @@ -86,7 +86,7 @@ async function setup(t: TestContext) { assert.equal(room.subscribers.has(id), true, 'the current subscriber must survive'); assert.equal(room.peers.get(listener.id), listenerPeer, 'unrelated peers must survive'); assert.equal(mixer.sourceCount, 1, 'the current mixer source must survive'); - assert.equal(h.hub.metrics().find(sample => sample.name === 'stage_publishers')!.value, 1); + assert.equal(h.hub.metrics().filter(sample => sample.type !== 'histogram').find(sample => sample.name === 'stage_publishers')!.value, 1); assert.equal(h.hub.roomCount, 1); }; const assertAudio = (id = speaker.id) => { @@ -99,7 +99,7 @@ async function setup(t: TestContext) { assert.equal(room.publishers.has(speaker.id), false); assert.equal(room.subscribers.has(speaker.id), false); assert.equal(mixer.sourceCount, 0); - assert.equal(h.hub.metrics().find(sample => sample.name === 'stage_publishers')!.value, 0); + assert.equal(h.hub.metrics().filter(sample => sample.type !== 'histogram').find(sample => sample.name === 'stage_publishers')!.value, 0); assert.equal(h.transport.emitUplink(host.roomId, speaker.id, sine(960, 0.2)), false); assert.equal(room.peers.get(listener.id), listenerPeer); assert.equal(h.hub.roomCount, 1); diff --git a/test/oggOpus.test.ts b/test/oggOpus.test.ts new file mode 100644 index 0000000..30eb99e --- /dev/null +++ b/test/oggOpus.test.ts @@ -0,0 +1,99 @@ +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(0xf8, 1, 2)); w.packet(null); w.packet(new Uint8Array(0)); }); + assert.deepEqual(pages.slice(2).flatMap((p) => p.packets).map((p) => [...p]), [[0xf8, 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); + }); +}); + +for (const sampleRate of [8000, 12000, 16000, 24000, 48000]) { + for (const frameMs of [10, 20, 40, 60]) { + it(`preserves decoded concealment duration and granules at ${sampleRate} Hz / ${frameMs} ms`, async () => { + const { OpusEncoder, OpusDecoder } = await import('../src/transport/opus.ts'); + const { testConfig } = await import('./helpers.ts'); + const audio = testConfig().audio; + audio.sampleRate = sampleRate; audio.frameMs = frameMs; + const encoder = new OpusEncoder(audio); + const decoder = new OpusDecoder(48000); + try { + const pcm = Float32Array.from({ length: sampleRate * frameMs / 1000 }, (_, i) => Math.sin(i * 0.1) * 0.2); + const chunks: Uint8Array[] = []; + const writer = new OggOpusWriter(chunk => chunks.push(chunk), { frameSamples: frameMs * 48, serial: 1, vendor: 'test' }); + writer.packet(encoder.encode(pcm)); writer.packet(null); writer.packet(new Uint8Array()); writer.close(); + const pages = parse(new Uint8Array(Buffer.concat(chunks))); + const packets = pages.slice(2).flatMap(page => page.packets); + assert.equal(packets.length, 3); + for (const packet of packets) assert.equal(decoder.decode(packet).length, frameMs * 48); + assert.equal(pages.at(-1)!.granule, BigInt(3 * frameMs * 48)); + } finally { encoder.free(); decoder.free(); } + }); + } +} diff --git a/test/persistence.test.ts b/test/persistence.test.ts new file mode 100644 index 0000000..c918656 --- /dev/null +++ b/test/persistence.test.ts @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { stripTypeScriptTypes } from 'node:module'; +import { runInNewContext } from 'node:vm'; +import { it } from 'node:test'; +import { loadState, saveState } from '../src/persistence.ts'; +import { silentLogger } from '../src/log.ts'; +import { harness, testConfig } from './helpers.ts'; + +const source = readFileSync(new URL('../src/index.ts', import.meta.url), 'utf8'); +const startup = source.slice(source.indexOf('const stateFile ='), source.indexOf('const processMetrics =')); +it('production saver refreshes a quiet room beyond restoreGraceMs and retries a failed write', async t => { + const dir = await mkdtemp(join(tmpdir(), 'stage-persistence-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const config = testConfig(c => { c.persistence.stateFile = 'state.json'; c.persistence.restoreGraceMs = 2000; }); + const h = harness(config); await h.create(); t.after(() => h.hub.shutdown()); + let tick!: () => void, failures = 1, writes = 0; + const errors: unknown[] = []; + const saver = runInNewContext(stripTypeScriptTypes(`${startup}\n({ drain: () => saving });`), { + config, root: dir, resolve, stageHub: h.hub, Date: { now: () => h.clock.now }, loadState, + log: { ...silentLogger, error: (_: string, error: unknown) => errors.push(error) }, + setInterval(callback: () => void) { tick = callback; return { unref() {} }; }, + saveState: async (file: string, state: Parameters[1]) => { + writes++; if (failures-- > 0) throw new Error('temporary disk failure'); + await saveState(file, state); + }, + }) as { drain(): Promise }; + await saver.drain(); assert.equal(errors.length, 1); + const revision = h.hub.stateRevision; + for (let i = 0; i < 5; i++) { h.clock.now += 1000; tick(); tick(); await saver.drain(); } + assert.equal(writes, 6, 'one pending write despite overlapping ticks'); + assert.equal(h.hub.stateRevision, revision, 'no control activity'); + const saved = loadState(join(dir, 'state.json'), config.persistence.restoreGraceMs, h.clock.now, silentLogger); + assert.ok(saved); assert.equal(saved.savedAt, h.clock.now); assert.equal(saved.rooms.length, 1); + assert.equal(JSON.parse(await readFile(join(dir, 'state.json'), 'utf8')).savedAt, h.clock.now); +}); + +it('production recording wiring uses 48 kHz granules for every supported input rate', () => { + const recording = source.slice(source.indexOf(' startRecording: (roomId)'), source.indexOf('\n});\nhub = stageHub;')); + for (const sampleRate of [8000, 12000, 16000, 24000, 48000]) for (const frameMs of [10, 20, 40, 60]) { + const factory = runInNewContext(stripTypeScriptTypes(`({${recording}})`), { + audio: { sampleRate, frameMs }, config: { recording: { dir: 'recordings' } }, root: '/tmp', resolve, + version: 'test', log: silentLogger, startRecording: (options: unknown) => options, + }) as { startRecording(roomId: string): { frameSamples: number } }; + assert.equal(factory.startRecording('room').frameSamples, frameMs * 48); + } +}); + +it('existing main configurations keep recording and persistence opt-in', async () => { + const { parseConfig } = await import('../src/config.ts'); + const { parse } = await import('yaml'); + const raw = parse(readFileSync(new URL('../config.example.yaml', import.meta.url), 'utf8')); + delete raw.recording; delete raw.persistence; delete raw.rooms.chatHistory; + delete raw.limits.chatMaxLength; delete raw.limits.chatIntervalMs; delete raw.limits.reactionIntervalMs; + const config = parseConfig(raw); + assert.equal(config.recording.enabled, false); assert.equal(config.persistence.stateFile, ''); + assert.equal(config.rooms.chatHistory, 50); assert.equal(config.limits.chatMaxLength, 500); +}); diff --git a/test/qualityEmpty.test.ts b/test/qualityEmpty.test.ts index 6f9ded4..b5cbe18 100644 --- a/test/qualityEmpty.test.ts +++ b/test/qualityEmpty.test.ts @@ -257,7 +257,7 @@ it('real SDK and existing full sample consume the Hub empty frame and clear stal boundary.form.submit(); await flushSample(); const sdk = boundary.clients[0]!; const app = sampleApp({ sdk, decodeName }); cleanup = () => app.cleanup(); const socket = StageSocket.instances[0]!; - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await boundary.until(() => socket.sent.some(message => message.type === 'join'), 'public SDK join request'); const request = socket.sent.find(message => message.type === 'join')!; socket.deliver(f.host.s.last('room:state')!); socket.deliver({ type: 'ok', requestId: request.requestId }); diff --git a/test/roomSequences.test.ts b/test/roomSequences.test.ts index dab2a7d..77cd856 100644 --- a/test/roomSequences.test.ts +++ b/test/roomSequences.test.ts @@ -109,7 +109,7 @@ function verify(r: Room, m: Ledger): void { const shared = { roomId: 'room', name: 'Sequence room', controllerId: m.controller, speakers: staged(m).map(p => view(m, p)), hands: hands(m).map(p => view(m, p)), - audienceCount: audience(m).length, codeRequired: m.codeRequired, + audienceCount: audience(m).length, codeRequired: m.codeRequired, recording: false, recordingAvailable: false, limits: m.limits, status: staged(m).length ? 'live' : 'waiting', }; assert.deepEqual(r.sharedSnapshot(), shared, 'shared snapshot has exactly the public fields'); diff --git a/test/server.test.ts b/test/server.test.ts index 7d1bcb9..965cfa1 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/test/serverPackageCheck.test.ts b/test/serverPackageCheck.test.ts index 3e5a76e..22801b2 100644 --- a/test/serverPackageCheck.test.ts +++ b/test/serverPackageCheck.test.ts @@ -32,7 +32,7 @@ async function withFixture(run: (fixture: Fixture) => Promise, scenario = server: { host: '192.0.2.1', port: 1, tls: { certFile: 'not-read', keyFile: 'not-read' }, metrics: { enabled: true, token: 'not-used' }, trustProxy: true }, rtc: { iceServers: ['not-contacted'], serverIceServers: ['not-contacted'], mediaWorkers: 8, turn: { secret: 'not-used' } }, audio: { codecWorkers: 8 }, rooms: { createToken: 'not-used' }, log: { level: 'error' }, - limits: { controlPerSecond: 7, icePerSecond: 13, handRaiseIntervalMs: 250 }, + limits: { controlPerSecond: 7, icePerSecond: 13, handRaiseIntervalMs: 250, chatIntervalMs: 1000, reactionIntervalMs: 250 }, })); await writeFile(join(fixture.repositoryDirectory, 'config.yaml'), 'This user config must never be parsed.'); await writeFile(join(fixture.repositoryDirectory, 'src', 'source.js'), 'throw new Error("Checkout source was executed");'); diff --git a/test/signalingErrors.test.ts b/test/signalingErrors.test.ts index 45179ca..39eea34 100644 --- a/test/signalingErrors.test.ts +++ b/test/signalingErrors.test.ts @@ -150,7 +150,7 @@ async function setup(t: TestContext) { assert.equal(room.peers.get(listener.id), listenerPeer); assert.equal(room.subscribers.has(listener.id), true); assert.equal(mixer.sourceCount, publishers); - assert.equal(hub.metrics().find(sample => sample.name === 'stage_publishers')!.value, publishers); + assert.equal(hub.metrics().filter(sample => sample.type !== 'histogram').find(sample => sample.name === 'stage_publishers')!.value, publishers); assert.deepEqual(roomCloses, []); for (const session of [speaker.session, listener.session]) { assert.equal(hub.isJoined(session), true); diff --git a/test/transport.test.ts b/test/transport.test.ts index 2250789..577753a 100644 --- a/test/transport.test.ts +++ b/test/transport.test.ts @@ -88,8 +88,8 @@ describe('downlink fan-out', () => { assert.equal(t.payloads.length, 1, 'one encode for the whole room'); 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); + const skipped = (await transport.metrics()).filter(m => m.type !== 'histogram').find(m => m.name === 'stage_mix_frames_silent_skipped_total')!; + 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'); @@ -106,7 +106,7 @@ describe('downlink fan-out', () => { assert.ok(t!.same('a', 'b'), 'zero PCM shares the full mix after a second'); t = await tick(zero); assert.ok(t.payloads.every(p => p.length === 0), 'zero PCM reaches the idle DTX fast path'); - const skipped = (await transport.metrics()).find(m => m.name === 'stage_mix_frames_silent_skipped_total')!; + const skipped = (await transport.metrics()).filter(m => m.type !== 'histogram').find(m => m.name === 'stage_mix_frames_silent_skipped_total')!; assert.equal(skipped.value, 1); } finally { await transport.close(); } }); diff --git a/test/webActions.test.ts b/test/webActions.test.ts index 9c7d299..e33258b 100644 --- a/test/webActions.test.ts +++ b/test/webActions.test.ts @@ -11,7 +11,7 @@ const people = source.slice(source.indexOf('function panelList('), source.indexO const landing = source.slice(source.indexOf('function landing()'), source.indexOf('function renderNotices(')); const audienceFilter = source.slice(source.indexOf('function resetAudienceFilter()'), source.indexOf('function renderRoom(')); const render = source.slice(source.indexOf('function renderRoom('), source.indexOf('function panelList(')); -const cooldown = source.slice(source.indexOf('function updateCooldown('), source.indexOf("client.on('state', ({ detail })")); +const cooldown = source.slice(source.indexOf('function updateCooldown('), source.indexOf('/** Chat lives')) + source.slice(source.indexOf('function syncHandPosition('), source.indexOf("client.on('state', ({ detail })")); interface Button { disabled: boolean; textContent: string; @@ -63,6 +63,7 @@ function setup(): Harness { const visit = node => { if (node.dataset.roomAction) nodes.push(node); for (const child of node.children) visit(child); }; children.forEach(visit); } }; + const chatPanel = { dataset: {}, children: [] }, chatList = { replaceChildren() {} }; const brand = {}, status = {}, connectionLine = { dataset: {}, children: [], classList: { toggle() {} } }; const audioRetryNotice = { hidden: true, textContent: '', dataset: {}, children: [] }; const ownHandPosition = { hidden: true, textContent: '', dataset: {}, children: [] }; diff --git a/test/webAudioRetry.test.ts b/test/webAudioRetry.test.ts index a612c00..87b432e 100644 --- a/test/webAudioRetry.test.ts +++ b/test/webAudioRetry.test.ts @@ -191,7 +191,7 @@ async function actualSample(t: TestContext, holdJoinAck = false) { const join = async (room = host) => { const form = app.find(node => node.tagName === 'form' && node.children.some(child => child.tagName === 'h2' && child.textContent === '加入房間')); for (const [name, value] of Object.entries({ roomId: room.roomId, code: room.code, name: 'Listener' })) { - const input = form.descendants().find(node => node.name === name); assert.ok(input); input.value = value; + const input = form.descendants().filter(node => node.type !== 'histogram').find(node => node.name === name); assert.ok(input); input.value = value; } form.submit(); await until(() => sdk.state !== null && Peer.instances.at(-1)!.createCalls > 0, 'room snapshot and initial offer'); return { socket: Socket.instances.at(-1)!, peer: Peer.instances.at(-1)!, id: sdk.me!.participantId }; diff --git a/test/webAudioUnlock.test.ts b/test/webAudioUnlock.test.ts index cb4f5c6..cf7f8d5 100644 --- a/test/webAudioUnlock.test.ts +++ b/test/webAudioUnlock.test.ts @@ -58,7 +58,7 @@ function unlock(h: App): { button: SampleNode; play: Deferred } { } function assertLanding(h: App): void { assert.equal(h.sdk.state, null); assert.equal(h.sdk.status, 'disconnected'); - assert.equal(h.app.descendants().filter(node => node.tagName === 'form').length, 2); + assert.equal(h.app.descendants().filter(node => node.tagName === 'form' && !node.classList.contains('chat-form')).length, 2); assert.equal(h.clock.jobs.size, 0); } diff --git a/test/webAudioUnlockPending.test.ts b/test/webAudioUnlockPending.test.ts index 5cda92f..2494b53 100644 --- a/test/webAudioUnlockPending.test.ts +++ b/test/webAudioUnlockPending.test.ts @@ -61,11 +61,11 @@ async function actualSample(t: TestContext) { const state = roomState(); const form = h.find(node => node.tagName === 'form' && node.children.some(child => child.tagName === 'h2' && child.textContent === '加入房間')); for (const [name, value] of Object.entries({ roomId: state.roomId, name: state.me.name })) { - const input = form.descendants().find(node => node.name === name); assert.ok(input); input.value = value; + const input = form.descendants().filter(node => node.type !== 'histogram').find(node => node.name === name); assert.ok(input); input.value = value; } form.submit(); await flushSample(); const socket = StageSocket.instances.at(-1)!; - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await until(() => socket.sent.some(message => message.type === 'join'), 'initial join request'); const request = socket.sent.find(message => message.type === 'join')!; socket.deliver({ type: 'room:state', ...state }); socket.deliver({ type: 'ok', requestId: request.requestId }); @@ -207,7 +207,7 @@ it('real SDK sample same-seat reconnect retains the existing play and pending no assert.equal(h.audio.plays.length, 2); await h.until(() => StageSocket.instances.length === 2, 'reconnect socket'); const resumed = StageSocket.instances.at(-1)!; - resumed.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + resumed.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await h.until(() => resumed.sent.some(message => message.type === 'join'), 'resume join request'); const request = resumed.sent.find(message => message.type === 'join')!; resumed.deliver({ type: 'room:state', ...state }); resumed.deliver({ type: 'ok', requestId: request.requestId }); await flushSample(); @@ -234,7 +234,7 @@ for (const change of ['same-seat-rejoin', 'participant', 'room'] as const) { function acceptJoin(): void { const form = h.find(node => node.tagName === 'form' && node.children.some(child => child.tagName === 'h2' && child.textContent === '加入房間')); for (const [name, value] of Object.entries({ roomId: 'same-room', name: '測試聽眾' })) { - const input = form.descendants().find(node => node.name === name); assert.ok(input); input.value = value; + const input = form.descendants().filter(node => node.type !== 'histogram').find(node => node.name === name); assert.ok(input); input.value = value; } form.submit(); h.sdk.acceptJoin(roomState()); } diff --git a/test/webCooldownRetiming.test.ts b/test/webCooldownRetiming.test.ts index 288dbd2..97c5425 100644 --- a/test/webCooldownRetiming.test.ts +++ b/test/webCooldownRetiming.test.ts @@ -6,7 +6,7 @@ type App = ReturnType; async function join(h: App): Promise { const form = h.find(node => node.tagName === 'form' && node.children.some(child => child.tagName === 'h2' && child.textContent === '加入房間')); for (const [name, value] of [['roomId', 'same-room'], ['name', '測試聽眾']]) { - const input = form.descendants().find(node => node.name === name); assert.ok(input); input.value = value!; + const input = form.descendants().filter(node => node.type !== 'histogram').find(node => node.name === name); assert.ok(input); input.value = value!; } form.submit(); h.sdk.acceptJoin(roomState()); await flushSample(); } diff --git a/test/webDisconnectedControls.test.ts b/test/webDisconnectedControls.test.ts index 03c304e..d49871e 100644 --- a/test/webDisconnectedControls.test.ts +++ b/test/webDisconnectedControls.test.ts @@ -31,8 +31,8 @@ class Socket { send(data: string): void { assert.equal(this.readyState, Socket.OPEN); this.sent.push(JSON.parse(data) as Request); } deliver(message: unknown): void { this.onmessage?.({ data: JSON.stringify(message) }); } hello(): void { - this.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + this.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); } close(): void { this.readyState = 3; this.onclose?.(); } } @@ -59,7 +59,7 @@ function action(h: View, key: string, text?: string): SampleNode { assert.ok(nodes.length, `missing room control ${key}`); return nodes[0]!; } const errorBox = (h: View): SampleNode => h.find(node => node.attributes.role === 'alert'); -const forms = (h: View): SampleNode[] => h.app.descendants().filter(node => node.tagName === 'form'); +const forms = (h: View): SampleNode[] => h.app.descendants().filter(node => node.tagName === 'form' && !node.classList.contains('chat-form')); function controllerState(onStage = false): RoomStatePayload { const state = roomState({ role: 'controller', onStage }); state.controllerId = state.me.participantId; const audience = roomState({ participantId: 'other', name: '其他觀眾', handRaised: true }).me; diff --git a/test/webEmbedAudioRetry.test.ts b/test/webEmbedAudioRetry.test.ts index d91c2d9..78c83cb 100644 --- a/test/webEmbedAudioRetry.test.ts +++ b/test/webEmbedAudioRetry.test.ts @@ -282,7 +282,7 @@ it('embed actual SDK socket loss settles public pending while its native offer l button(h).click(); await flush(); assert.equal(entered, 1); control(h, { pending: true }); socket.close(); await flush(); assert.equal(client.status, 'reconnecting'); control(h, { disabled: true }); hiddenFeedback(h); await h.tick(500); const resumed = StageSocket.instances.at(-1)!; assert.notEqual(resumed, socket); - resumed.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + resumed.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await h.until(() => resumed.sent.some(message => message.type === 'join'), 'automatic control resume'); const join = resumed.sent.find(message => message.type === 'join')!; resumed.deliver({ type: 'room:state', ...actualState({ role: 'audience' }) }); resumed.deliver({ type: 'ok', requestId: join.requestId }); diff --git a/test/webEmbedCancellation.test.ts b/test/webEmbedCancellation.test.ts index 229d2e0..0e9874e 100644 --- a/test/webEmbedCancellation.test.ts +++ b/test/webEmbedCancellation.test.ts @@ -20,8 +20,8 @@ async function start(t: TestContext) { return { ...h, cancel, submit, socket }; } async function hello(h: Awaited>, socket = h.socket) { - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await h.until(() => socket.sent.some(message => message.type === 'join'), 'join request'); const request = socket.sent.find((message): message is Extract => message.type === 'join'); assert.ok(request); return request; @@ -115,8 +115,8 @@ for (const phase of ['hello', 'join'] as const) { h.cancel.click(); h.form.submit(); submission(h.submit, true); cancelControl(h.cancel, true); await flush(); const current = StageSocket.instances.at(-1)!; assert.notEqual(current, retired); const notice = h.status.textContent; - retired.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + retired.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); if (request) retired.deliver({ type: 'ok', requestId: request.requestId }); retired.deliver({ type: 'room:state', ...actualState({ role: 'audience' }) }); for (const [event, detail] of [ diff --git a/test/webEmbedConnectionStats.test.ts b/test/webEmbedConnectionStats.test.ts index ca4c1e4..73be313 100644 --- a/test/webEmbedConnectionStats.test.ts +++ b/test/webEmbedConnectionStats.test.ts @@ -30,7 +30,7 @@ function state(role: ParticipantView['role'] = 'audience', onStage = false): Roo s.audienceCount = role === 'audience' && !onStage ? 1 : 0; return s; } function hello(socket: StageSocket) { - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); } async function join(h: App, s = state()) { h.form.submit(); await flush(); const socket = StageSocket.instances.at(-1)!; hello(socket); diff --git a/test/webEmbedDisconnectedHand.test.ts b/test/webEmbedDisconnectedHand.test.ts index 7496656..5c967c7 100644 --- a/test/webEmbedDisconnectedHand.test.ts +++ b/test/webEmbedDisconnectedHand.test.ts @@ -38,8 +38,8 @@ function state(handRaised: boolean, overrides: Partial = {}): R return snapshot; } async function hello(h: ActualApp, socket: StageSocket) { - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await h.until(() => socket.sent.some(message => message.type === 'join'), 'real SDK join frame'); const request = socket.sent.find((message): message is Extract => message.type === 'join'); assert.ok(request); return request; diff --git a/test/webEmbedEntry.test.ts b/test/webEmbedEntry.test.ts index 16bb22e..bf6de68 100644 --- a/test/webEmbedEntry.test.ts +++ b/test/webEmbedEntry.test.ts @@ -39,7 +39,7 @@ class Peer { } const state = (name = '測試舞台'): RoomStatePayload => ({ roomId: 'same-room', name, controllerId: 'host', speakers: [], hands: [], audienceCount: 1, - codeRequired: true, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', + codeRequired: true, recording: false, recordingAvailable: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', me: { participantId: 'guest', name: '測試聽眾', role: 'audience', onStage: false, handRaised: false, muted: false, forceMuted: false, gainDb: 0, connected: true, joinedAt: 0 }, }); @@ -114,7 +114,7 @@ async function fixture(t: TestContext, options: { deferLibrary?: boolean } = {}) node, form, submit, status, hand, leave, clients, loads, tick, async start(): Promise { form.submit(); assertSubmission(submit, true); await flush(); const socket = Socket.instances.at(-1); assert.ok(socket); return socket; }, async hello(socket: Socket): Promise> { - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await flush(); await tick(); const request = socket.sent.find((message): message is Extract => message.type === 'join'); assert.ok(request); return request; @@ -322,7 +322,7 @@ it('whole embed checks ownership after disconnect and hello awaits before constr h.form.submit(); h.form.submit(); await flush(); assert.equal(h.clients.length, 1, 'only the newest synchronous form event reaches library construction'); const retired = Socket.instances[0]!; - retired.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }); + retired.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); h.form.submit(); await flush(); await h.tick(); assert.equal(h.clients.length, 2); assert.deepEqual(retired.sent, []); assertSubmission(h.submit, true); diff --git a/test/webEmbedErrorNotice.test.ts b/test/webEmbedErrorNotice.test.ts index 72b2247..a8c1c61 100644 --- a/test/webEmbedErrorNotice.test.ts +++ b/test/webEmbedErrorNotice.test.ts @@ -25,8 +25,8 @@ function request(socket: StageSocket): Extract const join = socket.sent.find((message): message is Extract => message.type === 'join'); assert.ok(join); return join; } -const hello = (socket: StageSocket): void => socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); +const hello = (socket: StageSocket): void => socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); it('whole embed declares a separate initially hidden last-error alert and native dismiss button', () => { const html = readFileSync(new URL('../web/embed.html', import.meta.url), 'utf8'); diff --git a/test/webEmbedHandModeration.test.ts b/test/webEmbedHandModeration.test.ts index 0993725..c7c1ca0 100644 --- a/test/webEmbedHandModeration.test.ts +++ b/test/webEmbedHandModeration.test.ts @@ -479,7 +479,7 @@ it('whole embed actual SDK reconnect retains rows/open, rejects old request natu socket.close(); assert.equal(summary(h), '舉手申請(2) · 上次收到'); assert.equal(row(h).node, a.node); locked(a); await flush(); locked(a, false, true); assert.equal(h.node('error-notice').hidden, true); assert.equal(panel(h).open, true); force(a.reject); await h.tick(500); const resumed = StageSocket.instances.at(-1)!; assert.notEqual(resumed, socket); - resumed.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + resumed.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await h.until(() => resumed.sent.some(message => message.type === 'join'), 'resume join'); const joined = resumed.sent.find(message => message.type === 'join'); assert.ok(joined?.type === 'join'); assert.equal(joined.resumeToken, 'test-resume'); resumed.deliver({ type: 'ok', requestId: joined.requestId }); await flush(); locked(a, false, true); diff --git a/test/webEmbedHandQueueSearch.test.ts b/test/webEmbedHandQueueSearch.test.ts index 53fd96e..a1e57d8 100644 --- a/test/webEmbedHandQueueSearch.test.ts +++ b/test/webEmbedHandQueueSearch.test.ts @@ -255,7 +255,7 @@ it('actual SDK reconnect keeps search history while resume ACK alone cannot admi const { h, socket, room, deliver } = await actual(t), original = rows(h)[19]!; query(h, 'ALPHA'); panel(h).open = true; socket.close(); await flush(); assert.equal(rows(h)[19], original); assert.equal(search(h).value, 'ALPHA'); locked(original, false, true); query(h, '陳小明'); force(original); await h.tick(500); const resumed = StageSocket.instances.at(-1)!; assert.notEqual(resumed, socket); - resumed.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + resumed.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await h.until(() => resumed.sent.some(m => m.type === 'join'), 'resume join'); const request = resumed.sent.find(m => m.type === 'join')!; assert.equal(request.resumeToken, room.get('guest')!.resumeToken); resumed.deliver({ type: 'ok', requestId: request.requestId }); await flush(); locked(original, false, true); force(original); diff --git a/test/webEmbedHandRejection.test.ts b/test/webEmbedHandRejection.test.ts index ec0f157..2b5ae96 100644 --- a/test/webEmbedHandRejection.test.ts +++ b/test/webEmbedHandRejection.test.ts @@ -157,7 +157,7 @@ it('whole embed real SDK reconnect never replays old feedback and retired socket const h = await actualApp(t), { socket } = await h.join({ role: 'audience' }); socket.deliver({ type: 'hand:rejected', roomId: 'same-room', participantId: 'guest' }); shown(h); socket.close(); hidden(h); synthetic(h.clients[0]); hidden(h); await h.tick(500); const resumed = StageSocket.instances.at(-1)!; assert.notEqual(resumed, socket); - resumed.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + resumed.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await h.until(() => resumed.sent.some(message => message.type === 'join'), 'resume request'); const join = resumed.sent.find(message => message.type === 'join')!; resumed.deliver({ type: 'room:state', ...actualState({ role: 'audience' }) }); resumed.deliver({ type: 'ok', requestId: join.requestId }); await flush(); hidden(h); socket.deliver({ type: 'hand:rejected', roomId: 'same-room', participantId: 'guest' }); hidden(h); @@ -166,7 +166,7 @@ it('whole embed real SDK reconnect never replays old feedback and retired socket it('whole embed real pre-ACK disconnection retires feedback despite retained SDK state', async t => { const h = await actualApp(t); h.form.submit(); await flush(); const socket = StageSocket.instances.at(-1)!; - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await h.until(() => socket.sent.some(message => message.type === 'join'), 'join request'); socket.deliver({ type: 'room:state', ...actualState({ role: 'audience' }) }); socket.deliver({ type: 'hand:rejected', roomId: 'same-room', participantId: 'guest' }); shown(h); diff --git a/test/webEmbedInputDevice.test.ts b/test/webEmbedInputDevice.test.ts index 034f54a..fc9efd8 100644 --- a/test/webEmbedInputDevice.test.ts +++ b/test/webEmbedInputDevice.test.ts @@ -332,7 +332,7 @@ for (const stage of ['capture', 'sender'] as const) { it('actual SDK whole embed initial state owns a no-capture default preference before join ACK settles', async t => { const h = await actualApp(t); h.form.submit(); await flush(); const socket = StageSocket.instances.at(-1)!; - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await h.until(() => socket.sent.some(message => message.type === 'join'), 'join request before ACK'); const join = socket.sent.find(message => message.type === 'join')!; socket.deliver({ type: 'room:state', ...actualState({ role: 'audience' }) }); picker(h); @@ -350,7 +350,7 @@ it('actual SDK whole embed reconnect retains pending input replacement on its or const captures = h.captures.length; socket.close(); await flush(); assert.equal(client.status, 'reconnecting'); picker(h, 'pending-usb', 'apply'); force(h, 'apply'); force(h, 'refresh'); forceChange(h, ''); picker(h, 'pending-usb', 'apply'); assert.equal(h.captures.length, captures); await h.tick(500); const resumed = StageSocket.instances.at(-1)!; assert.notEqual(resumed, socket); - resumed.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + resumed.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await h.until(() => resumed.sent.some(message => message.type === 'join'), 'resume join'); const join = resumed.sent.find(message => message.type === 'join')!; resumed.deliver({ type: 'room:state', ...actualState({ role: 'speaker', onStage: true, muted: true }) }); diff --git a/test/webEmbedMicRetry.test.ts b/test/webEmbedMicRetry.test.ts index d2cc427..0b5b049 100644 --- a/test/webEmbedMicRetry.test.ts +++ b/test/webEmbedMicRetry.test.ts @@ -240,7 +240,7 @@ for (const pending of [false, true]) { const captures = h.captures.length, calls = h.calls(); h.socket.close(); await flush(); assert.equal(client.status, 'reconnecting'); retry(h.micRetry, false, pending, true); forceInvoke(h.micRetry); assert.equal(h.calls(), calls); await h.tick(500); const resumed = StageSocket.instances.at(-1)!; assert.notEqual(resumed, h.socket); - resumed.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + resumed.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await h.until(() => resumed.sent.some(message => message.type === 'join'), 'resume join'); const join = resumed.sent.find(message => message.type === 'join')!; resumed.deliver({ type: 'room:state', ...actualState(onstage) }); resumed.deliver({ type: 'ok', requestId: join.requestId }); await h.tick(); diff --git a/test/webEmbedMicTest.test.ts b/test/webEmbedMicTest.test.ts index e846ba3..fa9e022 100644 --- a/test/webEmbedMicTest.test.ts +++ b/test/webEmbedMicTest.test.ts @@ -283,7 +283,7 @@ it('actual SDK visibility and page lifecycle stop pending or active resources an node(h, 'start').click(); const track = h.captures[1]!.resolve(); await flush(); h.pageWindow.dispatchEvent(new Event('pagehide')); assert.equal(track.stops, 1); idle(h, true); h.pageWindow.dispatchEvent(new Event('pageshow')); idle(h, true); await h.tick(1000); const resumed = StageSocket.instances.at(-1)!; assert.notEqual(resumed, h.socket); - resumed.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + resumed.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await h.until(() => resumed.sent.some(message => message.type === 'join'), 'resume join'); const request = resumed.sent.find(message => message.type === 'join')!; resumed.deliver({ type: 'room:state', ...actualState({ role: 'audience' }) }); resumed.deliver({ type: 'ok', requestId: request.requestId }); diff --git a/test/webEmbedMicrophone.test.ts b/test/webEmbedMicrophone.test.ts index 5005cdc..689d7b5 100644 --- a/test/webEmbedMicrophone.test.ts +++ b/test/webEmbedMicrophone.test.ts @@ -210,7 +210,7 @@ class MicContext { } function actualState(overrides: Partial = {}): RoomStatePayload { return { roomId: 'same-room', name: '測試舞台', controllerId: 'host', speakers: [], hands: [], audienceCount: 1, - codeRequired: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', resumeToken: 'test-resume', + codeRequired: false, recording: false, recordingAvailable: false, limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', resumeToken: 'test-resume', me: { participantId: 'guest', name: '測試聽眾', role: 'audience', onStage: false, handRaised: false, muted: false, forceMuted: false, gainDb: 0, connected: true, joinedAt: 0, ...overrides } }; } @@ -268,7 +268,7 @@ async function actualApp(t: TestContext) { return { mic, status, leave, clients, tracks, tick, async join(): Promise { form.submit(); await flush(); const socket = MicSocket.instances.at(-1); assert.ok(socket); - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await flush(); await tick(); const request = socket.sent.find(message => message.type === 'join'); assert.ok(request); socket.deliver({ type: 'room:state', ...actualState() }); socket.deliver({ type: 'ok', requestId: request.requestId }); await flush(); return socket; }, @@ -334,7 +334,7 @@ it('whole embed actual SDK socket recovery preserves the microphone track and re assertMic(h.mic, { hidden: false, disabled: true, muted: false, busy: false }); h.mic.invoke(); assert.equal(socket.sent.filter(message => message.type === 'mic:mute').length, 1); await h.tick(500); const resumed = MicSocket.instances.at(-1)!; assert.notEqual(resumed, socket); - resumed.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + resumed.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await flush(); await h.tick(); const join = resumed.sent.find(message => message.type === 'join'); assert.ok(join); resumed.deliver({ type: 'room:state', ...actualState({ role: 'speaker', onStage: true, muted: true }) }); resumed.deliver({ type: 'ok', requestId: join.requestId }); await flush(); diff --git a/test/webEmbedOccupancy.test.ts b/test/webEmbedOccupancy.test.ts index 854c2c5..f699685 100644 --- a/test/webEmbedOccupancy.test.ts +++ b/test/webEmbedOccupancy.test.ts @@ -20,7 +20,7 @@ function snapshot(options: { audience?: number; stage?: number; controllerOnStag speakers: [...(controllerOnStage ? [person('controller', 'controller')] : []), ...Array.from({ length: options.stage ?? 1 }, (_, i) => person(`speaker-${i}`, 'speaker'))], hands: [], audienceCount: options.audience ?? 2, limits: { maxAudience: options.maxAudience ?? 300, maxSpeakers: options.maxSpeakers ?? 8 }, - status: 'live', codeRequired: true, code: 'PRIVATE-CODE', resumeToken: 'PRIVATE-TOKEN', + status: 'live', codeRequired: true, recording: false, recordingAvailable: false, code: 'PRIVATE-CODE', resumeToken: 'PRIVATE-TOKEN', me: person(options.participantId ?? (viewer === 'controller' ? 'controller' : viewer === 'speaker' ? 'speaker-0' : 'listener'), viewer, viewer === 'controller' ? controllerOnStage : viewer === 'speaker') }; } diff --git a/test/webEmbedOutputDevice.test.ts b/test/webEmbedOutputDevice.test.ts index 3e94d9b..6857991 100644 --- a/test/webEmbedOutputDevice.test.ts +++ b/test/webEmbedOutputDevice.test.ts @@ -95,7 +95,7 @@ it('actual SDK newer error survives a pending output rejection and fulfillment', it('actual SDK state before join ACK may select; disconnected state never admits output', async t => { const h = await actualApp(t, { output: true }); force(h); assert.equal(h.sinks.length, 0); h.form.submit(); await flush(); const socket = StageSocket.instances.at(-1)!; - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await h.until(() => socket.sent.some(m => m.type === 'join'), 'join request'); force(h); assert.equal(h.sinks.length, 0); socket.deliver({ type: 'room:state', ...actualState({ role: 'audience' }) }); apply(h).click(); assert.equal(h.sinks.length, 1); @@ -179,7 +179,7 @@ it('actual SDK forced changes during pending, reconnect and hidden controls cann it('actual SDK same-seat reconnect retains the operation until settlement after resumed state and ACK', async t => { const h = await setup(t); await list(h); select(h).change('usb'); apply(h).click(); const original = StageSocket.instances.at(-1)!; original.close(); await flush(); await h.tick(500); const socket = StageSocket.instances.at(-1)!; assert.notEqual(socket, original); - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await h.until(() => socket.sent.some(m => m.type === 'join'), 'resumed join'); const request = socket.sent.find(m => m.type === 'join')!; socket.deliver({ type: 'room:state', ...actualState({ role: 'audience' }) }); socket.deliver({ type: 'ok', requestId: request.requestId }); await flush(); locked(h, 'apply'); force(h); assert.equal(h.sinks.length, 1); h.sinks[0]!.work.resolve(); await flush(); diff --git a/test/webEmbedPublicRoster.test.ts b/test/webEmbedPublicRoster.test.ts index 9a728f0..8121982 100644 --- a/test/webEmbedPublicRoster.test.ts +++ b/test/webEmbedPublicRoster.test.ts @@ -92,7 +92,7 @@ function state(patch: Partial = {}): RoomStatePayload { return { roomId: 'same-room', name: 'Public room', controllerId: 'host', me: person('reader', { name: 'Private reader', role: 'audience', onStage: false }), speakers: [person('host', { role: 'controller' }), person('target')], hands: [], audienceCount: 1, - limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', codeRequired: true, + limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', codeRequired: true, recording: false, recordingAvailable: false, code: 'PRIVATE-CODE', resumeToken: 'PRIVATE-TOKEN', ...patch }; } function publish(sdk: EmbedSdk, snapshot = state()): void { sdk.state = snapshot; sdk.me = snapshot.me; sdk.emit('state', snapshot); } diff --git a/test/webEmbedQuality.test.ts b/test/webEmbedQuality.test.ts index 5c1f11c..73dabec 100644 --- a/test/webEmbedQuality.test.ts +++ b/test/webEmbedQuality.test.ts @@ -18,7 +18,7 @@ function state(me: Partial = {}): RoomStatePayload { const own = { ...person('controller', 'controller'), ...me }; return { roomId: 'same-room', name: 'Synthetic room', controllerId: 'controller', me: own, speakers: [...(own.onStage ? [own] : []), person('target')], hands: [], audienceCount: 0, - limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', codeRequired: true, + limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', codeRequired: true, recording: false, recordingAvailable: false, code: 'PRIVATE-CODE', resumeToken: 'PRIVATE-TOKEN' }; } function publish(sdk: EmbedSdk, snapshot = state()): void { sdk.state = snapshot; sdk.me = snapshot.me; sdk.emit('state', snapshot); } @@ -262,7 +262,7 @@ async function server(t: TestContext) { } async function enter(t:TestContext,state:RoomStatePayload) { const h=await actualApp(t); h.form.submit();await flush();const socket=StageSocket.instances.at(-1)!; - socket.deliver({type:'hello',protocol:1,serverVersion:'test',limits:{controlPerSecond:2000,icePerSecond:2000,handRaiseIntervalMs:10000}}); + socket.deliver({type:'hello',instance:'test-instance',protocol:1,serverVersion:'test',limits:{controlPerSecond:2000,icePerSecond:2000,handRaiseIntervalMs:10000, chatIntervalMs: 1000, reactionIntervalMs: 250}}); await h.until(()=>socket.sent.some(m=>m.type==='join'),'join request');const request=socket.sent.find(m=>m.type==='join')!; socket.deliver({type:'room:state',...state});socket.deliver({type:'ok',requestId:request.requestId}); await h.until(()=>StagePeer.instances.at(-1)?.signalingState==='stable' && StagePeer.instances.at(-1)?.localDescription!==null,'negotiation'); @@ -327,7 +327,7 @@ it('embed quality reconnect ignores retired socket and requires state receipt th client.dispatchEvent(new CustomEvent('quality', { detail: report(55, 'guest') })); empty(node); client.dispatchEvent(new CustomEvent('state', { detail: previous })); empty(node); await h.tick(500); const next = StageSocket.instances.at(-1)!; assert.notEqual(next, socket); - next.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + next.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await h.until(() => next.sent.some(m => m.type === 'join'), 'resume join'); next.deliver({ type: 'status', state: 'waiting' }); next.deliver(quality); empty(node); const request = next.sent.find(m => m.type === 'join')!; @@ -375,7 +375,7 @@ it('embed quality actual track-ended failure retains history and later transport }); it('embed quality initial state-before-ACK loss retires retained SDK snapshot permanently for that entry', async t => { const h = await actualApp(t); h.form.submit(); await flush(); const socket = StageSocket.instances[0]!; - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await h.until(() => socket.sent.some(m => m.type === 'join'), 'join'); socket.deliver({ type: 'room:state', ...actualState({ onStage: true }) }); const client = h.clients[0] as StageClient, node = h.node('own-connection-quality'); socket.deliver({ type: 'quality', ...report(37, 'guest') }); shown(node); socket.close(); await flush(); assert.equal(client.status, 'disconnected'); assert.ok(client.state); empty(node); assert.equal(h.leave.disabled, true); @@ -391,7 +391,7 @@ for (const terminal of ['closed', 'shutdown', 'kicked'] as const) : { type: 'room:closed', roomId: 'same-room', ...(terminal === 'shutdown' ? { reason: 'shutdown' as const } : {}) }); await flush(); assert.equal(entered, true); assert.equal(h.clients.length, 2); empty(node); assert.equal(h.status.textContent, '連線中…'); const next = StageSocket.instances.at(-1)!; - next.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + next.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await h.until(() => next.sent.some(m => m.type === 'join'), 'replacement join'); const request = next.sent.find(m => m.type === 'join')!; next.deliver({ type: 'room:state', ...actualState({ onStage: true }) }); next.deliver({ type: 'ok', requestId: request.requestId }); await h.tick(20); next.deliver({ type: 'quality', ...report(51, 'guest') }); shown(node, summary('8.0%', '2.0%', '51 ms')); diff --git a/test/webEmbedQueuePosition.test.ts b/test/webEmbedQueuePosition.test.ts index fc2f6a2..49952a6 100644 --- a/test/webEmbedQueuePosition.test.ts +++ b/test/webEmbedQueuePosition.test.ts @@ -27,8 +27,8 @@ async function command(h: App, socket: StageSocket, type: 'hand:raise' | 'hand:w const request = controls(socket).at(-1)!; assert.equal(request.type, type); return request; } async function joinRequest(h: App, socket: StageSocket) { - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await h.until(() => socket.sent.some(message => message.type === 'join'), 'resume request'); const request = socket.sent.find((message): message is Extract => message.type === 'join'); assert.ok(request); return request; diff --git a/test/webEmbedRename.test.ts b/test/webEmbedRename.test.ts index 4695ca7..fc64d3a 100644 --- a/test/webEmbedRename.test.ts +++ b/test/webEmbedRename.test.ts @@ -24,7 +24,7 @@ async function command(h: ActualApp, socket: StageSocket, name: string) { return requests(socket).at(-1)!; } async function hello(h: ActualApp, socket: StageSocket) { - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await h.until(() => socket.sent.some(message => message.type === 'join'), 'public SDK join'); const request = socket.sent.find((message): message is Extract => message.type === 'join'); assert.ok(request); return request; } diff --git a/test/webEmbedRoomRename.test.ts b/test/webEmbedRoomRename.test.ts index 95a23b4..35e7149 100644 --- a/test/webEmbedRoomRename.test.ts +++ b/test/webEmbedRoomRename.test.ts @@ -43,7 +43,7 @@ async function command(h: ActualApp, socket: StageSocket, name: string): Promise return requests(socket).at(-1)!; } async function hello(h: ActualApp, socket: StageSocket) { - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await h.until(() => socket.sent.some(message => message.type === 'join'), 'public SDK join'); const request = socket.sent.find((message): message is Extract => message.type === 'join'); assert.ok(request); return request; diff --git a/test/webEmbedShutdownFeedback.test.ts b/test/webEmbedShutdownFeedback.test.ts index 531a41d..245832a 100644 --- a/test/webEmbedShutdownFeedback.test.ts +++ b/test/webEmbedShutdownFeedback.test.ts @@ -37,8 +37,8 @@ function pendingPlayback(t: TestContext, h: ActualApp): Deferred { h.node('unlock').click(); busy(h.node('unlock'), true); return work; } function hello(socket: StageSocket): void { - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); } function joinRequest(socket: StageSocket): Extract { const request = socket.sent.find((message): message is Extract => message.type === 'join'); diff --git a/test/webEmbedSpeaking.test.ts b/test/webEmbedSpeaking.test.ts index 30bdb0d..3bbbeb9 100644 --- a/test/webEmbedSpeaking.test.ts +++ b/test/webEmbedSpeaking.test.ts @@ -22,7 +22,7 @@ function state(me: Partial = {}, speakers?: ParticipantView[]): const own = { ...person('controller', '甲', 'controller'), ...me }; return { roomId: 'same-room', name: 'Synthetic room', controllerId: 'controller', me: own, speakers: speakers ?? [...(own.onStage ? [own] : []), person('target')], hands: [], audienceCount: 0, - limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', codeRequired: true, + limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', codeRequired: true, recording: false, recordingAvailable: false, code: 'PRIVATE-CODE', resumeToken: 'PRIVATE-TOKEN' }; } function audience(speakers = [person('controller', '甲', 'controller'), person('target')]): RoomStatePayload { @@ -551,7 +551,7 @@ it('embed speaking actual role→stageleft→state gap suppresses a new report w async function enter(t: TestContext, snapshot: RoomStatePayload) { const h = await actualApp(t); h.form.submit(); await flush(); const socket = StageSocket.instances.at(-1)!; - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await h.until(() => socket.sent.some(m => m.type === 'join'), 'join request'); const request = socket.sent.find(m => m.type === 'join')!; socket.deliver({ type: 'room:state', ...snapshot }); socket.deliver({ type: 'ok', requestId: request.requestId }); await h.until(() => StagePeer.instances.at(-1)?.signalingState === 'stable' && StagePeer.instances.at(-1)?.localDescription !== null, 'negotiation'); @@ -582,7 +582,7 @@ for (const role of ['controller', 'speaker', 'audience'] as const) }); it('embed speaking actual SDK state-before-join-ACK disconnect permanently retires that entry', async t => { const h = await actualApp(t); h.form.submit(); await flush(); const socket = StageSocket.instances[0]!; - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await h.until(() => socket.sent.some(m => m.type === 'join'), 'join'); const snapshot = actualState(); snapshot.speakers = [person('target')]; socket.deliver({ type: 'room:state', ...snapshot }); const client = h.clients[0] as StageClient, node = h.node('speaking-summary'); socket.deliver({ type: 'speaking', participantIds: ['target'] }); shown(node, summary('乙')); @@ -600,7 +600,7 @@ for (const terminal of ['closed', 'shutdown', 'kicked'] as const) : { type: 'room:closed', roomId: 'same-room', ...(terminal === 'shutdown' ? { reason: 'shutdown' as const } : {}) }); await flush(); assert.equal(entered, true); assert.equal(h.clients.length, 2); empty(node); assert.equal(h.status.textContent, '連線中…'); const next = StageSocket.instances.at(-1)!; - next.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + next.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await h.until(() => next.sent.some(m => m.type === 'join'), 'replacement join'); const request = next.sent.find(m => m.type === 'join')!; next.deliver({ type: 'room:state', ...snapshot }); next.deliver({ type: 'ok', requestId: request.requestId }); await h.tick(20); next.deliver({ type: 'speaking', participantIds: [] }); shown(node, SILENT); diff --git a/test/webEmbedStageManagement.test.ts b/test/webEmbedStageManagement.test.ts index 457c051..2c0d04b 100644 --- a/test/webEmbedStageManagement.test.ts +++ b/test/webEmbedStageManagement.test.ts @@ -301,7 +301,7 @@ it('whole embed actual SDK reconnect retains row/open, releases its rejected pro socket.close(); assert.equal(summary(h), '舞台管理(2) · 上次收到'); assert.equal(row(h).node, a.node); locked(a); await flush(); locked(a, false, true); assert.equal(h.node('error-notice').hidden, true); assert.equal(panel(h).open, true); force(a.remove); await h.tick(500); const resumed = StageSocket.instances.at(-1)!; assert.notEqual(resumed, socket); - resumed.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + resumed.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await h.until(() => resumed.sent.some(message => message.type === 'join'), 'resume join'); const joined = resumed.sent.find(message => message.type === 'join'); assert.ok(joined?.type === 'join'); assert.equal(joined.resumeToken, 'test-resume'); resumed.deliver({ type: 'ok', requestId: joined.requestId }); await flush(); locked(a, false, true); @@ -315,7 +315,7 @@ it('whole embed actual SDK reconnect retains row/open, releases its rejected pro }); it('whole embed actual SDK initial state-before-ACK disconnect retires controls despite retained SDK snapshot', async t => { const h = await actualApp(t); h.form.submit(); await flush(); const socket = StageSocket.instances[0]!; - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await h.until(() => socket.sent.some(message => message.type === 'join'), 'initial join'); socket.deliver({ type: 'room:state', ...actualState(), speakers: [person('a')] }); const a = row(h); panel(h).open = true; socket.close(); await flush(); diff --git a/test/webEmbedSubmission.test.ts b/test/webEmbedSubmission.test.ts index d50d32e..63771be 100644 --- a/test/webEmbedSubmission.test.ts +++ b/test/webEmbedSubmission.test.ts @@ -145,8 +145,8 @@ async function sdkApp(t: TestContext) { return h; } async function hello(h: Awaited>, socket: StageSocket) { - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await h.until(() => socket.sent.some(message => message.type === 'join'), 'submitted join frame'); const request = socket.sent.find((message): message is Extract => message.type === 'join'); assert.ok(request); return request; diff --git a/test/webEmbedUnlockPending.test.ts b/test/webEmbedUnlockPending.test.ts index fc189e1..4e02ce6 100644 --- a/test/webEmbedUnlockPending.test.ts +++ b/test/webEmbedUnlockPending.test.ts @@ -47,8 +47,8 @@ function controls(h: View) { }); } function hello(socket: StageSocket): void { - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); } for (const phase of ['pre-entry', 'live', 'reconnecting'] as const) { diff --git a/test/webEntryFeedback.test.ts b/test/webEntryFeedback.test.ts index 5706d89..a14faed 100644 --- a/test/webEntryFeedback.test.ts +++ b/test/webEntryFeedback.test.ts @@ -86,8 +86,8 @@ class Socket { deliver(message: unknown): void { this.onmessage?.({ data: JSON.stringify(message) }); } hello(): void { this.readyState = Socket.OPEN; - this.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }); + this.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); } close(): void { this.readyState = 2; } } diff --git a/test/webGain.test.ts b/test/webGain.test.ts index 6c577b2..43fc316 100644 --- a/test/webGain.test.ts +++ b/test/webGain.test.ts @@ -60,7 +60,7 @@ const errorBox = (h: View) => h.find(node => node.attributes.role === 'alert'); const notices = (h: App) => h.find(node => node.classList.contains('notices')); function assertLanding(h: App): void { assert.equal(h.sdk.state, null); assert.equal(h.sdk.status, 'disconnected'); - assert.equal(h.app.descendants().filter(node => node.tagName === 'form').length, 2); + assert.equal(h.app.descendants().filter(node => node.tagName === 'form' && !node.classList.contains('chat-form')).length, 2); assert.equal(h.clock.jobs.size, 0); } async function join(h: App, state = managementState()): Promise { @@ -328,8 +328,8 @@ class GainSocket { deliver(message: ServerMessage): void { this.onmessage?.({ data: JSON.stringify(message) }); } hello(): void { this.readyState = GainSocket.OPEN; - this.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }); + this.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); } close(): void { this.readyState = 3; this.onclose?.(); } } diff --git a/test/webHandQueueFilter.test.ts b/test/webHandQueueFilter.test.ts index 565fcb1..535abdd 100644 --- a/test/webHandQueueFilter.test.ts +++ b/test/webHandQueueFilter.test.ts @@ -256,9 +256,9 @@ async function actualSetup(t: TestContext) { const until = async (test: () => boolean) => { for (let i = 0; i < 80 && !test(); i++) { now += 10; t.mock.timers.tick(10); await flushSample(); } assert.ok(test()); }; const join = async (s: RoomStatePayload) => { const form = h.find(node => node.tagName === 'form' && node.children.some(child => child.tagName === 'h2' && child.textContent === '加入房間')); - for (const [name, value] of Object.entries({ roomId: s.roomId, name: s.me.name, code: s.code! })) form.descendants().find(node => node.name === name)!.value = value; + for (const [name, value] of Object.entries({ roomId: s.roomId, name: s.me.name, code: s.code! })) form.descendants().filter(node => node.type !== 'histogram').find(node => node.name === name)!.value = value; form.submit(); await flushSample(); const socket = StageSocket.instances.at(-1)!; - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await until(() => socket.sent.some(message => message.type === 'join')); const request = socket.sent.find(message => message.type === 'join')!; socket.deliver({ type: 'room:state', ...s }); socket.deliver({ type: 'ok', requestId: request.requestId }); diff --git a/test/webHandRejection.test.ts b/test/webHandRejection.test.ts index f3ef97e..55b85c6 100644 --- a/test/webHandRejection.test.ts +++ b/test/webHandRejection.test.ts @@ -152,10 +152,10 @@ async function actualSample(t: TestContext, state: RoomStatePayload) { } const form = h.find(node => node.tagName === 'form' && node.children.some(child => child.tagName === 'h2' && child.textContent === '加入房間')); for (const [key, value] of Object.entries({ roomId: state.roomId, name: state.me.name })) { - const field = form.descendants().find(node => node.name === key)!; field.value = value; + const field = form.descendants().filter(node => node.type !== 'histogram').find(node => node.name === key)!; field.value = value; } form.submit(); await flushSample(); const socket = StageSocket.instances.at(-1)!; - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await until(() => socket.sent.some(message => message.type === 'join')); const request = socket.sent.find(message => message.type === 'join')!; socket.deliver({ type: 'room:state', ...state }); socket.deliver({ type: 'ok', requestId: request.requestId }); diff --git a/test/webInvitation.test.ts b/test/webInvitation.test.ts index 50a70e0..43b149b 100644 --- a/test/webInvitation.test.ts +++ b/test/webInvitation.test.ts @@ -59,7 +59,7 @@ function assertNoSelection(h: App): void { } function assertLanding(h: App): void { assert.equal(h.sdk.state, null); assert.equal(h.sdk.status, 'disconnected'); - assert.equal(h.app.descendants().filter(node => node.tagName === 'form').length, 2); + assert.equal(h.app.descendants().filter(node => node.tagName === 'form' && !node.classList.contains('chat-form')).length, 2); assert.equal(h.app.descendants().filter(node => node.classList.contains('invite-link')).length, 0); assert.equal(h.clock.jobs.size, 0); } diff --git a/test/webInviteCopyAdmission.test.ts b/test/webInviteCopyAdmission.test.ts index 8d879b8..3c2fa1a 100644 --- a/test/webInviteCopyAdmission.test.ts +++ b/test/webInviteCopyAdmission.test.ts @@ -16,7 +16,7 @@ const currentError = '保留目前操作錯誤。'; function state(): RoomStatePayload { const s = roomState({ participantId: 'fictional-host', role: 'controller' }); return { ...s, roomId: 'fictional-room', controllerId: s.me.participantId, audience: [], audienceCount: 0, - codeRequired: true, code: 'FICTIONAL_OLD', resumeToken: 'fictional-resume' }; + codeRequired: true, recording: false, recordingAvailable: false, code: 'FICTIONAL_OLD', resumeToken: 'fictional-resume' }; } type StateChange = 'room' | 'participant' | 'code' | 'role' | 'code-required' | 'room-name' | 'own-name'; @@ -65,11 +65,11 @@ async function setup(t: TestContext, location = new URL('https://sample.test/')) async function join(s = state()) { const form = h.find(node => node.tagName === 'form' && node.children.some(child => child.tagName === 'h2' && child.textContent === '加入房間')); for (const [name, value] of Object.entries({ roomId: s.roomId, code: s.code!, name: s.me.name })) { - const input = form.descendants().find(node => node.name === name); assert.ok(input); input.value = value; + const input = form.descendants().filter(node => node.type !== 'histogram').find(node => node.name === name); assert.ok(input); input.value = value; } form.submit(); await flushSample(); const socket = StageSocket.instances.at(-1)!; - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await until(() => socket.sent.some(message => message.type === 'join')); const request = socket.sent.find(message => message.type === 'join')!; socket.deliver({ type: 'room:state', ...s }); socket.deliver({ type: 'ok', requestId: request.requestId }); diff --git a/test/webLandingCancellation.test.ts b/test/webLandingCancellation.test.ts index d4c932e..2cf4233 100644 --- a/test/webLandingCancellation.test.ts +++ b/test/webLandingCancellation.test.ts @@ -294,8 +294,8 @@ class Socket { deliver(message: unknown): void { this.onmessage?.({ data: JSON.stringify(message) }); } hello(): void { this.readyState = Socket.OPEN; - this.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }); + this.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); } close(): void { this.closeCalls++; this.readyState = 2; } } @@ -353,7 +353,7 @@ for (const action of ['create', 'join'] as const) { if (action === 'create') { state.controllerId = state.me.participantId; state.codeRequired = true; } // The authoritative snapshot opens room controls before the entry request's ACK. old.deliver({ type: 'room:state', ...state }); await flushSample(); - assert.ok(sdk.state); assert.equal(h.app.descendants().filter(node => node.tagName === 'form').length, 0); + assert.ok(sdk.state); assert.equal(h.app.descendants().filter(node => node.tagName === 'form' && !node.classList.contains('chat-form')).length, 0); h.button('離開房間').click(); assert.equal(old.closeCalls, 1); assert.equal(sdk.state, null); assert.equal(sdk.status, 'disconnected'); assertLocked(forms(h), true); assertCancel(h, false); @@ -416,8 +416,8 @@ for (const action of ['create', 'join'] as const) { const replacementAction = action === 'create' ? 'join' : 'create'; landing[replacementAction].form.submit(); assertLocked(landing, true); assertCancel(h, true); assert.equal(Socket.instances.length, 2); const current = Socket.instances[1]!; - oldMessage?.({ data: JSON.stringify({ type: 'hello', protocol: 1, serverVersion: 'retired', - limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }) }); + oldMessage?.({ data: JSON.stringify({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'retired', + limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }) }); if (old.sent[0]) oldMessage?.({ data: JSON.stringify({ type: 'ok', requestId: old.sent[0].requestId }) }); oldMessage?.({ data: JSON.stringify({ type: 'room:created', roomId: 'retired-room', code: 'RETIRED' }) }); for (const message of [ diff --git a/test/webLandingPending.test.ts b/test/webLandingPending.test.ts index 0b0ea80..a338227 100644 --- a/test/webLandingPending.test.ts +++ b/test/webLandingPending.test.ts @@ -101,13 +101,13 @@ for (const action of ['create', 'join'] as const) { landing[action].form.submit(); assertPending(landing, true); snapshotBeforeAck(h, action); assert.equal(h.pending(action).status, 'pending'); for (const entry of Object.values(landing)) { assert.equal(entry.form.isConnected, false); assert.equal(entry.submit.isConnected, false); } - assert.equal(h.app.descendants().filter(node => node.tagName === 'form').length, 0); + assert.equal(h.app.descendants().filter(node => node.tagName === 'form' && !node.classList.contains('chat-form')).length, 0); const key = action === 'create' ? 'mute' : 'hand'; const control = () => h.find(node => node.dataset.roomAction === key); control().click(); assert.equal(action === 'create' ? muteCalls : h.sdk.raises.length, 1); const state = h.sdk.state; assert.ok(state); h.acknowledge(action); await flushSample(); - assert.equal(h.sdk.state, state); assert.equal(h.app.descendants().filter(node => node.tagName === 'form').length, 0); + assert.equal(h.sdk.state, state); assert.equal(h.app.descendants().filter(node => node.tagName === 'form' && !node.classList.contains('chat-form')).length, 0); assertPending(landing, true); // Detached controls retain their last rendered state. assert.equal(control().disabled, true); assert.equal(control().attributes['aria-busy'], 'true'); control().click(); assert.equal(action === 'create' ? muteCalls : h.sdk.raises.length, 1); @@ -133,7 +133,7 @@ for (const action of ['create', 'join'] as const) { assertPending(replacement, false); assertPending(retired, true); assert.equal(h.clock.jobs.size, 0); replacement[opposite].form.submit(); assert.equal(h.requestCount(), 2); assertPending(replacement, true); assert.equal(errorBox(h).hidden, true); snapshotBeforeAck(h, opposite); h.acknowledge(opposite); await flushSample(); - assert.equal(h.sdk.state?.roomId, 'same-room'); assert.equal(h.app.descendants().filter(node => node.tagName === 'form').length, 0); + assert.equal(h.sdk.state?.roomId, 'same-room'); assert.equal(h.app.descendants().filter(node => node.tagName === 'form' && !node.classList.contains('chat-form')).length, 0); assertPending(retired, true); assertPending(replacement, true); assert.equal(errorBox(h).hidden, true); assert.equal(h.clock.intervals(2000).length, 1); h.sdk.assertSettled(); }); diff --git a/test/webLandingProgress.test.ts b/test/webLandingProgress.test.ts index 4835782..3f6f320 100644 --- a/test/webLandingProgress.test.ts +++ b/test/webLandingProgress.test.ts @@ -64,8 +64,8 @@ class Socket { deliver(message: unknown): void { this.onmessage?.({ data: JSON.stringify(message) }); } hello(): void { this.readyState = Socket.OPEN; - this.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }); + this.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); } // Deliberately stall the close handshake to exercise SDK local cancellation. close(): void { this.closeCalls++; this.readyState = 2; } @@ -183,7 +183,7 @@ for (const action of ['create', 'join'] as const) { await flushSample(); assertProgress(h); assertLocked(forms(h), false); assert.equal(errorBox(h).hidden, true); const fresh = h.start(opposite(action)); assert.equal(Socket.instances.length, 2); for (const message of [ - { type: 'hello', protocol: 1, serverVersion: 'retired', limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000 } }, + { type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'retired', limits: { controlPerSecond: 20, icePerSecond: 30, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }, ...(request ? [{ type: 'ok', requestId: request.requestId }] : []), { type: 'room:created', roomId: 'retired-room', code: 'RETIRED' }, { type: 'room:state', ...roomState(), roomId: 'retired-room' }, diff --git a/test/webLandingTimers.test.ts b/test/webLandingTimers.test.ts index cd0d578..02f52e0 100644 --- a/test/webLandingTimers.test.ts +++ b/test/webLandingTimers.test.ts @@ -12,7 +12,7 @@ type View = Pick; interface Stats { inbound: { rtt?: number; lossPercent?: number; bufferMs?: number } | null; outbound: null } const result = (rtt = 0.02, lossPercent = 0): Stats => ({ inbound: { rtt, lossPercent, bufferMs: 10 }, outbound: null }); const connection = (h: Pick) => h.find(node => node.classList.contains('connection')); -const forms = (h: Pick) => h.app.descendants().filter(node => node.tagName === 'form'); +const forms = (h: Pick) => h.app.descendants().filter(node => node.tagName === 'form' && !node.classList.contains('chat-form')); function timers(h: Pick, meter: number, stats: number): void { assert.equal(h.clock.intervals(50).length, meter, '50 ms microphone meter count'); assert.equal(h.clock.intervals(2000).length, stats, '2000 ms connection diagnostics count'); @@ -53,8 +53,8 @@ class Socket { send(data: string): void { assert.equal(this.readyState, Socket.OPEN); this.sent.push(JSON.parse(data) as Request); } deliver(message: unknown): void { this.onmessage?.({ data: JSON.stringify(message) }); } hello(): void { - this.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + this.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); } close(): void { this.closeCalls++; this.readyState = 3; this.onclose?.(); } } diff --git a/test/webLifecycle.test.ts b/test/webLifecycle.test.ts index cc20bfc..0da72fd 100644 --- a/test/webLifecycle.test.ts +++ b/test/webLifecycle.test.ts @@ -26,7 +26,7 @@ const connection = (h: App) => h.find(node => node.classList.contains('connectio it('whole sample joins through its form, isolates preview generations across same-identity rejoin, and keeps the post-disconnect shutdown notice', async t => { const h = sampleApp(); t.after(() => h.cleanup()); await flushSample(); - assert.equal(h.app.descendants().filter(node => node.tagName === 'form').length, 2); + assert.equal(h.app.descendants().filter(node => node.tagName === 'form' && !node.classList.contains('chat-form')).length, 2); assert.equal(h.id('microphone-device').children.length, 2); assert.equal(h.id('playback-volume').value, '100'); assert.equal(h.clock.jobs.size, 0); @@ -36,7 +36,7 @@ it('whole sample joins through its form, isolates preview generations across sam assert.equal(h.id('microphone-device').disabled, true); const leaving = h.sdk.disconnect(); assert.equal(canceled.status, 'rejected', 'the SDK cancels pending capture on disconnect'); - assert.equal(h.app.descendants().filter(node => node.tagName === 'form').length, 2); + assert.equal(h.app.descendants().filter(node => node.tagName === 'form' && !node.classList.contains('chat-form')).length, 2); submitJoin(h); h.sdk.acceptJoin(roomState()); h.id('mic-test').click(); const replacement = h.sdk.previews[1]!; await leaving; await flushSample(); @@ -67,7 +67,7 @@ it('whole sample joins through its form, isolates preview generations across sam assert.equal(notices(h).textContent, '', 'disconnect renders landing before the closed notification'); await closing; await flushSample(); assert.match(notices(h).textContent, /伺服器維護或重新啟動/); - assert.equal(h.app.descendants().filter(node => node.tagName === 'form').length, 2); + assert.equal(h.app.descendants().filter(node => node.tagName === 'form' && !node.classList.contains('chat-form')).length, 2); assert.equal(h.clock.jobs.size, 0); assert.equal(current.stops, 1); assert.equal(h.id('mic-level').value, 0); assert.deepEqual(h.sdk.events.slice(-2), ['status', 'closed']); h.sdk.assertSettled(); }); diff --git a/test/webMicRetry.test.ts b/test/webMicRetry.test.ts index 9d73b66..65ebfe0 100644 --- a/test/webMicRetry.test.ts +++ b/test/webMicRetry.test.ts @@ -169,8 +169,8 @@ class Socket { onclose: (() => void) | null = null; constructor() { Socket.instances.push(this); - queueMicrotask(() => this.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', - limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } })); + queueMicrotask(() => this.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', + limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } })); } deliver(message: ServerMessage): void { this.onmessage?.({ data: JSON.stringify(message) }); } send(data: string): void { @@ -277,7 +277,7 @@ async function integratedSetup(t: TestContext) { async function join() { const form = h.find(node => node.tagName === 'form' && node.children.some(child => child.tagName === 'h2' && child.textContent === '加入房間')); for (const [name, value] of Object.entries({ roomId: 'same-room', name: '測試聽眾' })) { - const input = form.descendants().find(node => node.name === name); assert.ok(input); input.value = value; + const input = form.descendants().filter(node => node.type !== 'histogram').find(node => node.name === name); assert.ok(input); input.value = value; } form.submit(); await until(() => sdk.state !== null && Peer.instances.at(-1)?.remoteDescription !== null, 'join form and audience negotiation'); const socket = Socket.instances.at(-1)!, peer = Peer.instances.at(-1)!; diff --git a/test/webNoticeReuseHub.test.ts b/test/webNoticeReuseHub.test.ts index 7d3444b..7dd1d59 100644 --- a/test/webNoticeReuseHub.test.ts +++ b/test/webNoticeReuseHub.test.ts @@ -103,7 +103,7 @@ async function actualSample(t: TestContext) { const form = h.find(node => node.tagName === 'form' && node.children.some(child => child.tagName === 'h2' && child.textContent === title)); const values = onStage ? { name: 'Listener', roomName: 'Fixture' } : { roomId: host.roomId, code: host.code, name: 'Listener' }; for (const [name, value] of Object.entries(values)) { - const input = form.descendants().find(node => node.name === name); assert.ok(input); input.value = value; + const input = form.descendants().filter(node => node.type !== 'histogram').find(node => node.name === name); assert.ok(input); input.value = value; } form.submit(); await until(() => sdk.state !== null && StagePeer.instances.at(-1)?.localDescription !== null, 'room entry and offer'); diff --git a/test/webParticipantRename.test.ts b/test/webParticipantRename.test.ts index d5b2c70..0cef7f7 100644 --- a/test/webParticipantRename.test.ts +++ b/test/webParticipantRename.test.ts @@ -241,7 +241,7 @@ it('whole sample and public SDK send one rename command, honor ACK/error and pre let answer = 'Fresh & '; const h = sampleApp({ sdk, decodeName, prompt: () => answer }); cleanup = () => h.cleanup(); const socket = StageSocket.instances[0]!; - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await boundary.until(() => socket.sent.some(message => message.type === 'join'), 'public SDK join request'); const joining = socket.sent.find(message => message.type === 'join')!; const state = actualState({ onStage: true }); state.speakers = [state.me]; diff --git a/test/webPreviewStopped.test.ts b/test/webPreviewStopped.test.ts index 82e7e86..315f0af 100644 --- a/test/webPreviewStopped.test.ts +++ b/test/webPreviewStopped.test.ts @@ -88,10 +88,10 @@ async function setup(t: TestContext) { async join() { const form = h.find(node => node.tagName === 'form' && node.children.some(child => child.tagName === 'h2' && child.textContent === '加入房間')); for (const [name, value] of Object.entries({ roomId: 'same-room', name: '測試聽眾' })) { - const input = form.descendants().find(node => node.name === name); assert.ok(input); input.value = value; + const input = form.descendants().filter(node => node.type !== 'histogram').find(node => node.name === name); assert.ok(input); input.value = value; } form.submit(); await flushSample(); const socket = StageSocket.instances.at(-1)!; - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await boundary.until(() => socket.sent.some(message => message.type === 'join'), 'public SDK join request'); const request = socket.sent.find(message => message.type === 'join')!; const state = { ...actualState({ role: 'audience' }), controllerId: 'host', audienceCount: 1 }; diff --git a/test/webRoomOccupancy.test.ts b/test/webRoomOccupancy.test.ts index 4c0f82f..0443723 100644 --- a/test/webRoomOccupancy.test.ts +++ b/test/webRoomOccupancy.test.ts @@ -21,7 +21,7 @@ function snapshot(options: { audience?: number; stage?: number; controllerOnStag speakers: [...(controllerOnStage ? [person('controller', 'controller')] : []), ...Array.from({ length: options.stage ?? 1 }, (_, i) => person(`speaker-${i}`, 'speaker'))], hands: [], audienceCount: options.audience ?? 2, limits: { maxAudience: options.maxAudience ?? 300, maxSpeakers: options.maxSpeakers ?? 8 }, - status: 'live', codeRequired: false, + status: 'live', codeRequired: false, recording: false, recordingAvailable: false, me: person(options.participantId ?? (viewer === 'controller' ? 'controller' : viewer === 'speaker' ? 'speaker-0' : 'listener'), viewer, viewer === 'controller' ? controllerOnStage : viewer === 'speaker') }; } diff --git a/test/webRoomRename.test.ts b/test/webRoomRename.test.ts index 9426b46..f128178 100644 --- a/test/webRoomRename.test.ts +++ b/test/webRoomRename.test.ts @@ -242,7 +242,7 @@ it('whole sample public SDK room-title command routes through validator and real let answer = ' Cafe\u0301\u0000 & <茶> ', oldServer = false, held: ServerMessage | undefined; const h = sampleApp({ sdk, decodeName, prompt: () => answer }); cleanup = () => h.cleanup(); const socket = StageSocket.instances[0]!; - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await boundary.until(() => socket.sent.some(message => message.type === 'join'), 'public SDK join request'); const joining = socket.sent.find(message => message.type === 'join')!; socket.deliver(host.s.last('room:state')!); socket.deliver({ type: 'ok', requestId: joining.requestId }); diff --git a/test/webStagePermissionNotice.test.ts b/test/webStagePermissionNotice.test.ts index ecc831c..25b80b6 100644 --- a/test/webStagePermissionNotice.test.ts +++ b/test/webStagePermissionNotice.test.ts @@ -27,7 +27,7 @@ function invite(h: FakeApp): void { async function join(h: FakeApp, state = roomState()): Promise { const form = h.find(node => node.tagName === 'form' && node.children.some(child => child.tagName === 'h2' && child.textContent === '加入房間')); for (const [name, value] of Object.entries({ roomId: state.roomId, name: state.me.name })) { - const input = form.descendants().find(node => node.name === name); assert.ok(input); input.value = value; + const input = form.descendants().filter(node => node.type !== 'histogram').find(node => node.name === name); assert.ok(input); input.value = value; } form.submit(); h.sdk.acceptJoin(state); await flushSample(); } @@ -42,7 +42,7 @@ async function actualSetup(t: TestContext) { const sdk = boundary.clients[0]!; const h = sampleApp({ sdk }); cleanup = () => h.cleanup(); const socket = StageSocket.instances[0]!; - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await boundary.until(() => socket.sent.some(message => message.type === 'join'), 'public SDK join request'); const request = socket.sent.find(message => message.type === 'join')!; const audience = { ...actualState({ role: 'audience', handRaised: true }), controllerId: 'host' }; diff --git a/test/webStageQualityDetails.test.ts b/test/webStageQualityDetails.test.ts index 4438071..ae0d7ab 100644 --- a/test/webStageQualityDetails.test.ts +++ b/test/webStageQualityDetails.test.ts @@ -265,7 +265,7 @@ it('whole sample and real SDK receive quality without signaling requests, new pe boundary.form.submit(); await flushSample(); const sdk = boundary.clients[0]!; const h = sampleApp({ sdk, decodeName }); cleanup = () => h.cleanup(); const socket = StageSocket.instances[0]!; - socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); + socket.deliver({ type: 'hello', instance: 'test-instance', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000, chatIntervalMs: 1000, reactionIntervalMs: 250 } }); await boundary.until(() => socket.sent.some(message => message.type === 'join'), 'public SDK join request'); const request = socket.sent.find(message => message.type === 'join')!; const state = actualState(); state.speakers = [{ ...state.me, participantId: 'speaker-0', name: '台上甲', role: 'speaker', onStage: true }]; diff --git a/web/src/main.ts b/web/src/main.ts index 56e9abc..90b9934 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')!; @@ -616,6 +616,7 @@ function field(form: HTMLFormElement, label: string, name: string, value = '', r return input; } function landing(): void { + chatList.replaceChildren(); retireOccupancy(); // Drop ownership before any reset can synchronously deliver another speaking callback. resetSpeaking(); @@ -1053,6 +1054,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', '● 錄音中:此房間的混音正在被錄下')); if (controller) { summary.append(element('p', '', state.codeRequired ? `房間代碼:${roomCode ?? '未提供'}` : '不需要房間代碼')); const titleRoomId = state.roomId, titleParticipantId = state.me.participantId; @@ -1109,6 +1111,11 @@ function renderRoom(state: RoomStatePayload): void { if (generation !== roomActionGeneration) return; 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, ownHandPosition, handRejectionNotice); @@ -1182,7 +1189,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); renderedRoomActions = Array.from(content.querySelectorAll('[data-room-action]')); @@ -1282,6 +1289,55 @@ function updateCooldown(): void { syncRoomActions(); scheduleCooldown(cooldownButton?.isConnected ? remainingMs : 0); } +/** 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); +}); function syncHandPosition(): void { const state = client.state, me = state?.me; const visible = Boolean(me?.handRaised && !me.onStage && me.role !== 'controller'); diff --git a/web/src/styles.css b/web/src/styles.css index ab90aa1..161dad9 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); } @@ -57,6 +58,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)); }