You're reading the Rask v1.0.0 guides.View source
All guides

IWebRtc

Connect two browsers directly for peer-to-peer data.

  • Wraps: WebRTC (RTCPeerConnection, RTCDataChannel)
  • MDN: WebRTC API
  • Home: Rask.Core.Browser (all hosts)
  • Shape: subscription
  • Availability: Web/Server ✅ · PWA/WASM ✅

WebRTC opens a direct connection between two browsers, so data travels peer-to-peer instead of through your server. The live RTCPeerConnection stays in the browser under the framework's __raskRtc helper; C# holds an IPeerConnection addressed by id, and the browser pushes candidates, state changes and messages back through a static [JSInvokable] — one wiring, both transports.

You supply the signaling

WebRTC cannot start a connection on its own. Before two peers can talk, they have to trade an offer, an answer, and their ICE candidates through some channel they already share. Rask does not pick that channel for you — RtcDescription and RtcIceCandidate are plain serializable records, so they ride whatever you already have: a WebSocket, an HTTP endpoint, or IBroadcastChannel between two tabs of the same origin.


public sealed class Call(IWebRtc rtc) : Component, IAsyncDisposable
{
    private IPeerConnection? _conn;
    private IRtcDataChannel? _chat;

    protected override async Task OnRenderedAsync(bool first)
    {
        if (!first) return;

        _conn = await rtc.CreateAsync(new RtcConfiguration(), new RtcHandlers
        {
            OnIceCandidates = async cands => { foreach (var c in cands) await Signal(c); },
            OnConnectionStateChanged = s => { _state = s; StateHasChanged(); return Task.CompletedTask; },
            OnDataChannel = ch => ch.ListenAsync(OnMessagesAsync).AsTask(),
        });

        _chat = await _conn.CreateDataChannelAsync("chat");
        await _chat.ListenAsync(OnMessagesAsync);

        var offer = await _conn.CreateOfferAsync();
        await _conn.SetLocalDescriptionAsync(offer);
        await Signal(offer);
    }

    private Task OnMessagesAsync(IReadOnlyList<RtcMessage> batch) { /* … */ }

    public async ValueTask DisposeAsync()
    {
        if (_conn is not null) await _conn.DisposeAsync();   // closes its channels too
    }
}

Messages arrive in batches

ListenAsync hands you an IReadOnlyList<RtcMessage>, not one message at a time, and OnIceCandidates does the same. That is not a convenience — it is what keeps the Server host alive. Each push from the browser costs one inbound WebSocket frame, and the host closes a socket that exceeds its inbound frame rate (RaskServerLimits.MaxInboundFramesPerSecond, 1000 by default). A busy data channel delivered one message per push would end the session in under a second; an ICE gathering burst would spike it. The framework buffers on a short timer instead, which bounds the push rate no matter how fast the peer sends. WASM gets the same shape, so the two hosts stay identical.

Under sustained overload the client-side buffer is capped. Past the cap the oldest messages are dropped and counted, and the count is reported through RaskDiagnostics — an unbounded buffer would only trade a closed socket for an out-of-memory tab. ICE candidates are never dropped.

Call ListenAsync, or you receive nothing

A channel buffers from the moment it exists, and starts delivering only when you call ListenAsync. This is what makes a remote-opened channel safe: by the time OnDataChannel runs, the peer may already have sent something, and those messages ride the first batch rather than being lost.

Buffer candidates until the remote description is applied

AddIceCandidateAsync throws if the connection has no remote description yet, and gathering routinely outruns the answer. Hold incoming candidates in a list until you have applied the offer or answer, then drain it. This is the single most common way a first WebRTC integration fails, and it is not something the wrapper can hide — only your signaling knows when the exchange completed.

Sending camera, microphone or screen

Acquire a stream, then hand its MediaStreamId to the connection:


// Server host: the click gesture acquires it, OnStream hands back the id.
MediaCaptureTrigger.For(_preview).Video(true).Audio(true)
    .OnStream(async id => await _conn!.AddStreamAsync(id))
    .Template(g => Button.Type("button").Data(g)["Start camera"])

// The peer's media comes back the same way, and attaches like any other stream.
new RtcHandlers { OnTrack = id => streams.AttachToAsync(id, _remoteVideo) }

Screen sharing needs nothing extra — getDisplayMedia yields the same kind of id as the camera.

OnTrack fires once per stream, not per track: a peer sending camera and microphone sends two tracks in one stream, and the stream is what you attach.

Ownership splits in the way you'd want. A stream you added stays yours — disposing the connection stops sending it but leaves it running, so stop it yourself with IMediaStreams.StopAsync. A remote stream from OnTrack belongs to the connection, and disposing the connection stops it.

Adding or removing a stream renegotiates: exchange a fresh offer/answer afterwards, or the peer never sees the change.

ICE servers, and what they leak

RtcConfiguration.IceServers is empty by default, which means host candidates only — enough for two peers on the same machine or LAN, not across the internet. Rask ships no STUN or TURN server; supply your own. URLs must use stun:, turn: or turns:; anything else is rejected.

Be aware that ICE tells the other peer your local network addresses. That is inherent to WebRTC, not to this wrapper. Where it matters, set IceTransportPolicy = "relay" and provide a TURN server, so all traffic goes through the relay and the peer learns nothing about your network.

See also