Skip to content

Connection

Melih Ercan edited this page Sep 21, 2026 · 2 revisions

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.

Which one

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
Loading

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.

The interface

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);

Joining a call

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 room

Everything 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.

Muting, screen sharing, statistics

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 IceRestart on 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.

Wiring

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
Loading

AddConnection() registers both implementations and the factory, and AddMiddleware() calls it, so anything using the middleware already has this.

Where next

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

Clone this wiki locally