Skip to content

colocohen/stable-webrtc

Repository files navigation

stable-webrtc

StableWebRTC

Production-grade WebRTC for Node.js & Browsers

WebRTC connections that survive the real world — glare, network chaos, oversized SDPs, and all.

npm version license


The Problem

A WebRTC demo connects two browsers in 20 lines. A WebRTC product fights these battles daily:

Issue What happens How often
Glare Both peers send an offer at the same time. The native API deadlocks. Every call with camera + screenshare
Signal reordering An ICE candidate arrives before the offer it belongs to. Connection fails silently. Any non-WebSocket transport
Renegotiation storms User switches camera while a previous negotiation is still in-flight. SDP state corrupts. Camera/mic toggle, screenshare start/stop
SDP bloat 4KB+ of SDP text fragments across packets, slowing or breaking the handshake. Simulcast, multiple codecs, many transceivers
Network roaming User walks from Wi-Fi to LTE. ICE candidates expire. Call drops. Mobile, every day
No disconnect detection The native API gives you iceConnectionState and nothing else. No events for "reconnecting" or "recovered". Building a reconnection UI requires polling and guesswork. Every app with a status indicator
No stream telemetry Want to show bitrate, packet loss, or resolution per stream? You have to poll getStats(), parse 30+ report types, compute deltas yourself, and track which stats belong to which stream by MID. Every app with quality indicators
DataChannel backpressure Burst of messages fills the buffer. App freezes. No error. No warning. File transfer, game state sync
Tab backgrounding Mobile OS suspends the tab. Timers stop. State goes stale. Peer thinks you left. Mobile browsers

stable-webrtc handles all of this internally. You get a clean API — the library handles the rest.


Quick Start

var peer1 = new StableWebRTC();
var peer2 = new StableWebRTC();

// Relay signaling (in production, use WebSocket / HTTP / MQTT / anything)
peer1.on('signal', data => peer2.signal(data));
peer2.on('signal', data => peer1.signal(data));

// Connected — send data
peer1.on('connect', () => peer1.send('Hello from peer1!'));
peer2.on('data', msg => console.log('Received:', msg.toString()));

That's it. No role assignment, no ICE configuration, no state machine — it just works.


What the Native API Gets Wrong

The browser's WebRTC API (RTCPeerConnection) is a low-level protocol binding, not an application API. Here's what stable-webrtc fixes:

Glare is your problem

The native API gives you onnegotiationneeded and expects you to handle the case where both peers send an offer simultaneously. The MDN "perfect negotiation" pattern is 40 lines that doesn't cover all edge cases. stable-webrtc uses a 6-state machine with epoch-rotating politeness — one side always yields, and who yields alternates to prevent starvation.

Rollback is broken across browsers

setLocalDescription({type:'rollback'}) behaves differently between Chrome and Firefox. Chrome can corrupt BUNDLE group state during implicit rollback. stable-webrtc always uses explicit rollback with a serialized callback queue, ensuring only one rollback is ever in-flight and working around browser-specific bugs.

ICE candidates arrive out of order

If addIceCandidate() is called before setRemoteDescription(), it silently fails. stable-webrtc queues candidates per-ufrag, sorts by priority, deduplicates, and drains them after the remote description is applied.

Transceivers fight each other

If both peers create transceivers simultaneously, m-line ordering in the SDP conflicts. stable-webrtc ensures only the offering side creates transceivers (right before createOffer()), recycles inactive ones instead of stopping them, and automatically cleans up orphaned transceivers after 1.5 seconds.

No disconnect/reconnect lifecycle

The native API gives you raw ICE state strings. Building a "reconnecting..." UI requires polling iceConnectionState, debouncing transitions, and guessing. stable-webrtc gives you disconnect and reconnect events with zero configuration.

No per-stream telemetry

Getting bitrate for a specific video stream requires polling getStats(), iterating 30+ report types, matching by MID, computing byte deltas over time, and correlating inbound/outbound reports. stable-webrtc does this internally and gives you streamstats events with { bitrate, fps, packetLoss, jitter, codec } per tagged stream.

