Skip to content

Connection Signaling

Melih Ercan edited this page Sep 19, 2026 · 4 revisions

Connection: Signaling

Mesh / peer-to-peer. Every peer connects directly to every other; the server carries signalling only and never touches media.

This is the simpler of the two connection types, the cheaper one to run, and the one with the most verification behind it — it works on all five platforms. Use it for one-to-one and small calls. Above four or five peers, each client's upload becomes the limit; that is what MediaSoup is for.

var connection = connectionFactory.SelectConnection(ConnectionType.Signaling);

The IConnection surface, joining, and PeerResponse are on the Connection page. This page is about the server and the network.

What the server does

WebRTCme.Connection.Signaling.Server is an ASP.NET Core app in this repository. It is a SignalR hub at /roomhub using the MessagePack protocol, plus a Blazor page for eyeballing rooms.

It does two jobs and no more:

  1. Rooms. Who is in which room, and telling everyone when that changes.
  2. ICE servers. Answering "what STUN/TURN should I use?", so clients do not hard-code it.

It never sees media, and it never sees SDP contents — it relays opaque strings.

sequenceDiagram
    autonumber
    participant A as Alice
    participant S as signalling server<br/>/roomhub
    participant B as Bob

    A->>S: GetIceServersAsync()
    S-->>A: RTCIceServer[]
    A->>S: JoinAsync(id, "alice", "demo")

    B->>S: GetIceServersAsync()
    S-->>B: RTCIceServer[]
    B->>S: JoinAsync(id, "bob", "demo")
    S-->>A: OnPeerJoinedAsync(bobId, "bob")

    Note over A: creates RTCPeerConnection, adds local tracks
    A->>S: SdpAsync(bobId, offer)
    S-->>B: OnPeerSdpAsync(aliceId, "alice", offer)
    B->>S: SdpAsync(aliceId, answer)
    S-->>A: OnPeerSdpAsync(bobId, "bob", answer)

    par candidates, continuously, both ways
        A->>S: IceAsync(bobId, candidate)
        S-->>B: OnPeerIceAsync(aliceId, candidate)
    and
        B->>S: IceAsync(aliceId, candidate)
        S-->>A: OnPeerIceAsync(bobId, candidate)
    end

    Note over A,B: media flows directly, server not involved

    A->>S: MediaAsync(id, videoMuted, audioMuted, speaking)
    S-->>B: OnPeerMediaAsync(aliceId, ...)
    A->>S: LeaveAsync(id)
    S-->>B: OnPeerLeftAsync(aliceId)
Loading

Compare that middle column with Hello world's two event handlers. It is the same courier job, with a websocket in it.

Each new peer means one more RTCPeerConnection on every existing peer — that is what "mesh" costs.

Running the server

It is an ordinary ASP.NET Core app, so run it however you run those.

dotnet run --project WebRTCme.Connection/Signaling/WebRTCme.Connection.Signaling.Server

Not localhost, unless every client is on the same machine. A phone cannot reach your PC's localhost. Bind to an address the clients can see:

dotnet run --project WebRTCme.Connection/Signaling/WebRTCme.Connection.Signaling.Server `
  --urls "https://0.0.0.0:5053;http://0.0.0.0:5052"

Then point the clients at it — SignalingServer:BaseUrl in the app's appsettings.json. The client appends /roomhub itself:

"SignalingServer": {
  "BaseUrl": "https://192.168.1.48:5053"
}

Certificates. A development certificate is not trusted by a phone. For LAN testing, debug builds of the mobile clients tolerate a self-signed certificate; release builds validate properly, so a real deployment needs a real one. Browsers need it accepted once — visit the server's address and click through.

For anything beyond a LAN, host it as you would any ASP.NET Core app, behind TLS, with WebSockets allowed through whatever is in front of it.

STUN and TURN

This is the part that decides whether calls work for people who are not on your LAN, and it is where most "works on my machine" WebRTC stories end.

Clients never hard-code ICE servers. They ask the signalling server, which answers through an ITurnServerProxy:

Proxy What it does State
StunOnlyProxy The default. Returns a hardcoded list of public STUN servers. No TURN. implemented
XirsysProxy Fetches short-lived credentials from Xirsys implemented
TwilioProxy Would fetch credentials from Twilio's Network Traversal Service stub — throws
CoturnProxy Would talk to your own coturn stub — throws
AppRtcProxy Google's old appr.tc endpoint — historical stub — throws

Three of the five are stubs. Their GetIceServersAsync throws NotImplementedException, so choosing one gets you an exception on the first client that joins a room, not a working call. Only StunOnlyProxy and XirsysProxy do anything. This page previously listed all five as though they were choices, which was wrong.

That leaves exactly one way to get a relay today: Xirsys. Running your own coturn works fine as a server — nothing here asks it for credentials, which is the missing part.

Choosing one

Since 26.9.18, one configuration key:

{ "SignalingServer": { "TurnServer": "Xirsys" } }

StunOnly is the default. The server refuses to start if the key names one of the three stubs, with a message saying which two work, rather than failing later at a client.

On 26.9.16 and earlier it took three edits, and missing any of them failed differently — worth knowing if you are running an older checkout:

  1. Startup.cs registered StunOnlyProxy alone; the others were commented out.
  2. Hubs/RoomHub.cs hardcoded turnServerProxyFactory.Create(TurnServer.StunOnly), so doing only step 1 changed nothing.
  3. IHttpClientFactory was never registered, so XirsysProxy could not be resolved even after steps 1 and 2 — it needs one to fetch credentials.

Because of those, the TURN settings in appsettings.json were read by nothing.

STUN alone is not enough for a real deployment. STUN lets a peer discover its public address, which is sufficient when at least one side's NAT is cooperative. When both peers are behind symmetric NAT — common on mobile networks and corporate wifi — neither can reach the other and the call needs a TURN server to relay. That is the classic failure: works on your LAN, works between two home connections, fails silently for a meaningful share of real users.

So for anything beyond testing, run TURN. Today that means Xirsys, the one hosted proxy that is implemented. A coturn of your own is the other obvious answer and the proxy for it is a stub, so it would need writing first — XirsysProxy is the example to follow.

Two things to check before you depend on the defaults:

  • The STUN list in StunOnlyProxy is hardcoded in source. Public STUN servers come and go; review the list for your own deployment rather than inheriting it.
  • If you enable a hosted provider, its credentials are configuration. appsettings.ReplaceMe.json is the template. Do not commit real credentials — see the note at the foot of this page.

Configuration reference

Server, appsettings.json — only needed for the proxy you enable:

{
  "SignalingServer":         { "TurnServer": "Xirsys" },
  "TurnServerChannel":       { "Xirsys": "<your channel>" },
  "TurnServerBaseUrl":       { "Xirsys": "https://global.xirsys.net/_turn" },
  "TurnServerAuthorization": { "Xirsys": "<ident>:<secret>" }
}

SignalingServer:TurnServer is StunOnly, Xirsys, Coturn, AppRct or Twilio, and the last three refuse to start. It arrived in 26.9.18; before that the choice was in source.

Client, appsettings.json:

{
  "SignalingServer": { "BaseUrl": "https://192.168.1.48:5053" }
}

When the address is not known at build time

A server the person picks, one per tenant, one discovered at sign-in: configuration cannot express any of those, and whether you could work around it by rebuilding configuration at runtime depended on the platform rather than on anything deliberate. From 26.9.18, register a provider instead:

services.AddSignaling();
services.AddSingleton<ISignalingServerUrlProvider, MyUrlProvider>();
public interface ISignalingServerUrlProvider
{
    string BaseUrl { get; }   // null means "not known yet"
}

It is asked when the connection is first needed rather than at startup, so returning null until the person has chosen is fine — it gets asked again. Registering yours before or after AddSignaling() both work. The default reads SignalingServer:BaseUrl exactly as before, so nothing changes if you have one.

The contract

If you would rather write your own server, this is all it has to implement — ISignalingServerApi for calls in, ISignalingServerNotify for notifications out:

Task<Result<RTCIceServer[]>> GetIceServersAsync();
Task<Result<Unit>> JoinAsync(Guid id, string name, string room);
Task<Result<Unit>> LeaveAsync(Guid id);
Task<Result<Unit>> SdpAsync(Guid peerId, string sdp);
Task<Result<Unit>> IceAsync(Guid peerId, string ice);
Task<Result<Unit>> MediaAsync(Guid id, bool videoMuted, bool audioMuted, bool speaking);

Task OnPeerJoinedAsync(Guid peerId, string peerName);
Task OnPeerLeftAsync(Guid peerId);
Task OnPeerSdpAsync(Guid peerId, string peerName, string peerSdp);
Task OnPeerIceAsync(Guid peerId, string peerIce);
Task OnPeerMediaAsync(Guid peerId, bool videoMuted, bool audioMuted, bool speaking);

Eleven methods. Nothing in it is WebRTC-specific beyond carrying two opaque strings.

Failures leave the hub as HubException, not as an error result. HubException is the one exception type SignalR propagates verbatim; anything else reaches the client as "An unexpected error occurred" with the detail stripped. This was learned the hard way: returning Result.Error(...) looked correct and arrived at the client as IsOk=true, because MessagePack rebuilds Result<T> through its single public constructor and the status falls back to its default. Every failure the server reported looked like success.

Voice activity

Peer-to-peer has no server in the media path to observe anything, so speaking detection is computed locally: SignalingConnection samples the microphone's audioLevel before encoding, every 400ms, from any one peer connection — they all send the same local track — and sends a media message only when the state changes. It arrives at other peers as PeerResponseType.PeerMedia.

The flag follows the detector directly, so it flickers across the short gaps in natural speech. A UI that highlights the speaker wants its own hold-off; that is a presentation decision and is deliberately not made in the library.

Known limits

  • Peer id is the display name. Two clients joining one room with the same name collide.
  • Screen sharing carries one second source per peer. The far side identifies the screen by it being the second stream, because peer-to-peer has no application data for a source label.
  • A client killed abruptly — swiped away rather than leaving the page — sends no close frame, so the server keeps the peer until its own keepalive notices.

A note on the committed configuration

appsettings.json in the server project is tracked in git with a real-looking Xirsys credential in it, rather than the ReplaceMe template beside it. If you fork or clone this repository, treat that credential as compromised and supply your own. It should not be relied on and should not be copied.

Where next

Connection IConnection, joining, PeerResponse
Connection: MediaSoup The SFU alternative, for larger calls
Demo apps This path, working, in two applications

Clone this wiki locally