@nolag/signal

WebRTC signalling over NoLag: peer discovery, offer/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 handles peer discovery through 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.

Key Features

  • Peer discovery through room presence, so you know who to call
  • Offer, answer, ICE candidate and hangup messages addressed to one peer
  • Join and leave events driven by presence
  • Arbitrary metadata per peer, for display names or capabilities
  • Automatic reconnection with presence restored

How It Works

NoLagSignal attaches to an injected @nolag/js-sdk client. Joining a room returns a SignalRoom, which subscribes to one topic and advertises the local peer through presence. Every message carries a toPeerId. By default the broker delivers it to every peer 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 (signal.joinRoom('call-room', { filters: [signal.localPeer!.peerId] })) and send with { filter: peer.peerId }; a filtered peer no longer receives unfiltered broadcasts, so adopt it on every peer or none. See Filters.

TopicPurpose
signalingOffers, answers, ICE candidates, renegotiation requests, hangup

Signalling is deliberately ephemeral, and the broker never replays it: a replayed offer is worse than no offer, because the peer connection it referred to is long gone. See Replay for the one case where replay applies.

Before you start. Create an app from the nolag-signal-sdk blueprint. That seeds the room this SDK expects (call-room) and the online lobby, and returns an app slug with a random suffix. That slug is the appName you pass to the wrapper. You can also do this in the portal: Apps, New App, pick the blueprint.

import { NoLagApi } from "@nolag/js-sdk";

const api = new NoLagApi(process.env.NOLAG_API_KEY); // nlg_live_...
const app = await api.apps.create({ name: "My App", blueprintId: "nolag-signal-sdk" });
console.log(app.slug); // e.g. "my-app-a3f9": this is your appName

const actor = await api.actors.create({ name: "web-client", actorType: "user" });
console.log(actor.accessToken); // shown once; keep it

Installation

npm install @nolag/signal @nolag/js-sdk

Shared 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().

Quick Start

import { NoLag } from '@nolag/js-sdk'
import { NoLagSignal } from '@nolag/signal'

const client = NoLag(async () => (await (await fetch('/api/nolag-token')).json()).token)

const signal = new NoLagSignal({
  client,
  appName: APP_SLUG, // the slug returned when you created the app
  metadata: { displayName: 'Alice' },
})

await client.connect()
await signal.ready()

// Join the seeded room
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)
  }
  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.
for (const peerId of peers.keys()) {
  room.sendBye(peerId)
  teardown(peerId)
}
signal.detach()
client.disconnect()

API Reference

NoLagSignal

The main class. Attaches to the injected core client and manages the room lifecycle.

Constructor Options

OptionTypeDefaultDescription
clientNoLagSocketrequiredThe injected core client
metadataRecord<string, unknown>noneAdvertised with this peer's presence
appNamestring"signal"The app slug returned when you created the app; the default only works if an app with exactly that slug exists
debugbooleanfalseVerbose logging

Methods

MethodReturnsDescription
ready()Promise<void>Resolves once wrapper setup has completed
joinRoom(name, opts?)SignalRoomJoin a signalling room; opts.filters limits delivery to matching signals; throws before ready()
leaveRoom(name)voidLeave a room
getRooms()SignalRoom[]All joined rooms
getOnlinePeers()Peer[]Peers visible in the lobby
localPeerPeer | nullThis peer, available once ready() resolves
detach()voidRelease handlers and topics; never closes the socket

Events

EventPayloadDescription
connectednoneWrapper setup completed on a fresh connection
disconnectedreason: stringConnection closed
reconnectingnoneThe client is attempting to reconnect
reconnectednoneReconnection successful; rooms and presence restored
errorerror: ErrorUnrecoverable error occurred
peerOnlinePeerA peer appeared in the lobby
peerOfflinePeerA peer left the lobby

SignalRoom

MethodReturnsDescription
sendOffer(toPeerId, offer, opts?)voidSend an RTCSessionDescriptionInit offer
sendAnswer(toPeerId, answer, opts?)voidSend an answer
sendIceCandidate(toPeerId, candidate, opts?)voidSend one RTCIceCandidateInit
sendBye(toPeerId, opts?)voidTell a peer you are hanging up
signal(toPeerId, type, payload, opts?)voidSend any SignalType: 'offer', 'answer', 'ice-candidate', 'renegotiate', 'bye'
getPeers()Peer[]Peers currently in this room
getPeer(peerId)Peer | undefinedOne peer
localPeerIdstringThis peer's id, the value to filter on for directed delivery
setFilters(values)voidReplace this room's subscription filters

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.

Events

EventPayloadDescription
signalSignalMessageA message addressed to you: id, type, fromPeerId, toPeerId, payload, timestamp
peerJoinedPeerSomeone's presence appeared in the room
peerLeftPeerSomeone's presence left the room. A bye arrives as a signal of type 'bye' instead

On React Native. @nolag/react-native does not re-export WebRTCManager, and the core SDK's React Native build omits it, because its Node path bare-requires wrtc and Metro would fail the build. Signalling itself works fine there; the media side needs react-native-webrtc. See the React Native SDK.