SDP is absurdly large

A simple DataChannel-only offer is 2–3KB of text. Adding video makes it 4KB+. stable-webrtc compresses the initial offer to ~80 bytes and renegotiation diffs to ~50 bytes using four competing compression strategies. ICE candidates are stripped from SDPs and sent separately as ~20-byte binary packets.

No signaling abstraction

The native API forces you to manually relay offers, answers, and candidates through your own transport. stable-webrtc takes binary Uint8Arrays — pipe them through anything (WebSocket, HTTP, MQTT, SMS). After the DataChannel opens, signaling automatically routes through the peer-to-peer connection itself.

Host candidates: connectivity vs. privacy, your choice

By default, host candidates (containing local addresses like 192.168.x.x) are sent — they're what lets two peers on the same machine or LAN find a direct local path, so dropping them silently breaks same-network connections. If you'd rather not expose internal network topology through your signaling relay, set exclude_host_candidates: true and only server-reflexive and relay candidates are transmitted. You choose the tradeoff explicitly instead of having connectivity quietly removed.

No end-of-candidates signal

The native API fires onicecandidate with null when gathering completes, but there's no built-in way to tell the remote peer "I'm done sending candidates." The remote side has to guess using timeouts. stable-webrtc sends an explicit candidate count per ufrag, so the receiver knows exactly when all candidates have arrived and signals the ICE agent immediately.

Certificate generation blocks each connection

Every new RTCPeerConnection() generates a fresh DTLS certificate if you don't provide one. This takes 50–200ms and blocks the constructor. If you're creating multiple connections (mesh network, reconnections), this adds up. stable-webrtc pre-generates a shared ECDSA P-256 certificate once and reuses it across all instances, with automatic renewal on expiry.

Signaling has no integrity protection

The native API trusts whatever you feed to setRemoteDescription and addIceCandidate. If your signaling transport corrupts a byte (UDP packet damage, WebSocket frame issue), the connection silently fails with an opaque error. stable-webrtc wraps every signaling message in a MurmurHash3 checksum — corrupt messages are dropped before they can poison the state machine. Nonce verification prevents stale or injected signals from being processed.


Install

Node.js / bundlers (ESM)

npm install stable-webrtc
import StableWebRTC from 'stable-webrtc';

Browser (prebuilt bundle)

<script src="dist/stable-webrtc.browser.js"></script>
<!-- exposes window.StableWebRTC -->

The package ships as ES modules (src/ split into focused modules; index.js is the entry). A prebuilt IIFE bundle for direct <script> use is produced by npm run build:browser.

Node.js requires a WebRTC binding:

import wrtc from '@roamhq/wrtc';
const peer = new StableWebRTC({ wrtc: wrtc });

Dependencies: two tiny zero-dependency libraries — compact-delta (SDP delta encoding) and litepack (binary wire schemas).


How It Works

Nonce-Based Role Resolution

Both peers embed a random nonce in every signaling message. The peer with the higher nonce creates the DataChannel and sends the first offer. The other peer waits. No configuration needed — roles are locked before any SDP is generated, eliminating DataChannel glare and m-line ordering conflicts at the source.

To further reduce glare during startup, each peer waits a random 8–63ms before creating its DataChannel. This staggering ensures that in most cases, only one peer initiates before the nonce exchange resolves who should lead.

Serialized Negotiation

All negotiations pass through a 6-state machine:

STABLE (0) → MAKING_OFFER (1) → WAITING_FOR_ANSWER (2) → APPLYING_ANSWER (3) → STABLE
                                                                                    ↑
STABLE (0) → HANDLING_REMOTE_OFFER (4) → WAITING_FOR_DONE (5) ─────────────────────┘

Only one negotiation can be in-flight at a time. If a new track is added while a negotiation is active, it queues and coalesces. Glare is resolved via epoch-rotating politeness — each successful negotiation flips who yields, preventing starvation.

Adaptive answer timeout: When waiting for an answer, the timeout starts at 7 seconds but adjusts based on historical round-trip times. If previous answers took 5 seconds, the timeout extends to match. If no answer arrives in time, the library rolls back and retries automatically.

