Skip to content

WebRTC - Signaling

WebRTC - Building a Basic Signaling Server

Section titled “WebRTC - Building a Basic Signaling Server”

WebRTC itself does not define how peers discover each other or exchange control messages (like SDP offers/answers and ICE candidates). This process is called ‘signaling’, and developers must implement their own signaling mechanism. In this chapter, we’ll build a basic signaling server using Node.js and WebSockets.

Before a direct peer-to-peer WebRTC connection can be established, peers need to exchange information:

  • Session Control Messages: To initiate, manage, or terminate a call (e.g., user A wants to call user B).
  • Session Description Protocol (SDP) Offers/Answers: To negotiate media capabilities (codecs, resolution, etc.).
  • ICE Candidates: Network information (IP addresses, ports) that helps peers find the best path to connect.

A typical signaling flow might be:

  1. User A and User B connect to a signaling server.
  2. User A decides to call User B. User A creates an SDP offer.
  3. User A sends the offer to User B via the signaling server.
  4. User B receives the offer and creates an SDP answer.
  5. User B sends the answer back to User A via the signaling server.
  6. During this process, both User A and User B also discover ICE candidates and exchange them via the signaling server.
  7. Once offer/answer and ICE candidate exchange are complete, a direct peer-to-peer connection is attempted.

This is just one example; the specifics of signaling logic can vary greatly. WebSockets are a common choice for signaling due to their bidirectional, low-latency communication capabilities.

Our server will allow users to ‘login’ with a username and then facilitate the exchange of WebRTC messages between them. The following diagram conceptually illustrates the message flow: User A and User B both connect to the signaling server. User A sends an offer for User B to the server. The server forwards this offer to User B. User B generates an answer and sends it to User A via the server. ICE candidates are exchanged similarly. This continues until a peer-to-peer connection is established or fails.

First, ensure you have Node.js installed and the ws library (npm install ws in your project directory). Create a file named server.js:

const WebSocket = require('ws');
// Create a WebSocket server on port 9090
const wss = new WebSocket.Server({ port: 9090 });
// Store connected users: { username: WebSocketConnection }
const users = {};
console.log('Signaling server started on ws://localhost:9090');
wss.on('connection', (connection) => {
console.log('User connected');
connection.on('message', (message) => {
let data;
try {
data = JSON.parse(message);
} catch (e) {
console.log('Invalid JSON received:', message);
data = {};
}
console.log('Received message:', data);
switch (data.type) {
case 'login':
handleLogin(connection, data);
break;
case 'offer':
case 'answer':
case 'candidate':
handleWebRTCMsg(connection, data);
break;
case 'leave':
handleLeave(connection);
break;
default:
sendTo(connection, {
type: 'error',
message: 'Command not found: ' + data.type
});
break;
}
});
connection.on('close', () => {
handleDisconnect(connection);
});
connection.on('error', (error) => {
console.error('Connection error for user:', connection.name, error);
handleDisconnect(connection); // Treat errors as disconnections for cleanup
});
// Send a welcome message (optional)
// sendTo(connection, { type: 'info', message: 'Connected to signaling server' });
});
function sendTo(conn, message) {
try {
conn.send(JSON.stringify(message));
} catch (error) {
console.error('Failed to send message to', conn.name, ':', error);
}
}
function handleLogin(connection, data) {
if (users[data.name]) {
sendTo(connection, { type: 'login', success: false, message: 'Username already taken' });
} else {
users[data.name] = connection;
connection.name = data.name;
console.log('User logged in:', data.name);
sendTo(connection, { type: 'login', success: true });
}
}
function handleWebRTCMsg(connection, data) {
const targetUser = data.name; // The recipient of the WebRTC message
const targetConnection = users[targetUser];
if (targetConnection) {
// Add sender's name to the message for the recipient
const outgoingMessage = { ...data, sender: connection.name };
delete outgoingMessage.name; // Remove target name, as it's now the recipient
// If it's an offer or answer, store the other party's name for potential leave/disconnect handling
if (data.type === 'offer' || data.type === 'answer') {
connection.otherName = targetUser;
targetConnection.otherName = connection.name;
}
console.log(`Forwarding ${data.type} from ${connection.name} to ${targetUser}`);
sendTo(targetConnection, outgoingMessage);
} else {
console.log(`User ${targetUser} not found for ${data.type} from ${connection.name}`);
sendTo(connection, {
type: 'error',
message: `User ${targetUser} not found.`
});
}
}
function handleLeave(connection) {
console.log('User leaving:', connection.name, '; notifying other party:', connection.otherName);
if (connection.otherName) {
const otherUserConnection = users[connection.otherName];
if (otherUserConnection) {
otherUserConnection.otherName = null; // Clear their otherName reference
sendTo(otherUserConnection, { type: 'leave', sender: connection.name });
}
}
connection.otherName = null; // Clear this user's otherName reference
// Note: We don't remove the user from `users` here, that happens on 'close'.
// This 'leave' is for active disconnection from a call, not from the server itself.
}
function handleDisconnect(connection) {
console.log('User disconnected:', connection.name);
if (connection.name) {
// Notify the other party if they were in a call
if (connection.otherName) {
const otherUserConnection = users[connection.otherName];
if (otherUserConnection) {
otherUserConnection.otherName = null;
sendTo(otherUserConnection, { type: 'leave', sender: connection.name, reason: 'disconnect' });
}
}
delete users[connection.name];
}
}

To run this server, save it as server.js and execute node server.js in your terminal. You should see ‘Signaling server started on ws://localhost:9090’.

Testing with wscat: Open two terminal windows. In the first, run your server: node server.js. In the second terminal, connect as ‘userA’: wscat -c ws://localhost:9090 Once connected (you’ll see Connected (press CTRL+C to quit)), type the login message for userA: {"type":"login","name":"userA"} and press Enter. The server should log ‘User logged in: userA’, and wscat should receive: {"type":"login","success":true}. Open a third terminal and connect as ‘userB’: wscat -c ws://localhost:9090 Login as userB: {"type":"login","name":"userB"}. Now, from userA’s wscat terminal, send an offer to userB: {"type":"offer","name":"userB","offer":"SDP_OFFER_DATA_HERE"} UserB’s wscat terminal should receive something like: {"type":"offer","offer":"SDP_OFFER_DATA_HERE","sender":"userA"}, and the server will log the forwarding action. This demonstrates the basic message relaying capability.

  • Connection Handling: When a new client connects (wss.on('connection', ...)), the server sets up listeners for messages, closure, and errors from that client.
  • User Registration (Login): Clients send a login message with a username. The server checks if the username is taken. If not, it stores the connection associated with the username.
  • WebRTC Message Relaying (Offer, Answer, Candidate): When the server receives an offer, answer, or candidate message, it looks up the target user specified in the name field of the message and forwards the message to that user’s WebSocket connection. The sender’s name is added to the message so the recipient knows who it’s from.
  • Handling Disconnections (Leave/Close): If a user sends a leave message or their WebSocket connection closes ('close' event), the server cleans up their entry and notifies the other connected peer, if any, so they can also terminate their WebRTC connection gracefully.

This signaling server is very basic. Production signaling servers would need to handle authentication, scalability, error recovery, presence, and potentially room management for multi-party calls. However, this example covers the fundamental message-passing requirements for establishing a two-party WebRTC connection.

In this chapter, we built a simple signaling server using Node.js and WebSockets. We explored the signaling process, including user registration, and the relaying of WebRTC-specific messages like offers, answers, and ICE candidates. This server forms a crucial intermediary for WebRTC clients to discover and connect with each other.