Implementando Videochamada Web com WebRTC e Socket.IO

Este artigo demonstra como construir uma aplicação de videochamada peer-to-peer utilizando WebRTC no front end e um servidor Node.js com Socket.IO para sinalização.

Servidor de Sinalização (Node.js + Express + Socket.IO)

O servidor atua como intermediário para trocar mensagesn de sinalização (ofertas SDP, respostas SDP e candidatos ICE) antre os peers conectados:

const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const path = require('path');

const app = express();
const httpServer = http.createServer(app);
const io = new Server(httpServer, {
  cors: { origin: '*' }
});

app.get('/', (req, res) => {
  res.sendFile(path.join(__dirname, 'index.html'));
});

// Mapa de clientes conectados
const connectedClients = new Map();

io.on('connection', (client) => {
  console.log(`Cliente conectado: ${client.id}`);
  connectedClients.set(client.id, client);
  console.log(`Total online: ${connectedClients.size}`);

  // Repassar candidatos ICE
  client.on('relay_ice', (candidate) => {
    for (const [id, peer] of connectedClients) {
      if (id !== client.id) {
        peer.emit('relay_ice', candidate);
      }
    }
  });

  // Repassar oferta SDP
  client.on('relay_offer', (sdp) => {
    for (const [id, peer] of connectedClients) {
      if (id !== client.id) {
        peer.emit('relay_offer', sdp);
      }
    }
  });

  // Repassar resposta SDP
  client.on('relay_answer', (sdp) => {
    for (const [id, peer] of connectedClients) {
      if (id !== client.id) {
        peer.emit('relay_answer', sdp);
      }
    }
  });

  client.on('disconnect', () => {
    console.log(`Cliente desconectado: ${client.id}`);
    connectedClients.delete(client.id);
  });
});

httpServer.listen(5544, () => {
  console.log(`Servidor ativo em http://localhost:5544`);
});

Frontend: index.html

A página HTML captura o stream de mídia local (câmera ou compartilhamento de tela), estabelece a conexão peer-to-peer via RTCPeerConnection e gerencia a troca de SDP e ICE candidates através do Socket.IO:


<html lang="pt-BR">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Videochamada WebRTC</title>
  <style>
    .video-box {
      width: 320px;
      height: 200px;
      margin: 10px;
      background: #ddd;
    }
    #local-stream { border: 3px solid #2ecc71; }
    #remote-stream { border: 3px solid #3498db; }
    .layout {
      display: flex;
      flex-direction: column;
      align-items: center;
    }
  </style>
</head>
<body>
  <div class="layout">
    <h2>Videochamada WebRTC</h2>
    <div>
      <p>Stream Local:</p>
      <video id="local-stream" class="video-box" autoplay muted></video>
    </div>
    <div>
      <p>Stream Remoto:</p>
      <video id="remote-stream" class="video-box" autoplay></video>
    </div>
  </div>
  <script src="/socket.io/socket.io.js"></script>
  <script>
    const rtcConfig = {
      iceServers: [
        { urls: 'stun:stun.l.google.com:19302' }
      ]
    };

    const peerConn = new RTCPeerConnection(rtcConfig);
    const socketClient = io('ws://127.0.0.1:5544');

    socketClient.on('connect', () => {
      console.log('Conectado ao servidor de sinalização');
    });

    socketClient.on('relay_ice', async (candidate) => {
      try {
        await peerConn.addIceCandidate(JSON.parse(candidate));
      } catch (e) {
        console.error('Erro ao adicionar ICE candidate:', e);
      }
    });

    socketClient.on('relay_offer', async (sdp) => {
      const desc = JSON.parse(sdp);
      await peerConn.setRemoteDescription(desc);
      const answer = await peerConn.createAnswer();
      await peerConn.setLocalDescription(answer);
      socketClient.emit('relay_answer', JSON.stringify(answer));
    });

    socketClient.on('relay_answer', async (sdp) => {
      const desc = JSON.parse(sdp);
      await peerConn.setRemoteDescription(desc);
    });

    peerConn.ontrack = (event) => {
      document.getElementById('remote-stream').srcObject = event.streams[0];
    };

    peerConn.onicecandidate = (event) => {
      if (event.candidate) {
        socketClient.emit('relay_ice', JSON.stringify(event.candidate));
      }
    };

    async function initMedia() {
      try {
        const stream = await navigator.mediaDevices.getUserMedia({
          video: true,
          audio: false
        });
        attachStream(stream);
      } catch (e) {
        console.log('Câmera indisponível, tentando compartilhamento de tela...');
        const screenStream = await navigator.mediaDevices.getDisplayMedia({
          video: true,
          audio: false
        });
        attachStream(screenStream);
      }
    }

    async function attachStream(stream) {
      document.getElementById('local-stream').srcObject = stream;
      stream.getTracks().forEach((track) => {
        peerConn.addTrack(track, stream);
      });

      const offer = await peerConn.createOffer({
        offerToReceiveAudio: true,
        offerToReceiveVideo: true
      });
      await peerConn.setLocalDescription(offer);
      socketClient.emit('relay_offer', JSON.stringify(offer));
    }

    initMedia();
  </script>
</body>
</html>

Como executar

Instale as dependências necessárias:

npm install express socket.io

Inicie o servidor com node server.js e acesse http://localhost:5544 em duas abas ou navegadores diferentes. O servidor STUN público do Google é utilizado para descoberta de ICE candidates. Como não há HTTPS configurado, a aplicação deve ser executada em ambiente local. Se a câmera não estiver disponível, o código automaticamente tenta utilizar o compartilhamento de tela como fallback.

Tags: WebRTC Socket.io Node.js express RTCPeerConnection

Publicado em 9-13 21:32