Serialized rollback: Only one rollback can be in-flight at a time. If multiple code paths request a rollback simultaneously (e.g., glare + timeout), they queue and get notified in order when the rollback completes. This prevents Chrome's BUNDLE state corruption that can occur during concurrent rollback attempts.

SDP Compression Engine

Every offer and answer is compressed through four competing strategies. The smallest payload wins:

Strategy Best for Typical saving
Compact First offer (DataChannel only) 70–80% — binary encoding of SDP fields
Diff Renegotiations 90–97% — only the changed lines
Deflate Large SDPs (simulcast, many codecs) 50–60%
Diff + Deflate Moderate changes on large SDPs 85–95%

Each payload includes a MurmurHash3 checksum. If decompression fails (hash mismatch), the receiver automatically requests a raw retransmit — no manual intervention needed.

Why this matters: A typical renegotiation (adding a video track) produces a 3KB SDP. With diff compression, the signaling message is 50–100 bytes — small enough for a single UDP packet, an MQTT message, or even an SMS.

Trickle-Only ICE

ICE candidates are always stripped from the SDP before setLocalDescription. They're sent separately using binary compression (see below). This design choice enables the SDP compression engine to work effectively — embedded candidates add ~1KB of unpredictable text that defeats diff compression.

Signaling via DataChannel

Once the DataChannel is open, signaling messages (offers, answers, candidates) automatically route through it instead of your external transport. This cuts renegotiation latency to the raw RTT of the peer-to-peer connection. If the DataChannel drops, routing falls back to external signaling transparently.

Signal Integrity

Every signaling message is wrapped with a MurmurHash3 checksum and the sender's nonce. On receipt:

  • Checksum mismatch → message is silently dropped (corrupt or truncated in transit).
  • Nonce mismatch → message is silently dropped (prevents signal injection from a third party or a stale connection).

This protects against unreliable transports (UDP, lossy WebSocket) and ensures only the intended peer's messages are processed.

ICE Candidate Compression

Each ICE candidate is encoded from ~150 bytes of text into ~20–30 bytes of binary using bit-packed fields (transport, type, priority, IP, port, related address). Candidates are deduplicated by ufrag, sorted by priority, and trickled in optimal order.

Host Candidate Filtering (optional)

By default all candidate types — including host candidates (local addresses like 192.168.x.x) — are sent, because host candidates are what let two peers on the same machine or LAN establish a direct local path. Setting exclude_host_candidates: true transmits only server-reflexive (srflx), peer-reflexive (prflx), and relay candidates, preventing local-IP exposure through the signaling channel at the cost of same-network direct connectivity. The default favors connectivity; the option lets privacy-sensitive deployments opt out.

Signaling Chunking

The library's own signaling (offers, answers, candidates, stream maps) is split into chunks when a message exceeds the per-pipe size limit, then reassembled transparently on the other side — so oversized SDPs (simulcast, many codecs) never exceed a transport's message-size cap. This applies to both pipes: the external signal transport and the internal DataChannel (SCTP). The limit is max_signal_chunk_size (default 1 KB); the internal pipe uses min(max_signal_chunk_size, sctp.maxMessageSize). Reassembly is all-or-nothing with a lazy timeout — on the library's unreliable/unordered DataChannel a lost chunk just fails reassembly and the message-level logic re-sends. Application data (peer.send) is never chunked — that remains the developer's responsibility. Small messages travel with a single byte of overhead, so the common case pays almost nothing.

Reassembly is bounded by defensive limits so a malformed or malicious stream can't exhaust memory: each chunk reserves 16 bytes for its header (so the actual payload per chunk is max_signal_chunk_size − 16); partial reassemblies are dropped after 5 s (CHUNK_REASSEMBLY_TIMEOUT); at most 16 partial messages may be in flight at once (CHUNK_MAX_OPEN); and any message claiming more than 65536 chunks (CHUNK_MAX_TOTAL) is rejected outright. These are internal constants — max_signal_chunk_size is the only one you configure.

