← Retour au blog

WebRTC : streamer une vidéo en temps réel sans serveur média

Implémentation complète d'un système de streaming WebRTC peer-to-peer avec signaling via WebSocket. Cas réel d'une app de tournoi de tennis.

Le problème

Projet TennisPro : une app de gestion de tournois. Le client veut streamer les matchs en direct depuis les téléphones des arbitres vers l’écran géant.

Contraintes

Solution : WebRTC peer-to-peer avec signaling via WebSocket.

WebRTC en 3 minutes

WebRTC = Web Real-Time Communication. Standard W3C pour la communication temps réel navigateur-à-navigateur.

Ce que WebRTC fait

📱 Phone (broadcaster)  →  P2P  →  💻 Screen (viewer)
      │                               │
      └──── Signaling Server ─────────┘
           (WebSocket)

Sans serveur média : les peers se connectent directement. Le serveur ne fait que l’introduction.

Les 3 composants clés

1. MediaStream (getUserMedia)

// Capturer caméra + micro
const stream = await navigator.mediaDevices.getUserMedia({
  video: { facingMode: 'environment' }, // Caméra arrière
  audio: true
});

2. RTCPeerConnection

L’objet qui gère la connexion P2P

const pc = new RTCPeerConnection({
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' } // NAT traversal
  ]
});

3. Signaling (WebSocket, Socket.io, etc.)

WebRTC ne spécifie pas le signaling. C’est à nous de le coder.

But : échanger les métadonnées de connexion (SDP + ICE candidates)

Architecture complète

Fichier server.js (backend Node.js)

Le serveur WebSocket gère 3 rôles :

const broadcasters = new Map(); // courtId → {ws, id, name}
const rtcViewers   = new Map(); // ws → {id, courtId}

// Connexion WebSocket
wss.on('connection', (ws, req) => {
  const url    = new URL(req.url, `http://${req.headers.host}`);
  const token  = url.searchParams.get('token') || '';
  const role   = auth.resolveRole(token) || 'viewer';
  const id     = crypto.randomBytes(4).toString('hex');
  
  clients.set(ws, { role, id });
  
  // Envoyer l'état initial des streams live
  const rtcStatus = {};
  broadcasters.forEach((bc, courtId) => { 
    rtcStatus[courtId] = { live: true, name: bc.name }; 
  });
  ws.send(JSON.stringify({ 
    type: 'RTC_STATUS', 
    payload: rtcStatus 
  }));
});

Le flux complet (5 étapes)

Étape 1 : Broadcaster announce

Téléphone de l’arbitre démarre le stream

// Frontend (broadcaster)
ws.send(JSON.stringify({
  type: 'RTC_BROADCAST_START',
  payload: { courtId: 'court-1', courtName: 'Court Central' }
}));
// Backend
case 'RTC_BROADCAST_START': {
  const { courtId, courtName } = payload;
  broadcasters.set(courtId, { ws, id: meta.id, name: courtName });
  
  // Notifier tous les viewers
  broadcastRTCStatus();
  break;
}

Étape 2 : Viewer join

Un spectateur veut regarder le court

// Frontend (viewer)
ws.send(JSON.stringify({
  type: 'RTC_VIEWER_JOIN',
  payload: { courtId: 'court-1' }
}));
// Backend : relay au broadcaster
case 'RTC_VIEWER_JOIN': {
  const { courtId } = payload;
  rtcViewers.set(ws, { id: meta.id, courtId });
  
  const bc = broadcasters.get(courtId);
  if (bc && bc.ws.readyState === WebSocket.OPEN) {
    bc.ws.send(JSON.stringify({
      type: 'RTC_VIEWER_JOINED',
      payload: { viewerId: meta.id, courtId }
    }));
  }
  break;
}

Étape 3 : Offer (broadcaster → viewer)

Le broadcaster crée une offre SDP

