-
-
Notifications
You must be signed in to change notification settings - Fork 55
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.
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:
- Rooms. Who is in which room, and telling everyone when that changes.
- 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)
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.
It is an ordinary ASP.NET Core app, so run it however you run those.
dotnet run --project WebRTCme.Connection/Signaling/WebRTCme.Connection.Signaling.ServerNot 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.
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.
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:
-
Startup.csregisteredStunOnlyProxyalone; the others were commented out. -
Hubs/RoomHub.cshardcodedturnServerProxyFactory.Create(TurnServer.StunOnly), so doing only step 1 changed nothing. -
IHttpClientFactorywas never registered, soXirsysProxycould 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
StunOnlyProxyis 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.jsonis the template. Do not commit real credentials — see the note at the foot of this page.
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" }
}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.
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.
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.
- 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.
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.
| Connection |
IConnection, joining, PeerResponse
|
| Connection: MediaSoup | The SFU alternative, for larger calls |
| Demo apps | This path, working, in two applications |
Start here
The stack
- WebRTCnative
- Bindings
- The unified API
- Middleware
- Connection
- · Signaling (mesh)
- · MediaSoup (SFU)
- Demo apps
Reference
When it breaks