Reliable Stream Maps

The MEDIASTREAM_MAP (which tells the remote side how MIDs map to tagged streams) is delivered with an ACK + retransmit scheme, since it travels over the unreliable DataChannel. The receiver ACKs every copy (including duplicates); the sender retransmits with backoff until acknowledged, so a dropped map can't leave the two peers with mismatched stream associations.

End-of-Candidates Signaling

When ICE gathering completes, the library sends the total candidate count per ufrag to the remote peer. The receiver tracks how many candidates it has processed and calls addIceCandidate(null) when all have arrived. This signals the browser's ICE agent that gathering is complete, enabling faster candidate pair selection instead of waiting for the default gathering timeout.

Automatic ICE Restart

The library drives ICE-restart decisions from a single source of truth — iceConnectionState (the correct signal for ICE-level recovery):

  • disconnected → starts a configurable timer (default 3s). If the connection doesn't recover, triggers ICE restart.
  • failed → immediate ICE restart.
  • connected → clears timer, resets retry counter.

Up to 5 retries with backoff. No manual intervention needed — network roaming (Wi-Fi → LTE) is handled transparently. The disconnect and reconnect events let your UI react immediately.

Transceiver Recycling & Cleanup

When a track is removed, its transceiver is set to inactive (not stopped). The m-line stays in the SDP at its original position, preserving ordering. When a new track of the same kind is added later, the inactive transceiver is reused — no new m-line, no renegotiation overhead. Transceivers are created only by the offering side, right before createOffer(), preventing index conflicts during glare.

Automatic cleanup: Transceivers that are no longer mapped to any logical stream are cleaned up after 1.5 seconds. This prevents dead m-lines from accumulating in the SDP over long sessions with many add/remove cycles.

Tagged Stream Mapping

Each stream is identified by a user-defined tag (e.g., 'camera', 'screen'). The library sends a sequenced MEDIASTREAM_MAP message that maps tag IDs to SDP MIDs. The receiving peer uses this map to associate incoming transceivers with logical streams — so it knows that MID 1 is camera video and MID 2 is camera audio. The sequence number prevents out-of-order map updates from corrupting the mapping.

DataChannel Deduplication

If both peers create a DataChannel before the nonce exchange resolves roles (a race condition during startup), multiple DataChannels may end up open. The library detects this and picks a winner — the channel with the lowest SID (SCTP stream identifier). All other channels are closed immediately. This prevents duplicate message delivery and m-line ordering conflicts.

Shared DTLS Certificate

The library pre-generates a single ECDSA P-256 certificate and shares it across all StableWebRTC instances on the same page. This eliminates the ~50–200ms certificate generation latency that normally blocks each new RTCPeerConnection. The certificate is cached until expiry and regenerated automatically.

DataChannel Backpressure

Bounded queues with dual quotas (messages and bytes), watermark-based pacing, and drain notifications keep DataChannels responsive under load. Configurable limits:

connection.data_channel_max_sending_messages_per_sec  // default: 1000
connection.data_channel_max_sending_bytes_per_sec     // default: 64KB
connection.data_channel_min_buffered_amount           // default: 64KB
connection.data_channel_max_buffered_amount           // default: 1MB

Network Profiling

The library characterizes the user's own network (independent of any specific peer) by analyzing local ICE candidates against multiple STUN servers, and emits a networkprofile event once the data is available (and again only if it changes):

peer.on('networkprofile', (p) => {
  // p.public_ipv4       — your public IPv4 (from STUN), or null
  // p.public_ipv6       — your public IPv6, or null
  // p.all_public_ipv4   — array (multiple interfaces)
  // p.local_ipv4        — local host IPv4, or null (mDNS-hidden)
  // p.symmetric_nat     — true = problematic, false = fine, null = couldn't tell
  // p.supports_udp      — is UDP usable at all
  // p.supports_tcp
  // p.needs_relay       — derived hint: a TURN relay is likely required
});

