node-rtc-connection
v2.1.1
Published
WebRTC DataChannel implementation for Node.js with STUN, TURN, NAT traversal, and encryption. Pure Node.js, no native dependencies.
Maintainers
Readme
node-rtc-connection
A from-scratch, pure-Node.js WebRTC data-channel implementation that
interoperates with browsers. No native dependencies — the entire ICE / DTLS
/ SCTP stack is built on Node's crypto and dgram. Written in TypeScript;
ships type declarations.
Features
- ✅ Browser interoperable: Verified end-to-end against Chromium (Playwright) and OpenSSL
- ✅ Real protocols, not stubs: Genuine DTLS 1.2 handshake + SCTP association over UDP
- ✅ ICE (RFC 8445): connectivity checks with MESSAGE-INTEGRITY, host/srflx/relay candidates
- ✅ STUN/TURN (RFC 5389/5766): NAT traversal and relay for restrictive networks, including encrypted
turns:(TURN-over-DTLS and TURN-over-TLS) - ✅ DTLS 1.2 (RFC 6347):
ECDHE_ECDSA_AES128_GCM, mutual auth, self-signed ECDSA P-256 certs - ✅ SCTP + DCEP (RFC 8831/8832): ordered/unordered data channels, string + binary
- ✅ W3C API: familiar
RTCPeerConnection/RTCDataChannelsurface, includingEventTarget,on...handlers,addEventListener, and DOM-style errors - ✅ Pure Node.js, no native deps; CommonJS + ESM bundles with TypeScript types
Connection Flow
flowchart TD
app["Application code"] --> offerer["Offerer RTCPeerConnection"]
app --> answerer["Answerer RTCPeerConnection"]
offerer -->|createDataChannel| localChannel["Local RTCDataChannel"]
offerer -->|createOffer + setLocalDescription| gatherOffer["ICE gathering"]
gatherOffer -->|offer SDP + candidates| signaling["Your signaling channel"]
signaling -->|setRemoteDescription + addIceCandidate| answerer
answerer -->|createAnswer + setLocalDescription| gatherAnswer["ICE gathering"]
gatherAnswer -->|answer SDP + candidates| signaling
signaling -->|setRemoteDescription + addIceCandidate| offerer
offerer --> checks["ICE connectivity checks"]
answerer --> checks
checks --> dtls["DTLS 1.2 handshake<br/>fingerprint verified"]
dtls --> sctp["SCTP association"]
sctp --> dcep["DCEP channel open"]
dcep --> open["RTCDataChannel open"]
open --> messages["string / binary / Blob messages"]Installation
npm install node-rtc-connectionWorks from both CommonJS and ES modules, and bundles TypeScript declarations:
// CommonJS
const { RTCPeerConnection } = require('node-rtc-connection');
// ES modules / TypeScript
import { RTCPeerConnection } from 'node-rtc-connection';Quick Start
Basic Local Connection (No STUN/TURN)
const { RTCPeerConnection } = require('node-rtc-connection');
// Create two peer connections
const pc1 = new RTCPeerConnection({ iceServers: [] });
const pc2 = new RTCPeerConnection({ iceServers: [] });
// Set up data channel on peer 1
const channel = pc1.createDataChannel('chat');
channel.on('open', () => {
console.log('Channel opened!');
channel.send('Hello from Peer 1!');
});
channel.on('message', (event) => {
console.log('Received:', event.data);
});
// Peer 2 receives data channel
pc2.on('datachannel', (event) => {
const channel = event.channel;
channel.on('message', (event) => {
console.log('Received:', event.data);
channel.send('Hello from Peer 2!');
});
});
// Exchange ICE candidates
pc1.on('icecandidate', (e) => {
if (e.candidate) pc2.addIceCandidate(e.candidate).catch(() => {});
});
pc2.on('icecandidate', (e) => {
if (e.candidate) pc1.addIceCandidate(e.candidate).catch(() => {});
});
// Signaling (offer/answer exchange)
async function connect() {
const offer = await pc1.createOffer();
await pc1.setLocalDescription(offer);
await pc2.setRemoteDescription(pc1.localDescription);
const answer = await pc2.createAnswer();
await pc2.setLocalDescription(answer);
await pc1.setRemoteDescription(pc2.localDescription);
}
connect();With STUN Server (NAT Traversal)
const { RTCPeerConnection } = require('node-rtc-connection');
const config = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
};
const pc = new RTCPeerConnection(config);
// Listen for gathered ICE candidates
pc.on('icecandidate', (event) => {
if (event.candidate) {
console.log('ICE Candidate:', event.candidate.candidate);
// Send to remote peer via your signaling channel
}
});
// Create offer and start ICE gathering
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);With TURN Server (Relay Support)
const { RTCPeerConnection } = require('node-rtc-connection');
const config = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: 'turn:turn.example.com:3478',
username: 'your-username',
credential: 'your-password'
}
]
};
const pc = new RTCPeerConnection(config);
pc.on('icecandidate', (event) => {
if (event.candidate) {
const candidate = event.candidate.candidate;
// Check candidate type
if (candidate.includes('typ relay')) {
console.log('TURN relay candidate:', candidate);
} else if (candidate.includes('typ srflx')) {
console.log('STUN reflexive candidate:', candidate);
} else if (candidate.includes('typ host')) {
console.log('Host candidate:', candidate);
}
}
});Configuration Options
const config = {
// Array of ICE servers (STUN/TURN)
iceServers: [
{
urls: 'stun:stun.l.google.com:19302'
},
{
urls: [
'turn:turn.example.com:3478?transport=udp',
'turn:turn.example.com:3478?transport=tcp'
],
username: 'user',
credential: 'pass'
}
],
// ICE transport policy
iceTransportPolicy: 'all', // 'all' or 'relay'
// Bundle policy
bundlePolicy: 'balanced', // 'balanced', 'max-bundle', or 'max-compat'
// RTCP mux policy
rtcpMuxPolicy: 'require', // 'negotiate' or 'require'
// ICE candidate pool size
iceCandidatePoolSize: 0
};
const pc = new RTCPeerConnection(config);With iceTransportPolicy: 'relay', only relay candidates are gathered. Host
UDP sockets are not opened just to be filtered later, which keeps relay-only
deployments from consuming local ports unnecessarily.
ICE server URLs
ICE server URLs are parsed with query-string support:
const config = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: [
'turn:turn.example.com:3478?transport=udp',
'turn:turn.example.com:53?transport=udp'
],
username: 'user',
credential: 'pass'
}
]
};URL format: stun:host[:port] and turn(s):host[:port][?transport=udp|tcp&...].
The default port is 3478 (5349 for the turns: scheme).
Transport support: the
turns:scheme is encrypted end-to-end to the TURN server — DTLS over UDP (turns:host:5349) or TLS over TCP (turns:host:5349?transport=tcp). Plainturn:(and STUN srflx) use UDP. Unknown query parameters are preserved and ignored.
STUN/TURN gather requests use finite timeouts. Closing a connection while a STUN/TURN request is in flight settles the pending request and releases the underlying socket or stream.
TLS certificate validation (turns: over TCP)
For TURN-over-TLS, the server's certificate is validated by default. To accept a
self-signed or otherwise unverifiable certificate (e.g. a local/test TURN
server), set rejectUnauthorized: false on that ICE server entry — this is
insecure and intended for development only:
const config = {
iceServers: [
{
urls: 'turns:turn.example.com:5349?transport=tcp',
username: 'user',
credential: 'pass',
rejectUnauthorized: false // accept self-signed cert (insecure)
}
]
};Transport Stability and Security
The transport stack is designed to fail closed around peer identity:
- Remote SDP must include a supported
a=fingerprint:sha-256 ...line. SDP without a supported DTLS fingerprint is rejected bysetRemoteDescription(). - DTLS peers are verified against the SDP fingerprint before application data is accepted.
- DTLS server mode requires the peer to prove possession of its certificate with
CertificateVerify.
Reliable data-channel messages are carried over SCTP DATA chunks. If a DATA chunk is not acknowledged by SACK, the association retransmits it with exponential backoff and aborts after repeated failure instead of keeping an undeliverable message queued forever.
Data Channel API
// Create data channel with options
const channel = pc.createDataChannel('myChannel', {
ordered: true, // Guarantee message order
protocol: 'custom', // Sub-protocol
negotiated: false // Set true only for out-of-band negotiation
});
channel.binaryType = 'arraybuffer'; // or 'blob' for received binary frames
// Events
channel.on('open', () => {
console.log('Channel opened');
});
channel.on('close', () => {
console.log('Channel closed');
});
channel.on('error', (error) => {
console.error('Channel error:', error);
});
channel.on('message', (event) => {
console.log('Message received:', event.data);
});
// Send data
channel.send('Hello World');
channel.send(new Uint8Array([1, 2, 3, 4]));
channel.send(Buffer.from([1, 2, 3, 4])); // Binary data
channel.send(new Blob([Uint8Array.from([1, 2, 3, 4])]));
// Close channel
channel.close();Partial reliability options (maxRetransmits and maxPacketLifeTime) are
currently rejected because PR-SCTP abandon / FORWARD-TSN behavior is not
implemented. Reliable channels use SCTP retransmission until the peer
acknowledges DATA chunks.
W3C Compatibility Notes
This package targets browser-compatible WebRTC data channels. It does not implement media tracks, transceivers, RTP senders/receivers, or the WebRTC stats API.
Implemented W3C-facing behavior includes:
RTCPeerConnectionandRTCDataChannelextend nativeEventTarget. Node-styleon(),once(),off(),removeListener(),listenerCount(), andemit()are retained for existing users.addEventListener()supportsonce,capture, andsignaloptions for public peer-connection and data-channel events.setLocalDescription()andsetRemoteDescription()maintain separate current and pending local/remote descriptions.localDescriptionandremoteDescriptionreturn the pending description when one exists, matching browser behavior.- Offer/answer/pranswer/rollback signaling transitions are validated, and a remote offer applied during local-offer glare performs the W3C implicit rollback path.
canTrickleIceCandidatesisnulluntil a remote description is applied, then reflects whether the remote SDP advertises trickle ICE.addIceCandidate()acceptsnullor an empty candidate as end-of-candidates, validates candidate targets against the active remote SDP (including pending remote SDP), givessdpMidprecedence oversdpMLineIndex, checksusernameFragmentagainst the current/pending remote generation, and appends valid candidates to the matching remote SDP.RTCSessionDescriptionrequirestype, defaults missingsdpto'', and serializes with non-nulltypeandsdpfields.RTCIceCandidateaccepts missing or nullsdpMid/sdpMLineIndexat construction time. Non-empty targetless candidates are rejected byaddIceCandidate(), where the W3C validation order applies.RTCDataChannelsupports text,ArrayBuffer, typed arrays, NodeBuffer, andBlobsends. Received binary data is delivered asArrayBufferorBlobaccording tobinaryType.RTCErrorextendsDOMExceptionwithname === 'OperationError'and the W3CerrorDetailvalues.RTCPeerConnection.generateCertificate()accepts the W3C-required ECDSA P-256 and RSA-2048 algorithm identifiers. The DTLS transport currently negotiates only the ECDSA P-256 cipher suite, so configured RSA certificates are rejected for transport use.
RTCPeerConnection Events
const pc = new RTCPeerConnection(config);
// ICE candidate discovered
pc.on('icecandidate', (event) => {
// event.candidate contains the ICE candidate
});
// ICE gathering state changed
pc.on('icegatheringstatechange', () => {
console.log('Gathering state:', pc.iceGatheringState);
// 'new', 'gathering', or 'complete'
});
// ICE connection state changed
pc.on('iceconnectionstatechange', () => {
console.log('ICE state:', pc.iceConnectionState);
// 'new', 'checking', 'connected', 'completed', 'failed', 'disconnected', 'closed'
});
// Connection state changed
pc.on('connectionstatechange', () => {
console.log('Connection state:', pc.connectionState);
// 'new', 'connecting', 'connected', 'disconnected', 'failed', 'closed'
});
// Signaling state changed
pc.on('signalingstatechange', () => {
console.log('Signaling state:', pc.signalingState);
// 'stable', 'have-local-offer', 'have-remote-offer', 'have-local-pranswer', 'have-remote-pranswer', 'closed'
});
// Data channel received (for answerer)
pc.on('datachannel', (event) => {
const channel = event.channel;
console.log('Received data channel:', channel.label);
});
// Negotiation needed
pc.on('negotiationneeded', () => {
console.log('Negotiation needed');
});EventEmitter listeners, W3C property handlers, and addEventListener are all
supported for public peer-connection and data-channel events:
pc.onicecandidate = (event) => {
if (event.candidate) signaling.send(event.candidate);
};
pc.addEventListener('datachannel', (event) => {
event.channel.onmessage = (messageEvent) => {
console.log(messageEvent.data);
};
}, { once: true });Complete Example: Two-Peer Communication
const { RTCPeerConnection } = require('node-rtc-connection');
async function createPeerConnection() {
const config = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
};
// Create peer connections
const offerer = new RTCPeerConnection(config);
const answerer = new RTCPeerConnection(config);
// Exchange ICE candidates
offerer.on('icecandidate', (e) => {
if (e.candidate) answerer.addIceCandidate(e.candidate).catch(() => {});
});
answerer.on('icecandidate', (e) => {
if (e.candidate) offerer.addIceCandidate(e.candidate).catch(() => {});
});
// Set up data channel on offerer
const channel = offerer.createDataChannel('chat');
channel.on('open', () => {
console.log('Offerer: Channel opened');
channel.send('Hello from offerer!');
});
channel.on('message', (event) => {
console.log('Offerer received:', event.data);
});
// Answerer receives data channel
answerer.on('datachannel', (event) => {
const channel = event.channel;
channel.on('open', () => {
console.log('Answerer: Channel opened');
});
channel.on('message', (event) => {
console.log('Answerer received:', event.data);
channel.send('Hello from answerer!');
});
});
// Perform signaling
const offer = await offerer.createOffer();
await offerer.setLocalDescription(offer);
await answerer.setRemoteDescription(offerer.localDescription);
const answer = await answerer.createAnswer();
await answerer.setLocalDescription(answer);
await offerer.setRemoteDescription(answerer.localDescription);
// Wait for connection
await new Promise(resolve => setTimeout(resolve, 2000));
// Clean up
channel.close();
offerer.close();
answerer.close();
}
createPeerConnection().catch(console.error);Example Files
The package includes runnable examples in examples/:
examples/node-to-node.ts— Two node-rtc-connection peers in one process establish a real data channel through a TURN server and exchange string + binary messages. The quickest way to see the full ICE/DTLS/SCTP stack work. Configure the server viaTURN_URL/TURN_USER/TURN_PASS(defaults match the test-suite coturn), and setRELAY_ONLY=1to force traffic through the relay.examples/browser-server.ts+examples/browser-client.html— A Node.js HTTP server that runs a node-rtc-connection peer (the offerer) and serves a chat page. A browser opens the page, runs its nativeRTCPeerConnectionas the answerer, and the two establish a genuine WebRTC data channel over UDP.
Run them (the examples are TypeScript, run via tsx):
# Node ↔ Node
npm run example:node
# Node ↔ Browser — then open http://localhost:3000
npm run example:browserThe browser example uses plain HTTP for signaling and folds ICE candidates into the SDP (non-trickle) to keep it simple. A production app would typically use WebSockets with trickle ICE.
API Reference
RTCPeerConnection
Constructor
new RTCPeerConnection(configuration?)Methods
createOffer(options?)- Create an SDP offer without changing signaling statecreateAnswer(options?)- Create an SDP answer after a remote offer is pendingsetLocalDescription(description?)- Set local SDP. If omitted, an offer or answer is created implicitly from the current signaling state. ICE gathering starts immediately; while candidates are trickled throughicecandidate, gathered candidates are also folded intolocalDescription.sdponce gathering completes for non-trickle signaling flows.setRemoteDescription(description)- Set remote SDP, validate DTLS fingerprint presence, update current/pending descriptions, and apply offer-glare rollback when required.addIceCandidate(candidate)- Add a remote ICE candidate or end-of-candidates (nullor{ candidate: '' }). A remote description must already be set.createDataChannel(label, options?)- Create a reliable data channel.ordered: falseis supported.maxRetransmitsandmaxPacketLifeTimeare rejected until PR-SCTP abandon semantics are implemented.getConfiguration()/setConfiguration(configuration)- Read or replace the peer-connection configuration.addEventListener(type, listener, options?)/removeEventListener(type, listener, options?)- Browser-compatible event listener methods.close()- Close the connection and release its transport stack (ICE/DTLS/ SCTP sockets, timers, and buffers). Closes any open data channels first.RTCPeerConnection.generateCertificate(algorithm)- Generate anRTCCertificatefrom an ECDSA P-256 or RSA-2048 algorithm identifier.
Properties
localDescription- Local SDP descriptionremoteDescription- Remote SDP descriptioncurrentLocalDescription/currentRemoteDescription- Stable negotiated descriptions, ornullpendingLocalDescription/pendingRemoteDescription- Descriptions being negotiated, ornullcanTrickleIceCandidates-nullbefore remote SDP, thentrueorfalsesignalingState- Current signaling stateiceGatheringState- ICE gathering stateiceConnectionState- ICE connection stateconnectionState- Overall connection statesctp- Underlying SCTP transport object when the stack exists, otherwisenull
RTCDataChannel
Methods
send(data)- Sendstring,ArrayBuffer, a typed array /ArrayBufferView, NodeBuffer, orBlob. ThrowsInvalidStateErrorif the channel is not open,TypeErrorif the message exceeds the negotiated SCTPmax-message-size, andOperationErrorif the transport is unavailable.addEventListener(type, listener, options?)/removeEventListener(type, listener, options?)- Browser-compatible event listener methods.close()- Close the channel. The transport drops its reference and the channel detaches its internal listeners, so closed channels are reclaimed (detach your own'message'/'open'handlers if you keep the reference).
Properties
label- Channel labelordered- Whether messages are orderedmaxRetransmits- Maximum retransmissions. Non-null partial reliability is currently rejected bycreateDataChannel().maxPacketLifeTime- Maximum packet lifetime. Non-null partial reliability is currently rejected bycreateDataChannel().protocol- Sub-protocolnegotiated- Whether manually negotiatedid- Channel IDreadyState- Current state ('connecting', 'open', 'closing', 'closed')bufferedAmount- Bytes queued for outbound SCTP DATA that have not yet been acknowledged by the peerbufferedAmountLowThreshold- Threshold forbufferedamountlow; the event fires whenbufferedAmountcrosses from above the threshold to at or below itbinaryType-'arraybuffer'(default) or'blob'; controls how received binary frames are delivered
RTCSessionDescription
new RTCSessionDescription({ type, sdp })- Construct a description.typeis required and must be one of'offer','pranswer','answer', or'rollback'; missingsdpdefaults to''.type/sdp- Read-only getters.toJSON()- Returns{ type, sdp }.
RTCIceCandidate
new RTCIceCandidate(init?)- Construct an ICE candidate object. Missingcandidatedefaults to''; missingsdpMid,sdpMLineIndex, andusernameFragmentdefault tonull.sdpMLineIndexis validated as an unsigned short.- Parsed candidate attributes include
foundation,component('rtp'or'rtcp'),protocol,priority,address,port,type,tcpType,relatedAddress,relatedPort,relayProtocol, andurl. toJSON()returns the W3C candidate fields, includingusernameFragment: nullwhen no fragment is present.
RTCCertificate
const ecdsa = await RTCPeerConnection.generateCertificate({
name: 'ECDSA',
namedCurve: 'P-256'
});
const rsa = await RTCPeerConnection.generateCertificate({
name: 'RSASSA-PKCS1-v1_5',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1])
});- ECDSA P-256 and RSA-2048 certificate generation are supported.
expireson generation algorithms is a validity duration in milliseconds and is capped at 365 days.RTCCertificate.generateCertificate()also accepts package convenience options such as{ keyType: 'ECDSA' }or{ keyType: 'RSA', rsaModulusLength: 2048 }.getFingerprints()returns SDP-ready fingerprints;toPEM()andfromPEM()support serialization.
Configured certificates used for actual DTLS transport must currently be ECDSA P-256. RSA certificate objects can be generated for W3C API compatibility, but RSA transport negotiation is not implemented.
RTCError
new RTCError({ errorDetail }, message?)creates a DOMException subclass withname === 'OperationError'.errorDetailis required and must be one of the W3C detail strings exposed onRTCError.DetailType.- Optional
sdpLineNumber,sctpCauseCode,receivedAlert, andsentAlertfields are exposed as read-only getters and included bytoJSON()when set.
Requirements
- Node.js 18 or higher
- UDP network access for ICE connectivity (and to a TURN server, if used)
Setting Up Your Own TURN Server
For production use, it's recommended to run your own TURN server using coturn:
# Install coturn
apt-get install coturn
# Basic configuration (plain TURN over UDP)
turnserver -v -L 0.0.0.0 -a -u user:password -r realmFor the encrypted turns: scheme, give coturn an ECDSA certificate (required
for the ECDHE_ECDSA cipher suite this library negotiates) and enable the
TLS/DTLS listener:
turnserver -v -L 0.0.0.0 -a -u user:password -r realm \
--tls-listening-port=5349 \
--cert=/path/to/cert.pem --pkey=/path/to/key.pemThen connect with turns:host:5349 (DTLS) or turns:host:5349?transport=tcp
(TLS). For a self-signed cert, set rejectUnauthorized: false on the ICE server
entry (see TLS certificate validation).
Development
The project is written in strict TypeScript. Sources live in src/; tests in
test/ run directly through tsx (no
precompile step).
npm run build # rollup → minified dist/ bundles + dist/types/ declarations
npm run typecheck # strict tsc --noEmit over src + tests
npm test # full suite (auto-starts a coturn container for the TURN test)
npm run test:unit # SKIP_INTEGRATION=1 — no Docker / browser / external servers
npm run test:coverage # full suite under c8The full test suite proves interoperability against external references:
DTLS handshakes against openssl, an end-to-end data channel against real
Chromium (via Playwright), and a relay path against a real coturn server.
Integration tests skip gracefully when their dependency (Docker, openssl,
Chromium) is unavailable or when SKIP_INTEGRATION=1.
License
MIT
Contributing
Contributions are welcome! Please read CONTRIBUTING.md for the development workflow and conventions, and our Code of Conduct. Security issues should be reported privately — see SECURITY.md. Release notes live in CHANGELOG.md.
Acknowledgments
This is a from-scratch, pure-Node.js implementation that follows the relevant IETF RFCs (8445 ICE, 6347 DTLS 1.2, 8831 SCTP-over-DTLS, 8832 DCEP) and the W3C WebRTC specification.
