Fundamentos WebRTC
WebRTC (Web Real-Time Communication) é uma API open-source que permite comunicação P2P de áudio, vídeo e dados diretamente entre browsers, sem plugins.
Arquitetura P2P vs cliente-servidor:
- Dados de mídia trafegam diretamente entre peers (sem passar pelo servidor após handshake)
- Servidor de sinalização é necessário apenas para o setup inicial
- Latência menor, custo de infra menor para o provedor
- Desvantagem: NAT traversal complexo, qualidade depende da rede dos peers
Componentes principais:
RTCPeerConnection— gerencia a conexão P2P (ICE, DTLS, SRTP)MediaStream— stream de áudio/vídeo capturado de câmera/mic/telaRTCDataChannel— canal de dados arbitrários P2P (como WebSocket, mas P2P)
Fluxo geral de uma chamada (Offer → Answer → ICE):
// Peer A (caller)
const pc = new RTCPeerConnection({ iceServers });
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// Envia offer via servidor de sinalização para Peer B
// Peer B (callee) — recebe offer
await pc.setRemoteDescription(offer);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
// Envia answer via sinalização para Peer A
// Peer A — recebe answer
await pc.setRemoteDescription(answer);
// Ambos trocam ICE candidates via sinalização
pc.onicecandidate = ({ candidate }) => {
if (candidate) signaling.send({ type: 'ice', candidate });
};
signaling.on('ice', async ({ candidate }) => {
await pc.addIceCandidate(candidate);
});Por que precisa de servidor de sinalização mesmo sendo P2P? Os peers precisam trocar SDP (descrição de mídia) e ICE candidates antes de se conectarem. Não há como fazer isso sem um canal inicial — é o “bootstrap” da conexão.
Casos de uso:
- Videochamada (1:1 ou em grupo via SFU)
- Screen share
- Transferência de arquivo P2P (DataChannel)
- Gaming multiplayer com baixa latência
- Live streaming direto do browser
Protocolo ICE, STUN e TURN
O problema do NAT traversal: A maioria dos devices está atrás de NAT (roteador doméstico, CGNAT). Peers não sabem seu IP público e não aceitam conexões de entrada diretamente.
ICE (Interactive Connectivity Establishment): Mecanismo que descobre e testa todos os caminhos possíveis de conexão, escolhendo o melhor.
Tipos de candidatos ICE:
host— IP local da interface de rede (192.168.x.x)srflx(server reflexive) — IP público descoberto via STUNrelay— endereço do servidor TURN (fallback quando P2P falha)
STUN Server: Responde com o IP público e porta do peer. Gratuito, baixo custo. Não retransmite dados.
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
});TURN Server: Retransmite todo o tráfego quando P2P falha (NAT simétrico, firewalls corporativos). Tem custo de bandwidth. Necessário para confiabilidade em produção.
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.exemplo.com' },
{
urls: 'turn:turn.exemplo.com:3478',
username: 'usuario',
credential: 'senha'
}
]
});Trickle ICE vs ICE completo:
- ICE completo: aguarda todos os candidatos antes de enviar — adiciona latência
- Trickle ICE (padrão): envia candidatos conforme são descobertos — mais rápido
Estados ICE:
pc.oniceconnectionstatechange = () => {
// new → checking → connected → completed
// disconnected → failed → closed
console.log('ICE state:', pc.iceConnectionState);
};
pc.onicegatheringstatechange = () => {
// new → gathering → complete
console.log('Gathering:', pc.iceGatheringState);
};SDP — Session Description Protocol
O que é SDP: Formato de texto que descreve as capacidades de mídia de um peer — codecs suportados, portas, endereços, parâmetros de segurança. Não é um protocolo de transporte, apenas descrição.
Estrutura básica:
v=0 (versão)
o=- 12345 2 IN IP4 127.0.0.1 (origem)
s=- (session name)
t=0 0 (timing)
m=audio 9 UDP/TLS/RTP/SAVPF 111 (mídia: tipo porta protocolo codecs)
a=rtpmap:111 opus/48000/2 (mapeamento codec)
a=fmtp:111 minptime=10;useinbandfec=1
m=video 9 UDP/TLS/RTP/SAVPF 96 97
a=rtpmap:96 VP8/90000
a=rtpmap:97 VP9/90000Offer/Answer Model:
- Offer descreve o que o caller suporta
- Answer confirma o subconjunto que o callee aceita
- Negociação de codecs acontece automaticamente
Codecs comuns negociados:
- Vídeo: VP8, VP9, H.264/AVC, AV1 (depende do browser)
- Áudio: Opus (padrão recomendado), G.711 (PCMU/PCMA)
SDP mungling (modificar SDP manualmente):
// EVITAR quando possível — frágil e não padronizado
// Usar RTCRtpSender.setParameters() no lugar
const offer = await pc.createOffer();
// Não faça isso em produção:
// offer.sdp = offer.sdp.replace('VP8', 'H264');
await pc.setLocalDescription(offer);Debug — decodificar SDP:
// Inspecionar SDP gerado
const offer = await pc.createOffer();
console.log(offer.sdp);
// Ou usar: https://webrtchacks.github.io/sdp-transform/RTCPeerConnection
Criar conexão:
const iceServers = [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'turn:turn.meuservidor.com', username: 'u', credential: 'p' }
];
const pc = new RTCPeerConnection({ iceServers });Ciclo completo de negociação:
// Criar offer (caller)
const offer = await pc.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true
});
await pc.setLocalDescription(offer);
// Criar answer (callee)
await pc.setRemoteDescription(remoteOffer);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
// Caller processa answer
await pc.setRemoteDescription(remoteAnswer);
// Adicionar candidato ICE recebido
await pc.addIceCandidate(new RTCIceCandidate(candidateData));Eventos principais:
// Candidato ICE local gerado — enviar via sinalização
pc.onicecandidate = (event) => {
if (event.candidate) {
signalingChannel.send({ type: 'candidate', candidate: event.candidate });
}
};
// Track remoto recebido — exibir no <video>
pc.ontrack = (event) => {
const [remoteStream] = event.streams;
remoteVideo.srcObject = remoteStream;
};
// Renegociação necessária (novo track adicionado, etc.)
pc.onnegotiationneeded = async () => {
await doOffer(); // chama createOffer → setLocalDescription → sinalização
};
// Monitorar estado da conexão
pc.onconnectionstatechange = () => {
if (pc.connectionState === 'connected') console.log('Conectado!');
if (pc.connectionState === 'failed') pc.restartIce();
};Fechar conexão corretamente:
// Parar todos os senders
pc.getSenders().forEach(sender => {
if (sender.track) sender.track.stop();
});
pc.close();Perfect Negotiation Pattern:
const polite = true; // definido pelo servidor de sinalização
let makingOffer = false;
pc.onnegotiationneeded = async () => {
try {
makingOffer = true;
await pc.setLocalDescription();
signalingChannel.send({ description: pc.localDescription });
} finally {
makingOffer = false;
}
};
signalingChannel.onmessage = async ({ data: { description, candidate } }) => {
if (description) {
const offerCollision = description.type === 'offer' &&
(makingOffer || pc.signalingState !== 'stable');
if (offerCollision && !polite) return; // impolite ignora
await pc.setRemoteDescription(description);
if (description.type === 'offer') {
await pc.setLocalDescription();
signalingChannel.send({ description: pc.localDescription });
}
} else if (candidate) {
try {
await pc.addIceCandidate(candidate);
} catch (e) {
if (!ignoreOffer) throw e;
}
}
};MediaStream e Mídia
getUserMedia — capturar câmera e microfone:
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 48000
},
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 30 },
facingMode: 'user' // 'environment' para câmera traseira
}
});
localVideo.srcObject = stream;getDisplayMedia — screen share:
const screenStream = await navigator.mediaDevices.getDisplayMedia({
video: { cursor: 'always' },
audio: true // captura áudio do sistema (suporte limitado)
});Adicionar tracks à conexão:
// Forma atual (recomendada)
stream.getTracks().forEach(track => pc.addTrack(track, stream));
// addStream está deprecated — evitar
// pc.addStream(stream);replaceTrack — trocar câmera sem renegociar:
const newStream = await navigator.mediaDevices.getUserMedia({ video: true });
const newTrack = newStream.getVideoTracks()[0];
const sender = pc.getSenders().find(s => s.track?.kind === 'video');
await sender.replaceTrack(newTrack);Mute local (sem renegociar):
const audioTrack = stream.getAudioTracks()[0];
audioTrack.enabled = false; // mute
audioTrack.enabled = true; // unmuteParar stream:
stream.getTracks().forEach(track => track.stop());Listar dispositivos:
const devices = await navigator.mediaDevices.enumerateDevices();
const cameras = devices.filter(d => d.kind === 'videoinput');
const mics = devices.filter(d => d.kind === 'audioinput');RTCDataChannel
Criar canal (no caller, antes do offer):
const dc = pc.createDataChannel('chat', {
ordered: true, // garante ordem de entrega
// maxRetransmits: 0, // sem retransmissão (UDP-like)
// maxPacketLifeTime: 500 // ms máximo para tentar entregar
});
dc.onopen = () => console.log('DataChannel aberto');
dc.onmessage = (e) => console.log('Recebido:', e.data);
dc.onerror = (e) => console.error('Erro:', e);
dc.onclose = () => console.log('Canal fechado');Receber canal no callee:
pc.ondatachannel = (event) => {
const dc = event.channel;
dc.onmessage = (e) => console.log('Msg:', e.data);
};Enviar dados:
dc.send('Olá!'); // string
dc.send(new ArrayBuffer(1024)); // binário
dc.send(new Uint8Array([1, 2, 3]).buffer);Backpressure — controle de fluxo:
function sendWithBackpressure(dc, data) {
const BUFFER_THRESHOLD = 16 * 1024; // 16KB
if (dc.bufferedAmount > BUFFER_THRESHOLD) {
dc.onbufferedamountlow = () => {
dc.onbufferedamountlow = null;
dc.send(data);
};
dc.bufferedAmountLowThreshold = BUFFER_THRESHOLD / 2;
} else {
dc.send(data);
}
}DataChannel vs WebSocket:
| Aspecto | DataChannel | WebSocket |
|---|---|---|
| Roteamento | P2P direto | Via servidor |
| Latência | Menor (sem servidor) | Maior |
| Confiabilidade | Configurável | TCP (ordenado) |
| Custo servidor | Zero (dados) | Bandwidth total |
| Setup | Complexo (ICE) | Simples |
Servidor de Sinalização
Por que é necessário: Peers precisam trocar SDP e ICE candidates antes de se conectar. O servidor não vê mídia — apenas mensagens de controle.
O que trafega:
offerSDP (do caller)answerSDP (do callee)- ICE candidates (ambos os lados, durante e após negociação)
Implementação simples com Node.js + ws:
// server.js
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
const rooms = new Map();
wss.on('connection', (ws) => {
ws.on('message', (raw) => {
const msg = JSON.parse(raw);
if (msg.type === 'join') {
const room = rooms.get(msg.room) || [];
room.push(ws);
rooms.set(msg.room, room);
// Avisa se há outro peer na sala
if (room.length === 2) {
room[0].send(JSON.stringify({ type: 'ready', polite: false }));
room[1].send(JSON.stringify({ type: 'ready', polite: true }));
}
return;
}
// Retransmite para o outro peer da sala
for (const [, peers] of rooms) {
if (peers.includes(ws)) {
const other = peers.find(p => p !== ws);
if (other?.readyState === WebSocket.OPEN) {
other.send(raw.toString());
}
break;
}
}
});
});Cliente — sequência completa:
const ws = new WebSocket('wss://meu-servidor.com/signal');
ws.onopen = () => ws.send(JSON.stringify({ type: 'join', room: 'sala-123' }));
ws.onmessage = async ({ data }) => {
const msg = JSON.parse(data);
if (msg.type === 'ready') {
isPolite = msg.polite;
if (!isPolite) await startCall(); // caller cria offer
}
if (msg.description) await handleDescription(msg.description);
if (msg.candidate) await pc.addIceCandidate(msg.candidate);
};
pc.onicecandidate = ({ candidate }) => {
if (candidate) ws.send(JSON.stringify({ candidate }));
};Perfect Negotiation Pattern
O problema de colisão de offers: Ambos os peers podem tentar criar offer simultaneamente (ex: ao adicionar tracks). Isso causa conflito de signalingState.
Solução — polite vs impolite peer:
- Polite: cede em caso de conflito — faz rollback do próprio offer e aceita o remoto
- Impolite: mantém seu offer e ignora o offer conflitante do remoto
Implementação completa (padrão W3C desde 2021):
let makingOffer = false;
let ignoreOffer = false;
const isPolite = true; // definido pelo servidor de sinalização
pc.onnegotiationneeded = async () => {
try {
makingOffer = true;
await pc.setLocalDescription(); // browser gera offer automaticamente
ws.send(JSON.stringify({ description: pc.localDescription }));
} catch (err) {
console.error(err);
} finally {
makingOffer = false;
}
};
ws.onmessage = async ({ data }) => {
const { description, candidate } = JSON.parse(data);
try {
if (description) {
const offerCollision =
description.type === 'offer' &&
(makingOffer || pc.signalingState !== 'stable');
ignoreOffer = !isPolite && offerCollision;
if (ignoreOffer) return;
await pc.setRemoteDescription(description);
if (description.type === 'offer') {
await pc.setLocalDescription();
ws.send(JSON.stringify({ description: pc.localDescription }));
}
} else if (candidate) {
try {
await pc.addIceCandidate(candidate);
} catch (e) {
if (!ignoreOffer) throw e;
}
}
} catch (err) {
console.error(err);
}
};Por que usar:
- Elimina race conditions na sinalização
- Funciona com renegociação (adicionar/remover tracks em chamada ativa)
- É o padrão recomendado pelo W3C e MDN
Codecs e Qualidade
Codecs de vídeo:
| Codec | Chrome | Firefox | Safari | Notas |
|---|---|---|---|---|
| VP8 | Sim | Sim | Sim | Padrão histórico, amplo suporte |
| VP9 | Sim | Sim | Sim (14+) | Melhor qualidade que VP8 |
| H.264 | Sim | Sim | Sim | Hardware accelerated, licenciado |
| AV1 | Sim | Sim | Não | Melhor compressão, mais CPU |
Codec de áudio: Opus é padrão obrigatório para todos os browsers WebRTC.
Limitar bitrate via RTCRtpSender:
const sender = pc.getSenders().find(s => s.track?.kind === 'video');
const params = sender.getParameters();
params.encodings[0].maxBitrate = 500_000; // 500 kbps
await sender.setParameters(params);Monitorar qualidade com getStats:
async function logStats(pc) {
const stats = await pc.getStats();
stats.forEach(report => {
if (report.type === 'inbound-rtp' && report.kind === 'video') {
console.log({
bitrate: report.bytesReceived,
packetLoss: report.packetsLost,
jitter: report.jitter,
framesDecoded: report.framesDecoded
});
}
if (report.type === 'candidate-pair' && report.state === 'succeeded') {
console.log('RTT:', report.currentRoundTripTime * 1000, 'ms');
}
});
}
setInterval(() => logStats(pc), 2000);Simulcast — enviar múltiplas resoluções simultaneamente:
const sender = pc.addTrack(videoTrack, stream);
const params = sender.getParameters();
params.encodings = [
{ rid: 'high', maxBitrate: 900_000 },
{ rid: 'mid', maxBitrate: 300_000, scaleResolutionDownBy: 2 },
{ rid: 'low', maxBitrate: 100_000, scaleResolutionDownBy: 4 }
];
await sender.setParameters(params);Casos de Uso Práticos
Videochamada 1:1 — fluxo completo:
async function startCall() {
// 1. Capturar mídia local
const stream = await navigator.mediaDevices.getUserMedia({
video: true, audio: true
});
localVideo.srcObject = stream;
// 2. Criar conexão
const pc = new RTCPeerConnection({ iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]});
// 3. Adicionar tracks
stream.getTracks().forEach(t => pc.addTrack(t, stream));
// 4. Receber mídia remota
pc.ontrack = ({ streams: [remote] }) => {
remoteVideo.srcObject = remote;
};
// 5. ICE candidates
pc.onicecandidate = ({ candidate }) => {
if (candidate) ws.send(JSON.stringify({ candidate }));
};
// 6. Criar e enviar offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
ws.send(JSON.stringify({ description: pc.localDescription }));
return pc;
}Screen share com troca de track:
async function shareScreen(pc) {
const screen = await navigator.mediaDevices.getDisplayMedia({ video: true });
const screenTrack = screen.getVideoTracks()[0];
const sender = pc.getSenders().find(s => s.track?.kind === 'video');
await sender.replaceTrack(screenTrack);
// Restaurar câmera quando parar de compartilhar
screenTrack.onended = async () => {
const cam = await navigator.mediaDevices.getUserMedia({ video: true });
await sender.replaceTrack(cam.getVideoTracks()[0]);
};
}Transferência de arquivo P2P via DataChannel:
async function sendFile(dc, file) {
const CHUNK_SIZE = 16384; // 16KB
const buffer = await file.arrayBuffer();
const total = buffer.byteLength;
// Enviar metadados primeiro
dc.send(JSON.stringify({ name: file.name, size: total, type: file.type }));
// Enviar chunks com controle de backpressure
let offset = 0;
dc.bufferedAmountLowThreshold = CHUNK_SIZE;
function sendNext() {
while (offset < total) {
if (dc.bufferedAmount > 16 * CHUNK_SIZE) {
dc.onbufferedamountlow = sendNext;
return;
}
const chunk = buffer.slice(offset, offset + CHUNK_SIZE);
dc.send(chunk);
offset += chunk.byteLength;
}
dc.send(JSON.stringify({ done: true }));
}
sendNext();
}
// Receptor
let receivedChunks = [];
let fileInfo = null;
dc.onmessage = ({ data }) => {
if (typeof data === 'string') {
const msg = JSON.parse(data);
if (msg.name) { fileInfo = msg; receivedChunks = []; }
if (msg.done) {
const blob = new Blob(receivedChunks, { type: fileInfo.type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = fileInfo.name; a.click();
}
} else {
receivedChunks.push(data);
}
};SFU vs MCU vs Mesh para chamadas em grupo:
| Topologia | Escalabilidade | Latência | Custo servidor | Qualidade |
|---|---|---|---|---|
| Mesh P2P | Ruim (>4 peers) | Mínima | Zero | Boa (direto) |
| SFU | Boa (100+ peers) | Baixa | Médio (routing) | Boa |
| MCU | Excelente | Média | Alto (transcodificação) | Variável |
SFUs open-source: mediasoup, Janus Gateway, LiveKit, Ion-SFU
Segurança e Considerações
DTLS obrigatório: toda mídia WebRTC é criptografada via DTLS-SRTP. Não é opcional — faz parte da especificação.
SRTP (Secure RTP): camada de criptografia sobre RTP para streams de mídia.
HTTPS/WSS obrigatório:
// getUserMedia só funciona em contexto seguro
// HTTP → bloqueado (exceto localhost)
// HTTPS → funciona
// Mesmo para o servidor de sinalização: use WSS, não WS
const ws = new WebSocket('wss://meu-servidor.com/signal');Permissões de câmera/microfone:
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
} catch (err) {
if (err.name === 'NotAllowedError') {
// Usuário negou — ou site não está em HTTPS
}
if (err.name === 'NotFoundError') {
// Dispositivo não encontrado
}
}Privacidade — ocultar IP real via TURN:
// Força uso do TURN relay — peers não veem IP real um do outro
const pc = new RTCPeerConnection({
iceTransportPolicy: 'relay', // 'all' é o padrão
iceServers: [{ urls: 'turn:...', username: '...', credential: '...' }]
});Proteção do servidor de sinalização:
- Autenticar conexões WebSocket (JWT)
- Rate limiting por IP
- Gerar credenciais TURN temporárias com HMAC-SHA1 (RFC 5389)
Ferramentas e Debug
chrome://webrtc-internals: Ferramenta mais poderosa para debug. Mostra:
- SDP completo (offer/answer com timestamps)
- Estado ICE e candidatos coletados
- Gráficos de bitrate, packetLoss, jitter, RTT em tempo real
- Dump exportável para análise offline
Verificar suporte a codecs:
const capabilities = RTCRtpReceiver.getCapabilities('video');
console.log(capabilities.codecs.map(c => c.mimeType));
// ['video/VP8', 'video/VP9', 'video/H264', 'video/AV1']STUN servers gratuitos (Google):
stun:stun.l.google.com:19302
stun:stun1.l.google.com:19302
stun:stun2.l.google.com:19302coturn — servidor TURN open-source:
# Instalar (Ubuntu/Debian)
sudo apt install coturn
# /etc/turnserver.conf
listening-port=3478
tls-listening-port=5349
fingerprint
lt-cred-mech
realm=meudominio.com
server-name=meudominio.com
user=usuario:senha
log-file=/var/log/coturn/turnserver.log
sudo systemctl enable coturn
sudo systemctl start coturnadapter.js — compatibilidade cross-browser:
<script src="https://webrtc.github.io/adapter/adapter-latest.js"></script>Normaliza diferenças de API entre Chrome, Firefox e Safari (prefixos, argumentos, comportamentos).
Teste rápido de conectividade ICE:
async function checkConnectivity(iceServers) {
return new Promise((resolve) => {
const pc = new RTCPeerConnection({ iceServers });
pc.createDataChannel('test');
pc.createOffer().then(o => pc.setLocalDescription(o));
const candidates = [];
pc.onicecandidate = ({ candidate }) => {
if (candidate) candidates.push(candidate.type);
else resolve({
hasHost: candidates.includes('host'),
hasSrflx: candidates.includes('srflx'),
hasRelay: candidates.includes('relay')
});
};
setTimeout(() => { pc.close(); resolve({ timeout: true }); }, 5000);
});
}