symmetric_nat is the key signal: a symmetric NAT maps the same local socket to a different external address per destination, so direct P2P generally fails and a TURN relay is needed. It's detected by comparing the reflexive mappings reported by several STUN servers — a heuristic hint, not a guarantee (STUN-based NAT typing is inherently unreliable; treat null as "unknown" rather than "fine"). Useful for deciding whether to allocate TURN up front, warning the user, or choosing iceTransportPolicy: 'relay'.

Note: there is no remote NAT type — it can't be measured from your side. Once connected, connectioninfo.remote.candidateType tells you how the path to the peer was actually established (host / srflx / relay), which is the operative fact.

Connection Observability

Full connection telemetry available via peer.getConnectionInfo():

var info = peer.getConnectionInfo();
// info.type             — 'direct-udp', 'direct-tcp', 'relayed', 'unknown'
// info.rtt              — round-trip time (ms)
// info.bandwidth_outgoing — estimated outgoing bandwidth (bits/sec)
// info.local.ip         — local IP address
// info.local.port       — local port
// info.local.protocol   — 'udp' or 'tcp'
// info.local.relay      — true if using TURN
// info.local.candidateType — 'host', 'srflx', 'prflx', 'relay'
// info.remote.*         — same fields for remote side

// For network-level characterization (NAT, public IP, UDP support),
// listen to the `networkprofile` event — see "Network Profiling" above.

Stream Telemetry

Per-stream stats are computed automatically from getStats() — no manual polling or delta computation needed:

var streams = peer.getStreams();
// streams['camera'].sending.video.bitrate      — bits/sec
// streams['camera'].sending.video.fps          — frames per second
// streams['camera'].sending.video.width        — frame width
// streams['camera'].sending.video.height       — frame height
// streams['camera'].sending.video.codec        — e.g. 'video/VP8'
// streams['camera'].sending.video.active       — true if actively sending

// Receiving side includes quality metrics:
// streams['camera'].receiving.video.packetLoss — percentage (0–100)
// streams['camera'].receiving.video.jitter     — seconds
// streams['camera'].receiving.audio.bitrate    — bits/sec

// Use the streamstats event for reactive updates:
peer.on('streamstats', (tagId, direction, stats) => {
  updateQualityUI(tagId, stats.video.bitrate, stats.video.packetLoss);
});

API Reference

Constructor

var peer = new StableWebRTC(options);
Option Type Default Description
iceServers Array Google + Twilio STUN ICE server configuration
wrtc Object Node.js only: WebRTC bindings (@roamhq/wrtc)
exclude_host_candidates Boolean false If true, host candidates aren't sent (privacy; breaks same-LAN direct paths)
max_signal_chunk_size Number 1024 Max bytes per signaling message before chunking (both pipes)
gatheringTimeout Number ms before a stuck ICE-gathering triggers a retry
gatheringMaxRetries Number Max gathering-stuck retries before giving up
certificates Array shared ECDSA P-256 Custom DTLS certificate(s)
iceCandidatePoolSize Number Pre-gathered candidate pool size
portRange Object Restrict local port range (where supported)
bundlePolicy String 'balanced' BUNDLE policy ('balanced', 'max-bundle', 'max-compat')
rtcpMuxPolicy String 'require' RTCP mux policy

Properties

Property Type Description
peer.connected Boolean true when DataChannel is open and ready
peer.connection Object Internal connection state (for advanced inspection)

Methods

Method Description
peer.signal(data) Feed signaling data received from the remote peer
peer.send(data) Send data over DataChannel. Accepts String, Uint8Array, or ArrayBuffer
peer.write(data) Alias of send
peer.stream(tagId, options) Add/replace/remove a tagged media stream. options: { video_track, audio_track }
peer.addStream(stream, options?) Add a MediaStream (auto-extracts tracks)
peer.removeStream(stream) Remove a MediaStream
peer.addTrack(track, stream, options?) Add a single MediaStreamTrack associated with a stream
peer.removeTrack(track, stream) Remove a single track
peer.restartIce() Manually trigger an ICE restart
peer.setConfiguration(config) Update the underlying RTCPeerConnection configuration
peer.getConnectionInfo() Returns connection telemetry (type, RTT, bandwidth, endpoints)
peer.getStreams(direction?) Returns all streams with stats. Optional filter: 'sending' or 'receiving'
peer.on(event, fn) Subscribe to an event
peer.off(event, fn) Unsubscribe from an event (same reference required)
peer.close() / peer.destroy() Close and clean up the connection

