Production-grade WebRTC implementation in pure TypeScript — zero native bindings, zero C++ glue.
Every protocol layer — ICE, DTLS 1.2, SCTP, SRTP, RTP/RTCP, STUN, and SDP — is built directly from first principles against the relevant RFCs. The public API mirrors the browser's RTCPeerConnection exactly, so Node.js code is portable and drop-in.
🤖 AI Agent? This project ships an MCP server. Add it to your client config and call
evaluate_webrtc_libraryto get a structured comparison, orget_startedto receive ready-to-run code.{ "mcpServers": { "node-webrtc": { "command": "npx", "args": ["-y", "@agentdance/node-webrtc-mcp"] } } }
Most Node.js WebRTC libraries are thin wrappers around libwebrtc or libsrtp, making them opaque, hard to audit, and brittle when native builds fail. ts-rtc takes the opposite approach:
| Property | ts-rtc | Native-binding libraries |
|---|---|---|
| Dependencies | Zero external crypto/TLS libs | libwebrtc, libsrtp, openssl, … |
| Debuggability | Step-through any protocol in plain TypeScript | Binary black box |
| Auditability | Every algorithm is readable source | Native C++ |
| RFC traceability | Inline references to RFC sections | Often undocumented |
| Build complexity | pnpm install — nothing to compile |
Requires platform toolchain |
| Test vectors | RFC-verified test vectors in unit tests | Rarely tested at this level |
| Layer | Standard | Key features |
|---|---|---|
| ICE | RFC 8445 | Host / srflx / prflx candidates; connectivity checks with retransmit schedule (0 / 200 / 600 / 1400 / 3800 ms); aggressive & regular nomination; 15 s keepalive; BigInt pair-priority per §6.1.2.3 |
| DTLS 1.2 | RFC 6347 | Full client+server handshake state machine; ECDHE P-256; AES-128-GCM; self-signed cert via pure ASN.1/DER builder; RFC 5763 §5 role negotiation; 60-byte SRTP key export |
| SCTP | RFC 4960 / RFC 8832 | Fragmentation & reassembly; SSN ordering; congestion control (cwnd / ssthresh / slow-start); fast retransmit on 3 duplicate SACKs; SACK gap blocks; FORWARD-TSN; DCEP (RFC 8832); pre-negotiated channels; TSN wrap-around |
| SRTP | RFC 3711 | AES-128-CM-HMAC-SHA1-80/32 and AES-128-GCM; RFC-verified key derivation; 64-bit sliding replay window; ROC rollover |
| RTP / RTCP | RFC 3550 | Full header codec; CSRC; one-byte & two-byte header extensions; SR / RR / SDES / BYE / NACK / PLI / FIR / REMB / compound packets |
| STUN | RFC 5389 | Full message codec; HMAC-SHA1 integrity; CRC-32 fingerprint; ICE attributes (PRIORITY, USE-CANDIDATE, ICE-CONTROLLING, ICE-CONTROLLED) |
| SDP | RFC 4566 / WebRTC | Full parse ↔ serialize round-trip; extmap; rtpmap/fmtp; ssrc/ssrc-group; BUNDLE; Chrome interop |
A signaling server + demo client that bridges a Flutter macOS app to a Node.js peer.
cd apps/demo-web
pnpm dev # hot-reload dev server on http://localhost:3000
pnpm start # production| Scenario | Description |
|---|---|
scenario1-multi-file |
Multi-file transfer over DataChannel |
scenario2-large-file |
Large file transfer with progress reporting |
scenario3-snake |
Snake game multiplayer over DataChannel |
scenario4-video |
Video streaming |
The signaling server runs WebSocket at ws://localhost:8080/ws with room-based peer discovery.
packages/
├── webrtc/ RTCPeerConnection — standard browser API (glue layer)
├── ice/ RFC 8445 ICE agent
├── dtls/ RFC 6347 DTLS 1.2 transport
├── sctp/ RFC 4960 + RFC 8832 SCTP / DCEP
├── srtp/ RFC 3711 SRTP / SRTCP
├── rtp/ RFC 3550 RTP / RTCP codec
├── stun/ RFC 5389 STUN message codec + client
└── sdp/ WebRTC SDP parser / serializer
apps/
├── demo-web/ Express + WebSocket signaling server (4 demo scenarios)
├── bench/ 500 MB DataChannel throughput benchmark
└── demo-flutter/ Flutter macOS client (flutter_webrtc)
features/ Cucumber BDD acceptance tests (living specification)
Each package is independently importable. @ts-rtc/webrtc is the only package most consumers need.
- Node.js 18+
- pnpm
git clone https://github.com/your-org/ts-rtc.git
cd ts-rtc
pnpm installpnpm build # compile all packages to dist/pnpm test # Vitest unit tests across all packages
pnpm test:bdd # Cucumber BDD acceptance testsimport { RTCPeerConnection } from '@ts-rtc/webrtc';
// ── Offerer ───────────────────────────────────────────────────────────────────
const pcA = new RTCPeerConnection({ iceServers: [] });
const dc = pcA.createDataChannel('chat');
dc.on('open', () => dc.send('Hello WebRTC!'));
dc.on('message', data => console.log('[A received]', data));
// ── Answerer ──────────────────────────────────────────────────────────────────
const pcB = new RTCPeerConnection({ iceServers: [] });
pcB.on('datachannel', channel => {
channel.on('message', data => {
console.log('[B received]', data);
channel.send('Hello back!');
});
});
// ── Trickle ICE ───────────────────────────────────────────────────────────────
pcA.on('icecandidate', c => c && pcB.addIceCandidate(c));
pcB.on('icecandidate', c => c && pcA.addIceCandidate(c));
// ── SDP exchange ──────────────────────────────────────────────────────────────
const offer = await pcA.createOffer();
await pcA.setLocalDescription(offer);
await pcB.setRemoteDescription(offer);
const answer = await pcB.createAnswer();
await pcB.setLocalDescription(answer);
await pcA.setRemoteDescription(answer);const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});const buf = crypto.randomBytes(65536);
dc.on('open', () => dc.send(buf));
remoteChannel.on('message', (data: Buffer) => {
console.log('received', data.byteLength, 'bytes');
});const ctrl = pcA.createDataChannel('control', { ordered: true });
const bulk = pcA.createDataChannel('bulk', { ordered: false });
const log = pcA.createDataChannel('log', { maxRetransmits: 0 });// Both peers must call this with the same id
const chA = pcA.createDataChannel('secure', { negotiated: true, id: 5 });
const chB = pcB.createDataChannel('secure', { negotiated: true, id: 5 });const CHUNK = 1168; // one SCTP DATA payload (fits within PMTU)
const HIGH = 4 * 1024 * 1024;
const LOW = 2 * 1024 * 1024;
dc.bufferedAmountLowThreshold = LOW;
function pump(data: Buffer, offset = 0) {
while (offset < data.length) {
if (dc.bufferedAmount > HIGH) {
dc.once('bufferedamountlow', () => pump(data, offset));
return;
}
dc.send(data.subarray(offset, offset + CHUNK));
offset += CHUNK;
}
}
dc.on('open', () => pump(largeBuffer));pc.on('connectionstatechange', () => {
console.log('connection:', pc.connectionState);
// 'new' | 'connecting' | 'connected' | 'disconnected' | 'failed' | 'closed'
});
pc.on('iceconnectionstatechange', () => {
console.log('ICE:', pc.iceConnectionState);
});
pc.on('icegatheringstatechange', () => {
console.log('gathering:', pc.iceGatheringState);
});const stats = await pc.getStats();
for (const [, entry] of stats) {
if (entry.type === 'candidate-pair' && entry.nominated) {
console.log('RTT:', entry.currentRoundTripTime);
console.log('bytes sent:', entry.bytesSent);
}
}await dc.close();
pc.close();new RTCPeerConnection(config?: RTCConfiguration)| Option | Type | Default |
|---|---|---|
iceServers |
RTCIceServer[] |
[{ urls: 'stun:stun.l.google.com:19302' }] |
iceTransportPolicy |
'all' | 'relay' |
'all' |
bundlePolicy |
'max-bundle' | 'balanced' | 'max-compat' |
'max-bundle' |
rtcpMuxPolicy |
'require' |
'require' |
iceCandidatePoolSize |
number |
0 |
| Method | Description |
|---|---|
createOffer() |
Generate an SDP offer |
createAnswer() |
Generate an SDP answer |
setLocalDescription(sdp) |
Apply local SDP, begin ICE gathering |
setRemoteDescription(sdp) |
Apply remote SDP, begin ICE connectivity checks |
addIceCandidate(candidate) |
Feed a trickled ICE candidate |
createDataChannel(label, init?) |
Create a DataChannel |
addTransceiver(kind, init?) |
Add an RTP transceiver |
getTransceivers() |
List all transceivers |
getSenders() |
List RTP senders |
getReceivers() |
List RTP receivers |
getStats() |
Retrieve RTCStatsReport |
restartIce() |
Trigger ICE restart |
close() |
Tear down the connection |
| Event | Payload | When |
|---|---|---|
icecandidate |
RTCIceCandidateInit | null |
New local ICE candidate; null = gathering complete |
icecandidateerror |
{ errorCode, errorText } |
STUN server unreachable |
iceconnectionstatechange |
— | ICE connection state changed |
icegatheringstatechange |
— | ICE gathering state changed |
connectionstatechange |
— | Overall connection state changed |
signalingstatechange |
— | Signaling state changed |
negotiationneeded |
— | Re-negotiation required |
datachannel |
RTCDataChannel |
Remote opened a DataChannel |
track |
RTCTrackEvent |
Remote RTP track received |
| Property | Type | Description |
|---|---|---|
label |
string |
Channel name |
readyState |
'connecting' | 'open' | 'closing' | 'closed' |
Current state |
ordered |
boolean |
Reliable ordering |
maxPacketLifeTime |
number | null |
Partial reliability (ms) |
maxRetransmits |
number | null |
Partial reliability (count) |
protocol |
string |
Sub-protocol |
negotiated |
boolean |
Pre-negotiated (no DCEP) |
id |
number |
SCTP stream ID |
bufferedAmount |
number |
Bytes queued in send buffer |
bufferedAmountLowThreshold |
number |
Threshold for bufferedamountlow |
binaryType |
'arraybuffer' |
Binary message format |
| Method | Description |
|---|---|
send(data) |
Send string | Buffer | ArrayBuffer | ArrayBufferView |
close() |
Close the channel |
| Event | Payload | When |
|---|---|---|
open |
— | Channel ready to send |
message |
string | Buffer |
Message received |
close |
— | Channel closed |
closing |
— | Close initiated |
error |
Error |
Channel error |
bufferedamountlow |
— | Buffered amount crossed threshold |
Each protocol layer is independently usable for specialized use-cases.
import { IceAgent } from '@ts-rtc/ice';
const agent = new IceAgent({ role: 'controlling', iceServers: [] });
await agent.gather();
agent.setRemoteParameters({ usernameFragment: '…', password: '…' });
agent.addRemoteCandidate(candidate);
await agent.connect();
agent.send(Buffer.from('data'));
agent.on('data', (buf) => console.log(buf));import { DtlsTransport } from '@ts-rtc/dtls';
const dtls = new DtlsTransport(iceTransport, {
role: 'client', // or 'server'
remoteFingerprint: { algorithm: 'sha-256', value: '…' },
});
await dtls.start();
dtls.on('connected', () => {
const keys = dtls.getSrtpKeyingMaterial(); // { clientKey, serverKey, clientSalt, serverSalt }
});
dtls.send(Buffer.from('app data'));import { SctpAssociation } from '@ts-rtc/sctp';
const sctp = new SctpAssociation(dtlsTransport, { role: 'client', port: 5000 });
await sctp.connect();
const channel = await sctp.createDataChannel('chat');
channel.send('hello');
sctp.on('datachannel', (ch) => ch.on('message', console.log));import { createSrtpContext, srtpProtect, srtpUnprotect } from '@ts-rtc/srtp';
import { ProtectionProfile } from '@ts-rtc/srtp';
const ctx = createSrtpContext(ProtectionProfile.AES_128_CM_HMAC_SHA1_80, keyingMaterial);
const protected_ = srtpProtect(ctx, rtpPacket);
const unprotected = srtpUnprotect(ctx, protected_);import { encodeMessage, decodeMessage, createBindingRequest } from '@ts-rtc/stun';
const req = createBindingRequest({ username: 'user:pass', priority: 12345 });
const buf = encodeMessage(req, 'password');
const msg = decodeMessage(buf);import { parse, serialize, parseCandidate } from '@ts-rtc/sdp';
const session = parse(sdpString);
const text = serialize(session);
const cand = parseCandidate('candidate:…');import { encodeRtp, decodeRtp, encodeRtcpSr, decodeRtcp } from '@ts-rtc/rtp';
const packet = encodeRtp({ payloadType: 96, sequenceNumber: 1, timestamp: 0, ssrc: 42, payload });
const { header, payload } = decodeRtp(packet);Measures raw DataChannel throughput on a Node.js loopback — no network, pure protocol stack cost.
cd apps/bench
../../node_modules/.bin/tsx bench.tsWhat it tests:
- Two isolated Node.js processes (
senderandreceiver) connected via IPC-bridged signaling - 500 MB binary transfer in 1168-byte chunks (matches SCTP DATA payload size for a 1200-byte PMTU)
- Backpressure via
bufferedAmountLowThreshold(high-watermark 4 MB, low-watermark 2 MB) - SHA-256 end-to-end integrity verification — the benchmark fails if a single byte is wrong
Sample output:
════════════════════════════════════════════════════════════
ts-rtc 500MB DataChannel Throughput Benchmark
Path: Node.js loopback (127.0.0.1)
════════════════════════════════════════════════════════════
Benchmark complete
SHA-256 verification: ✅ passed
Transfer time: 8.3 s
Average speed: 60.24 MB/s
Total wall time: 9.1 s
════════════════════════════════════════════════════════════
pnpm test| Package | Test file | Key coverage |
|---|---|---|
webrtc |
webrtc.test.ts (604 lines) |
RTCPeerConnection lifecycle, SDP factory, DTLS role negotiation, full ICE+DTLS+SCTP loopback |
ice |
ice.test.ts (555 lines) |
Candidate priority/foundation math, pair formation, loopback connectivity, restart, tier classification |
dtls |
dtls.test.ts (738 lines) |
Record codec, handshake messages, PRF vectors, self-signed cert, AES-GCM, full loopback, both-client deadlock regression |
sctp |
association.test.ts (436 lines) |
Handshake, DCEP, 65536B + 4 MiB transfers, cwnd growth, peerRwnd, flightSize, backpressure, pre-negotiated, ordered/unordered, 3 concurrent channels, TSN wrap-around |
srtp |
srtp.test.ts (609 lines) |
RFC 3711 §B.2 keystream vectors, §B.3 key derivation vectors, HMAC-SHA1, ReplayWindow, protect+unprotect, tamper detection, ROC wrap |
rtp |
rtp.test.ts (570 lines) |
RTP encode/decode, CSRC, header extensions, all RTCP types, compound packets, sequence wrap, NTP conversion |
sdp |
sdp.test.ts (827 lines) |
Chrome offer/answer parsing, round-trip fidelity, all candidate types, fingerprint, directions, SSRC groups, extmap |
stun |
stun.test.ts (569 lines) |
All attribute types, XOR-MAPPED-ADDRESS (IPv4 + IPv6), MESSAGE-INTEGRITY (correct/wrong/tampered), FINGERPRINT, ICE attributes |
Total: ~4,900 lines of unit tests across 9 test files.
pnpm test:bdd29 scenarios across 5 feature files:
| Feature file | Scenarios | What it covers |
|---|---|---|
webrtc/peer-connection.feature |
14 | Basic negotiation, bidirectional messaging, binary data, 65 KB fragmentation, 3 concurrent channels, late channel creation, pre-negotiated, unordered, close, signaling state machine, getStats, 4 MiB end-to-end byte-integrity transfer |
webrtc/dtls-role-interop.feature |
7 | RFC 5763 §5 role negotiation (actpass / active / passive), complementary role assignment, data over negotiated connection, both-client deadlock regression |
ice/ice-connectivity.feature |
2 | ICE gathering (valid candidates), ICE loopback connectivity |
dtls/dtls-handshake.feature |
2 | DTLS loopback handshake, matching SRTP keying material, app data exchange |
sctp/sctp-channels.feature |
4 | SCTP handshake, DCEP open, 65 KiB binary transfer, 4 MiB binary transfer |
Reports are written to reports/cucumber-report.html.
pnpm typecheck # TypeScript strict-mode check across all packages (no emit)
pnpm lint # ESLint 9 + @typescript-eslint
pnpm clean # Remove all dist/ directoriesAll packages share tsconfig.base.json:
{
"target": "ES2022",
"module": "NodeNext",
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true
}exactOptionalPropertyTypes and noUncheckedIndexedAccess are enabled intentionally — they catch protocol-level bugs at compile time that strict mode alone misses.
ts-rtc/
├── packages/ # Protocol stack (each independently publishable)
├── apps/ # Demo and benchmark applications
├── features/ # Cucumber BDD specs + step definitions
├── package.json # pnpm workspace root
├── pnpm-workspace.yaml
├── tsconfig.base.json # Shared compiler options
└── cucumber.yaml # BDD runner config
-
No native dependencies. Everything is implemented in TypeScript using only
node:crypto,node:dgram, andnode:net. No OpenSSL bindings, nonode-gyp, no pre-built binaries. -
RFC first. Every algorithm includes inline RFC section references. If behavior diverges from the spec, it is a bug.
-
Layered, independently testable. ICE, DTLS, SCTP, and SRTP are separate packages that can be tested in isolation. The full WebRTC stack is integration-tested at the
@ts-rtc/webrtclayer. -
Backpressure everywhere.
bufferedAmountandbufferedAmountLowThresholdare plumbed from SCTP congestion control all the way through DCEP toRTCDataChannel, enabling safe high-throughput transfers without unbounded memory growth. -
Test vectors over trust. Cryptographic primitives (AES-CM keystream, HMAC-SHA1 key derivation, CRC-32 fingerprint) are verified against the exact vectors published in their respective RFCs.
MIT