Skip to content

Signaling

Before two browsers can connect directly, they have to exchange a few messages: who is in a room, the descriptions of their connections, and the network addresses they can be reached at. Carrying those messages is signaling. peer-data doesn’t do it itself; you give PeerData the signaling to use.

import { BroadcastChannelSignaling, PeerData } from 'peer-data';
const peer = new PeerData(new BroadcastChannelSignaling());

BroadcastChannelSignaling reaches the pages of the same origin in one browser: tabs, windows and iframes. It needs no server, which makes it right for demos, tests, and apps that connect their own tabs. Give each PeerData its own instance; pass the same name, peer-data by default, to the ones that should meet.

close() stops it. Anything sent afterwards goes nowhere, so close it after peer.close(), not before, or the others won’t hear that you left.

import { io } from 'socket.io-client';
import { PeerData, SocketChannel } from 'peer-data';
const peer = new PeerData(new SocketChannel(io('https://signaling.example.com')));

SocketChannel sends each signaling message as a message event on the socket, and passes on the message events that come back. It takes a socket you create, so you pick the client version and its options:

  • socket.io 3 or 4 server: socket.io-client 4.
  • socket.io 2 server, such as peer-data-server 1.x: socket.io-client 2.

peer-data itself depends on neither. When the socket reconnects after a drop, SocketChannel announces this peer again in every room it’s in: the server has forgotten it, and the others may have been told it left.

A signaling server only routes messages; it never looks inside them. This one, on socket.io 4, is what the examples and tests run:

examples/socket/server.ts
import type { Server as HttpServer } from 'node:http';
import type { Http2SecureServer } from 'node:http2';
import { Server, type ServerOptions } from 'socket.io';
interface Event {
type: string;
room: { id: string };
caller: { id: string };
callee?: { id: string } | null;
}
const hasId = (value: unknown): value is { id: string } =>
typeof value === 'object' && value !== null && 'id' in value && typeof value.id === 'string' && value.id !== '';
const isEvent = (value: unknown): value is Event =>
typeof value === 'object' &&
value !== null &&
'type' in value &&
typeof value.type === 'string' &&
'room' in value &&
hasId(value.room) &&
'caller' in value &&
hasId(value.caller);
/** A page needs one id for each room it joins: a socket asking for more is flooding the others with peers. */
const MAX_IDS = 16;
/**
* A signaling server for peer-data on socket.io 4. CONNECT and DISCONNECT go to the others in the room, and
* OFFER, ANSWER and CANDIDATE to the callee alone. A socket speaks only for the ids it joined rooms with, 16 at
* most, and when it drops, the others in its rooms get a DISCONNECT for each. A CONNECT for an id another socket
* holds waits until that socket leaves: a page that reconnects asks with its id before the server notices that
* its old socket is gone.
*/
export function attachSignaling(
httpServer: HttpServer | Http2SecureServer,
options: Partial<ServerOptions> = {},
): Server {
const io = new Server(httpServer, options);
/** Every peer id in use, so no socket can claim another's. */
const claimed = new Set<string>();
/** For an id in use, the socket that asked for it last: the ids it waits for, and how it joins with this one. */
const waiting = new Map<string, { pending: Map<string, string>; join: () => void }>();
function release(id: string): void {
claimed.delete(id);
const waiter = waiting.get(id);
waiting.delete(id);
waiter?.join();
}
io.on('connection', (socket) => {
/** The room of each peer id this socket joined with. */
const own = new Map<string, string>();
/** The room of each id this socket waits for, until it's free or another socket asks for it. */
const pending = new Map<string, string>();
function connect(event: Event): void {
const { id } = event.caller;
const room = event.room.id;
pending.delete(id);
claimed.add(id);
own.set(id, room);
void socket.join([`room:${room}`, `peer:${id}`]);
socket.to(`room:${room}`).emit('message', event);
}
/** Waits for the event's id to be free, in place of the socket that asked for it before. */
function wait(event: Event): void {
const { id } = event.caller;
waiting.get(id)?.pending.delete(id);
pending.set(id, event.room.id);
waiting.set(id, {
pending,
join: () => {
connect(event);
},
});
}
socket.on('message', (event: unknown) => {
if (!isEvent(event)) return;
const { id } = event.caller;
const room = event.room.id;
switch (event.type) {
case 'CONNECT':
if (own.has(id) || (!pending.has(id) && own.size + pending.size >= MAX_IDS)) return;
if (claimed.has(id)) wait(event);
else connect(event);
return;
case 'DISCONNECT':
if (pending.get(id) === room) {
pending.delete(id);
waiting.delete(id);
return;
}
if (own.get(id) !== room) return;
// Before the id goes to a socket waiting for it, whose CONNECT must come after.
socket.to(`room:${room}`).emit('message', event);
leave(id, room);
return;
case 'OFFER':
case 'ANSWER':
case 'CANDIDATE':
// Only into the room the id joined: the callee routes it by room.
if (own.get(id) !== room || !hasId(event.callee)) return;
socket.to(`peer:${event.callee.id}`).emit('message', event);
return;
}
});
socket.on('disconnect', () => {
for (const id of pending.keys()) waiting.delete(id);
for (const [id, room] of own) {
io.to(`room:${room}`).emit('message', {
type: 'DISCONNECT',
room: { id: room },
caller: { id },
callee: null,
payload: null,
});
release(id);
}
});
function leave(id: string, room: string): void {
own.delete(id);
void socket.leave(`peer:${id}`);
// The socket may be in the room with another id too.
if (![...own.values()].includes(room)) void socket.leave(`room:${room}`);
release(id);
}
});
return io;
}