Events

Event Payload Description
signal data (Uint8Array) Signaling data to relay to the remote peer
connect DataChannel is open, connection is ready
data data (Uint8Array) Incoming DataChannel message
stream mediaStream, info Remote media received. info: { tag_id, video_track, audio_track, video_mid, audio_mid }
statechange snapshot Any connection state changed. Snapshot includes negotiation_state, signaling_state, data_channel_state, ice_connection_state, connection_state, ice_gathering_state, sctp_state, sctp_dtls_state, connection_type, rtt, bandwidth_outgoing, need_ice_restart, ice_restart_count, epoch
connectioninfo info Connection type or candidate pair changed. Same format as getConnectionInfo()
networkprofile profile Local network characterized. { public_ipv4, public_ipv6, all_public_ipv4, all_public_ipv6, local_ipv4, symmetric_nat, supports_udp, supports_tcp, needs_relay }
streamstats tagId, direction, stats Per-stream stats updated. direction: 'sending' or 'receiving'. stats: { video: { active, width, height, fps, codec, bitrate, packetLoss, jitter }, audio: { active, codec, bitrate, packetLoss, jitter } }
disconnect { reason, restartCount } ICE connection lost. reason: 'disconnected' or 'failed'. Automatic ICE restart begins.
reconnect ICE connection recovered after a disconnect
fingerprints localFP, remoteFP DTLS fingerprints available for identity verification
close Connection closed
error err Error occurred
log message Internal diagnostic message

Usage Examples

Video Call

var peer = new StableWebRTC();

// Signaling — relay via your server
peer.on('signal', data => ws.send(data));
ws.onmessage = msg => peer.signal(msg.data);

// Send camera
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
  .then(stream => peer.addStream(stream));

// Receive remote video
peer.on('stream', (mediaStream, info) => {
  document.querySelector('video').srcObject = mediaStream;
});

Tagged Streams (camera + screenshare)

// Camera — tagged as 'camera'
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
  .then(stream => {
    peer.stream('camera', {
      video_track: stream.getVideoTracks()[0],
      audio_track: stream.getAudioTracks()[0]
    });
  });

// Screen share — tagged as 'screen'
navigator.mediaDevices.getDisplayMedia({ video: true })
  .then(stream => {
    peer.stream('screen', { video_track: stream.getVideoTracks()[0] });
  });

// Stop screen share
peer.stream('screen', { video_track: null });

// On the receiving side:
peer.on('stream', (mediaStream, info) => {
  if (info.tag_id === 'camera') {
    document.getElementById('camera-video').srcObject = mediaStream;
  } else if (info.tag_id === 'screen') {
    document.getElementById('screen-video').srcObject = mediaStream;
  }
});

Reactive Connection Status UI

var peer = new StableWebRTC();

// Connection lifecycle — no polling needed
peer.on('connect', () => {
  showStatus('connected');
});

peer.on('disconnect', (info) => {
  showStatus('reconnecting (' + info.reason + ')...');
});

peer.on('reconnect', () => {
  showStatus('connected');
});

peer.on('close', () => {
  showStatus('disconnected');
});

// Connection quality — reactive updates
peer.on('connectioninfo', (info) => {
  showConnectionType(info.type);     // 'direct-udp', 'relayed', etc.
  showLatency(info.rtt);             // 2.1 ms
});

// Per-stream quality — reactive updates
peer.on('streamstats', (tagId, direction, stats) => {
  if (direction === 'receiving') {
    showBitrate(tagId, stats.video.bitrate);
    showPacketLoss(tagId, stats.video.packetLoss);
    if (stats.video.packetLoss > 5) {
      showQualityWarning(tagId);
    }
  }
});

