diff --git a/.env.example b/.env.example index 31ffaf3..7de12c5 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,9 @@ MAX_ROOMS=1000 MAX_VIEWERS_PER_ROOM=8 # Experimental standard-WebRTC relay chains. Keep false outside measured tests. PEER_ASSISTED_MEDIA=false +# Optional exact-room canary when peer assistance is true. Use comma-separated +# positive numeric room IDs. Missing or empty means every room for compatibility. +PEER_ASSISTED_ROOM_IDS= # Optional automatic SFU fallback capacity. Set PEER_ASSISTED_MEDIA=true and # supply all three values together; leaving them empty keeps LiveKit completely @@ -42,6 +45,7 @@ TURN_CREDENTIAL_TTL_SECONDS=3600 # TURN_SHARED_SECRET=replace-with-at-least-32-random-bytes # Optional automatic SFU fallback (requires PEER_ASSISTED_MEDIA=true and does # not replace STUN/TURN above): +# PEER_ASSISTED_ROOM_IDS=1 # LIVEKIT_URL=wss://share.example.com # LIVEKIT_API_KEY=replace-with-a-livekit-api-key # LIVEKIT_API_SECRET=replace-with-at-least-32-independent-random-bytes diff --git a/docs/adr/0004-peer-assisted-media-experiment.md b/docs/adr/0004-peer-assisted-media-experiment.md index ef4161c..f70b56c 100644 --- a/docs/adr/0004-peer-assisted-media-experiment.md +++ b/docs/adr/0004-peer-assisted-media-experiment.md @@ -44,6 +44,13 @@ cross-mode fallback as a product goal. The experiment is gated by `PEER_ASSISTED_MEDIA`, which defaults to `false`. Enabling it requires `MAX_VIEWERS_PER_ROOM` at or below eight; larger configured rooms fail configuration rather than silently running a different topology. +An optional non-empty `PEER_ASSISTED_ROOM_IDS` deployment allowlist restricts +the experiment to exact valid room IDs. Non-allowlisted rooms must use the +legacy P2P authenticated wire, signaling, quality behavior, and lifecycle and +must never enter `HybridMediaRouter`. Missing or empty retains the original +all-room behavior when the experiment is enabled; isolated canaries therefore +must provide at least one ID. Invalid, empty-list-entry, or duplicate values +fail startup. This adds no UI, percentage rollout, or routing score. The signaling server assigns two sticky, balanced chains with a deterministic breadth-first walk. The host has capacity for at most two children and each diff --git a/docs/adr/0005-automatic-hybrid-media-routing.md b/docs/adr/0005-automatic-hybrid-media-routing.md index 516f329..988d535 100644 --- a/docs/adr/0005-automatic-hybrid-media-routing.md +++ b/docs/adr/0005-automatic-hybrid-media-routing.md @@ -59,6 +59,15 @@ production release `769de201f7cc`, but the deployment has neither `PEER_ASSISTED_MEDIA` nor a LiveKit tuple, so its active path remains ordinary one-host-peer-per-viewer P2P/TURN. +The repository also supports an optional strict `PEER_ASSISTED_ROOM_IDS` +deployment allowlist. When non-empty, only exact listed room IDs enter the +controller or receive optional LiveKit standby and grants. All other rooms +retain the legacy P2P authentication shape and signaling/quality/lifecycle +behavior. Missing or empty preserves the previous all-room behavior when +`PEER_ASSISTED_MEDIA=true`; malformed or duplicate entries fail startup. The +boundary is deployment-only and intentionally has no browser selector, +percentage framework, or second router. + The implementation keeps the LiveKit dependency dormant unless the complete URL, API key, and API secret tuple is present together with `PEER_ASSISTED_MEDIA=true`. It issues short-lived room-, role-, peer-, and publication-generation-bound grants, @@ -135,6 +144,9 @@ connection generation and assigned-edge authorization. The server derives the failed edge from the authenticated session, active revision, and connection ID instead of accepting a client-supplied parent or arbitrary reason. Late, duplicate, or stale revision messages have no effect. +Non-allowlisted rooms receive the ordinary P2P snapshot with none of the hybrid +fields above, even when the same process serves an allowlisted room and has a +complete LiveKit tuple. ## Transition @@ -272,6 +284,9 @@ encrypted from the SFU operator; the deployment and UI must not claim otherwise. - Existing descendants, source selection, quality profile, pause, audio, and persistent-room stop/restart semantics survive the transition. - No LiveKit configuration preserves the existing P2P/peer behavior and wire. +- In one process, non-allowlisted rooms preserve the legacy P2P wire, directed + signaling, quality rejection, stop/reconnect/delete semantics, and remain + isolated from allowlisted peer/SFU state. - LiveKit UDP, TCP, and TURN fallback are independently verified before the SFU can be called a reliable final route. diff --git a/docs/deployment.md b/docs/deployment.md index 37635e8..7f49e77 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -98,12 +98,24 @@ To make automatic SFU fallback capacity available, add the complete tuple: ```dotenv PEER_ASSISTED_MEDIA=true +PEER_ASSISTED_ROOM_IDS=1 LIVEKIT_URL=wss://share.example.com LIVEKIT_API_KEY= LIVEKIT_API_SECRET= MAX_SFU_ROOTS_PER_ROOM=2 ``` +`PEER_ASSISTED_ROOM_IDS` is the deployment canary boundary. A non-empty value +is a comma-separated set of exact positive numeric room IDs, using the same +1-to-12-digit syntax as room authentication. Duplicate IDs, empty entries, +leading zeroes, and malformed IDs fail startup. A non-empty allowlist requires +`PEER_ASSISTED_MEDIA=true`. Only listed rooms receive peer-assisted +authentication, routing, room quality state, or optional LiveKit fallback; +every other room keeps the legacy P2P wire and signaling behavior. Omitting the +variable or leaving it empty deliberately preserves the earlier all-room +behavior when peer assistance is enabled, so a canary deployment must set at +least one exact ID. There is no browser control or percentage rollout. + `ALLOWED_ORIGINS` must list exact `http` or `https` origins, never `*`. `ACCESS_PASSWORD` is optional: omit it or leave it empty for a public site. A non-empty value must contain 1 through 128 visible ASCII characters (`0x21` @@ -132,6 +144,15 @@ optional SDK and server path dormant. `LIVEKIT_URL` must be a plain `ws:` or credentials authorize short-lived LiveKit room tokens and do not provide E2EE: the LiveKit operator can access ordinary SFU media. +For the first canary, prefer an existing protected persistent room whose ID is +stable across restarts. Set only that ID, restart the application, and verify +that its authenticated message contains `mediaMode: "peer-assisted"` while a +second non-allowlisted room contains none of `mediaMode`, `qualitySettings`, +`routeRevision`, `routeAssignment`, or `sfuStandbyUrl`. Exercise join, offer and +answer, stop, reconnect, and room deletion in both rooms. Roll back by setting +`PEER_ASSISTED_MEDIA=false` and removing the allowlist and LiveKit tuple, then +restart; no room database migration is involved. + When `ACCESS_PASSWORD` is configured, both host and viewer routes first show the same login gate. Only `POST /api/session` accepts the password in an `Authorization: Bearer` header, then returns a 12-hour stateless HMAC-SHA256 diff --git a/docs/project-memory.md b/docs/project-memory.md index 3fd2a93..4429a0f 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -17,7 +17,7 @@ Last updated: 2026-08-19 ## Current Recommendation - Keep desktop Chrome/Edge and the responsive Web viewer as the baseline. Validate Android Chrome and iOS Safari as leaves. -- Use direct host P2P for one or two viewers. Keep ADR-0004's two-chain experiment off on production: without LiveKit, two mobile/iPad leaves can consume both host roots and leave later viewers admitted without media. Test it only on an isolated instance until the full resource, quality, recovery, TURN, and browser/mobile gates pass. +- Use direct host P2P for one or two viewers. Keep ADR-0004 off for broad production traffic: two mobile leaves can consume both host roots. Use the exact-room allowlist only on an isolated canary until its resource, quality, recovery, TURN, and browser/mobile gates pass. - Standard browser relays resend remote `MediaStreamTrack` values and therefore decode and re-encode at every hop. Browser WebRTC does not guarantee one shared encoder across peer connections; measure this cost rather than hiding it. - Keep the browser experiment bounded. Evaluate native RTP relay, encoded-object striping, SVC, or FEC only in separate measured spikes. - Plan a separate native-sender ADR for TeamSpeak-style shared encoding regardless of the browser relay result. One compatible encoded output may feed at most two standard WebRTC packetizers to reduce host encode work, but each edge still consumes upload bandwidth. This work is not part of the current Draft and cannot rescue another failed browser-relay gate. @@ -36,7 +36,7 @@ Last updated: 2026-08-19 - Access stays small: optional `ACCESS_PASSWORD`, a 12-hour stateless HMAC HttpOnly `SameSite=Strict` cookie, internal host token, role-bound signaling, Origin/payload checks, and no accounts, JWTs, or session map. - With `ROOM_DATABASE_PATH` and the site password, SQLite stores only room ID and host-token digest. Links persist and stopping leaves viewers waiting. Room `1` survived the `769de201f7cc` deployment; without the path, rooms are random and temporary. - Production runs behind nginx with Node.js 24.19.0. Authenticated coturn 4.17.2 at `turn.bonfire.icu:3478` has verified public STUN, TURN/UDP, TURN/TCP, and relay-only traffic. TURN/TLS is intentionally disabled. -- The deployed binary contains the default-off peer relay and ADR-0005 controller, but production has neither `PEER_ASSISTED_MEDIA` nor a LiveKit tuple. It therefore exposes no standby URL, imports no SFU SDK at runtime, and uses only the existing P2P/TURN path. +- Production contains the default-off ADR-0005 controller but has no peer/SFU configuration, so it remains P2P/TURN. Repository configuration can restrict enabled peer/SFU routing to exact `PEER_ASSISTED_ROOM_IDS`; unlisted rooms stay legacy P2P, while empty or missing means all rooms. - When enabled later, peer recovery precedes an allowlisted SFU root; session-bound revisions prepare/commit/abort, and an active SFU gets one token refresh before share-local failback. Browser relays re-encode; native shared encoding remains outside product code. - Corrected Chrome 151/LiveKit 1.13.5 same-leaf/two-root cold/standby A/B reduced failure-to-active from 1.481 seconds to 200 ms and failure-to-render from 2.257 seconds to 319.7 ms. The leaf decoded/rendered 31 new frames and 25 ms sampling kept host edge peak two. It is localhost, headless, synthetic 720p30 video only; the roughly 86% result combines SDK download/parse and network prewarm and must not be extrapolated to public networks. - Draft native ladder #16/#18/#22/#23/#25/#28 reaches one bounded Chrome 720p30 loop: one WebCodecs object feeds two Pion/browser legs, applies their lower stock-GCC target, and recovers one isolated loss via primary-SSRC replay. This is not proof of one physical or hardware encode. diff --git a/docs/status.md b/docs/status.md index c9ed1c8..f5c34ae 100644 --- a/docs/status.md +++ b/docs/status.md @@ -15,7 +15,7 @@ deployed binary still uses one host peer connection per viewer and P2P/TURN. - Whole-site `ACCESS_PASSWORD` is optional. Protected sessions use a stateless 12-hour HMAC HttpOnly `SameSite=Strict` cookie; host authentication remains internal and signaling is role-bound. - Without `ROOM_DATABASE_PATH`, rooms are random and temporary. With both the database path and site password, room IDs start at `1`, links persist, and stopping a share leaves viewers waiting. SQLite stores only room ID and host-token digest. - Direct ICE is preferred independently per media edge. Authenticated TURN/UDP and TURN/TCP are required production fallbacks; TURN/TLS is optional. -- Hidden routing is `direct P2P -> peer-assisted -> optional SFU`. After bounded ICE recovery it tries peer reparenting before an allowlisted SFU root. Session-bound revisions prepare, commit break-before-make under two host edges, or abort. Active SFU gets one token refresh, then fails back for that share. +- Hidden routing is `direct P2P -> peer-assisted -> optional SFU`. `PEER_ASSISTED_ROOM_IDS` canaries exact rooms; others stay legacy P2P. Empty or missing means all, production is disabled, and the 22-file/284-test full suite covers both paths. - Complete fallback configuration adds a non-secret standby URL to peer-assisted authentication. Host and viewers import the SDK and make one token-free DNS/TLS warmup; no configuration means no field, import, request, participant, or media edge. - A viewer starts as a leaf each session and explicitly advertises relay capacity zero or one; the Web client reports detected mobile/iPad clients as zero and desktop-class browsers as one. Withdrawal stops future assignment without moving a healthy edge. Browser relays remain one-child; the host remains two-child. - Production uses one strict, memory-only video quality setting with bounded manual ceilings and sender readback. It also carries equal idle-stage share/join actions and default-closed technical details. Browser audio remains request/presence-only. @@ -45,9 +45,9 @@ deployed binary still uses one host peer connection per viewer and P2P/TURN. ## Next Milestone -First validate the deployed quality controls on real game capture. Before -enabling experimental routes, validate standby gains across public transports, -rollback, reconnect, edge counts, egress, and load. +First validate the deployed quality controls on real game capture. Before any +broad route enablement, use one exact-room canary and validate standby gains +across public transports, rollback, reconnect, edge counts, egress, and load. ADR-0004 still requires a full-resolution 30-minute `1/3/5/8` network, resource, quality, latency, recovery, and browser/mobile-leaf matrix. @@ -66,7 +66,8 @@ transport/control; no-RTX remains research-only. Production enablement of peer assistance and automatic routing is No-Go until ADR-0004/0005 gates pass. Without LiveKit, two mobile leaves can occupy both -host roots and leave later viewers without media; use only an isolated canary. +host roots and leave later viewers without media; use only an exact-room +allowlisted isolated canary. Native shared encode and multi-tree striping remain separate experiments. - Whole-system versus selected-game audio for the first release. diff --git a/docs/方案设计.md b/docs/方案设计.md index 4372f51..3d360c4 100644 --- a/docs/方案设计.md +++ b/docs/方案设计.md @@ -251,6 +251,13 @@ TURN/TLS 是可选能力,不是生产启动门槛。启用时默认使用 RFC 状态:Proposed / Experiment Only。该扩展不改变上面的已部署 PoC,也不表示正式采用。 +部署可在 `PEER_ASSISTED_MEDIA=true` 时用可选的 +`PEER_ASSISTED_ROOM_IDS` 精确限制实验房间。非空值只接受逗号分隔、符合现有 +room code 规则且不重复的 ID;非法配置启动即失败。未配置或空值为兼容旧行为而 +表示全部房间,因此隔离灰度必须填写至少一个精确 ID。未命中房间不得进入 +`HybridMediaRouter`、房间质量同步或 LiveKit 兜底,鉴权 wire、定向信令、停止、 +重连和删除必须逐项保持普通 P2P 行为。该边界不增加 UI、用户选择或百分比框架。 + 产品硬目标是分享端任何时刻最多两条下游媒体边。系统按一个或两个观看者直接 P2P、更多观看者 peer-assisted、部署已提供的 SFU 最后兜底的顺序自动分配和恢复,分享者与观看者不选择拓扑。当前实验只实现前两级;跨模式迁移由后续独立 ADR 定义。 实验只实现一个由服务端确定的广度优先拓扑:host 最多两个 child,每名 viewer 最多一个 child,因此形成两条按加入顺序平衡的 sticky 链。父节点只按深度和 `serverJoinSequence` 选择第一个空 slot。新 viewer 加入时不搬动已有节点;parent 离开时只把失去 parent 的 subtree root 重新分配到第一个空 slot,其 descendants 保持连接。该规则不采集或组合 RTT、带宽、CPU、地理位置与质量评分,也不主动重平衡。没有 parent 时明确等待或拒绝,不能创建分享端第三条边。 diff --git a/docs/需求理解.md b/docs/需求理解.md index 75deae7..bd56e10 100644 --- a/docs/需求理解.md +++ b/docs/需求理解.md @@ -30,6 +30,8 @@ ### P1 - 重要功能 +- [ ] 实验路由按房间灰度:部署开启 `PEER_ASSISTED_MEDIA` 时可用严格的房间 ID allowlist 只让指定房间进入 peer-assisted 与可选 SFU 路径;同一进程内未命中房间的鉴权 wire、P2P 信令、质量拒绝、停止、重连和删除行为必须保持不变。缺失或空 allowlist 为兼容已有部署而表示全部房间,因此隔离灰度必须显式填写至少一个合法 ID;非法或重复配置启动即失败。该能力仅由部署控制,不增加 UI、用户选择、百分比发布或第二套路由器。 + - [ ] 质量控制扩展:推荐档位仍是默认入口;折叠高级区只允许 720p/1080p/1440p 分辨率 ceiling、15 至 60 的整数 FPS ceiling、2 至 12 Mbps 视频码率 ceiling,以及清晰/平衡/流畅三种偏好。直播中显式应用后才生效,避免滑杆产生连续控制流量。当前 Web 发送端不提供音频声道、采样率、Opus 码率、stereo、DTX 或 FEC 控件:display capture 未把声道和采样率定义为可移植控制,标准音频 `maxBitrate` 只是可能导致更低质量或停播的发送上限,其他项依赖 codec/fmtp 协商而没有可移植 sender setter。除非真实音频矩阵证明用户问题、标准 API 能应用并读回且实际结果可独立观测,否则不得为这些控件新增应用层 SDP munging 或用默认值制造伪控制。实际质量持续低于所选上限时必须显示可解释的限制原因;在真实拥塞、编码器和接收端证据完成前,不把任一组合称为“完美平衡”。 - [ ] Peer-assisted 媒体实验:分享端最多两个 child、每名 viewer 最多一个 child;信令服务只按深度和服务端加入序号确定性地形成两条 sticky 平衡链,不加入综合评分或主动重平衡。每个 viewer 将收到的标准 remote track 通过新的 `RTCPeerConnection` 转发,因此会逐跳解码和重编码;必须测量而不能称为 shared encode。服务端只在受房间数限制的内存状态中保存当前严格校验的质量设置,不写入 SQLite;host 鉴权后先重报设置,在线 viewers 接收更新,后加入者从鉴权快照取得当前值。该功能在 ADR-0004 的实验门槛通过并由后续 ADR 正式接受前不得成为产品路径。 - [ ] 原生 shared encode 分享端:后续独立的 Electron/原生分享端阶段必须保证一份兼容编码输出扇出到最多两套独立 WebRTC packetizer,并由单独 ADR 和测量约束。它降低分享端重复编码负载,但不消除每条出边各自的上传带宽。浏览器不保证跨 `RTCPeerConnection` 复用编码器,因此当前 Web spike 不实现、也不能宣称满足 shared encode 保证。 diff --git a/src/server/app.ts b/src/server/app.ts index d5b87f9..5be8b5d 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -111,6 +111,7 @@ export async function createScreenerServer( server: httpServer, roomStore, peerAssistedMedia: config.peerAssistedMedia, + peerAssistedRoomIds: config.peerAssistedRoomIds, ...(sfuFallback ? { sfuFallback } : {}), ice: iceOptions, allowedOrigins: config.allowedOrigins, diff --git a/src/server/config.ts b/src/server/config.ts index 4ea1e93..8ff0acb 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -1,4 +1,7 @@ -import { MAX_VIEWERS_PER_ROOM_LIMIT } from "../shared/protocol.js"; +import { + MAX_VIEWERS_PER_ROOM_LIMIT, + roomCodeSchema, +} from "../shared/protocol.js"; export type RuntimeEnvironment = "development" | "test" | "production"; @@ -31,6 +34,7 @@ export interface ServerConfig { maxRooms: number; maxViewersPerRoom: number; peerAssistedMedia: boolean; + peerAssistedRoomIds?: ReadonlySet; livekitFallback?: LiveKitFallbackConfig; stunUrls: readonly string[]; turnUrls: readonly string[]; @@ -199,6 +203,32 @@ function parseOrigins(value: string | undefined, fallback: string): Set return new Set((origins.length > 0 ? origins : [fallback]).map(toOrigin)); } +function parsePeerAssistedRoomIds( + value: string | undefined, +): ReadonlySet | undefined { + if (value === undefined || value.trim() === "") { + return undefined; + } + + const roomIds = new Set(); + for (const entry of value.split(",")) { + const roomId = entry.trim(); + if (!roomId) { + throw new Error("PEER_ASSISTED_ROOM_IDS contains an empty room ID"); + } + if (!roomCodeSchema.safeParse(roomId).success) { + throw new Error( + "PEER_ASSISTED_ROOM_IDS must contain comma-separated valid room IDs", + ); + } + if (roomIds.has(roomId)) { + throw new Error(`PEER_ASSISTED_ROOM_IDS contains duplicate room ID ${roomId}`); + } + roomIds.add(roomId); + } + return roomIds; +} + function parseIceEndpoint(value: string): IceEndpoint | undefined { const schemeSeparator = value.indexOf(":"); if (schemeSeparator <= 0) { @@ -357,8 +387,16 @@ export function loadConfig( false, "PEER_ASSISTED_MEDIA", ); + const peerAssistedRoomIds = parsePeerAssistedRoomIds( + environment.PEER_ASSISTED_ROOM_IDS, + ); const livekitFallback = parseLiveKitFallback(environment, nodeEnv); + if (peerAssistedRoomIds && !peerAssistedMedia) { + throw new Error( + "PEER_ASSISTED_ROOM_IDS requires PEER_ASSISTED_MEDIA=true", + ); + } if (livekitFallback && !peerAssistedMedia) { throw new Error("LiveKit fallback requires PEER_ASSISTED_MEDIA=true"); } @@ -432,6 +470,7 @@ export function loadConfig( maxRooms: parsePositiveInteger(environment.MAX_ROOMS, 1_000, "MAX_ROOMS"), maxViewersPerRoom, peerAssistedMedia, + peerAssistedRoomIds, livekitFallback, stunUrls, turnUrls, diff --git a/src/server/signaling.ts b/src/server/signaling.ts index 7887e08..41edf99 100644 --- a/src/server/signaling.ts +++ b/src/server/signaling.ts @@ -45,6 +45,7 @@ export interface SignalingOptions { server: HttpServer; roomStore: RoomStore; peerAssistedMedia: boolean; + peerAssistedRoomIds?: ReadonlySet; sfuFallback?: SfuFallbackOptions; ice: IceConfigOptions; allowedOrigins: ReadonlySet; @@ -103,6 +104,13 @@ export class SignalingServer { if (options.sfuFallback && !options.peerAssistedMedia) { throw new Error("SFU fallback requires peer-assisted media"); } + if ( + options.peerAssistedRoomIds && + options.peerAssistedRoomIds.size > 0 && + !options.peerAssistedMedia + ) { + throw new Error("Peer-assisted room IDs require peer-assisted media"); + } if (options.peerAssistedMedia) { this.hybridMediaRouter = new HybridMediaRouter({ roomStore: options.roomStore, @@ -340,7 +348,9 @@ export class SignalingServer { }; let hybridState; try { - hybridState = this.hybridMediaRouter?.connectParticipant(routeParticipant); + hybridState = this.isPeerAssistedRoom(participant.roomId) + ? this.hybridMediaRouter!.connectParticipant(routeParticipant) + : undefined; } catch { console.error("Peer relay topology did not assign an authenticated participant"); this.sendError(socket, "SERVER_ERROR", "Media assignment failed"); @@ -410,14 +420,14 @@ export class SignalingServer { if (participant.role === "host") { for (const viewer of connectedViewers) { this.sendToSession(viewer.sessionId, { type: "host-status", online: true }); - if (!this.options.peerAssistedMedia) { + if (!this.isPeerAssistedRoom(participant.roomId)) { this.send(socket, { type: "peer-joined", peerId: viewer.peerId }); } } return; } - if (this.options.peerAssistedMedia) { + if (this.isPeerAssistedRoom(participant.roomId)) { return; } @@ -448,7 +458,7 @@ export class SignalingServer { this.sendError(socket, "FORBIDDEN", "Only viewers may request an ICE restart"); return; } - if (this.options.peerAssistedMedia) { + if (this.isPeerAssistedRoom(authenticated.roomId)) { this.routePeerAssistedRestart(socket, authenticated, message); return; } @@ -474,7 +484,10 @@ export class SignalingServer { }); return; case "set-quality-settings": - if (!this.options.peerAssistedMedia || authenticated.role !== "host") { + if ( + !this.isPeerAssistedRoom(authenticated.roomId) || + authenticated.role !== "host" + ) { this.sendError( socket, "FORBIDDEN", @@ -497,7 +510,7 @@ export class SignalingServer { return; case "relay-capacity": if ( - !this.options.peerAssistedMedia || + !this.isPeerAssistedRoom(authenticated.roomId) || authenticated.role !== "viewer" ) { this.sendError( @@ -516,7 +529,7 @@ export class SignalingServer { ); return; case "route-ready": - if (!this.options.peerAssistedMedia) { + if (!this.isPeerAssistedRoom(authenticated.roomId)) { this.sendError(socket, "FORBIDDEN", "Media routes are not enabled"); return; } @@ -529,7 +542,7 @@ export class SignalingServer { ); return; case "route-failed": - if (!this.options.peerAssistedMedia) { + if (!this.isPeerAssistedRoom(authenticated.roomId)) { this.sendError(socket, "FORBIDDEN", "Media routes are not enabled"); return; } @@ -542,7 +555,10 @@ export class SignalingServer { ); return; case "refresh-sfu": - if (!this.options.sfuFallback) { + if ( + !this.options.sfuFallback || + !this.isPeerAssistedRoom(authenticated.roomId) + ) { this.sendError(socket, "FORBIDDEN", "SFU fallback is not enabled"); return; } @@ -602,7 +618,7 @@ export class SignalingServer { source: AuthenticatedSession, message: Extract, ): void { - if (this.options.peerAssistedMedia) { + if (this.isPeerAssistedRoom(source.roomId)) { this.routePeerAssistedSignal(sourceSocket, source, message); return; } @@ -782,7 +798,9 @@ export class SignalingServer { if (!disconnected) { return; } - this.hybridMediaRouter?.disconnectParticipant(disconnected.roomId); + if (this.isPeerAssistedRoom(disconnected.roomId)) { + this.hybridMediaRouter!.disconnectParticipant(disconnected.roomId); + } if (disconnected.role === "host") { for (const viewer of this.options.roomStore.getConnectedViewers( disconnected.roomId, @@ -804,8 +822,8 @@ export class SignalingServer { this.connectionIdsByViewer.delete( viewerConnectionKey(disconnected.roomId, disconnected.peerId), ); - if (this.hybridMediaRouter) { - this.hybridMediaRouter.removeViewer( + if (this.isPeerAssistedRoom(disconnected.roomId)) { + this.hybridMediaRouter!.removeViewer( disconnected.roomId, disconnected.peerId, ); @@ -826,7 +844,9 @@ export class SignalingServer { private stopSharing(roomId: string): void { this.clearRoomConnectionIds(roomId); - this.hybridMediaRouter?.stopRoom(roomId); + if (this.isPeerAssistedRoom(roomId)) { + this.hybridMediaRouter!.stopRoom(roomId); + } for (const viewer of this.options.roomStore.getConnectedViewers(roomId)) { this.sendToSession(viewer.sessionId, { type: "sharing-stopped" }); this.sendToSession(viewer.sessionId, { type: "host-status", online: false }); @@ -840,7 +860,9 @@ export class SignalingServer { } this.clearRoomGraceTimers(roomId); this.clearRoomConnectionIds(roomId); - this.hybridMediaRouter?.deleteRoom(roomId); + if (this.isPeerAssistedRoom(roomId)) { + this.hybridMediaRouter!.deleteRoom(roomId); + } this.qualitySettingsByRoom.delete(roomId); this.shareGenerationsByRoom.delete(roomId); for (const sessionId of abandoned.sessionIds) { @@ -857,7 +879,9 @@ export class SignalingServer { for (const expired of this.options.roomStore.expireRooms(this.now())) { this.clearRoomGraceTimers(expired.roomId); this.clearRoomConnectionIds(expired.roomId); - this.hybridMediaRouter?.deleteRoom(expired.roomId); + if (this.isPeerAssistedRoom(expired.roomId)) { + this.hybridMediaRouter!.deleteRoom(expired.roomId); + } this.qualitySettingsByRoom.delete(expired.roomId); this.shareGenerationsByRoom.delete(expired.roomId); for (const sessionId of expired.sessionIds) { @@ -909,6 +933,14 @@ export class SignalingServer { } } + private isPeerAssistedRoom(roomId: string): boolean { + if (!this.hybridMediaRouter) { + return false; + } + const allowlist = this.options.peerAssistedRoomIds; + return !allowlist || allowlist.size === 0 || allowlist.has(roomId); + } + private hasConnectionCapacity(): boolean { return ( !this.closing && diff --git a/tests/server-config.test.ts b/tests/server-config.test.ts index ee8a0f0..39a9ca1 100644 --- a/tests/server-config.test.ts +++ b/tests/server-config.test.ts @@ -15,6 +15,7 @@ describe("server configuration", () => { expect(config.turnUrls).toEqual([]); expect(config.maxViewersPerRoom).toBe(8); expect(config.peerAssistedMedia).toBe(false); + expect(config.peerAssistedRoomIds).toBeUndefined(); expect(config.livekitFallback).toBeUndefined(); }); @@ -196,6 +197,46 @@ describe("server configuration", () => { ); }); + it("parses an optional strict peer-assisted room allowlist", () => { + expect( + loadConfig({ + PEER_ASSISTED_MEDIA: "true", + PEER_ASSISTED_ROOM_IDS: " 1,123456789012 ", + }).peerAssistedRoomIds, + ).toEqual(new Set(["1", "123456789012"])); + expect( + loadConfig({ + PEER_ASSISTED_MEDIA: "true", + PEER_ASSISTED_ROOM_IDS: " ", + }).peerAssistedRoomIds, + ).toBeUndefined(); + }); + + it("requires peer-assisted media for a non-empty room allowlist", () => { + expect(() => + loadConfig({ + PEER_ASSISTED_MEDIA: "false", + PEER_ASSISTED_ROOM_IDS: "1", + }), + ).toThrow("PEER_ASSISTED_ROOM_IDS requires PEER_ASSISTED_MEDIA=true"); + }); + + it.each([ + "0", + "01", + "1234567890123", + "room-1", + "1,,2", + "1,1", + ])("rejects an invalid peer-assisted room allowlist: %s", (roomIds) => { + expect(() => + loadConfig({ + PEER_ASSISTED_MEDIA: "true", + PEER_ASSISTED_ROOM_IDS: roomIds, + }), + ).toThrow("PEER_ASSISTED_ROOM_IDS"); + }); + it("allows an explicit loopback listen host", () => { const config = loadConfig({ LISTEN_HOST: " 127.0.0.1 " }); diff --git a/tests/server-signal.test.ts b/tests/server-signal.test.ts index bfc808f..64d8313 100644 --- a/tests/server-signal.test.ts +++ b/tests/server-signal.test.ts @@ -160,6 +160,7 @@ async function startHarness( accessPassword?: string; persistent?: boolean; peerAssistedMedia?: boolean; + peerAssistedPrimaryRoomOnly?: boolean; } = {}, ): Promise { const config = testConfig(); @@ -174,6 +175,9 @@ async function startHarness( database: overrides.persistent ? new RoomDatabase(":memory:") : undefined, }); const room = roomStore.createRoom(); + if (overrides.peerAssistedPrimaryRoomOnly) { + config.peerAssistedRoomIds = new Set([room.roomId]); + } runningServer = await createScreenerServer({ config, roomStore, @@ -200,6 +204,7 @@ async function startSfuHarness(options: { prepareTimeoutMs?: number; maxRoots?: number; viewerDisconnectGraceMs?: number; + peerAssistedPrimaryRoomOnly?: boolean; }): Promise { const roomStore = new RoomStore({ ttlMs: 14_400_000, @@ -215,6 +220,9 @@ async function startSfuHarness(options: { server: httpServer, roomStore, peerAssistedMedia: true, + ...(options.peerAssistedPrimaryRoomOnly + ? { peerAssistedRoomIds: new Set([room.roomId]) } + : {}), sfuFallback: { url: "wss://sfu.example.test", tokenIssuer: options.tokenIssuer, @@ -870,6 +878,247 @@ describe("WebSocket signaling", () => { await viewer.inbox.expectNone(30); }); + it("isolates allowlisted hybrid and legacy rooms in one process", async () => { + const harness = await startSfuHarness({ + tokenIssuer: { issueToken: async () => "unused-test-token" }, + peerAssistedPrimaryRoomOnly: true, + }); + const legacyRoom = harness.roomStore.createRoom(); + + const hybridHost = await openClient(harness.webSocketUrl); + const hybridHostAuth = peerAssisted( + await authenticate( + hybridHost, + harness.room, + "host", + "allowlisted-host", + ), + ); + expect(hybridHostAuth.sfuStandbyUrl).toBe("wss://sfu.example.test"); + + const legacyHost = await openClient(harness.webSocketUrl); + const legacyHostAuth = await authenticate( + legacyHost, + legacyRoom, + "host", + "legacy-host", + ); + expect(Object.keys(legacyHostAuth).sort()).toEqual( + [ + "connectionId", + "hostOnline", + "iceConfig", + "maxViewers", + "peerId", + "role", + "roomExpiresAt", + "type", + "viewerPeerIds", + ].sort(), + ); + + const legacyViewer = await openClient(harness.webSocketUrl); + const legacyViewerAuth = await authenticate( + legacyViewer, + legacyRoom, + "viewer", + "legacy-viewer", + ); + expect(Object.keys(legacyViewerAuth).sort()).toEqual( + Object.keys(legacyHostAuth).sort(), + ); + expect(await legacyHost.inbox.next("peer-joined")).toEqual({ + type: "peer-joined", + peerId: legacyViewerAuth.peerId, + }); + + legacyHost.socket.send( + JSON.stringify({ + type: "signal", + targetPeerId: legacyViewerAuth.peerId, + payload: { + kind: "description", + connectionId: "legacy-room-connection", + description: { type: "offer", sdp: "v=0\r\n" }, + }, + }), + ); + expect(await legacyViewer.inbox.next("signal")).toMatchObject({ + fromPeerId: legacyHostAuth.peerId, + payload: { connectionId: "legacy-room-connection" }, + }); + legacyViewer.socket.send( + JSON.stringify({ + type: "signal", + payload: { + kind: "description", + connectionId: "legacy-room-connection", + description: { type: "answer", sdp: "v=0\r\n" }, + }, + }), + ); + expect(await legacyHost.inbox.next("signal")).toMatchObject({ + fromPeerId: legacyViewerAuth.peerId, + payload: { connectionId: "legacy-room-connection" }, + }); + + legacyHost.socket.send( + JSON.stringify({ + type: "set-quality-settings", + qualitySettings: balancedQualitySettings, + }), + ); + expect((await legacyHost.inbox.next("error")).code).toBe("FORBIDDEN"); + legacyViewer.socket.send( + JSON.stringify({ type: "relay-capacity", downstreamEdges: 1 }), + ); + expect((await legacyViewer.inbox.next("error")).code).toBe("FORBIDDEN"); + legacyViewer.socket.send( + JSON.stringify({ type: "refresh-sfu", revision: 0 }), + ); + expect((await legacyViewer.inbox.next("error")).code).toBe("FORBIDDEN"); + + await closeClient(legacyViewer); + expect(await legacyHost.inbox.next("peer-left", 500)).toEqual({ + type: "peer-left", + peerId: legacyViewerAuth.peerId, + }); + }); + + it("keeps allowlisted and legacy room lifecycles independent", async () => { + const harness = await startHarness({ + peerAssistedMedia: true, + peerAssistedPrimaryRoomOnly: true, + viewerDisconnectGraceMs: 500, + }); + const legacyRoom = harness.roomStore.createRoom(); + + const hybridHost = await openClient(harness.webSocketUrl); + const hybridHostAuth = peerAssisted( + await authenticate(hybridHost, harness.room, "host", "lifecycle-hybrid-host"), + ); + const hybridViewer = await openClient(harness.webSocketUrl); + const hybridViewerAuth = peerAssisted( + await authenticate( + hybridViewer, + harness.room, + "viewer", + "lifecycle-hybrid-viewer", + ), + ); + await hybridHost.inbox.next("media-assignment"); + hybridHost.socket.send( + JSON.stringify({ + type: "set-quality-settings", + qualitySettings: lowQualitySettings, + }), + ); + await hybridViewer.inbox.next("quality-settings"); + + const legacyHost = await openClient(harness.webSocketUrl); + await authenticate(legacyHost, legacyRoom, "host", "lifecycle-legacy-host"); + const legacyViewer = await openClient(harness.webSocketUrl); + const legacyViewerAuth = await authenticate( + legacyViewer, + legacyRoom, + "viewer", + "lifecycle-legacy-viewer", + ); + await legacyHost.inbox.next("peer-joined"); + + const hybridHostClosed = new Promise((resolve) => + hybridHost.socket.once("close", (code) => resolve(code)), + ); + const legacyHostClosed = new Promise((resolve) => + legacyHost.socket.once("close", (code) => resolve(code)), + ); + hybridHost.socket.send(JSON.stringify({ type: "stop-sharing" })); + legacyHost.socket.send(JSON.stringify({ type: "stop-sharing" })); + await hybridViewer.inbox.next("sharing-stopped"); + await hybridViewer.inbox.next("host-status"); + await legacyViewer.inbox.next("sharing-stopped"); + await legacyViewer.inbox.next("host-status"); + expect(await hybridHostClosed).toBe(1000); + expect(await legacyHostClosed).toBe(1000); + + await closeClient(hybridViewer); + await closeClient(legacyViewer); + const resumedHybridViewer = await openClient(harness.webSocketUrl); + const resumedHybridViewerAuth = peerAssisted( + await authenticate( + resumedHybridViewer, + harness.room, + "viewer", + "lifecycle-hybrid-viewer", + ), + ); + expect(resumedHybridViewerAuth).toMatchObject({ + peerId: hybridViewerAuth.peerId, + connectionId: null, + qualitySettings: lowQualitySettings, + }); + const resumedLegacyViewer = await openClient(harness.webSocketUrl); + const resumedLegacyViewerAuth = await authenticate( + resumedLegacyViewer, + legacyRoom, + "viewer", + "lifecycle-legacy-viewer", + ); + expect(resumedLegacyViewerAuth.peerId).toBe(legacyViewerAuth.peerId); + expect(resumedLegacyViewerAuth.connectionId).toBeNull(); + expect("mediaMode" in resumedLegacyViewerAuth).toBe(false); + expect("qualitySettings" in resumedLegacyViewerAuth).toBe(false); + + const resumedHybridHost = await openClient(harness.webSocketUrl); + const resumedHybridHostAuth = peerAssisted( + await authenticate( + resumedHybridHost, + harness.room, + "host", + "lifecycle-hybrid-host", + ), + ); + expect(resumedHybridHostAuth).toMatchObject({ + peerId: hybridHostAuth.peerId, + qualitySettings: lowQualitySettings, + }); + const resumedLegacyHost = await openClient(harness.webSocketUrl); + const resumedLegacyHostAuth = await authenticate( + resumedLegacyHost, + legacyRoom, + "host", + "lifecycle-legacy-host", + ); + expect(await resumedLegacyHost.inbox.next("peer-joined")).toMatchObject({ + peerId: resumedLegacyViewerAuth.peerId, + }); + + resumedHybridHost.socket.send(JSON.stringify({ type: "abandon-room" })); + await resumedHybridHost.inbox.next("room-closed"); + await resumedHybridViewer.inbox.next("room-closed"); + expect(harness.roomStore.size).toBe(1); + + resumedLegacyHost.socket.send( + JSON.stringify({ + type: "signal", + targetPeerId: resumedLegacyViewerAuth.peerId, + payload: { + kind: "description", + connectionId: "legacy-after-hybrid-delete", + description: { type: "offer", sdp: "v=0\r\n" }, + }, + }), + ); + expect(await resumedLegacyViewer.inbox.next("signal")).toMatchObject({ + fromPeerId: resumedLegacyHostAuth.peerId, + payload: { connectionId: "legacy-after-hybrid-delete" }, + }); + resumedLegacyHost.socket.send(JSON.stringify({ type: "abandon-room" })); + await resumedLegacyHost.inbox.next("room-closed"); + await resumedLegacyViewer.inbox.next("room-closed"); + expect(harness.roomStore.size).toBe(0); + }); + it("routes offer and answer only between the host and the targeted viewer", async () => { const harness = await startHarness(); const secondRoom = harness.roomStore.createRoom();