// Frontend broadcaster
ws.on('message', async (msg) => {
  const data = JSON.parse(msg);
  
  if (data.type === 'RTC_VIEWER_JOINED') {
    const { viewerId } = data.payload;
    
    // Créer une PeerConnection pour ce viewer
    const pc = new RTCPeerConnection(rtcConfig);
    
    // Ajouter le stream local
    localStream.getTracks().forEach(track => {
      pc.addTrack(track, localStream);
    });
    
    // Créer l'offre
    const offer = await pc.createOffer();
    await pc.setLocalDescription(offer);
    
    // Envoyer via WebSocket
    ws.send(JSON.stringify({
      type: 'RTC_OFFER',
      payload: { 
        viewerId,
        courtId: 'court-1',
        sdp: offer
      }
    }));
  }
});
// Backend : relay au viewer
case 'RTC_OFFER': {
  const { viewerId, sdp } = payload;
  rtcViewers.forEach((v, vws) => {
    if (v.id === viewerId && vws.readyState === WebSocket.OPEN) {
      vws.send(JSON.stringify({ 
        type: 'RTC_OFFER', 
        payload: { sdp, courtId: payload.courtId } 
      }));
    }
  });
  break;
}

Étape 4 : Answer (viewer → broadcaster)

Le viewer répond avec sa config

// Frontend viewer
ws.on('message', async (msg) => {
  const data = JSON.parse(msg);
  
  if (data.type === 'RTC_OFFER') {
    const { sdp, courtId } = data.payload;
    
    const pc = new RTCPeerConnection(rtcConfig);
    
    // Recevoir le stream distant
    pc.ontrack = (event) => {
      videoElement.srcObject = event.streams[0];
    };
    
    // Accepter l'offre
    await pc.setRemoteDescription(sdp);
    
    // Créer la réponse
    const answer = await pc.createAnswer();
    await pc.setLocalDescription(answer);
    
    // Envoyer via WebSocket
    ws.send(JSON.stringify({
      type: 'RTC_ANSWER',
      payload: { courtId, sdp: answer }
    }));
  }
});
// Backend : relay au broadcaster
case 'RTC_ANSWER': {
  const { courtId, sdp } = payload;
  const bc = broadcasters.get(courtId);
  if (bc?.ws.readyState === WebSocket.OPEN) {
    bc.ws.send(JSON.stringify({ 
      type: 'RTC_ANSWER', 
      payload: { viewerId: meta.id, sdp } 
    }));
  }
  break;
}

Étape 5 : ICE candidates exchange

ICE = Interactive Connectivity Establishment. Trouve la meilleure route réseau.

// Frontend (broadcaster ET viewer)
pc.onicecandidate = (event) => {
  if (event.candidate) {
    ws.send(JSON.stringify({
      type: 'RTC_ICE_CANDIDATE',
      payload: {
        courtId: 'court-1',
        candidate: event.candidate,
        viewerId: viewerId // Si broadcaster→viewer
      }
    }));
  }
};
// Backend : relay bidirectionnel
case 'RTC_ICE_CANDIDATE': {
  const { courtId, candidate, viewerId } = payload;
  
  if (viewerId) {
    // Broadcaster → Viewer
    rtcViewers.forEach((v, vws) => {
      if (v.id === viewerId && vws.readyState === WebSocket.OPEN) {
        vws.send(JSON.stringify({ 
          type: 'RTC_ICE_CANDIDATE', 
          payload: { candidate, courtId } 
        }));
      }
    });
  } else {
    // Viewer → Broadcaster
    const bc = broadcasters.get(courtId);
    if (bc?.ws.readyState === WebSocket.OPEN) {
      bc.ws.send(JSON.stringify({ 
        type: 'RTC_ICE_CANDIDATE', 
        payload: { candidate, viewerId: meta.id } 
      }));
    }
  }
  break;
}

Les pièges (et comment les éviter)

Piège #1 : NAT traversal sur internet

Problème : les peers sont derrière des NAT/firewalls. STUN seul ne suffit pas (réussite ~60%)

Solution : ajouter un serveur TURN (relay fallback)

// Configuration complète
const rtcConfig = {
  iceServers: [
    // STUN : découverte d'IP publique (gratuit)
    { urls: 'stun:stun.l.google.com:19302' },
    
    // TURN : relay si P2P impossible (payant ~$0.01/GB)
    { 
      urls: 'turn:a.relay.metered.ca:443',
      username: 'tennispro',
      credential: process.env.METERED_API_KEY
    }
  ]
};

Services TURN gratuits (limites faibles) :