Attach it to your HTTP server:

import { createServer } from 'node:http';
import { attachSignaling } from './server';
const httpServer = createServer();
attachSignaling(httpServer, { cors: { origin: 'https://your.site' } });
httpServer.listen(8080);

Beyond routing, it checks that each socket only speaks for the peer ids it joined with, 16 at most, and tells a room when a socket drops. A peer whose socket reconnects before the server notices the old one is gone gets its id back once the server does, within socket.io’s ping interval and timeout: 45 seconds by default. Add whatever your app needs on top, such as authentication before a socket may join a room, or rate limits.

Every signaling message has the same shape, unchanged since peer-data 3:

type SignalingEvent = {
type: 'CONNECT' | 'DISCONNECT' | 'OFFER' | 'ANSWER' | 'CANDIDATE';
room: { id: string };
caller: { id: string }; // the peer that sent it
callee: { id: string } | null; // the peer it is for, or null for the whole room
payload: RTCSessionDescriptionInit | RTCIceCandidateInit | null;
};

A server routes them in two ways, never back to the peer that sent them, and in the order they were sent:

  • CONNECT and DISCONNECT have no callee: send them to everyone else in the room.
  • OFFER, ANSWER and CANDIDATE go to the callee only. A peer that received another’s offers would apply them to its own connection to that peer, so in a room of three or more nobody connects.

Peer ids are random, made up by each peer for each room it joins. A server may replace the caller’s id with its own, such as the socket’s id, as long as it routes messages for that id back to the same peer: peer-data uses whichever id the others address it by.

Signaling is an object with two methods, so anything that can route JSON between peers as the messages describes works: WebSockets, or server-sent events and fetch, to a server that routes them. peer-data can’t filter out offers meant for someone else itself, because a server may replace the ids peers address each other by.

import { PeerData, type Signaling } from 'peer-data';
const socket = new WebSocket('wss://signaling.example.com');
const signaling: Signaling = {
send: (event) => socket.send(JSON.stringify(event)),
subscribe: (listener) => {
const onMessage = ({ data }: MessageEvent<string>) => listener(JSON.parse(data));
socket.addEventListener('message', onMessage);
return () => socket.removeEventListener('message', onMessage);
},
};
const peer = new PeerData(signaling);

send carries a message to the server; subscribe passes each message that arrives to the listener until the function it returns is called. PeerData checks every message and ignores anything that isn’t a signaling event, so you can pass messages on as they come.

send is called as soon as you join a room, so the transport should queue messages until it is open. The WebSocket above doesn’t: wait for its open event before calling peer.connect. If send throws, so does peer.connect, and calling it again later tries again.