- Replace manual WebRTC setup with offerFactory pattern - Use publishService() + startFilling() instead of manual polling - Update browser client to use connectToService() - Remove all manual ICE candidate polling code - Simplify shutdown to just call stopFilling() - Reduce example from ~400 lines to ~150 lines
15 KiB
Hosting WebRTC Services with Node.js
This guide shows you how to create a WebRTC service host in Node.js that web clients can discover and connect to using Rondevu.
Table of Contents
- Overview
- Prerequisites
- Node.js Host (Offerer)
- Browser Client (Answerer)
- Message Protocol
- WebRTC Patterns
- TURN Server Configuration
- Troubleshooting
Overview
In this pattern:
- Node.js host runs a service (e.g., chat bot, data processor) and publishes offers on Rondevu
- Browser clients discover the service and connect via WebRTC
- Direct P2P communication happens over WebRTC data channels (no server relay after connection)
┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐
│ Node.js Host │────1───▶│ Rondevu │◀───2────│ Browser Client │
│ (Offerer) │ │ Server │ │ (Answerer) │
│ │ │ │ │ │
│ Publishes offer │ │ Signaling │ │ Gets offer │
│ Creates channel │ │ │ │ Receives channel│
└────────┬────────┘ └──────────────┘ └────────┬────────┘
│ │
│ 3. WebRTC P2P Connection │
└─────────────────────────────────────────────────────┘
(Data channel messages)
Prerequisites
Node.js Requirements
- Node.js 19+ (recommended), OR
- Node.js 18 with
--experimental-global-webcryptoflag
Install Dependencies
npm install @xtr-dev/rondevu-client wrtc
Important: wrtc requires native compilation and build tools:
Ubuntu/Debian:
sudo apt-get install python3 make g++
npm install wrtc
macOS:
# Xcode Command Line Tools required
xcode-select --install
npm install wrtc
Windows:
# Visual Studio Build Tools required
npm install --global windows-build-tools
npm install wrtc
Installation may take several minutes as wrtc compiles native WebRTC libraries.
Node.js Host (Offerer)
Here's a complete example of a Node.js service host that creates a chat bot:
#!/usr/bin/env node
import { Rondevu, NodeCryptoAdapter } from '@xtr-dev/rondevu-client'
import wrtcModule from 'wrtc'
const { RTCPeerConnection } = wrtcModule
// Configuration
const API_URL = 'https://api.ronde.vu'
const USERNAME = 'chatbot' // Your service username
const SERVICE = 'chat:2.0.0' // Service name (username will be auto-appended)
async function main() {
console.log('🤖 Starting Chat Bot Service')
console.log('='.repeat(50))
// 1. Connect to Rondevu with Node crypto adapter and ICE server preset
console.log('1. Connecting to Rondevu...')
const rondevu = await Rondevu.connect({
apiUrl: API_URL,
username: USERNAME,
cryptoAdapter: new NodeCryptoAdapter(),
iceServers: 'ipv4-turn' // Use preset: 'ipv4-turn', 'hostname-turns', 'google-stun', or 'relay-only'
})
console.log(` ✓ Connected as: ${rondevu.getUsername()}`)
console.log(` ✓ Public key: ${rondevu.getPublicKey()?.substring(0, 20)}...`)
// 2. Publish service with automatic offer management
console.log('2. Publishing service with automatic offer management...')
await rondevu.publishService({
service: SERVICE,
maxOffers: 5, // Maintain up to 5 concurrent offers
offerFactory: async (rtcConfig) => {
console.log('\n3. Creating new WebRTC offer...')
const pc = new RTCPeerConnection(rtcConfig)
// IMPORTANT: Offerer creates the data channel
const dc = pc.createDataChannel('chat', {
ordered: true,
maxRetransmits: 3
})
// Set up data channel handlers
dc.onopen = () => {
console.log(' ✓ Data channel opened with new peer!')
// Send welcome message
dc.send(JSON.stringify({
type: 'identify',
from: USERNAME,
publicKey: rondevu.getPublicKey()
}))
}
dc.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
console.log(`📥 Message from peer:`, msg)
if (msg.type === 'identify') {
// Peer identified themselves
console.log(` Peer: @${msg.from}`)
// Send acknowledgment
dc.send(JSON.stringify({
type: 'identify_ack',
from: USERNAME,
publicKey: rondevu.getPublicKey()
}))
} else if (msg.type === 'message') {
// Received chat message - echo it back
console.log(` 💬 @${msg.from || 'peer'}: ${msg.text}`)
dc.send(JSON.stringify({
type: 'message',
from: USERNAME,
text: `Echo: ${msg.text}`
}))
}
} catch (err) {
console.error('Failed to parse message:', err)
}
}
dc.onclose = () => {
console.log(' ❌ Data channel closed')
}
dc.onerror = (error) => {
console.error(' ❌ Data channel error:', error)
}
// Monitor connection state
pc.onconnectionstatechange = () => {
console.log(` Connection state: ${pc.connectionState}`)
if (pc.connectionState === 'connected') {
console.log(` ✅ Connected to peer!`)
} else if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
console.log(` ❌ Connection ${pc.connectionState}`)
}
}
pc.oniceconnectionstatechange = () => {
console.log(` ICE state: ${pc.iceConnectionState}`)
}
// Create offer
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
console.log(' ✓ Offer created and local description set')
return { pc, dc, offer }
},
ttl: 300000 // 5 minutes per offer
})
console.log(` ✓ Service published: ${SERVICE}@${USERNAME}`)
// 3. Start automatic offer pool management
console.log('3. Starting automatic offer pool management...')
await rondevu.startFilling()
console.log(` ✓ Maintaining up to 5 concurrent offers`)
console.log(` ✓ Polling for answers and ICE candidates`)
console.log(`\n✅ Service is live! Clients can connect to: ${SERVICE}@${USERNAME}`)
// 4. Handle graceful shutdown
process.on('SIGINT', () => {
console.log('\n\n🛑 Shutting down...')
rondevu.stopFilling()
process.exit(0)
})
}
main().catch(err => {
console.error('Fatal error:', err)
process.exit(1)
})
Running the Host
# Make executable
chmod +x host-service.js
# Run
node host-service.js
# Or with Node 18:
node --experimental-global-webcrypto host-service.js
Browser Client (Answerer)
Here's how to connect from a browser (or see the demo app for a full UI):
import { Rondevu } from '@xtr-dev/rondevu-client'
// Configuration
const API_URL = 'https://api.ronde.vu'
const SERVICE_FQN = 'chat:2.0.0@chatbot' // Full service name with username
async function connectToService() {
console.log('🌐 Connecting to chat bot...')
// 1. Connect to Rondevu with ICE server preset
const rondevu = await Rondevu.connect({
apiUrl: API_URL,
iceServers: 'ipv4-turn' // Use same preset as host
// No username = auto-generated anonymous username
})
console.log(`✓ Connected as: ${rondevu.getUsername()}`)
// 2. Connect to service (automatic WebRTC setup)
console.log(`Looking for service: ${SERVICE_FQN}`)
const connection = await rondevu.connectToService({
serviceFqn: SERVICE_FQN,
onConnection: ({ dc, peerUsername }) => {
console.log(`✅ Connected to @${peerUsername}!`)
// Set up message handler
dc.addEventListener('message', (event) => {
try {
const msg = JSON.parse(event.data)
console.log('📥 Message:', msg)
if (msg.type === 'identify') {
console.log(` Peer identified as: @${msg.from}`)
} else if (msg.type === 'identify_ack') {
console.log(' ✅ Connection acknowledged!')
// Send a test message
dc.send(JSON.stringify({
type: 'message',
text: 'Hello from browser!'
}))
} else if (msg.type === 'message') {
console.log(` 💬 @${msg.from}: ${msg.text}`)
}
} catch (err) {
console.error('Parse error:', err)
}
})
// Send identify message
dc.send(JSON.stringify({
type: 'identify',
from: rondevu.getUsername(),
publicKey: rondevu.getPublicKey()
}))
}
})
console.log('✅ Connection established!')
// Monitor connection state
connection.pc.onconnectionstatechange = () => {
console.log(`Connection state: ${connection.pc.connectionState}`)
if (connection.pc.connectionState === 'failed' || connection.pc.connectionState === 'closed') {
console.log('❌ Connection ended')
}
}
}
// Run it
connectToService().catch(err => {
console.error('Error:', err)
})
Message Protocol
The examples above use a simple JSON-based protocol:
Message Types
1. Identify
Sent when a peer first connects to introduce themselves.
{
type: 'identify',
from: 'username',
publicKey: 'base64-encoded-public-key' // For verification
}
2. Identify Acknowledgment
Response to identify message.
{
type: 'identify_ack',
from: 'username',
publicKey: 'base64-encoded-public-key'
}
3. Chat Message
Actual message content.
{
type: 'message',
from: 'username', // Optional
text: 'message text'
}
Custom Protocols
You can implement any protocol you want over the data channel:
// Binary protocol
dc.binaryType = 'arraybuffer'
dc.send(new Uint8Array([1, 2, 3, 4]))
// Custom JSON protocol
dc.send(JSON.stringify({
type: 'file-transfer',
filename: 'document.pdf',
size: 1024000,
chunks: 100
}))
WebRTC Patterns
Critical Pattern: Data Channel Creation
IMPORTANT: In WebRTC, only the offerer creates data channels. The answerer receives them.
// ✅ CORRECT - Offerer (Node.js host)
const pc = new RTCPeerConnection()
const dc = pc.createDataChannel('chat') // Offerer creates
const offer = await pc.createOffer()
// ...
// ✅ CORRECT - Answerer (Browser client)
const pc = new RTCPeerConnection()
pc.ondatachannel = (event) => { // Answerer receives via event
const dc = event.channel
// ...
}
await pc.setRemoteDescription(offer)
// ...
// ❌ WRONG - Answerer creating channel
const pc = new RTCPeerConnection()
const dc = pc.createDataChannel('chat') // DON'T DO THIS!
// This creates a SEPARATE channel that won't communicate
Creating channels on both sides results in two separate, non-communicating channels. Always follow the offerer/answerer pattern.
ICE Candidate Timing
Set up ICE handlers before setting local description to catch all candidates:
// ✅ CORRECT ORDER
pc.onicecandidate = (event) => {
// Send candidate to server
}
await pc.setLocalDescription(offer) // This triggers ICE gathering
// ❌ WRONG ORDER
await pc.setLocalDescription(offer) // Starts gathering immediately
pc.onicecandidate = (event) => {
// Might miss early candidates!
}
Answer Before ICE (Answerer)
Answerers should send their answer before ICE gathering to authorize candidate posting:
// ✅ CORRECT - Answer first, then gather ICE
await pc.setRemoteDescription(offer)
const answer = await pc.createAnswer()
// Send answer to authorize ICE posting
await rondevu.postOfferAnswer(serviceFqn, offerId, answer.sdp)
// Now set local description (starts ICE gathering)
await pc.setLocalDescription(answer)
// ICE candidates can now be posted (authorized)
TURN Server Configuration
For production deployments, you'll need TURN servers for NAT traversal:
const RTC_CONFIG = {
iceServers: [
// STUN for public IP discovery
{ urls: 'stun:stun.l.google.com:19302' },
// TURN relay for NAT traversal
{
urls: [
'turn:your-turn-server.com:3478?transport=tcp',
'turn:your-turn-server.com:3478?transport=udp',
],
username: 'your-username',
credential: 'your-password'
}
]
}
Testing TURN Connectivity
Use turnutils_uclient to verify TURN server:
# Install coturn utilities
sudo apt-get install coturn-utils
# Test TURN server
turnutils_uclient -u username -w password your-turn-server.com 3478 -y
Force TURN (Testing)
To test if TURN is working, force relay mode:
const RTC_CONFIG = {
iceServers: [/* ... */],
iceTransportPolicy: 'relay' // Forces TURN, bypasses direct connections
}
Remove iceTransportPolicy: 'relay' for production to allow direct connections when possible.
Troubleshooting
Connection Stuck in "connecting"
Possible causes:
- TURN server not working
- Both peers behind same NAT (hairpinning issue)
- Firewall blocking UDP ports
Solutions:
// Enable relay-only mode to test TURN
const RTC_CONFIG = {
iceServers: [/* ... */],
iceTransportPolicy: 'relay'
}
// Check TURN server
turnutils_uclient -u user -w pass server.com 3478 -y
// Verify both peers are on different networks
No Candidates Gathered
Possible causes:
- ICE handler set up too late
- STUN/TURN servers unreachable
- Firewall blocking
Solutions:
// Set handler BEFORE setLocalDescription
pc.onicecandidate = (event) => { /* ... */ }
await pc.setLocalDescription(offer)
// Test STUN connectivity
ping stun.l.google.com
Messages Not Received
Possible causes:
- Data channel created on both sides
- Channel not opened yet
- Wrong channel name
Solutions:
// Only offerer creates channel
// Offerer:
const dc = pc.createDataChannel('chat')
// Answerer:
pc.ondatachannel = (event) => {
const dc = event.channel // Receive it
}
// Wait for channel to open
dc.onopen = () => {
dc.send('message') // Now safe to send
}
wrtc Installation Fails
Ubuntu/Debian:
sudo apt-get update
sudo apt-get install -y python3 make g++ pkg-config libssl-dev
npm install wrtc
macOS:
xcode-select --install
npm install wrtc
Windows:
npm install --global windows-build-tools
npm install wrtc
Complete Working Example
See /demo/test-connect.js for a complete working example that connects to the chat demo at chat:2.0.0@bas.
To run:
cd demo
npm install wrtc
npm test
Additional Resources
License
MIT