-
-
Notifications
You must be signed in to change notification settings - Fork 55
Connection
The signalling layer, inside the WebRTCme.Middleware package. It does the part WebRTC
deliberately leaves out: getting offers, answers and ICE candidates from one peer to another, and
managing who is in a call.
Two implementations, one interface:
| topology | server | page | |
|---|---|---|---|
ConnectionType.Signaling |
mesh — every peer connects to every other | a SignalR server, in this repository | Connection: Signaling |
ConnectionType.MediaSoup |
star — everyone sends once to an SFU | versatica's mediasoup-demo, deployed separately | Connection: MediaSoup |
Both implement IConnection, so the choice is a runtime one and nothing above the connection layer
changes.
You deploy the server either way. There is no hosted service — no public signalling server, no public SFU. Both pages say how.
flowchart LR
subgraph M["Signaling — mesh: media between peers"]
direction TB
s1["signalling server<br/>signalling only"]
a1((A)) --- b1((B))
a1 --- c1((C))
b1 --- c1
s1 -.-> a1
s1 -.-> b1
s1 -.-> c1
end
M ~~~ S
subgraph S["MediaSoup — SFU: media through the server"]
direction TB
a2((A)) --- sfu["mediasoup<br/>forwards media"]
b2((B)) --- sfu
c2((C)) --- sfu
end
Solid lines are media, dotted is signalling.
The difference that decides it is upload cost per client.
In a mesh, each peer encodes and sends its media once per other peer. Three people is three connections; five is ten; and each client's upload grows with the room. The signalling server never touches media, so it is cheap to run and the media path is direct — lowest latency, and nothing in the middle that could see the media.
With an SFU, each peer sends once and the server forwards. Client cost is flat as the room grows; the server's is not, and you are now running something that handles media.
| Signaling (mesh) | MediaSoup (SFU) | |
|---|---|---|
| Good for | 2–4 peers | 5 and up |
| Client upload | grows per peer | constant |
| Server cost | tiny — signalling only | real — it relays every stream |
| Media path | direct, peer to peer | through the server |
| Server to run | in this repository | versatica's mediasoup-demo |
| Works on | all five platforms | Blazor, Android, iOS (Mac Catalyst compile-verified only) |
For one-to-one calls, use Signaling. It is simpler, cheaper, better on latency, and it is the
path with the most verification behind it.
public interface IConnection
{
IObservable<PeerResponse> ConnectionRequest(UserContext userContext);
Task ReplaceOutgoingTrackAsync(IMediaStreamTrack track, IMediaStreamTrack newTrack);
Task<IRTCStatsReport> GetStats(Guid id);
Task<IRTCStatsReport> GetOutgoingStatsAsync();
bool IsOutgoingMediaEnabled(MediaStreamTrackKind kind);
Task SetOutgoingMediaEnabledAsync(MediaStreamTrackKind kind, bool enabled);
Task RestartIceAsync();
Task SetMaxOutgoingSpatialLayerAsync(int spatialLayer);
Task StartScreenShareAsync(IMediaStream displayStream);
Task StopScreenShareAsync();
}Get one from the factory:
var connection = connectionFactory.SelectConnection(ConnectionType.Signaling);ConnectionRequest is the whole lifecycle in one call. It returns an observable: subscribing joins
the room, and disposing the subscription leaves it and tears everything down.
var userContext = new UserContext
{
ConnectionType = ConnectionType.Signaling,
Id = Guid.NewGuid(),
Name = "alice",
Room = "demo",
LocalStream = cameraStream,
DataChannelName = "chat" // null means no data channel
};
var subscription = connection
.ConnectionRequest(userContext)
.Subscribe(
onNext: OnPeerResponse,
onError: ex => logger.LogError(ex, "call failed"));
// later
subscription.Dispose(); // leaves the roomEverything that happens in the call arrives as a PeerResponse:
PeerResponseType |
carries |
|---|---|
PeerJoined |
Id, Name, MediaStream, DataChannel — a peer and their media |
PeerLeft |
Id |
PeerMedia |
MediaContext — that peer's mute state and whether they are speaking |
PeerError |
ErrorMessage |
ProducerDataChannel / ConsumerDataChannel
|
mediasoup's separate data channels |
Name is the peer id. Two clients joining one room with the same name collide, so give each
device a different one.
These are on IConnection rather than left to the caller, because the caller is not the only thing
that can change them — on the mediasoup path the producer's own paused flag is where the truth
lives, and a reconnect resets it without anyone asking.
Mute is not "stop the track". Stopping releases the camera and needs renegotiation to undo. Both paths leave the sender in place and stop what flows through it: peer-to-peer disables the local track, so peers receive silence and black frames; mediasoup additionally pauses the producer on the server, so nothing is forwarded at all. Pausing only locally would leave the SFU relaying silence.
Peers are told either way — peer-to-peer as an explicit signalling message arriving as PeerMedia,
and on mediasoup by the server pausing the matching consumers.
Screen sharing adds a second source rather than displacing the camera, so both arrive as two
tiles. On mediasoup the screen is a producer of its own; peer-to-peer adds a second transceiver to
every peer connection and renegotiates with each. Getting the stream is the caller's job, so the
platforms that cannot capture a screen fail at GetDisplayMedia, not here.
One peer-to-peer limit worth knowing: the far side identifies the screen by it being the second stream, because peer-to-peer carries no application data for a source label to travel in. One second source per peer.
GetStats(id) answers a question about one peer, which on the mediasoup path can only ever be
a receive-side answer — peers arrive as consumers, and the producers carrying your own media belong
to no peer in particular. GetOutgoingStatsAsync() asks the senders directly instead, and returns
empty rather than throwing when nothing is being sent yet.
RestartIceAsync() recovers a call whose network path died under it — a device changing
network, a NAT binding expiring — while signalling is still alive. Without it such a call stays dead
until somebody hangs up and redials. Peer-to-peer it restarts only the peers this side offers to:
both ends restarting at once is glare, and the remaining peers have to be restarted from theirs.
Since 26.9.21 a call also recovers by itself. A peer whose transport reaches Failed is
restarted without anyone pressing anything — the initiator tries three times and then reports the
peer as lost, rather than leaving a tile frozen on its last frame with the call still looking
connected. RestartIceAsync() remains for the cases nothing detects, and for restarting a peer
this side merely suspects.
Before 26.9.21 this only restarted ICE on Blazor. The restart was requested by setting
IceRestarton the offer options, and the other four bindings discarded them, so the offer went out carrying the old credentials. Nothing failed visibly — the call renegotiated and the request was answered — which is why it passed a browser test. If you are on an earlier package, the button is decorative on Android, iOS, Mac Catalyst and Windows.
flowchart TB
vm["CallViewModel"]
fac["IConnectionFactory"]
sc["SignalingConnection"]
mc["MediaSoupConnection"]
sig["SignalR proxy<br/>→ your signalling server"]
ms["protoo client<br/>→ your mediasoup-demo"]
api["IRTCPeerConnection<br/>the unified API"]
vm --> fac
fac --> sc
fac --> mc
sc --> sig
mc --> ms
sc --> api
mc --> api
AddConnection() registers both implementations and the factory, and AddMiddleware() calls it,
so anything using the middleware already has this.
| Connection: Signaling | Mesh, the SignalR server, STUN and TURN |
| Connection: MediaSoup | SFU, deploying mediasoup-demo, the pinned version |
| Middleware | The layer that consumes this |
| What works where | Which of the above is verified on which platform |
Start here
The stack
- WebRTCnative
- Bindings
- The unified API
- Middleware
- Connection
- · Signaling (mesh)
- · MediaSoup (SFU)
- Demo apps
Reference
When it breaks