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

ISignaling

The relay two peers trade an offer, an answer and their ICE candidates over.

  • Wraps: WebSocket (Rask's signaling relay)
  • MDN: WebSockets API
  • Home: Rask.Core.Browser (all hosts)
  • Shape: subscription
  • Availability: Web/Server ✅ · PWA/WASM ✅

IWebRtc deliberately doesn't pick a signaling channel — an app that already has one should use it. ISignaling is the channel for apps that don't, and it pairs with the Rask.Signaling package — which depends on nothing from Rask.Server, so any ASP.NET host can map it, including a static-file host serving a published WASM bundle.

The payload is an opaque string end to end. Serialize an RtcDescription or an RtcIceCandidate into it however you like; neither the relay nor this wrapper looks inside. That's deliberate — parsing attacker-controlled SDP server-side would be a lot of surface for no benefit, since only the two browsers need to understand it.

Server: hosting the relay


builder.Services.AddRaskSignaling(o =>
{
    // The framework can't know who belongs in which room. This is where you say.
    o.AuthorizeRoom = ctx => ctx.Services
        .GetRequiredService<IConversations>()
        .IsMemberAsync(ctx.User, ctx.Room);
});

app.MapRaskSignaling();

The host must have WebSocket support in the pipeline: Rask.Server's UseRask() calls app.UseWebSockets() for you, but a static-file host serving a published WASM bundle does not — call it yourself before mapping. The relay says so explicitly rather than refusing clients with a bare 400.

Authentication is required by default. A signaling relay anyone can join is a way to reach other people's browsers, so a public default would make that an accident rather than a decision. AuthorizeRoom is the per-room hook on top; its default lets any authenticated caller into any room, which is only right when every user of the app may talk to every other.

Client: joining a room


_signal = await signaling.JoinAsync("room-42", new SignalingHandlers
{
    // The peers already here are the ones WE offer to. A peer arriving later offers to us instead.
    OnJoined = async (self, peers) => { foreach (var p in peers) await OfferToAsync(p); },
    OnSignal = (from, payload) => ApplyAsync(from, payload),
    OnPeerLeft = id => DropAsync(id),
});

await _signal.SendAsync(peerId, JsonSerializer.Serialize(offer));

That asymmetry in OnJoined matters: if both sides offer at once you get an SDP glare collision that neither browser resolves for you. "The one who arrives offers to those already there" is the simplest rule that avoids it.

What the relay guarantees

  • Peer ids are minted by the server, never taken from the client — otherwise a caller could impersonate another peer, or overwrite it.
  • A message reaches only a peer in the sender's own room, checked at delivery rather than trusted from the message.
  • Nothing is ever echoed to its sender, so the relay can't be aimed at itself.
  • Payload size, message rate, room size and room count are all capped (RaskSignalingOptions).
  • A refused join says the same thing whether the room was full or you weren't allowed in — anything else would let a caller probe which rooms exist.

Limits worth knowing

Rooms are held in memory, per process. Signaling is short-lived, so there's nothing worth persisting — but a multi-instance deployment needs sticky routing for the signaling path, or two peers assigned to different instances never see each other.

The socket is separate from the live render socket, on both hosts. That one has its own frame contract, rate limits and shutdown-drain semantics, and signaling traffic has no business sharing them. It lives in the browser on both hosts too, so a Server-hosted app doesn't put its own server in the middle of a relay it is already running.

See also