Claude Code for WebRTC: Peer-to-Peer Video, Signaling Servers, and Data Channels — Claude Skills 360 Blog
Blog / Frontend / Claude Code for WebRTC: Peer-to-Peer Video, Signaling Servers, and Data Channels
Frontend

Claude Code for WebRTC: Peer-to-Peer Video, Signaling Servers, and Data Channels

Published: October 2, 2026
Read time: 9 min read
By: Claude Skills 360

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.

Keep Reading

Frontend

Claude Code for Chart.js Advanced: Custom Plugins and Mixed Charts

Advanced Chart.js patterns with Claude Code — chart.register() for tree-shaking, mixed chart types combining bar and line, custom plugin API with beforeDraw and afterDatasetsDraw hooks, ScriptableContext for computed colors, ChartDataLabels plugin for value labels, chartjs-plugin-zoom for pan and zoom, custom gradient fills via ctx.createLinearGradient, ChartJS annotation plugin for threshold lines, streaming data with chartjs-plugin-streaming, and react-chartjs-2 with useRef and chart instance.

6 min read Jun 27, 2027
Frontend

Claude Code for Nivo: Rich SVG and Canvas Charts

Build rich data visualizations with Nivo and Claude Code — ResponsiveLine and ResponsiveBar for adaptive charts, ResponsiveHeatMap for matrix data, ResponsiveTreeMap for hierarchal data, ResponsiveSunburst for nested proportions, ResponsiveChord for relationship diagrams, ResponsiveCalendar for activity heat maps, ResponsiveNetwork for force graphs, NivoTheme for consistent styling, tooltip customization with sliceTooltip, and motion config for spring animations.

6 min read Jun 26, 2027
Frontend

Claude Code for Victory Charts: React Native and Web Charts

Build cross-platform charts with Victory and Claude Code — VictoryChart, VictoryLine, VictoryBar, and VictoryScatter for web and React Native, VictoryPie for donut charts, VictoryArea for stacked areas, VictoryAxis for custom axes, VictoryTooltip and VictoryVoronoiContainer for hover tooltips, VictoryBrushContainer for range selection, VictoryZoomContainer for pan and zoom, VictoryLegend for series labels, custom theme with VictoryTheme, and VictoryStack for grouped bars.

6 min read Jun 25, 2027

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free