A tiny, signaling-agnostic library for building peer-to-peer WebRTC conferencing (media + data channels) with pluggable signaling drivers.
Key ideas:
- Sender: creates outgoing RTCPeerConnections, publishes a local MediaStream, and optionally opens data channels to receivers.
- Receiver: listens for offers, answers them, and exposes remote MediaStreams and incoming data messages.
- Signaling driver: any object implementing
on(namespace, handler),off(namespace, handler)andemit(namespace, message). This keeps the library transport-agnostic (WebSocket, pub/sub, in-memory, etc).
Why use p2p:
- Minimal footprint and API surface for broadcasting a local stream and exchanging data.
- Easy to test locally with an in-memory driver; swap to WebSocket or other drivers for production.
- Handles ICE, offer/answer exchange, candidate buffering, and per-peer data channels.
Install:
npm install p2pRun the demo (clone the repo if needed):
npm run devOpen http://localhost:8000/demo/ in two browser tabs to see a simple video chat demo.
Minimal in-memory signaling driver (useful for local testing):
// Minimal in-memory pub/sub driver
class MemoryDriver extends Map {
constructor() { super(); }
on(namespace, handler) {
const k = namespace.join(':');
if (!this.has(k)) {
this.set(k, new Set());
}
this.get(k).add(handler);
}
off(namespace, handler) {
const k = namespace.join(':');
this.get(k)?.delete(handler);
}
emit(namespace, message) {
const k = namespace.join(':');
if (!this.has(k)) return;
for (const h of this.get(k)) {
try {
h(message);
} catch (e) {
/* swallow errors */
}
}
}
}Signaling namespaces (contract)
- Sender listens on:
['sender', room]and['sender', room, senderId] - Sender emits to:
['receiver', room]and['receiver', room, receiverId] - Receiver listens on:
['receiver', room]and['receiver', room, receiverId] - Receiver emits to:
['sender', room]and['sender', room, senderId]
Message types
invoke— discovery / request to connect (contains id, optional credentials)offer— sender -> receiver with SDP offer and metadataanswer— receiver -> sender with SDP answercandidate— ICE candidate exchangedispose— end/tear-down
Receiver — listen for senders and attach incoming streams:
import { Receiver } from 'p2p';
const driver = new MemoryDriver();
const receiver = new Receiver({ driver });
receiver.addEventListener('stream', (e) => {
const { id, stream, metadata } = e.detail;
console.log('received stream from', id, metadata);
// attach the received stream to a video element
const video = document.createElement('video');
video.autoplay = true;
video.srcObject = stream;
video.dataset.source = metadata.source || 'unknown';
document.body.appendChild(video);
});
receiver.addEventListener('channel:message', (e) => {
const { id, channel, data } = e.detail;
console.log('msg from', id, channel.label, data);
if (channel.label === 'chat') {
console.log('chat message:', data);
// respond to chat messages
channel.send('ping');
}
});
receiver.start({ room: 'demo-room' });
// receiver.stop();Sender — capture local media, broadcast, and send messages:
import { Sender } from 'p2p';
const driver = new MemoryDriver();
const sender = new Sender({ driver });
sender.addEventListener('connect', (e) => {
const { id } = e.detail;
console.log('peer connected', id);
});
sender.addEventListener('channel:open', (e) => {
const { id, channel } = e.detail;
console.log('data channel opened', id, channel.label);
if (channel.label === 'chat') {
// send a message to the data channel
channel.send('ping');
}
});
sender.addEventListener('channel:message', (e) => {
const { id, channel, data } = e.detail;
console.log('msg from', id, channel.label, data);
if (channel.label === 'chat') {
// read a chat message from the data channel
console.log('chat message:', data);
}
});
navigator.mediaDevices.getUserMedia({ audio: true, video: true })
.then((stream) => {
sender.start({
room: 'demo-room',
stream,
channels: {
chat: { ordered: true }
},
metadata: { source: 'camera' },
});
// sender.stop();
});You can send a message to all connected peers via the 'chat' data channel:
sender.connections.forEach((conn) => {
const channel = conn.channels.get('chat');
if (channel && channel.readyState === 'open') {
channel.send('hello peers!');
}
});Sender:
- constructor(config: { driver, iceServers?, verify?, connectionTimeout?, audioBitrate?, videoBitrate? })
- start({ room?, stream?, channels?, metadata? })
- stop()
Events: connect, dispose, error, channel:open, channel:close, channel:error, channel:message
Receiver:
- constructor(config: { driver, iceServers?, connectionTimeout?, pingInterval?, pingAttempts? })
- start({ room?, credentials? })
- stop()
Events: stream, connect, dispose, channel:open, channel:close, channel:error, channel:message
- Browser permissions: getUserMedia requires secure context (https or localhost) and user consent.
- TURN servers: include TURN servers in iceServers for reliable connectivity across NATs.
- Candidate buffering: the library buffers ICE candidates received before a connection is created.
- Bitrate and codec hints: the library sets preferred codecs and bitrate where supported; browsers may ignore hints.
- Debugging: use browser WebRTC internals and ICE/state events to diagnose connectivity issues.
- Sender supports an optional verify() callback to accept/reject incoming invocations.
- If you need authentication/authorization, implement it in your signaling layer and/or verify callback.
- Always use secure signaling channels (e.g., WSS) to protect exchanged SDP and ICE candidates.
