refactor(media): keep routing automatic [skip ci]
This commit is contained in:
1 parent
b874e12af6
commit
5810ec2201
15 files changed
+30
-243
No files matched your search
@@ -29,7 +29,7 @@ Use separate control and media planes:
|
||||
- A small HTTPS/WSS service owns identity, rooms, invitations, presence, and WebRTC signaling.
|
||||
- A viewer can join through a numeric room code on desktop or mobile without installing the sharing client. The viewer link has no separate token or fragment; the host publication token remains internal. A deployment may place one site-wide password gate in front of both hosting and viewing; access policy remains control-plane state and does not change the media topology. ADR-0002 owns the current room-ID and lifetime policy.
|
||||
- ICE attempts a direct UDP path for every current broadcaster-viewer edge, using STUN to discover candidates. A later accepted peer-assisted topology must apply the same rule independently to each assigned parent-child edge.
|
||||
- Ordinary peer connections use STUN-only ICE. After direct/peer UDP and the bounded SFU/UDP virtual-parent path fail, ADR-0005 permits an optional authenticated TURN attempt for only the controller-selected exceptional edge. TURN/TCP or TURN/TLS remains a separately gated restrictive-network enhancement.
|
||||
- Ordinary peer connections use STUN-only ICE. After direct/peer UDP and the bounded SFU/UDP virtual-parent path fail, ADR-0005 permits one optional authenticated TURN/UDP attempt for only the controller-selected exceptional edge, followed by bounded failure.
|
||||
- Candidate and route selection is independent per edge. A room may simultaneously contain direct, SFU-root, and one selected relayed edge without moving healthy peers onto the server.
|
||||
- The deployed MVP broadcaster creates one peer connection per viewer. Rooms default to eight viewers and deployments may configure a limit from 1 through 16. Eight is an admission default, not a validated media-performance promise. A newer product target caps host media fanout at two; the current implementation does not satisfy that target above two viewers, and Proposed ADR-0004 owns the isolated experiment rather than silently changing this accepted baseline.
|
||||
- An SFU is not the default whole-room path. The automatic controller is enabled for all configured normal rooms; local SFU/UDP media and active Host-ingress selected TURN/UDP function pass, while public-room and performance evidence remain open. ADR-0005 owns the bounded-root target.
|
||||
@@ -51,7 +51,6 @@ Negative:
|
||||
- If the site-wide password is disabled, the numeric room code is the sole viewing capability and does not provide a strong privacy guarantee.
|
||||
- A room-level P2P-to-SFU migration adds state, keyframe, and reconnection complexity and is not part of the first prototype. This implementation boundary does not remove the product requirement for automatic, viewer-transparent fallback.
|
||||
- TURN over TCP can suffer head-of-line blocking, and its `turn:` client-to-server transport is not TLS-wrapped. The WebRTC media remains protected by DTLS-SRTP independently of that TURN transport.
|
||||
- Optional TURN/TLS on port 5349 or 443 can improve compatibility but cannot guarantee success through every authenticated proxy or policy-controlled network. A normal Cloudflare HTTP proxy does not proxy TURN; using Cloudflare for TURN requires a compatible layer-4 product such as Spectrum.
|
||||
|
||||
## Rejected For The MVP
|
||||
|
||||
|
||||
@@ -466,8 +466,7 @@ These items are not current runtime behavior:
|
||||
- A room in which the Host and every possible root are restricted may require
|
||||
several server-fed exceptional edges. Any such extension requires a per-room
|
||||
selected-relay and central-egress admission cap; it must wait or fail at that
|
||||
cap rather than become unbounded server fanout. TURN/TCP and TURN/TLS remain
|
||||
separate transport decisions.
|
||||
cap rather than become unbounded server fanout.
|
||||
- ICE restart and connection rebuild already recover failed edges after Wi-Fi,
|
||||
cellular, or similar network changes. A healthy fallback path remains sticky:
|
||||
the current controller does not proactively move it back when a new Viewer
|
||||
@@ -558,8 +557,8 @@ boundary is actually configured and the UI must not claim E2EE.
|
||||
controller-selected exceptional edge may receive a short-lived TURN grant.
|
||||
- LiveKit/SFU UDP must pass CGNAT, double-NAT, hotspot, home-network, loss,
|
||||
rollback, and SFU-unavailable gates. Blocked UDP fails clearly within a
|
||||
bounded window even when TURN/UDP is enabled. Media TCP remains a separate
|
||||
decision and TURN availability never becomes a quality claim.
|
||||
bounded window even when TURN/UDP is enabled; media does not fall back to TCP.
|
||||
TURN availability never becomes a quality claim.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ Last updated: 2026-08-21
|
||||
- The repository is one npm package using Node.js 24, React, TypeScript, Vite, native WebRTC, `ws`, Zod, Vitest, and separate coturn.
|
||||
- Production is exact `691863e1720ebbee1b5368f29e94f05b3710ccf8`; artifact 605,904 bytes/SHA-256 `7e751c0bf7c97bc7d23edb1e37441fb6c55d90a0cd80647b778fdb5b1ee28bae`; cutover 3,376.021 ms lock/669.678 ms stop-health/604.174 ms switch-health; rollbacks `6634cb9`, `ecc794d`, `16f6eab`.
|
||||
- Web keeps one SFU stream, clears unavailable video, shows authoritative pending/P2P/SFU and actual-only TURN, labels paused preview, and drops signals to a grace-retained offline current-edge target. Shared entry/nickname UI is live; Native remains source-only.
|
||||
- Per-share P2P-only debug is generation-bound and blocks SFU/selected TURN. Web relay is cap2 with one upstream; child3 rejects and no UA/visibility split exists.
|
||||
- Web relay is cap2 with one upstream; child3 rejects and no UA/visibility split exists.
|
||||
- Four services are active/running at `NRestarts=0`; local/public health are 200. SQLite v3 has five rooms, SHA-256 `aef724ae52c4107be8ec8791e72f8dcaa11b0ec849fb432d022e2cfb9cfe0974`, owner/mode `screener:screener`/0600.
|
||||
- Production keeps ordinary ICE STUN-only, SFU roots <=2, selected-edge UDP TTL 120, and one pending/answered `peer-selected` attempt per room excluding Host ingress. This rollout ran no browser/media/SFU/TURN/performance canary; earlier exact-`16f6eab` evidence proves active Host ingress only.
|
||||
- Production requires the independent site-access secret through `SITE_ACCESS_PASSWORD`. Its stateless cookie authorizes creation/Host and permits code-only Viewer attempts; valid private fragment grants remain direct. Public-watch accepts site access plus code, while private code-only entry additionally requires the room password. The env, endpoint, cookie, and nginx limiter naming migrated atomically; anonymous failures stay neutral, and there are no accounts/JWT/session rows.
|
||||
|
||||
@@ -125,8 +125,8 @@ Before production enablement, one isolated canary must prove, in order:
|
||||
7. Android Chrome and iOS Safari complete the representative home, hotspot, and
|
||||
carrier matrix before broader rollout.
|
||||
|
||||
TURN/TCP or TURN/TLS is a separate media-transport decision. HTTPS/WSS remains
|
||||
TLS/TCP and does not satisfy these media gates.
|
||||
The media ladder remains UDP-only. HTTPS/WSS remains TLS/TCP and does not
|
||||
satisfy these media gates.
|
||||
|
||||
## Sources
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ network changes, but their selected ICE path must be measured.
|
||||
| Endpoint-independent mapping with UDP | Full ICE can check host, server-reflexive, and peer-reflexive paths | Keep direct/peer UDP first |
|
||||
| One endpoint-dependent mapper | Coordinated checks can sometimes create a peer-reflexive path; success is not guaranteed | Exhaust the existing bounded restart/rebuild/alternate-parent steps |
|
||||
| Both peer endpoints use endpoint-dependent mapping | A direct peer path is not reliable; RFC 8835 requires TURN support for this case | A public SFU/UDP connection can still feed one or two roots when outbound UDP works; an eligible edge may then use selected TURN/UDP |
|
||||
| All outbound UDP is blocked | Direct ICE/TCP can help only when its TCP candidate is reachable; TURN/TCP or TURN/TLS gives each browser an outbound connection to a public relay | Current tracked LiveKit and selected-edge examples deliberately disable these transports, so the present result is bounded failure |
|
||||
| All outbound UDP is blocked | The UDP media ladder has no reachable candidate | End with a clear bounded failure |
|
||||
| Wi-Fi/cellular or address change | Old mappings and candidate pairs can become invalid | Use a new opaque generation and bounded ICE restart/rebuild, then re-run the same priority ladder |
|
||||
|
||||
Peer-reflexive discovery records an address only after a connectivity check
|
||||
@@ -175,24 +175,10 @@ serve one or two roots, and those roots may still serve peer descendants. If
|
||||
every endpoint needs a server path, the existing root, exceptional-viewer, and
|
||||
egress caps must bound it.
|
||||
|
||||
ICE/TCP, TURN/TCP, and TURN/TLS solve different failures. ICE/TCP attempts a
|
||||
direct TCP candidate pair and inherits TCP simultaneous-open, NAT, firewall, and
|
||||
browser limitations. TURN/TCP or TURN/TLS instead carries the client-to-relay
|
||||
leg over outbound TCP; the relay-to-peer leg may still be UDP. TCP avoids a UDP
|
||||
block but adds head-of-line blocking and can amplify latency during packet loss.
|
||||
"UDP/TCP hybrid" means selection or fallback between candidate pairs, not
|
||||
striping or duplicating one media flow across both.
|
||||
|
||||
The next two browser-first candidates worth isolated measurement are:
|
||||
The remaining browser-first reachability candidate worth isolated measurement is:
|
||||
|
||||
1. Add dual-stack reachability for Web, STUN, SFU, and TURN. A public IPv6 pair
|
||||
avoids IPv4 NAT, while ICE still handles IPv6 firewalls and IPv4 fallback.
|
||||
2. Compare pinned LiveKit 1.13.5 SFU ICE/TCP with a separately authenticated
|
||||
TURN/TLS selected-edge path. The pinned server exposes `rtc.tcp_port`,
|
||||
`allow_tcp_fallback`, and TURN/TLS; the tracked example sets `tcp_port: 0`,
|
||||
`allow_tcp_fallback: false`, and no LiveKit TURN. TURN/TLS on 443 is an L4
|
||||
TLS service with certificate and port constraints, not HTTPS.
|
||||
|
||||
The acceptance matrix is EIM/EIM, one endpoint-dependent mapper, two endpoint-
|
||||
dependent mappers including cellular-to-cellular, all UDP blocked, and a Wi-Fi
|
||||
to-cellular change. Record only sanitized selected transport, generation, time
|
||||
@@ -320,11 +306,9 @@ later selected-edge functional evidence above.
|
||||
|
||||
No public port is selected by this decision. LiveKit documents ICE/UDP mux as
|
||||
optional and its pinned sample recommends a multi-port UDP mux range at least
|
||||
as wide as the CPU count for performance. Embedded TURN/UDP defaults to 3478
|
||||
and recommends 443 only when it does not conflict with HTTP/3/QUIC; TURN/TLS
|
||||
has different certificate and 443 constraints. The current nginx template has
|
||||
no HTTP/3 listener, but that fact alone does not prove one UDP port or UDP 443
|
||||
is the best production layout.
|
||||
as wide as the CPU count for performance. Embedded TURN/UDP defaults to 3478.
|
||||
The current nginx template has no HTTP/3 listener, but that fact alone does not
|
||||
prove one UDP port is the best production layout.
|
||||
|
||||
For one equal representation of measured bitrate `B` and `R` roots:
|
||||
|
||||
@@ -477,7 +461,6 @@ Primary sources checked on 2026-08-19 and 2026-08-21:
|
||||
under IETF Trust terms.
|
||||
- [UDP NAT behavior, RFC 4787](https://www.rfc-editor.org/rfc/rfc4787.html),
|
||||
[P2P across NATs, RFC 5128](https://www.rfc-editor.org/rfc/rfc5128.html),
|
||||
[ICE-TCP, RFC 6544](https://www.rfc-editor.org/rfc/rfc6544.html),
|
||||
[PCP, RFC 6887](https://www.rfc-editor.org/rfc/rfc6887.html), and
|
||||
[CGN requirements, RFC 6888](https://www.rfc-editor.org/rfc/rfc6888.html) -
|
||||
mapping, filtering, prediction, simultaneous-open, gateway-control, and CGN
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
- STUN 让客户端发现公网映射并产生 server-reflexive candidate。它不承载媒体,也不能保证穿过所有 NAT。
|
||||
- ICE 测试 host、server-reflexive、peer-reflexive 和 relay candidates,并选择可工作的候选对。Trickle ICE 可以减少建连等待。
|
||||
- TURN 在无法直连时转发完整媒体流,是 NAT 组件中真正产生高带宽成本的部分。
|
||||
- 当前 production release `31bee238bc1e` 只向 ordinary peer 提供 STUN;共享主机保留旧 authenticated coturn relay 配置,但应用不签发凭据且最终审计为零 allocation。ADR-0005 的目标是 direct/peer UDP -> SFU/UDP roots -> optional selected-edge TURN -> bounded failure。每个 PeerConnection 只检查实际配置的候选;媒体 TCP 仍是独立验收项,在丢包时可能产生队头阻塞。
|
||||
- 当前媒体梯级是 direct/peer UDP -> SFU/UDP roots -> optional selected-edge TURN/UDP -> bounded failure。Ordinary peer 只接收 STUN;controller 只为当前异常 edge 的一次重建签发短期 TURN 凭据。每个 `RTCPeerConnection` 只检查实际配置的候选,全部 UDP 路径耗尽后明确失败。
|
||||
|
||||
不存在适用于所有用户的权威“P2P 直连率”。CGNAT、endpoint-dependent mapping、校园/企业防火墙、移动网络、IPv6 和地区运营商都会改变结果。首版必须通过 `getStats()` 统计自己的 `host/srflx/prflx/relay` 比例,而不是引用未经验证的行业百分比。
|
||||
|
||||
@@ -62,17 +62,15 @@
|
||||
|
||||
ICE 是按分享者与每一名观看者的网络组合独立选路,而不是整个房间只做一次连接。一个家庭宽带观看者可能成功 UDP 打洞,另一个处于 CGNAT、对称 NAT、校园网、企业代理或蜂窝网络的观看者却没有可用直连候选。因此同一房间天然可能出现不同结果。
|
||||
|
||||
早期 PoC 曾同时提供 TURN 凭据和多种传输;当前/目标边界如下:
|
||||
当前边界如下:
|
||||
|
||||
1. 将 direct UDP 设为最高优先级,成功者保持零媒体服务器路径。
|
||||
2. 当前生产 ordinary peer ICE 为 STUN-only;UDP peer 失败时优先使用 SFU root,可选 coturn 只由新 selected-edge grant/rebuild 授予异常连接。
|
||||
2. Ordinary peer ICE 为 STUN-only;UDP peer 失败时优先使用 SFU root,可选 coturn 只由 selected-edge grant/rebuild 授予异常连接。
|
||||
3. 通过统计确认最终选中的 candidate pair;LiveKit participant TURN 与 selected-edge coturn 分别记录,不能从应用计时顺序推断路径。
|
||||
4. 网络切换或候选对失效时执行 ICE restart,超时后重建该 peer connection。
|
||||
5. 在 UI 和诊断中区分“直连”“服务器中继”“正在恢复”和明确失败原因。
|
||||
|
||||
这种混合房间里,一名观看者走 TURN 不会迫使其他观看者也中继。`turn:` over TCP 不提供客户端至 TURN 的 TLS 封装,但被中继的 WebRTC 媒体仍由端点间 DTLS-SRTP 加密。可选 TURN/TLS 使用 RFC 7065 标准 TCP 5349;改用 443 能覆盖更多只放行常见端口的网络,但需要独立公网 IP 或经过验证的 L4/SNI 路由,且仍无法穿过所有认证代理、深度检测或管理员策略。
|
||||
|
||||
普通 Cloudflare 橙云代理只代理其 HTTP/HTTPS 管线,不能因为目标端口是 443 就转发 TURN。Cloudflare 官方将任意 TCP/UDP 应用放在 Spectrum 产品边界内;不使用这类 L4 服务时,TURN DNS 记录必须直连源站。网站仍可独立使用 Cloudflare HTTP 代理。
|
||||
这种混合房间里,一名观看者走 TURN/UDP 不会迫使其他观看者也中继;被中继的 WebRTC 媒体仍由端点间 DTLS-SRTP 加密。
|
||||
|
||||
来源:
|
||||
|
||||
@@ -363,7 +361,7 @@ WebRTC 标准没有承诺固定毫秒延迟。工程目标必须带网络条件
|
||||
- `ws.close()` 默认会等待关闭握手,不能用“升级成功后发送 close frame”实现公网连接硬上限。总连接和未鉴权连接容量应在 `handleUpgrade()` 前检查,超限直接返回 HTTP 503 并销毁底层 socket。
|
||||
- Node 的 `IncomingMessage.url` 是未经应用路由解析的 request-target,而 WHATWG `URL` 构造器会拒绝部分输入。upgrade handler 必须捕获解析失败并关闭 socket,不能让未鉴权输入抛到 EventEmitter 顶层。
|
||||
- coturn 使用 `use-auth-secret` 支持的 TURN REST 短期凭据:`base64(HMAC-SHA1(secret, expiry + ":" + subject))`。coturn 不提供 HTTP 凭据接口,必须由已鉴权的应用服务生成。
|
||||
- RFC 7065/5928 将 `turn` + UDP、`turn` + TCP 和 `turns` + TCP 分别映射到客户端至 TURN 的 UDP、TCP 和 TLS 传输,并规定 TURN/TLS 默认端口为 5349。WebRTC 会逐条验证 ICE URL,任一坏项都可能使整个 PeerConnection 配置失败,因此启动预检先按原始 URI 语法 fail closed,再要求生产基线的 TURN/UDP 和 TURN/TCP URL 显式声明小写 `transport`;`turns` 是可选项,可显式使用 5349 或部署者实际提供的端口。这只能验证配置形状,不能替代公网 relay allocation 测试。
|
||||
- WebRTC 会逐条验证 ICE URL,任一坏项都可能使整个 `RTCPeerConnection` 配置失败。启动预检按原始 URI 语法 fail closed,并要求 selected TURN URI 显式声明小写 `transport=udp`;配置形状验证不能替代公网 relay allocation 测试。
|
||||
- Node `server.listen()` 省略 host 时可能监听未指定 IPv6 地址或 `0.0.0.0`。单机反向代理基线应显式绑定 loopback,容器或可信 LAN 才通过配置选择宽绑定;进程级 HTTP 健康检查不应同步探测 TURN 或其他外部网络。
|
||||
- 截至本次复核,coturn 应使用 4.17.2 或更新补丁版本;4.17.2 修复了此前补丁版本的 UDP TTL 回归。
|
||||
- MiroTalk BRO 当前 P2P 模式仍是 broadcaster 对每位 viewer 建独立连接;Screego 也采用独立 session 和 HMAC TURN 凭据。这验证了拓扑,但两者的静态/长时凭据与轻量恢复策略不直接照搬。
|
||||
@@ -378,10 +376,6 @@ WebRTC 标准没有承诺固定毫秒延迟。工程目标必须带网络条件
|
||||
- [coturn 4.17.2 release](https://github.com/coturn/coturn/releases/tag/4.17.2)
|
||||
- [coturn turnserver documentation](https://github.com/coturn/coturn/blob/master/README.turnserver)
|
||||
- [coturn example configuration](https://github.com/coturn/coturn/blob/master/examples/etc/turnserver.conf)
|
||||
- [TURN URI scheme RFC 7065](https://www.rfc-editor.org/rfc/rfc7065.html)
|
||||
- [TURN TCP/TLS allocations RFC 5928](https://www.rfc-editor.org/rfc/rfc5928.html)
|
||||
- [Cloudflare proxied network ports](https://developers.cloudflare.com/fundamentals/reference/network-ports/)
|
||||
- [Cloudflare Spectrum configuration](https://developers.cloudflare.com/spectrum/reference/configuration-options/)
|
||||
- [Node.js 24 `server.listen`](https://nodejs.org/docs/latest-v24.x/api/net.html#serverlistenport-host-backlog-callback)
|
||||
|
||||
## 实施难度与预估
|
||||
|
||||
+1
-2
@@ -7,7 +7,7 @@ Last updated: 2026-08-21
|
||||
Production at `https://share.bonfire.icu` is exact `691863e1720ebbee1b5368f29e94f05b3710ccf8`; artifact 605,904 bytes, SHA-256 `7e751c0bf7c97bc7d23edb1e37441fb6c55d90a0cd80647b778fdb5b1ee28bae`.
|
||||
Cutover: 3,376.021 ms lock/669.678 ms stop-health/604.174 ms switch-health. SQLite v3/five rooms has SHA-256 `aef724ae52c4107be8ec8791e72f8dcaa11b0ec849fb432d022e2cfb9cfe0974`, owner/mode `screener:screener`/0600. Four services are active/running at `NRestarts=0`; rollbacks are `6634cb9`, then `ecc794d` and `16f6eab`.
|
||||
Ordinary ICE is STUN-only; limits are SFU roots <=2, one `peer-selected` attempt excluding Host ingress, and selected UDP TTL 120. This rollout ran no browser/media/SFU/TURN/performance canary; earlier exact-`16f6eab` Host-ingress proof remains separate.
|
||||
Stable SFU stream/unavailable state, authoritative pending/P2P/SFU and actual-only TURN are deployed with Host preview notice, entry/nickname UI, P2P-only debug and Web relay cap2. Native is source-only; Web defaults VP8.
|
||||
Stable SFU stream/unavailable state, authoritative pending/P2P/SFU and actual-only TURN are deployed with Host preview notice, entry/nickname UI and Web relay cap2. Native is source-only; Web defaults VP8.
|
||||
|
||||
## Execution Principle
|
||||
|
||||
@@ -20,7 +20,6 @@ Flagship; parallel; min run/smoke/rollback; benchmark later. Active UI/config/lo
|
||||
- Production UI uses adjacent nickname editing, distinct site/room prompts, and a shared room-code form. Exact upstream may label green P2P/yellow `SFU fallback` before media proof; connected status still needs evidence and TURN stays actual-only.
|
||||
- Native v2 is source-only (memory rooms, 300s reclaim); it follows authenticated direct-child assignments up to two and fails unsupported SFU/selected ingress boundedly.
|
||||
- Production is roots <=2/Web relay <=2; every Web Viewer keeps one upstream and bounded sticky recovery, while child3 is rejected. Room `1` is historical; HTTPS/WSS is TLS/TCP.
|
||||
- Host advanced settings offer generation-bound P2P-only debug before sharing; it disables SFU/selected TURN and fails clearly after peer recovery.
|
||||
- After an answer, a generation-bound 15s initial-connect deadline enters ICE restart; success/replacement/disposal cancels it. It is deployed but not mobile-verified.
|
||||
- Room `1` C+B reparenting remains uncalibrated. Deployed admission rescue promotes an unassigned relay over the oldest childless zero-capacity Host leaf, with no scores or periodic optimization.
|
||||
|
||||
|
||||
+1
-3
@@ -124,8 +124,6 @@ WebSocket 使用无 query/fragment 的同源 `/signal`。upgrade 只记录 site-
|
||||
|
||||
Zod 在服务边界拒绝未知类型、超长字符串、非法 SDP/candidate 结构和越权目标。服务端根据已认证会话决定路由:观看者不能给另一名观看者发信令,分享者也不能向本房间以外的 peerId 发送。
|
||||
|
||||
Host 新 share generation 的首次 `authenticate` 可选带 `debugP2pOnly: true`。服务端在 participant 进入 router 前把它绑定为当前进程内的 room/share-generation 状态;Viewer schema 严格拒绝该字段。同 generation 信令重连保持权威状态,不能借重连中途切换;`stop-sharing`、新 generation、room 删除和进程重启清除。新进程可从 Host 不可变认证 identity 重新接受该 generation 的请求,`authenticated.debugP2pOnly` 回显服务端实际状态,避免 UI 与路由状态分叉。启用时 router 仍执行 STUN-only direct、ICE restart/rebuild 与 alternate peer,但不创建 SFU prepare/config/token/publication、不发 standby URL,也不签发 `peer-selected` 或 `host-sfu-ingress` TURN;路径耗尽沿现有 `PEER_NOT_FOUND` 有界失败。它不持久化、不改变默认自动路由,也不充当 NAT 类型判断器;Hard NAT、TCP 与生日探测边界继续以[低服务器成本媒体路由](./research/low-server-media-routes.md)为准。
|
||||
|
||||
该授权迁移把唯一 literal 从 `screener-v1` 原子改为 `screener-v2`,不做版本协商。旧页面的首条消息在任何角色进入 room lookup 前得到同一个 fatal refresh 终态、停止自动重连;服务端不解析 v1、不双写、不维护 translator。缺失、过期、跨房、已 rotate 或 revoked 的 v2 Viewer grant 则使用同一个通用“邀请无效或已失效”结果,不区分房间不存在与凭据错误。
|
||||
|
||||
## WebRTC 协商
|
||||
@@ -330,7 +328,7 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md)
|
||||
- Fragment 只避免其进入 HTTP/WebSocket request-target,不能把 capability 变成非敏感数据。客户端首次读取后立即清理地址栏,只在 room-scoped `sessionStorage` 留存;服务端和代理日志不得记录 WebSocket payload、Authorization、cookie、raw grant 或响应 body,异常只能记录安全路径、状态码和分类。
|
||||
- HTTP body、WebSocket payload、总连接数、未鉴权连接数、输出缓冲、房间总数、观看人数和 candidate 缓存都有硬上限;无法解析的 upgrade request-target 在未鉴权边界返回 HTTP 400,不允许异常逃逸到进程顶层。
|
||||
- 应用日志不得主动记录 IP;反向代理、STUN 和 LiveKit 为提供网络服务必然可见源地址,部署时需设置最短必要留存与访问控制。
|
||||
- tracked `stun-only` 及 `no-tcp`/`no-tls` 配置只是当前回滚/验收前模板,不是最终产品目标。当前共享机的应用 TURN tuple 已关闭且只下发 STUN,daemon/防火墙仍是旧 authenticated-relay baseline(TCP/UDP 3478 与 UDP 49152-49251),没有 credential 下发,最后审计 allocation 为零。Selected-edge 验收必须使用独立 realm/secret、小 relay UDP range 与 allocation/bandwidth quotas,并先验证 STUN、SFU/UDP exhaustion 与单条短期 credential;不启用匿名 TURN 或媒体 TLS/TCP。coturn 4.17.2 已弃用 `no-dtls`,tracked 临时配置不使用它。
|
||||
- tracked `stun-only` 与 `no-tcp`/`no-tls` 是当前 UDP-only 媒体边界。Ordinary peer 只接收 STUN;selected edge 使用独立 realm/secret、小 relay UDP range、allocation/bandwidth quotas 和单条短期 credential,不启用匿名 TURN。coturn 4.17.2 已弃用 `no-dtls`,tracked 配置不使用它。
|
||||
- 分享者直连会向可信观看者暴露网络地址。普通 peer PC 不配置 TURN;只有 controller 选择的异常 edge 可在重建时 relay-only。UI 和后台不得显示 bearer 或把 relay 宣称为匿名或 coturn 侧密码学 edge 隔离。
|
||||
|
||||
## 验证计划
|
||||
|
||||
+4
-6
@@ -26,7 +26,7 @@
|
||||
- [ ] 一对多观看:当前安全默认上限为 8、可配置范围为 1 至 16;自动分布式拓扑验收后,同一 release 将默认值和上限改为 20。当前 PoC 的逐观看者连接是测量基线,不再是超过两名观看者时的目标拓扑;未通过 20 人门禁时继续明确拒绝超额加入。
|
||||
- [ ] 端点 fanout 硬上限:任一非服务器端点任何时刻最多维护两条 active downstream media edges,包括加入、断线、换父和恢复过程;上游接收不计入这项上传预算。普通 Web relay 统一声明最多两个 child,不按 UA、设备类型或页面可见性另建容量策略;实际 edge 失败进入同一有界换父路径。第三名及后续观看者由系统自动分配给通过验收的 peer-assisted 拓扑,不能隐藏地回退为 Host 第三条连接。没有可行 peer path 时,SFU 作为主要中央兜底 virtual parent 正常向一至两个必要 roots 送流;媒体到达 root 后其余可行观看者仍优先沿受限 peer descendants 扩散。多个无法挂到健康 root 的极端 viewer 可成为额外 server-fed 例外,但必须受显式中央 egress/admission 上限约束,不能无界整房 fanout。TURN 只作为可选的极端网络 transport 兼容层,不定义房间拓扑。
|
||||
- [ ] P2P 优先:每条逻辑媒体边的 ICE 必须先尝试直接 UDP 路径,服务器不得默认中转可直连的媒体。一个或两个观看者优先直接连接分享端;更多观看者的 peer-assisted 行为须先通过 ADR-0004 的实验门槛。
|
||||
- [ ] NAT 与传输兜底:STUN 必须可用,所有 ordinary peer `RTCPeerConnection` 只接收 STUN ICE servers。PC 失败后按一次 ICE restart、一次同 parent rebuild、一个 alternate peer、再 SFU/UDP virtual parent 的顺序恢复,任一步成功即停止;只有该 edge 的 SFU/UDP 也失败后,controller 才可授权一次短期 authenticated selected-edge TURN/UDP rebuild。授权必须绑定当前 room/share generation、viewer/parent session、route revision 与 old/new connection identity,成功、失败、超时或代次变化即消费;同一房间同时 pending 或 active 的 `peer-selected` attempt 总数最多为一,`host-sfu-ingress` 使用独立状态且不占该 last-mile 名额。不得按 participant、房间或普通 peer PC 下发,Native v2 保持 STUN-only。凭据不进入 URL、日志、浏览器持久化或 SQLite。Coturn 只验证 HMAC 与 expiry,应用必须重验 edge 代次并用短 TTL、one-use state 与 quota 限界。媒体 ICE/TCP、TURN/TCP 或 TURN/TLS 另行验收;HTTPS/WSS 仍使用 TLS/TCP。
|
||||
- [ ] NAT 与传输兜底:STUN 必须可用,所有 ordinary peer `RTCPeerConnection` 只接收 STUN ICE servers。PC 失败后按一次 ICE restart、一次同 parent rebuild、一个 alternate peer、再 SFU/UDP virtual parent 的顺序恢复,任一步成功即停止;只有该 edge 的 SFU/UDP 也失败后,controller 才可授权一次短期 authenticated selected-edge TURN/UDP rebuild。授权必须绑定当前 room/share generation、viewer/parent session、route revision 与 old/new connection identity,成功、失败、超时或代次变化即消费;同一房间同时 pending 或 active 的 `peer-selected` attempt 总数最多为一,`host-sfu-ingress` 使用独立状态且不占该 last-mile 名额。不得按 participant、房间或普通 peer PC 下发,Native v2 保持 STUN-only。凭据不进入 URL、日志、浏览器持久化或 SQLite。Coturn 只验证 HMAC 与 expiry,应用必须重验 edge 代次并用短 TTL、one-use state 与 quota 限界。媒体路径保持 UDP-only;全部 UDP 路径耗尽后明确失败。HTTPS/WSS 仍独立使用 TLS/TCP。
|
||||
- [ ] 质量控制:保留 1080p60(8 Mbps)、1080p30(5 Mbps)、720p30(3 Mbps)三档推荐上限,三档与高级设置初始值统一采用 `balanced`;分享期间可直接换档或应用有界高级设置,不重新选择来源或重建健康连接。Peer-assisted relay 与已配置的 SFU 必须接收同一个严格校验的房间级权威设置,覆盖当前和以后建立的 sender;普通 P2P 信令结构保持不变。任何设置都只是浏览器上限和偏好,具体降级由浏览器决定;必须读回实际 sender 参数并通过 stats 观测结果、显示参数拒绝、改写和持续受限原因。不做自定义动态 FPS、综合评分或周期性全房间调档。
|
||||
- [ ] 路径隔离双表示:direct/peer 继续使用每条 PeerConnection 独立的 stock WebRTC GCC,不把任何 viewer 反馈合成全房目标。每条 SFU path 从 `HIGH` ceiling 开始;优先用 pinned LiveKit 2.22.0/1.13.5 的标准两层 screen-share simulcast,在 Dynacast off 下常驻 exactly `HIGH+LOW`,让内建 per-subscriber BWE 独立降层和恢复。若该 gate 通过,Screener 不实现媒体层 selector。首 gate 使用 zero-descendant SFU leaf;root-with-children 的自动 downshift 与有界迁移是 default-on 前独立硬门。只有内建选择不满足门槛时才用标准 subscriber quality 显式限制,常驻成本不合格时再测标准 sender 手动启停。`LOW` 停编只是资源优化;active representation/layer 硬上限为二,禁止按 viewer 增长。必须实测实际接收层、硬件编码证据、游戏 FPS/p1 low、CPU/GPU、区间 encode cost、上传和分层 bytes。当前 Web P2P simulcast 缺少 receiver 选 RID 和跨 PeerConnection shared-encode 契约;当前 Web/LiveKit SVC 也缺少 direct/peer 独立选层与可移植硬件保证,且 pinned screen share 强制三层 temporal-only `L1T3`,二者按现契约静态 no-go。全部标准路径失败后才考虑 custom/native dual encode。没有合格 hardware/power-efficient 路径或额外表示破坏游戏/上传预算时 fail closed:保护健康路径并明确显示弱路径降级或不可用。
|
||||
- [ ] 路径级质量状态:应用的 `HIGH`/`FALLBACK` 非对称窗口只决定拓扑资格和诊断,不命令正常的媒体层切换,也不计算综合分数。SFU BWE downshift 先进入 `suspect`;多个连续窗口的 sender/viewer 关联证据确认 `FALLBACK` 后,应用不得稳定保留该 parent,并把 downstream capacity 降为 0。计划或显式质量 fallback 仅是内建路径失败后的候选:已有 children 时先在 connection/assignment generation guard 下全部迁走,失败或代次过期则保持原 assignment/quality。Autonomous downshift 无法 packet-level 预先迁移,default-on 前必须验收 descendants 的短暂影响。只有更长的稳定恢复窗口和 cooldown 后才能重新声明 relay capacity。Viewer 请求必须鉴权、限频、去重,并由 sender 的 GCC/Transport-CC、实际码率、RTT、丢包、重传、编码限制、协商 codec/profile/parameters 与 viewer 解码、丢帧、冻结证据交叉验证;不能仅凭自报生效,也不得上传 raw SDP、candidate 地址或原始设备/网络标识。
|
||||
@@ -42,7 +42,6 @@
|
||||
- [ ] 私密房间双入口:private room 接受现有 `/r/{code}#v={grant}` 合法邀请直接进入,或“站点访问 + 房间号 + 该房间观看密码”。没有 grant 的 `/join` 或 `/r/{code}` 先显示明确标为“站点访问”的整站验证,通过后才显示明确标为“加入房间”的当前房间密码输入;有效 site-access cookie 直接跳过第一步,但两类密码不得复用、代填或互相替代。Host 可设置、轮换或移除房间观看密码。密码接受 1 至 64 个可见 ASCII 字符;异步 scrypt 全局最多执行 2 个、等待 16 个派生。站点口令和房间密码都不得进入 URL/query/storage/log/SQLite;不存在、错误密码、队满和正确密码后的满房都返回通用结果。
|
||||
|
||||
- [ ] 统一自动路由:旗舰部署开启 `PEER_ASSISTED_MEDIA` 后,所有正常房间都进入同一个 direct/peer -> SFU -> selected-edge controller;每个房间独立保存 assignment/session/revision/connection generation 和容量状态。合格父节点构成网状候选关系,但实际媒体保持无环且每个 Viewer 只有一个 active upstream;加入、容量变化、掉线和当前边失败触发有界重选,切换重叠必须受现有边预算与当前代媒体证据约束,不做常驻多源分片。`PEER_ASSISTED_ROOM_IDS` 已退役,room `1` 只作为历史 smoke fixture,不能作为功能开关。未启用总开关的开发/轻量部署仍可保留 ordinary P2P;普通 peer 始终 STUN-only。selected-edge TURN 只能复用当前 controller 选中的异常 edge,不能向普通 peer edge 预发 candidates。该能力不增加 UI、用户选择、百分比发布或第二套路由器。
|
||||
- [ ] 纯 P2P 调试会话:Host 可在高级设置中为下一次分享临时选择“仅测试 P2P”。该选择只允许 Host 在新 share generation 的首次鉴权中请求,服务端权威阻止本次分享的 SFU prepare/config/publication、standby 和 selected-edge TURN,同时保留 STUN-only direct、ICE restart/rebuild 与一个 alternate peer;耗尽后明确失败。同 generation 信令重连保持且不能中途切换;停止分享、新 share generation、room 删除或服务端进程重启才清除。新进程可从 Host 不可变认证 identity 重新接受请求,并在 `authenticated` 中回显实际状态。设置不持久化,Viewer 不能设置。它只用于收集真实连接结果,不得宣称能稳定穿透 endpoint-dependent NAT。
|
||||
- [ ] 质量控制扩展:推荐档位仍是默认入口;折叠高级区只允许 720p/1080p/1440p 分辨率 ceiling、15 至 60 的整数 FPS ceiling、2 至 12 Mbps 视频码率 ceiling,以及清晰/平衡/流畅三种偏好。直播中显式应用后才生效,避免滑杆产生连续控制流量。Web 不提供音频声道、采样率、Opus 码率、stereo、DTX 或 FEC 控件;每个实际浏览器音频 sender 固定请求并读回 128 kbps ceiling,SFU 音频发布使用同值 preset 且关闭 DTX。该值只是标准 API/SDK 支持的发送上限,实际码率仍以 stats 为准。display capture 未把声道和采样率定义为可移植控制,其他项依赖 codec/fmtp 协商而没有可移植 sender setter。不得为这些控件新增应用层 SDP munging 或用默认值制造伪控制。实际质量持续低于所选上限时必须显示可解释的限制原因;在真实拥塞、编码器和接收端证据完成前,不把任一组合称为“完美平衡”。
|
||||
- [ ] 本地预览性能候选:在相同动态场景中 A/B 测量 Host 动态预览开/关的 CPU、GPU、encode time 和实际 FPS。只有稳定收益且捕获、编码和远端画面不受影响时,才允许直播开始后默认显示静止缩略图或隐藏动态预览并提供按需展开;未测前不宣称收益。
|
||||
- [ ] Peer-assisted 媒体路径:分享端与每名普通 Web viewer 都最多两个 child。信令服务维护可从多个合格节点选择 parent 的候选图,但每名 viewer 任一时刻只有一个 active upstream,活动媒体图保持无环;只在新加入、容量释放、节点断开或当前 edge 失败时按深度和服务端加入序号做有界分配或换父,不加入综合评分、主动重平衡、多源媒体或持续优化。每个 viewer 将收到的标准 remote track 通过最多两个新的 `RTCPeerConnection` 转发,因此会逐跳解码和分别重编码;必须测量而不能称为 shared encode。服务端只在受房间数限制的内存状态中保存当前严格校验的质量设置,不写入 SQLite;host 鉴权后先重报设置,在线 viewers 接收更新,后加入者从鉴权快照取得当前值。
|
||||
@@ -53,8 +52,8 @@
|
||||
- [ ] SFU-root 中央兜底:产品部署目标必须提供经 canary 验收的单节点 SFU 资源;系统在 direct/peer UDP 不可用或明确拓扑预算无法接纳时自动把它作为主要中央兜底,不要求用户选模式。它只向总计一至两个必要 roots 出口;root 优先承担受限 peer descendants,没有可靠 relay root 时必要 viewer 只作为同一总上限内的零 descendants root。已关闭 PR #12 的显式整房 SFU 模式已被取代。room `1` 的历史 smoke 没有 retained SFU media;公网媒体、资源和恢复门禁仍适用于全站路由。
|
||||
- [ ] 分享端桌面封装:用 Electron 固定 Chromium 版本、改善选源/热键/系统音频体验,同时保留 Web 观看端。
|
||||
- [ ] Windows 应用音频:Windows 11 Native sender 使用本地 opaque window target 绑定 HWND、PID 与进程创建时间,只捕获所选游戏及其子进程的音频,避免把语音聊天或通知声一并分享;“全部系统声音”只能由分享者显式选择。普通 Windows 10 不具备当前 process-loopback API 门槛,必须明确标为未解决/不支持而不是伪兼容。浏览器无法证明或隔离任意进程音频时必须如实提示,原生捕获失败、无声或进程退出不得静默扩大为整机捕获。
|
||||
- [ ] 连接自检:在开始分享前验证信令、STUN 和 SFU ICE/UDP 可达性,并给出不暴露地址或凭据的诊断结果。启用 selected-edge TURN canary 时只报告脱敏的授权阶段与 selected candidate type,不输出 URL、username、credential、地址或原始 candidate;媒体 TCP 不在当前配置面。
|
||||
- [ ] 受限网络增强:短期鉴权 TURN 只能由完整、默认关闭的部署 tuple 与当前 room controller 启用,并且只能在 direct/peer UDP 与 SFU/UDP 均已按有界梯级失败后,向 controller 选中的当前异常 edge 一次性授权。普通 peer PC 从创建起保持 STUN-only;不得向整个 participant、房间或所有 peer connections 预发 TURN candidates。凭据必须绑定当前授权 session 与 route/connection generation、短期有效、内存态、一次性消费且不进入 URL、日志、浏览器持久化或 SQLite。媒体 TCP 仍需独立 canary;两者都不能被描述为更高清的传输方式。
|
||||
- [ ] 连接自检:在开始分享前验证信令、STUN 和 SFU ICE/UDP 可达性,并给出不暴露地址或凭据的诊断结果。启用 selected-edge TURN canary 时只报告脱敏的授权阶段与 selected candidate type,不输出 URL、username、credential、地址或原始 candidate;自检只覆盖当前 UDP 媒体梯级。
|
||||
- [ ] 受限网络增强:短期鉴权 TURN 只能由完整、默认关闭的部署 tuple 与当前 room controller 启用,并且只能在 direct/peer UDP 与 SFU/UDP 均已按有界梯级失败后,向 controller 选中的当前异常 edge 一次性授权。普通 peer PC 从创建起保持 STUN-only;不得向整个 participant、房间或所有 peer connections 预发 TURN candidates。凭据必须绑定当前授权 session 与 route/connection generation、短期有效、内存态、一次性消费且不进入 URL、日志、浏览器持久化或 SQLite。媒体保持 UDP-only,全部路径耗尽后明确失败;TURN 不得被描述为更高清的传输方式。
|
||||
- [ ] 运行时编解码选择:先保留浏览器默认协商;只有端点能力相交、Media Capabilities 对目标分辨率/FPS/码率报告 supported/smooth/power-efficient,且实际 outbound codec、区间 encode time、游戏 FPS、CPU/GPU 与 sender 数量共同通过时,才调整 H.264、VP8、VP9 或 AV1 优先级。AV1 不得因压缩率推断而默认启用,任何候选都不得静默落入高负载软件编码。
|
||||
- [ ] 诊断页面:用户可导出不含敏感地址和凭据的连接诊断信息。
|
||||
|
||||
@@ -111,11 +110,10 @@
|
||||
|
||||
- Given 两端网络允许 UDP 打洞,When 观看者加入,Then `getStats()` 显示选中的候选对不是 `relay`,且服务器没有承载媒体流量。
|
||||
- Given direct/peer UDP 无法建立但 LiveKit SFU/UDP 可达,When 观看者加入或现有 edge 恢复耗尽,Then 系统只把必要 root 切到 SFU,保留可行 peer descendants 和未受影响路径,无需用户选择模式。
|
||||
- Given 未配置 TURN 且所有媒体 UDP 路径均不可用,When 有界连接窗口耗尽,Then 界面快速显示通用连接失败,不静默黑屏、不长时间假连接,也不自动尝试媒体 TCP。
|
||||
- Given 未配置 TURN 且所有媒体 UDP 路径均不可用,When 有界连接窗口耗尽,Then 界面快速显示通用连接失败,不静默黑屏、不长时间假连接,也不尝试未配置的媒体路径。
|
||||
- Given 未配置完整 selected-edge TURN tuple、当前 route/connection generation 不匹配或该 edge 未经过 controller 选择,When 会话鉴权、peer PC 创建或旧消息重放,Then authenticated snapshot 与 ordinary peer PC 必须保持 STUN-only,服务端不得签发或接受该 TURN grant;不得把 credential 发给 Native v2、普通房间或未鉴权会话。
|
||||
- Given 当前异常 edge 已耗尽 direct/peer UDP 与 SFU/UDP 且 controller 授权一次 selected-edge TURN attempt,When parent 与 child 以同一 session/revision/old-and-new connection generation 重建,Then 只有该新 PC 使用 relay-only 短期 credential;成功即停止,失败或过期即消费并明确失败,不得把 bearer 复用于其他 edge、后续 PC 或整个 participant。
|
||||
- Given 部署者为 LiveKit 启用了 embedded/external TURN,When publisher 或 subscriber 的 SFU/UDP 路径耗尽,Then 只把它作为 LiveKit participant-to-SFU 兼容 transport 单独验收;它不是 Screener selected peer-edge grant,也不得改变 controller 的默认梯级。
|
||||
- Given 部署者另行启用并验收媒体 TCP/TLS 兼容模式,When UDP 被封锁,Then 对应路径建立或返回可诊断失败;未启用该能力不阻塞面向消费网络的旗舰验收,HTTPS/WSS 仍正常使用 TLS/TCP。
|
||||
- Given 同一房间中一名观看者可以 direct/peer UDP、另一名只能通过 SFU root 接收,When 两者同时观看,Then 前者保持分布式 P2P,后者使用 SFU,双方互不迁移且 host/SFU root 上限不变。
|
||||
|
||||
### 浏览器与移动端观看
|
||||
|
||||
@@ -229,7 +229,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [switchingSource, setSwitchingSource] = useState(false);
|
||||
const [changingQuality, setChangingQuality] = useState(false);
|
||||
const [p2pOnlyDebug, setP2pOnlyDebug] = useState(false);
|
||||
const [sharingPaused, setSharingPaused] = useState(false);
|
||||
const [localPreviewPaused, setLocalPreviewPaused] = useState(false);
|
||||
const [showConnectionDetails, setShowConnectionDetails] = useState(false);
|
||||
@@ -478,7 +477,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
setSignalStatus("offline");
|
||||
setSwitchingSource(false);
|
||||
setChangingQuality(false);
|
||||
setP2pOnlyDebug(false);
|
||||
sharingPausedRef.current = false;
|
||||
setSharingPaused(false);
|
||||
}
|
||||
@@ -1199,7 +1197,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
token: activeRoom.hostToken,
|
||||
clientId: hostClientId,
|
||||
shareGeneration,
|
||||
...(p2pOnlyDebug ? { debugP2pOnly: true } : {}),
|
||||
viewerPresence: true,
|
||||
viewerPasswordSettings: true,
|
||||
displayName: initialDisplayName,
|
||||
@@ -1238,9 +1235,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
return;
|
||||
}
|
||||
if (message.type === "authenticated" && message.role === "host") {
|
||||
setP2pOnlyDebug(
|
||||
"debugP2pOnly" in message && message.debugP2pOnly === true,
|
||||
);
|
||||
iceConfigRef.current = message.iceConfig;
|
||||
peersRef.current.forEach((peer) =>
|
||||
peer.updateIceConfig(message.iceConfig),
|
||||
@@ -1822,21 +1816,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
</fieldset>
|
||||
|
||||
<details className="advanced-quality">
|
||||
<summary>高级设置</summary>
|
||||
<label className="viewer-policy-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={p2pOnlyDebug}
|
||||
disabled={phase === "starting" || phase === "live"}
|
||||
onChange={(event) => setP2pOnlyDebug(event.target.checked)}
|
||||
/>
|
||||
<span>仅测试 P2P</span>
|
||||
</label>
|
||||
{p2pOnlyDebug && (
|
||||
<WarningBanner>
|
||||
本次分享不使用 SFU 或 TURN;P2P 路径耗尽后直接报错
|
||||
</WarningBanner>
|
||||
)}
|
||||
<summary>高级视频设置</summary>
|
||||
<div className="advanced-quality-grid">
|
||||
<label>
|
||||
<span>分辨率上限</span>
|
||||
|
||||
@@ -193,7 +193,6 @@ export class HybridMediaRouter {
|
||||
string,
|
||||
Map<string, string>
|
||||
>();
|
||||
private readonly p2pOnlyGenerationByRoom = new Map<string, string>();
|
||||
private readonly sfuDisabledRoomIds = new Set<string>();
|
||||
private readonly viewerQualityEvidenceStates = new Map<
|
||||
string,
|
||||
@@ -243,31 +242,11 @@ export class HybridMediaRouter {
|
||||
this.viewerRouteIntentsByRoom.clear();
|
||||
this.consumedSfuRefreshesByRoom.clear();
|
||||
this.relayCapacitySessionsByRoom.clear();
|
||||
this.p2pOnlyGenerationByRoom.clear();
|
||||
this.sfuDisabledRoomIds.clear();
|
||||
this.viewerQualityEvidenceStates.clear();
|
||||
this.roomQualityMigrationCooldownUntilMs.clear();
|
||||
}
|
||||
|
||||
setP2pOnly(
|
||||
roomId: string,
|
||||
shareGeneration: string,
|
||||
enabled: boolean,
|
||||
): void {
|
||||
if (enabled) {
|
||||
this.p2pOnlyGenerationByRoom.set(roomId, shareGeneration);
|
||||
return;
|
||||
}
|
||||
this.p2pOnlyGenerationByRoom.delete(roomId);
|
||||
}
|
||||
|
||||
isP2pOnly(roomId: string, shareGeneration?: string): boolean {
|
||||
const activeGeneration = this.p2pOnlyGenerationByRoom.get(roomId);
|
||||
return shareGeneration === undefined
|
||||
? activeGeneration !== undefined
|
||||
: activeGeneration === shareGeneration;
|
||||
}
|
||||
|
||||
connectParticipant(input: AuthenticatedRouteParticipant): HybridAuthenticationState {
|
||||
this.clearRoomViewerQualityEvidenceStates(input.roomId);
|
||||
this.clearSfuRefreshesForPeer(input.roomId, input.peerId);
|
||||
@@ -938,14 +917,12 @@ export class HybridMediaRouter {
|
||||
}
|
||||
|
||||
stopRoom(roomId: string): void {
|
||||
this.p2pOnlyGenerationByRoom.delete(roomId);
|
||||
this.clearRoomMediaRouteState(roomId);
|
||||
}
|
||||
|
||||
deleteRoom(roomId: string): void {
|
||||
this.peerRelayTopology.deleteRoom(roomId);
|
||||
this.relayCapacitySessionsByRoom.delete(roomId);
|
||||
this.p2pOnlyGenerationByRoom.delete(roomId);
|
||||
this.clearRoomMediaRouteState(roomId);
|
||||
}
|
||||
|
||||
@@ -1037,7 +1014,6 @@ export class HybridMediaRouter {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
this.isP2pOnly(roomId) ||
|
||||
!config ||
|
||||
!active ||
|
||||
!intent ||
|
||||
@@ -1191,7 +1167,6 @@ export class HybridMediaRouter {
|
||||
);
|
||||
}
|
||||
if (
|
||||
this.isP2pOnly(roomId) ||
|
||||
!config ||
|
||||
!fallback ||
|
||||
(pending !== undefined &&
|
||||
@@ -1618,7 +1593,6 @@ export class HybridMediaRouter {
|
||||
if (
|
||||
intent.sfuAttempts === 0 &&
|
||||
this.options.sfuFallback &&
|
||||
!this.isP2pOnly(roomId) &&
|
||||
!this.sfuDisabledRoomIds.has(roomId)
|
||||
) {
|
||||
if (
|
||||
@@ -1748,7 +1722,7 @@ export class HybridMediaRouter {
|
||||
): SfuPrepareResult {
|
||||
const fallback = this.options.sfuFallback;
|
||||
const controller = this.mediaRouteControllers.get(roomId);
|
||||
if (!fallback || !controller || this.isP2pOnly(roomId)) {
|
||||
if (!fallback || !controller) {
|
||||
return "unavailable";
|
||||
}
|
||||
if (controller.getPendingRoute()) {
|
||||
@@ -1929,7 +1903,7 @@ export class HybridMediaRouter {
|
||||
expectedSessionIds: ReadonlyMap<string, string>,
|
||||
): Promise<void> {
|
||||
const fallback = this.options.sfuFallback;
|
||||
if (!fallback || this.isP2pOnly(roomId)) {
|
||||
if (!fallback) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -1953,7 +1927,6 @@ export class HybridMediaRouter {
|
||||
const pendingRoute = controller.getPendingRoute();
|
||||
if (
|
||||
this.mediaRouteControllers.get(roomId) !== controller ||
|
||||
this.isP2pOnly(roomId) ||
|
||||
pending?.revision !== revision ||
|
||||
pendingRoute?.route.revision !== revision
|
||||
) {
|
||||
@@ -2162,7 +2135,6 @@ export class HybridMediaRouter {
|
||||
if (
|
||||
!fallback ||
|
||||
!controller ||
|
||||
this.isP2pOnly(roomId) ||
|
||||
!active ||
|
||||
active.revision !== revision ||
|
||||
!active.sfu.publicationGeneration ||
|
||||
|
||||
+4
-18
@@ -435,19 +435,12 @@ export class SignalingServer {
|
||||
);
|
||||
shareGeneration =
|
||||
message.shareGeneration ?? currentGeneration ?? state.sessionId;
|
||||
const generationChanged =
|
||||
if (
|
||||
currentGeneration !== undefined &&
|
||||
currentGeneration !== shareGeneration;
|
||||
if (generationChanged) {
|
||||
currentGeneration !== shareGeneration
|
||||
) {
|
||||
this.stopSharing(participant.roomId);
|
||||
}
|
||||
if (currentGeneration === undefined || generationChanged) {
|
||||
this.hybridMediaRouter?.setP2pOnly(
|
||||
participant.roomId,
|
||||
shareGeneration,
|
||||
message.debugP2pOnly === true,
|
||||
);
|
||||
}
|
||||
this.shareGenerationsByRoom.set(participant.roomId, shareGeneration);
|
||||
}
|
||||
state.authenticated = {
|
||||
@@ -527,14 +520,7 @@ export class SignalingServer {
|
||||
qualitySettings:
|
||||
this.qualitySettingsByRoom.get(participant.roomId) ??
|
||||
DEFAULT_QUALITY_SETTINGS,
|
||||
...(this.hybridMediaRouter?.isP2pOnly(
|
||||
participant.roomId,
|
||||
shareGeneration ?? undefined,
|
||||
)
|
||||
? { debugP2pOnly: true as const }
|
||||
: {}),
|
||||
...(this.options.sfuFallback &&
|
||||
!this.hybridMediaRouter?.isP2pOnly(participant.roomId)
|
||||
...(this.options.sfuFallback
|
||||
? { sfuStandbyUrl: this.options.sfuFallback.url }
|
||||
: {}),
|
||||
});
|
||||
|
||||
@@ -484,7 +484,6 @@ const authenticateMessageSchema = z.discriminatedUnion("role", [
|
||||
token: tokenSchema,
|
||||
clientId: opaqueIdSchema,
|
||||
shareGeneration: opaqueIdSchema.optional(),
|
||||
debugP2pOnly: z.literal(true).optional(),
|
||||
viewerPresence: z.literal(true).optional(),
|
||||
viewerPasswordSettings: z.literal(true).optional(),
|
||||
displayName: displayNameSchema.optional(),
|
||||
@@ -622,7 +621,6 @@ const authenticatedMessageSchema = z.union([
|
||||
routeRevision: mediaRouteRevisionSchema,
|
||||
routeAssignment: participantRouteAssignmentSchema,
|
||||
qualitySettings: qualitySettingsSchema,
|
||||
debugP2pOnly: z.literal(true).optional(),
|
||||
sfuStandbyUrl: liveKitWebSocketUrlSchema.optional(),
|
||||
})
|
||||
.strict(),
|
||||
|
||||
@@ -402,19 +402,6 @@ describe("client signaling protocol", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a Viewer request for the Host-only P2P debug mode", () => {
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
type: "authenticate",
|
||||
protocol: SIGNALING_PROTOCOL,
|
||||
roomId,
|
||||
role: "viewer",
|
||||
clientId: "client_12345678",
|
||||
debugP2pOnly: true,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts only strict, bounded quality settings", () => {
|
||||
expect(
|
||||
clientMessageSchema.safeParse({
|
||||
@@ -817,8 +804,6 @@ describe("server signaling protocol", () => {
|
||||
};
|
||||
|
||||
expect(serverMessageSchema.safeParse(peerAssisted).success).toBe(true);
|
||||
expect(serverMessageSchema.safeParse({ ...peerAssisted, debugP2pOnly: true }).success).toBe(true);
|
||||
expect(serverMessageSchema.safeParse({ ...peerAssisted, debugP2pOnly: false }).success).toBe(false);
|
||||
expect(
|
||||
serverMessageSchema.safeParse({
|
||||
...peerAssisted,
|
||||
|
||||
@@ -444,7 +444,6 @@ async function authenticate(
|
||||
viewerPresence?: true;
|
||||
viewerPasswordSettings?: true;
|
||||
viewerPassword?: string;
|
||||
debugP2pOnly?: true;
|
||||
} = {},
|
||||
) {
|
||||
client.socket.send(
|
||||
@@ -458,7 +457,6 @@ async function authenticate(
|
||||
token: room.hostToken,
|
||||
clientId,
|
||||
...(shareGeneration ? { shareGeneration } : {}),
|
||||
...(presence.debugP2pOnly ? { debugP2pOnly: true } : {}),
|
||||
...(presence.viewerPresence ? { viewerPresence: true } : {}),
|
||||
...(presence.viewerPasswordSettings
|
||||
? { viewerPasswordSettings: true }
|
||||
@@ -4125,108 +4123,6 @@ describe("WebSocket signaling", () => {
|
||||
expect(issued.length).toBeGreaterThan(issuedBeforeFailback);
|
||||
});
|
||||
|
||||
it("binds the Host-only P2P debug mode to one share generation", async () => {
|
||||
const issueToken = vi.fn(async ({ peerId }: { peerId: string }) =>
|
||||
`token-${peerId}`,
|
||||
);
|
||||
const harness = await startSfuHarness({
|
||||
tokenIssuer: { issueToken },
|
||||
selectedEdgeTurn: true,
|
||||
stunUrls: ["stun:stun.example.test:3478"],
|
||||
});
|
||||
const shareGeneration = "debug-p2p-only-share";
|
||||
// Empty generation state is also the first authentication after a restart.
|
||||
const initialHost = await openClient(harness.webSocketUrl);
|
||||
const hostAuth = peerAssisted(
|
||||
await authenticate(
|
||||
initialHost, harness.room, "host", "debug-p2p-host", 1, shareGeneration,
|
||||
{ debugP2pOnly: true },
|
||||
),
|
||||
);
|
||||
expect(hostAuth.debugP2pOnly).toBe(true);
|
||||
expect(hostAuth.sfuStandbyUrl).toBeUndefined();
|
||||
expect(hostAuth.iceConfig.iceServers).toEqual([
|
||||
{ urls: ["stun:stun.example.test:3478"] },
|
||||
]);
|
||||
|
||||
await closeClient(initialHost);
|
||||
const host = await openClient(harness.webSocketUrl);
|
||||
const resumedAuth = peerAssisted(
|
||||
await authenticate(
|
||||
host, harness.room, "host", "debug-p2p-host", 1, shareGeneration,
|
||||
),
|
||||
);
|
||||
expect(resumedAuth.debugP2pOnly).toBe(true);
|
||||
expect(resumedAuth.sfuStandbyUrl).toBeUndefined();
|
||||
|
||||
const failedViewer = await openClient(harness.webSocketUrl);
|
||||
const failedAuth = peerAssisted(
|
||||
await authenticate(
|
||||
failedViewer, harness.room, "viewer", "debug-p2p-failed",
|
||||
),
|
||||
);
|
||||
expect(failedAuth.debugP2pOnly).toBe(true);
|
||||
expect(failedAuth.sfuStandbyUrl).toBeUndefined();
|
||||
host.socket.send(JSON.stringify({
|
||||
type: "signal",
|
||||
targetPeerId: failedAuth.peerId,
|
||||
payload: {
|
||||
kind: "description",
|
||||
connectionId: "debug-direct-connection",
|
||||
description: { type: "offer", sdp: "v=0\r\n" },
|
||||
},
|
||||
}));
|
||||
await failedViewer.inbox.next("signal");
|
||||
failedViewer.socket.send(JSON.stringify({
|
||||
type: "route-failed",
|
||||
revision: failedAuth.routeRevision,
|
||||
phase: "active",
|
||||
connectionId: "debug-direct-connection",
|
||||
}));
|
||||
expect((await failedViewer.inbox.next("error")).code).toBe("PEER_NOT_FOUND");
|
||||
for (const [client, type] of [
|
||||
[host, "sfu-config"],
|
||||
[failedViewer, "sfu-config"],
|
||||
[host, "selected-edge-turn"],
|
||||
[failedViewer, "selected-edge-turn"],
|
||||
] as const) {
|
||||
await expect(client.inbox.next(type, 40)).rejects.toThrow("Timed out");
|
||||
}
|
||||
expect(issueToken).not.toHaveBeenCalled();
|
||||
|
||||
await closeClient(host);
|
||||
const nextHost = await openClient(harness.webSocketUrl);
|
||||
const nextAuth = peerAssisted(
|
||||
await authenticate(
|
||||
nextHost, harness.room, "host", "debug-p2p-host", 1,
|
||||
"next-share-generation",
|
||||
),
|
||||
);
|
||||
expect(nextAuth.debugP2pOnly).toBeUndefined();
|
||||
expect(nextAuth.sfuStandbyUrl).toBe("wss://sfu.example.test");
|
||||
|
||||
await closeClient(nextHost);
|
||||
const stoppedHost = await openClient(harness.webSocketUrl);
|
||||
const stoppedGeneration = "stopped-debug-generation";
|
||||
expect(peerAssisted(await authenticate(
|
||||
stoppedHost, harness.room, "host", "debug-p2p-host", 1,
|
||||
stoppedGeneration, { debugP2pOnly: true },
|
||||
)).debugP2pOnly).toBe(true);
|
||||
const stopped = new Promise<void>((resolve) =>
|
||||
stoppedHost.socket.once("close", () => resolve()),
|
||||
);
|
||||
stoppedHost.socket.send(JSON.stringify({ type: "stop-sharing" }));
|
||||
await stopped;
|
||||
|
||||
const afterStop = await openClient(harness.webSocketUrl);
|
||||
const afterStopAuth = peerAssisted(await authenticate(
|
||||
afterStop, harness.room, "host", "debug-p2p-host", 1,
|
||||
stoppedGeneration, { debugP2pOnly: true },
|
||||
));
|
||||
expect(afterStopAuth.debugP2pOnly).toBeUndefined();
|
||||
expect(afterStopAuth.sfuStandbyUrl).toBe("wss://sfu.example.test");
|
||||
});
|
||||
|
||||
it("falls through to selected TURN when SFU prepare is unavailable", async () => {
|
||||
const harness = await startSfuHarness({
|
||||
tokenIssuer: { async issueToken() { throw new Error("offline"); } },
|
||||
|
||||
Reference in new issue
Block a user