WebRTC enables browser-to-browser audio, video, and data transfer without a media relay server for most connections. The complexity is in signaling — WebRTC peers need to exchange session descriptions and ICE candidates through a server you control, but the media itself flows directly between peers. Claude Code implements the signaling server, handles the browser WebRTC API, sets up TURN fallback for symmetric NAT, and builds data channels for multiplayer features.
CLAUDE.md for WebRTC Projects
## WebRTC Stack
- Signaling: WebSocket server (Express + ws)
- STUN: stun:stun.l.google.com:19302 (free, for development)
- TURN: Coturn self-hosted at turn.myapp.com:3478 (required for corporate networks)
- Browser API: RTCPeerConnection, RTCDataChannel
- Video: getUserMedia for camera, getDisplayMedia for screen share
- Recording: MediaRecorder API for saving sessions
- ICE policy: all (use relay only when debugging TURN)
- DTLS-SRTP: always enabled (WebRTC default)
Signaling Server
Build the WebRTC signaling server. Peers need to exchange
offers, answers, and ICE candidates.
// server/signaling.ts — WebSocket signaling server
import { WebSocketServer, WebSocket } from 'ws';
import type { IncomingMessage } from 'http';
type SignalingMessage =
| { type: 'join'; roomId: string; userId: string }
| { type: 'offer'; to: string; sdp: RTCSessionDescriptionInit }
| { type: 'answer'; to: string; sdp: RTCSessionDescriptionInit }
| { type: 'ice-candidate'; to: string; candidate: RTCIceCandidateInit }
| { type: 'leave'; roomId: string };
interface Peer {
ws: WebSocket;
userId: string;
roomId: string | null;
}
const peers = new Map<string, Peer>();
const rooms = new Map<string, Set<string>>();
export function setupSignaling(server: any) {
const wss = new WebSocketServer({ server, path: '/signal' });
wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
let userId: string | null = null;
ws.on('message', (data) => {
let msg: SignalingMessage;
try {
msg = JSON.parse(data.toString());
} catch {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
return;
}
if (msg.type === 'join') {
userId = msg.userId;
const roomId = msg.roomId;
peers.set(userId, { ws, userId, roomId });
if (!rooms.has(roomId)) rooms.set(roomId, new Set());
const room = rooms.get(roomId)!;
const existingPeers = [...room].filter(id => id !== userId);
ws.send(JSON.stringify({ type: 'room-joined', peers: existingPeers }));
existingPeers.forEach(peerId => {
sendToPeer(peerId, { type: 'peer-joined', peerId: userId });
});
room.add(userId);
return;
}
if (!userId) {
ws.send(JSON.stringify({ type: 'error', message: 'Must join a room first' }));
return;
}
if (msg.type === 'offer' || msg.type === 'answer' || msg.type === 'ice-candidate') {
sendToPeer(msg.to, { ...msg, from: userId });
}
if (msg.type === 'leave') {
cleanup(userId, msg.roomId);
userId = null;
}
});
ws.on('close', () => {
if (userId) {
const peer = peers.get(userId);
if (peer?.roomId) cleanup(userId, peer.roomId);
}
});
});
}
function sendToPeer(peerId: string, msg: object) {
const peer = peers.get(peerId);
if (peer?.ws.readyState === WebSocket.OPEN) {
peer.ws.send(JSON.stringify(msg));
}
}
function cleanup(userId: string, roomId: string) {
peers.delete(userId);
const room = rooms.get(roomId);
if (room) {
room.delete(userId);
if (room.size === 0) rooms.delete(roomId);
else room.forEach(peerId => sendToPeer(peerId, { type: 'peer-left', peerId: userId }));
}
}
Browser WebRTC Client
Implement the RTCPeerConnection setup — media negotiation,
ICE candidate exchange, and handling reconnection.
// src/lib/webrtc.ts
const ICE_SERVERS = [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: 'turn:turn.myapp.com:3478',
username: 'webrtc',
credential: import.meta.env.VITE_TURN_SECRET,
},
];
export class PeerConnection {
private pc: RTCPeerConnection;
private localStream: MediaStream | null = null;
onRemoteStream: ((stream: MediaStream) => void) | null = null;
onConnectionStateChange: ((state: RTCPeerConnectionState) => void) | null = null;
constructor(private peerId: string, private signal: WebSocket) {
this.pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
this.pc.onicecandidate = ({ candidate }) => {
if (candidate) {
this.signal.send(JSON.stringify({
type: 'ice-candidate',
to: this.peerId,
candidate: candidate.toJSON(),
}));
}
};
this.pc.ontrack = (event) => {
this.onRemoteStream?.(event.streams[0]);
};
this.pc.onconnectionstatechange = () => {
this.onConnectionStateChange?.(this.pc.connectionState);
};
this.pc.oniceconnectionstatechange = () => {
if (this.pc.iceConnectionState === 'failed') {
this.pc.restartIce();
}
};
}
async addLocalStream(stream: MediaStream) {
this.localStream = stream;
stream.getTracks().forEach(track => this.pc.addTrack(track, stream));
}
async createOffer() {
const offer = await this.pc.createOffer();
await this.pc.setLocalDescription(offer);
this.signal.send(JSON.stringify({ type: 'offer', to: this.peerId, sdp: this.pc.localDescription }));
}
async handleOffer(sdp: RTCSessionDescriptionInit) {
await this.pc.setRemoteDescription(sdp);
const answer = await this.pc.createAnswer();
await this.pc.setLocalDescription(answer);
this.signal.send(JSON.stringify({ type: 'answer', to: this.peerId, sdp: this.pc.localDescription }));
}
async handleAnswer(sdp: RTCSessionDescriptionInit) {
await this.pc.setRemoteDescription(sdp);
}
async handleIceCandidate(candidate: RTCIceCandidateInit) {
await this.pc.addIceCandidate(candidate);
}
async switchToScreenShare() {
const screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
const videoTrack = screenStream.getVideoTracks()[0];
const sender = this.pc.getSenders().find(s => s.track?.kind === 'video');
if (sender) await sender.replaceTrack(videoTrack);
videoTrack.onended = async () => {
const cameraStream = await navigator.mediaDevices.getUserMedia({ video: true });
await sender?.replaceTrack(cameraStream.getVideoTracks()[0]);
};
}
close() {
this.pc.close();
this.localStream?.getTracks().forEach(t => t.stop());
}
}
Video Room React Component
// src/components/VideoRoom.tsx
import { useEffect, useRef, useState } from 'react';
import { PeerConnection } from '../lib/webrtc';
export function VideoRoom({ roomId, userId }: { roomId: string; userId: string }) {
const [peers, setPeers] = useState<Map<string, MediaStream>>(new Map());
const signalRef = useRef<WebSocket | null>(null);
const connectionsRef = useRef<Map<string, PeerConnection>>(new Map());
const localVideoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
let localStream: MediaStream;
async function start() {
localStream = await navigator.mediaDevices.getUserMedia({
video: { width: 1280, height: 720 },
audio: { echoCancellation: true, noiseSuppression: true },
});
if (localVideoRef.current) localVideoRef.current.srcObject = localStream;
const ws = new WebSocket(`wss://${location.host}/signal`);
signalRef.current = ws;
ws.onopen = () => ws.send(JSON.stringify({ type: 'join', roomId, userId }));
ws.onmessage = async (event) => {
const msg = JSON.parse(event.data);
switch (msg.type) {
case 'room-joined':
for (const peerId of msg.peers) {
const conn = createConn(peerId, ws, localStream);
await conn.createOffer();
}
break;
case 'peer-joined':
createConn(msg.peerId, ws, localStream);
break;
case 'offer':
(connectionsRef.current.get(msg.from) ?? createConn(msg.from, ws, localStream))
.handleOffer(msg.sdp);
break;
case 'answer':
await connectionsRef.current.get(msg.from)?.handleAnswer(msg.sdp);
break;
case 'ice-candidate':
await connectionsRef.current.get(msg.from)?.handleIceCandidate(msg.candidate);
break;
case 'peer-left':
connectionsRef.current.get(msg.peerId)?.close();
connectionsRef.current.delete(msg.peerId);
setPeers(prev => { const n = new Map(prev); n.delete(msg.peerId); return n; });
break;
}
};
}
function createConn(peerId: string, ws: WebSocket, stream: MediaStream) {
const conn = new PeerConnection(peerId, ws);
conn.addLocalStream(stream);
conn.onRemoteStream = (s) => setPeers(prev => new Map(prev).set(peerId, s));
connectionsRef.current.set(peerId, conn);
return conn;
}
start();
return () => {
localStream?.getTracks().forEach(t => t.stop());
connectionsRef.current.forEach(c => c.close());
signalRef.current?.close();
};
}, [roomId, userId]);
return (
<div className="video-grid">
<video ref={localVideoRef} autoPlay muted playsInline />
{[...peers.entries()].map(([peerId, stream]) => (
<RemoteVideo key={peerId} stream={stream} />
))}
</div>
);
}
function RemoteVideo({ stream }: { stream: MediaStream }) {
const ref = useRef<HTMLVideoElement>(null);
useEffect(() => { if (ref.current) ref.current.srcObject = stream; }, [stream]);
return <video ref={ref} autoPlay playsInline />;
}
Data Channels
Add a data channel for chat and cursor positions alongside video.
// Unreliable channel: UDP-like, good for cursor positions (drop old updates)
const cursorChannel = pc.createDataChannel('cursors', {
ordered: false,
maxRetransmits: 0,
});
// Reliable channel: TCP-like, for chat messages
const chatChannel = pc.createDataChannel('chat', {
ordered: true,
});
cursorChannel.onopen = () => {
// Start sending cursor positions
document.addEventListener('mousemove', ({ clientX, clientY }) => {
if (cursorChannel.bufferedAmount < 65536) { // Back-pressure
cursorChannel.send(JSON.stringify({ type: 'cursor', x: clientX, y: clientY }));
}
});
};
// Receive channels on the other peer
pc.ondatachannel = (event) => {
const channel = event.channel;
channel.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'cursor') updateRemoteCursor(msg.x, msg.y);
if (msg.type === 'chat') appendChatMessage(msg.text);
};
};
TURN Server and Credentials
// server: generate time-limited TURN credentials (RFC 5766)
import * as crypto from 'crypto';
export function generateTurnCredentials(userId: string) {
const ttl = 24 * 60 * 60;
const timestamp = Math.floor(Date.now() / 1000) + ttl;
const username = `${timestamp}:${userId}`;
const hmac = crypto.createHmac('sha1', process.env.TURN_SECRET!);
hmac.update(username);
return {
urls: ['turn:turn.myapp.com:3478', 'turns:turn.myapp.com:5349'],
username,
credential: hmac.digest('base64'),
};
}
app.get('/api/turn-credentials', requireAuth, (req, res) => {
res.json(generateTurnCredentials(req.user.id));
});
Connection Diagnostics
// Get stats: RTT, packet loss, bitrate
async function getConnectionStats(pc: RTCPeerConnection) {
const stats = await pc.getStats();
const report: Record<string, any> = {};
stats.forEach(stat => {
if (stat.type === 'candidate-pair' && stat.state === 'succeeded') {
report.rtt = stat.currentRoundTripTime;
report.bytesSent = stat.bytesSent;
}
if (stat.type === 'inbound-rtp' && stat.kind === 'video') {
report.packetsLost = stat.packetsLost;
report.jitter = stat.jitter;
}
});
return report;
}
// Check ICE candidate types to determine network path
// host = direct LAN, srflx = STUN (different networks), relay = TURN
pc.onicecandidate = ({ candidate }) => {
if (candidate) {
console.log('ICE candidate type:', candidate.type); // host | srflx | relay
}
};
// chrome://webrtc-internals for deep debugging
For the Node.js WebSocket server infrastructure, see the observability guide for monitoring connection health. For authentication on the signaling endpoint, the OAuth2 guide covers session management. The Claude Skills 360 bundle includes real-time communication skill sets covering WebRTC, signaling servers, and TURN configuration. Start with the free tier to try peer connection implementation.