@agentdance/node-webrtc
v1.0.4
Published
Production-grade WebRTC in pure TypeScript — RTCPeerConnection, DataChannel, ICE, DTLS, SCTP, SRTP. Zero native bindings.
Downloads
480
Maintainers
Readme
ts-rtc
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.
Why ts-rtc?
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 |
Protocol Coverage
| 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 |
Demo Web Application
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 # productionDemo scenarios
| 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.
Architecture
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.
Quickstart
Prerequisites
- Node.js 18+
- pnpm
Install
git clone https://github.com/your-org/ts-rtc.git
cd ts-rtc
pnpm installBuild
pnpm build # compile all packages to dist/Run tests
pnpm test # Vitest unit tests across all packages
pnpm test:bdd # Cucumber BDD acceptance testsUsage
Minimal DataChannel (peer-to-peer in Node.js)
import { 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);With a STUN server
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});Binary data
const buf = crypto.randomBytes(65536);
dc.on('open', () => dc.send(buf));
remoteChannel.on('message', (data: Buffer) => {
console.log('received', data.byteLength, 'bytes');
});Multiple concurrent channels
const ctrl = pcA.createDataChannel('control', { ordered: true });
const bulk = pcA.createDataChannel('bulk', { ordered: false });
const log = pcA.createDataChannel('log', { maxRetransmits: 0 });Pre-negotiated channel (no DCEP round-trip)
// 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 });Backpressure-aware large transfers
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));Connection state monitoring
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);
});Stats
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);
}
}Graceful close
await dc.close();
pc.close();RTCPeerConnection API Reference
Constructor
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 |
Methods
| 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 |
Events
| 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 |
RTCDataChannel API Reference
Properties
| 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 |
Methods
| Method | Description |
|---|---|
| send(data) | Send string \| Buffer \| ArrayBuffer \| ArrayBufferView |
| close() | Close the channel |
Events
| 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 |
Lower-Level Package APIs
Each protocol layer is independently usable for specialized use-cases.
@ts-rtc/ice — ICE Agent
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));@ts-rtc/dtls — DTLS 1.2 Transport
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'));@ts-rtc/sctp — SCTP Association
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));@ts-rtc/srtp — SRTP Protect / Unprotect
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_);@ts-rtc/stun — STUN Codec
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);@ts-rtc/sdp — SDP Parser / Serializer
import { parse, serialize, parseCandidate } from '@ts-rtc/sdp';
const session = parse(sdpString);
const text = serialize(session);
const cand = parseCandidate('candidate:…');@ts-rtc/rtp — RTP / RTCP Codec
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);Throughput Benchmark
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
════════════════════════════════════════════════════════════Test Suite
Unit tests — Vitest
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.
BDD acceptance tests — Cucumber.js
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.
Other Commands
pnpm typecheck # TypeScript strict-mode check across all packages (no emit)
pnpm lint # ESLint 9 + @typescript-eslint
pnpm clean # Remove all dist/ directoriesTypeScript Configuration
All 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.
Monorepo Layout
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 configDesign Principles
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.
License
MIT