Coût réel : $0.40/GB chez Metered (mais souvent gratuit sur LAN/même réseau)

Piège #2 : Cleanup des connexions

Problème : broadcaster déconnecté → viewers affichent écran noir

Solution : cleanup systematique

// Backend
ws.on('close', () => {
  const meta = clients.get(ws) || {};
  cleanupRTC(ws, meta);
  clients.delete(ws);
});

function cleanupRTC(ws, meta) {
  // Si broadcaster : notifier tous les viewers
  if (meta.isBroadcaster && meta.courtId) {
    broadcasters.delete(meta.courtId);
    
    rtcViewers.forEach((v, vws) => {
      if (v.courtId === meta.courtId && vws.readyState === WebSocket.OPEN) {
        vws.send(JSON.stringify({ 
          type: 'RTC_STREAM_ENDED', 
          payload: { courtId: meta.courtId } 
        }));
      }
    });
    
    broadcastRTCStatus(); // MAJ du statut global
  }
  
  rtcViewers.delete(ws);
}

Piège #3 : 1 broadcaster → N viewers = N connexions

Problème : chaque viewer = 1 PeerConnection sur le broadcaster

📱 Broadcaster
  ├─ PC1 → Viewer 1
  ├─ PC2 → Viewer 2
  ├─ PC3 → Viewer 3
  └─ PC4 → Viewer 4

Upload requis : ~2 Mbps × N viewers

Limite mobile : ~10 viewers max (upload limité à 20 Mbps)

Solutions

Piège #4 : Mobile Safari & iOS contraintes

Problème : iOS 17.4+ requiert HTTPS pour getUserMedia (même en local!)

Solutions

# Dev local : certificat auto-signé
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes

# Node.js
const https = require('https');
const server = https.createServer({
  key: fs.readFileSync('key.pem'),
  cert: fs.readFileSync('cert.pem')
}, app);

Production : Caddy (auto HTTPS) ou Let’s Encrypt

# Caddy (le plus simple)
caddy reverse-proxy --from tennispro.local --to localhost:3000

Monitoring en production

Endpoint de statut RTC

// Backend
app.get('/api/rtc-status', (req, res) => {
  const status = {};
  broadcasters.forEach((bc, courtId) => { 
    status[courtId] = { live: true, name: bc.name }; 
  });
  res.json(status);
});

Health check

app.get('/api/health', (req, res) => {
  res.json({
    status: 'ok',
    uptime: process.uptime(),
    clients: clients.size,
    broadcasters: broadcasters.size,
    viewers: rtcViewers.size,
    ts: new Date().toISOString()
  });
});

Metrics utiles

// Frontend : qualité du stream
pc.getStats().then(stats => {
  stats.forEach(report => {
    if (report.type === 'inbound-rtp' && report.kind === 'video') {
      console.log('Bitrate:', report.bytesReceived, 'bytes');
      console.log('Packets lost:', report.packetsLost);
      console.log('Jitter:', report.jitter);
    }
  });
});

Résultats

Avant (tentative avec HLS)

Après (WebRTC)

Pour aller plus loin

Bibliothèques recommandées

Simple WebRTC (wrapper haut niveau)

npm install simple-peer
const SimplePeer = require('simple-peer');

// Broadcaster
const peer = new SimplePeer({ initiator: true, stream: localStream });
peer.on('signal', data => ws.send(JSON.stringify({ sdp: data })));
peer.on('error', err => console.error(err));

// Viewer
const peer = new SimplePeer({ initiator: false });
peer.on('signal', data => ws.send(JSON.stringify({ sdp: data })));
peer.on('stream', stream => video.srcObject = stream);

SFU pour scale (>10 viewers)

Janus Gateway (C, ultra-performant)

docker run -p 8088:8088 -p 8188:8188 meetecho/janus-gateway

mediasoup (Node.js, moderne)

npm install mediasoup

Alternatives complètes

Conclusion

WebRTC c’est 3 composants :

  1. MediaStream (caméra/micro)
  2. RTCPeerConnection (transmission P2P)
  3. Signaling custom (WebSocket pour échanger SDP/ICE)

Use cases parfaits :

À éviter si :

Le code complet de TennisPro est sur GitHub (lien en bio).

Question ? Ping-moi sur Twitter @fabiomeyer