@nolag/signal
WebRTC signalling over NoLag: peer discovery, offer and answer exchange, and ICE candidate relay.
Overview
WebRTC needs a signalling channel before a peer connection can exist. Two browsers cannot exchange session descriptions or ICE candidates over WebRTC itself, because that is the very thing they are negotiating. Something else has to carry those messages, and it has to know who is in the call.
@nolag/signal is that channel. It discovers peers through room presence, delivers offers, answers and ICE candidates addressed to a specific peer, and tells you when someone joins or leaves. It does not touch RTCPeerConnection: the media is yours, this moves the negotiation. Your app owns one core NoLag client and injects it into NoLagSignal; the wrapper attaches its behaviour to that connection.
Key Features
- Peer discovery through room presence, so you know who to call
- Offer, answer, ICE candidate, renegotiate and hangup messages addressed to one peer
- Join and leave events driven by presence
- Arbitrary metadata per peer, for display names or capabilities
- Optional broker-side addressing with filters
How It Works
NoLagSignal attaches to an injected @nolag/js-sdk client. Joining a room returns a SignalRoom, which subscribes to one topic, signaling, and advertises the local peer through presence. Every message carries a toPeerId. By default kraken delivers each message to every peer subscribed in the room and each receiver drops anything not addressed to it, which is fine for a handful of participants.
To make the broker do the addressing instead, join with your own peer id as a subscription filter and send with { filter: peer.peerId }. A peer that subscribes with filters no longer receives unfiltered messages, so adopt it on every peer or none. Peers without filters still receive filtered messages. See Filters.
| Topic | Purpose |
|---|---|
signaling | Offers, answers, ICE candidates, renegotiation requests, hangup |
Signalling messages are published with echo: false, so a peer never receives its own offer back. Nothing is stored: a peer that was not connected when an offer was sent never sees it, which is what you want, because the peer connection it referred to is gone.
Topics to grant
| What | Value |
|---|---|
| App Slug | Whatever you pass as appName (default signal) |
| Rooms | One per room name you pass to joinRoom(). The blueprint defines call-room |
| Topics in each room | signaling |
| Lobby | online |
| Permission | pubSub: every peer both sends and receives |
Grant these either in kraken's static token file or in a @nolag/core project document. See Static auth and Project document for the full formats.
{
"tokens": {
"alice-call-token": {
"actorTokenId": "alice",
"projectId": "demo-project",
"organizationId": "demo-org",
"actorType": "user",
"allowedTopics": [
{
"pattern": "signal/call-room/signaling",
"permission": "pubSub",
"app_id": "signal-app",
"room_id": "room-call-room",
"room_slug": "call-room"
}
],
"allowedLobbies": [{ "lobby_slug": "online", "lobby_id": "signal-online" }]
}
}
}Things that matter with the static file:
- Every peer that should reach the others needs the same
app_id. - Peer discovery is presence, and presence only works in rooms that have a rule carrying both
room_slugandroom_id. A catch-all rule such assignal/+/signalinglets a token use any room name, but no peer would ever seepeerJoinedin a room without its own rule. - Without the
allowedLobbiesentry the lobby subscribe is refused: the wrapper emits anerror(not_authorized) andready()only resolves after the core SDK's 10 second lobby timeout.
For a call per meeting, a room per call works well with @nolag/core: create the room in core when the meeting is booked (or list it in the project document), and keep it private with grants. See Rooms and Access Control.
Installation
npm install @nolag/signal @nolag/js-sdkShared connection. One core NoLag client can back several wrapper SDKs at once, as long as each uses a distinct appName. Each wrapper attaches its handlers on construction and releases them with detach(), and never touches the socket itself. Your app owns connect() and disconnect(). One connection holds presence in one room at a time, so a client that joins a second room (in this or any other wrapper on the same client) drops out of the first room's peer list.
Quick Start
import { NoLag } from '@nolag/js-sdk'
import { NoLagSignal } from '@nolag/signal'
const client = NoLag('alice-call-token', { url: 'ws://localhost:8080/ws' })
const signal = new NoLagSignal({
client,
appName: 'signal', // the App Slug you granted
metadata: { displayName: 'Alice' },
})
await client.connect()
await signal.ready()
const room = signal.joinRoom('call-room')
// One RTCPeerConnection per remote peer, created on demand.
const peers = new Map<string, RTCPeerConnection>()
function peerConnectionFor(peerId: string): RTCPeerConnection {
let pc = peers.get(peerId)
if (pc) return pc
pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] })
// Trickle ICE as candidates are discovered.
pc.onicecandidate = ({ candidate }) => {
if (candidate) room.sendIceCandidate(peerId, candidate.toJSON())
}
peers.set(peerId, pc)
return pc
}
function teardown(peerId: string) {
peers.get(peerId)?.close()
peers.delete(peerId)
}
// Someone new arrived: offer them a connection.
room.on('peerJoined', async (peer) => {
const pc = peerConnectionFor(peer.peerId)
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
room.sendOffer(peer.peerId, offer)
})
// Everything addressed to us arrives here.
room.on('signal', async (message) => {
const pc = peerConnectionFor(message.fromPeerId)
if (message.type === 'offer') {
await pc.setRemoteDescription(message.payload as RTCSessionDescriptionInit)
const answer = await pc.createAnswer()
await pc.setLocalDescription(answer)
room.sendAnswer(message.fromPeerId, answer)
}
if (message.type === 'answer') {
await pc.setRemoteDescription(message.payload as RTCSessionDescriptionInit)
}
if (message.type === 'ice-candidate') {
await pc.addIceCandidate(message.payload as RTCIceCandidateInit)
}
// A hangup is a signal, not a presence change: peerLeft does not fire for it.
if (message.type === 'bye') {
teardown(message.fromPeerId)
}
})
// Presence says they are gone (closed tab, lost connection).
room.on('peerLeft', (peer) => teardown(peer.peerId))
// Hang up on everyone, then release the wrapper and close the socket.
function hangUp() {
for (const peerId of peers.keys()) {
room.sendBye(peerId)
teardown(peerId)
}
signal.detach()
client.disconnect()
}Both sides of a new pair see each other through presence, so in this sketch both may send an offer at the same time ("glare"). Pick one side to offer, for example the peer with the lower peerId, or use the perfect negotiation pattern.
Directed delivery with filters
// Receive only signals addressed to this peer
const directRoom = signal.joinRoom('call-room', { filters: [signal.localPeer!.peerId] })
// Send to one peer and let the broker do the addressing
directRoom.sendOffer(remotePeerId, offer, { filter: remotePeerId })Every peer in the room has to switch together: a filtered peer stops receiving the unfiltered messages that other peers send.
Reconnects
When the core client reconnects, the wrapper re-subscribes the online lobby, re-sets its presence in every joined room and then emits reconnected. It does not re-subscribe the signaling topic: it was written for a broker that restores subscriptions on its own. Do not rely on that with kraken (see Production); re-issue the subscription yourself:
signal.on('reconnected', () => {
for (const room of signal.getRooms()) {
const filters = room.filters.length > 0 ? { filters: room.filters } : {}
client.setApp('signal').setRoom(room.name).subscribe('signaling', filters)
}
})The wrapper's message handler stays registered on the client, so subscribing again is enough. Peer connections that were live before the drop are your own to keep or renegotiate.
The online lobby. peerOnline, peerOffline and getOnlinePeers() depend on the app's online lobby. On kraken v0.9.0 a lobby covers the rooms in the subscribing token's own grants, and that room list is cached on each kraken node for 5 minutes after the last lobby subscribe there; after that, lobby events stop until something subscribes to the lobby again. peerJoined and peerLeft come from room presence and are not affected. See Lobbies.
API Reference
NoLagSignal
The main class. Attaches to the injected core client and manages the room lifecycle.
Constructor Options
| Option | Type | Default | Description |
|---|---|---|---|
client | NoLagSocket | required | The injected core client |
metadata | Record<string, unknown> | none | Advertised with this peer's presence |
appName | string | "signal" | The App Slug used as the first segment of every topic |
debug | boolean | false | Verbose logging |
Methods and properties
| Method / Property | Returns | Description |
|---|---|---|
ready() | Promise<void> | Resolves once wrapper setup has completed |
joinRoom(name, opts?) | SignalRoom | Join a signalling room; opts.filters limits delivery to matching signals; throws before ready() |
leaveRoom(name) | void | Leave a room |
getRooms() | SignalRoom[] | All joined rooms |
getOnlinePeers() | Peer[] | Peers currently seen as online |
localPeer | Peer | null | This peer, available once ready() resolves. Its peerId is generated per wrapper instance |
detach() | void | Release handlers and topics; never closes the socket |
Events
| Event | Payload | Description |
|---|---|---|
connected | none | Wrapper setup completed on a fresh connection |
disconnected | reason: string | Connection closed |
reconnecting | none | The client is attempting to reconnect |
reconnected | none | Setup ran again after a reconnect: lobby and presence re-applied (the topic is not; see Reconnects) |
error | error: Error | A transport or broker error, for example a refused subscribe |
peerOnline | Peer | A peer came online |
peerOffline | Peer | A peer left the lobby |
SignalRoom
| Method / Property | Returns | Description |
|---|---|---|
sendOffer(toPeerId, offer, opts?) | void | Send an RTCSessionDescriptionInit offer |
sendAnswer(toPeerId, answer, opts?) | void | Send an answer |
sendIceCandidate(toPeerId, candidate, opts?) | void | Send one RTCIceCandidateInit |
sendBye(toPeerId, opts?) | void | Tell a peer you are hanging up |
signal(toPeerId, type, payload, opts?) | void | Send any SignalType: 'offer', 'answer', 'ice-candidate', 'renegotiate', 'bye' |
getPeers() | Peer[] | Remote peers currently in this room |
getPeer(peerId) | Peer | undefined | One peer |
localPeerId | string | This peer's id, the value to filter on for directed delivery |
filters | FilterValue[] | The filter values applied to this room's subscription |
setFilters(values) / addFilters(values) / removeFilters(values) | void | Change this room's subscription filters; an empty set restores the unfiltered subscription |
opts on every send method accepts filter (one value) or filters (an AND group), so the broker delivers only to peers subscribed with that value, plus any peer subscribed without filters.
Events
| Event | Payload | Description |
|---|---|---|
signal | SignalMessage | A message addressed to you: id, type, fromPeerId, toPeerId, payload, timestamp |
peerJoined | Peer | Someone's presence appeared in the room |
peerLeft | Peer | Someone's presence left the room. A bye arrives as a signal of type 'bye' instead |
On React Native. Signalling works there unchanged. The core SDK's React Native build leaves out its WebRTC helper, so the media side needs react-native-webrtc. See the React Native SDK.