// State machine visibility (for debugging)
peer.on('statechange', (snap) => {
  console.log('negotiation:', snap.negotiation_state,
              'ice:', snap.ice_connection_state,
              'dc:', snap.data_channel_state);
});

Event Cleanup (React, Vue, etc.)

function setupPeer(peer) {
  var onStream = (ms, info) => { /* ... */ };
  var onStats = (tag, dir, stats) => { /* ... */ };

  peer.on('stream', onStream);
  peer.on('streamstats', onStats);

  // Cleanup — removes the specific listener
  return function cleanup() {
    peer.off('stream', onStream);
    peer.off('streamstats', onStats);
    peer.close();
  };
}

Mesh Network (N peers)

var peers = {};

function connectTo(remoteId) {
  var peer = new StableWebRTC();
  peers[remoteId] = peer;

  peer.on('signal', data => {
    server.send({ to: remoteId, signal: data });
  });

  peer.on('connect', () => {
    peer.send('Hello from ' + myId);
  });

  peer.on('data', msg => {
    console.log(remoteId + ' says:', msg.toString());
  });

  return peer;
}

// When receiving signaling from the server:
server.on('signal', (fromId, data) => {
  if (!peers[fromId]) connectTo(fromId);
  peers[fromId].signal(data);
});

Security

Every WebRTC connection creates a short-lived DTLS certificate. The fingerprints event exposes both peers' certificate fingerprints, enabling end-to-end identity verification — even if the signaling server is compromised.

peer.on('fingerprints', (localFP, remoteFP) => {
  // Build a transcript binding this session to both fingerprints
  var transcript = makeTranscriptHash(localFP, remoteFP);

  // Sign with your application's identity key
  var signature = sign(transcript, myPrivateKey);

  // Send proof to the remote peer via your signaling channel
  sendProof(signature);
});

// When receiving proof from the remote peer:
onReceiveProof((signature, senderPublicKey) => {
  // Rebuild the transcript from the remote peer's perspective (reversed)
  var transcript = makeTranscriptHash(myRemoteFP, myLocalFP);

  if (!verify(transcript, signature, senderPublicKey)) {
    throw new Error('MITM detected — fingerprint mismatch');
  }

  // Connection is cryptographically verified
  markAsVerified();
});

This is optional — connections work without it. Recommended for messaging, payments, healthcare, and enterprise applications.


Troubleshooting & FAQ

"Who is the initiator?" You don't choose. Roles are assigned automatically via nonce comparison at startup.

"What if both peers add video at the same time?" Glare is resolved deterministically. One peer yields (rolls back), the other's offer goes through. The yielding peer re-sends its tracks in the next negotiation cycle. No deadlocks, no dropped tracks.

"What signaling transport should I use?" Anything. WebSocket, HTTP long-polling, MQTT, Server-Sent Events, UDP, even SMS. The library handles reordering, deduplication, and compression. Signaling messages are binary Uint8Arrays — relay them as-is.

"How do I know the connection quality?" Use the connectioninfo event for connection-level info (type, RTT, bandwidth) and streamstats for per-stream metrics (bitrate, packet loss, jitter). No polling needed — both are event-driven.

"How do I show a reconnecting UI?" Listen to disconnect and reconnect events. The library handles ICE restart automatically — you just update the UI.

"Can I use this with an SFU?" The library is designed for peer-to-peer connections. For SFU integration (Janus, mediasoup, etc.), you'd use the signaling layer directly but not the media negotiation, since the SFU controls the offer/answer flow.

"What about Firefox / Safari?" The library targets Unified Plan (the standard). Chrome, Firefox, and Safari all support it. Edge cases around rollback behavior differ slightly between browsers — the library accounts for this.


Support

Please ⭐ star the repo to follow progress!

Stable-WebRTC is an evenings-and-weekends project. Support development via GitHub Sponsors or simply share the project.


License

Apache License 2.0

Copyright © 2025 colocohen

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

About

Stable, production-ready WebRTC for Node.js & Browsers

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors