feat(media): add bounded peer-assisted topology (#13)

spike: add bounded peer-assisted media topology
This commit is contained in:
AAA authored and GitHub committed 2026-08-19 14:05:12 +08:00
commit 5823eb1460
48 files changed
+6823 -331

No files matched your search

+2
View File
@@ -16,6 +16,8 @@ ROOM_DATABASE_PATH=
ROOM_TTL_SECONDS=14400
MAX_ROOMS=1000
MAX_VIEWERS_PER_ROOM=8
# Experimental standard-WebRTC relay chains. Keep false outside measured tests.
PEER_ASSISTED_MEDIA=false
# ICE servers. Separate multiple URLs with commas.
STUN_URLS=
+1
View File
@@ -14,6 +14,7 @@ node_modules/
build/
coverage/
dist/
benchmark-results/
.cache/
*.sqlite
*.sqlite-journal
+4 -5
View File
@@ -13,10 +13,9 @@
## Repository Knowledge
- At the start of substantial work, read `docs/project-memory.md`, `docs/status.md`, and the relevant requirement, ADR, and research documents.
- Keep durable project decisions, requirements, research, code, and agent configuration inside this repository and Git-tracked.
- Do not leave required project context only in chat history, a user home directory, or an untracked scratch file.
- Treat `docs/project-memory.md` and `docs/status.md` as bounded current snapshots, not append-only journals. Replace or remove stale facts and use Git history for the timeline.
- Update project memory when a durable decision changes, before context compaction on long tasks, and at task handoff. Use `docs/adr/` for architecture decisions and tradeoffs.
- Treat the Git-tracked repository as the persistent source of truth. Before dependent work continues, write every accepted requirement or priority, design change, durable research conclusion or retained candidate, actionable TODO or blocker, and material status change to its owning repository document; chat alone is not a durable record.
- Maintain one minimal truth set: current snapshots in `docs/project-memory.md` and `docs/status.md`, current requirements and design in their specifications, decisions in `docs/adr/`, evidence in `docs/research/`, and the timeline in Git history. Replace stale facts in place and do not duplicate detailed content into this file.
- Checkpoint that truth set at material phase boundaries, after accepted decisions change, at branch/PR or agent handoff, and before likely context compaction or a long pause. After compaction or resumption, re-read the snapshots and inspect Git state before continuing.
- Keep `docs/README.md` current when documentation is added, moved, or superseded.
## Research Before Changes
@@ -37,7 +36,6 @@
- Keep this root file concise and stable; put specialized rules in the closest relevant directory only when that code exists.
- Never store raw transcripts, large logs, generated summaries, temporary plans, or facts that can be cheaply rediscovered in always-loaded memory.
- On a long session, checkpoint accepted decisions and current state before relying on automatic context compression. After compression or resumption, re-read the current snapshots and inspect Git state.
- When a fact changes, update it in place and remove conflicting text in the same change. Do not preserve obsolete guidance merely for history.
## Security
@@ -59,6 +57,7 @@
- Keep changes scoped and preserve unrelated user work.
- Prefer the smallest proven extension point; do not add speculative frameworks, hooks, or abstractions without a current consumer.
- Add automated tests in proportion to behavioral risk. For realtime media changes, also document the manual network and browser matrix used.
- Batch validation by risk and phase: run narrow checks while iterating, and run expensive full suites, browser matrices, endurance tests, or deployment checks only at an acceptance boundary or when a relevant change invalidates prior evidence. Documentation-only or unrelated changes do not invalidate media-path evidence.
- Use comments for non-obvious rationale, invariants, protocol constraints, and workarounds; do not narrate obvious code.
- Use WebRTC statistics and reproducible measurements for latency, bitrate, candidate type, packet loss, encode time, and quality limitations. Do not claim performance from assumptions alone.
- Keep repository scripts and hooks cross-platform, deterministic, fast, and runnable in CI. A hook must call a tracked script rather than hide project logic in machine-local configuration.
+6
View File
@@ -7,9 +7,15 @@
- [Deployment](./deployment.md): minimal Node, HTTPS/WSS, coturn, firewall, secret, optional TURN/TLS, and network-verification procedure.
- [Maintenance guide](./maintenance.md): Git workflow, context hygiene, document lifecycle, research policy, and automation.
- [P2P WebRTC research](./research/webrtc-p2p-screen-sharing.md): evidence, bandwidth model, browser constraints, reference implementations, and feasibility assessment.
- [Peer-assisted media research](./research/peer-assisted-media.md): browser shared-encode limits, standard relay re-encoding, rejected custom encoded transport, deterministic sticky topology, and abandon gates.
- [Low-server-cost media routes](./research/low-server-media-routes.md): traffic accounting, route screening, conditional native encoded-RTP relay, and automatic use of enabled SFU capacity as the final fallback.
- [Advanced peer distribution](./research/advanced-peer-distribution.md): multi-tree/SVC, encoded-object relay, native RTP forwarding, FEC/network-coding, MoQ, and measurable go/no-go gates.
- [Native shared-encode sender](./research/native-shared-encode-sender.md): bounded libwebrtc encoder-proxy risk spike, Pion fallback, and hard failure gates.
- [Realtime quality adaptation](./research/realtime-quality-adaptation.md): browser degradation policy, live profile changes, offline-encoding boundaries, and measurement gates.
- [Agent context governance research](./research/agent-context-governance.md): official Codex, Claude Code, Hermes Agent, and GitHub practices adopted by this repository.
- [ADR-0001](./adr/0001-p2p-first-media-topology.md): accepted P2P-first media topology and its consequences.
- [ADR-0002](./adr/0002-persistent-protected-rooms.md): optional SQLite persistence, protected sequential room IDs, and room lifecycle.
- [ADR-0004](./adr/0004-peer-assisted-media-experiment.md): proposed, experiment-only standard-WebRTC peer-assisted spike with hard fanout and failure gates.
- [ADR-0005](./adr/0005-automatic-hybrid-media-routing.md): proposed automatic P2P/peer/SFU preparation, commit, and rollback with a hard host-edge budget.
Documentation and project memory are part of the product source of truth. Update them in the same change that alters the corresponding requirement or architecture.
+16 -5
View File
@@ -3,6 +3,17 @@
- Status: Accepted for the MVP
- Date: 2026-08-18
Current clarification, 2026-08-19: the MVP remains unchanged, but statements
below that exclude automatic topology migration do not describe the product
target. Automatic, viewer-transparent routing through direct P2P,
peer-assisted media, and an enabled SFU fallback is a standing requirement and
will be governed by a later ADR.
Draft ADR-0003 and Proposed ADR-0004 do not supersede this production baseline.
They isolate an optional SFU implementation and a bounded peer-assisted
experiment respectively. A later Accepted ADR is required before either changes
the default topology.
## Context
The product targets one game broadcaster and a small group of trusted friends. Low glass-to-glass latency, private access, no-install browser viewing, and low server bandwidth cost matter more than large-room scalability. Public broadcasting is deliberately delegated to existing OBS/Twitch-class services. NATs, CGNAT, restrictive firewalls, variable publisher upload, and browser capture limitations prevent a direct-only design from being reliable for every user; partial room reachability is not acceptable behavior.
@@ -13,11 +24,11 @@ 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 broadcaster-viewer pair, using STUN to discover candidates.
- 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.
- Only pairs that cannot connect directly use authenticated TURN. Production requires STUN plus TURN over UDP and TCP; TURN/TLS is an optional restrictive-network enhancement, using its standard TCP port 5349 by default. Port 443 is optional and requires a dedicated public IP or a validated layer-4/SNI route when HTTPS already owns that address and port.
- Candidate selection is independent per pair. A room may simultaneously contain direct and relayed viewers without moving working peers onto the server.
- The 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; the sustainable envelope is decided from publisher upload, encoder, latency, and stability measurements.
- No SFU is planned for the normal product envelope. It is only a future reconsideration point if the small-room scope changes or measured relay/upload/encoder pressure makes the chosen envelope unworkable.
- 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 path for the normal product envelope. Severe user-observed multi-viewer degradation triggered Draft ADR-0003/PR #12 as optional infrastructure, but it is not merged, deployed, or accepted while that PR remains Draft. If configured later, it may be selected automatically only after cheaper endpoint-carried routes cannot satisfy the route contract.
## Consequences
@@ -34,7 +45,7 @@ Negative:
- Browser APIs do not guarantee that several peer connections share a single hardware encode.
- Direct peers learn one another's network addresses; this is acceptable only for the initial trusted-friends threat model.
- 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.
- 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.
@@ -43,7 +54,7 @@ Negative:
- Always-SFU: operationally stable but makes the server carry all viewer egress, contrary to the main cost constraint.
- Public-broadcast scaling: Twitch/OBS-class services already solve this separate workload and it would distort the private-room design.
- MCU/transcoding: unnecessary CPU/GPU work and added latency for a single screen stream.
- Peer-assisted relay tree: adds churn handling, extra latency, trust problems, and browser re-encoding or a custom packet-forwarding protocol.
- Peer-assisted relay tree for the MVP: adds churn handling, extra latency, trust problems, and browser re-encoding or a custom packet-forwarding protocol. Proposed ADR-0004 permits only a bounded, removable experiment; production use remains rejected until a later ADR passes measured gates.
- Custom Parsec-like transport: duplicates capture, codec, congestion-control, NAT, and security work already provided by WebRTC.
## Revisit Triggers
@@ -0,0 +1,200 @@
# ADR-0004: Peer-Assisted Media Experiment
- Status: Proposed - Experiment Only
- Date: 2026-08-19
## Context
ADR-0001 accepted standard browser WebRTC P2P for the MVP and rejected a viewer
relay tree because it adds churn, latency, and either browser re-encoding or a
custom packet-forwarding protocol. Real multi-viewer use later degraded severely.
The product now also has a hard target that a broadcaster must never carry more
than two outgoing media edges.
Draft PR #12 proposes an independently reversible deployment-level SFU under
ADR-0003, but it is not merged or accepted. Making an SFU the normal path would
move every viewer's egress cost to the server. TeamSpeak-style shared encoding
reduces host encode work but still sends one network copy to every viewer, so it
cannot meet the new fanout target alone.
The research in `docs/research/peer-assisted-media.md` finds no browser API that
can move one encoded frame between `RTCRtpSender` pipelines. Standard browser
track relay decodes and re-encodes at each hop. The first experiment accepts
that measurable cost because avoiding it requires a custom encoded-frame media
plane.
This ADR does not accept peer-assisted media for production. It proposes one
bounded experiment that must either meet explicit gates or be removed.
## Proposed Experiment
Preserve the product priority in this order:
1. direct P2P for one or two viewers;
2. peer-assisted forwarding for later viewers when this experiment is active
and every required capability is present; and
3. an enabled optional SFU as the final automatic fallback in a later route
controller.
This bounded experiment implements only the first two levels. That scope must
not be read as requiring users to select a topology or rejecting automatic
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.
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
viewer for at most one. Candidate parents are ordered by depth and server-issued
join sequence. Joining a viewer does not move existing assignments. If a parent
leaves, only its orphaned subtree root is assigned to the first available slot;
the root's descendants stay attached. No RTT, bandwidth, CPU, geography,
capability, or quality scoring is added.
For this ADR, one media edge is one downstream `RTCPeerConnection` carrying the
shared stream. Using TURN for that connection does not alter the edge count. The
host's two-child limit is a hard invariant across join, reconnect,
reparent, and recovery paths. If the deterministic topology has no connected
eligible parent, the viewer remains admitted but waits without media until a
slot becomes reachable; it must not create a third host connection.
Each logical edge still uses its own ICE process and authenticated TURN fallback
when direct connectivity is unavailable.
Every edge uses the existing standard WebRTC media path. A viewer receives the
remote `MediaStream`, renders it, and sends those remote tracks through one new
downstream `RTCPeerConnection`. The browser therefore decodes and re-encodes at
every relay hop. Existing audio follows the same stream when capture provides
it; the experiment does not invent a separate audio protocol.
The peer-assisted wire accepts exactly `1080p60`, `1080p30`, and `720p30` as
room quality profile IDs. The signaling server holds the current ID in bounded,
per-room memory only; it is not persisted. An authenticated peer-assisted
snapshot includes the current value, the host reasserts its local choice before
reconciling assigned children, and live changes are sent to all online viewers.
The viewer relay keeps one desired profile for its current and future child.
Sender setup, stream replacement, and profile changes are serialized inside
each `HostPeer`, with the latest desired value winning. The ordinary P2P
authenticated wire remains unchanged and profile-control messages are forbidden
in that mode. This coordinates sender targets; it does not prove achieved
resolution, bitrate, frame rate, or resource cost.
Encoded Transform, DataChannel media, WebCodecs rendering, dummy-sender byte
replacement, custom congestion control, codec ladders, multiple trees, mobile
background relay, and cross-mode SFU migration are excluded from this spike.
They are not rescue work if standard track relay fails; a separate automatic
route-controller ADR owns cross-mode fallback.
## Shared Encoding Boundary
The browser spike may encode once for each of the host's one or two seed
connections because browsers do not guarantee cross-connection encoder reuse.
Each viewer relay also performs one downstream encode. This is tolerated only
for the experiment and must be measured honestly.
A separate planned packaged/native sender must use custom libwebrtc encoder
proxies that share one encoded output while retaining independent standard
WebRTC packetizers for the host's edges. That work requires its own ADR and
measurements regardless of this browser relay experiment's result. It reduces
host encoding work but does not remove the upload copy for each outgoing edge.
It is not implemented or abstracted in advance by this spike. Standard browser
viewing remains required.
## Acceptance Gate
Test 1, 3, 5, and 8 viewers for 30 minutes across the 1080p60, 1080p30, and
720p30 profiles with controlled per-edge RTT at or below 40 ms and loss at or
below 1%. Current desktop Chrome and Edge form the relay cohort; current Android
Chrome and iOS Safari join last as leaf checks. The protocol has no
relay-capability bit, so controlled join order is the only enforcement and this
spike is not safe for arbitrary-user deployment.
The proposal advances only if every condition holds:
- host media fanout is never greater than two and viewer fanout is never greater
than one, including relay loss and reparenting;
- assignments are reproducible from depth and server join order, without a
composite score, live optimization loop, or proactive rebalancing;
- every relay's expected outbound RTP encoder is measured through encode time,
CPU/GPU load, and `qualityLimitationReason`; no result calls this shared or
zero-copy forwarding;
- excluding explicit TURN paths, the application server carries no media;
- host and relay upload remain within 20% of `childCount * observedBitrate`;
- authentication and live profile changes leave every current and future child
sender targeting the same latest room profile without altering the P2P wire;
- first picture is at most 3 seconds, a 60 fps run does not remain below 50
decoded fps for more than 5 seconds, and depth-four p95 glass-to-glass
latency is at most 350 ms;
- relay delta encode time per frame stays at or below 16.7 ms on the reference
cohort and CPU limitation does not persist for more than 5 seconds;
- loss of either first-level relay that the server observes immediately first
receives the default 5-second disconnect grace; if it does not reconnect,
the same deterministic assignment rule produces a new decodable picture
within the following 3 seconds (about 8 seconds total under defaults); and
- the common codec path works on the required desktop relays and mobile leaves.
The about-8-second gate covers a controlled page close that the server observes
immediately. A silent network partition depends on the default 30-second
heartbeat and can take 30 to 60 seconds to detect before the same 5-second
grace; that path is a separate, currently unverified measurement.
Fixing an ordinary implementation bug inside the bounded spike is allowed. If
meeting a gate requires Encoded Transform/DataChannel/WebCodecs media, custom
congestion control, FEC/RTX changes, multiple trees, relay scoring, transcoding,
a codec ladder, relaxed host fanout, or a browser-specific RTP injection hack,
stop. Change this ADR to Rejected and delete the experimental browser-relay
runtime path and dependencies. That result keeps standard P2P plus enabled
user-operated or central SFU fallbacks for the later automatic controller. It
does not cancel the separate planned
native shared-encode sender, and that sender cannot be used to mark an otherwise
failed browser-relay topology as passing.
Passing the gate does not change this ADR to Accepted. It permits a separate ADR
to propose a production design, including broader game-audio/A-V verification,
voluntary relay policy, and privacy disclosure. The native shared-encode sender
is the independent planned work described above, not a reward for passing this
gate.
## Consequences If The Spike Proceeds
Positive:
- The host upload and connection count become bounded without assigning normal
viewer egress to an SFU.
- ICE, TURN, DTLS-SRTP, RTP recovery, congestion control, codec negotiation,
jitter buffering, and browser rendering remain provided by WebRTC.
- Deterministic assignment is small enough to inspect and reproduce.
Negative:
- Every relay decodes and re-encodes, adding CPU/GPU load, latency, and
generational quality loss.
- Every relay adds a network hop and becomes an availability dependency for its
descendants.
- Relay upload, battery, background suspension, and peer IP exposure affect
viewers, not just the broadcaster.
- Two one-child chains need depth four for eight viewers, so the latency target
is uncertain by design.
## Relationship To Existing ADRs
- ADR-0001 remains the accepted production baseline. This proposal narrows its
peer-tree rejection only enough to run an isolated experiment.
- Draft ADR-0003 in PR #12 remains the separate optional-SFU proposal. This
experiment neither accepts/removes it nor implements the required automatic
P2P/peer-assisted/SFU route controller.
- If a later ADR accepts peer-assisted media, it must state exactly which parts
of ADR-0001 and ADR-0003 it supersedes.
## References
Sources were checked on 2026-08-19:
- [WebRTC](https://w3c.github.io/webrtc-pc/)
- [WebRTC Encoded Transform](https://w3c.github.io/webrtc-encoded-transform/)
- [WebCodecs](https://www.w3.org/TR/webcodecs/)
- [WebRTC Data Channels, RFC 8831](https://www.rfc-editor.org/rfc/rfc8831.html)
- [libwebrtc video send stream](https://webrtc.googlesource.com/src/+/refs/heads/main/video/video_send_stream_impl.cc)
- [libwebrtc video encoder factory](https://webrtc.googlesource.com/src/+/refs/heads/main/api/video_codecs/video_encoder_factory.h)
- [TeamSpeak single-encoding explanation](https://community.teamspeak.com/t/ts6-beta-community-update-insights/58116/208)
- [SplitStream, SOSP 2003](https://www.microsoft.com/en-us/research/publication/splitstream-high-bandwidth-multicast-in-a-cooperative-environment/)
@@ -0,0 +1,232 @@
# ADR-0005: Automatic Hybrid Media Routing
- Status: Proposed - Experiment Only
- Date: 2026-08-19
## Context
The product has always required route selection and recovery to be automatic
and invisible to the broadcaster and viewers. Earlier repository wording that
prohibited silent migration was an incorrect interpretation and is superseded
by this proposal. Simplicity constrains the controller and user experience; it
does not require users to choose a media topology.
The ordered preference remains:
1. direct P2P edges;
2. peer-assisted forwarding;
3. an SFU supplied by the deployment; and
4. bounded waiting or failure when no route can satisfy the budgets.
Every P2P edge independently tries direct ICE and authenticated TURN fallback.
TURN is not a separate room topology. The host and every future native/encoded
relay have a downstream budget of at most two active media edges. The current
browser relay remains stricter at one child.
ADR-0004 implements automatic peer assignment but not cross-mode fallback.
Draft PR #12 implements a mutually exclusive process-wide `p2p|sfu` mode. That
model cannot satisfy the required priority or minimize server egress.
## Proposed Experiment
Treat the SFU as a virtual parent for selected fallback roots, not as an
all-room replacement. Keep the existing peer-assisted topology as the active
baseline and add one in-memory `MediaRouteController` per room.
```text
normal: host -> root A -> ...
host -> root B -> ...
fallback: host -> remaining peer root -> ...
host -> SFU -> selected fallback roots -> existing descendants
```
Only fallback roots subscribe directly to the SFU. Each root can continue to
feed its existing peer child, so SFU egress is approximately `K*B`, where `K`
is the number of SFU roots, rather than necessarily `N*B` for all viewers.
Deployment decides whether SFU capacity exists by configuring the complete
LiveKit endpoint/key/secret tuple. There is no user-facing topology selector and
no process-wide `MEDIA_MODE` union. Without the tuple, the same controller ends
at peer assistance and bounded waiting/failure.
## State And Wire
The server holds one room revision with a participant assignment for every
retained member, plus at most one pending room plan:
```ts
type Upstream =
| { kind: "none" }
| { kind: "peer"; peerId: string }
| { kind: "sfu" };
interface ParticipantRoute {
upstream: Upstream;
childPeerIds: string[];
}
interface RoomMediaRoute {
revision: number;
assignments: Map<string, ParticipantRoute>;
sfu: {
publicationGeneration: string | null;
rootPeerIds: string[];
};
}
```
The server mapping is authoritative. Each participant receives only its own
assignment for that room revision; it never receives or edits the complete
mapping. The explicit SFU publication generation prevents an old and a new
publisher from being counted as one logical edge during commit or rollback.
Minimal protocol additions:
- `route-update { revision, phase: "prepare" | "active", assignment }`;
- `sfu-config { revision, url, token }`;
- `route-ready { revision, phase: "prepare" | "active" }`;
- `route-failed { revision, phase, connectionId }`; and
- `refresh-sfu { revision }`.
Authentication carries only the current participant assignment and room
revision. Existing peer signaling keeps its 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.
## Transition
1. Keep two ordinary peer roots while those routes work.
2. A failed edge first exhausts its current ICE restart/rebuild and per-edge
direct/TURN options.
3. The server computes revision `R+1` without mutating the active topology. It
sends a prepare plan only to the host and required fallback roots.
4. Those participants establish actual LiveKit connections with
`autoSubscribe:false`, but the host does not publish and roots do not
subscribe yet. `prepareConnection()` may warm DNS/TLS earlier but is not a
ready signal.
5. After every required participant acknowledges prepare, the server commits
the active revision. The host synchronously closes the selected direct root
before publishing to the SFU. Thus:
`directHostChildren + activeSfuPublicationGenerations <= 2`.
`activeSfuPublicationGenerations` is zero or one. A new generation cannot
publish until the replaced direct edge and any old SFU generation are
inactive.
6. Allowlisted fallback roots explicitly subscribe. On their first new video
track, they atomically replace their upstream stream, acknowledge active,
and feed the existing downstream relay without rebuilding descendants.
7. A prepare timeout increments the revision and restores the unchanged active
route. A failure after commit creates a new rollback/failure revision; stale
acknowledgements cannot revive the abandoned plan.
SFU fallback is sticky until the current share stops. A new sharing generation
starts from the cheapest available route. The first experiment does not
continuously fail back during a live share, avoiding oscillation without adding
a score or hysteresis framework.
Use make-before-break when a downstream slot is free. When both slots are in
use, strict fanout requires warm break-before-make: participants prepare the new
transport, then the host releases one media edge before publishing. Zero
interruption, no standby path, and a hard two-edge maximum cannot all be
guaranteed at once.
## Triggers And Budgets
The first controller reacts only to discrete events:
- no eligible peer slot or the accepted maximum depth;
- exhausted ICE/rebuild for an assigned edge;
- relay participant departure;
- unsupported route capability; or
- a hard bounded-send-queue overflow on a future encoded-object route.
It does not combine RTT, CPU, bitrate, geography, or a synthetic health score.
Measurements may add a new explicit trigger only through a reviewed change.
`MAX_SFU_ROOTS_PER_ROOM` bounds server egress. Exhausting that budget waits or
fails explicitly; it never creates a third host edge or silently fans the SFU
out to the whole room.
Sub-second failure recovery is a target after failure detection. The current
30-second control heartbeat cannot meet it for silent partitions, so the media
plane needs a small 100-200 ms liveness/queue signal or an equivalent native
transport event. This signal must be measured before its interval is fixed.
## Security And Privacy
LiveKit credentials remain short-lived, room-bound, role-bound, and memory-only.
Only the host may publish screen tracks. Subscription permissions allow only
the current fallback-root identities. Ordinary SFU media is not end-to-end
encrypted from the SFU operator; the deployment and UI must not claim otherwise.
## Implementation Order
1. Port only LiveKit dependencies, token issuance, deployment templates, and
thin publisher/subscriber classes from Draft PR #12. Do not merge its
mutually exclusive protocol or page branches.
2. Split publisher lifecycle into connect, activate, deactivate, and profile
update; make subscriber connections selective and inactive until assigned.
3. Add versioned route state and pure invariant/property tests.
4. Implement server prepare/commit/abort and strict authorization.
5. Integrate host/viewer first-frame switching while retaining relay children.
6. Extend the tracked `1/3/5/8` benchmark with automatic fallback, direct/TURN,
source/profile/pause/stop, and server-egress measurements.
## Acceptance Gates
- Host active media edges never exceed two across every prepare, commit,
rollback, reconnect, and stale-message sequence.
- Current browser relays never exceed one child; future capacity two is exposed
only by a proven native/encoded capability.
- Only necessary fallback roots receive SFU media and the configured root/egress
budget is never exceeded.
- Prepare failure leaves the old active route unchanged.
- First new decodable picture arrives within one second after a route failure is
detected in the reference regional network.
- 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.
- LiveKit UDP, TCP, and TURN fallback are independently verified before the SFU
can be called a reliable final route.
## Consequences
Positive:
- Users do not select or understand a topology.
- Host fanout remains bounded while server media egress is paid only for
fallback roots.
- The ordinary peer path and its direct/TURN behavior remain reusable.
- Revisioned prepare/commit isolates stale asynchronous results without a
continuous optimizer.
Negative:
- Strict two-edge migration can include a bounded interruption.
- LiveKit adds an optional operational dependency and can inspect ordinary SFU
media.
- A media liveness signal and versioned cross-client transition expand the
state space and require real failure-injection tests.
## Relationship To Existing ADRs
- This proposal corrects the automatic-migration interpretation in ADR-0001
without changing the currently deployed MVP.
- ADR-0004 remains the bounded full-stream browser-relay experiment.
- If accepted, this ADR supersedes Draft ADR-0003's process-wide explicit media
mode with automatic hybrid fallback using the same optional LiveKit building
blocks.
- Native shared encoding and two-tree striped distribution remain orthogonal
data-plane experiments under separate decisions.
## References
- [LiveKit selective subscription](https://docs.livekit.io/transport/media/subscribe/#selective-subscription)
- [LiveKit track subscription permissions](https://docs.livekit.io/transport/media/publish/#track-permissions)
- [LiveKit connection preparation](https://docs.livekit.io/reference/client-sdk-js/functions/Room.prepareConnection.html)
- [WebRTC](https://w3c.github.io/webrtc-pc/)
+19 -6
View File
@@ -15,19 +15,24 @@
| 当前阶段、下一步和阻塞项 | `docs/status.md` | 就地更新,最多 120 行且不超过 8 KB |
| 当前可验收需求 | `docs/需求理解.md` | 修改现行规范;变更历史交给 Git |
| 架构取舍与原因 | `docs/adr/` | 状态化管理;新决定 supersede 旧 ADR |
| 可复核的外部证据 | `docs/research/` | 标注日期、来源和适用边界,过时结论要更新或标记 |
| 可复核的证据、保留候选与验收门槛 | `docs/research/` | 标注日期、来源和适用边界,过时结论要更新或标记 |
| 已完成工作的时间线 | commit、PR、issue | 不复制进常驻 memory |
每个事实只设一个权威归属,顶层文件只索引,不复制深层正文。一次检查点的最小真相集只包含后续执行无法安全重建的内容:已接受且仍生效的要求/约束、当前实现与证据边界、下一项可执行工作、真实阻塞或未决项,以及需要继续工作的分支/PR。能从代码、测试、Git 或权威资料廉价恢复的细节不进入常驻快照。
## 长 Session 检查点
在以下时机更新仓库快照:
- 用户确认或改变产品范围、优先级、约束或工作习惯后;
- 一个重要设计被接受、推翻或被实测结果修正后;
- 长任务即将发生上下文压缩、交接或暂停前;
- 一个功能分支准备提交或 PR 前。
- 用户确认或改变产品范围、优先级、约束或工作习惯后,在依赖该结论的设计或实现继续前;
- 一个重要设计、调研结论或验收门槛被接受、推翻或被实测结果修正后;
- 从调研、设计、实现、验证中的一个实质阶段进入下一阶段时;
- 切换分支、提交/更新 PR、agent 交接或任务交接前;
- 长任务可能自动压缩、即将暂停或离开当前 session 前。
检查点只保存以后仍需要的结论、当前状态、下一步和阻塞项。不要保存原始对话、完整日志、工具输出、临时文件路径、重复的代码结构、可随时重新搜索的常识或已经在其他规范中存在的内容。
检查点应立即修改事实的权威归属文件;只有当前全局快照确实改变时才同步修改 `project-memory.md` 或 `status.md`。不要等到 session 末尾才补写,也不要为每个文件、函数、工具调用或未形成结论的探索建立检查点。
检查点只保存以后仍需要的结论、当前状态、下一步和阻塞项。不要保存原始对话、完整日志、工具输出、临时文件路径、重复的代码结构、可随时重新搜索的常识、过程性 TODO 流水账或已经在其他规范中存在的内容。
恢复上下文时按以下顺序读取:`AGENTS.md`、`docs/project-memory.md`、`docs/status.md`、相关需求/ADR/调研,然后检查当前分支、`git status`、最近 commit 和 PR 状态。任何摘要与仓库冲突时,以当前代码、测试和正式文档为准。
@@ -40,6 +45,14 @@
- 已完成的临时计划从 `status.md` 删除,由合并后的 PR 和 commit 保存历史。
- 每个重要 PR 都检查文档是否互相矛盾、链接是否仍有效、memory/status 是否仍代表当前事实。
## 验证节奏
- 迭代中运行能覆盖当前改动的最小定向检查;同一未变化路径已经通过的昂贵检查不因相邻小改动自动失效。
- 完整测试套件、真实浏览器矩阵、长时稳定性、网络整形、跨设备和部署验证按一个完整候选或验收阶段批量运行。只有相关媒体路径、协议、配置、依赖、环境或验收门槛改变,或者上次结果失败/含糊时才重跑对应证据。
- 文档改动默认只运行 Markdown/链接/whitespace/diff 检查;不为与运行时代码无关的改动重跑 npm 全套或媒体基准。
- 在 `status.md`、调研或 ADR 中记录昂贵验证对应的 commit、环境、结果和适用边界,PR 可以引用该记录,使后续工作能判断证据是否仍有效,而不是机械重跑。
- 发布、安全边界、数据库迁移和部署切换仍按其风险执行必要的最终门禁;“批量验证”不是跳过会被当前变更影响的检查。
## 在线调研规则
非平凡的设计、实现和 bug 修复先读仓库,再查当前的一手资料。优先级为规范/官方文档、官方源码与维护者说明、原始论文,然后才是高质量社区复现与运行经验。安全、网络协议、浏览器能力、依赖版本和云服务行为属于易变化信息,必须按当前日期重新验证。
+51 -52
View File
@@ -4,70 +4,69 @@ Last updated: 2026-08-19
## Confirmed Intent
- The product is a Discord/KOOK/Oopz/TeamSpeak-like screen-sharing tool for gaming with friends.
- One user broadcasts a game, application, or display; several friends watch with very low latency.
- The normal use case is a small, trusted friend group. Public or large-scale streaming is explicitly out of scope and can be handled by OBS/Twitch-class services.
- Viewers should be able to open an invite link in a desktop or mobile browser without installing a dedicated client.
- The system should resemble a Photon-style developer experience: a central room/rendezvous service establishes sessions while realtime traffic is carried by clients whenever possible.
- Server bandwidth cost is a primary constraint. The default media path remains P2P-first, with servers responsible for rooms, signaling, STUN, TURN fallback, and observability. Any topology change requires separate measurements and an ADR.
- Avoiding TeamSpeak-like partial reachability is a primary requirement: every broadcaster-viewer pair must independently have TURN/UDP and TURN/TCP candidates available when direct ICE cannot connect. TURN/TLS is an optional compatibility enhancement, not a production prerequisite.
- A web experience is preferred for convenience, but using a desktop sender is acceptable when it materially improves game capture, audio capture, or hardware encoding.
- All project documentation, memory, code, `AGENTS.md`, and `.codex/` configuration must live in this Git repository and remain tracked for cross-device development.
- Normal maintenance uses short-lived branches, focused commits, remote backup, pull requests, checks, merge, and branch deletion.
- Long conversations require explicit repository checkpoints. Current memory is curated and rewritten when facts change; it must not grow as a transcript or duplicate Git history.
- Material designs, fixes, and implementations begin with current online research of official docs and established projects, supplemented by community reports or papers where useful.
- Engineering follows Occam's razor: use the simplest complete solution for verified needs and reject speculative scale, abstractions, services, and compatibility work until evidence justifies them.
- Build Discord/KOOK/Oopz/TeamSpeak-like low-latency game screen sharing for one broadcaster and a small trusted friend group. Public or large broadcasts belong on OBS/Twitch-class services.
- Viewers should join from a normal desktop or mobile browser. A packaged/native sender is a planned later stage for shared encoding and may also improve capture and audio without changing the Web viewer requirement.
- Use a Photon-style central rendezvous service for access, rooms, signaling, deterministic topology, STUN, TURN fallback, and observability while clients carry media whenever practical.
- Server bandwidth cost is a primary constraint. The ordered media ladder is direct P2P, then measured peer assistance, then an enabled user-operated or central SFU fallback. Route selection and failure recovery must be automatic and viewer-transparent, while remaining deterministic, budget-driven, and small enough to reason about. This has always been the product intent; earlier text prohibiting silent migration was an incorrect interpretation and is superseded.
- Host and relay-capable endpoints each have a target budget of at most two downstream media edges. Every P2P edge independently uses direct ICE or authenticated TURN fallback. With two edges, the long-term native/encoded-object path should reuse one compatible encoded output; the current browser experiment is stricter at one viewer child and re-encodes.
- Packet or layer striping across two peer trees is a recorded candidate for reducing endpoint upload toward one full-stream bitrate. It requires a separate bounded experiment for multi-parent assembly, loss recovery, synchronization, churn, and sub-second latency; it is not part of the current full-stream two-chain implementation.
- Browser convenience does not override reachability, measurable performance, or honest resource accounting. Shared encoding reduces compute, not the last-hop copy required by every viewer.
- Durable decisions, current snapshots, research, code, `AGENTS.md`, and `.codex/` belong in Git. Memory and status are rewritten in place rather than kept as transcripts.
- Research current official sources and established implementations before material work. Apply Occam's razor and reject speculative protocols, services, scoring, and scale.
## Current Recommendation
- Start with a Windows Chrome/Edge sharing MVP and a responsive Web viewer using one `RTCPeerConnection` per viewer. Validate current Android Chrome and iOS Safari as viewing endpoints.
- Rooms default to one broadcaster and at most eight viewers, with a deployment range of 1 through 16. Treat eight as an admission default rather than a validated performance claim; measure publisher upload, encoder load, latency, and stability before describing a supported 1:8 envelope.
- Use HTTPS/WSS signaling, trickle ICE, STUN, and authenticated coturn candidates. Prefer direct UDP, then relay UDP, with TURN/TCP as the required non-UDP fallback. Optional TURN/TLS uses standard TCP 5349 by default; TCP 443 is reserved for deployments with a dedicated public IP or validated L4/SNI routing.
- Allow mixed connectivity in one room: direct viewers stay direct while only incompatible network pairs consume TURN bandwidth.
- Treat 1080p60 at 8 Mbps as a best-effort ceiling, not a universal guarantee. Keep three simple manual profiles: 1080p60 at 8 Mbps, 1080p30 at 5 Mbps, and 720p30 at 3 Mbps. Live profile changes do not reopen the source picker. Keep `contentHint = "motion"` and explicit `balanced` degradation, but do not claim resolution-first behavior; actual frame-rate and resolution tradeoffs require browser, hardware, codec, and network measurements.
- Do not add custom scene detection or dynamic-FPS control until WebRTC statistics and host resource measurements show that browser capture, encoding, and congestion behavior leave a material problem.
- Leave codec order at the browser default in the first PoC and record the negotiated codec, encoder implementation, and power efficiency. Prefer H.264 only after target-machine measurements show that it is the hardware-efficient path; retain VP8 compatibility.
- Add an Electron or native Windows sender only after browser measurements identify capture, application-audio, or encode bottlenecks.
- Keep one independent publisher-to-viewer connection in the Web PoC until a separately measured and reversible topology decision is accepted.
- Keep room policy deployment-driven and small. Public and password-only deployments use random temporary rooms. A deployment that configures both the whole-site password and a SQLite path gets sequential, non-expiring protected rooms; stopping a share leaves viewers waiting and does not destroy the room.
- Keep the deployed Windows Chrome/Edge broadcaster and responsive Web viewer as the measurement baseline. Validate current Android Chrome and iOS Safari as leaves.
- Use direct host P2P for one or two viewers. Draft ADR-0004 and Draft PR #13 own the default-off experiment for later viewers: two sticky deterministic chains, host capacity two, viewer capacity one, no proactive rebalance or composite score, and no more than eight viewers when `PEER_ASSISTED_MEDIA=true`.
- 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 full-stream browser experiment bounded. If it needs a different media plane, mark that experiment failed and evaluate native RTP relay, encoded-object striping, SVC, or FEC in separate measured spikes rather than hiding the change inside ADR-0004.
- 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.
- Keep user-operated mini-SFU deployment and central single-node SFU Draft PR #12 as optional infrastructure. When configured, the route controller may select them automatically as the last fallback; their fanout egress belongs to their operator and does not justify Redis or multi-node infrastructure.
- Add automatic routing only through a separate, measured controller contract. It must keep the host at no more than two media edges, prefer endpoint-carried traffic over central egress, and use discrete capacity/failure events instead of continuous multi-metric scoring. Cross-mode live migration, make-before-break overlap, and rollback semantics remain unimplemented and require an ADR before code.
- Prefer direct UDP, then TURN/UDP, with TURN/TCP as the required non-UDP fallback. Optional TURN/TLS uses TCP 5349 by default; TCP 443 needs a dedicated address or validated L4/SNI routing.
- Treat 1080p60 at 8 Mbps as a best-effort ceiling. Keep the deployed 1080p60 at 8 Mbps, 1080p30 at 5 Mbps, and 720p30 at 3 Mbps profiles switchable during sharing. `balanced` does not guarantee resolution-first behavior; measure actual browser tradeoffs.
- Do not add custom scene detection or dynamic-FPS control until WebRTC statistics and host resource measurements prove a material gap. Keep browser codec order until target hardware proves a more efficient common codec.
- Keep room policy deployment-driven. Public and password-only deployments use random temporary rooms. A site password plus SQLite path enables sequential persistent rooms; stopping sharing leaves the room and viewer link available.
## Current Implementation
- The repository contains a single npm package using Node.js 24, React, TypeScript, Vite, native WebRTC, `ws`, Zod, Vitest, and a separate coturn deployment. The server defaults to an all-interface listener for LAN development and containers; the bare-metal reverse-proxy baseline explicitly binds loopback. It provides a process-only `/healthz` endpoint and requires STUN plus explicit TURN/UDP and TURN/TCP URLs before production startup. TURN/TLS is accepted but optional; configuration checks do not establish public-network reachability.
- The Web PoC implements capture-before-room creation, live source and profile changes without renegotiating healthy peers, three manual quality profiles with an explicit `balanced` sender preference, temporary picture pause with audio unaffected, one independent peer connection per viewer, stable signaling reconnect identities that also work for LAN viewers on HTTP, explicit ICE restart or peer rebuild, host session generation isolation, viewer connection-generation guards for asynchronous signaling and stats, short-lived TURN credentials, clearer waiting/TURN states, and local WebRTC statistics.
- Access is deliberately small: `ACCESS_PASSWORD` is optional, site-wide, and accepts 1 through 128 visible ASCII characters when non-empty. When configured, host and viewer routes first establish a 12-hour stateless HMAC HttpOnly `SameSite=Strict` cookie; room creation and WebSocket upgrade accept that cookie and no direct room-creation Bearer bypass. There are no accounts, JWTs, server-side access-session maps, or logout flow.
- ADR-0002 accepts an optional `ROOM_DATABASE_PATH` only alongside `ACCESS_PASSWORD`. In that mode, built-in `node:sqlite` stores only an auto-incremented room ID and host-token digest; links do not expire and stopping sharing leaves the room waiting. Without the path, rooms remain random and temporary. Public mode can never use sequential persistent rooms. Commit `5b2fb005f6f7` is deployed with `/var/lib/screener/rooms.sqlite`; the state directory and database permissions are verified, and room ID `1` survived the deployment restart.
- `/r/{code}` carries no viewer token or fragment, `/join` accepts only the numeric code, and the 256-bit host token stays internal to host authentication. In public mode the random code is the sole viewing capability and is not a strong privacy guarantee, so private Internet deployments should configure `ACCESS_PASSWORD`.
- The Web control plane is deployed at `https://share.bonfire.icu` behind nginx with Node.js 24.19.0, and `turn.bonfire.icu` runs authenticated coturn 4.17.2 on standard UDP/TCP 3478. HTTPS, WSS, room authentication, certificate renewal, public STUN, authenticated TURN/UDP and TURN/TCP allocations, and relay-only bidirectional data paths are verified. TURN/TLS is intentionally not enabled.
- Automated checks pass 108 Vitest tests and both production builds. Release `5b2fb005f6f7` passed loopback and public HTTPS/access-gate deployment checks with zero automatic service restarts; nginx, coturn, and the existing blog remained healthy. Same-machine synthetic-media Chromium recovery and public relay-only DataChannel evidence remain from the earlier baseline. A real live quality/pause cycle, persistent-room stop/reuse browser cycle, real screen/game audio, heterogeneous media sessions, mobile lifecycle handling, and latency or quality targets remain unverified.
- No infrastructure blocker remains for the current deployment. The live-control browser cycle and real-device media matrix are bounded in `docs/status.md`; neither justifies a native sender yet.
- The repository is one npm package using Node.js 24, React, TypeScript, Vite, native WebRTC, `ws`, Zod, Vitest, and separate coturn. LAN/container listening defaults to all interfaces; bare-metal reverse-proxy deployment binds loopback and exposes `/healthz`.
- Production commit `5b2fb005f6f7` is deployed at `https://share.bonfire.icu`. It provides live source and quality changes without rebuilding healthy peers, video-only pause, clearer connection states, and the standard one-host-`RTCPeerConnection`-per-viewer path. Production therefore still exceeds the two-edge host target above two viewers.
- Production supports the three quality profiles above with `contentHint = "motion"` and explicit `degradationPreference = "balanced"`. A real live quality/pause cycle remains unverified.
- Access remains deliberately small: optional site-wide `ACCESS_PASSWORD`, a 12-hour stateless HMAC HttpOnly `SameSite=Strict` cookie, internal host token, role-bound signaling, Origin/payload checks, and no accounts, JWTs, session map, or logout flow.
- With `ROOM_DATABASE_PATH` and the site password, built-in SQLite stores only auto-incremented room ID and host-token digest. Links persist and stopping sharing leaves viewers waiting. Production room ID `1` survived the `5b2fb005f6f7` deployment restart. Without the path, rooms remain random and temporary; public mode cannot use sequential rooms.
- 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.
- Draft PR #13 contains deterministic server/client peer-assisted assignment, standard remote-track relay, and synchronized 1080p60/1080p30/720p30 room profiles. It defaults off, is capped at eight viewers, re-encodes at every relay, and is neither merged nor deployed. Native shared encoding and custom encoded browser transport are not implemented.
- The tracked Chrome 151 harness passed short synthetic `1/3/5/8` runs: host active edges peaked at two, relay edges at one, every viewer kept increasing decoded frames through the measurement window, and the slowest first frame was about 1.05 seconds. A three-viewer first-level relay close recovered in about 5.32 seconds without exceeding host fanout. These are topology/control checks, not quality, load, or endurance proof; silent partitions remain unverified.
- The peer-assisted authenticated snapshot and host-only update synchronize one bounded, non-persistent room profile. Online viewers receive changes; `ViewerRelay` uses the latest desired profile for current and future children, while `HostPeer` serializes sender setup, stream replacement, and last-wins profile updates. The ordinary P2P wire is unchanged. Real 1/3/5/8 quality, resource, and latency measurements remain pending.
- Chromium 151 kept the same relay peer, sender, and signaling generations while changing its sender ceilings from 8 Mbps/60 fps to 5 Mbps/30 fps and 3 Mbps/30 fps; the leaf continued decoding. The synthetic 640x360/30 source proves profile propagation and connection preservation only, not full-resolution quality or load.
- Per-frame encode/decode diagnostics use adjacent non-overlapping `getStats()` deltas rather than connection-lifetime averages; first, empty, changed-stream, and reset intervals remain unknown and rebase.
- The recorded peer-assisted full check passes type checking, 13 Vitest files with 152 tests, and both client and server production builds. Production release `5b2fb005f6f7` separately passed 108 tests and deployment checks.
- Draft SFU PR #12 and ADR-0003 remain unmerged, undeployed, and independently reversible. There is no infrastructure blocker for the deployed Web baseline or bounded peer-assisted experiment.
## Provisional Quality Targets
- Under a controlled direct path with RTT at or below 40 ms and packet loss at or below 1%: glass-to-glass latency p50 at or below 150 ms and p95 at or below 250 ms.
- Under a regional TURN/UDP path with the same endpoint conditions: glass-to-glass latency p95 at or below 350 ms.
- First picture within 3 seconds after a viewer requests to watch, excluding explicit user permission time.
- A stable 60 fps profile should not silently fall below 50 decoded fps for more than 5 consecutive seconds; it must visibly downgrade or report the limiting reason.
- These are engineering targets to validate with timestamp/high-speed-camera tests, not claims about untested networks.
- Controlled direct path at RTT no more than 40 ms and loss no more than 1%: glass-to-glass p50 no more than 150 ms and p95 no more than 250 ms.
- Regional TURN/UDP under the same endpoint conditions: glass-to-glass p95 no more than 350 ms.
- First picture within 3 seconds after a watch request, excluding explicit permission time.
- A 60 fps profile must not remain below 50 decoded fps for more than 5 seconds without a visible downgrade or limiting reason.
These are measurement gates, not performance claims.
## Open Decisions
- The sustainable viewer count for each publisher hardware, quality profile, network class, and media mode. Current P2P use degraded severely as viewers increased; that observation needs instrumented 1/3/5/8 comparison rather than a 1:8 support claim.
- Exact mobile browser support matrix and the required behavior around autoplay, backgrounding, orientation changes, and network handoff.
- Whether the first release is open source, source-available, or proprietary; this affects whether GPL/AGPL projects can be reused rather than only studied.
- Initial deployment regions and expected mainland China/Hong Kong/overseas network mix.
- Whether Windows per-application audio is P0 or whether whole-system loopback is acceptable initially.
- Whether voice chat is ever in scope or the product remains complementary to an existing voice application.
- Sustainable viewer count by broadcaster and relay hardware, quality profile, network class, and media route; use instrumented 1/3/5/8 comparisons rather than a 1:8 claim.
- Whether ADR-0004 passes fanout, re-encoding, depth-four latency, reparenting, silent-partition, and mobile-leaf gates.
- The smallest safe automatic route and migration contract for the ordered P2P, peer-assisted, and optional-SFU ladder, including its bounded overlap and rollback budget.
- Exact mobile lifecycle behavior and whether Windows per-application audio is required for the first release.
- Project license and distribution model, which determines whether GPL/AGPL sources can move beyond study-only use.
- Initial deployment regions and expected mainland China, Hong Kong, and overseas network mix.
- Whether voice chat ever enters scope or Screener stays complementary to an existing voice application.
## Source Of Truth
- Requirements: `docs/需求理解.md`
- First PoC technical design: `docs/方案设计.md`
- Minimal production-shaped deployment: `docs/deployment.md`
- Research and feasibility: `docs/research/webrtc-p2p-screen-sharing.md`
- Realtime quality policy: `docs/research/realtime-quality-adaptation.md`
- Topology decision: `docs/adr/0001-p2p-first-media-topology.md`
- Persistent protected-room decision: `docs/adr/0002-persistent-protected-rooms.md`
- Current phase and next step: `docs/status.md`
- Maintenance and context lifecycle: `docs/maintenance.md`
- Agent workflow: `AGENTS.md` and `.codex/`
- Documentation index and current phase: `docs/README.md` and `docs/status.md`
- Requirements and design: `docs/需求理解.md` and `docs/方案设计.md`
- Media research: `docs/research/webrtc-p2p-screen-sharing.md`, `docs/research/realtime-quality-adaptation.md`, `docs/research/peer-assisted-media.md`, `docs/research/low-server-media-routes.md`, `docs/research/advanced-peer-distribution.md`, and `docs/research/native-shared-encode-sender.md`
- Architecture: ADR-0001, ADR-0002, proposed experiments ADR-0004/ADR-0005, and Draft PR #12's unaccepted ADR-0003
- Deployment and maintenance: `docs/deployment.md`, `docs/maintenance.md`, `AGENTS.md`, and `.codex/`
+196
View File
@@ -0,0 +1,196 @@
# Advanced Peer Distribution
- Research date: 2026-08-19
- Scope: at most eight trusted viewers, sub-second interactive media, endpoint
downstream fanout at most two, and minimal central-server media egress
- Status: candidate routes and bounded spike gates; none is implemented
## Terms
- **Multiple trees:** split one media representation into two stripes and send
each stripe through a different peer tree. A viewer receives both trees; an
internal relay forwards only its assigned stripe. This balances endpoint
upload but requires two parents, assembly, loss recovery, and churn handling.
- **SVC:** one encoder produces a base layer plus dependent temporal or spatial
enhancement layers. It lets a forwarder drop enhancement data, but it does
not create a distribution topology or move layers between transports.
- **Network coding:** send repair symbols formed from several source symbols so
a receiver can recover loss after collecting enough independent symbols. It
adds bytes and CPU; it can reduce retransmission delay but cannot compress the
video or replace route migration after a complete path failure.
Complexity alone is not a rejection reason. A route is retained only when a
bounded experiment shows lower endpoint compute or bandwidth, keeps the user
experience automatic, and meets the latency and queue limits below.
## Cost Accounting
Let `B` be one complete stream bitrate, `N` the viewer count, and `r` repair
overhead. Protocol headers, retransmission, and TURN overhead are additional.
| Route | Host upload | Central egress | Encode work |
| --- | ---: | ---: | --- |
| Current full-stream browser chains | at most `2B` | zero except TURN edges | host up to two encoders; every relay encodes again |
| Native full-stream RTP relay | at most `2B` | zero except TURN edges | relay zero encode; native host can share one encode |
| Two encoded-object stripe trees | about `(1+r)B` | zero except TURN edges | host one encode; relay zero encode |
| Central SFU or MoQ relay | about `B` | about `N*B` | host one encode; server forwards without transcoding |
Useful last-hop traffic remains approximately `N*B`; these routes only decide
which nodes emit the copies. Every P2P edge still runs its own ICE process and
may independently select direct connectivity or TURN.
## Candidate 1: Native RTP Relay
This is the nearest-term high-confidence optimization. A native helper can read
encoded RTP from one WebRTC receiver and write the payload to at most two
independent downstream browser PeerConnections without decoding and encoding
the video again. Each downstream edge keeps its own SSRC, pacing, RTCP,
DTLS-SRTP, ICE, and TURN behavior.
The first Go/Pion spike is video-only VP8 at 720p30 with one browser upstream
and two unchanged browser children. It must prove:
- zero relay video-encoder calls and at least 40% lower relay CPU than browser
remote-track forwarding;
- no more than 25 ms added p95 relay latency;
- per-child bytes no more than 110% of input payload bitrate;
- bounded queue age below 100 ms;
- PLI, NACK/RTX, 1% loss, one direct edge, and one TURN edge; and
- reparented first picture within one second after failure detection.
This lowers relay compute and generational quality loss. It does not lower host
or total upload until combined with a native shared sender or striped media.
Relay-capable participants need the helper; ordinary desktop/mobile browsers
remain valid leaves.
## Candidate 2: Two Stripe Trees With SVC
The target shape assigns every relay-capable endpoint at most two downstream
edges. The host sends stripe A to one seed and stripe B to another. An internal
node forwards its assigned roughly `B/2` stripe to at most two children, so its
total upload stays near `B`. Every viewer receives both stripes from two parents.
The host encodes once and sends about `B`, central egress stays near zero, and
relay nodes do not encode.
SVC may make one stripe a base temporal layer and the other enhancement data,
or duplicate selected base data for resilience. SVC alone cannot implement this
shape: the WebRTC SVC API keeps layers in one encoded RTP stream, and WebRTC
Encoded Transform does not permit moving encoded frames across streams. The
data plane therefore requires either native RTP/object nodes or WebCodecs plus
DataChannel.
Start with single-layer 50/50 frame/object striping, then test `L1T2` temporal
SVC only after transport works. Use two deterministic, interior-node-disjoint
trees; do not add a continuous optimizer.
Go gates:
- host and relay upload no more than `1.1B` without repair;
- unique host encode calls equal input frames and relay encode calls stay zero;
- whole-room useful bytes no more than `1.1*N*B` without repair;
- no more than 20 ms p95 overhead per hop and depth-four p95 no more than
350 ms;
- a path failure returns a decodable picture within one second after detection;
- protection still saves at least 30% host upload relative to `2B`; and
- target desktop relays and mobile leaves pass runtime codec capability probes.
Reject that spike if endpoint upload remains near `2B`, relay encoding returns,
queues cannot be bounded, or supported clients require two unrelated decoders
and application-side picture composition.
## Candidate 3: Browser Encoded Objects
WebCodecs can encode once and expose `EncodedVideoChunk` values. WebRTC
DataChannel can carry framed chunks over peer ICE/TURN paths. This is the
credible pure-browser route to encoded forwarding and later two-tree striping,
but it replaces the browser's RTP media receiver with application framing,
pacing, a jitter buffer, keyframe requests, a WebCodecs renderer, and eventually
A/V synchronization.
The first spike is only `host -> relay -> leaf`, 720p30 video, reliable control,
and unordered media with bounded retransmission. Use chunks no larger than
16 KiB and a hard `bufferedAmount` ceiling.
Go gates:
- one host encode and zero relay encode;
- at least 30% lower host encode CPU than two browser send pipelines and at
least 40% lower relay CPU than remote-track forwarding;
- framing bytes no more than 110% of the RTP baseline;
- two-hop glass-to-glass p95 no more than 250 ms;
- bounded queues and first picture below one second after reroute; and
- required mobile decoders pass, with later A/V drift no more than 50 ms.
## Candidate 4: FEC And Network Coding
FEC is useful only when repair bytes avoid slower RTX or visible freezes. It
does not reduce clean-path bandwidth or survive permanent loss of a full tree
unless redundancy approaches another complete stream.
Before media integration, replay captured RTP/object traces through a simulator:
- random loss at 0%, 1%, 3%, and 5%;
- 20, 50, and 100 ms bursts at 20, 40, and 80 ms RTT;
- baseline NACK/RTX versus XOR or Reed-Solomon `k=4/8` with one/two repair
symbols, then sliding-window RLC only if those results justify it.
Go gates:
- at least 50% lower residual loss/freeze at 1-3% loss or 20-50 ms bursts;
- repair p95 below 100 ms and at least 25% faster than RTX;
- total media plus repair and residual RTX bytes no more than 110% of baseline
media plus RTX bytes;
- no more than 16 ms generation buffering and 15% active repair overhead; and
- coding CPU below roughly 5% of one reference core.
Clean paths should keep repair disabled or below 3-5%. Whole-path failure still
belongs to the route controller.
## Candidate 5: MoQ
Browser WebTransport connects a browser to a server; it does not make a normal
browser an Internet-listening peer relay. A central MoQ deployment therefore
has the same server-egress class as an SFU. Benchmark it only as an optional
central fallback and keep it only if, at equal quality and egress, latency or
server CPU improves at least 20% over the SFU reference and draft-version churn
is isolated behind a small adapter.
## Automatic Route Controller Boundary
The product order is:
`direct -> peer full/native -> peer striped-object -> enabled SFU root -> enabled central fanout -> wait/fail`
The controller must be automatic and invisible. It uses explicit capability
bits, route revisions, media generations, edge budgets, and discrete failures;
it does not expose topology choices to host or viewers. Initial hard triggers
are capacity, maximum depth, ICE/media failure, unsupported codec, and bounded
send-queue overflow. Measurements may later justify additional triggers.
Use make-before-break when a downstream slot is free. With all two slots in
use, strict fanout means break-before-make; zero interruption, no standby, and a
hard two-edge limit cannot all be guaranteed simultaneously. Sub-second recovery
also requires a media/data heartbeat near 100-200 ms rather than the current
30-second control heartbeat.
## Sources And License Boundary
Sources checked on 2026-08-19:
- [WebRTC SVC](https://www.w3.org/TR/webrtc-svc/),
[Encoded Transform](https://www.w3.org/TR/webrtc-encoded-transform/),
[WebCodecs](https://www.w3.org/TR/webcodecs/), and
[WebTransport](https://www.w3.org/TR/webtransport/) - W3C specifications.
- [WebRTC Data Channels, RFC 8831](https://www.rfc-editor.org/rfc/rfc8831.html)
and [Sliding-window RLC, RFC 8681](https://www.rfc-editor.org/rfc/rfc8681.html).
- [Pion WebRTC](https://github.com/pion/webrtc) and
[klauspost/reedsolomon](https://github.com/klauspost/reedsolomon) - MIT; no
source copied.
- [SplitStream](https://www.microsoft.com/en-us/research/publication/splitstream-high-bandwidth-multicast-in-a-cooperative-environment/)
and [Network Coding for Large Scale Content Distribution](https://www.microsoft.com/en-us/research/publication/network-coding-for-large-scale-content-distribution/)
- research evidence, not reusable source licenses.
- [MOQT draft](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) and
[moq-dev/moq](https://github.com/moq-dev/moq) - core MIT/Apache-2.0; its OBS
plugin is separately GPL-2.0-or-later and remains study-only.
No GPL/AGPL implementation code was copied.
+10 -5
View File
@@ -1,6 +1,7 @@
# Agent 上下文与仓库治理调研
- 调研日期:2026-08-18
- 最后复核:2026-08-19
- 范围:Codex、Claude Code、Hermes Agent 与 GitHub Flow 的官方资料
- 目的:让长 session 和多设备开发可恢复,同时限制常驻上下文膨胀与陈旧事实污染
@@ -8,16 +9,17 @@
### Codex
Codex 从 Git 根目录向当前工作目录发现 `AGENTS.md`,越接近当前目录的文件优先级越高;默认合并上限为 32 KiB。适合把仓库级不变量放在根文件,把未来模块特有规则放在相邻目录,而不是把所有细节集中到一个无限增长的说明中。
Codex 从 Git 根目录向当前工作目录发现 `AGENTS.md`,越接近当前目录的文件优先级越高;默认合并上限为 32 KiB。官方还要求规则保持简洁,并建议长任务在重大里程碑后压缩,而不是每轮压缩。因此仓库级不变量应放在根文件,具体流程按需放进维护文档或相邻目录,且同一规则只表达一次。
来源:
- [Codex AGENTS.md 官方说明](https://learn.chatgpt.com/docs/agent-configuration/agents-md)
- [Codex 项目配置官方说明](https://learn.chatgpt.com/docs/config-file/config-basic)
- [OpenAI 模型指南:精简提示与里程碑压缩](https://developers.openai.com/api/docs/guides/latest-model)
### Claude Code
Claude Code 官方建议项目指令具体、简洁、结构化,单个 `CLAUDE.md` 目标控制在 200 行以内,并定期清理冲突或过时指令。官方还区分“上下文中的建议”和“确定执行的 hook”:必须在固定时机运行的检查应写成 hook。根 `CLAUDE.md` 会在压缩后重新注入;大型读取可以交给独立 subagent,主上下文只接收结论。
Claude Code 官方建议项目指令具体、简洁、结构化,单个 `CLAUDE.md` 目标控制在 200 行以内,并定期清理冲突或过时指令。根 `CLAUDE.md` 和 auto memory 会在压缩后从磁盘重新注入,而仅存在于对话的指令可能丢失;`MEMORY.md` 只把前 200 行或 25 KB 作为常驻索引,并要求临近上限时合并或删除陈旧内容。官方还区分“上下文中的建议”和“确定执行的 hook”:必须在固定时机运行的机器检查应写成 hook,大型读取可以交给独立 subagent,主上下文只接收结论。
Claude Code 原生读取 `CLAUDE.md`,官方建议已有 `AGENTS.md` 的仓库通过 `@AGENTS.md` 导入,避免复制两份规则。本仓库因此保留一个极小的根 `CLAUDE.md` 作为兼容入口。
@@ -29,7 +31,7 @@ Claude Code 原生读取 `CLAUDE.md`,官方建议已有 `AGENTS.md` 的仓库
### Hermes Agent
Hermes Agent 的内置 memory 采用硬字符上限,并明确要求容量接近上限时先合并或删除旧条目。官方把“始终需要的关键事实”与“需要时再搜索的历史 session”分开;还明确不应保存原始数据、临时上下文、容易重新发现的信息或已经存在于项目指令中的重复内容。
Hermes Agent 的内置 memory 采用硬字符上限,并明确要求容量接近上限时先合并或删除旧条目。官方把“始终需要的关键事实”与“需要时再搜索的历史 session”分开;还明确不应保存原始数据、临时上下文、容易重新发现的信息或已经存在于项目指令中的重复内容。其 memory 是 session 启动时的冻结快照,进一步说明跨设备、跨 agent 的项目事实应进入 Git 跟踪的项目文件,而不是机器本地 memory。
Hermes Agent 的开发指南强调保持核心窄小、优先扩展已有接口、避免没有当前使用者的 speculative hook,并要求真实路径验证而不只依赖 mock。这些原则适合约束本项目未来的 agent、hook 和脚本增长。
@@ -48,8 +50,11 @@ GitHub 官方流程是短分支、隔离的完整 commit、push 远端、PR 评
- 根 `AGENTS.md` 最多 200 行,只放跨任务不变量;`.codex/config.toml` 将指令发现预算固定为 32 KiB。
- `CLAUDE.md` 只导入 `AGENTS.md`,不维护第二份规则。
- `project-memory.md` 是最多 12 KB 的当前事实快照;`status.md` 是当前阶段快照;两者都更新和清理,不追加对话历史。
- 需求、ADR、调研、代码、测试和 Git/PR 各自承担不同类型的事实,避免同一信息在多个常驻文件里漂移。
- `project-memory.md` 是最多 12 KB 的当前事实快照;`status.md` 是当前阶段快照;两者只保留最小真相集并就地更新,不追加对话、过程 TODO 或完成时间线。
- 上下文按层归属:`AGENTS.md` 保存顶级约束与读取触发器;memory/status 保存当前最小快照;现行需求/设计和 ADR 保存已接受约束;research 保存证据、候选与门槛;Git/PR 保存时间线。顶层只索引深层事实,不复制正文。
- 已接受的需求、设计变化、调研结论、可执行 TODO、阻塞和状态必须在依赖工作继续前落到对应 Git 跟踪文件。阶段边界、分支/PR/agent 交接和压缩风险是自动检查点;恢复后先重读快照和 Git 状态。
- 非平凡变更先做一手资料调研,耐久结论才进入 `docs/research/`;大量原始资料不进入常驻 memory。
- 快速确定的约束由 tracked Git hook 和 CI 执行,判断型检查留给测试和评审。
- 每个重大 PR 都包含一次上下文卫生检查:更新新事实、删除旧事实、确认没有未跟踪项目文件。
上述“自动检查点”和分层归属是本仓库根据官方机制做出的工程约束,不是这些工具自动提供的跨工具同步能力。来源均于 2026-08-19 访问;Hermes Agent 来源为其官方仓库,Claude Code 与 Codex/OpenAI 来源为各自官方文档。
+161
View File
@@ -0,0 +1,161 @@
# Low-Server-Cost Media Routes
- Research date: 2026-08-19
- Scope: one broadcaster, at most eight trusted viewers, low latency, and host
media fanout at most two
- Status: route screening for ADR-0004; only the standard browser relay is in
the current spike
## Current Route Ladder
The smallest current plan is:
1. Use direct host P2P for one or two viewers.
2. For later viewers, test the fixed two-chain browser relay in ADR-0004. It is
disabled by default with `PEER_ASSISTED_MEDIA=false`, is limited to at most
eight viewers, and decodes and re-encodes at every relay.
3. Plan a separate native shared-encode sender regardless of the browser relay
result. It reduces duplicate host encoding while retaining at most two
standard WebRTC edges, so it does not remove their upload cost. The bounded
risk spike is specified in
[Native Shared-Encode Sender](./native-shared-encode-sender.md).
4. Only if browser relay re-encoding is the isolated failure should another
experiment add opt-in native volunteer encoded-RTP relays.
5. Keep a user-operated mini-SFU and a centrally operated single-node SFU as
optional capacity. When configured, a later route controller uses that
capacity automatically only after cheaper endpoint-carried routes fail.
They transfer fanout bandwidth to their operator; they do not make it
disappear.
Any browser-spike failure other than isolated relay re-encoding closes that
browser-relay route. It does not cancel the separate native sender plan. Draft
SFU PR #12 remains unmerged and undeployed. The required automatic cross-mode
fallback is not implemented in the current spike and needs its own bounded ADR.
## Traffic Conservation And The Impossible Triangle
This is an engineering accounting identity, not a named theorem. For `N`
viewers receiving bitrate `B`, useful last-hop delivery is approximately
`N * B`. Ignoring protocol overhead:
`host last-hop copies + peer last-hop copies + server last-hop copies = N * B`
TURN, RTP/QUIC headers, retransmission, FEC, and redundant paths only add traffic.
Encoding once can reduce compute and memory bandwidth, but it cannot remove the
network copy delivered to each viewer.
On today's unicast Internet, a design cannot maximize all three of these for
more than one viewer:
1. near-zero central-server media egress;
2. near-zero host and viewer upload, compute, and installation burden; and
3. ultra-low-latency, robust recovery across churn, NAT, and restrictive networks.
If the server does not emit the copies, endpoints must. If endpoints do not,
the server must. Redundancy can improve recovery but costs traffic and
coordination; removing redundancy leaves a recovery interval after a relay is
lost.
## Route Screening
| Route | Where copies are emitted | Endpoint cost | Current disposition |
| --- | --- | --- | --- |
| Direct host P2P | Host emits one copy per viewer | Host upload and sender pipelines grow with viewers | Keep for one or two viewers |
| Fixed two-chain browser relay | Host emits at most two copies; each relay emits at most one | Ordinary browser, but every relay decodes and re-encodes and adds a hop | Current default-off, maximum-eight-viewer spike |
| Native shared-encode host | Host targets one encode for at most two standard WebRTC edges | libwebrtc public-API proxy risk spike, with Pion as fallback | Planned separate sender phase; still pays per-edge upload |
| Native volunteer encoded-RTP relay | Each volunteer forwards one encoded copy | Native install, RTP/RTCP forwarding, packaging, and opt-in relay policy | Conditional experiment only if relay re-encoding is the sole browser-spike failure |
| User-operated mini-SFU | User's SFU emits viewer copies | Separate deployment and its egress bill; running it on the host does not reduce that host's uplink | Optional capacity; automatic final fallback when enabled |
| Central single-node SFU | Service SFU emits viewer copies | Lowest endpoint relay burden; service pays approximately `N * B` egress | Optional capacity; automatic final fallback when enabled; Draft PR #12 only |
| SVC plus multiple trees | Peers emit striped layer copies across several trees | Layer scheduling, reassembly, redundancy, and more churn state | Separate conditional spike; target endpoint upload near `B` |
| Network coding | Peers or servers emit coded blocks | Generations, buffering, decoding, integrity, and a custom media plane | Trace/FEC spike only; optimize loss recovery, not clean bandwidth |
| MoQ | Publishers and MoQ relays emit object copies | New transport, packaging, player, relay, and auth stack | Optional central-fallback benchmark; still pays server egress |
| Peer-assisted CDN | Peers cache or upload segments/objects | Discovery, locality, scheduling, incentives, abuse, and privacy systems | Reject for this trusted group |
| IP multicast | Multicast routers replicate packets | Requires multicast-enabled hosts and routed networks unavailable to ordinary Internet browsers | Reject outside managed networks |
## Planned Native Sender And Conditional Encoded-RTP Relay
Pion exposes the necessary native building blocks: `TrackRemote.ReadRTP()` reads
encoded RTP, `TrackLocalStaticRTP.WriteRTP()` writes pre-packetized RTP into
standard downstream WebRTC connections, and `RTPSender.ReadRTCP()` exposes
receiver feedback. A native volunteer could therefore terminate an upstream
WebRTC connection and forward encoded RTP to one downstream connection without
decoding or re-encoding. The downstream leaf can remain a normal browser.
This is feasibility evidence, not a ready relay. A real implementation still
has to prove codec/profile agreement, SSRC and sequence/timestamp handling,
RTCP feedback translation, NACK/RTX behavior, congestion control, keyframe
requests, generation changes, security, packaging, and voluntary use. The host
also needs a separate native shared encoder so its two WebRTC packetizers consume
one encoded result. Pion's broadcast example demonstrates server-side RTP
fanout, but it does not supply this end-user topology or product behavior.
The native shared-encode sender is a planned independent phase. Native volunteer
relays remain conditional on measurements isolating relay re-encoding as the
sole browser-spike failure. They must not rescue failures in deterministic
assignment, host fanout, relay loss recovery, ICE/TURN connectivity, mobile
leaves, or end-to-end latency.
## Outside The Current Browser-Track Spike
- **SVC and multiple trees:** WebRTC SVC controls layered encoding; it does not
distribute replicas. SplitStream shows how striping across multiple trees can
spread peer load. A separate encoded-object/native spike now owns its tree
maintenance, reassembly, scheduling, and redundancy gates; none belongs in
the fixed full-stream two-chain experiment.
- **Network coding:** published work improves block distribution in large
overlays. A trace-first FEC experiment must prove lower freeze/repair delay
within strict byte and buffering budgets before any media integration.
- **MoQ:** Media over QUIC provides promising relay-oriented publish/subscribe,
priorities, and partial reliability, but MOQT remains an active Internet-Draft.
Benchmark it only as a central fallback because a browser cannot act as an
inbound WebTransport peer and the relay still pays fanout egress.
- **Peer-assisted CDN:** PCDN research addresses large file or segmented VoD
distribution and ISP/provider cost. Its discovery, caching, locality,
scheduling, incentives, and abuse controls are disproportionate for at most
eight trusted viewers and do not guarantee sub-second live latency.
- **IP multicast:** Internet Group Management and Source-Specific Multicast
require participating hosts and multicast routers. ICE establishes unicast
candidate-pair transports; normal browser WebRTC across NAT and mobile networks
exposes no Internet multicast delivery path.
The measurable gates and exact staged experiments are in
[Advanced Peer Distribution](./advanced-peer-distribution.md).
## Sources And License Boundary
Primary sources checked on 2026-08-19:
- [Pion WebRTC](https://github.com/pion/webrtc) - MIT; no code copied.
- [Pion WebRTC v4 API](https://pkg.go.dev/github.com/pion/webrtc/v4) - API
evidence for RTP and RTCP access.
- [Pion broadcast example](https://github.com/pion/webrtc/tree/main/examples/broadcast)
and [examples index](https://github.com/pion/webrtc/blob/main/examples/README.md)
- MIT; reference behavior only.
- [RTP Topologies, RFC 7667](https://www.rfc-editor.org/rfc/rfc7667.html) and
[ICE, RFC 8445](https://www.rfc-editor.org/rfc/rfc8445.html) - IETF standards
under IETF Trust terms.
- [WebRTC](https://www.w3.org/TR/webrtc/),
[WebRTC Encoded Transform](https://w3c.github.io/webrtc-encoded-transform/#stream-processing),
and [WebRTC SVC](https://www.w3.org/TR/webrtc-svc/) - W3C specifications; SVC
is an encoding control, not a distribution topology.
- [MoQ Transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/),
[MoQ Streaming Format](https://datatracker.ietf.org/doc/draft-ietf-moq-msf/),
and the [MoQ working group](https://datatracker.ietf.org/wg/moq/) - active IETF
work, not final RFCs.
- [moq-dev/moq](https://github.com/moq-dev/moq) - core crates are dual
MIT/Apache-2.0; its separately distributed `cpp/obs` plugin is
GPL-2.0-or-later. No code was copied.
- [SplitStream, SOSP 2003](https://www.microsoft.com/en-us/research/publication/splitstream-high-bandwidth-multicast-in-a-cooperative-environment/)
- published research, no implementation license granted here.
- [Network Coding for Large Scale Content Distribution, INFOCOM 2005](https://www.microsoft.com/en-us/research/publication/network-coding-for-large-scale-content-distribution/)
- published research, no implementation license granted here.
- [Should Internet Service Providers Fear Peer-Assisted Content Distribution?](https://www.usenix.org/conference/imc-05/should-internet-service-providers-fear-peer-assisted-content-distribution)
- published research, no implementation license granted here.
- [IP multicast, RFC 1112](https://www.rfc-editor.org/rfc/rfc1112.html) and
[Source-Specific Multicast, RFC 4607](https://www.rfc-editor.org/rfc/rfc4607.html)
- IETF standards under IETF Trust terms.
- [LiveKit](https://github.com/livekit/livekit) - Apache-2.0; the optional SFU
reference, with no code copied into this research change.
No GPL/AGPL code was copied. Published papers and specifications support design
analysis only; their presence here is not an implementation license.
@@ -0,0 +1,140 @@
# Native Shared-Encode Sender
- Research date: 2026-08-19
- Scope: one Windows game-capture sender, one encoded video stream, and at most
two independent standard WebRTC media edges
- Status: candidate risk spike; not implemented or accepted for production
## Decision Input
TeamSpeak-style shared encoding is a required later sender phase. It can remove
duplicate host encoding work, but it cannot remove the upload copy, RTP state,
or congestion control required by each outgoing edge. The first implementation
must prove one narrow property before adding capture, audio, UI, or packaging:
> Two unmodified browser viewers receive two independent WebRTC sessions while
> one physical encoder invocation produces the compatible encoded access unit
> consumed by both sessions.
The preferred first experiment is a standalone Windows C++ sender using a fixed
libwebrtc revision. It keeps libwebrtc's mature PeerConnection, RTP/RTCP, pacing,
NACK/RTX, DTLS-SRTP, ICE, and TURN behavior on each edge. Electron is not part of
the media experiment; it can only wrap a proven helper later.
## Minimal Risk Spike
```text
existing Node signaling
|
Windows sender.exe
synthetic source -> shared encoder coordinator -> encoder proxy A -> PC A -> browser A
`--------> encoder proxy B -> PC B -> browser B
```
- Windows x64, C++, video-only, fixed 720p30, and exactly two outgoing edges.
- Inject a custom `VideoEncoderFactory` into libwebrtc. It returns two proxy
encoders, one for each normal send pipeline.
- Each proxy keeps its own `EncodedImageCallback`; one coordinator owns the
physical counting encoder.
- The coordinator deduplicates matching input-frame timestamps and publishes
the same reference-counted encoded buffer to both callbacks.
- Reject the second edge when codec, profile, dimensions, or layer configuration
differ. Never hide incompatibility by creating a second physical encoder.
- In this fixed single-layer spike, aggregate the two `SetRates()` requests by
taking the minimum total target bitrate and minimum requested frame rate,
bounded by the selected profile. Do not attempt to merge per-edge loss, RTT,
bandwidth-allocation, or multilayer state. A weak edge can therefore lower
quality for the strong edge; independent quality requires another encode or
a separately accepted layered design.
- Coalesce PLI/keyframe requests so one physical keyframe is delivered to both
packetizers. RTP sequence numbers, SSRC, TWCC, pacing, retransmission caches,
RTCP, DTLS-SRTP, and ICE remain independent per edge.
libwebrtc officially permits an application to inject a `VideoEncoderFactory`
and defines `VideoEncoder::Encode`, `SetRates`, loss/RTT notifications, and the
encoded callback. It does **not** promise cross-PeerConnection shared encoding.
The proxy/coordinator design is therefore an inference that this spike must
validate, not a supported feature claim.
Stop the libwebrtc route if two send pipelines produce incompatible cadence,
timestamps, adaptation, or callback behavior and correctness would require a
fork of `VideoStreamEncoder`, `RtpVideoSender`, or another libwebrtc internal.
Do not carry a permanent media-engine fork for this feature.
## Capture And Hardware Encoding Come Later
Only after the synthetic two-edge property passes:
1. Capture a window or display with Windows Graphics Capture into a D3D11
surface.
2. Convert BGRA to NV12 once. Do not claim zero-copy until profiling proves the
actual texture path.
3. Enumerate hardware Media Foundation H.264 encoders with `MFTEnumEx` and
`MFT_ENUM_FLAG_HARDWARE`, then probe every required input/output type and
`ICodecAPI` control. Enumeration alone is not a capability guarantee.
4. Require proven support for low-latency operation, runtime bitrate control,
and explicit keyframe requests; reject that adapter instead of silently
weakening the contract when a property is unavailable or read-only.
5. Request one H.264 Constrained Baseline, packetization-mode 1 stream, then
inspect the emitted SPS and negotiated `profile-level-id` before accepting
the path. Browser baseline interoperability does not guarantee a particular
hardware MFT or 1080p60; test every target GPU/browser combination.
Audio, per-process WASAPI capture, WGC/DDA fallback, source selection, Electron,
installers, updating, and cross-platform abstractions stay outside the first
spike.
## Pion Fallback
Pion is the fallback when public libwebrtc encoder APIs cannot satisfy the
shared property without internal changes. `TrackLocalStaticSample` owns one
packetizer and writes through a `TrackLocalStaticRTP` that can bind to multiple
PeerConnections, making encoded-payload fanout explicit.
This certainty moves more responsibility into the application. Pion's default
interceptors provide NACK, RTCP reports, statistics, and transport-wide feedback,
but an external encoder still needs explicit send-side bandwidth estimation,
aggregation of both edge targets, encoder bitrate control, PLI handling, and
bounded RTP/RTX queues. A Pion route must use the same minimum-edge rate rule and
must not invent a custom SRTP, ICE, or congestion protocol.
## Acceptance And Failure Gates
- Two current unmodified browsers decode continuously.
- Unique physical encode calls equal unique input frames, not twice that count.
- Direct and TURN edges can coexist; rebuilding one edge does not create a
second physical encoder or interrupt the other edge.
- Limiting one edge lowers the common encoder target without an unbounded pacer
queue; removing that weak edge lets the remaining target recover.
- One viewer's PLI produces one physical keyframe and both viewers recover.
- Measurements include encode count/time, target and actual bitrate, PLI/NACK,
RTT/loss, queue growth, CPU, GPU, and host upload.
Reject a libwebrtc fork, deprecated internal-source encoder APIs, SVC/simulcast,
a second codec, FFmpeg/x264, a third host edge, custom RTP/SRTP or congestion
control, automatic topology switching, audio, UI, and packaging in the risk
spike. These are separate decisions after the shared property is proven.
## Primary Sources And License Boundary
- [libwebrtc PeerConnection factory injection](https://webrtc.googlesource.com/src/+/refs/heads/main/api/create_peerconnection_factory.h)
- [libwebrtc VideoEncoder API](https://webrtc.googlesource.com/src/+/refs/heads/main/api/video_codecs/video_encoder.h)
- [libwebrtc license](https://webrtc.googlesource.com/src/+/refs/heads/main/LICENSE)
and [PATENTS](https://webrtc.googlesource.com/src/+/refs/heads/main/PATENTS)
- [Windows Graphics Capture](https://learn.microsoft.com/en-us/windows/apps/develop/media-authoring-processing/screen-capture)
- [Media Foundation `MFTEnumEx`](https://learn.microsoft.com/en-us/windows/win32/api/mfapi/nf-mfapi-mftenumex)
- [Media Foundation `ICodecAPI::IsSupported`](https://learn.microsoft.com/en-us/windows/win32/api/icodecapi/nf-icodecapi-icodecapi-issupported)
and [`IsModifiable`](https://learn.microsoft.com/en-us/windows/win32/api/icodecapi/nf-icodecapi-icodecapi-ismodifiable)
- [Media Foundation H.264 encoder](https://learn.microsoft.com/en-us/windows/win32/medfound/h-264-video-encoder)
- [Media Foundation low-latency codec property](https://learn.microsoft.com/en-us/windows/win32/medfound/codecapi-avlowlatencymode)
- [Media Foundation force-keyframe property](https://learn.microsoft.com/en-us/windows/win32/medfound/codecapi-avencvideoforcekeyframe)
- [WebRTC video codec requirements, RFC 7742](https://www.rfc-editor.org/rfc/rfc7742.html)
- [WebRTC congestion control requirements, RFC 8836](https://www.rfc-editor.org/rfc/rfc8836.html)
- [Pion track fanout implementation](https://github.com/pion/webrtc/blob/main/track_local_static.go)
- [Pion send-side bandwidth estimator](https://github.com/pion/interceptor/blob/main/pkg/gcc/send_side_bwe.go)
- [Pion WebRTC license](https://github.com/pion/webrtc/blob/main/LICENSE)
- [Electron desktop capture](https://www.electronjs.org/docs/latest/api/desktop-capturer/)
libwebrtc uses a BSD-style license and Pion uses MIT, but distribution still
requires a full libwebrtc third-party notice review and an H.264 patent/licensing
review. No source code from these projects was copied into Screener.
+345
View File
@@ -0,0 +1,345 @@
# Peer-Assisted Media Research
- Research date: 2026-08-19
- Scope: one game-screen broadcaster, at most eight trusted viewers, host media fanout at most two
- Status: evidence for a bounded experiment; not a production topology decision
## Conclusion
Peer-assisted forwarding can cap the broadcaster at one or two outgoing media
edges without making an SFU carry every viewer's traffic. The smallest credible
experiment uses only standard WebRTC media:
1. the browser host sends its screen stream to at most two first-level viewers;
2. each viewer may add the received remote `MediaStreamTrack` values to one
downstream `RTCPeerConnection`; and
3. the signaling server assigns two balanced chains deterministically.
This path reuses WebRTC capture, codec negotiation, RTP, NACK/PLI/RTX, congestion
control, jitter buffering, ICE, STUN, TURN, DTLS-SRTP, and browser rendering. Its
cost is unavoidable in ordinary browsers: every relay decodes and re-encodes
the screen stream. The first spike measures whether that cost is acceptable; it
does not hide it or claim shared encoding.
The product preference is `direct P2P -> peer-assisted -> optional SFU`. Direct
P2P remains the simplest path for one or two viewers. The experiment assigns the
third and later viewers to peers automatically. Deployment decides whether SFU
capacity exists; when it does, a later small route controller must use it as an
automatic last fallback without asking host or viewers to select a topology.
## What Browsers Can Share
The WebRTC specification permits the same `MediaStreamTrack` to be sent more
than once, but each send is represented by a separate `RTCRtpSender`. It does
not promise that these senders share an encoder. Current libwebrtc constructs a
separate `VideoSendStream` and `VideoStreamEncoder` with its own encoder queue
for each send stream. The product must therefore assume one encoder pipeline
per browser send stream even if a particular GPU driver happens to optimize it.
Cloning a track or using `VideoTrackGenerator` can share a capture source or
raw-frame preprocessing. The values entering each `RTCRtpSender` are still raw
`VideoFrame` objects, so this does not share the encoded result.
Sources:
- [WebRTC sender/receiver relationship](https://w3c.github.io/webrtc-pc/#rtp-media-api)
- [Media Capture shared sources](https://w3c.github.io/mediacapture-main/)
- [Media Capture Transform raw `VideoFrame` model](https://w3c.github.io/mediacapture-transform/)
- [libwebrtc creates a send stream per sender](https://webrtc.googlesource.com/src/+/refs/heads/main/media/engine/webrtc_video_engine.cc)
- [libwebrtc creates a `VideoStreamEncoder` per send stream](https://webrtc.googlesource.com/src/+/refs/heads/main/video/video_send_stream_impl.cc)
## Why Encoded Transform Is Not Shared Encoding
WebRTC Encoded Transform runs after a sender encoder and before its packetizer,
or after a receiver depacketizer and before its decoder. Its normative write
algorithm rejects a frame whose owner differs from the transform's frame
source, stating that a processor cannot create frames or move frames between
streams. It therefore cannot tee sender A's encoded frame into sender B's RTP
pipeline.
Replacing the bytes of dummy frames produced by sender B would still run sender
B's encoder and would couple unrelated keyframe, dependency, cadence, and codec
metadata. It is a browser-specific hack rather than a supported pre-encoded RTP
injection point and is outside this experiment.
Encoded Transform could expose the already depacketized frame before decode,
but using that data outside the receiver leads to the custom transport described
below. It is not part of the first spike.
Source: [WebRTC Encoded Transform stream processing](https://w3c.github.io/webrtc-encoded-transform/#stream-processing)
## Standard Browser Relay Versus Encoded Relay
### Standard WebRTC Track Relay
A browser can receive a remote `MediaStreamTrack` and add it to another
`RTCPeerConnection`. This is the compatibility-first route, but the public
track carries decoded frames. Current libwebrtc connects a receiver to a
`VideoSinkInterface<VideoFrame>` and a sender accepts a
`VideoSourceInterface<VideoFrame>` before creating its own encoder pipeline.
It follows from those source interfaces that a normal browser relay decodes and
re-encodes at every hop. It is functional but adds relay CPU/GPU load, encode
delay, and generational quality loss. Each outbound WebRTC send stream creates
an independent sender encoder: the experimental host can therefore encode
twice, while a viewer with one child encodes once.
Sources:
- [libwebrtc video RTP receiver](https://webrtc.googlesource.com/src/+/refs/heads/main/pc/video_rtp_receiver.cc)
- [libwebrtc raw-frame sender source](https://webrtc.googlesource.com/src/+/refs/heads/main/media/engine/webrtc_video_engine.cc)
### Encoded DataChannel Relay, Rejected For The First Spike
WebCodecs accepts and emits `EncodedVideoChunk` values but defines no network
transport and no bridge into `RTCRtpSender`. Sending those chunks through a
DataChannel avoids relay re-encoding, but the application must define:
- protocol and stream-generation versions;
- frame ID, timestamp, duration, key/delta type, and codec configuration;
- fragments and reassembly bounds;
- queue limits and late-frame dropping;
- keyframe requests after join, loss, or reparenting;
- decoder reset and generation changes; and
- explicit failure when the codec or feature set is unsupported.
DataChannel still provides ICE/TURN traversal, DTLS protection, SCTP
fragmentation, partial reliability, and association-level congestion control.
RFC 8831 warns that a large message can monopolize an SCTP association when
message interleaving is unavailable and recommends limiting messages to 16 KB
in that case. Even a minimal custom alternative would need a reliable ordered
control channel plus an unordered, zero-retransmission video channel, with
application messages no larger than 16 KB when interleaving is unavailable.
This is a custom media plane over a proven transport, not a custom UDP stack.
It remains materially more complex than standard WebRTC media and is rejected
for the first spike. It cannot rescue a failed browser-relay gate. The separate
native shared-encode sender described in `low-server-media-routes.md` is planned
independently and requires its own ADR and measurements.
Sources:
- [WebCodecs](https://www.w3.org/TR/webcodecs/)
- [WebRTC Data Channels, RFC 8831](https://www.rfc-editor.org/rfc/rfc8831.html)
## Native TeamSpeak-Style Shared Encoding
TeamSpeak describes its current single-encoding design as encoding once and
sending the result directly to viewers. This removes repeated encoding and
memory cost, but it does not remove one network copy per viewer. It therefore
does not satisfy a hard host fanout of two by itself.
A candidate native Screener sender could preserve standard browser receivers
without writing a new transport. libwebrtc accepts an application-supplied
`VideoEncoderFactory`, which creates a possible boundary for per-stream proxy
encoders backed by one shared hardware encoder. If the bounded spike proves the
callback and adaptation contract, libwebrtc could retain independent RTP
sequence numbers, packetization, SRTP, RTCP, NACK/RTX, ICE, and congestion state
for the two host edges.
This is an engineering inference from the native interfaces, not a ready-made
libwebrtc feature. The coordinator must deduplicate input frames, reconcile
independent rate requests, combine keyframe requests, preserve timestamps, and
require one common codec/profile/resolution/layer. The separate bounded risk
spike must prove whether this route can guarantee one host encode while keeping
normal browser first-hop receivers; requiring a libwebrtc internal fork rejects
the route. Browser JavaScript cannot provide that guarantee.
Sources:
- [TeamSpeak single-encoding explanation](https://community.teamspeak.com/t/ts6-beta-community-update-insights/58116/208)
- [libwebrtc `VideoEncoderFactory`](https://webrtc.googlesource.com/src/+/refs/heads/main/api/video_codecs/video_encoder_factory.h)
- [libwebrtc `VideoEncoder` and encoded callback](https://webrtc.googlesource.com/src/+/refs/heads/main/api/video_codecs/video_encoder.h)
## Deterministic Experimental Topology
The signaling server remains the topology authority. The experiment uses two
sticky, balanced chains selected by one breadth-first walk. Here, one media edge
means one downstream `RTCPeerConnection` carrying the shared stream; selecting
TURN for that connection does not change the edge count.
- the host has capacity for at most two children and every viewer for at most
one;
- the host's child count is a hard invariant and never exceeds two, including
joins, reconnects, and reparenting;
- candidate parents are ordered by depth and server join sequence, and the
first connected node with a free slot is selected;
- joining a viewer never moves an existing assignment; on parent loss, only the
orphaned subtree root is reassigned and its descendants remain attached; and
- if no assigned parent is available, media waits or admission fails explicitly.
The host never opens a third edge.
There is no RTT, bandwidth, CPU, geography, candidate-type, or historical
quality score. There is no optimization loop or live topology rebalancing. With
two host slots and one slot per viewer, eight viewers require a maximum depth of
four; that latency and repeated-encode cost is part of the experiment rather
than something hidden by a scheduler. The controlled relay cohort uses
foreground desktop Chrome/Edge; mobile leaf checks join after their intended
parent so the spike does not pretend to solve background mobile relay policy.
Per-edge ICE remains independent. An edge may be direct or may use authenticated
TURN, so peer assistance reduces normal server media traffic but cannot promise
zero server traffic in restrictive networks.
## Implemented Quality Profile Coordination
The current Draft implementation coordinates one room profile across the
peer-assisted tree without adding adaptation logic. Its strict ID set is
`1080p60`, `1080p30`, and `720p30`, matching the existing capture and sender
profiles. The signaling server stores the current ID in a room-count-bounded
in-memory map, defaults to `1080p60`, includes it in peer-assisted authenticated
snapshots, and broadcasts host changes to online viewers. The value is retained
when sharing stops, removed when the room is abandoned or expires, and is not
written to SQLite. The standard P2P authenticated variant is unchanged and
profile-control messages are forbidden in that mode.
After peer-assisted authentication, the host reasserts its local selection
before reconciling assigned children. A viewer records the snapshot or update
before applying its assignment. `ViewerRelay` keeps a synchronously updated
desired profile and serializes profile changes with stream replacement, so both
an existing child and a later replacement start from the latest target. Inside
`HostPeer`, initial sender configuration, stream replacement, and profile
updates share one mutation queue; operations read the latest desired profile at
execution time, giving rapid changes last-wins behavior without versions or
acknowledgements.
This only removes the previous fixed-1080p60 relay envelope. Browser constraints
and RTP sender parameters remain targets, so achieved bitrate, frame rate,
resolution, encode work, and cross-hop quality still require the measurement
matrix below. It does not add or imply shared encoding.
A controlled Chromium 151 loopback smoke used one host, three viewers, and a
synthetic 640x360/30 source. The downstream relay kept the same peer, sender,
and signaling generations while its configured ceilings changed from
8 Mbps/60 fps to 5 Mbps/30 fps and then 3 Mbps/30 fps. Each real
`setParameters()` call and both host `applyConstraints()` calls succeeded, and
the leaf kept decoding. This verifies wire ordering, relay propagation, and
connection preservation only. It does not verify 1080p output, visual quality,
CPU/GPU cost, TURN, public networks, or endurance.
Peer multicast research such as SplitStream demonstrates why load-balanced,
failure-tolerant overlays normally introduce multiple trees and content
striping. Those mechanisms are intentionally excluded: needing them is a reason
to abandon this small-product experiment, not to expand it.
Source: [SplitStream, SOSP 2003](https://www.microsoft.com/en-us/research/publication/splitstream-high-bandwidth-multicast-in-a-cooperative-environment/)
## Compatibility Boundary
The standard remote-track relay in the first spike uses broadly deployed WebRTC
media APIs and does not depend on Encoded Transform or WebCodecs. Current MDN
browser-compatibility data records `RTCRtpScriptTransform` in
Chrome 141, Firefox 117, and Safari 15.4. `VideoDecoder` is recorded in Chrome
94, Firefox desktop 130, and Safari/iOS 16.4, but not Firefox Android.
`MediaStreamTrackProcessor` remains less portable: Chrome's implementation is
partial, Firefox has no implementation, and Safari added it in version 18.
These compatibility figures explain why the custom encoded alternative is not
the first spike. Even when an API exists, an application must call
`VideoDecoder.isConfigSupported()` for the exact codec configuration. API
presence is not proof that a particular codec, profile, resolution, or hardware
path works.
Sources:
- [Encoded Transform compatibility data](https://raw.githubusercontent.com/mdn/browser-compat-data/main/api/RTCRtpScriptTransform.json)
- [WebCodecs `VideoDecoder` compatibility data](https://raw.githubusercontent.com/mdn/browser-compat-data/main/api/VideoDecoder.json)
- [`MediaStreamTrackProcessor` compatibility data](https://raw.githubusercontent.com/mdn/browser-compat-data/main/api/MediaStreamTrackProcessor.json)
## Resource Model
For observed encoded bitrate `B`, `N` viewers, two host slots, and one viewer
slot:
- host upload is at most approximately `2 * B`, plus transport overhead;
- each relay upload is at most approximately `B`;
- aggregate viewer delivery still requires approximately `N * B` across hosts,
relays, and any TURN paths; and
- signaling/STUN carry negligible media traffic, while a TURN path still incurs
relay ingress and egress for that edge.
Peer assistance distributes traffic; it does not eliminate it. Relay
eligibility must therefore be visible and voluntary in any production design.
This spike has no runtime capability flag: its desktop relay/mobile leaf matrix
is enforced only by controlled join order, so it must not be enabled for
arbitrary users.
## Bounded Spike And Gates
The experiment is disabled by default through `PEER_ASSISTED_MEDIA=false` and
cannot be enabled above eight viewers. It uses the existing standard WebRTC
screen stream, current desktop Chrome/Edge as relay nodes, current Android
Chrome and iOS Safari as required
leaf checks, one child per viewer, and at most eight viewers. It may carry the
existing screen-audio track when the browser provides one. SVC/simulcast,
custom encoded transport, FEC changes, multi-tree striping, transcoding,
background mobile relay, and automatic SFU migration are excluded.
Run 1, 3, 5, and 8 viewers for 30 minutes across the 1080p60, 1080p30, and
720p30 profiles under controlled per-edge RTT at or below 40 ms and loss at or
below 1%. Record topology generation and depth, selected candidate type, host
and relay upload, packets lost, jitter, frames encoded/decoded/dropped, total
encode/decode time, decoded FPS, `qualityLimitationReason`, first picture,
reparent time, CPU, GPU, and glass-to-glass latency.
The in-app per-frame encode/decode fields use adjacent `getStats()` counter
deltas and skip a tick while the same connection already has a sample in
flight. A first sample, zero-frame interval, changed stats object, or counter
reset reports unknown and rebases; a connection-lifetime average is not
accepted as relay-load evidence. Long-run percentiles and event timing still
come from a tracked run manifest plus browser WebRTC diagnostics, not a new
telemetry service.
All hard gates must pass:
1. The host has no more than two outgoing media edges and every viewer has no
more than one child at every sampled instant,
including churn; no hidden direct-host fallback is allowed.
2. Assignment is reproducible from depth and server join order. Standard relay
statistics are expected to show a new outbound RTP encoder at every hop; its
encode time, CPU/GPU cost, and `qualityLimitationReason` are recorded rather
than mislabeled as shared encoding.
3. Excluding explicitly selected TURN edges, application-server media ingress
and egress remains zero. Host and relay upload stay within 20% of
`childCount * B`.
4. First picture is at most 3 seconds, decoded 60 fps does not remain below 50
for more than 5 seconds without an explicit failure, and depth-four p95
glass-to-glass latency is at most 350 ms.
5. Closing either first-level relay so the server observes it immediately starts
the default 5-second disconnect grace. If it does not reconnect during that
grace, deterministic reassignment produces a new decodable picture within the
following 3 seconds without violating host fanout, about 8 seconds total.
6. At each relay, delta encode time per encoded frame stays at or below one
60 fps frame interval on the reference cohort and CPU limitation does not
persist for more than 5 seconds.
7. The standard WebRTC stream works on current Chrome and Edge relay nodes and
on current Android Chrome and iOS Safari leaves.
An ordinary implementation defect may be corrected inside the spike. If a gate
requires an encoded DataChannel/WebCodecs media plane, custom congestion
control, FEC/RTX changes, multiple distribution trees, relay scoring,
transcoding, a codec ladder, or relaxed host fanout, abandon this browser
architecture. Mark ADR-0004 rejected, remove its experimental runtime code and
dependencies, and retain this research. The separate planned native
shared-encode sender still requires its own ADR, lowers encode work rather than
per-edge upload, and cannot rescue other failed browser-relay gates. A rejected
relay experiment keeps standard P2P plus explicit user-operated or central SFU
fallbacks.
Passing these gates proves only that a second design phase is justified. It
does not accept peer-assisted media for production. Production adoption would
need a new ADR, voluntary relay policy, and broader audio/A-V verification. The
native host shared-encode sender is a separate planned phase regardless of this
experiment's result and is not implemented here.
The recovery gate above does not cover silent network partitions. With the
default 30-second heartbeat, server detection can take 30 to 60 seconds before
the same 5-second grace begins; that case remains unverified and must be reported
separately.
## License Boundary
This research uses W3C/IETF specifications, BSD-licensed libwebrtc source,
official TeamSpeak statements, browser-compatibility data, and a published
systems paper. No GPL/AGPL implementation code was copied. GPL/AGPL projects
remain study-only until the project license and distribution model are decided.
@@ -80,6 +80,13 @@ resolution, frame rate, or bitrate.
- Pausing the picture disables the existing video track, producing black video
without closing the room or media connection. Audio remains enabled.
A Chromium 151 loopback smoke with one host, three viewers, and a synthetic
640x360/30 source kept the same relay peer, sender, and signaling generations
while applying 8 Mbps/60 fps, 5 Mbps/30 fps, and 3 Mbps/30 fps sender ceilings.
The leaf continued decoding after both live switches. This verifies control
propagation and connection preservation, not actual 1080p output, visual
quality, CPU/GPU cost, public-network behavior, or sustained performance.
## Deliberate Non-Goals
- No canvas pixel-difference detector, machine-learned rate controller, or
@@ -99,6 +106,15 @@ dropped frames, RTT, packet loss, and host CPU/GPU utilization. A live profile
change must preserve peer connection IDs, avoid a second source prompt, and
visibly converge to the requested bounds.
Compute per-frame encode and decode cost from adjacent samples of cumulative
`totalEncodeTime`/`totalDecodeTime` and frame counters. The first sample, a
zero-frame interval, changed stats object, or a counter reset is unknown and
establishes a new baseline. Skip overlapping sampling ticks on the same peer so
reports cannot complete out of order. Do not use a connection-lifetime average
to judge a later overload. Use browser WebRTC diagnostics plus a small run
manifest for time series and percentiles rather than adding a server telemetry
pipeline.
Compare image readability and motion continuity instead of declaring success
from FPS alone. If reproducible evidence later shows that `balanced` still
oscillates or makes the wrong tradeoff on supported machines, revise the fixed
+9 -7
View File
@@ -2,7 +2,7 @@
- 调研日期:2026-08-18
- 目标场景:一名玩家向少量熟人私密分享,观看者可用手机/桌面浏览器加入,低延迟,尽量不消耗媒体服务器带宽
- 结论状态:可用于原型立项;人数、音频范围和部署地区仍需实测确认
- 结论状态:标准 P2P 原型基线已采用;多人退化已触发独立的 peer-assisted 调研与 Draft SFU 对照,二者都未成为默认路径
## 结论
@@ -16,7 +16,8 @@
|
+---- 仅直连失败的观看者 ---- TURN relay -------+
若未来产品范围超出小房间边界,再单独评估区域 SFU;大规模公开分享直接使用现有直播服务。
首选顺序:一至两名观看者直接 P2P -> 通过门槛后的 peer-assisted -> 部署已提供的可选 SFU。
系统必须无感完成拓扑分配、恢复和必要迁移;大规模公开分享仍直接使用现有直播服务。
```
“让画面跑起来”难度不高;“像 Discord/TeamSpeak 一样在不同 GPU、浏览器、NAT、运营商和弱网中都保持清晰、60 fps、低延迟”难度高。建议把产品分层:
@@ -88,6 +89,7 @@ ICE 是按分享者与每一名观看者的网络组合独立选路,而不是
| --- | ---: | ---: | --- |
| P2P 星型 | `N * B` | 直连时接近 0 | 最符合成本目标;适合少量朋友 |
| P2P + TURN | 仍为 `N * B` | 每条中继约 `B` 入 + `B` 出 | 只解决不可达,不解决分享者上行 |
| Peer-assisted 两链 | 分享端最多约 `2 * B` | 直连边接近 0;TURN 仍按边计费 | 每名 viewer 最多转发一份;标准 browser relay 逐跳解码/重编码 |
| SFU | 约 `B` | `B` 入 + `N * B` 出 | 分享者轻松,但服务器承担全部观看流量 |
| MCU | 约 `B` | 另有解码、合成、重编码 | 本场景没有合成需求,不采用 |
@@ -110,7 +112,7 @@ IETF 对 mesh/SFU 的拓扑说明见 [RFC 7667](https://www.rfc-editor.org/rfc/r
## 推荐的拓扑策略
MVP(也是预期的正常产品形态):
已部署 MVP 基线:
- 小范围观看者默认 P2P,每人一条独立连接。
- 当前 PoC 默认允许八名观看者,部署者可配置 1 至 16,超额连接会被明确拒绝。该数值只控制接入,不代表 1:8 已通过性能验收;必须收集可用上行、实际发送码率、`qualityLimitationReason`、编码耗时和发送队列来确定真实可持续人数。
@@ -118,7 +120,7 @@ MVP(也是预期的正常产品形态):
- 若一开始就有多条 `relay`,应提示服务器带宽正在增加。
- 桌面和手机观看者使用同一个 Web 播放端;分享者不要求朋友安装完整客户端。
本项目不以 SFU 作为正常扩容路径;超过小房间上限时直接建议使用外部直播服务。只有以后改变产品范围时,才根据遥测重新评估房间级 SFU,观察项包括:
新的产品目标要求分享端 fanout 永远不超过二。Proposed ADR-0004 用一个可整体删除的实验验证第三名及后续 viewer 能否由客户端转发;Draft PR #12 则保留可选单节点 SFU 作为独立对照。自动跨模式兜底是产品要求,但当前两个 Draft 都未实现它;超过小房间上限时仍建议使用外部直播服务。观察项包括:
- 正常工作负载持续超过实测可承载的 P2P 人数。
- 第一名或多名观看者已使用 TURN,继续 P2P 会重复占用服务器上行。
@@ -126,7 +128,7 @@ MVP(也是预期的正常产品形态):
- 分享者因 CPU/encoder 限制降质。
- 产品开始要求隐藏好友之间的 IP。
不要在 MVP 做观看者转发树。浏览器对收到的 WebRTC track 再发布可能发生解码/重编码,增加负载、质量损失和一跳延迟;真正的数据包级转发还需要自定义密钥、拥塞、故障切换和原生网络层。
不要把观看者转发树直接加入 MVP。标准浏览器把收到的 WebRTC track 再发布会走解码后的 raw-frame sender,并创建新的编码 pipeline,增加负载、质量损失和逐跳延迟。ADR-0004 只允许用这条标准路径做确定性的两链 spike;若需要 Encoded Transform/DataChannel/WebCodecs 媒体、自定义拥塞控制、FEC/RTX、多树或综合评分,即放弃实验。详细证据见 `peer-assisted-media.md`。
## Web、Electron 与原生能力
@@ -321,5 +323,5 @@ WebRTC 标准没有承诺固定毫秒延迟。工程目标必须带网络条件
3. 从第一天部署 coturn,并验证 direct、TURN/UDP、TURN/TCP 以及移动网络切换;这是避免“部分好友永远看不了”的必要条件。TURN/TLS 按部署需要选配,默认 5349,443 只作为受限网络增强。
4. 观看端优先做成免安装响应式 Web;分享端先 Web 验证,再按捕获/音频实测升级 Electron。
5. 产品代码优先直接使用浏览器 WebRTC API;借鉴 MiroTalk BRO 和 Screego,不在许可证未定前直接 fork GPL/AGPL 代码。
6. 正常人数超过产品上限时引导使用现有直播服务,不为了假设规模提前搭 SFU;若范围以后改变,首选 Broadcast Box 或 LiveKit,不从零写 SFU。
7. 不做客户端转发树和自定义视频协议;它们会把项目从中等难度推到接近自研 Parsec 的高难度。
6. 分享端 hard fanout 为二;一至两名 viewer 走直接 P2P,第三名及以后只通过 ADR-0004 的 bounded peer-assisted spike 验证,不得隐藏回退为更多 host 连接。
7. 可选 SFU 保持可逆且非默认;部署提供后可被后续路由控制器自动用作最后兜底。peer-assisted 若未通过逐跳重编码负载、延迟、兼容和换父门槛就整体放弃,不通过 encoded custom media、评分器、多树或自研 RTP 来挽救。
+53 -49
View File
@@ -4,67 +4,71 @@ Last updated: 2026-08-19
## Phase
The WebRTC proof of concept is deployed at `https://share.bonfire.icu`. Commit
`5b2fb005f6f7` adds live quality changes, picture pause, clearer connection
states, protected SQLite persistence, sequential room IDs, reusable links, and
a waiting state after sharing stops. The production database contains room ID
`1`; the deployment restart preserved that row.
The WebRTC proof of concept at `https://share.bonfire.icu` runs commit
`5b2fb005f6f7` with live quality/source changes, video pause, protected SQLite
rooms, sequential IDs, and reusable links.
The Web PoC includes an explicit `balanced` sender degradation preference,
temporary picture pause with audio unaffected, and clearer stopped/waiting and
TURN-warning states. Real capture, game audio, mobile lifecycle, sustained
multi-viewer behavior, and performance targets remain unverified.
Production still creates one host `RTCPeerConnection` per viewer. Draft PR #13
adds default-off, maximum-eight-viewer peer assistance: two sticky chains,
host capacity two, viewer capacity one, per-hop decode/re-encode, and synchronized
quality profiles. Draft SFU PR #12 is also unmerged and undeployed.
## Established Baseline
## Current Snapshot
- Runtime: Node.js 24, React, TypeScript, Vite, native browser WebRTC, `ws`, Zod, and a separate coturn deployment.
- Product scope: private game sharing for one broadcaster and a small friend group. Rooms default to eight viewers and accept a configured limit from 1 through 16; this is an admission limit, not a verified performance envelope.
- Client behavior: capture precedes room creation; live source and quality changes preserve healthy peer connections; picture pause keeps audio and connections active; `/r/{code}` and `/join` use a numeric room code; every viewer has an independent `RTCPeerConnection`.
- Access control: an optional whole-site `ACCESS_PASSWORD` issues a 12-hour stateless HMAC HttpOnly `SameSite=Strict` cookie. Room creation and WebSocket upgrade accept that cookie with no Bearer bypass; there are no accounts, JWTs, server-side access sessions, or logout flow.
- Room policy: protected deployments may configure SQLite-backed sequential rooms whose links survive stopped sharing and process restarts. Other rooms remain random and temporary. SQLite stores only room ID and host-token digest.
- Media path: direct ICE is preferred per viewer, with authenticated TURN/UDP and TURN/TCP required in production. TURN/TLS remains optional, and direct and relay paths may coexist in one room.
- Diagnostics: peer path, candidates, ICE protocol, local TURN protocol, RTT, bitrate, frame rate, dimensions, loss, jitter, codec, encode time, and quality limitations are read from local WebRTC stats.
- Large public broadcasts remain out of scope and should use OBS/Twitch-class services.
- Capture precedes room creation. Live source and quality changes preserve healthy peers; picture pause keeps audio and connections active. `contentHint = "motion"` and explicit `balanced` degradation do not guarantee resolution-first behavior.
- 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.
- The required media priority has always been direct P2P, then peer assistance, then an enabled user-operated or central SFU fallback, with route allocation and recovery hidden from host and viewers. The current peer-assisted Draft already assigns the first two viewers to the host and later viewers to peers automatically. Cross-mode failure fallback and live migration are not implemented yet; they must preserve the host two-edge budget, minimize server egress, and use a small deterministic state machine rather than composite health scoring.
- Peer-assisted profile state is bounded, server-memory-only, and absent from the ordinary P2P wire. The host reasserts its choice after authentication; online viewers receive changes, and each relay applies the latest desired profile to its current or future child through serialized sender mutations.
## Verification
## Verified Evidence
- `npm run check` passes type checking, 108 Vitest tests, and both production builds.
- Release `5b2fb005f6f7` passed a separate production-process smoke on loopback,
then was atomically activated under `/opt/screener/current`. External HTTPS
health, the protected access gate, authenticated Secure/HttpOnly cookie, and
the new 1080p30 client asset passed after restart. Screener reported zero
automatic restarts and no warning-level journal entries; nginx and coturn
remained active, while `bonfire.icu` and `www.bonfire.icu` continued returning
HTTP 200.
- Headless Chromium 151 established real local peer connections from animated canvas streams, delivered a live video track over direct UDP, and recovered from dropped offer/answer and initial `createOffer` failures.
- Host and viewer generation guards discard delayed signaling, candidate, and stats work after replacement. Synthetic source changes added and removed audio without another offer, preserved healthy peer objects, and stopped retired tracks.
- Unit coverage verifies live capture constraints, balanced sender parameters, video-only pause, sender-update failure and retry, source replacement rollback, and audio `null -> track -> null` changes.
- Headless Chrome layout checks at actual inner widths 500, 781, and 820 px keep all live controls inside the host area, including the forced-relay badge. Chrome clamped the requested 390 px window to 500 px, so a true 390 px browser viewport remains unverified.
- Production configuration fails closed without HTTPS, STUN, TURN credentials, explicit TURN/UDP, or explicit TURN/TCP. Public STUN and authenticated relay-only DataChannel tests passed over TURN/UDP and TURN/TCP.
- Staging runs Debian 12, nginx, Node.js 24.19.0, and coturn 4.17.2. Screener binds loopback behind nginx; the protected SQLite directory and database permissions survive a clean service restart. TURN/TLS is intentionally disabled.
- Production passed 108 tests, both builds, public HTTPS/access/TURN checks, clean activation, and a database restart with room `1` retained.
- Draft PR #13 now provides `npm run benchmark:peer-assisted`; the full check passes type checking, 13 Vitest files with 152 tests, and both production builds.
- A short Chrome 151 synthetic `1/3/5/8` benchmark passed every topology check: host active edges peaked at two, relay edges at one, every viewer kept increasing decoded frames through the measurement window, and the slowest first decoded frame was about 1.05 seconds. Closing a first-level relay in the three-viewer run recovered in about 5.32 seconds without exceeding host fanout two.
- A separate live-profile smoke kept the same relay peer, sender, and signaling generations through 8 Mbps/60, 5 Mbps/30, and 3 Mbps/30 ceilings while its leaf kept decoding.
- Local WebRTC diagnostics derive per-frame encode/decode cost from adjacent non-overlapping `getStats()` samples. First, empty, changed-stream, and reset intervals stay unknown and rebase instead of publishing a misleading lifetime average.
- Production HTTPS/WSS, access cookie, room/WebSocket authorization, certificate renewal, public STUN, and authenticated TURN/UDP and TURN/TCP relay-only bidirectional paths are verified. TURN/TLS is intentionally disabled.
## Unverified Boundaries
- The short harness uses synthetic headless capture and proves topology, controls, stats collection, and recovery only. Full-resolution 30-minute runs, relay CPU/GPU, generational quality, controlled loss/RTT, depth latency, and actual game-capture behavior remain unverified.
- Android Chrome and iOS Safari remain required leaves but are not verified for this topology. There is no runtime relay-capability bit; controlled join order is the only mobile-leaf enforcement, so arbitrary-user deployment is excluded.
- The observed recovery starts from a page close immediately seen by the server. A silent partition can wait 30 to 60 seconds for heartbeat detection before the default 5-second grace; it remains unverified.
- Real screen/game audio, heterogeneous machines and networks, mobile lifecycle behavior, the production live quality/pause cycle, room `1` stop-and-republish/link reuse, and sustained profile performance remain unverified.
- Browser relays do not provide shared encoding. Encoded Transform, DataChannel/WebCodecs media, custom congestion control, multiple trees, and network coding remain researched alternatives rather than current implementation. They may enter a separate bounded experiment only when measurements identify a specific bottleneck and show a plausible sub-second benefit.
- The long-term endpoint budget is at most two downstream edges for both the host and relay-capable viewers, with one compatible encoded output reused across both edges where a native media engine can prove it. The current browser spike remains host capacity two/viewer capacity one and performs a new encode at each relay. Packet/layer striping and multi-parent assembly are recorded, not implemented.
## Next Milestone
Verify a real quality/pause cycle in room ID `1`, stop-and-republish behavior,
link reuse in the browser, and host recovery after a service restart. The
database row itself has already survived the live-controls deployment restart.
Complete ADR-0004 before UI polish: run full-resolution 30-minute `1/3/5/8`
matrices with network shaping and resource/quality/latency capture; exercise source,
profile, stop/restart, rebuild, reparent, signaling and silent-partition paths; and
verify Chrome/Edge relays with Android Chrome and iOS Safari leaves.
Execute and record the manual browser/network matrix:
In parallel, Proposed ADR-0005 defines the smallest automatic route controller
for `direct P2P -> peer-assisted -> optional SFU`. It must use explicit
budgets and discrete failure events, avoid continuous composite scoring, and
specify make-before-break or a bounded visible interruption before any live
migration is implemented. The current Draft still performs no automatic
cross-mode migration.
- real screen/window capture and available game or system audio on Windows Chrome and Edge;
- one broadcaster with 1, 3, 5, and 8 heterogeneous viewers, recording publisher upload, encode load, and server traffic;
- same-LAN direct, cross-network direct, forced relay, mixed direct/relay, and UDP-blocked TURN/TCP paths;
- Android Chrome and iOS Safari playback, orientation, backgrounding, and network handoff;
- live 1080p60 at 8 Mbps, 1080p30 at 5 Mbps, and 720p30 at 3 Mbps changes without a second source prompt or peer rebuild, plus picture pause/resume while audio continues;
- codec implementation, encode load, bitrate, frame rate, first picture, and glass-to-glass latency.
For a disconnect immediately observed by the server, the gate retains the
default 5-second grace followed by at most 3 seconds to restore a decodable
picture, about 8 seconds total. Silent partitions include their detection delay.
## Current Blocker
ADR-0004 gates fail closed for the current full-stream browser path. Native
shared encode and packet/layer striping are separate measured experiments, not
ways to relabel a failed browser-relay result.
- No infrastructure blocker remains. The live-control browser cycle, room `1`
browser lifecycle, and real-device media matrix remain unverified.
## Blockers And Decisions
## Blocking Decisions
Production peer assistance remains blocked on the matrix and ADR-0004 gates.
Automatic cross-mode routing, native shared encode, and multi-tree striping each
require their own bounded ADR/spike.
- Whole-system versus selected-game audio for the first release.
- Initial deployment region and network test cohort.
- Project license and intended distribution model.
- Initial deployment region and network cohort.
- Project license and distribution model.
- Whether peer assistance passes every non-encoding gate; any other failure closes that route.
+26 -4
View File
@@ -2,7 +2,7 @@
## 2026-08-18 14:45
状态:已接受,可进入实现。本文约束首个可测 PoC,不代表全部 P0 已完成。
状态:已接受的首个 PoC 基线。本文不接受后续媒体拓扑;peer-assisted 仅由 ADR-0004 提议为独立实验。
## 目标与边界
@@ -18,7 +18,7 @@
- 超过部署配置上限的观看者被明确拒绝,不静默超载;
- 在代码、测试和真实浏览器验证完成前,不宣称达到延迟或画质目标。
本阶段不实现账号数据库、通用业务数据库、Redis、SFU、simulcast/SVC、观看者转发树、语音聊天、Electron、原生捕获、PWA、录制、遥测平台或多地区调度。唯一持久化例外是可选的 `node:sqlite` 房间身份表。
本基线不实现账号数据库、通用业务数据库、Redis、SFU、simulcast/SVC、观看者转发树、语音聊天、Electron、原生捕获、PWA、录制、遥测平台或多地区调度。唯一持久化例外是可选的 `node:sqlite` 房间身份表。后续 SFU 与 peer-assisted 工作必须留在各自分支和 ADR 中,不能反向把实验复杂度预埋进该基线。
## 技术栈决策
@@ -176,7 +176,7 @@ TURN/TLS 是可选能力,不是生产启动门槛。启用时默认使用 RFC
- 本地 relay candidate 的 `relayProtocol`;规范禁止远端 candidate 暴露该字段,因此不推断远端使用 TURN/UDP、TCP 还是 TLS;
- `currentRoundTripTime`、发送码率和 `availableOutgoingBitrate`;
- 实际帧率、分辨率和丢包;
- codec、编码实现、每帧平均编码耗时和 `qualityLimitationReason`。
- codec、编码实现、最近采样区间内的每帧编解码耗时和 `qualityLimitationReason`;同一连接不并发采样,首个样本、无新帧、统计对象换代或计数器重置时显示未知并重新建立基线,不用连接生命周期均值掩盖后续过载。
观看者显示接收码率、帧率、分辨率、丢包、jitter、解码/丢帧和当前路径。字段不可用时显示“未知”。RTT 只标为网络往返时间,不能冒充玻璃到玻璃延迟。
@@ -190,6 +190,8 @@ TURN/TLS 是可选能力,不是生产启动门槛。启用时默认使用 RFC
- `/r/{code}` 观看者:主区域最大化远端视频;顶部显示房间与连接状态;提供声音、全屏和重试控制,不要求额外邀请码或 viewer token。
- 移动端:视频保持稳定宽高比,控制与状态移到视频下方,任何文字和按钮不得溢出或遮挡。
用户可见的非直播状态只使用“已停止分享”(分享端已结束当前发布)和“等待开始分享”(观看端等待发布)。房间寿命、媒体代次、换父和恢复等内部状态不得作为额外产品文案暴露。
按钮使用 `lucide-react` 图标并提供可访问名称和 tooltip。动态状态区域固定最小尺寸,避免连接状态变化导致布局跳动。
## 安全与故障边界
@@ -233,7 +235,27 @@ TURN/TLS 是可选能力,不是生产启动门槛。启用时默认使用 RFC
4. 完成 TURN 短期凭据、ICE 配置刷新、连接恢复和 stats 面板。
5. 完成自动测试、真实浏览器烟测、部署说明、状态与记忆更新。
任何一步只实现当前消费者需要的接口。不得为未来账号、SFU 或原生 sender 预建抽象层。
任何一步只实现当前消费者需要的接口。不得为未来账号、第三种媒体拓扑或原生 sender 预建抽象层。
## 2026-08-19 Peer-Assisted 实验边界
状态:Proposed / Experiment Only。该扩展不改变上面的已部署 PoC,也不表示正式采用。
产品硬目标是分享端任何时刻最多两条下游媒体边。系统按一个或两个观看者直接 P2P、更多观看者 peer-assisted、部署已提供的 SFU 最后兜底的顺序自动分配和恢复,分享者与观看者不选择拓扑。当前实验只实现前两级;跨模式迁移由后续独立 ADR 定义。
实验只实现一个由服务端确定的广度优先拓扑:host 最多两个 child,每名 viewer 最多一个 child,因此形成两条按加入顺序平衡的 sticky 链。父节点只按深度和 `serverJoinSequence` 选择第一个空 slot。新 viewer 加入时不搬动已有节点;parent 离开时只把失去 parent 的 subtree root 重新分配到第一个空 slot,其 descendants 保持连接。该规则不采集或组合 RTT、带宽、CPU、地理位置与质量评分,也不主动重平衡。没有 parent 时明确等待或拒绝,不能创建分享端第三条边。
所有边都继续使用标准 WebRTC。每名 viewer 播放收到的 remote `MediaStream`,并把其中的 remote tracks 加入给唯一 child 的新 `RTCPeerConnection`。该路径会逐跳解码和重编码;实验必须记录每层 encode time、CPU/GPU、`qualityLimitationReason`、帧率、分辨率和延迟,不能称为 zero-copy 或 shared encode。
Peer-assisted 信令只接受 `1080p60`、`1080p30`、`720p30` 三个 `qualityProfileId`。服务端用受房间容量限制的内存 Map 保存当前值,默认 `1080p60`,不写入 SQLite;停止分享时保留,房间被放弃或过期时删除。peer-assisted 鉴权快照携带当前值,host 鉴权后先重报本地选择再创建已分配的 child,直播中换档时通知所有在线 viewers,后加入者直接取得最新快照。普通 P2P 的严格鉴权 wire 保持原样,host 或 viewer 在该模式发送档位同步消息都会被拒绝。
Viewer 在处理 peer-assisted 鉴权快照时先更新 `ViewerRelay` 的 desired profile,再应用拓扑分配;同一个串行队列协调 remote stream、当前 child 更新和以后重建的 child,因此它们读取同一最新目标。每个 `HostPeer` 也把初始 sender 配置、`replaceStream()` 和 `updateProfile()` 放进同一 sender-mutation queue;`desiredProfile` 在入队前同步更新,已排队操作执行时读取最新值,使快速连续换档保持 last-wins,而不增加版本号或确认协议。这里同步的是浏览器 sender 的目标上限,不是实际分辨率、帧率、码率或 shared encoding 的性能保证。
spike 复用现有 screen stream 和浏览器 codec 协商,覆盖当前桌面 Chrome/Edge relay、按测试顺序位于链尾的当前 Android Chrome 与 iOS Safari leaf、最多八名 viewer;捕获存在时沿同一标准 WebRTC 路径带上音频。Encoded Transform/DataChannel/WebCodecs 媒体、SVC/simulcast、多树、转码、codec ladder、综合评分、移动端后台 relay 和跨模式 SFU 迁移不在本次 spike 内实现。该范围限制不能解释为拒绝产品所需的自动兜底;验收与失败即放弃条件以 ADR-0004 为准,自动路由由后续独立 ADR 定义。
浏览器不保证多个 `RTCPeerConnection` 共享一次编码。实验允许 host 对一至两个 seed 各自编码。后续独立的 native sender 阶段必须用 shared encoder 将一份编码结果交给最多两套标准 WebRTC packetizer,并由新的 ADR 约束;它降低 host 编码负载,但两条出边仍各自消耗上传带宽。该计划不依赖本次浏览器 relay 实验成败,也不在本 spike 中提前抽象或实现。
详细证据见 `docs/research/peer-assisted-media.md`,提案见 `docs/adr/0004-peer-assisted-media-experiment.md`。
## 官方与参考资料
+24 -13
View File
@@ -4,7 +4,7 @@
## 需求概述
构建一个面向熟人游戏场景的低延迟屏幕共享工具。分享者可以在私密房间内分享游戏、应用窗口或显示器画面及声音,多名朋友可以即时观看。系统采用类似 Photon Host Mode 的产品抽象:中心服务负责房间、身份和建立连接,媒体默认通过 WebRTC 在客户端间 P2P 传输;STUN 协助打洞,直连失败的单条链路才使用 TURN。分享端 Web 优先并允许后续 Electron/原生增强;观看端必须可通过桌面或手机浏览器打开邀请链接,不绑定专用客户端。
构建一个面向熟人游戏场景的低延迟屏幕共享工具。分享者可以在私密房间内分享游戏、应用窗口或显示器画面及声音,多名朋友可以即时观看。系统采用类似 Photon Host Mode 的产品抽象:中心服务负责房间、身份、确定性拓扑和建立连接,媒体优先级依次为小规模直接 P2P、peer-assisted 转发、部署已提供的可选 SFU;STUN 协助打洞,直连失败的单条边才使用 TURN。系统必须自动、无感地完成分配、故障恢复和必要的路线迁移,分享者与观看者不手动选择拓扑。分享端 Web 优先并允许后续 Electron/原生增强;观看端必须可通过桌面或手机浏览器打开邀请链接,不绑定专用客户端。
当前 PoC 默认允许一名分享者和最多八名观看者,部署者可在 1 至 16 之间配置上限,超额观看者会被明确拒绝。八人只是当前容量默认值,不是已验证的性能承诺;真实 1:8 上行、编码和稳定性仍须测量。产品不承担公开直播或大规模观看,相关需求交给 OBS/Twitch 等成熟直播服务。
@@ -16,17 +16,23 @@
- [ ] 免安装观看:桌面和手机观看者可直接通过浏览器链接进入、授权并播放,不依赖与分享端相同的桌面客户端。
- [ ] 屏幕采集:分享者可主动选择显示器或应用窗口,开始、停止、临时暂停画面和切换分享来源。
- [ ] 游戏声音:Windows Chrome/Edge 目标环境可请求系统音频;没有返回音轨时必须明确提示,而不是静默无声。
- [ ] 一对多观看:一名分享者可向配置允许的多名观看者发送画面,每名观看者拥有独立的 WebRTC 连接与状态;默认上限为 8,可配置范围为 1 至 16。
- [ ] P2P 优先:ICE 必须先尝试直接 UDP 路径,服务器不得默认中转可直连的媒体。
- [ ] NAT 兜底:每名观看者独立进行 ICE;生产环境必须提供 STUN、TURN/UDP 与 TURN/TCP candidates,通过优先级偏好直连和 UDP,同时允许无法使用 UDP 的网络选择 TCP,不能因为其网络与其他观看者不同而静默失败。
- [ ] 质量档位:提供 1080p60(8 Mbps)、1080p30(5 Mbps)、720p30(3 Mbps)三档上限;分享期间可直接换档而不重新选择来源或重建健康连接。保留 `contentHint = "motion"` 并显式设置 `degradationPreference = "balanced"`;该偏好允许浏览器在帧率和分辨率之间权衡,不保证清晰度优先,具体行为必须实测。自定义动态 FPS 或画面检测策略必须在统计证明有必要后再实现。
- [ ] 一对多观看:一名分享者可向配置允许的多名观看者发送画面;默认上限为 8,可配置范围为 1 至 16。当前 PoC 的逐观看者连接是测量基线,不再是超过两名观看者时的目标拓扑。
- [ ] 分享端 fanout 硬上限:分享端任何时刻最多维护两条下游媒体边,包括加入、断线、换父和恢复过程。第三名及后续观看者由系统自动分配给通过验收的 peer-assisted 拓扑,必要时自动回退到部署已提供的可选 SFU;两者都不可用时才明确拒绝,不能隐藏地回退为分享端第三条连接。
- [ ] P2P 优先:每条逻辑媒体边的 ICE 必须先尝试直接 UDP 路径,服务器不得默认中转可直连的媒体。一个或两个观看者优先直接连接分享端;更多观看者的 peer-assisted 行为须先通过 ADR-0004 的实验门槛。
- [ ] NAT 兜底:每条 parent-child 边独立进行 ICE;生产环境必须提供 STUN、TURN/UDP 与 TURN/TCP candidates,通过优先级偏好直连和 UDP,同时允许无法使用 UDP 的网络选择 TCP,不能因为某条边的网络与其他边不同而静默失败。
- [ ] 质量档位:提供 1080p60(8 Mbps)、1080p30(5 Mbps)、720p30(3 Mbps)三档上限;分享期间可直接换档而不重新选择来源或重建健康连接。Peer-assisted 房间只允许这三个档位 ID,并把分享者当前选择同步给现有和后续 relay sender;普通 P2P 信令结构保持不变。保留 `contentHint = "motion"` 并显式设置 `degradationPreference = "balanced"`;该偏好允许浏览器在帧率和分辨率之间权衡,不保证清晰度优先,具体行为必须实测。自定义动态 FPS 或画面检测策略必须在统计证明有必要后再实现。
- [ ] 断线恢复:临时网络变化时尝试 ICE restart 和重连,并向用户显示当前连接状态。
- [ ] 可观测性:采集候选类型、RTT、丢包、抖动、实际码率、帧率、编码耗时和质量受限原因。
- [ ] 访问控制:部署者可配置一个全站 `ACCESS_PASSWORD`;为空时网站公开,非空值必须为 1 至 128 个可见 ASCII 字符。配置时分享者和观看者都必须先登录。登录成功签发 12 小时、无服务端会话状态的 HttpOnly、`SameSite=Strict` cookie,建房 HTTP 和 WebSocket 都只使用该 cookie,不提供建房 Bearer 旁路;不引入账号、JWT、服务端 session Map 或 logout。
- [ ] 持久受保护房间:`ROOM_DATABASE_PATH` 可选且必须与 `ACCESS_PASSWORD` 同时配置。两者都存在时,SQLite 只持久保存从 `1` 开始自增的房间 ID 与 host token 摘要;房间及链接不设过期时间,停止分享只让观看页进入等待状态。密码模式不配置数据库时仍使用临时房间;公开模式始终使用随机临时房间。
- [ ] 持久受保护房间:`ROOM_DATABASE_PATH` 可选且必须与 `ACCESS_PASSWORD` 同时配置。两者都存在时,SQLite 只持久保存从 `1` 开始自增的房间 ID 与 host token 摘要;房间及链接不设过期时间,停止后分享端显示“已停止分享”,观看页显示“等待开始分享”。密码模式不配置数据库时仍使用临时房间;公开模式始终使用随机临时房间。
### P1 - 重要功能
- [ ] 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 保证。
- [ ] 原生 shared encode relay:具备原生 relay 能力的 audience 同样最多维护两条下游媒体边,并应让一份兼容编码输出供两条边复用。普通浏览器 audience 继续作为 leaf 或使用当前会逐跳重编码的实验路径,不伪称 shared encode。
- [ ] 分包/分层多树实验:把编码包、对象或可独立解码层拆到多个 peer 路径以分散上传,只能在独立 spike 中验证。必须保持亚秒级,处理多父重组、排序、丢包恢复、关键帧、同步和节点退出;未通过门槛前不进入产品路由控制器。
- [ ] 可选 SFU:部署者可选择是否提供单节点 SFU 资源;配置后由路由控制器把它作为 P2P 与 peer-assisted 之后的自动兜底,而不要求用户选模式。它保持非默认、可逆,并承担每名观看者的服务器出口成本。Draft PR #12 仍在评审,不能视为已合并、已部署或推荐路径。
- [ ] 分享端桌面封装:用 Electron 固定 Chromium 版本、改善选源/热键/系统音频体验,同时保留 Web 观看端。
- [ ] Windows 应用音频:仅捕获所选游戏及其子进程的音频,避免把语音聊天或通知声一并分享。
- [ ] 连接自检:在开始分享前验证信令、STUN、TURN/UDP、TURN/TCP 及部署实际配置的可选 TURN/TLS 可达性,并给出不暴露凭据的诊断结果。
@@ -44,11 +50,11 @@
- 性能:在 RTT 不高于 40 ms、丢包不高于 1% 的受控 P2P 路径中,玻璃到玻璃延迟目标为 p50 不高于 150 ms、p95 不高于 250 ms;区域 TURN/UDP 路径的 p95 目标不高于 350 ms。
- 启播:观看者请求观看后,除用户授权耗时外,3 秒内出现首帧。
- 流畅性:选择 60 fps 档位时,不能在没有降档或提示的情况下连续 5 秒低于 50 个解码帧每秒。
- 成本:直连媒体不经过服务器;STUN 与信令仅承担小流量;TURN 的实际流量必须可计量、限额和告警。
- 成本:除按边选择的 TURN 中继外,直连和 peer-assisted 媒体不经过应用服务器;STUN 与信令仅承担小流量;TURN 的实际流量必须可计量、限额和告警。Peer-assisted 只把约 `N * B` 的总观看流量分散到参与节点,不能宣称消除流量;SFU 则把该出口成本转移到服务器。
- 安全:应用控制面全程 HTTPS/WSS,媒体端到端链路使用 WebRTC DTLS-SRTP;普通 `turn:` over TCP 不额外套 TLS,但不移除媒体层加密。TURN 使用短期凭据、配额和速率限制;仓库不得包含真实密钥。
- 隐私:P2P 会向房间内好友暴露网络地址,首版威胁模型仅覆盖可信熟人;若要求隐藏 IP,必须切换为服务器转发。
- 可维护性:代码、需求、调研、项目记忆、`AGENTS.md` 和 `.codex/` 均在 Git 中跟踪;开发配置不得依赖单一操作系统的绝对路径。
- 兼容性:分享端首个目标矩阵为 Windows 10/11 的当前 Chrome/Edge;观看端还必须覆盖当前 Android Chrome 和 iOS Safari,并实测自动播放限制、横竖屏、锁屏恢复与移动网络切换。
- 兼容性:分享端首个目标矩阵为 Windows 10/11 的当前 Chrome/Edge;观看端还必须覆盖当前 Android Chrome 和 iOS Safari,并实测自动播放限制、横竖屏、锁屏恢复与移动网络切换。Peer-assisted 实验只允许能力完整的前台桌面 Chrome/Edge 作为 relay,手机必须保持 leaf。
## 边界与约束
@@ -58,7 +64,7 @@
- 默认八名观看者和可配置的 1 至 16 范围是接入容量边界,不承诺任意机器、档位或网络都能稳定承载该人数,也不承诺跨洲网络下的固定低延迟。
- 浏览器规范要求每次由用户主动选择分享源,权限不能永久保存;纯网页不能保证指定游戏窗口或始终获得系统音频。
- 1080p60 是能力目标,实际分辨率、帧率和硬件编码由浏览器、GPU、驱动、画面复杂度及网络共同决定。
- 首版不实现 SFU,不从零实现编解码器或自定义 UDP 视频协议;产品范围以后改变时重新立项,而不是预留复杂框架。
- 已部署首版仍是标准 WebRTC P2P。Peer-assisted spike 只复用标准 remote-track WebRTC,不加入 Encoded Transform/DataChannel/WebCodecs 媒体协议,也不自研 UDP、编解码器、拥塞控制、FEC/RTX、多树、转码、codec ladder 或综合评分器;需要其中任一项即放弃本次浏览器 relay 实验。独立计划中的 native shared encode sender 不在本 spike 实现,也不能挽救其他拓扑门槛失败。跨 P2P、peer-assisted 与可选 SFU 的自动路由是产品要求,但由单独 ADR 约束;当前 spike 尚未实现跨模式迁移或短时双发。
- GPL/AGPL 项目在许可证策略确认前仅用于研究,不能直接复制到计划闭源的产品中。
## 验收标准
@@ -66,8 +72,8 @@
### 房间模式与生命周期
- Given `ACCESS_PASSWORD` 和 `ROOM_DATABASE_PATH` 都已配置,When 连续创建房间并重启应用,Then 房间码从 `1` 开始单调递增,已创建链接仍可加入,且 SQLite 的应用数据只包含房间 ID 与 host token 摘要。
- Given 持久房间正在分享,When 分享者明确停止或捕获轨结束,Then 房间和链接保持有效,观看者进入等待状态;分享者重新发布后可继续在同一房间观看。
- Given 分享期间只有 host 信令短暂断开,When 现有 P2P 媒体仍健康,Then 客户端继续播放媒体并恢复信令,而不是提前进入停止分享状态。
- Given 持久房间正在分享,When 分享者明确停止或捕获轨结束,Then 房间和链接保持有效,分享端显示“已停止分享”,观看者显示“等待开始分享”;分享者重新发布后可继续在同一房间观看。
- Given 分享期间只有 host 信令短暂断开,When 现有 P2P 媒体仍健康,Then 客户端继续播放媒体并恢复信令,而不是错误显示“已停止分享”。
- Given `ROOM_DATABASE_PATH` 已配置但 `ACCESS_PASSWORD` 为空,When 应用启动,Then 配置校验明确失败,不能在公开网站暴露可枚举的持久房间。
- Given `ROOM_DATABASE_PATH` 为空,When 创建房间,Then 无论是否启用全站密码都使用随机临时房间码并遵守 `ROOM_TTL_SECONDS`;停止分享本身不立即销毁房间。
@@ -86,7 +92,12 @@
### 一对多与带宽保护
- Given 一名分享者和八名观看者,When 在受控网络中运行已记录的质量档位,Then 八名观看者可独立加入和离开,任一观看者离开不会中断其他连接,并记录每路码率、候选类型、编码负载和分享者总上行;该条在真实矩阵完成前保持未验收。
- Given 一名分享者和一至两名观看者,When 运行直接 P2P,Then 分享端为每人建立独立媒体边且总 fanout 不超过二。
- Given peer-assisted spike 分别运行 1、3、5、8 名观看者,When 用户加入、离开或 relay 失效,Then 服务端使用同一个确定性广度优先规则形成两条 sticky 链,只为新 viewer 或失去 parent 的 subtree root 分配 parent,不主动搬动其他节点;分享端在任何瞬间都不超过两个 child 且每名 viewer 不超过一个,没有可用 parent 时明确等待或拒绝,而不是增加分享端 fanout。
- Given 一名分享者和八名观看者,When 在受控网络中运行已记录的质量档位,Then 记录每条边的拓扑深度、候选类型、码率、编码/解码负载、分享端与 relay 上行、首帧和玻璃到玻璃延迟;该条在真实矩阵完成前保持未验收。
- Given peer-assisted host 鉴权或直播中换档,When 其选择三个受限档位之一,Then 当前和以后建立的 relay child sender 都使用最新目标档位,服务端只保留房间级内存状态并通知在线 viewers;普通 P2P 的鉴权 wire 不新增该字段,且不接受档位同步消息。
- Given 标准浏览器把收到的 remote track 直接加入下游 `RTCPeerConnection`,When 检查 relay 统计和 libwebrtc 路径,Then 明确记录新的 outbound encoder、编码耗时和 CPU/GPU 限制,不把它当作 encoded forwarding 或 shared encode 成果。
- Given ADR-0004 的任一硬门槛只有在引入 Encoded Transform/DataChannel/WebCodecs 媒体、自定义拥塞控制、FEC/RTX、多树、综合评分、转码、codec ladder、浏览器 RTP 注入 hack 或放宽 host fanout 后才能满足,When 结束 bounded spike,Then 放弃并删除浏览器 relay 实验运行时代码,保留标准 P2P 和可选 SFU;后续 native shared encode sender 仍按独立 ADR 推进,但不能据此把失败的 relay 拓扑判为通过。
- Given 新观看者会使预计总上行超过安全预算,When 其请求加入,Then 系统自动降低档位、拒绝该档位或建议改用外部直播服务,而不是持续堆积发送队列。
### 质量和恢复
@@ -107,6 +118,6 @@
- 首批用户主要位于香港、内地还是跨地区;该信息决定 TURN 部署位置和测试矩阵。
- iOS Safari 与 Android Chrome 的最低版本及是否要求平板适配。
详细技术证据、参考代码和难度评估见 `docs/research/webrtc-p2p-screen-sharing.md`。
基础 WebRTC 证据与难度评估见 `docs/research/webrtc-p2p-screen-sharing.md`;peer-assisted、shared encode 和浏览器限制见 `docs/research/peer-assisted-media.md`。
---
+1
View File
@@ -13,6 +13,7 @@
"build:client": "vite build",
"build:server": "tsc -p tsconfig.server.json",
"start": "node dist/server/server/index.js",
"benchmark:peer-assisted": "tsx scripts/peer-assisted-benchmark.ts",
"typecheck": "tsc -b --pretty false",
"test": "vitest run",
"test:watch": "vitest",
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -78,8 +78,8 @@ export function StatsGrid({
<>
<Metric label="编码器" value={encoder} />
<Metric
label="平均编码"
value={`${readableNumber(metrics.averageEncodeMs, 1)} ms`}
label="最近区间编码/帧"
value={`${readableNumber(metrics.intervalEncodeMs, 1)} ms`}
/>
<Metric label="质量状态" value={qualityReason(metrics.qualityLimitationReason)} />
</>
@@ -87,8 +87,8 @@ export function StatsGrid({
<>
<Metric label="丢帧" value={readableNumber(metrics.framesDropped)} />
<Metric
label="平均解码"
value={`${readableNumber(metrics.averageDecodeMs, 1)} ms`}
label="最近区间解码/帧"
value={`${readableNumber(metrics.intervalDecodeMs, 1)} ms`}
/>
</>
)}
+6
View File
@@ -0,0 +1,6 @@
export function createOpaqueId(): string {
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(
"",
);
}
+2 -5
View File
@@ -3,6 +3,7 @@ import {
roomCodeSchema,
type CreateRoomResponse,
} from "../../shared/protocol";
import { createOpaqueId } from "./opaque-id";
const CLIENT_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;
const HOST_ROOM_STORAGE_KEY = "screener:host-room:v1";
@@ -98,11 +99,7 @@ export function getStableClientId(role: "host" | "viewer", roomId: string): stri
return existing;
}
// randomUUID is unavailable to LAN viewers on HTTP; getRandomValues is not.
const randomBytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
const clientId = Array.from(randomBytes, (byte) =>
byte.toString(16).padStart(2, "0"),
).join("");
const clientId = createOpaqueId();
writeSessionValue(storageKey, clientId);
return clientId;
}
+14 -2
View File
@@ -1,3 +1,7 @@
import type { QualityProfileId } from "../../shared/protocol";
export type { QualityProfileId } from "../../shared/protocol";
export const QUALITY_PROFILES = {
"1080p60": {
label: "1080p 60",
@@ -20,9 +24,17 @@ export const QUALITY_PROFILES = {
frameRate: 30,
maxBitrate: 3_000_000,
},
} as const;
} as const satisfies Record<
QualityProfileId,
{
label: string;
width: number;
height: number;
frameRate: number;
maxBitrate: number;
}
>;
export type QualityProfileId = keyof typeof QUALITY_PROFILES;
export type QualityProfile = (typeof QUALITY_PROFILES)[QualityProfileId];
function captureConstraints(profile: QualityProfile): MediaTrackConstraints {
+76 -10
View File
@@ -10,10 +10,11 @@ import {
Users,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import type {
CreateRoomResponse,
IceConfig,
ServerMessage,
import {
DEFAULT_QUALITY_PROFILE_ID,
type CreateRoomResponse,
type IceConfig,
type ServerMessage,
} from "../../shared/protocol";
import { AppHeader } from "../components/AppHeader";
import {
@@ -44,6 +45,10 @@ import type {
SignalConnectionState,
} from "../types";
import { HostPeer } from "../webrtc/host-peer";
import {
limitMediaAssignment,
MAX_HOST_MEDIA_CHILDREN,
} from "../webrtc/media-assignment";
type HostPhase = "idle" | "starting" | "live" | "ended" | "error";
@@ -105,7 +110,9 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
() => new URLSearchParams(window.location.search).get("relay") === "1",
[],
);
const [qualityId, setQualityId] = useState<QualityProfileId>("1080p60");
const [qualityId, setQualityId] = useState<QualityProfileId>(
DEFAULT_QUALITY_PROFILE_ID,
);
const [phase, setPhase] = useState<HostPhase>("idle");
const [signalStatus, setSignalStatus] =
useState<SignalConnectionState>("offline");
@@ -128,11 +135,12 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
const signalRef = useRef<SignalingClient | null>(null);
const iceConfigRef = useRef<IceConfig | null>(null);
const peersRef = useRef(new Map<string, HostPeer>());
const peerAssistedRef = useRef(false);
const generationRef = useRef(0);
const activeGenerationRef = useRef<number | null>(null);
const sourceSwitchRef = useRef<object | null>(null);
const qualityChangeRef = useRef<object | null>(null);
const qualityIdRef = useRef<QualityProfileId>("1080p60");
const qualityIdRef = useRef<QualityProfileId>(DEFAULT_QUALITY_PROFILE_ID);
const picturePausedRef = useRef(false);
const retiringStreamRef = useRef<MediaStream | null>(null);
@@ -191,6 +199,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
streamRef.current = null;
retiringStreamRef.current = null;
iceConfigRef.current = null;
peerAssistedRef.current = false;
setStream(null);
setDetails(null);
setRelayAvailable(false);
@@ -296,6 +305,12 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
commitQuality(nextId);
setDetails(captureDetails(activeStream));
if (peerAssistedRef.current) {
signalRef.current?.send({
type: "set-quality-profile",
qualityProfileId: nextId,
});
}
const results = await Promise.all(
[...peersRef.current.values()].map((peer) => peer.updateProfile(profile)),
);
@@ -467,12 +482,51 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
await startPeer(peerId, generation);
}
function reconcilePeerAssistedChildren(
childPeerIds: string[],
generation: number,
): void {
const assignment = limitMediaAssignment(
{ parentPeerId: null, childPeerIds },
MAX_HOST_MEDIA_CHILDREN,
);
const assignedChildren = new Set(assignment.childPeerIds);
for (const peerId of peersRef.current.keys()) {
if (!assignedChildren.has(peerId)) {
removePeer(peerId);
}
}
for (const peerId of assignedChildren) {
void startPeer(peerId, generation).catch((error: unknown) => {
if (isCurrentGeneration(generation)) {
setNotice(readableError(error));
}
});
}
}
function handleSignalMessage(message: ServerMessage, generation: number): void {
if (!isCurrentGeneration(generation)) {
return;
}
if (message.type === "authenticated" && message.role === "host") {
setMaxViewers(message.maxViewers);
if (
"mediaMode" in message &&
message.mediaMode === "peer-assisted"
) {
peerAssistedRef.current = true;
signalRef.current?.send({
type: "set-quality-profile",
qualityProfileId: qualityIdRef.current,
});
reconcilePeerAssistedChildren(
message.mediaAssignment.childPeerIds,
generation,
);
return;
}
peerAssistedRef.current = false;
const currentViewerIds = new Set(message.viewerPeerIds);
for (const peerId of peersRef.current.keys()) {
if (!currentViewerIds.has(peerId)) {
@@ -481,7 +535,19 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
}
return;
}
if (message.type === "media-assignment") {
if (peerAssistedRef.current) {
reconcilePeerAssistedChildren(
message.mediaAssignment.childPeerIds,
generation,
);
}
return;
}
if (message.type === "peer-joined") {
if (peerAssistedRef.current) {
return;
}
void startPeer(message.peerId, generation).catch((error: unknown) => {
if (isCurrentGeneration(generation)) {
setNotice(readableError(error));
@@ -490,6 +556,9 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
return;
}
if (message.type === "peer-left") {
if (peerAssistedRef.current) {
return;
}
removePeer(message.peerId);
return;
}
@@ -756,10 +825,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
return {
peerId,
peer,
replaced: await peer.replaceStream(
captured,
QUALITY_PROFILES[qualityIdRef.current],
),
replaced: await peer.replaceStream(captured),
};
} catch {
return { peerId, peer, replaced: false };
+181 -10
View File
@@ -7,7 +7,12 @@ import {
VolumeX,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { IceConfig, ServerMessage } from "../../shared/protocol";
import {
DEFAULT_QUALITY_PROFILE_ID,
type IceConfig,
type MediaAssignment,
type ServerMessage,
} from "../../shared/protocol";
import { AppHeader } from "../components/AppHeader";
import {
PathBadge,
@@ -18,11 +23,22 @@ import {
import { StatsGrid } from "../components/StatsGrid";
import { getStableClientId } from "../lib/session";
import { SignalingClient } from "../lib/signaling";
import {
QUALITY_PROFILES,
type QualityProfileId,
} from "../media/quality";
import type {
PeerSnapshot,
SignalConnectionState,
} from "../types";
import {
limitMediaAssignment,
MAX_VIEWER_MEDIA_CHILDREN,
viewerRestartMessage,
viewerSignalMessage,
} from "../webrtc/media-assignment";
import { ViewerPeer } from "../webrtc/viewer-peer";
import { ViewerRelay } from "../webrtc/viewer-relay";
interface ViewerPageProps {
roomId: string;
@@ -41,6 +57,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
const [relayAvailable, setRelayAvailable] = useState(false);
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
const [peerSnapshot, setPeerSnapshot] = useState<PeerSnapshot | null>(null);
const [relaySnapshot, setRelaySnapshot] = useState<PeerSnapshot | null>(null);
const [playbackBlocked, setPlaybackBlocked] = useState(false);
const [muted, setMuted] = useState(false);
@@ -51,6 +68,13 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
let active = true;
let currentIceConfig: IceConfig | null = null;
let currentHostOnline = false;
let peerAssisted = false;
let currentQualityProfileId: QualityProfileId = DEFAULT_QUALITY_PROFILE_ID;
let currentAssignment: MediaAssignment = {
parentPeerId: null,
childPeerIds: [],
};
let viewerRelay: ViewerRelay | null = null;
const signal = new SignalingClient(
{
@@ -90,7 +114,40 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
},
);
function clearPeerState(): void {
function ensureViewerRelay(): ViewerRelay | null {
if (viewerRelay) {
return viewerRelay;
}
if (!peerAssisted || !currentIceConfig) {
return null;
}
viewerRelay = new ViewerRelay(
currentIceConfig,
QUALITY_PROFILES[currentQualityProfileId],
{
sendSignal: (targetPeerId, payload) =>
active &&
peerAssisted &&
currentAssignment.childPeerIds[0] === targetPeerId
? signal.send({
type: "signal",
targetPeerId,
payload,
})
: false,
onUpdate: (snapshot) => {
if (active) {
setRelaySnapshot(snapshot);
}
},
},
forceRelay,
);
viewerRelay.setChild(currentAssignment.childPeerIds[0] ?? null);
return viewerRelay;
}
function clearUpstreamState(): void {
peerRef.current?.dispose();
peerRef.current = null;
setRemoteStream(null);
@@ -98,6 +155,30 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
setPlaybackBlocked(false);
}
function clearPeerState(): void {
clearUpstreamState();
viewerRelay?.stop();
}
function applyMediaAssignment(assignment: MediaAssignment): void {
const nextAssignment = limitMediaAssignment(
assignment,
MAX_VIEWER_MEDIA_CHILDREN,
);
const previousParentId = currentAssignment.parentPeerId;
currentAssignment = nextAssignment;
if (previousParentId !== nextAssignment.parentPeerId) {
clearUpstreamState();
setStatusText(
nextAssignment.parentPeerId
? "正在切换媒体来源"
: "等待可用的媒体来源",
);
}
ensureViewerRelay()?.setChild(nextAssignment.childPeerIds[0] ?? null);
}
function ensurePeer(): ViewerPeer | null {
if (peerRef.current) {
return peerRef.current;
@@ -108,13 +189,26 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
const peer = new ViewerPeer(
currentIceConfig,
{
sendSignal: (payload) => signal.send({ type: "signal", payload }),
sendRestartRequest: (connectionId, rebuild) =>
signal.send({ type: "restart-request", connectionId, rebuild }),
sendSignal: (targetPeerId, payload) =>
signal.send(
viewerSignalMessage(peerAssisted, targetPeerId, payload),
),
sendRestartRequest: (targetPeerId, connectionId, rebuild) =>
signal.send(
viewerRestartMessage(
peerAssisted,
targetPeerId,
connectionId,
rebuild,
),
),
onStream: (nextStream) => {
if (active) {
setRemoteStream(nextStream);
setStatusText("正在播放");
// Audio and video can arrive as separate track events on the
// same MediaStream, so refresh both downstream senders each time.
ensureViewerRelay()?.setStream(nextStream);
}
},
onUpdate: (snapshot) => {
@@ -147,10 +241,26 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
async function handleMessage(message: ServerMessage): Promise<void> {
if (message.type === "authenticated") {
const nextPeerAssisted =
"mediaMode" in message && message.mediaMode === "peer-assisted";
if (!nextPeerAssisted && peerAssisted) {
clearPeerState();
viewerRelay?.dispose();
viewerRelay = null;
currentAssignment = { parentPeerId: null, childPeerIds: [] };
}
peerAssisted = nextPeerAssisted;
currentIceConfig = message.iceConfig;
currentHostOnline = message.hostOnline;
setRelayAvailable(message.iceConfig.relayAvailable);
setHostOnline(message.hostOnline);
if (nextPeerAssisted && "qualityProfileId" in message) {
currentQualityProfileId = message.qualityProfileId;
void viewerRelay?.updateProfile(
QUALITY_PROFILES[currentQualityProfileId],
);
applyMediaAssignment(message.mediaAssignment);
}
if (
!message.hostOnline &&
message.connectionId === null &&
@@ -163,21 +273,63 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
);
const peer = peerRef.current;
peer?.updateIceConfig(message.iceConfig);
viewerRelay?.updateIceConfig(message.iceConfig);
if (
message.connectionId &&
!peer?.hasConnectionId(message.connectionId)
) {
signal.send({
type: "restart-request",
connectionId: message.connectionId,
rebuild: true,
});
const parentPeerId = currentAssignment.parentPeerId;
if (!peerAssisted) {
signal.send({
type: "restart-request",
connectionId: message.connectionId,
rebuild: true,
});
} else if (parentPeerId) {
signal.send({
type: "restart-request",
targetPeerId: parentPeerId,
connectionId: message.connectionId,
rebuild: true,
});
}
} else if (peer?.hasConnection() && !peer.isConnected()) {
peer.requestRecovery();
}
return;
}
if (message.type === "media-assignment") {
if (peerAssisted) {
applyMediaAssignment(message.mediaAssignment);
}
return;
}
if (message.type === "quality-profile") {
if (peerAssisted) {
currentQualityProfileId = message.qualityProfileId;
void viewerRelay?.updateProfile(
QUALITY_PROFILES[currentQualityProfileId],
);
}
return;
}
if (message.type === "signal") {
if (
peerAssisted &&
currentAssignment.childPeerIds[0] === message.fromPeerId
) {
await viewerRelay?.acceptSignal(
message.fromPeerId,
message.payload,
);
return;
}
if (
peerAssisted &&
currentAssignment.parentPeerId !== message.fromPeerId
) {
return;
}
const peer = ensurePeer();
if (!peer) {
setStatusText("尚未收到可用的 ICE 配置");
@@ -186,10 +338,21 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
await peer.acceptSignal(message.fromPeerId, message.payload);
return;
}
if (message.type === "restart-request") {
if (peerAssisted) {
await viewerRelay?.recover(
message.fromPeerId,
message.connectionId,
message.rebuild,
);
}
return;
}
if (message.type === "ice-config") {
currentIceConfig = message.iceConfig;
setRelayAvailable(message.iceConfig.relayAvailable);
peerRef.current?.updateIceConfig(message.iceConfig);
viewerRelay?.updateIceConfig(message.iceConfig);
return;
}
if (message.type === "host-status") {
@@ -241,6 +404,8 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
signal.stop();
peerRef.current?.dispose();
peerRef.current = null;
viewerRelay?.dispose();
viewerRelay = null;
};
}, [forceRelay, onAuthorizationRequired, roomId]);
@@ -403,6 +568,12 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
<StatsGrid metrics={peerSnapshot.metrics} direction="receive" />
</section>
)}
{relaySnapshot && (
<section className="viewer-stats" aria-labelledby="relay-stats-heading">
<h2 id="relay-stats-heading">转发数据</h2>
<StatsGrid metrics={relaySnapshot.metrics} direction="send" />
</section>
)}
</main>
</div>
);
+4 -4
View File
@@ -23,8 +23,8 @@ export interface ConnectionMetrics {
codec: string | null;
encoderImplementation: string | null;
powerEfficientEncoder: boolean | null;
averageEncodeMs: number | null;
averageDecodeMs: number | null;
intervalEncodeMs: number | null;
intervalDecodeMs: number | null;
qualityLimitationReason: string | null;
}
@@ -54,7 +54,7 @@ export const EMPTY_METRICS: ConnectionMetrics = {
codec: null,
encoderImplementation: null,
powerEfficientEncoder: null,
averageEncodeMs: null,
averageDecodeMs: null,
intervalEncodeMs: null,
intervalDecodeMs: null,
qualityLimitationReason: null,
};
+93 -64
View File
@@ -1,4 +1,5 @@
import type { IceConfig, SignalPayload } from "../../shared/protocol";
import { createOpaqueId } from "../lib/opaque-id";
import type { QualityProfile } from "../media/quality";
import { configureVideoSender } from "../media/quality";
import {
@@ -22,23 +23,25 @@ interface HostPeerEvents {
}
export class HostPeer {
readonly connectionId = crypto.randomUUID();
readonly connectionId = createOpaqueId();
private readonly connection: RTCPeerConnection;
private readonly pendingCandidates: SignalCandidate[] = [];
private readonly statsAccumulator = createStatsAccumulator();
private statsAccumulator = createStatsAccumulator();
private videoSender: RTCRtpSender | null = null;
private audioSender: RTCRtpSender | null = null;
private statsTimer: number | null = null;
private statsInFlight = false;
private disposed = false;
private negotiating = false;
private senderMutationTail: Promise<void> = Promise.resolve();
private snapshot: PeerSnapshot;
constructor(
readonly peerId: string,
iceConfig: IceConfig,
private stream: MediaStream,
private profile: QualityProfile,
private desiredProfile: QualityProfile,
private readonly events: HostPeerEvents,
private readonly forceRelay = false,
) {
@@ -73,7 +76,12 @@ export class HostPeer {
streams: [this.stream],
}).sender;
try {
await configureVideoSender(this.videoSender, this.profile);
await this.enqueueSenderMutation(async () => {
if (this.disposed || !this.videoSender) {
return;
}
await configureVideoSender(this.videoSender, this.desiredProfile);
});
} catch (error) {
console.warn("Browser rejected preferred sender parameters", error);
}
@@ -87,74 +95,75 @@ export class HostPeer {
return true;
}
async replaceStream(
nextStream: MediaStream,
profile: QualityProfile,
): Promise<boolean> {
async replaceStream(nextStream: MediaStream): Promise<boolean> {
const nextVideoTrack = nextStream.getVideoTracks()[0];
if (
this.disposed ||
!this.videoSender ||
!this.audioSender ||
!nextVideoTrack
) {
if (this.disposed || !nextVideoTrack) {
return false;
}
const nextAudioTrack = nextStream.getAudioTracks()[0] ?? null;
const previousVideoTrack = this.videoSender.track;
const previousAudioTrack = this.audioSender.track;
return this.enqueueSenderMutation(async () => {
const videoSender = this.videoSender;
const audioSender = this.audioSender;
if (this.disposed || !videoSender || !audioSender) {
return false;
}
try {
await this.videoSender.replaceTrack(nextVideoTrack);
await this.audioSender.replaceTrack(nextAudioTrack);
} catch (error) {
await Promise.allSettled([
this.videoSender.replaceTrack(previousVideoTrack),
this.audioSender.replaceTrack(previousAudioTrack),
]);
this.setError(error, "切换共享源失败");
return false;
}
const nextAudioTrack = nextStream.getAudioTracks()[0] ?? null;
const previousVideoTrack = videoSender.track;
const previousAudioTrack = audioSender.track;
if (this.disposed) {
return false;
}
this.stream = nextStream;
this.profile = profile;
this.statsAccumulator.bytes = null;
this.statsAccumulator.frames = null;
this.statsAccumulator.timestamp = null;
try {
await configureVideoSender(this.videoSender, this.profile);
} catch (error) {
console.warn("Browser rejected preferred sender parameters", error);
}
this.snapshot = { ...this.snapshot, error: null };
this.emit();
return true;
try {
await videoSender.replaceTrack(nextVideoTrack);
await audioSender.replaceTrack(nextAudioTrack);
} catch (error) {
await Promise.allSettled([
videoSender.replaceTrack(previousVideoTrack),
audioSender.replaceTrack(previousAudioTrack),
]);
this.setError(error, "切换共享源失败");
return false;
}
if (this.disposed) {
return false;
}
this.stream = nextStream;
this.statsAccumulator = createStatsAccumulator();
try {
await configureVideoSender(videoSender, this.desiredProfile);
} catch (error) {
console.warn("Browser rejected preferred sender parameters", error);
}
this.snapshot = { ...this.snapshot, error: null };
this.emit();
return true;
});
}
async updateProfile(profile: QualityProfile): Promise<boolean> {
if (this.disposed || !this.videoSender) {
return false;
}
try {
await configureVideoSender(this.videoSender, profile);
} catch (error) {
this.setError(error, "调整画质失败");
return false;
}
updateProfile(profile: QualityProfile): Promise<boolean> {
if (this.disposed) {
return false;
return Promise.resolve(false);
}
this.profile = profile;
this.statsAccumulator.bytes = null;
this.statsAccumulator.frames = null;
this.statsAccumulator.timestamp = null;
this.snapshot = { ...this.snapshot, error: null };
this.emit();
return true;
this.desiredProfile = profile;
return this.enqueueSenderMutation(async () => {
const videoSender = this.videoSender;
if (this.disposed || !videoSender) {
return false;
}
try {
await configureVideoSender(videoSender, this.desiredProfile);
} catch (error) {
this.setError(error, "调整画质失败");
return false;
}
if (this.disposed) {
return false;
}
this.statsAccumulator = createStatsAccumulator();
this.snapshot = { ...this.snapshot, error: null };
this.emit();
return true;
});
}
async acceptSignal(payload: SignalPayload): Promise<void> {
@@ -241,6 +250,15 @@ export class HostPeer {
this.connection.addEventListener("iceconnectionstatechange", () => this.emit());
}
private enqueueSenderMutation<T>(operation: () => Promise<T>): Promise<T> {
const result = this.senderMutationTail.then(operation);
this.senderMutationTail = result.then(
() => undefined,
() => undefined,
);
return result;
}
private async createOffer(restart: boolean): Promise<boolean> {
if (this.disposed) {
return false;
@@ -289,19 +307,30 @@ export class HostPeer {
}
private async updateStats(): Promise<void> {
if (this.disposed || this.connection.connectionState === "closed") {
if (
this.disposed ||
this.connection.connectionState === "closed" ||
this.statsInFlight
) {
return;
}
this.statsInFlight = true;
const statsAccumulator = this.statsAccumulator;
try {
const metrics = await collectConnectionMetrics(
this.connection,
"send",
this.statsAccumulator,
statsAccumulator,
);
if (this.disposed || this.statsAccumulator !== statsAccumulator) {
return;
}
this.snapshot = { ...this.snapshot, metrics };
this.emit();
} catch {
// Stats are observational and must never disrupt a healthy media path.
} finally {
this.statsInFlight = false;
}
}
+44
View File
@@ -0,0 +1,44 @@
import type {
ClientMessage,
MediaAssignment,
SignalPayload,
} from "../../shared/protocol";
export const MAX_HOST_MEDIA_CHILDREN = 2;
export const MAX_VIEWER_MEDIA_CHILDREN = 1;
export function limitMediaAssignment(
assignment: MediaAssignment,
maxChildren: number,
): MediaAssignment {
return {
parentPeerId: assignment.parentPeerId,
childPeerIds: [...new Set(assignment.childPeerIds)].slice(0, maxChildren),
};
}
export function viewerSignalMessage(
peerAssisted: boolean,
targetPeerId: string,
payload: SignalPayload,
): Extract<ClientMessage, { type: "signal" }> {
return peerAssisted
? { type: "signal", targetPeerId, payload }
: { type: "signal", payload };
}
export function viewerRestartMessage(
peerAssisted: boolean,
targetPeerId: string,
connectionId: string,
rebuild: boolean,
): Extract<ClientMessage, { type: "restart-request" }> {
return peerAssisted
? {
type: "restart-request",
targetPeerId,
connectionId,
rebuild,
}
: { type: "restart-request", connectionId, rebuild };
}
+86 -32
View File
@@ -10,13 +10,45 @@ type StatsRecord = Record<string, unknown> & {
};
export interface StatsAccumulator {
mediaId: string | null;
bytes: number | null;
frames: number | null;
timestamp: number | null;
previousTotalEncodeTime: number | null;
previousTotalDecodeTime: number | null;
}
export function createStatsAccumulator(): StatsAccumulator {
return { bytes: null, frames: null, timestamp: null };
return {
mediaId: null,
bytes: null,
frames: null,
timestamp: null,
previousTotalEncodeTime: null,
previousTotalDecodeTime: null,
};
}
function intervalAverageMs(
totalTime: number | null,
previousTotalTime: number | null,
frames: number | null,
previousFrames: number | null,
): number | null {
if (
totalTime === null ||
previousTotalTime === null ||
frames === null ||
previousFrames === null
) {
return null;
}
const frameDelta = frames - previousFrames;
const timeDelta = totalTime - previousTotalTime;
return frameDelta > 0 && timeDelta >= 0
? (timeDelta / frameDelta) * 1_000
: null;
}
function numberValue(record: StatsRecord | null, key: string): number | null {
@@ -121,39 +153,67 @@ export async function collectConnectionMetrics(
: "unknown";
const media = mediaRecord(report, direction);
const mediaId = media?.id ?? null;
const sameMedia = mediaId !== null && mediaId === previous.mediaId;
const remoteInbound = direction === "send" ? remoteInboundVideo(report) : null;
const bytesKey = direction === "send" ? "bytesSent" : "bytesReceived";
const bytes = numberValue(media, bytesKey);
const framesKey = direction === "send" ? "framesEncoded" : "framesDecoded";
const frames = numberValue(media, framesKey);
const timestamp = media?.timestamp ?? null;
let bitrateKbps: number | null = null;
let derivedFps: number | null = null;
if (
bytes !== null &&
timestamp !== null &&
previous.bytes !== null &&
previous.timestamp !== null &&
timestamp > previous.timestamp
) {
const elapsedSeconds = (timestamp - previous.timestamp) / 1_000;
bitrateKbps = ((bytes - previous.bytes) * 8) / elapsedSeconds / 1_000;
if (frames !== null && previous.frames !== null) {
derivedFps = (frames - previous.frames) / elapsedSeconds;
}
}
previous.bytes = bytes;
previous.frames = frames;
previous.timestamp = timestamp;
const codec = getRecord(report, stringValue(media, "codecId"));
const width = numberValue(media, "frameWidth");
const height = numberValue(media, "frameHeight");
const framesEncoded = numberValue(media, "framesEncoded");
const framesDecoded = numberValue(media, "framesDecoded");
const totalEncodeTime = numberValue(media, "totalEncodeTime");
const totalDecodeTime = numberValue(media, "totalDecodeTime");
let bitrateKbps: number | null = null;
let derivedFps: number | null = null;
if (
sameMedia &&
timestamp !== null &&
previous.timestamp !== null &&
timestamp > previous.timestamp
) {
const elapsedSeconds = (timestamp - previous.timestamp) / 1_000;
if (bytes !== null && previous.bytes !== null && bytes >= previous.bytes) {
bitrateKbps = ((bytes - previous.bytes) * 8) / elapsedSeconds / 1_000;
}
if (
frames !== null &&
previous.frames !== null &&
frames >= previous.frames
) {
derivedFps = (frames - previous.frames) / elapsedSeconds;
}
}
const intervalEncodeMs =
direction === "send"
? intervalAverageMs(
totalEncodeTime,
sameMedia ? previous.previousTotalEncodeTime : null,
framesEncoded,
sameMedia ? previous.frames : null,
)
: null;
const intervalDecodeMs =
direction === "receive"
? intervalAverageMs(
totalDecodeTime,
sameMedia ? previous.previousTotalDecodeTime : null,
framesDecoded,
sameMedia ? previous.frames : null,
)
: null;
previous.mediaId = mediaId;
previous.bytes = bytes;
previous.frames = frames;
previous.timestamp = timestamp;
previous.previousTotalEncodeTime = totalEncodeTime;
previous.previousTotalDecodeTime = totalDecodeTime;
const codec = getRecord(report, stringValue(media, "codecId"));
const width = numberValue(media, "frameWidth");
const height = numberValue(media, "frameHeight");
const iceProtocol =
stringValue(localCandidate, "protocol") ??
stringValue(remoteCandidate, "protocol");
@@ -191,14 +251,8 @@ export async function collectConnectionMetrics(
codec: stringValue(codec, "mimeType"),
encoderImplementation: stringValue(media, "encoderImplementation"),
powerEfficientEncoder: booleanValue(media, "powerEfficientEncoder"),
averageEncodeMs:
framesEncoded && totalEncodeTime !== null
? (totalEncodeTime / framesEncoded) * 1_000
: null,
averageDecodeMs:
framesDecoded && totalDecodeTime !== null
? (totalDecodeTime / framesDecoded) * 1_000
: null,
intervalEncodeMs,
intervalDecodeMs,
qualityLimitationReason: stringValue(media, "qualityLimitationReason"),
};
}
+56 -15
View File
@@ -16,8 +16,12 @@ type SignalCandidate = Extract<
>["candidate"];
interface ViewerPeerEvents {
sendSignal: (payload: SignalPayload) => boolean;
sendRestartRequest: (connectionId: string, rebuild: boolean) => boolean;
sendSignal: (peerId: string, payload: SignalPayload) => boolean;
sendRestartRequest: (
peerId: string,
connectionId: string,
rebuild: boolean,
) => boolean;
onStream: (stream: MediaStream) => void;
onUpdate: (snapshot: PeerSnapshot) => void;
}
@@ -25,7 +29,7 @@ interface ViewerPeerEvents {
export class ViewerPeer {
private connection: RTCPeerConnection | null = null;
private connectionId: string | null = null;
private hostPeerId: string | null = null;
private parentPeerId: string | null = null;
private remoteStream = new MediaStream();
private pendingByConnection = new Map<
string,
@@ -33,8 +37,10 @@ export class ViewerPeer {
>();
private statsAccumulator: StatsAccumulator = createStatsAccumulator();
private statsTimer: number | null = null;
private statsInFlightConnection: RTCPeerConnection | null = null;
private disconnectTimer: number | null = null;
private restartRequested = false;
private offerRecoveryAttempts = 0;
private disposed = false;
private currentIceConfig: IceConfig;
private snapshot: PeerSnapshot | null = null;
@@ -48,13 +54,12 @@ export class ViewerPeer {
}
async acceptSignal(
hostPeerId: string,
parentPeerId: string,
payload: SignalPayload,
): Promise<void> {
if (this.disposed) {
return;
}
this.hostPeerId = hostPeerId;
let operationConnection: RTCPeerConnection | null = null;
let operationConnectionId: string | null = null;
@@ -63,8 +68,12 @@ export class ViewerPeer {
if (payload.description.type !== "offer") {
return;
}
if (!this.connection || this.connectionId !== payload.connectionId) {
this.replaceConnection(hostPeerId, payload.connectionId);
if (
!this.connection ||
this.connectionId !== payload.connectionId ||
this.parentPeerId !== parentPeerId
) {
this.replaceConnection(parentPeerId, payload.connectionId);
}
const connection = this.connection;
if (!connection) {
@@ -92,7 +101,7 @@ export class ViewerPeer {
if (!connection.localDescription) {
throw new Error("Local description was not created");
}
this.events.sendSignal({
this.events.sendSignal(parentPeerId, {
kind: "description",
connectionId,
description: {
@@ -101,8 +110,10 @@ export class ViewerPeer {
},
});
this.restartRequested = false;
this.offerRecoveryAttempts = 0;
} else if (
this.connection &&
this.parentPeerId === parentPeerId &&
this.connectionId === payload.connectionId &&
this.connection.remoteDescription
) {
@@ -126,6 +137,18 @@ export class ViewerPeer {
return;
}
this.setError(error, "处理分享端信令失败");
if (
payload.kind === "description" &&
this.offerRecoveryAttempts < 1 &&
this.events.sendRestartRequest(
parentPeerId,
operationConnectionId,
true,
)
) {
this.offerRecoveryAttempts += 1;
this.restartRequested = true;
}
}
}
@@ -145,10 +168,14 @@ export class ViewerPeer {
}
requestRecovery(): boolean {
if (!this.connectionId) {
if (!this.connectionId || !this.parentPeerId) {
return false;
}
const sent = this.events.sendRestartRequest(this.connectionId, false);
const sent = this.events.sendRestartRequest(
this.parentPeerId,
this.connectionId,
false,
);
if (sent) {
this.restartRequested = true;
}
@@ -176,9 +203,14 @@ export class ViewerPeer {
this.pendingByConnection.clear();
}
private replaceConnection(hostPeerId: string, connectionId: string): void {
private replaceConnection(parentPeerId: string, connectionId: string): void {
const parentChanged =
this.parentPeerId !== null && this.parentPeerId !== parentPeerId;
this.disposeConnection();
this.hostPeerId = hostPeerId;
if (parentChanged) {
this.offerRecoveryAttempts = 0;
}
this.parentPeerId = parentPeerId;
this.connectionId = connectionId;
this.remoteStream = new MediaStream();
this.statsAccumulator = createStatsAccumulator();
@@ -190,7 +222,7 @@ export class ViewerPeer {
});
this.connection = connection;
this.snapshot = {
peerId: hostPeerId,
peerId: parentPeerId,
connectionId,
connectionState: connection.connectionState,
iceConnectionState: connection.iceConnectionState,
@@ -199,10 +231,10 @@ export class ViewerPeer {
};
connection.addEventListener("icecandidate", (event) => {
if (!this.hostPeerId || this.connection !== connection) {
if (!this.parentPeerId || this.connection !== connection) {
return;
}
this.events.sendSignal({
this.events.sendSignal(parentPeerId, {
kind: "candidate",
connectionId,
candidate: event.candidate
@@ -315,6 +347,10 @@ export class ViewerPeer {
if (!this.isCurrentConnection(connection, connectionId)) {
return;
}
if (this.statsInFlightConnection === connection) {
return;
}
this.statsInFlightConnection = connection;
const statsAccumulator = this.statsAccumulator;
try {
const metrics = await collectConnectionMetrics(
@@ -331,6 +367,10 @@ export class ViewerPeer {
}
} catch {
// Stats are observational and must never disrupt a healthy media path.
} finally {
if (this.statsInFlightConnection === connection) {
this.statsInFlightConnection = null;
}
}
}
@@ -385,6 +425,7 @@ export class ViewerPeer {
this.connection?.close();
this.connection = null;
this.connectionId = null;
this.parentPeerId = null;
this.snapshot = null;
}
}
+252
View File
@@ -0,0 +1,252 @@
import type { IceConfig, SignalPayload } from "../../shared/protocol";
import type { QualityProfile } from "../media/quality";
import type { PeerSnapshot } from "../types";
import { HostPeer } from "./host-peer";
interface ViewerRelayEvents {
sendSignal: (peerId: string, payload: SignalPayload) => boolean;
onUpdate?: (snapshot: PeerSnapshot | null) => void;
}
export class ViewerRelay {
private childPeerId: string | null = null;
private stream: MediaStream | null = null;
private peer: HostPeer | null = null;
private snapshot: PeerSnapshot | null = null;
private syncQueue = Promise.resolve();
private disposed = false;
constructor(
private iceConfig: IceConfig,
private desiredProfile: QualityProfile,
private readonly events: ViewerRelayEvents,
private readonly forceRelay = false,
) {}
getSnapshot(): PeerSnapshot | null {
return this.snapshot
? { ...this.snapshot, metrics: { ...this.snapshot.metrics } }
: null;
}
setChild(childPeerId: string | null): void {
if (this.disposed) {
return;
}
if (this.childPeerId === childPeerId) {
if (
childPeerId &&
this.stream &&
(!this.peer || !this.peer.isConnected())
) {
this.disposePeer();
this.startPeer(childPeerId, this.stream);
}
return;
}
this.disposePeer();
this.childPeerId = childPeerId;
if (childPeerId && this.stream) {
this.startPeer(childPeerId, this.stream);
}
}
setStream(stream: MediaStream): void {
if (this.disposed) {
return;
}
this.stream = stream;
this.syncQueue = this.syncQueue
.then(() => this.syncStream(stream))
.catch(() => undefined);
}
updateProfile(profile: QualityProfile): Promise<boolean> {
if (this.disposed) {
return Promise.resolve(false);
}
this.desiredProfile = profile;
const result = this.syncQueue.then(() => this.syncProfile(profile));
this.syncQueue = result.then(
() => undefined,
() => undefined,
);
return result.catch(() => false);
}
async acceptSignal(
fromPeerId: string,
payload: SignalPayload,
): Promise<void> {
if (fromPeerId !== this.childPeerId) {
return;
}
await this.peer?.acceptSignal(payload);
}
async recover(
fromPeerId: string,
connectionId: string,
rebuild: boolean,
): Promise<void> {
const stream = this.stream;
const peer = this.peer;
if (this.disposed || !stream || fromPeerId !== this.childPeerId) {
return;
}
if (rebuild) {
if (peer && peer.connectionId !== connectionId) {
return;
}
this.disposePeer();
this.startPeer(fromPeerId, stream);
return;
}
if (!peer || peer.connectionId !== connectionId) {
return;
}
if (await peer.restartIce()) {
return;
}
if (this.peer === peer) {
this.disposePeer();
this.startPeer(fromPeerId, stream);
}
}
updateIceConfig(iceConfig: IceConfig): void {
this.iceConfig = iceConfig;
this.peer?.updateIceConfig(iceConfig);
}
stop(): void {
this.stream = null;
this.disposePeer();
}
dispose(): void {
if (this.disposed) {
return;
}
this.disposed = true;
this.stream = null;
this.childPeerId = null;
this.disposePeer();
}
private async syncStream(stream: MediaStream): Promise<void> {
const childPeerId = this.childPeerId;
if (this.disposed || this.stream !== stream || !childPeerId) {
return;
}
const peer = this.peer;
if (!peer) {
this.startPeer(childPeerId, stream);
return;
}
let replaced = false;
try {
replaced = await peer.replaceStream(stream);
} catch {
// Rebuilding below is safer than leaving a stopped upstream track.
}
if (
replaced ||
this.disposed ||
this.peer !== peer ||
this.stream !== stream ||
this.childPeerId !== childPeerId
) {
return;
}
this.disposePeer();
this.startPeer(childPeerId, stream);
}
private async syncProfile(profile: QualityProfile): Promise<boolean> {
if (this.disposed || this.desiredProfile !== profile) {
return false;
}
const peer = this.peer;
if (!peer) {
return true;
}
return peer.updateProfile(this.desiredProfile);
}
private startPeer(
childPeerId: string,
stream: MediaStream,
attempt = 0,
): void {
if (
this.disposed ||
this.peer ||
this.stream !== stream ||
this.childPeerId !== childPeerId
) {
return;
}
let peer: HostPeer;
peer = new HostPeer(
childPeerId,
this.iceConfig,
stream,
this.desiredProfile,
{
sendSignal: (targetPeerId, payload) =>
!this.disposed &&
this.peer === peer &&
this.childPeerId === targetPeerId
? this.events.sendSignal(targetPeerId, payload)
: false,
onUpdate: (snapshot) => {
if (
!this.disposed &&
this.peer === peer &&
this.childPeerId === childPeerId &&
snapshot.peerId === childPeerId &&
snapshot.connectionId === peer.connectionId
) {
this.snapshot = {
...snapshot,
metrics: { ...snapshot.metrics },
};
this.events.onUpdate?.(this.getSnapshot());
}
},
},
this.forceRelay,
);
this.peer = peer;
void peer
.start()
.catch(() => false)
.then((started) => {
if (started || this.peer !== peer) {
return;
}
this.disposePeer();
if (
attempt < 1 &&
!this.disposed &&
this.stream === stream &&
this.childPeerId === childPeerId
) {
window.setTimeout(() => {
this.startPeer(childPeerId, stream, attempt + 1);
}, 500);
}
});
}
private disposePeer(): void {
const peer = this.peer;
this.peer = null;
this.snapshot = null;
this.events.onUpdate?.(null);
peer?.dispose();
}
}
+1
View File
@@ -93,6 +93,7 @@ export async function createScreenerServer(
const signaling = new SignalingServer({
server: httpServer,
roomStore,
peerAssistedMedia: config.peerAssistedMedia,
ice: iceOptions,
allowedOrigins: config.allowedOrigins,
authorizeUpgrade: (request) =>
+38 -7
View File
@@ -5,6 +5,7 @@ export type RuntimeEnvironment = "development" | "test" | "production";
const MAX_TURN_CREDENTIAL_TTL_SECONDS = 3_600;
const MAX_ACCESS_PASSWORD_BYTES = 128;
const MIN_TURN_SECRET_BYTES = 32;
const MAX_PEER_ASSISTED_VIEWERS = 8;
const DEFAULT_MAX_VIEWERS_PER_ROOM = 8;
const VISIBLE_ASCII_PATTERN = /^[\x21-\x7e]+$/;
@@ -19,12 +20,30 @@ export interface ServerConfig {
roomTtlMs: number;
maxRooms: number;
maxViewersPerRoom: number;
peerAssistedMedia: boolean;
stunUrls: readonly string[];
turnUrls: readonly string[];
turnSharedSecret?: string;
turnCredentialTtlSeconds: number;
}
function parseBoolean(
value: string | undefined,
fallback: boolean,
name: string,
): boolean {
if (value === undefined || value === "") {
return fallback;
}
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
throw new Error(`${name} must be true or false`);
}
interface IceEndpoint {
scheme: "stun" | "stuns" | "turn" | "turns";
port?: number;
@@ -256,6 +275,18 @@ export function loadConfig(
"TURN_URLS",
new Set(["turn:", "turns:"]),
);
const maxViewersPerRoom = parseBoundedInteger(
environment.MAX_VIEWERS_PER_ROOM,
DEFAULT_MAX_VIEWERS_PER_ROOM,
"MAX_VIEWERS_PER_ROOM",
1,
MAX_VIEWERS_PER_ROOM_LIMIT,
);
const peerAssistedMedia = parseBoolean(
environment.PEER_ASSISTED_MEDIA,
false,
"PEER_ASSISTED_MEDIA",
);
if ((turnUrls.length > 0) !== Boolean(turnSharedSecret)) {
throw new Error(
@@ -293,6 +324,11 @@ export function loadConfig(
if (nodeEnv === "production") {
requireProductionTurnCoverage(turnUrls);
}
if (peerAssistedMedia && maxViewersPerRoom > MAX_PEER_ASSISTED_VIEWERS) {
throw new Error(
"PEER_ASSISTED_MEDIA currently supports at most 8 viewers per room",
);
}
return {
nodeEnv,
@@ -309,13 +345,8 @@ export function loadConfig(
parsePositiveInteger(environment.ROOM_TTL_SECONDS, 14_400, "ROOM_TTL_SECONDS") *
1_000,
maxRooms: parsePositiveInteger(environment.MAX_ROOMS, 1_000, "MAX_ROOMS"),
maxViewersPerRoom: parseBoundedInteger(
environment.MAX_VIEWERS_PER_ROOM,
DEFAULT_MAX_VIEWERS_PER_ROOM,
"MAX_VIEWERS_PER_ROOM",
1,
MAX_VIEWERS_PER_ROOM_LIMIT,
),
maxViewersPerRoom,
peerAssistedMedia,
stunUrls,
turnUrls,
turnSharedSecret,
+280
View File
@@ -0,0 +1,280 @@
import type { MediaAssignment } from "../shared/protocol.js";
export interface MediaAssignmentChange {
peerId: string;
previousParentPeerId?: string | null;
mediaAssignment: MediaAssignment;
}
interface RelayViewer {
order: number;
parentPeerId: string | null;
childPeerIds: string[];
}
interface RelayRoom {
hostPeerId?: string;
hostChildPeerIds: string[];
viewers: Map<string, RelayViewer>;
nextOrder: number;
}
export class PeerRelayTopology {
private readonly rooms = new Map<string, RelayRoom>();
setHost(
roomId: string,
hostPeerId: string,
connectedPeerIds: ReadonlySet<string>,
): MediaAssignmentChange[] {
const room = this.room(roomId);
const before = snapshot(room);
const previousHostPeerId = room.hostPeerId;
room.hostPeerId = hostPeerId;
if (previousHostPeerId !== hostPeerId) {
for (const childPeerId of room.hostChildPeerIds) {
const child = room.viewers.get(childPeerId);
if (child) {
child.parentPeerId = hostPeerId;
}
}
}
for (const [peerId, viewer] of orderedViewers(room)) {
if (viewer.parentPeerId === null) {
this.assignViewer(room, peerId, connectedPeerIds);
}
}
return changedAssignments(before, snapshot(room));
}
addViewer(
roomId: string,
peerId: string,
connectedPeerIds: ReadonlySet<string>,
): MediaAssignmentChange[] {
const room = this.room(roomId);
const before = snapshot(room);
if (!room.viewers.has(peerId)) {
room.viewers.set(peerId, {
order: room.nextOrder,
parentPeerId: null,
childPeerIds: [],
});
room.nextOrder += 1;
}
for (const [candidatePeerId, viewer] of orderedViewers(room)) {
if (viewer.parentPeerId === null) {
this.assignViewer(room, candidatePeerId, connectedPeerIds);
}
}
return changedAssignments(before, snapshot(room));
}
removeViewer(
roomId: string,
peerId: string,
connectedPeerIds: ReadonlySet<string>,
): MediaAssignmentChange[] {
const room = this.rooms.get(roomId);
const removed = room?.viewers.get(peerId);
if (!room || !removed) {
return [];
}
const before = snapshot(room);
this.detach(room, peerId, removed.parentPeerId);
const subtreeRootPeerId = removed.childPeerIds[0];
if (subtreeRootPeerId) {
const subtreeRoot = room.viewers.get(subtreeRootPeerId);
if (subtreeRoot) {
subtreeRoot.parentPeerId = null;
}
}
room.viewers.delete(peerId);
if (subtreeRootPeerId) {
this.assignViewer(room, subtreeRootPeerId, connectedPeerIds);
}
for (const [candidatePeerId, viewer] of orderedViewers(room)) {
if (viewer.parentPeerId === null) {
this.assignViewer(room, candidatePeerId, connectedPeerIds);
}
}
return changedAssignments(before, snapshot(room));
}
getAssignment(
roomId: string,
peerId: string,
): MediaAssignment | undefined {
const room = this.rooms.get(roomId);
if (!room) {
return undefined;
}
if (room.hostPeerId === peerId) {
return {
parentPeerId: null,
childPeerIds: [...room.hostChildPeerIds],
};
}
const viewer = room.viewers.get(peerId);
return viewer
? {
parentPeerId: viewer.parentPeerId,
childPeerIds: [...viewer.childPeerIds],
}
: undefined;
}
isParentOf(roomId: string, parentPeerId: string, childPeerId: string): boolean {
return (
this.rooms.get(roomId)?.viewers.get(childPeerId)?.parentPeerId ===
parentPeerId
);
}
deleteRoom(roomId: string): void {
this.rooms.delete(roomId);
}
private room(roomId: string): RelayRoom {
let room = this.rooms.get(roomId);
if (!room) {
room = {
hostChildPeerIds: [],
viewers: new Map(),
nextOrder: 0,
};
this.rooms.set(roomId, room);
}
return room;
}
private assignViewer(
room: RelayRoom,
peerId: string,
connectedPeerIds: ReadonlySet<string>,
): void {
const viewer = room.viewers.get(peerId);
if (
!viewer ||
viewer.parentPeerId !== null ||
!connectedPeerIds.has(peerId)
) {
return;
}
const parentPeerId = findShallowestAvailableParent(room, connectedPeerIds);
if (!parentPeerId) {
return;
}
viewer.parentPeerId = parentPeerId;
const children = childPeerIds(room, parentPeerId);
children.push(peerId);
children.sort(
(left, right) =>
room.viewers.get(left)!.order - room.viewers.get(right)!.order,
);
}
private detach(
room: RelayRoom,
peerId: string,
parentPeerId: string | null,
): void {
if (!parentPeerId) {
return;
}
const children = childPeerIds(room, parentPeerId);
const childIndex = children.indexOf(peerId);
if (childIndex !== -1) {
children.splice(childIndex, 1);
}
}
}
function findShallowestAvailableParent(
room: RelayRoom,
connectedPeerIds: ReadonlySet<string>,
): string | undefined {
const hostPeerId = room.hostPeerId;
if (!hostPeerId || !connectedPeerIds.has(hostPeerId)) {
return undefined;
}
const queue = [hostPeerId];
while (queue.length > 0) {
const peerId = queue.shift()!;
if (!connectedPeerIds.has(peerId)) {
continue;
}
const children = childPeerIds(room, peerId);
const capacity = peerId === hostPeerId ? 2 : 1;
if (children.length < capacity) {
return peerId;
}
queue.push(...children);
}
return undefined;
}
function childPeerIds(room: RelayRoom, peerId: string): string[] {
if (room.hostPeerId === peerId) {
return room.hostChildPeerIds;
}
const viewer = room.viewers.get(peerId);
if (!viewer) {
throw new Error("Peer relay topology references an unknown parent");
}
return viewer.childPeerIds;
}
function orderedViewers(room: RelayRoom): Array<[string, RelayViewer]> {
return [...room.viewers.entries()].sort(
([, left], [, right]) => left.order - right.order,
);
}
function snapshot(room: RelayRoom): Map<string, MediaAssignment> {
const assignments = new Map<string, MediaAssignment>();
if (room.hostPeerId) {
assignments.set(room.hostPeerId, {
parentPeerId: null,
childPeerIds: [...room.hostChildPeerIds],
});
}
for (const [peerId, viewer] of room.viewers) {
assignments.set(peerId, {
parentPeerId: viewer.parentPeerId,
childPeerIds: [...viewer.childPeerIds],
});
}
return assignments;
}
function changedAssignments(
before: ReadonlyMap<string, MediaAssignment>,
after: ReadonlyMap<string, MediaAssignment>,
): MediaAssignmentChange[] {
const changes: MediaAssignmentChange[] = [];
for (const [peerId, mediaAssignment] of after) {
const previous = before.get(peerId);
if (
!previous ||
previous.parentPeerId !== mediaAssignment.parentPeerId ||
previous.childPeerIds.length !== mediaAssignment.childPeerIds.length ||
previous.childPeerIds.some(
(childPeerId, index) =>
childPeerId !== mediaAssignment.childPeerIds[index],
)
) {
changes.push({
peerId,
previousParentPeerId: previous?.parentPeerId,
mediaAssignment,
});
}
}
return changes;
}
+311 -4
View File
@@ -5,13 +5,19 @@ import type { Duplex } from "node:stream";
import WebSocket, { WebSocketServer } from "ws";
import {
DEFAULT_QUALITY_PROFILE_ID,
MAX_SIGNAL_BYTES,
decodeClientMessage,
type ClientMessage,
type QualityProfileId,
type Role,
type ServerMessage,
} from "../shared/protocol.js";
import { RoomStore, RoomStoreError } from "./room-store.js";
import {
PeerRelayTopology,
type MediaAssignmentChange,
} from "./peer-relay-topology.js";
import { createIceConfig, type IceConfigOptions } from "./turn.js";
type ErrorCode = Extract<ServerMessage, { type: "error" }>["code"];
@@ -37,6 +43,7 @@ interface SocketState {
export interface SignalingOptions {
server: HttpServer;
roomStore: RoomStore;
peerAssistedMedia: boolean;
ice: IceConfigOptions;
allowedOrigins: ReadonlySet<string>;
authorizeUpgrade: (request: IncomingMessage) => boolean;
@@ -60,6 +67,11 @@ export class SignalingServer {
private readonly socketsBySessionId = new Map<string, WebSocket>();
private readonly viewerGraceTimers = new Map<string, NodeJS.Timeout>();
private readonly connectionIdsByViewer = new Map<string, string>();
private readonly qualityProfileIdsByRoom = new Map<
string,
QualityProfileId
>();
private readonly peerRelayTopology = new PeerRelayTopology();
private readonly now: () => number;
private readonly authenticationTimeoutMs: number;
private readonly viewerDisconnectGraceMs: number;
@@ -283,6 +295,32 @@ export class SignalingServer {
: Date.parse(participant.expiresAt),
};
this.clearViewerGrace(participant.roomId, participant.peerId);
const assignmentChanges = this.options.peerAssistedMedia
? participant.role === "host"
? this.peerRelayTopology.setHost(
participant.roomId,
participant.peerId,
this.connectedPeerIds(participant.roomId),
)
: this.peerRelayTopology.addViewer(
participant.roomId,
participant.peerId,
this.connectedPeerIds(participant.roomId),
)
: [];
this.clearChangedConnectionIds(participant.roomId, assignmentChanges);
const mediaAssignment = this.options.peerAssistedMedia
? this.peerRelayTopology.getAssignment(
participant.roomId,
participant.peerId,
)
: undefined;
if (this.options.peerAssistedMedia && !mediaAssignment) {
console.error("Peer relay topology did not assign an authenticated participant");
this.sendError(socket, "SERVER_ERROR", "Media assignment failed");
socket.close(1011, "Media assignment failed");
return;
}
const connectionId =
participant.role === "viewer"
? (this.connectionIdsByViewer.get(
@@ -294,8 +332,8 @@ export class SignalingServer {
? this.options.roomStore.getConnectedViewers(participant.roomId)
: [];
this.send(socket, {
type: "authenticated",
const authenticatedMessage = {
type: "authenticated" as const,
role: participant.role,
peerId: participant.peerId,
roomExpiresAt: participant.expiresAt,
@@ -310,7 +348,19 @@ export class SignalingServer {
? null
: Date.parse(participant.expiresAt),
),
});
};
if (mediaAssignment) {
this.send(socket, {
...authenticatedMessage,
mediaMode: "peer-assisted",
mediaAssignment,
qualityProfileId:
this.qualityProfileIdsByRoom.get(participant.roomId) ??
DEFAULT_QUALITY_PROFILE_ID,
});
} else {
this.send(socket, authenticatedMessage);
}
if (participant.replacedSessionId) {
const replaced = this.socketsBySessionId.get(participant.replacedSessionId);
@@ -319,14 +369,40 @@ export class SignalingServer {
}
}
if (this.options.peerAssistedMedia) {
this.sendMediaAssignmentChanges(
participant.roomId,
assignmentChanges,
participant.peerId,
);
if (
participant.role === "viewer" &&
mediaAssignment?.parentPeerId &&
!assignmentChanges.some(
({ peerId }) => peerId === mediaAssignment.parentPeerId,
)
) {
this.sendCurrentParentAssignment(
participant.roomId,
participant.peerId,
);
}
}
if (participant.role === "host") {
for (const viewer of connectedViewers) {
this.sendToSession(viewer.sessionId, { type: "host-status", online: true });
this.send(socket, { type: "peer-joined", peerId: viewer.peerId });
if (!this.options.peerAssistedMedia) {
this.send(socket, { type: "peer-joined", peerId: viewer.peerId });
}
}
return;
}
if (this.options.peerAssistedMedia) {
return;
}
const host = this.options.roomStore.getConnectedHost(participant.roomId);
if (!host) {
return;
@@ -354,6 +430,14 @@ export class SignalingServer {
this.sendError(socket, "FORBIDDEN", "Only viewers may request an ICE restart");
return;
}
if (this.options.peerAssistedMedia) {
this.routePeerAssistedRestart(socket, authenticated, message);
return;
}
if (message.targetPeerId) {
this.sendError(socket, "FORBIDDEN", "P2P restart requests target the host implicitly");
return;
}
this.routeToHost(socket, authenticated, {
type: "restart-request",
fromPeerId: authenticated.peerId,
@@ -371,6 +455,28 @@ export class SignalingServer {
),
});
return;
case "set-quality-profile":
if (!this.options.peerAssistedMedia || authenticated.role !== "host") {
this.sendError(
socket,
"FORBIDDEN",
"Only a peer-assisted host may set the quality profile",
);
return;
}
this.qualityProfileIdsByRoom.set(
authenticated.roomId,
message.qualityProfileId,
);
for (const viewer of this.options.roomStore.getConnectedViewers(
authenticated.roomId,
)) {
this.sendToSession(viewer.sessionId, {
type: "quality-profile",
qualityProfileId: message.qualityProfileId,
});
}
return;
case "stop-sharing":
case "close-room":
if (authenticated.role !== "host") {
@@ -408,6 +514,11 @@ export class SignalingServer {
source: AuthenticatedSession,
message: Extract<ClientMessage, { type: "signal" }>,
): void {
if (this.options.peerAssistedMedia) {
this.routePeerAssistedSignal(sourceSocket, source, message);
return;
}
const description =
message.payload.kind === "description" ? message.payload.description : undefined;
@@ -449,6 +560,101 @@ export class SignalingServer {
});
}
private routePeerAssistedSignal(
sourceSocket: WebSocket,
source: AuthenticatedSession,
message: Extract<ClientMessage, { type: "signal" }>,
): void {
const targetPeerId = message.targetPeerId;
if (!targetPeerId) {
this.sendError(
sourceSocket,
"FORBIDDEN",
"Peer-assisted signals must target an assigned peer",
);
return;
}
const target = this.connectedPeer(source.roomId, targetPeerId);
if (!target) {
this.sendError(sourceSocket, "PEER_NOT_FOUND", "Target peer is not connected");
return;
}
const parentToChild = this.peerRelayTopology.isParentOf(
source.roomId,
source.peerId,
targetPeerId,
);
const childToParent = this.peerRelayTopology.isParentOf(
source.roomId,
targetPeerId,
source.peerId,
);
const description =
message.payload.kind === "description"
? message.payload.description
: undefined;
const authorized = description
? description.type === "offer"
? parentToChild
: childToParent
: parentToChild || childToParent;
if (!authorized) {
this.sendError(
sourceSocket,
"FORBIDDEN",
"Signal target is not authorized by the media assignment",
);
return;
}
if (description?.type === "offer") {
this.connectionIdsByViewer.set(
viewerConnectionKey(source.roomId, targetPeerId),
message.payload.connectionId,
);
}
this.sendToSession(target.sessionId, {
type: "signal",
fromPeerId: source.peerId,
payload: message.payload,
});
}
private routePeerAssistedRestart(
sourceSocket: WebSocket,
source: AuthenticatedSession,
message: Extract<ClientMessage, { type: "restart-request" }>,
): void {
const targetPeerId = message.targetPeerId;
if (
!targetPeerId ||
!this.peerRelayTopology.isParentOf(
source.roomId,
targetPeerId,
source.peerId,
)
) {
this.sendError(
sourceSocket,
"FORBIDDEN",
"Restart target is not the assigned media parent",
);
return;
}
const parent = this.connectedPeer(source.roomId, targetPeerId);
if (!parent) {
this.sendError(sourceSocket, "PEER_NOT_FOUND", "Media parent is not connected");
return;
}
this.sendToSession(parent.sessionId, {
type: "restart-request",
fromPeerId: source.peerId,
connectionId: message.connectionId,
rebuild: message.rebuild,
});
}
private routeToHost(
sourceSocket: WebSocket,
source: AuthenticatedSession,
@@ -509,6 +715,22 @@ export class SignalingServer {
this.connectionIdsByViewer.delete(
viewerConnectionKey(disconnected.roomId, disconnected.peerId),
);
if (this.options.peerAssistedMedia) {
const assignmentChanges = this.peerRelayTopology.removeViewer(
disconnected.roomId,
disconnected.peerId,
this.connectedPeerIds(disconnected.roomId),
);
this.clearChangedConnectionIds(
disconnected.roomId,
assignmentChanges,
);
this.sendMediaAssignmentChanges(
disconnected.roomId,
assignmentChanges,
);
return;
}
const host = this.options.roomStore.getConnectedHost(disconnected.roomId);
if (host) {
this.sendToSession(host.sessionId, {
@@ -537,6 +759,8 @@ export class SignalingServer {
}
this.clearRoomGraceTimers(roomId);
this.clearRoomConnectionIds(roomId);
this.peerRelayTopology.deleteRoom(roomId);
this.qualityProfileIdsByRoom.delete(roomId);
for (const sessionId of abandoned.sessionIds) {
const socket = this.socketsBySessionId.get(sessionId);
if (!socket) {
@@ -551,6 +775,8 @@ export class SignalingServer {
for (const expired of this.options.roomStore.expireRooms(this.now())) {
this.clearRoomGraceTimers(expired.roomId);
this.clearRoomConnectionIds(expired.roomId);
this.peerRelayTopology.deleteRoom(expired.roomId);
this.qualityProfileIdsByRoom.delete(expired.roomId);
for (const sessionId of expired.sessionIds) {
const socket = this.socketsBySessionId.get(sessionId);
if (!socket) {
@@ -621,6 +847,87 @@ export class SignalingServer {
);
}
private connectedPeerIds(roomId: string): Set<string> {
const peerIds = new Set(
this.options.roomStore
.getConnectedViewers(roomId)
.map((viewer) => viewer.peerId),
);
const host = this.options.roomStore.getConnectedHost(roomId);
if (host) {
peerIds.add(host.peerId);
}
return peerIds;
}
private connectedPeer(roomId: string, peerId: string) {
const host = this.options.roomStore.getConnectedHost(roomId);
if (host?.peerId === peerId) {
return host;
}
return this.options.roomStore.getConnectedViewer(roomId, peerId);
}
private clearChangedConnectionIds(
roomId: string,
changes: readonly MediaAssignmentChange[],
): void {
for (const change of changes) {
if (
change.previousParentPeerId !== undefined &&
change.previousParentPeerId !==
change.mediaAssignment.parentPeerId
) {
this.connectionIdsByViewer.delete(
viewerConnectionKey(roomId, change.peerId),
);
}
}
}
private sendMediaAssignmentChanges(
roomId: string,
changes: readonly MediaAssignmentChange[],
excludedPeerId?: string,
): void {
for (const change of changes) {
if (change.peerId === excludedPeerId) {
continue;
}
const peer = this.connectedPeer(roomId, change.peerId);
if (peer) {
this.sendToSession(peer.sessionId, {
type: "media-assignment",
mediaAssignment: change.mediaAssignment,
});
}
}
}
private sendCurrentParentAssignment(
roomId: string,
childPeerId: string,
): void {
const parentPeerId = this.peerRelayTopology.getAssignment(
roomId,
childPeerId,
)?.parentPeerId;
if (!parentPeerId) {
return;
}
const parent = this.connectedPeer(roomId, parentPeerId);
const parentAssignment = this.peerRelayTopology.getAssignment(
roomId,
parentPeerId,
);
if (parent && parentAssignment) {
this.sendToSession(parent.sessionId, {
type: "media-assignment",
mediaAssignment: parentAssignment,
});
}
}
private sendToSession(sessionId: string, message: ServerMessage): void {
const socket = this.socketsBySessionId.get(sessionId);
if (socket) {
+57 -10
View File
@@ -25,6 +25,14 @@ export const roomCodeSchema = z
export const roleSchema = z.enum(["host", "viewer"]);
export type Role = z.infer<typeof roleSchema>;
export const qualityProfileIdSchema = z.enum([
"1080p60",
"1080p30",
"720p30",
]);
export type QualityProfileId = z.infer<typeof qualityProfileIdSchema>;
export const DEFAULT_QUALITY_PROFILE_ID: QualityProfileId = "1080p60";
const iceServerSchema = z
.object({
urls: z.union([
@@ -79,6 +87,14 @@ export const signalPayloadSchema = z.discriminatedUnion("kind", [
]);
export type SignalPayload = z.infer<typeof signalPayloadSchema>;
export const mediaAssignmentSchema = z
.object({
parentPeerId: opaqueIdSchema.nullable(),
childPeerIds: z.array(opaqueIdSchema).max(2),
})
.strict();
export type MediaAssignment = z.infer<typeof mediaAssignmentSchema>;
const authenticateMessageSchema = z.discriminatedUnion("role", [
z
.object({
@@ -111,11 +127,18 @@ export const clientMessageSchema = z.union([
z
.object({
type: z.literal("restart-request"),
targetPeerId: opaqueIdSchema.optional(),
connectionId: opaqueIdSchema,
rebuild: z.boolean(),
})
.strict(),
z.object({ type: z.literal("refresh-ice") }).strict(),
z
.object({
type: z.literal("set-quality-profile"),
qualityProfileId: qualityProfileIdSchema,
})
.strict(),
z.object({ type: z.literal("stop-sharing") }).strict(),
// Kept as a compatibility alias while previously deployed clients age out.
z.object({ type: z.literal("close-room") }).strict(),
@@ -135,20 +158,32 @@ const errorCodeSchema = z.enum([
"SERVER_ERROR",
]);
export const serverMessageSchema = z.discriminatedUnion("type", [
const authenticatedMessageShape = {
type: z.literal("authenticated"),
role: roleSchema,
peerId: opaqueIdSchema,
roomExpiresAt: z.string().datetime().nullable(),
maxViewers: z.number().int().min(1).max(MAX_VIEWERS_PER_ROOM_LIMIT),
hostOnline: z.boolean(),
connectionId: opaqueIdSchema.nullable(),
viewerPeerIds: z.array(opaqueIdSchema).max(MAX_VIEWERS_PER_ROOM_LIMIT),
iceConfig: iceConfigSchema,
};
const authenticatedMessageSchema = z.union([
z.object(authenticatedMessageShape).strict(),
z
.object({
type: z.literal("authenticated"),
role: roleSchema,
peerId: opaqueIdSchema,
roomExpiresAt: z.string().datetime().nullable(),
maxViewers: z.number().int().min(1).max(MAX_VIEWERS_PER_ROOM_LIMIT),
hostOnline: z.boolean(),
connectionId: opaqueIdSchema.nullable(),
viewerPeerIds: z.array(opaqueIdSchema).max(MAX_VIEWERS_PER_ROOM_LIMIT),
iceConfig: iceConfigSchema,
...authenticatedMessageShape,
mediaMode: z.literal("peer-assisted"),
mediaAssignment: mediaAssignmentSchema,
qualityProfileId: qualityProfileIdSchema,
})
.strict(),
]);
export const serverMessageSchema = z.union([
authenticatedMessageSchema,
z
.object({
type: z.literal("peer-joined"),
@@ -176,6 +211,18 @@ export const serverMessageSchema = z.discriminatedUnion("type", [
rebuild: z.boolean(),
})
.strict(),
z
.object({
type: z.literal("media-assignment"),
mediaAssignment: mediaAssignmentSchema,
})
.strict(),
z
.object({
type: z.literal("quality-profile"),
qualityProfileId: qualityProfileIdSchema,
})
.strict(),
z
.object({
type: z.literal("ice-config"),
+100 -3
View File
@@ -323,23 +323,42 @@ describe("WebRTC stats parsing", () => {
},
],
]);
const secondReport = new Map(report);
secondReport.set("inbound", {
id: "inbound",
type: "inbound-rtp",
timestamp: 4_000,
kind: "video",
bytesReceived: 4_000,
framesDecoded: 150,
totalDecodeTime: 1.5,
});
const reports = [report, secondReport];
const connection = {
getStats: async () => report as unknown as RTCStatsReport,
getStats: async () =>
reports.shift() as unknown as RTCStatsReport,
} as unknown as RTCPeerConnection;
const accumulator = createStatsAccumulator();
const firstMetrics = await collectConnectionMetrics(
connection,
"receive",
accumulator,
);
const metrics = await collectConnectionMetrics(
connection,
"receive",
createStatsAccumulator(),
accumulator,
);
expect(firstMetrics.intervalDecodeMs).toBeNull();
expect(metrics).toMatchObject({
path: "relay",
iceProtocol: "udp",
localRelayProtocol: "tls",
localCandidateType: "relay",
remoteCandidateType: "host",
averageDecodeMs: 5,
intervalDecodeMs: 20,
});
});
@@ -405,4 +424,82 @@ describe("WebRTC stats parsing", () => {
remoteCandidateType: "relay",
});
});
it("rebases interval metrics when the RTP stream or its counters reset", async () => {
const inbound = (
id: string,
timestamp: number,
bytesReceived: number,
framesDecoded: number,
totalDecodeTime: number,
) =>
new Map<string, unknown>([
[
id,
{
id,
type: "inbound-rtp",
timestamp,
kind: "video",
bytesReceived,
framesDecoded,
totalDecodeTime,
},
],
]) as unknown as RTCStatsReport;
const reports = [
inbound("inbound-a", 1_000, 10_000, 100, 0.5),
inbound("inbound-b", 2_000, 20_000, 150, 1.5),
inbound("inbound-b", 3_000, 22_000, 160, 1.7),
inbound("inbound-b", 4_000, 1_000, 10, 0.1),
inbound("inbound-b", 5_000, 3_000, 20, 0.3),
];
const connection = {
getStats: async () => reports.shift()!,
} as unknown as RTCPeerConnection;
const accumulator = createStatsAccumulator();
await collectConnectionMetrics(connection, "receive", accumulator);
const streamChanged = await collectConnectionMetrics(
connection,
"receive",
accumulator,
);
const afterStreamChange = await collectConnectionMetrics(
connection,
"receive",
accumulator,
);
const countersReset = await collectConnectionMetrics(
connection,
"receive",
accumulator,
);
const afterCounterReset = await collectConnectionMetrics(
connection,
"receive",
accumulator,
);
expect(streamChanged).toMatchObject({
bitrateKbps: null,
framesPerSecond: null,
intervalDecodeMs: null,
});
expect(afterStreamChange).toMatchObject({
bitrateKbps: 16,
framesPerSecond: 10,
});
expect(afterStreamChange.intervalDecodeMs).toBeCloseTo(20);
expect(countersReset).toMatchObject({
bitrateKbps: null,
framesPerSecond: null,
intervalDecodeMs: null,
});
expect(afterCounterReset).toMatchObject({
bitrateKbps: 16,
framesPerSecond: 10,
});
expect(afterCounterReset.intervalDecodeMs).toBeCloseTo(20);
});
});
+561 -14
View File
@@ -1,17 +1,31 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QUALITY_PROFILES } from "../src/client/media/quality.ts";
import type { PeerSnapshot } from "../src/client/types.ts";
import { HostPeer } from "../src/client/webrtc/host-peer.ts";
import { ViewerRelay } from "../src/client/webrtc/viewer-relay.ts";
const statsCallbacks: Array<() => void> = [];
class FakeSender {
failNextReplace = false;
failNextSetParameters = false;
deferNextSetParameters = false;
readonly appliedMaxBitrates: Array<number | undefined> = [];
private releaseSetParameters: (() => void) | null = null;
readonly setParameters = vi.fn(
async (_parameters: RTCRtpSendParameters) => {
async (parameters: RTCRtpSendParameters) => {
if (this.failNextSetParameters) {
this.failNextSetParameters = false;
throw new Error("setParameters failed");
}
if (this.deferNextSetParameters) {
this.deferNextSetParameters = false;
await new Promise<void>((resolve) => {
this.releaseSetParameters = resolve;
});
}
this.appliedMaxBitrates.push(parameters.encodings[0]?.maxBitrate);
},
);
readonly replaceTrack = vi.fn(async (track: MediaStreamTrack | null) => {
@@ -27,10 +41,16 @@ class FakeSender {
getParameters(): RTCRtpSendParameters {
return { encodings: [{}] } as RTCRtpSendParameters;
}
releaseDeferredSetParameters(): void {
this.releaseSetParameters?.();
this.releaseSetParameters = null;
}
}
class FakePeerConnection {
static latest: FakePeerConnection | null = null;
static offersFailing = 0;
readonly senders: FakeSender[] = [];
readonly transceiverInputs: Array<{
@@ -42,6 +62,7 @@ class FakePeerConnection {
signalingState: RTCSignalingState = "stable";
localDescription: RTCSessionDescription | null = null;
remoteDescription: RTCSessionDescription | null = null;
readonly statsReports: Array<RTCStatsReport | Promise<RTCStatsReport>> = [];
constructor() {
FakePeerConnection.latest = this;
@@ -62,6 +83,10 @@ class FakePeerConnection {
addEventListener(): void {}
async createOffer(): Promise<RTCSessionDescriptionInit> {
if (FakePeerConnection.offersFailing > 0) {
FakePeerConnection.offersFailing -= 1;
throw new Error("createOffer failed");
}
return { type: "offer", sdp: "test-offer" };
}
@@ -71,11 +96,106 @@ class FakePeerConnection {
this.localDescription = description as RTCSessionDescription;
}
readonly getStats = vi.fn(async (): Promise<RTCStatsReport> =>
await (this.statsReports.shift() ?? emptyStatsReport())
);
close(): void {
this.connectionState = "closed";
}
}
function emptyStatsReport(): RTCStatsReport {
return new Map() as unknown as RTCStatsReport;
}
function sendStatsReport({
bytesSent,
framesEncoded,
timestamp,
totalEncodeTime,
qualityLimitationReason,
}: {
bytesSent: number;
framesEncoded: number;
timestamp: number;
totalEncodeTime?: number;
qualityLimitationReason: string;
}): RTCStatsReport {
return new Map<string, Record<string, unknown>>([
[
"transport",
{
id: "transport",
type: "transport",
timestamp,
selectedCandidatePairId: "pair",
},
],
[
"pair",
{
id: "pair",
type: "candidate-pair",
timestamp,
state: "succeeded",
nominated: true,
localCandidateId: "local",
remoteCandidateId: "remote",
currentRoundTripTime: 0.02,
availableOutgoingBitrate: 6_000_000,
},
],
[
"local",
{
id: "local",
type: "local-candidate",
timestamp,
candidateType: "host",
protocol: "udp",
},
],
[
"remote",
{
id: "remote",
type: "remote-candidate",
timestamp,
candidateType: "host",
protocol: "udp",
},
],
[
"outbound-video",
{
id: "outbound-video",
type: "outbound-rtp",
timestamp,
kind: "video",
bytesSent,
framesEncoded,
framesPerSecond: 30,
frameWidth: 1280,
frameHeight: 720,
totalEncodeTime: totalEncodeTime ?? framesEncoded * 0.005,
qualityLimitationReason,
encoderImplementation: "test-encoder",
codecId: "codec",
},
],
[
"codec",
{
id: "codec",
type: "codec",
timestamp,
mimeType: "video/VP8",
},
],
]) as unknown as RTCStatsReport;
}
function createTrack(kind: "video" | "audio", id: string): MediaStreamTrack {
return { id, kind } as MediaStreamTrack;
}
@@ -92,7 +212,10 @@ function createStream(
} as unknown as MediaStream;
}
function createPeer(stream: MediaStream): HostPeer {
function createPeer(
stream: MediaStream,
onUpdate: (snapshot: PeerSnapshot) => void = () => undefined,
): HostPeer {
return new HostPeer(
"viewer-peer",
{ iceServers: [], expiresAt: null, relayAvailable: false },
@@ -100,17 +223,26 @@ function createPeer(stream: MediaStream): HostPeer {
QUALITY_PROFILES["720p30"],
{
sendSignal: () => true,
onUpdate: () => undefined,
onUpdate,
},
);
}
beforeEach(() => {
FakePeerConnection.latest = null;
FakePeerConnection.offersFailing = 0;
statsCallbacks.length = 0;
vi.stubGlobal("RTCPeerConnection", FakePeerConnection);
vi.stubGlobal("window", {
setInterval: vi.fn(() => 1),
setInterval: vi.fn((callback: () => void) => {
statsCallbacks.push(callback);
return statsCallbacks.length;
}),
clearInterval: vi.fn(),
setTimeout: (callback: () => void, delay: number) =>
globalThis.setTimeout(callback, delay),
clearTimeout: (timer: ReturnType<typeof setTimeout>) =>
globalThis.clearTimeout(timer),
});
});
@@ -135,14 +267,12 @@ describe("HostPeer source replacement", () => {
const nextVideo = createTrack("video", "next-video");
const nextAudio = createTrack("audio", "next-audio");
await expect(
peer.replaceStream(
createStream(nextVideo, nextAudio),
QUALITY_PROFILES["1080p60"],
),
peer.replaceStream(createStream(nextVideo, nextAudio)),
).resolves.toBe(true);
expect(connection.senders[0]?.track).toBe(nextVideo);
expect(connection.senders[1]?.track).toBe(nextAudio);
expect(connection.transceiverInputs).toHaveLength(2);
expect(connection.senders[0]?.setParameters).toHaveBeenCalledTimes(2);
});
@@ -160,7 +290,6 @@ describe("HostPeer source replacement", () => {
await expect(
peer.replaceStream(
createStream(createTrack("video", "next-video"), nextAudio),
QUALITY_PROFILES["1080p30"],
),
).resolves.toBe(true);
expect(connection.senders[1]?.track).toBe(nextAudio);
@@ -179,7 +308,6 @@ describe("HostPeer source replacement", () => {
await expect(
peer.replaceStream(
createStream(createTrack("video", "next-video"), null),
QUALITY_PROFILES["720p30"],
),
).resolves.toBe(true);
@@ -208,6 +336,55 @@ describe("HostPeer source replacement", () => {
});
});
it("serializes initial sender configuration with a live profile update", async () => {
const peer = createPeer(
createStream(
createTrack("video", "video"),
createTrack("audio", "audio"),
),
);
const starting = peer.start();
const videoSender = FakePeerConnection.latest!.senders[0]!;
videoSender.deferNextSetParameters = true;
await vi.waitFor(() =>
expect(videoSender.setParameters).toHaveBeenCalledTimes(1),
);
const updating = peer.updateProfile(QUALITY_PROFILES["1080p60"]);
await Promise.resolve();
expect(videoSender.setParameters).toHaveBeenCalledTimes(1);
videoSender.releaseDeferredSetParameters();
await expect(starting).resolves.toBe(true);
await expect(updating).resolves.toBe(true);
expect(videoSender.appliedMaxBitrates).toEqual([3_000_000, 8_000_000]);
});
it("continues queued profile updates after initial configuration rejects", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
try {
const peer = createPeer(
createStream(
createTrack("video", "video"),
createTrack("audio", "audio"),
),
);
const starting = peer.start();
const videoSender = FakePeerConnection.latest!.senders[0]!;
videoSender.failNextSetParameters = true;
const updating = peer.updateProfile(QUALITY_PROFILES["1080p60"]);
await expect(starting).resolves.toBe(true);
await expect(updating).resolves.toBe(true);
expect(videoSender.setParameters).toHaveBeenCalledTimes(2);
expect(videoSender.appliedMaxBitrates).toEqual([8_000_000]);
} finally {
warn.mockRestore();
}
});
it("can retry the selected quality after a sender update fails", async () => {
const peer = createPeer(
createStream(
@@ -234,6 +411,79 @@ describe("HostPeer source replacement", () => {
});
});
it("discards an in-flight stats sample after a profile reset", async () => {
let resolveOldStats!: (report: RTCStatsReport) => void;
const oldStats = new Promise<RTCStatsReport>((resolve) => {
resolveOldStats = resolve;
});
const updates: PeerSnapshot[] = [];
const peer = createPeer(
createStream(createTrack("video", "video"), null),
(snapshot) => updates.push(snapshot),
);
await expect(peer.start()).resolves.toBe(true);
const connection = FakePeerConnection.latest!;
connection.statsReports.push(oldStats);
statsCallbacks[0]!();
await vi.waitFor(() => expect(connection.statsReports).toHaveLength(0));
statsCallbacks[0]!();
expect(connection.getStats).toHaveBeenCalledOnce();
await expect(
peer.updateProfile(QUALITY_PROFILES["1080p60"]),
).resolves.toBe(true);
resolveOldStats(
sendStatsReport({
bytesSent: 1_000_000,
framesEncoded: 30,
timestamp: 1_000,
totalEncodeTime: 0.15,
qualityLimitationReason: "cpu",
}),
);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(
updates.some(
({ metrics }) => metrics.qualityLimitationReason === "cpu",
),
).toBe(false);
connection.statsReports.push(
sendStatsReport({
bytesSent: 2_000_000,
framesEncoded: 60,
timestamp: 2_000,
totalEncodeTime: 0.3,
qualityLimitationReason: "bandwidth",
}),
sendStatsReport({
bytesSent: 2_500_000,
framesEncoded: 90,
timestamp: 3_000,
totalEncodeTime: 0.9,
qualityLimitationReason: "bandwidth",
}),
);
statsCallbacks[0]!();
await vi.waitFor(() =>
expect(updates.at(-1)?.metrics.qualityLimitationReason).toBe(
"bandwidth",
),
);
expect(updates.at(-1)?.metrics.intervalEncodeMs).toBeNull();
statsCallbacks[0]!();
await vi.waitFor(() =>
expect(updates.at(-1)?.metrics.intervalEncodeMs).toBeCloseTo(20),
);
expect(
updates.some(
({ metrics }) => metrics.qualityLimitationReason === "cpu",
),
).toBe(false);
});
it("rolls the first sender back when the second replacement fails", async () => {
const oldVideo = createTrack("video", "old-video");
const oldAudio = createTrack("audio", "old-audio");
@@ -247,10 +497,7 @@ describe("HostPeer source replacement", () => {
const nextVideo = createTrack("video", "next-video");
const nextAudio = createTrack("audio", "next-audio");
await expect(
peer.replaceStream(
createStream(nextVideo, nextAudio),
QUALITY_PROFILES["1080p60"],
),
peer.replaceStream(createStream(nextVideo, nextAudio)),
).resolves.toBe(false);
expect(videoSender.replaceTrack).toHaveBeenNthCalledWith(1, nextVideo);
@@ -261,3 +508,303 @@ describe("HostPeer source replacement", () => {
expect(audioSender.track).toBe(oldAudio);
});
});
describe("ViewerRelay downstream ownership", () => {
it("exposes a defensive snapshot of current downstream send metrics", async () => {
const relay = new ViewerRelay(
{ iceServers: [], expiresAt: null, relayAvailable: false },
QUALITY_PROFILES["720p30"],
{ sendSignal: () => true },
);
relay.setChild("metrics-child");
relay.setStream(
createStream(createTrack("video", "metrics-video"), null),
);
await vi.waitFor(() =>
expect(relay.getSnapshot()?.peerId).toBe("metrics-child"),
);
const connection = FakePeerConnection.latest!;
connection.statsReports.push(
sendStatsReport({
bytesSent: 1_000_000,
framesEncoded: 30,
timestamp: 1_000,
totalEncodeTime: 0.15,
qualityLimitationReason: "cpu",
}),
sendStatsReport({
bytesSent: 1_500_000,
framesEncoded: 60,
timestamp: 2_000,
totalEncodeTime: 0.75,
qualityLimitationReason: "bandwidth",
}),
);
statsCallbacks[0]!();
await vi.waitFor(() =>
expect(relay.getSnapshot()?.metrics.qualityLimitationReason).toBe("cpu"),
);
expect(relay.getSnapshot()?.metrics.intervalEncodeMs).toBeNull();
statsCallbacks[0]!();
await vi.waitFor(() =>
expect(relay.getSnapshot()?.metrics.intervalEncodeMs).toBe(20),
);
const snapshot = relay.getSnapshot()!;
expect(snapshot.metrics).toMatchObject({
intervalEncodeMs: 20,
bitrateKbps: 4_000,
encoderImplementation: "test-encoder",
qualityLimitationReason: "bandwidth",
});
snapshot.metrics.qualityLimitationReason = "mutated";
expect(relay.getSnapshot()?.metrics.qualityLimitationReason).toBe(
"bandwidth",
);
});
it("clears snapshots across child replacement, stop, and dispose", async () => {
const relay = new ViewerRelay(
{ iceServers: [], expiresAt: null, relayAvailable: false },
QUALITY_PROFILES["720p30"],
{ sendSignal: () => true },
);
const stream = createStream(createTrack("video", "relay-video"), null);
relay.setChild("first-child");
relay.setStream(stream);
await vi.waitFor(() =>
expect(relay.getSnapshot()?.peerId).toBe("first-child"),
);
relay.setChild("second-child");
expect(relay.getSnapshot()).toBeNull();
await vi.waitFor(() =>
expect(relay.getSnapshot()?.peerId).toBe("second-child"),
);
relay.stop();
expect(relay.getSnapshot()).toBeNull();
relay.setStream(stream);
await vi.waitFor(() =>
expect(relay.getSnapshot()?.peerId).toBe("second-child"),
);
relay.dispose();
expect(relay.getSnapshot()).toBeNull();
relay.setStream(stream);
expect(relay.getSnapshot()).toBeNull();
});
it("does not publish a stale stats result after replacing its child", async () => {
let resolveOldStats!: (report: RTCStatsReport) => void;
const pendingOldStats = new Promise<RTCStatsReport>((resolve) => {
resolveOldStats = resolve;
});
const relay = new ViewerRelay(
{ iceServers: [], expiresAt: null, relayAvailable: false },
QUALITY_PROFILES["720p30"],
{ sendSignal: () => true },
);
relay.setChild("old-child");
relay.setStream(
createStream(createTrack("video", "relay-video"), null),
);
await vi.waitFor(() =>
expect(relay.getSnapshot()?.peerId).toBe("old-child"),
);
FakePeerConnection.latest!.statsReports.push(pendingOldStats);
statsCallbacks[0]!();
relay.setChild("current-child");
await vi.waitFor(() =>
expect(relay.getSnapshot()?.peerId).toBe("current-child"),
);
resolveOldStats(
sendStatsReport({
bytesSent: 999_999,
framesEncoded: 30,
timestamp: 1_000,
qualityLimitationReason: "old-peer",
}),
);
await Promise.resolve();
await Promise.resolve();
expect(relay.getSnapshot()).toMatchObject({
peerId: "current-child",
metrics: { qualityLimitationReason: null },
});
});
it("creates a downstream peer without secure-context randomUUID", async () => {
vi.stubGlobal("crypto", {
getRandomValues: (bytes: Uint8Array) => {
bytes.fill(7);
return bytes;
},
});
const targets: string[] = [];
const relay = new ViewerRelay(
{ iceServers: [], expiresAt: null, relayAvailable: false },
QUALITY_PROFILES["720p30"],
{
sendSignal: (peerId) => {
targets.push(peerId);
return true;
},
},
);
relay.setChild("lan-child-peer");
relay.setStream(
createStream(createTrack("video", "lan-video"), null),
);
await vi.waitFor(() => expect(targets).toEqual(["lan-child-peer"]));
expect(FakePeerConnection.latest?.connectionState).toBe("new");
});
it("retries a missing peer when the same assignment is reconciled", async () => {
vi.useFakeTimers();
try {
FakePeerConnection.offersFailing = 2;
const targets: string[] = [];
const relay = new ViewerRelay(
{ iceServers: [], expiresAt: null, relayAvailable: false },
QUALITY_PROFILES["720p30"],
{
sendSignal: (peerId) => {
targets.push(peerId);
return true;
},
},
);
const stream = createStream(createTrack("video", "relay-video"), null);
relay.setChild("same-child-peer");
relay.setStream(stream);
await vi.runAllTimersAsync();
const failedPeer = FakePeerConnection.latest;
expect(targets).toEqual([]);
relay.setChild("same-child-peer");
await vi.runAllTimersAsync();
expect(FakePeerConnection.latest).not.toBe(failedPeer);
expect(targets).toEqual(["same-child-peer"]);
} finally {
vi.useRealTimers();
}
});
it("rebuilds a stalled peer but preserves a connected peer on reconciliation", async () => {
const targets: string[] = [];
const relay = new ViewerRelay(
{ iceServers: [], expiresAt: null, relayAvailable: false },
QUALITY_PROFILES["720p30"],
{
sendSignal: (peerId) => {
targets.push(peerId);
return true;
},
},
);
relay.setChild("reconciled-child");
relay.setStream(
createStream(createTrack("video", "reconciled-video"), null),
);
await vi.waitFor(() => expect(targets).toEqual(["reconciled-child"]));
const stalledConnection = FakePeerConnection.latest!;
relay.setChild("reconciled-child");
await vi.waitFor(() =>
expect(FakePeerConnection.latest).not.toBe(stalledConnection),
);
const connectedConnection = FakePeerConnection.latest!;
await vi.waitFor(() => expect(targets).toHaveLength(2));
connectedConnection.connectionState = "connected";
relay.setChild("reconciled-child");
expect(FakePeerConnection.latest).toBe(connectedConnection);
expect(targets).toHaveLength(2);
});
it("keeps one downstream connection across upstream stream replacement", async () => {
const targets: string[] = [];
const relay = new ViewerRelay(
{ iceServers: [], expiresAt: null, relayAvailable: false },
QUALITY_PROFILES["1080p60"],
{
sendSignal: (peerId) => {
targets.push(peerId);
return true;
},
},
);
const firstStream = createStream(
createTrack("video", "first-video"),
createTrack("audio", "first-audio"),
);
relay.setChild("child-peer-one");
relay.setStream(firstStream);
await vi.waitFor(() => expect(FakePeerConnection.latest).not.toBeNull());
const connection = FakePeerConnection.latest!;
await vi.waitFor(() => expect(targets).toEqual(["child-peer-one"]));
const nextVideo = createTrack("video", "next-video");
const nextAudio = createTrack("audio", "next-audio");
relay.setStream(createStream(nextVideo, nextAudio));
await vi.waitFor(() => expect(connection.senders[0]?.track).toBe(nextVideo));
expect(FakePeerConnection.latest).toBe(connection);
expect(connection.senders[1]?.track).toBe(nextAudio);
expect(connection.transceiverInputs).toHaveLength(2);
relay.setChild("child-peer-two");
await vi.waitFor(() => expect(FakePeerConnection.latest).not.toBe(connection));
expect(connection.connectionState).toBe("closed");
await vi.waitFor(() => expect(targets.at(-1)).toBe("child-peer-two"));
});
it("applies the latest profile to the current and future child", async () => {
const relay = new ViewerRelay(
{ iceServers: [], expiresAt: null, relayAvailable: false },
QUALITY_PROFILES["1080p60"],
{ sendSignal: () => true },
);
relay.setChild("first-profile-child");
relay.setStream(createStream(createTrack("video", "profile-video"), null));
await vi.waitFor(() =>
expect(
FakePeerConnection.latest?.senders[0]?.setParameters,
).toHaveBeenCalled(),
);
const firstConnection = FakePeerConnection.latest!;
await expect(
relay.updateProfile(QUALITY_PROFILES["720p30"]),
).resolves.toBe(true);
expect(
firstConnection.senders[0]?.setParameters.mock.calls.at(-1)?.[0],
).toMatchObject({
encodings: [{ maxBitrate: 3_000_000, maxFramerate: 30 }],
});
relay.setChild("second-profile-child");
await vi.waitFor(() =>
expect(FakePeerConnection.latest).not.toBe(firstConnection),
);
const secondConnection = FakePeerConnection.latest!;
await vi.waitFor(() =>
expect(secondConnection.senders[0]?.setParameters).toHaveBeenCalled(),
);
expect(
secondConnection.senders[0]?.setParameters.mock.calls[0]?.[0],
).toMatchObject({
encodings: [{ maxBitrate: 3_000_000, maxFramerate: 30 }],
});
});
});
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import type { SignalPayload } from "../src/shared/protocol.ts";
import {
limitMediaAssignment,
MAX_HOST_MEDIA_CHILDREN,
MAX_VIEWER_MEDIA_CHILDREN,
viewerRestartMessage,
viewerSignalMessage,
} from "../src/client/webrtc/media-assignment.ts";
const offer: SignalPayload = {
kind: "description",
connectionId: "connection_12345678",
description: { type: "offer", sdp: "test-offer" },
};
describe("peer-assisted client assignment", () => {
it("keeps host and viewer child counts within their client limits", () => {
const assignment = {
parentPeerId: "parent_12345678",
childPeerIds: [
"child_12345678",
"child_abcdefgh",
"child_12345678",
],
};
expect(
limitMediaAssignment(assignment, MAX_HOST_MEDIA_CHILDREN),
).toEqual({
parentPeerId: "parent_12345678",
childPeerIds: ["child_12345678", "child_abcdefgh"],
});
expect(
limitMediaAssignment(assignment, MAX_VIEWER_MEDIA_CHILDREN),
).toEqual({
parentPeerId: "parent_12345678",
childPeerIds: ["child_12345678"],
});
});
it("leaves legacy viewer signaling untargeted", () => {
expect(viewerSignalMessage(false, "host_12345678", offer)).toEqual({
type: "signal",
payload: offer,
});
expect(
viewerRestartMessage(
false,
"host_12345678",
"connection_12345678",
true,
),
).toEqual({
type: "restart-request",
connectionId: "connection_12345678",
rebuild: true,
});
});
it("targets peer-assisted signaling at the assigned parent", () => {
expect(viewerSignalMessage(true, "parent_12345678", offer)).toEqual({
type: "signal",
targetPeerId: "parent_12345678",
payload: offer,
});
expect(
viewerRestartMessage(
true,
"parent_12345678",
"connection_12345678",
false,
),
).toEqual({
type: "restart-request",
targetPeerId: "parent_12345678",
connectionId: "connection_12345678",
rebuild: false,
});
});
});
+185
View File
@@ -0,0 +1,185 @@
import { describe, expect, it } from "vitest";
import {
activeVideoEdgeCount,
buildBenchmarkInitScript,
parseBenchmarkConfig,
parseViewerCounts,
summarizeSamples,
} from "../scripts/peer-assisted-benchmark";
function page(
role: "host" | "viewer",
label: string,
sendEdges: number,
receiveEdges: number,
) {
return {
label,
role,
viewerIndex: role === "viewer" ? Number(label.split("-")[1]) : null,
roomId: "1",
peerId: `${label}-peer`,
authenticatedAtEpochMs: 1_000,
authenticateSentAtEpochMs: 900,
signalingConnected: true,
qualityProfileId: "720p30",
maxActiveOutboundMediaEdges: sendEdges,
maxAssignedChildren: sendEdges,
assignment: {
parentPeerId: role === "viewer" ? "parent-peer" : null,
childPeerIds: Array.from({ length: sendEdges }, (_, index) => `child-${index}`),
},
firstDecodedAtEpochMs: role === "viewer" ? 1_500 : null,
firstRenderedAtEpochMs: role === "viewer" ? 1_550 : null,
connections: [
...Array.from({ length: sendEdges }, (_, index) => ({
index,
createdAtEpochMs: 1_000,
connectionId: `send-${index}`,
remotePeerId: `child-${index}`,
connectionState: "connected",
iceConnectionState: "connected",
hasOutboundVideo: true,
hasInboundVideo: false,
send: {},
receive: null,
sendTotals: { id: `send-${index}`, framesTotal: 10, bytesTotal: 100 },
receiveTotals: null,
error: undefined as string | undefined,
})),
...Array.from({ length: receiveEdges }, (_, index) => ({
index: sendEdges + index,
createdAtEpochMs: 1_000,
connectionId: `receive-${index}`,
remotePeerId: "parent-peer",
connectionState: "connected",
iceConnectionState: "connected",
hasOutboundVideo: false,
hasInboundVideo: true,
send: null,
receive: {},
sendTotals: null,
receiveTotals: { id: `receive-${index}`, framesTotal: 10, bytesTotal: 100 },
error: undefined as string | undefined,
})),
],
};
}
describe("peer-assisted benchmark configuration", () => {
it("uses the bounded 1/3/5/8 matrix by default", () => {
expect(parseViewerCounts(undefined)).toEqual([1, 3, 5, 8]);
});
it("deduplicates configured viewer counts without changing their order", () => {
expect(parseViewerCounts("8, 3,3,1")).toEqual([8, 3, 1]);
expect(() => parseViewerCounts("9")).toThrow(/1 to 8/);
});
it("requires recovery to target one selected case", () => {
expect(() =>
parseBenchmarkConfig({
CHROME_PATH: "chrome",
BENCHMARK_VIEWERS: "1,3",
BENCHMARK_RECOVERY_VIEWERS: "5",
}),
).toThrow(/selected viewer count/);
});
it("writes a report file by default and reserves '-' for stdout", () => {
expect(parseBenchmarkConfig({ CHROME_PATH: "chrome" }).outputPath).toBe(
"benchmark-results/peer-assisted.json",
);
expect(
parseBenchmarkConfig({ CHROME_PATH: "chrome", BENCHMARK_OUTPUT: "-" })
.outputPath,
).toBeNull();
});
});
describe("peer-assisted benchmark observations", () => {
it("counts only active media connections in the requested direction", () => {
const host = page("host", "host", 2, 0);
host.connections[0]!.connectionState = "closed";
expect(activeVideoEdgeCount(host, "send")).toBe(1);
expect(activeVideoEdgeCount(host, "receive")).toBe(0);
});
it("summarizes raw fanout and first-frame fields without a score", () => {
const initialPages = [
page("host", "host", 2, 0),
page("viewer", "viewer-1", 1, 1),
page("viewer", "viewer-2", 0, 1),
page("viewer", "viewer-3", 0, 1),
];
const finalPages = structuredClone(initialPages);
for (const viewer of finalPages.filter((entry) => entry.role === "viewer")) {
viewer.connections.at(-1)!.receiveTotals!.framesTotal = 20;
}
const summary = summarizeSamples(
[
{ atEpochMs: 2_000, elapsedMs: 0, pages: initialPages },
{ atEpochMs: 4_000, elapsedMs: 2_000, pages: finalPages },
],
3,
);
expect(summary.maxHostActiveMediaEdges).toBe(2);
expect(summary.maxRelayActiveMediaEdges).toBe(1);
expect(summary.everyViewerDecoded).toBe(true);
expect(summary.firstFrames[0]?.decodedAfterAuthenticateMs).toBe(600);
expect(summary.maxFirstDecodedAfterAuthenticateMs).toBe(600);
expect(
summary.finalTopology.every(
(entry) => entry.qualityProfileId === "720p30",
),
).toBe(true);
});
it("fails continuity when frames freeze or stats collection errors", () => {
const frozenInitial = [
page("host", "host", 1, 0),
page("viewer", "viewer-1", 0, 1),
];
const frozenFinal = structuredClone(frozenInitial);
expect(
summarizeSamples(
[
{ atEpochMs: 2_000, elapsedMs: 0, pages: frozenInitial },
{ atEpochMs: 4_000, elapsedMs: 2_000, pages: frozenFinal },
],
1,
).everyViewerDecoded,
).toBe(false);
const errorFinal = structuredClone(frozenFinal);
errorFinal[1]!.connections[0]!.receiveTotals!.framesTotal = 20;
errorFinal[1]!.connections[0]!.error = "getStats failed";
expect(
summarizeSamples(
[
{ atEpochMs: 2_000, elapsedMs: 0, pages: frozenInitial },
{ atEpochMs: 4_000, elapsedMs: 2_000, pages: errorFinal },
],
1,
).everyViewerDecoded,
).toBe(false);
});
it("injects the requested deterministic capture dimensions", () => {
const source = buildBenchmarkInitScript({
label: "host-1",
role: "host",
viewerIndex: null,
clearHostRoom: true,
width: 1280,
height: 720,
frameRate: 30,
});
expect(source).toContain('"width":1280');
expect(source).toContain('"frameRate":30');
expect(source).toContain("getDisplayMedia");
expect(source).toContain("collectConnectionMetrics");
expect(source).toContain("set-quality-profile");
});
});
+313
View File
@@ -0,0 +1,313 @@
import { describe, expect, it } from "vitest";
import { PeerRelayTopology } from "../src/server/peer-relay-topology.ts";
function connected(...peerIds: string[]): Set<string> {
return new Set(peerIds);
}
function expectValidTree(
topology: PeerRelayTopology,
roomId: string,
hostPeerId: string,
viewerPeerIds: readonly string[],
): number {
const hostAssignment = topology.getAssignment(roomId, hostPeerId)!;
expect(hostAssignment.parentPeerId).toBeNull();
expect(hostAssignment.childPeerIds.length).toBeLessThanOrEqual(2);
const allChildren = new Set(hostAssignment.childPeerIds);
let maximumDepth = 0;
for (const peerId of viewerPeerIds) {
const assignment = topology.getAssignment(roomId, peerId)!;
expect(assignment.childPeerIds.length).toBeLessThanOrEqual(1);
for (const childPeerId of assignment.childPeerIds) {
expect(allChildren.has(childPeerId)).toBe(false);
allChildren.add(childPeerId);
}
let parentPeerId = assignment.parentPeerId;
const ancestors = new Set([peerId]);
let depth = 0;
while (parentPeerId) {
expect(ancestors.has(parentPeerId)).toBe(false);
ancestors.add(parentPeerId);
depth += 1;
parentPeerId = topology.getAssignment(roomId, parentPeerId)?.parentPeerId ?? null;
}
maximumDepth = Math.max(maximumDepth, depth);
}
expect(allChildren).toEqual(new Set(viewerPeerIds));
return maximumDepth;
}
describe("PeerRelayTopology", () => {
it("fills the shallowest stable slots with bounded fanout", () => {
const topology = new PeerRelayTopology();
const roomId = "room";
const host = "host";
topology.setHost(roomId, host, connected(host));
const viewers = ["viewer-a", "viewer-b", "viewer-c", "viewer-d", "viewer-e"];
for (const viewer of viewers) {
topology.addViewer(roomId, viewer, connected(host, ...viewers));
}
expect(topology.getAssignment(roomId, host)).toEqual({
parentPeerId: null,
childPeerIds: ["viewer-a", "viewer-b"],
});
expect(topology.getAssignment(roomId, "viewer-a")).toEqual({
parentPeerId: host,
childPeerIds: ["viewer-c"],
});
expect(topology.getAssignment(roomId, "viewer-b")).toEqual({
parentPeerId: host,
childPeerIds: ["viewer-d"],
});
expect(topology.getAssignment(roomId, "viewer-c")).toEqual({
parentPeerId: "viewer-a",
childPeerIds: ["viewer-e"],
});
});
it("forms two bounded acyclic chains for eight viewers", () => {
const topology = new PeerRelayTopology();
const roomId = "room";
const hostPeerId = "host";
const viewerPeerIds = Array.from(
{ length: 8 },
(_, index) => `viewer-${index + 1}`,
);
topology.setHost(roomId, hostPeerId, connected(hostPeerId));
for (const peerId of viewerPeerIds) {
topology.addViewer(
roomId,
peerId,
connected(hostPeerId, ...viewerPeerIds),
);
}
expect(expectValidTree(topology, roomId, hostPeerId, viewerPeerIds)).toBe(4);
});
it("leaves a disconnected viewer subtree sticky during its grace window", () => {
const topology = new PeerRelayTopology();
const roomId = "room";
topology.setHost(roomId, "host", connected("host"));
topology.addViewer(roomId, "viewer-a", connected("host", "viewer-a"));
topology.addViewer(
roomId,
"viewer-b",
connected("host", "viewer-a", "viewer-b"),
);
topology.addViewer(
roomId,
"viewer-c",
connected("host", "viewer-a", "viewer-b", "viewer-c"),
);
topology.addViewer(
roomId,
"viewer-d",
connected("host", "viewer-b", "viewer-c", "viewer-d"),
);
expect(topology.getAssignment(roomId, "viewer-c")?.parentPeerId).toBe(
"viewer-a",
);
expect(topology.getAssignment(roomId, "viewer-d")?.parentPeerId).toBe(
"viewer-b",
);
});
it("reattaches only the removed viewer's direct child subtree entry", () => {
const topology = new PeerRelayTopology();
const roomId = "room";
topology.setHost(roomId, "host", connected("host"));
topology.addViewer(roomId, "viewer-a", connected("host", "viewer-a"));
topology.addViewer(
roomId,
"viewer-b",
connected("host", "viewer-a", "viewer-b"),
);
topology.addViewer(
roomId,
"viewer-c",
connected("host", "viewer-a", "viewer-b", "viewer-c"),
);
topology.addViewer(
roomId,
"viewer-d",
connected("host", "viewer-a", "viewer-b", "viewer-c", "viewer-d"),
);
topology.addViewer(
roomId,
"viewer-e",
connected(
"host",
"viewer-a",
"viewer-b",
"viewer-c",
"viewer-d",
"viewer-e",
),
);
const changes = topology.removeViewer(
roomId,
"viewer-a",
connected("host", "viewer-b", "viewer-c", "viewer-d", "viewer-e"),
);
expect(topology.getAssignment(roomId, "host")?.childPeerIds).toEqual([
"viewer-b",
"viewer-c",
]);
expect(topology.getAssignment(roomId, "viewer-c")?.parentPeerId).toBe(
"host",
);
expect(topology.getAssignment(roomId, "viewer-e")?.parentPeerId).toBe(
"viewer-c",
);
expect(changes.map((change) => change.peerId).sort()).toEqual([
"host",
"viewer-c",
]);
});
it("waits to reattach an offline subtree root until it reconnects", () => {
const topology = new PeerRelayTopology();
const roomId = "room";
topology.setHost(roomId, "host", connected("host"));
topology.addViewer(roomId, "viewer-a", connected("host", "viewer-a"));
topology.addViewer(
roomId,
"viewer-b",
connected("host", "viewer-a", "viewer-b"),
);
topology.addViewer(
roomId,
"viewer-c",
connected("host", "viewer-a", "viewer-b", "viewer-c"),
);
topology.removeViewer(
roomId,
"viewer-a",
connected("host", "viewer-b"),
);
expect(topology.getAssignment(roomId, "viewer-c")?.parentPeerId).toBeNull();
expect(topology.getAssignment(roomId, "host")?.childPeerIds).toEqual([
"viewer-b",
]);
const reconnectChanges = topology.addViewer(
roomId,
"viewer-c",
connected("host", "viewer-b", "viewer-c"),
);
expect(topology.getAssignment(roomId, "viewer-c")?.parentPeerId).toBe(
"host",
);
expect(reconnectChanges.map((change) => change.peerId).sort()).toEqual([
"host",
"viewer-c",
]);
});
it("assigns connected pending viewers when removals make slots reachable", () => {
const topology = new PeerRelayTopology();
const roomId = "room";
topology.setHost(roomId, "host", connected("host"));
for (const peerId of ["a", "b", "c", "d"]) {
topology.addViewer(
roomId,
peerId,
connected("host", "a", "b", "c", "d"),
);
}
topology.addViewer(roomId, "e", connected("host", "c", "d", "e"));
expect(topology.getAssignment(roomId, "e")?.parentPeerId).toBeNull();
topology.removeViewer(roomId, "a", connected("host", "c", "d", "e"));
topology.removeViewer(roomId, "b", connected("host", "c", "d", "e"));
expect(topology.getAssignment(roomId, "e")?.parentPeerId).toBe("c");
expect(topology.getAssignment(roomId, "c")?.childPeerIds).toEqual(["e"]);
});
it("keeps pending viewers unassigned until a host arrives", () => {
const topology = new PeerRelayTopology();
topology.addViewer("room", "viewer-a", connected("viewer-a"));
expect(topology.getAssignment("room", "viewer-a")?.parentPeerId).toBeNull();
topology.setHost(
"room",
"host",
connected("host", "viewer-a"),
);
expect(topology.getAssignment("room", "viewer-a")?.parentPeerId).toBe(
"host",
);
});
it("updates only direct root parents when the host identity changes", () => {
const topology = new PeerRelayTopology();
topology.setHost("room", "host-a", connected("host-a"));
topology.addViewer(
"room",
"viewer-a",
connected("host-a", "viewer-a"),
);
topology.addViewer(
"room",
"viewer-b",
connected("host-a", "viewer-a", "viewer-b"),
);
topology.addViewer(
"room",
"viewer-c",
connected("host-a", "viewer-a", "viewer-b", "viewer-c"),
);
topology.setHost(
"room",
"host-b",
connected("host-b", "viewer-a", "viewer-b", "viewer-c"),
);
expect(topology.getAssignment("room", "viewer-a")?.parentPeerId).toBe(
"host-b",
);
expect(topology.getAssignment("room", "viewer-b")?.parentPeerId).toBe(
"host-b",
);
expect(topology.getAssignment("room", "viewer-c")?.parentPeerId).toBe(
"viewer-a",
);
});
it("drops all assignments when a room is deleted", () => {
const topology = new PeerRelayTopology();
topology.setHost("room", "host-a", connected("host-a"));
topology.addViewer(
"room",
"viewer-a",
connected("host-a", "viewer-a"),
);
topology.deleteRoom("room");
expect(topology.getAssignment("room", "host-a")).toBeUndefined();
expect(topology.getAssignment("room", "viewer-a")).toBeUndefined();
topology.setHost("room", "host-b", connected("host-b"));
expect(topology.getAssignment("room", "host-b")).toEqual({
parentPeerId: null,
childPeerIds: [],
});
});
});
+99
View File
@@ -107,6 +107,12 @@ describe("client signaling protocol", () => {
};
expect(clientMessageSchema.safeParse(restartRequest).success).toBe(true);
expect(
clientMessageSchema.safeParse({
...restartRequest,
targetPeerId: "parent_12345678",
}).success,
).toBe(true);
expect(
clientMessageSchema.safeParse({
type: restartRequest.type,
@@ -126,6 +132,28 @@ describe("client signaling protocol", () => {
true,
);
});
it("accepts only a bounded quality profile update", () => {
expect(
clientMessageSchema.safeParse({
type: "set-quality-profile",
qualityProfileId: "1080p30",
}).success,
).toBe(true);
expect(
clientMessageSchema.safeParse({
type: "set-quality-profile",
qualityProfileId: "1440p60",
}).success,
).toBe(false);
expect(
clientMessageSchema.safeParse({
type: "set-quality-profile",
qualityProfileId: "1080p30",
bitrate: 5_000_000,
}).success,
).toBe(false);
});
});
describe("server signaling protocol", () => {
@@ -173,6 +201,77 @@ describe("server signaling protocol", () => {
).toBe(true);
});
it("requires a complete bounded peer-assisted assignment", () => {
const peerAssisted = {
...authenticatedMessage(8),
mediaMode: "peer-assisted",
mediaAssignment: {
parentPeerId: null,
childPeerIds: ["viewer_12345678", "viewer_87654321"],
},
qualityProfileId: "1080p60",
};
expect(serverMessageSchema.safeParse(peerAssisted).success).toBe(true);
expect(
serverMessageSchema.safeParse({
type: "media-assignment",
mediaAssignment: peerAssisted.mediaAssignment,
}).success,
).toBe(true);
expect(
serverMessageSchema.safeParse({
...authenticatedMessage(8),
mediaMode: "peer-assisted",
qualityProfileId: "1080p60",
}).success,
).toBe(false);
expect(
serverMessageSchema.safeParse({
...authenticatedMessage(8),
mediaMode: "peer-assisted",
mediaAssignment: peerAssisted.mediaAssignment,
}).success,
).toBe(false);
expect(
serverMessageSchema.safeParse({
...peerAssisted,
mediaAssignment: {
parentPeerId: null,
childPeerIds: [
"viewer_12345678",
"viewer_87654321",
"viewer_overflow",
],
},
}).success,
).toBe(false);
expect(
serverMessageSchema.safeParse({
...peerAssisted,
qualityProfileId: "1440p60",
}).success,
).toBe(false);
expect(
serverMessageSchema.safeParse({
...authenticatedMessage(8),
qualityProfileId: "1080p60",
}).success,
).toBe(false);
expect(
serverMessageSchema.safeParse({
type: "quality-profile",
qualityProfileId: "720p30",
}).success,
).toBe(true);
expect(
serverMessageSchema.safeParse({
type: "quality-profile",
qualityProfileId: "1440p60",
}).success,
).toBe(false);
});
it.each([0, 1.5, MAX_VIEWERS_PER_ROOM_LIMIT + 1])(
"rejects viewer limit %s outside the protocol boundary",
(maxViewers) => {
+21
View File
@@ -14,6 +14,27 @@ describe("server configuration", () => {
expect(config.allowedOrigins).toEqual(new Set(["http://localhost:9123"]));
expect(config.turnUrls).toEqual([]);
expect(config.maxViewersPerRoom).toBe(8);
expect(config.peerAssistedMedia).toBe(false);
});
it("requires an explicit boolean to enable peer-assisted media", () => {
expect(loadConfig({ PEER_ASSISTED_MEDIA: "true" }).peerAssistedMedia).toBe(
true,
);
expect(loadConfig({ PEER_ASSISTED_MEDIA: "false" }).peerAssistedMedia).toBe(
false,
);
expect(() => loadConfig({ PEER_ASSISTED_MEDIA: "1" })).toThrow(
"PEER_ASSISTED_MEDIA must be true or false",
);
expect(() =>
loadConfig({
PEER_ASSISTED_MEDIA: "true",
MAX_VIEWERS_PER_ROOM: "9",
}),
).toThrow(
"PEER_ASSISTED_MEDIA currently supports at most 8 viewers per room",
);
});
it("allows an explicit loopback listen host", () => {
+1
View File
@@ -42,6 +42,7 @@ function testConfig(overrides: Partial<ServerConfig> = {}): ServerConfig {
roomTtlMs: 14_400_000,
maxRooms: 10,
maxViewersPerRoom: 8,
peerAssistedMedia: false,
stunUrls: [],
turnUrls: [],
turnCredentialTtlSeconds: 3_600,
+579
View File
@@ -109,6 +109,7 @@ function testConfig(): ServerConfig {
roomTtlMs: 14_400_000,
maxRooms: 10,
maxViewersPerRoom: 8,
peerAssistedMedia: false,
stunUrls: [],
turnUrls: [],
turnCredentialTtlSeconds: 3_600,
@@ -124,10 +125,12 @@ async function startHarness(
maxUnauthenticatedSignalConnections?: number;
accessPassword?: string;
persistent?: boolean;
peerAssistedMedia?: boolean;
} = {},
): Promise<SignalHarness> {
const config = testConfig();
config.accessPassword = overrides.accessPassword;
config.peerAssistedMedia = overrides.peerAssistedMedia ?? false;
const maxViewersPerRoom = overrides.maxViewersPerRoom ?? 8;
config.maxViewersPerRoom = maxViewersPerRoom;
const roomStore = new RoomStore({
@@ -248,6 +251,15 @@ async function authenticate(
return client.inbox.next("authenticated");
}
function peerAssisted(
message: Extract<ServerMessage, { type: "authenticated" }>,
) {
if (!("mediaMode" in message)) {
throw new Error("Expected a peer-assisted authenticated message");
}
return message;
}
async function closeClient(client: TestClient): Promise<void> {
if (client.socket.readyState === WebSocket.CLOSED) {
return;
@@ -290,6 +302,7 @@ describe("WebSocket signaling", () => {
"viewer-client-early",
);
expect(viewerAuth.hostOnline).toBe(false);
expect("mediaMode" in viewerAuth).toBe(false);
const host = await openClient(harness.webSocketUrl);
await authenticate(host, harness.room, "host", "host-client-stable");
@@ -315,6 +328,94 @@ describe("WebSocket signaling", () => {
expect(authenticated.hostOnline).toBe(false);
});
it("synchronizes a bounded quality profile across a peer-assisted room", async () => {
const harness = await startHarness({ peerAssistedMedia: true });
const host = await openClient(harness.webSocketUrl);
const hostAuth = peerAssisted(
await authenticate(host, harness.room, "host", "quality-host"),
);
expect(hostAuth.qualityProfileId).toBe("1080p60");
const viewer = await openClient(harness.webSocketUrl);
const viewerAuth = peerAssisted(
await authenticate(viewer, harness.room, "viewer", "quality-viewer"),
);
expect(viewerAuth.qualityProfileId).toBe("1080p60");
await host.inbox.next("media-assignment");
host.socket.send(
JSON.stringify({
type: "set-quality-profile",
qualityProfileId: "1080p30",
}),
);
expect(await viewer.inbox.next("quality-profile")).toEqual({
type: "quality-profile",
qualityProfileId: "1080p30",
});
const lateViewer = await openClient(harness.webSocketUrl);
const lateViewerAuth = peerAssisted(
await authenticate(
lateViewer,
harness.room,
"viewer",
"quality-viewer-late",
),
);
expect(lateViewerAuth.qualityProfileId).toBe("1080p30");
await host.inbox.next("media-assignment");
viewer.socket.send(
JSON.stringify({
type: "set-quality-profile",
qualityProfileId: "720p30",
}),
);
expect((await viewer.inbox.next("error")).code).toBe("FORBIDDEN");
await lateViewer.inbox.expectNone(30);
});
it("keeps the P2P authenticated wire unchanged and forbids profile updates", async () => {
const harness = await startHarness();
const host = await openClient(harness.webSocketUrl);
const hostAuth = await authenticate(
host,
harness.room,
"host",
"p2p-quality-host",
);
expect("mediaMode" in hostAuth).toBe(false);
expect("qualityProfileId" in hostAuth).toBe(false);
const viewer = await openClient(harness.webSocketUrl);
const viewerAuth = await authenticate(
viewer,
harness.room,
"viewer",
"p2p-quality-viewer",
);
expect("mediaMode" in viewerAuth).toBe(false);
expect("qualityProfileId" in viewerAuth).toBe(false);
await host.inbox.next("peer-joined");
host.socket.send(
JSON.stringify({
type: "set-quality-profile",
qualityProfileId: "1080p30",
}),
);
expect((await host.inbox.next("error")).code).toBe("FORBIDDEN");
viewer.socket.send(
JSON.stringify({
type: "set-quality-profile",
qualityProfileId: "720p30",
}),
);
expect((await viewer.inbox.next("error")).code).toBe("FORBIDDEN");
await viewer.inbox.expectNone(30);
});
it("routes offer and answer only between the host and the targeted viewer", async () => {
const harness = await startHarness();
const secondRoom = harness.roomStore.createRoom();
@@ -389,6 +490,484 @@ describe("WebSocket signaling", () => {
await foreignViewer.inbox.expectNone(30);
});
it("assigns a bounded peer relay tree and authorizes only direct media edges", async () => {
const harness = await startHarness({ peerAssistedMedia: true });
const secondRoom = harness.roomStore.createRoom();
const host = await openClient(harness.webSocketUrl);
const hostAuth = peerAssisted(
await authenticate(host, harness.room, "host", "relay-host"),
);
expect(hostAuth.mediaAssignment).toEqual({
parentPeerId: null,
childPeerIds: [],
});
const firstViewer = await openClient(harness.webSocketUrl);
const firstAuth = peerAssisted(
await authenticate(
firstViewer,
harness.room,
"viewer",
"relay-viewer-a",
),
);
expect(firstAuth.mediaAssignment.parentPeerId).toBe(hostAuth.peerId);
expect((await host.inbox.next("media-assignment")).mediaAssignment).toEqual({
parentPeerId: null,
childPeerIds: [firstAuth.peerId],
});
const secondViewer = await openClient(harness.webSocketUrl);
const secondAuth = peerAssisted(
await authenticate(
secondViewer,
harness.room,
"viewer",
"relay-viewer-b",
),
);
expect(secondAuth.mediaAssignment.parentPeerId).toBe(hostAuth.peerId);
expect((await host.inbox.next("media-assignment")).mediaAssignment).toEqual({
parentPeerId: null,
childPeerIds: [firstAuth.peerId, secondAuth.peerId],
});
const thirdViewer = await openClient(harness.webSocketUrl);
const thirdAuth = peerAssisted(
await authenticate(
thirdViewer,
harness.room,
"viewer",
"relay-viewer-c",
),
);
expect(thirdAuth.mediaAssignment.parentPeerId).toBe(firstAuth.peerId);
expect(
(await firstViewer.inbox.next("media-assignment")).mediaAssignment,
).toEqual({
parentPeerId: hostAuth.peerId,
childPeerIds: [thirdAuth.peerId],
});
firstViewer.socket.send(
JSON.stringify({
type: "signal",
targetPeerId: thirdAuth.peerId,
payload: {
kind: "description",
connectionId: "relay-connection",
description: { type: "offer", sdp: "v=0\r\n" },
},
}),
);
expect(await thirdViewer.inbox.next("signal")).toMatchObject({
fromPeerId: firstAuth.peerId,
payload: { description: { type: "offer" } },
});
thirdViewer.socket.send(
JSON.stringify({
type: "signal",
targetPeerId: firstAuth.peerId,
payload: {
kind: "description",
connectionId: "relay-connection",
description: { type: "answer", sdp: "v=0\r\n" },
},
}),
);
expect(await firstViewer.inbox.next("signal")).toMatchObject({
fromPeerId: thirdAuth.peerId,
payload: { description: { type: "answer" } },
});
thirdViewer.socket.send(
JSON.stringify({
type: "signal",
targetPeerId: firstAuth.peerId,
payload: {
kind: "candidate",
connectionId: "relay-connection",
candidate: null,
},
}),
);
expect(await firstViewer.inbox.next("signal")).toMatchObject({
fromPeerId: thirdAuth.peerId,
payload: { kind: "candidate" },
});
thirdViewer.socket.send(
JSON.stringify({
type: "restart-request",
targetPeerId: firstAuth.peerId,
connectionId: "relay-connection",
rebuild: true,
}),
);
expect(await firstViewer.inbox.next("restart-request")).toMatchObject({
fromPeerId: thirdAuth.peerId,
connectionId: "relay-connection",
rebuild: true,
});
thirdViewer.socket.send(
JSON.stringify({
type: "restart-request",
targetPeerId: hostAuth.peerId,
connectionId: "relay-connection",
rebuild: false,
}),
);
expect((await thirdViewer.inbox.next("error")).code).toBe("FORBIDDEN");
host.socket.send(
JSON.stringify({
type: "signal",
targetPeerId: thirdAuth.peerId,
payload: {
kind: "candidate",
connectionId: "forbidden-non-edge",
candidate: null,
},
}),
);
expect((await host.inbox.next("error")).code).toBe("FORBIDDEN");
thirdViewer.socket.send(
JSON.stringify({
type: "signal",
targetPeerId: firstAuth.peerId,
payload: {
kind: "description",
connectionId: "forbidden-direction",
description: { type: "offer", sdp: "v=0\r\n" },
},
}),
);
expect((await thirdViewer.inbox.next("error")).code).toBe("FORBIDDEN");
const foreignViewer = await openClient(harness.webSocketUrl);
const foreignAuth = peerAssisted(
await authenticate(
foreignViewer,
secondRoom,
"viewer",
"relay-foreign-viewer",
),
);
firstViewer.socket.send(
JSON.stringify({
type: "signal",
targetPeerId: foreignAuth.peerId,
payload: {
kind: "candidate",
connectionId: "cross-room",
candidate: null,
},
}),
);
expect((await firstViewer.inbox.next("error")).code).toBe("PEER_NOT_FOUND");
await foreignViewer.inbox.expectNone(30);
});
it("keeps relay assignments through grace and reattaches only the direct child subtree", async () => {
const harness = await startHarness({
peerAssistedMedia: true,
viewerDisconnectGraceMs: 200,
});
const host = await openClient(harness.webSocketUrl);
const hostAuth = peerAssisted(
await authenticate(host, harness.room, "host", "relay-grace-host"),
);
const firstViewer = await openClient(harness.webSocketUrl);
const firstAuth = peerAssisted(
await authenticate(
firstViewer,
harness.room,
"viewer",
"relay-grace-a",
),
);
await host.inbox.next("media-assignment");
const secondViewer = await openClient(harness.webSocketUrl);
const secondAuth = peerAssisted(
await authenticate(
secondViewer,
harness.room,
"viewer",
"relay-grace-b",
),
);
await host.inbox.next("media-assignment");
const thirdViewer = await openClient(harness.webSocketUrl);
const thirdAuth = peerAssisted(
await authenticate(
thirdViewer,
harness.room,
"viewer",
"relay-grace-c",
),
);
await firstViewer.inbox.next("media-assignment");
const fourthViewer = await openClient(harness.webSocketUrl);
await authenticate(
fourthViewer,
harness.room,
"viewer",
"relay-grace-d",
);
await secondViewer.inbox.next("media-assignment");
const fifthViewer = await openClient(harness.webSocketUrl);
const fifthAuth = peerAssisted(
await authenticate(
fifthViewer,
harness.room,
"viewer",
"relay-grace-e",
),
);
const thirdWithChild = await thirdViewer.inbox.next("media-assignment");
expect(thirdWithChild.mediaAssignment).toEqual({
parentPeerId: firstAuth.peerId,
childPeerIds: [fifthAuth.peerId],
});
await closeClient(firstViewer);
await host.inbox.expectNone(30);
await thirdViewer.inbox.expectNone(30);
host.socket.send(
JSON.stringify({
type: "signal",
targetPeerId: firstAuth.peerId,
payload: {
kind: "description",
connectionId: "offer-while-child-offline",
description: { type: "offer", sdp: "v=0\r\n" },
},
}),
);
expect((await host.inbox.next("error")).code).toBe("PEER_NOT_FOUND");
const reconnectedFirstViewer = await openClient(harness.webSocketUrl);
const reconnectedFirstAuth = peerAssisted(
await authenticate(
reconnectedFirstViewer,
harness.room,
"viewer",
"relay-grace-a",
),
);
expect(reconnectedFirstAuth).toMatchObject({
peerId: firstAuth.peerId,
connectionId: null,
mediaAssignment: {
parentPeerId: hostAuth.peerId,
childPeerIds: [thirdAuth.peerId],
},
});
expect(
(await host.inbox.next("media-assignment", 500)).mediaAssignment,
).toEqual({
parentPeerId: null,
childPeerIds: [firstAuth.peerId, secondAuth.peerId],
});
await closeClient(reconnectedFirstViewer);
expect(
(await host.inbox.next("media-assignment", 500)).mediaAssignment,
).toEqual({
parentPeerId: null,
childPeerIds: [secondAuth.peerId, thirdAuth.peerId],
});
expect(
(await thirdViewer.inbox.next("media-assignment", 500)).mediaAssignment,
).toEqual({
parentPeerId: hostAuth.peerId,
childPeerIds: [fifthAuth.peerId],
});
await fifthViewer.inbox.expectNone(30);
});
it("reattaches a subtree root that reconnects after its parent expires", async () => {
const harness = await startHarness({
peerAssistedMedia: true,
viewerDisconnectGraceMs: 200,
});
const host = await openClient(harness.webSocketUrl);
const hostAuth = peerAssisted(
await authenticate(host, harness.room, "host", "relay-offline-host"),
);
const firstViewer = await openClient(harness.webSocketUrl);
const firstAuth = peerAssisted(
await authenticate(
firstViewer,
harness.room,
"viewer",
"relay-offline-a",
),
);
await host.inbox.next("media-assignment");
const secondViewer = await openClient(harness.webSocketUrl);
const secondAuth = peerAssisted(
await authenticate(
secondViewer,
harness.room,
"viewer",
"relay-offline-b",
),
);
await host.inbox.next("media-assignment");
const subtreeRoot = await openClient(harness.webSocketUrl);
const subtreeRootAuth = peerAssisted(
await authenticate(
subtreeRoot,
harness.room,
"viewer",
"relay-offline-c",
),
);
expect(subtreeRootAuth.mediaAssignment.parentPeerId).toBe(firstAuth.peerId);
await firstViewer.inbox.next("media-assignment");
await closeClient(firstViewer);
await new Promise((resolve) => setTimeout(resolve, 80));
await closeClient(subtreeRoot);
expect(
(await host.inbox.next("media-assignment", 500)).mediaAssignment,
).toEqual({
parentPeerId: null,
childPeerIds: [secondAuth.peerId],
});
const reconnectedSubtreeRoot = await openClient(harness.webSocketUrl);
const reconnectedAuth = peerAssisted(
await authenticate(
reconnectedSubtreeRoot,
harness.room,
"viewer",
"relay-offline-c",
),
);
expect(reconnectedAuth).toMatchObject({
peerId: subtreeRootAuth.peerId,
connectionId: null,
mediaAssignment: {
parentPeerId: hostAuth.peerId,
childPeerIds: [],
},
});
expect(
(await host.inbox.next("media-assignment", 500)).mediaAssignment,
).toEqual({
parentPeerId: null,
childPeerIds: [secondAuth.peerId, subtreeRootAuth.peerId],
});
});
it("retains peer relay assignments but clears connection generations when sharing stops", async () => {
const harness = await startHarness({
peerAssistedMedia: true,
viewerDisconnectGraceMs: 500,
});
const host = await openClient(harness.webSocketUrl);
const hostAuth = peerAssisted(
await authenticate(host, harness.room, "host", "relay-stop-host"),
);
const viewer = await openClient(harness.webSocketUrl);
const viewerAuth = peerAssisted(
await authenticate(
viewer,
harness.room,
"viewer",
"relay-stop-viewer",
),
);
await host.inbox.next("media-assignment");
host.socket.send(
JSON.stringify({
type: "set-quality-profile",
qualityProfileId: "720p30",
}),
);
expect(await viewer.inbox.next("quality-profile")).toEqual({
type: "quality-profile",
qualityProfileId: "720p30",
});
host.socket.send(
JSON.stringify({
type: "signal",
targetPeerId: viewerAuth.peerId,
payload: {
kind: "description",
connectionId: "connection-before-relay-stop",
description: { type: "offer", sdp: "v=0\r\n" },
},
}),
);
await viewer.inbox.next("signal");
const stoppedHostClosed = new Promise<number>((resolve) =>
host.socket.once("close", (code) => resolve(code)),
);
host.socket.send(JSON.stringify({ type: "stop-sharing" }));
await viewer.inbox.next("sharing-stopped");
expect(await viewer.inbox.next("host-status")).toMatchObject({
online: false,
});
expect(await stoppedHostClosed).toBe(1000);
await closeClient(viewer);
const reconnectedViewer = await openClient(harness.webSocketUrl);
const reconnectedViewerAuth = peerAssisted(
await authenticate(
reconnectedViewer,
harness.room,
"viewer",
"relay-stop-viewer",
),
);
expect(reconnectedViewerAuth).toMatchObject({
peerId: viewerAuth.peerId,
connectionId: null,
mediaAssignment: {
parentPeerId: hostAuth.peerId,
childPeerIds: [],
},
qualityProfileId: "720p30",
});
const resumedHost = await openClient(harness.webSocketUrl);
const resumedHostAuth = peerAssisted(
await authenticate(
resumedHost,
harness.room,
"host",
"relay-stop-host",
),
);
expect(resumedHostAuth).toMatchObject({
peerId: hostAuth.peerId,
mediaAssignment: {
parentPeerId: null,
childPeerIds: [viewerAuth.peerId],
},
qualityProfileId: "720p30",
});
expect(await reconnectedViewer.inbox.next("host-status")).toMatchObject({
online: true,
});
});
it("keeps a viewer peer stable when it reconnects inside the grace period", async () => {
const harness = await startHarness({ viewerDisconnectGraceMs: 80 });
const host = await openClient(harness.webSocketUrl);
+104 -1
View File
@@ -18,6 +18,7 @@ function createDeferred<T>(): Deferred<T> {
}
interface ConnectionPlan {
answerError?: Error;
candidateGates?: Promise<void>[];
localDescriptionGate?: Promise<void>;
}
@@ -46,6 +47,7 @@ class FakePeerConnection extends EventTarget {
private readonly candidateGates: Promise<void>[];
private readonly localDescriptionGate: Promise<void> | null;
private readonly answerError: Error | null;
readonly addIceCandidate = vi.fn(async (_candidate: RTCIceCandidateInit | null) => {
const gate = this.candidateGates.shift();
@@ -73,6 +75,7 @@ class FakePeerConnection extends EventTarget {
const plan = FakePeerConnection.plans.shift() ?? {};
this.candidateGates = [...(plan.candidateGates ?? [])];
this.localDescriptionGate = plan.localDescriptionGate ?? null;
this.answerError = plan.answerError ?? null;
FakePeerConnection.instances.push(this);
}
@@ -83,6 +86,9 @@ class FakePeerConnection extends EventTarget {
}
async createAnswer(): Promise<RTCSessionDescriptionInit> {
if (this.answerError) {
throw this.answerError;
}
return {
type: "answer",
sdp: `answer-${FakePeerConnection.instances.indexOf(this)}`,
@@ -122,11 +128,13 @@ function candidate(connectionId: string, value: string): SignalPayload {
function createPeer(
signals: SignalPayload[],
snapshots: PeerSnapshot[],
signalPeers: string[] = [],
): ViewerPeer {
return new ViewerPeer(
{ iceServers: [], expiresAt: null, relayAvailable: false },
{
sendSignal: (payload) => {
sendSignal: (peerId, payload) => {
signalPeers.push(peerId);
signals.push(payload);
return true;
},
@@ -165,6 +173,80 @@ afterEach(() => {
});
describe("ViewerPeer connection generations", () => {
it("requests one bounded rebuild when answer negotiation fails", async () => {
FakePeerConnection.plans.push(
{ answerError: new Error("first answer failed") },
{ answerError: new Error("second answer failed") },
);
const restartRequests: Array<{
peerId: string;
connectionId: string;
rebuild: boolean;
}> = [];
const peer = new ViewerPeer(
{ iceServers: [], expiresAt: null, relayAvailable: false },
{
sendSignal: () => true,
sendRestartRequest: (peerId, connectionId, rebuild) => {
restartRequests.push({ peerId, connectionId, rebuild });
return true;
},
onStream: () => undefined,
onUpdate: () => undefined,
},
);
await peer.acceptSignal("relay-parent", offer("connection-first"));
await peer.acceptSignal("relay-parent", offer("connection-second"));
expect(restartRequests).toEqual([
{
peerId: "relay-parent",
connectionId: "connection-first",
rebuild: true,
},
]);
});
it("targets answers and recovery requests at the current parent", async () => {
const signals: SignalPayload[] = [];
const signalPeers: string[] = [];
const restartRequests: Array<{
peerId: string;
connectionId: string;
rebuild: boolean;
}> = [];
const peer = new ViewerPeer(
{ iceServers: [], expiresAt: null, relayAvailable: false },
{
sendSignal: (peerId, payload) => {
signalPeers.push(peerId);
signals.push(payload);
return true;
},
sendRestartRequest: (peerId, connectionId, rebuild) => {
restartRequests.push({ peerId, connectionId, rebuild });
return true;
},
onStream: () => undefined,
onUpdate: () => undefined,
},
);
await peer.acceptSignal("parent-old", offer("connection-old"));
await peer.acceptSignal("parent-new", offer("connection-new"));
expect(signalPeers).toEqual(["parent-old", "parent-new"]);
expect(peer.requestRecovery()).toBe(true);
expect(restartRequests).toEqual([
{
peerId: "parent-new",
connectionId: "connection-new",
rebuild: false,
},
]);
});
it("does not send an old answer or candidate after replacing the connection", async () => {
const oldLocalDescription = createDeferred<void>();
FakePeerConnection.plans.push({
@@ -272,4 +354,25 @@ describe("ViewerPeer connection generations", () => {
expect(newSnapshots).toHaveLength(newSnapshotCount);
expect(newSnapshots.at(-1)?.metrics.resolution).toBeNull();
});
it("skips overlapping stats ticks on the same connection", async () => {
const peer = createPeer([], []);
await peer.acceptSignal("host", offer("connection"));
const connection = FakePeerConnection.instances[0]!;
const stats = createDeferred<RTCStatsReport>();
connection.statsGate = stats.promise;
const statsCallback = intervalCallbacks.get(1)!;
statsCallback();
statsCallback();
await vi.waitFor(() => expect(connection.getStats).toHaveBeenCalledOnce());
stats.resolve(new Map() as unknown as RTCStatsReport);
await flushAsyncWork();
connection.statsGate = null;
statsCallback();
await vi.waitFor(() =>
expect(connection.getStats).toHaveBeenCalledTimes(2),
);
});
});