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
- Latence < 1 seconde (impossible avec HLS/DASH)
- Pas de budget pour serveur média type Wowza/Ant Media (~$500/mois)
- Doit fonctionner sur réseau local ET internet
- Support mobile (arbitres utilisent leur téléphone)
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) :
- Metered.ca (50GB/mois gratuit)
- Twilio (10GB gratuit)
- Xirsys (1GB gratuit)
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
- Si > 10 viewers : utiliser SFU (Selective Forwarding Unit) type Janus/mediasoup
- Si budget : Cloudflare Stream (~$1/1000 minutes)
- Réseau local : aucun problème (WiFi = 100+ Mbps)
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)
- Latence : 8-15 secondes
- Setup complexe (FFmpeg + nginx-rtmp)
- CPU serveur : 60%
Après (WebRTC)
- Latence : 200-400ms
- Code simple (200 lignes backend)
- CPU serveur : 5% (signaling seulement)
- Coût TURN : $0 (réseau local)
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
- LiveKit (open-source, batteries included)
- Agora (commercial, SDK facile)
- Daily.co (SaaS, 10k minutes/mois gratuit)
Conclusion
WebRTC c’est 3 composants :
- MediaStream (caméra/micro)
- RTCPeerConnection (transmission P2P)
- Signaling custom (WebSocket pour échanger SDP/ICE)
Use cases parfaits :
- Visio 1-to-1 ou petit groupe
- Streaming local (< 10 viewers)
- Gaming temps réel
- Broadcast événementiel ponctuel
À éviter si :
-
50 viewers simultanés (utiliser SFU/MCU)
- Compatibilité IE11 requise
- Besoin d’enregistrement server-side
Le code complet de TennisPro est sur GitHub (lien en bio).
Question ? Ping-moi sur Twitter @fabiomeyer