@marianmeres/webrtc
v2.2.0
Published
[](https://www.npmjs.com/package/@marianmeres/webrtc) [](https://jsr.io/@marianmeres/webrtc)
Downloads
308
Readme
@marianmeres/webrtc
A lightweight, framework-agnostic WebRTC manager with state machine-based lifecycle management and event-driven architecture.
Features
- State Machine-based: Clean state transitions (IDLE → INITIALIZING → CONNECTING → CONNECTED)
- Event-driven: Subscribe to specific events or overall state changes
- Svelte Store Compatible: Works seamlessly with Svelte's reactive
$syntax - Audio Management: Microphone enable/disable, device switching, device change detection
- Data Channels: Easy creation and management of RTCDataChannels
- Auto-reconnection: Optional automatic reconnection with exponential backoff
- TypeScript: Full type safety and excellent IDE support
Installation
npm install @marianmeres/webrtcHigh-Level Overview
The WebRTCManager class handles the complete WebRTC connection lifecycle:
- Initialization: Sets up RTCPeerConnection, media streams, and data channels
- Connection Management: Handles state transitions, reconnection, and cleanup
- Signaling: Provides methods for offer/answer exchange and ICE candidate handling
- Media Control: Manages local/remote streams and microphone switching
- Events: Emits events for all important state changes
The manager doesn't handle the signaling transport layer - you're responsible for sending/receiving offers, answers, and ICE candidates through your own signaling mechanism (WebSocket, HTTP, etc.).
Core API
Constructor
const manager = new WebRTCManager<TContext>(factory, config);TContext: Optional type parameter for thecontextproperty (default:unknown)factory: Object implementingWebRTCFactoryinterface (providescreatePeerConnection,getUserMedia,enumerateDevices)config: Optional configuration object
Configuration Options:
peerConfig: RTCConfiguration (ICE servers, etc.)enableMicrophone: Enable microphone on initialization (default: false)audioDirection: Direction of the audio transceiver added whenenableMicrophoneis false (default:"recvonly"). Use"sendrecv"if you plan to enable the mic later and want to avoid renegotiation.dataChannelLabel: Create a default data channel with this labelautoReconnect: Enable automatic reconnection (default: false)maxReconnectAttempts: Max reconnection attempts (default: 5)reconnectDelay: Initial reconnection delay in ms (default: 1000)iceRestartTimeout: Timeout in ms for an ICE-restart attempt to reachCONNECTEDbefore the next attempt is scheduled (default: 10000)fullReconnectTimeout: Timeout in ms for a full reconnection attempt to reachCONNECTED(default: 30000)shouldReconnect: Callback to control whether reconnection should proceed (see below)logger: Custom logger instance implementingLoggerinterface (default: console)
State and Properties
manager.state; // Current WebRTCState
manager.localStream; // MediaStream | null
manager.remoteStream; // MediaStream | null (first stream — legacy)
manager.remoteStreams; // ReadonlyMap<string, MediaStream> (all remote streams by id)
manager.dataChannels; // ReadonlyMap<string, RTCDataChannel>
manager.peerConnection; // RTCPeerConnection | null
manager.context; // TContext | null - user-defined dataLifecycle Methods
await manager.initialize(); // Initialize peer connection
await manager.connect(); // Transition to CONNECTING state
manager.disconnect(); // Disconnect and cleanup (state unchanged from IDLE / DISCONNECTED / ERROR)
manager.reset(); // Reset to IDLE state (valid from any state)
manager.dispose(); // Full teardown: cleanup + unsubscribe every listenerAudio Methods
await manager.enableMicrophone(true); // Enable/disable microphone
await manager.switchMicrophone(deviceId); // Switch to different audio input
await manager.getAudioInputDevices(); // Get available audio inputsSignaling Methods
const offer = await manager.createOffer();
const answer = await manager.createAnswer();
await manager.setLocalDescription(offer);
await manager.setRemoteDescription(answer);
await manager.addIceCandidate(candidate);
await manager.iceRestart(); // Trigger ICE restart
await manager.gatherIceCandidates(options); // Wait for ICE gathering to completegatherIceCandidates(options?)
Wait for ICE gathering to complete. Useful for HTTP POST signaling patterns where you need all ICE candidates bundled in the local description before sending to the server.
Options:
timeout(ms, default 10000)onCandidate— callback fired for each real candidate as it arrives (the terminalnullsentinel is NOT forwarded; use the promise resolution to detect completion)resolveOnTimeout(boolean, defaultfalse) — whentrue, the promise resolves on timeout instead of rejecting, allowing you to proceed with whatever candidates were gathered so far
const offer = await manager.createOffer();
await manager.setLocalDescription(offer);
await manager.gatherIceCandidates({ timeout: 5000 });
// Now manager.peerConnection.localDescription has all ICE candidates bundledError Handling: A timeout rejection does not transition the FSM to ERROR state. This
is intentional - gatherIceCandidates() is a utility method, and a timeout means
"gathering didn't complete in time", not "the connection failed". The consumer decides how
to handle it:
try {
await manager.gatherIceCandidates({ timeout: 5000 });
} catch (e) {
if (e.message === "ICE gathering timeout") {
// Options:
// 1. Retry with longer timeout
// 2. Proceed anyway - localDescription may have partial candidates
// 3. Treat as fatal: manager.reset()
// 4. Or pass `resolveOnTimeout: true` upfront to skip the reject path
}
}Data Channel Methods
const dc = manager.createDataChannel(label, options);
const dc = manager.getDataChannel(label);
manager.sendData(label, data); // Returns booleanWorking with External Audio Streams
You might want to keep enableMicrophone: false even when your application uses audio.
Common scenarios include:
- Pre-acquired stream: You may have obtained the audio stream earlier in the application flow (e.g., during a permissions check, in a lobby/waiting room, or for local audio preview before joining)
- Custom audio processing: You want to apply audio effects, noise suppression, or other processing via Web Audio API before transmitting
- Multiple sources: You need to mix audio from multiple sources (microphone + system audio, multiple microphones, background music, etc.)
- Fine-grained privacy control: You want explicit control over exactly when the microphone activates
- Testing: You want to inject synthetic audio (e.g., oscillator tones) for automated testing
To use your own audio stream, access the peerConnection directly and add tracks after
initialization:
const manager = new WebRTCManager(factory, {
peerConfig: { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] },
enableMicrophone: false, // We'll handle the audio stream ourselves
});
// Your pre-acquired or processed audio stream
const myAudioStream = await navigator.mediaDevices.getUserMedia({ audio: true });
// Or a processed stream via Web Audio API
const audioCtx = new AudioContext();
const source = audioCtx.createMediaStreamSource(myAudioStream);
const gainNode = audioCtx.createGain();
gainNode.gain.value = 0.8;
source.connect(gainNode);
const destination = audioCtx.createMediaStreamDestination();
gainNode.connect(destination);
const processedStream = destination.stream;
// Initialize the manager
await manager.initialize();
// Add your audio track to the peer connection
const pc = manager.peerConnection;
if (pc) {
processedStream.getAudioTracks().forEach((track) => {
pc.addTrack(track, processedStream);
});
}
// Continue with normal connection flow
await manager.connect();
const offer = await manager.createOffer();
// ...Event Subscription
// Subscribe to specific event
const unsub = manager.on(WebRTCManager.EVENT_STATE_CHANGE, (state) => {
console.log("State changed:", state);
});
// Subscribe to overall state (Svelte store compatible)
const unsub = manager.subscribe((state) => {
console.log("Overall state:", state);
// state = { state, localStream, remoteStream, dataChannels, peerConnection }
});Available Event Constants:
EVENT_STATE_CHANGEEVENT_LOCAL_STREAM— also fires withnullwhen the local stream is torn downEVENT_REMOTE_STREAM— also fires withnullwhen the remote stream is torn downEVENT_DATA_CHANNEL_OPENEVENT_DATA_CHANNEL_MESSAGEEVENT_DATA_CHANNEL_CLOSEEVENT_ICE_CANDIDATEEVENT_ICE_RESTART_OFFER— emitted aftericeRestart()with the new local offer; forward via signalingEVENT_NEGOTIATION_NEEDED— forwarded frompc.onnegotiationneeded(e.g. after a late track or data channel)EVENT_RECONNECTINGEVENT_RECONNECT_FAILEDEVENT_DEVICE_CHANGEDEVENT_MICROPHONE_FAILEDEVENT_ERROR
Controlling Reconnection
When autoReconnect is enabled, you can use the shouldReconnect callback to
conditionally suppress reconnection attempts. This is useful when the remote peer
disconnected intentionally (e.g., left the call) rather than due to network failure.
let peerLeftIntentionally = false;
const manager = new WebRTCManager(factory, {
autoReconnect: true,
shouldReconnect: ({ attempt, maxAttempts, strategy }) => {
if (peerLeftIntentionally) {
return false; // Don't reconnect
}
return true;
},
});
// Listen for "goodbye" message from peer before they disconnect
manager.on(WebRTCManager.EVENT_DATA_CHANNEL_MESSAGE, ({ data }) => {
const msg = JSON.parse(data);
if (msg.type === "bye") {
peerLeftIntentionally = true;
}
});The callback receives:
attempt: Current reconnection attempt (1-based)maxAttempts: Configured maximum attemptsstrategy:"ice-restart"(attempts 1-2) or"full"(attempts 3+)
Reconnection Strategies
The manager uses two reconnection strategies with exponential backoff:
ICE Restart (attempts 1-2): Lightweight reconnection that keeps the existing peer connection and restarts ICE negotiation. Works when the network path changed but the remote peer is still available. The new local offer is emitted via
EVENT_ICE_RESTART_OFFER— forward it through your signaling channel.Full Reconnection (attempts 3+): Creates a completely new peer connection. This is necessary when ICE restart fails, but requires consumer action to complete the signaling handshake.
EVENT_RECONNECTING fires when an attempt actually executes (after its backoff delay),
not when it is scheduled. The backoff itself is observable as
state_change → RECONNECTING.
Handling Full Reconnection
When a full reconnection attempt executes, the manager will:
- Clean up the old peer connection
- Create a new peer connection (fresh
initialize(), stateCONNECTING) - Emit
EVENT_RECONNECTINGwithstrategy: 'full'— only after the new peer connection exists
Important: The manager cannot automatically complete the signaling handshake for full
reconnections. You must listen for the reconnecting event and re-establish signaling
when the strategy is 'full'. Because the event fires once the new connection is in
place, createOffer() inside the handler lands on the right RTCPeerConnection:
manager.on(WebRTCManager.EVENT_RECONNECTING, async ({ attempt, strategy }) => {
console.log(`Reconnecting (attempt ${attempt}, strategy: ${strategy})`);
if (strategy === "full") {
// Re-do the signaling handshake on the new peer connection
const offer = await manager.createOffer();
await manager.setLocalDescription(offer);
signalingChannel.send({ type: "offer", offer });
}
// For 'ice-restart', forward the offer from EVENT_ICE_RESTART_OFFER instead
});Every attempt is bounded. An ICE-restart attempt that doesn't reach CONNECTED within
iceRestartTimeout (default: 10 seconds), or a full attempt within fullReconnectTimeout
(default: 30 seconds), counts as a failed attempt and the next one is scheduled — or
EVENT_RECONNECT_FAILED is emitted once maxReconnectAttempts is exhausted. A failed
attempt never parks the manager in ERROR; only initialize() failing during a full
reconnect does (with an error event), and auto-reconnect does not run from ERROR —
call reset(). Calling connect(), disconnect() or reset() yourself during the
backoff cancels the scheduled attempt.
Examples
Basic Usage (Vanilla JavaScript)
import { WebRTCManager, WebRTCState } from "@marianmeres/webrtc";
// Create factory (browser implementation)
const factory = {
createPeerConnection: (config) => new RTCPeerConnection(config),
getUserMedia: (constraints) => navigator.mediaDevices.getUserMedia(constraints),
enumerateDevices: () => navigator.mediaDevices.enumerateDevices(),
};
// Create manager
const manager = new WebRTCManager(factory, {
peerConfig: {
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
},
enableMicrophone: true,
autoReconnect: true,
});
// Subscribe to events
manager.on(WebRTCManager.EVENT_ICE_CANDIDATE, (candidate) => {
// Send candidate to remote peer via your signaling channel
signalingChannel.send({ type: "candidate", candidate });
});
manager.on(WebRTCManager.EVENT_REMOTE_STREAM, (stream) => {
// Attach remote stream to audio element
audioElement.srcObject = stream;
});
// Initialize and create offer
await manager.initialize();
await manager.connect();
const offer = await manager.createOffer();
await manager.setLocalDescription(offer);
// Send offer to remote peer via your signaling channel
signalingChannel.send({ type: "offer", offer });
// Handle incoming signaling messages
signalingChannel.onmessage = async (msg) => {
if (msg.type === "answer") {
await manager.setRemoteDescription(msg.answer);
} else if (msg.type === "candidate") {
await manager.addIceCandidate(msg.candidate);
}
};Svelte 5 Integration
<script>
import { WebRTCManager, WebRTCState } from '@marianmeres/webrtc';
import { onMount } from 'svelte';
const factory = {
createPeerConnection: (config) => new RTCPeerConnection(config),
getUserMedia: (constraints) => navigator.mediaDevices.getUserMedia(constraints),
enumerateDevices: () => navigator.mediaDevices.enumerateDevices(),
};
const manager = new WebRTCManager(factory, {
peerConfig: {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
},
enableMicrophone: true,
});
// Subscribe to overall state (Svelte store compatible!)
const managerState = $derived(manager.subscribe((state) => state));
// Or use individual event subscriptions
let devices = $state([]);
onMount(() => {
const unsubDevices = manager.on(
WebRTCManager.EVENT_DEVICE_CHANGED,
(devs) => devices = devs
);
return () => {
unsubDevices();
manager.disconnect();
};
});
async function startCall() {
await manager.initialize();
await manager.connect();
const offer = await manager.createOffer();
await manager.setLocalDescription(offer);
// Send offer via your signaling channel
}
async function switchMic(deviceId) {
await manager.switchMicrophone(deviceId);
}
</script>
<div>
<p>State: {$managerState.state}</p>
<p>Microphone: {$managerState.localStream ? 'Enabled' : 'Disabled'}</p>
<button onclick={startCall}>Start Call</button>
<select onchange={(e) => switchMic(e.target.value)}>
{#each devices as device}
<option value={device.deviceId}>{device.label}</option>
{/each}
</select>
<audio bind:this={remoteAudio} autoplay></audio>
</div>Complete Peer-to-Peer Example
import { WebRTCManager } from "@marianmores/webrtc";
class P2PConnection {
manager: WebRTCManager;
signalingChannel: WebSocket;
constructor(signalingUrl: string) {
this.manager = new WebRTCManager(
{
createPeerConnection: (config) => new RTCPeerConnection(config),
getUserMedia: (constraints) =>
navigator.mediaDevices.getUserMedia(constraints),
enumerateDevices: () => navigator.mediaDevices.enumerateDevices(),
},
{
peerConfig: {
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
},
enableMicrophone: true,
dataChannelLabel: "chat",
autoReconnect: true,
},
);
this.signalingChannel = new WebSocket(signalingUrl);
this.setupSignaling();
this.setupManagerEvents();
}
setupSignaling() {
this.signalingChannel.onmessage = async (event) => {
const msg = JSON.parse(event.data);
switch (msg.type) {
case "offer":
await this.handleOffer(msg.offer);
break;
case "answer":
await this.manager.setRemoteDescription(msg.answer);
break;
case "candidate":
await this.manager.addIceCandidate(msg.candidate);
break;
}
};
}
setupManagerEvents() {
// Send ICE candidates to remote peer
this.manager.on(WebRTCManager.EVENT_ICE_CANDIDATE, (candidate) => {
this.signalingChannel.send(JSON.stringify({
type: "candidate",
candidate,
}));
});
// Handle remote audio stream
this.manager.on(WebRTCManager.EVENT_REMOTE_STREAM, (stream) => {
const audio = document.getElementById("remote-audio") as HTMLAudioElement;
audio.srcObject = stream;
});
// Handle data channel messages
this.manager.on(WebRTCManager.EVENT_DATA_CHANNEL_MESSAGE, ({ data }) => {
console.log("Received message:", data);
});
// Handle reconnection
this.manager.on(WebRTCManager.EVENT_RECONNECTING, ({ attempt, strategy }) => {
console.log(`Reconnecting (attempt ${attempt}, strategy: ${strategy})`);
if (strategy === "full") {
// For full reconnection, we need to re-do the signaling handshake
this.createOffer();
}
});
}
async createOffer() {
await this.manager.initialize();
await this.manager.connect();
const offer = await this.manager.createOffer();
await this.manager.setLocalDescription(offer);
this.signalingChannel.send(JSON.stringify({
type: "offer",
offer,
}));
}
async handleOffer(offer: RTCSessionDescriptionInit) {
await this.manager.initialize();
await this.manager.setRemoteDescription(offer);
const answer = await this.manager.createAnswer();
await this.manager.setLocalDescription(answer);
this.signalingChannel.send(JSON.stringify({
type: "answer",
answer,
}));
}
sendMessage(text: string) {
this.manager.sendData("chat", text);
}
disconnect() {
this.manager.disconnect();
this.signalingChannel.close();
}
}
// Usage
const connection = new P2PConnection("wss://your-signaling-server.com");
await connection.createOffer();
connection.sendMessage("Hello!");API Reference
For complete API documentation, see API.md.
State Machine
The manager uses a finite state machine with the following states:

How RTCPeerConnection.connectionState maps onto it: "connected" → CONNECTED;
"failed" and "closed" → DISCONNECTED (and auto-reconnect starts when enabled);
"disconnected" → no transition. Browsers report "disconnected" for transient ICE
hiccups (a Wi-Fi switch, sleep/wake) and usually recover to "connected" on their own, so
the manager only logs a warning and waits. A genuine loss surfaces as "failed" once ICE
consent expires (typically tens of seconds).
Testing
The project includes two types of tests:
Unit Tests (Deno)
Mock-based tests for the manager's logic and state transitions:
deno task testInteractive Example
The example/ directory contains a working demo of two peers communicating via WebRTC
data channels and audio streams:
# Build the example bundle
deno task example:build
# Serve the example directory
cd example && deno run -A jsr:@std/http/file-serverThen open http://localhost:8000/ in your browser.
Structure:
index.html- Parent page with two side-by-side iframes, acts as signaling relay viapostMessagepeer1.html- The "offerer" peer (click "Connect" to initiate)peer2.html- The "answerer" peer (waits for connection)peer.js- Shared utilities (factory, beep generation, logging)
This example demonstrates:
- P2P connection establishment without a signaling server (uses
postMessagebetween iframes) - SDP offer/answer exchange with bidirectional audio setup
- ICE candidate exchange
- Data channel creation and message passing
- Audio streaming via WebRTC media tracks (click "Send Beep" to transmit generated audio)
- State change monitoring
Changes in 2.2
Bug-fix release; nothing is removed. Audit the following if you relied on the old behavior:
Invalid FSM events are no-ops, never throws.
disconnect()fromIDLE/DISCONNECTED/ERROR, or a failing signaling call while already inERROR, previously threwInvalid transition …from the underlying FSM — masking the real error and breaking the documentedfalse/nullreturn contract. They are now ignored (debug log only).maxReconnectAttemptsis honored. Once the full strategy kicked in (attempt 3+), the internalconnect()call reset the attempt counter, so the sequence looped1, 2, 3, 1, 2, 3, …forever andreconnect_failednever fired. The counter now survives internal reconnects; an explicitconnect()/disconnect()/reset()/dispose()still resets it.Transient
"disconnected"no longer moves the FSM toDISCONNECTED(see the State Machine note above). Previously the FSM parked inDISCONNECTEDnext to a live connection once the browser recovered, and aconnect()from there tore the healthy connection down. A UI that showed "disconnected" on that transition now sees it only on"failed".reconnectingfires when the attempt executes, not when it is scheduled. Forstrategy: 'full'that is after the new peer connection exists, socreateOffer()in the handler is safe. Previously it ran against the old, failed connection and the remote answer then failed on the new one.ICE-restart attempts are bounded by the new
iceRestartTimeoutconfig (default 10 s). Previously a restart whose offer was never answered kept the manager inRECONNECTINGindefinitely.A failed reconnect attempt no longer parks the FSM in
ERROR; it counts as a failed attempt and the next strategy runs. Auto-reconnect does not run fromERROR(callreset()).enableMicrophone(true)beforeinitialize()now attaches that stream to the peer connection. Previously the stream was kept but never sent — no audio track and no audio transceiver.on()is typed per event:on<K extends keyof WebRTCEvents>(event: K, handler: (data: WebRTCEvents[K]) => void). Handlers typed(data: any)keep compiling; a handler with a wrong payload type is now a compile error.
Upgrading from 1.x to 2.x
Version 2.0 is a bug-fix-driven major release. Most changes are internal corrections that make the library behave the way the docs already said it did. There is exactly one API-level breaking change, plus a handful of observable behavior changes you should audit.
Breaking: gatherIceCandidates — onCandidate no longer receives the terminal null
In 1.x, the onCandidate callback was invoked once with null to signal
end-of-gathering. In 2.x, only real ICE candidates are forwarded. End-of-gathering is
signaled exclusively by the returned promise resolving.
// 1.x
await manager.gatherIceCandidates({
onCandidate: (c) => {
if (c === null) onGatheringComplete();
else collectedCandidates.push(c);
},
});
// 2.x
await manager.gatherIceCandidates({
onCandidate: (c) => collectedCandidates.push(c),
});
onGatheringComplete(); // promise resolution = end of gatheringIf you weren't using onCandidate, nothing changes.
Behavior changes (no API change, but worth auditing)
Stream events now fire with
nullon teardown.local_streamandremote_streamsubscribers will now receivenullwhendisconnect()ordispose()runs. If your handler assumed the payload was always aMediaStream, add a null check. This matches howenableMicrophone(false)already behaved and fixes stale<audio srcObject>references in UIs.reset()now works from every state. In 1.x, callingreset()fromINITIALIZING,CONNECTING, orCONNECTEDsilently did nothing (the FSM refused the transition). In 2.x, it always lands inIDLE. If you relied onreset()being a no-op from those states, wrap the call in a state guard.ICE-restart reconnects now actually transition to
CONNECTED. In 1.x, a successful ICE-restart reconnect left the FSM stuck inRECONNECTINGbecause the necessary transition was missing. In 2.x,RECONNECTING → CONNECTEDis valid and the state machine tracks reality. Code that polled forstate === "RECONNECTING"as a proxy for "still in flux" may now seeCONNECTEDsooner.#reconnectAttemptsresets on every explicitconnect()/disconnect()/reset()/dispose(). In 1.x, once the reconnect budget was exhausted, a subsequent user-initiatedconnect()inherited the exhausted counter and would skip all further reconnect attempts on the new session. In 2.x, the counter resets so the next session gets a fresh budget.iceRestart()now emitsice_restart_offer. A real ICE restart requires the new local offer to reach the remote peer via signaling. In 1.x, the library set the local description and returned — the offer never left the process, so ICE restarts silently failed unless the remote side also initiated. In 2.x, subscribe toice_restart_offerand forward the offer through your signaling channel:manager.on("ice_restart_offer", (offer) => { signalingChannel.send({ type: "offer", offer }); });switchMicrophone()now promotesrecvonly/inactivetransceivers tosendrecv. Previously, callingswitchMicrophone()on a connection that was originally set up with the mic disabled would replace the track but leave the transceiver direction asrecvonly, silently producing no audio. 2.x promotes the direction so the new track actually transmits.
New, additive APIs (no code changes required)
audioDirectionconfig — set to"sendrecv"if you plan to enable the mic mid-session and want to avoid renegotiation.remoteStreamsgetter —ReadonlyMap<string, MediaStream>of all remote streams, keyed by stream id. The legacy single-streamremoteStreamgetter still works and points at the first stream received.dispose()method — one-shot teardown that unsubscribes every listener registered viaon()orsubscribe()and cleans up the peer connection. Use in framework teardown hooks instead of tracking each returned unsubscribe handle.gatherIceCandidates({ resolveOnTimeout: true })— resolves the promise on timeout instead of rejecting.negotiation_neededevent — forwarded frompc.onnegotiationneeded. Listen for it when you create data channels or add tracks after the initial handshake.
License
MIT
