Protocolos & APIs

WebRTC

Referência completa de WebRTC — arquitetura P2P, ICE/STUN/TURN, SDP, RTCPeerConnection, streams de mídia, data channels, sinalização e casos de uso reais

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/tela
  • RTCDataChannel — 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 STUN
  • relay — 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/90000

Offer/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;  // unmute

Parar 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:

AspectoDataChannelWebSocket
RoteamentoP2P diretoVia servidor
LatênciaMenor (sem servidor)Maior
ConfiabilidadeConfigurávelTCP (ordenado)
Custo servidorZero (dados)Bandwidth total
SetupComplexo (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:

  • offer SDP (do caller)
  • answer SDP (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:

CodecChromeFirefoxSafariNotas
VP8SimSimSimPadrão histórico, amplo suporte
VP9SimSimSim (14+)Melhor qualidade que VP8
H.264SimSimSimHardware accelerated, licenciado
AV1SimSimNãoMelhor 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:

TopologiaEscalabilidadeLatênciaCusto servidorQualidade
Mesh P2PRuim (>4 peers)MínimaZeroBoa (direto)
SFUBoa (100+ peers)BaixaMédio (routing)Boa
MCUExcelenteMédiaAlto (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:19302

coturn — 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 coturn

adapter.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);
  });
}