Protocolos & APIs

WebSocket

Referência completa de WebSocket — protocolo, API browser, Node.js, autenticação, rooms, heartbeat, reconexão e comparativo com SSE e HTTP polling

Fundamentos WebSocket

WebSocket é um protocolo full-duplex sobre TCP, diferente do HTTP que é half-duplex (cliente sempre inicia). Com WS, tanto cliente quanto servidor podem enviar mensagens a qualquer momento, mantendo uma única conexão persistente.

Handshake HTTP Upgrade

A conexão começa como HTTP e é “upgraded” para WebSocket:

// Requisição do cliente
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Sec-WebSocket-Protocol: chat, superchat

// Resposta do servidor — 101 Switching Protocols
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat

O Sec-WebSocket-Accept é calculado: base64(sha1(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")).

Framing

Cada mensagem WS é enviada em frames:

  • FIN — indica último frame do fragmento
  • opcode — 0x1 (text), 0x2 (binary), 0x8 (close), 0x9 (ping), 0xA (pong)
  • masking — cliente SEMPRE mascara frames; servidor NUNCA mascara
  • payload length — 7 bits, 16 bits ou 64 bits

Subprotocolos e Extensões

Sec-WebSocket-Protocol: graphql-ws, json-rpc
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits

permessage-deflate comprime o payload com zlib — reduz bandwidth mas aumenta CPU.

Estados da Conexão

ConstanteValorDescrição
CONNECTING0Handshake em andamento
OPEN1Conexão estabelecida
CLOSING2Close handshake iniciado
CLOSED3Conexão encerrada

API Browser (WebSocket nativo)

// Criar conexão — segundo argumento é subprotocolo(s)
const ws = new WebSocket('wss://example.com/chat', ['chat', 'json-rpc']);

// Verificar estado antes de enviar
console.log(ws.readyState); // 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED

// Configurar tipo de dado binário recebido
ws.binaryType = 'arraybuffer'; // padrão: 'blob'

// Eventos
ws.onopen = (event) => {
  console.log('Conectado, protocolo negociado:', ws.protocol);
  ws.send(JSON.stringify({ type: 'auth', token: 'Bearer xyz' }));
};

ws.onmessage = (event) => {
  if (typeof event.data === 'string') {
    const msg = JSON.parse(event.data);
    console.log('Mensagem recebida:', msg);
  } else if (event.data instanceof ArrayBuffer) {
    const view = new DataView(event.data);
    console.log('Binário recebido, bytes:', event.data.byteLength);
  }
};

ws.onerror = (event) => {
  // event não contém detalhes do erro por segurança
  console.error('Erro WebSocket');
};

ws.onclose = (event) => {
  console.log('Fechado:', event.code, event.reason, 'limpo:', event.wasClean);
  // event.code — código numérico
  // event.reason — string enviada pelo servidor
  // event.wasClean — true se close handshake foi completo
};

// Enviar dados
ws.send('Olá servidor!');
ws.send(JSON.stringify({ type: 'message', content: 'Oi' }));

// Enviar binário
const buffer = new ArrayBuffer(4);
new DataView(buffer).setUint32(0, 42);
ws.send(buffer);

// Verificar dados em buffer (não enviados ainda)
console.log('Bytes em fila:', ws.bufferedAmount);

// Fechar com código e motivo
ws.close(1000, 'Encerrando sessão');

Códigos de Fechamento Importantes

CódigoSignificado
1000Normal closure — encerramento limpo
1001Going away — página fechando, servidor reiniciando
1002Protocol error
1003Unsupported data type
1006Anormal — sem close frame (queda de rede)
1008Policy violation — ex.: auth falhou
1009Message too big
1011Internal server error
4000+Customizados pela aplicação

Reconexão e Heartbeat no Cliente

class ReconnectingWebSocket {
  constructor(url, protocols = []) {
    this.url = url;
    this.protocols = protocols;
    this.ws = null;
    this.retries = 0;
    this.maxRetries = 10;
    this.baseDelay = 500;    // ms
    this.maxDelay = 30000;   // ms
    this.heartbeatInterval = 25000; // ms
    this.heartbeatTimer = null;
    this.pongTimer = null;
    this.shouldReconnect = true;
    this.messageHandlers = [];

    this.connect();
  }

  connect() {
    this.ws = new WebSocket(this.url, this.protocols);

    this.ws.onopen = () => {
      console.log('Conectado');
      this.retries = 0;
      this.startHeartbeat();
    };

    this.ws.onmessage = (event) => {
      // Resetar heartbeat ao receber qualquer mensagem
      this.resetHeartbeat();

      const data = JSON.parse(event.data);
      if (data.type === 'pong') return; // ignorar pong interno

      this.messageHandlers.forEach(fn => fn(data));
    };

    this.ws.onclose = (event) => {
      this.stopHeartbeat();
      if (this.shouldReconnect && event.code !== 1000) {
        this.scheduleReconnect();
      }
    };

    this.ws.onerror = () => {
      this.ws.close();
    };
  }

  // Backoff exponencial com jitter
  getDelay() {
    const exp = Math.min(this.retries, 10);
    const delay = Math.min(this.baseDelay * 2 ** exp, this.maxDelay);
    // Jitter: ±20% do delay para evitar thundering herd
    const jitter = delay * 0.2 * (Math.random() * 2 - 1);
    return Math.floor(delay + jitter);
  }

  scheduleReconnect() {
    if (this.retries >= this.maxRetries) {
      console.error('Max retries atingido, desistindo');
      return;
    }
    const delay = this.getDelay();
    console.log(`Reconectando em ${delay}ms (tentativa ${this.retries + 1})`);
    setTimeout(() => {
      this.retries++;
      this.connect();
    }, delay);
  }

  startHeartbeat() {
    this.heartbeatTimer = setInterval(() => {
      if (this.ws.readyState === WebSocket.OPEN) {
        this.ws.send(JSON.stringify({ type: 'ping' }));
        // Se não receber pong em 5s, considerar conexão morta
        this.pongTimer = setTimeout(() => {
          console.warn('Sem resposta ao ping — forçando reconexão');
          this.ws.close(1006);
        }, 5000);
      }
    }, this.heartbeatInterval);
  }

  resetHeartbeat() {
    clearTimeout(this.pongTimer);
  }

  stopHeartbeat() {
    clearInterval(this.heartbeatTimer);
    clearTimeout(this.pongTimer);
  }

  send(data) {
    if (this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(data));
    }
  }

  onMessage(fn) {
    this.messageHandlers.push(fn);
  }

  close() {
    this.shouldReconnect = false;
    this.ws.close(1000, 'Cliente encerrando');
  }
}

// Uso
const ws = new ReconnectingWebSocket('wss://example.com/ws');
ws.onMessage((msg) => console.log('Recebido:', msg));
ws.send({ type: 'hello', userId: 123 });

A biblioteca reconnecting-websocket (npm) implementa esse padrão como drop-in replacement para o WebSocket nativo.


WebSocket com Node.js (ws library)

// npm install ws
const { WebSocketServer, WebSocket } = require('ws');
const https = require('https');
const fs = require('fs');

// Servidor HTTP simples
const server = https.createServer({
  cert: fs.readFileSync('./cert.pem'),
  key: fs.readFileSync('./key.pem'),
});

const wss = new WebSocketServer({ server });

// Mapa de clientes com metadados
const clients = new Map(); // ws -> { userId, isAlive }

wss.on('connection', (ws, req) => {
  const ip = req.socket.remoteAddress;
  console.log('Nova conexão de', ip);

  clients.set(ws, { isAlive: true, userId: null });

  ws.on('message', (data, isBinary) => {
    if (isBinary) {
      console.log('Binário recebido:', data.length, 'bytes');
      return;
    }

    let msg;
    try {
      msg = JSON.parse(data.toString());
    } catch {
      ws.close(1007, 'Invalid JSON');
      return;
    }

    console.log('Mensagem:', msg);
    handleMessage(ws, msg);
  });

  ws.on('close', (code, reason) => {
    console.log('Conexão fechada:', code, reason.toString());
    clients.delete(ws);
  });

  ws.on('error', (err) => {
    console.error('Erro no socket:', err.message);
    clients.delete(ws);
  });

  ws.on('pong', () => {
    const meta = clients.get(ws);
    if (meta) meta.isAlive = true;
  });
});

// Broadcast para todos os clientes conectados
function broadcast(message, excludeWs = null) {
  const data = JSON.stringify(message);
  wss.clients.forEach((ws) => {
    if (ws !== excludeWs && ws.readyState === WebSocket.OPEN) {
      ws.send(data, (err) => {
        if (err) console.error('Erro ao enviar broadcast:', err);
      });
    }
  });
}

// Heartbeat server-side para detectar conexões mortas (zumbis)
const heartbeatInterval = setInterval(() => {
  wss.clients.forEach((ws) => {
    const meta = clients.get(ws);
    if (!meta) return;

    if (!meta.isAlive) {
      console.log('Cliente zumbi detectado, encerrando');
      clients.delete(ws);
      return ws.terminate(); // força fechamento sem close handshake
    }

    meta.isAlive = false;
    ws.ping(); // dispara evento 'pong' no cliente
  });
}, 30000);

wss.on('close', () => clearInterval(heartbeatInterval));

server.listen(8443, () => console.log('WSS rodando em :8443'));

// Enviar com tratamento de erro
function safeSend(ws, data) {
  if (ws.readyState !== WebSocket.OPEN) return;
  ws.send(JSON.stringify(data), (err) => {
    if (err) console.error('Falha ao enviar:', err.message);
  });
}

Rooms e Namespaces (sem Socket.IO)

// Gerenciamento de rooms com Map
const rooms = new Map(); // roomId -> Set<WebSocket>
const clientRooms = new Map(); // ws -> Set<roomId>

function joinRoom(ws, roomId) {
  if (!rooms.has(roomId)) {
    rooms.set(roomId, new Set());
  }
  rooms.get(roomId).add(ws);

  if (!clientRooms.has(ws)) {
    clientRooms.set(ws, new Set());
  }
  clientRooms.get(ws).add(roomId);

  console.log(`Cliente entrou na room: ${roomId}`);
}

function leaveRoom(ws, roomId) {
  const room = rooms.get(roomId);
  if (room) {
    room.delete(ws);
    if (room.size === 0) rooms.delete(roomId); // limpar rooms vazias
  }
  clientRooms.get(ws)?.delete(roomId);
}

function leaveAllRooms(ws) {
  const myRooms = clientRooms.get(ws);
  if (myRooms) {
    myRooms.forEach((roomId) => leaveRoom(ws, roomId));
    clientRooms.delete(ws);
  }
}

function broadcastToRoom(roomId, message, excludeWs = null) {
  const room = rooms.get(roomId);
  if (!room) return;

  const data = JSON.stringify(message);
  room.forEach((ws) => {
    if (ws !== excludeWs && ws.readyState === WebSocket.OPEN) {
      ws.send(data);
    }
  });
}

// Mensagem direta (DM)
function sendDirect(targetWs, message) {
  if (targetWs.readyState === WebSocket.OPEN) {
    targetWs.send(JSON.stringify(message));
  }
}

// Estrutura de mensagem padronizada com roteamento
function handleMessage(ws, msg) {
  // msg sempre tem: { type, payload, roomId? }
  switch (msg.type) {
    case 'join':
      joinRoom(ws, msg.roomId);
      safeSend(ws, { type: 'joined', roomId: msg.roomId });
      broadcastToRoom(msg.roomId, {
        type: 'user_joined',
        userId: clients.get(ws)?.userId,
      }, ws);
      break;

    case 'leave':
      leaveRoom(ws, msg.roomId);
      broadcastToRoom(msg.roomId, { type: 'user_left', userId: clients.get(ws)?.userId });
      break;

    case 'message':
      broadcastToRoom(msg.roomId, {
        type: 'message',
        userId: clients.get(ws)?.userId,
        content: msg.payload,
        timestamp: Date.now(),
      });
      break;

    case 'dm':
      // Encontrar ws do destinatário pelo userId
      const target = [...clients.entries()]
        .find(([_, meta]) => meta.userId === msg.targetUserId)?.[0];
      if (target) sendDirect(target, { type: 'dm', from: clients.get(ws)?.userId, content: msg.payload });
      break;

    default:
      safeSend(ws, { type: 'error', message: `Tipo desconhecido: ${msg.type}` });
  }
}

// Limpar ao desconectar
wss.on('connection', (ws) => {
  ws.on('close', () => leaveAllRooms(ws));
});

Autenticação

// OPÇÃO 1 — Token no query string (NÃO recomendado em produção)
// wss://example.com/ws?token=eyJhbGci...
// Problema: token fica no log do servidor, history do browser, referrer headers

// OPÇÃO 2 — Token no primeiro message (PADRÃO RECOMENDADO)
const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET;

// Mapa de clientes aguardando autenticação
const pendingAuth = new Map(); // ws -> setTimeout handle

wss.on('connection', (ws, req) => {
  // Dar 5s para o cliente se autenticar
  const authTimeout = setTimeout(() => {
    if (!clients.get(ws)?.userId) {
      ws.close(1008, 'Authentication timeout');
    }
  }, 5000);

  pendingAuth.set(ws, authTimeout);
  clients.set(ws, { isAlive: true, userId: null, authenticated: false });

  ws.on('message', (data) => {
    const msg = JSON.parse(data.toString());

    // Primeira mensagem deve ser auth
    const meta = clients.get(ws);
    if (!meta.authenticated) {
      if (msg.type !== 'auth') {
        ws.close(1008, 'First message must be auth');
        return;
      }

      try {
        const payload = jwt.verify(msg.token, JWT_SECRET);
        meta.userId = payload.sub;
        meta.authenticated = true;

        // Cancelar timeout de auth
        clearTimeout(pendingAuth.get(ws));
        pendingAuth.delete(ws);

        safeSend(ws, { type: 'auth_ok', userId: meta.userId });
        console.log(`Usuário autenticado: ${meta.userId}`);
      } catch (err) {
        ws.close(1008, 'Invalid token');
      }
      return;
    }

    // Processar mensagens normais apenas de clientes autenticados
    handleMessage(ws, msg);
  });

  ws.on('close', () => {
    clearTimeout(pendingAuth.get(ws));
    pendingAuth.delete(ws);
  });
});

// Renovar token em conexões longas
// Cliente envia { type: 'refresh_token', token: 'novo_token' }
function handleTokenRefresh(ws, msg) {
  try {
    const payload = jwt.verify(msg.token, JWT_SECRET);
    const meta = clients.get(ws);
    if (meta && meta.userId === payload.sub) {
      safeSend(ws, { type: 'token_refreshed' });
    } else {
      ws.close(1008, 'Token mismatch');
    }
  } catch {
    ws.close(1008, 'Invalid refresh token');
  }
}

Socket.IO

Socket.IO é uma camada de abstração sobre WebSocket com fallback automático para HTTP long-polling.

Quando usar Socket.IO vs ws puro:

  • Socket.IO: precisa de rooms, namespaces, reconnect automático, fallback, broadcasting simples
  • ws puro: máximo desempenho, protocolo customizado, menos overhead, microserviços
// npm install socket.io socket.io-client

// --- SERVIDOR ---
const { createServer } = require('http');
const { Server } = require('socket.io');

const httpServer = createServer();
const io = new Server(httpServer, {
  cors: { origin: 'https://meusite.com', methods: ['GET', 'POST'] },
  pingInterval: 25000,
  pingTimeout: 5000,
});

// Middleware de autenticação (roda antes de aceitar conexão)
io.use((socket, next) => {
  const token = socket.handshake.auth.token;
  try {
    const payload = jwt.verify(token, JWT_SECRET);
    socket.userId = payload.sub;
    next();
  } catch {
    next(new Error('Unauthorized'));
  }
});

io.on('connection', (socket) => {
  console.log('Conectado:', socket.id, 'userId:', socket.userId);

  // Enviar para este socket
  socket.emit('welcome', { message: 'Bem-vindo!' });

  // Enviar para todos exceto o remetente
  socket.broadcast.emit('user_connected', { userId: socket.userId });

  // Emitir para todos (incluindo remetente)
  io.emit('announcement', { text: 'Novo usuário online' });

  // Entrar em room
  socket.on('join_room', (roomId) => {
    socket.join(roomId);
    io.to(roomId).emit('user_joined', { userId: socket.userId, roomId });
  });

  // Emitir apenas para uma room
  socket.on('message', ({ roomId, content }) => {
    socket.to(roomId).emit('message', {
      from: socket.userId,
      content,
      timestamp: Date.now(),
    });
  });

  socket.on('leave_room', (roomId) => {
    socket.leave(roomId);
  });

  socket.on('disconnect', (reason) => {
    console.log('Desconectado:', socket.id, reason);
  });
});

// Namespace separado para notificações
const notif = io.of('/notifications');
notif.use(authMiddleware);
notif.on('connection', (socket) => {
  socket.join(`user:${socket.userId}`); // room privada por usuário
});

// Enviar notificação de outro módulo
function notifyUser(userId, payload) {
  io.of('/notifications').to(`user:${userId}`).emit('notification', payload);
}

httpServer.listen(3000);

// --- CLIENTE ---
import { io } from 'socket.io-client';

const socket = io('https://example.com', {
  auth: { token: 'Bearer eyJhbGci...' },
  reconnection: true,
  reconnectionAttempts: 10,
  reconnectionDelay: 500,
  reconnectionDelayMax: 30000,
  randomizationFactor: 0.2, // jitter
});

socket.on('connect', () => console.log('Conectado:', socket.id));
socket.on('disconnect', (reason) => console.log('Desconectado:', reason));
socket.on('connect_error', (err) => console.error('Erro:', err.message));

socket.emit('join_room', 'sala-geral');
socket.emit('message', { roomId: 'sala-geral', content: 'Olá!' });
socket.on('message', (msg) => console.log(msg));

Structured Streaming

// Protocolo de mensagens com JSON tipado
// { type, id, payload } — id permite correlacionar request/response

let messageIdCounter = 0;
const pendingRequests = new Map(); // id -> { resolve, reject, timer }

function sendRequest(ws, type, payload, timeoutMs = 5000) {
  return new Promise((resolve, reject) => {
    const id = ++messageIdCounter;
    const timer = setTimeout(() => {
      pendingRequests.delete(id);
      reject(new Error(`Timeout aguardando resposta para ${type}#${id}`));
    }, timeoutMs);

    pendingRequests.set(id, { resolve, reject, timer });
    ws.send(JSON.stringify({ type, id, payload }));
  });
}

// No onmessage do cliente
ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);

  // Resolver request pendente
  if (msg.replyTo && pendingRequests.has(msg.replyTo)) {
    const { resolve, reject, timer } = pendingRequests.get(msg.replyTo);
    clearTimeout(timer);
    pendingRequests.delete(msg.replyTo);
    msg.error ? reject(new Error(msg.error)) : resolve(msg.payload);
    return;
  }

  // Streaming de chunks (ex.: resposta de IA)
  if (msg.type === 'stream_chunk') {
    onStreamChunk(msg.streamId, msg.payload);
    return;
  }
  if (msg.type === 'stream_end') {
    onStreamEnd(msg.streamId);
    return;
  }

  handleIncomingMessage(msg);
};

// Uso
const result = await sendRequest(ws, 'get_user', { userId: 42 });

// Servidor — streaming de dados
function streamData(ws, streamId, items) {
  items.forEach((item, i) => {
    safeSend(ws, { type: 'stream_chunk', streamId, payload: item, index: i });
  });
  safeSend(ws, { type: 'stream_end', streamId, total: items.length });
}

// Dados binários com ArrayBuffer (ex.: chunk de arquivo)
function sendBinaryChunk(ws, chunkIndex, buffer) {
  // Header de 8 bytes: 4 bytes chunkIndex + 4 bytes length
  const header = new ArrayBuffer(8);
  const view = new DataView(header);
  view.setUint32(0, chunkIndex);
  view.setUint32(4, buffer.byteLength);

  const combined = new Uint8Array(8 + buffer.byteLength);
  combined.set(new Uint8Array(header), 0);
  combined.set(new Uint8Array(buffer), 8);
  ws.send(combined.buffer);
}

// Backpressure no servidor — pausar envio se buffer cheio
function safeSendWithBackpressure(ws, data) {
  const BUFFER_LIMIT = 1024 * 1024; // 1MB
  if (ws.bufferedAmount > BUFFER_LIMIT) {
    // Aguardar antes de enviar
    setTimeout(() => safeSendWithBackpressure(ws, data), 100);
    return;
  }
  ws.send(JSON.stringify(data));
}

Performance e Escalabilidade

// Limites do sistema — aumentar file descriptors
// /etc/security/limits.conf:
// * soft nofile 65535
// * hard nofile 65535
// ulimit -n 65535

// --- Load Balancer com Sticky Sessions (Nginx) ---
// upstream websocket {
//   ip_hash;  # sticky session por IP
//   server backend1:3000;
//   server backend2:3000;
// }

// --- Redis Pub/Sub para múltiplas instâncias ---
const Redis = require('ioredis');
const pub = new Redis(process.env.REDIS_URL);
const sub = new Redis(process.env.REDIS_URL);

const CHANNEL = 'ws:broadcast';

// Publicar para todas as instâncias
function publishToAll(message) {
  pub.publish(CHANNEL, JSON.stringify(message));
}

// Cada instância se inscreve e entrega aos seus clientes locais
sub.subscribe(CHANNEL);
sub.on('message', (channel, data) => {
  const msg = JSON.parse(data);
  // Entregar apenas para clientes desta instância
  wss.clients.forEach((ws) => {
    if (ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify(msg));
    }
  });
});

// --- Socket.IO Redis Adapter (fan-out automático) ---
// npm install @socket.io/redis-adapter
const { createAdapter } = require('@socket.io/redis-adapter');
io.adapter(createAdapter(pub, sub));
// Agora io.to('room').emit() funciona entre instâncias automaticamente

// --- Monitoramento ---
function getMetrics() {
  return {
    connectionsActive: wss.clients.size,
    connectionsAuthenticated: [...clients.values()].filter(m => m.authenticated).length,
    rooms: rooms.size,
    roomsTotal: [...rooms.values()].reduce((acc, r) => acc + r.size, 0),
    uptime: process.uptime(),
    memoryMB: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
  };
}

// Expor via endpoint HTTP para Prometheus/Grafana
const http = require('http');
http.createServer((req, res) => {
  if (req.url === '/metrics') {
    res.setHeader('Content-Type', 'application/json');
    res.end(JSON.stringify(getMetrics()));
  }
}).listen(9090);

// Compressão permessage-deflate — trade-off
const wssCompressed = new WebSocketServer({
  server,
  perMessageDeflate: {
    zlibDeflateOptions: { level: 6 },     // nível de compressão (1-9)
    threshold: 1024,                       // só comprimir acima de 1KB
    concurrencyLimit: 10,                  // limitar threads zlib
  },
});

WebSocket vs SSE vs HTTP Polling

CaracterísticaWebSocketSSEHTTP Long Polling
DireçãoFull-duplexServer→Client apenasSimulado half-duplex
Browser supportExcelenteExcelente (IE via polyfill)Universal
Proxy/firewallPode bloquearHTTP padrão — amigávelHTTP padrão — amigável
Auto-reconnectManualNativo (EventSource)Manual
Binary supportNativoNão (base64 workaround)Via multipart
Overhead por msgMínimo (2-14 bytes)Pequeno (HTTP headers 1x)Alto (headers completos)
Estado no servidorConexão persistenteConexão persistenteSem estado
EscalabilidadeComplexa (sticky)Mais simplesSimples (stateless)

Quando usar cada um:

  • WebSocket: chat em tempo real, jogos multiplayer, collaborative editing (Google Docs), trading/live quotes, dashboards com envio bidirecional
  • SSE: notificações push, live feed (redes sociais), logs em tempo real, deploy status, streaming de respostas de IA (ChatGPT usa SSE)
  • HTTP Polling: sistemas legados, detrás de proxies restritivos, quando a latência de segundos é aceitável, suporte a clientes muito antigos
// SSE — Exemplo simples de comparação
app.get('/events', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');

  const send = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);

  const interval = setInterval(() => send({ time: Date.now() }), 1000);
  req.on('close', () => clearInterval(interval));
});

// Cliente SSE
const evtSource = new EventSource('/events');
evtSource.onmessage = (e) => console.log(JSON.parse(e.data));
// Auto-reconnect nativo do EventSource — sem código extra

Nota sobre HTTP/2 Server Push: foi removido do Chrome em 2022 por baixa adoção e complexidade. SSE sobre HTTP/2 é a alternativa recomendada (múltiplos streams em uma conexão TCP).


Segurança

const { parse } = require('url');

wss.on('connection', (ws, req) => {
  // 1. Validar Origin — prevenir CSRF via WebSocket
  const origin = req.headers.origin;
  const allowedOrigins = ['https://meusite.com', 'https://www.meusite.com'];

  if (!allowedOrigins.includes(origin)) {
    console.warn(`Origin rejeitada: ${origin}`);
    ws.close(1008, 'Origin not allowed');
    return;
  }

  // 2. Rate limiting de mensagens por conexão
  const meta = { messageCount: 0, windowStart: Date.now() };
  const RATE_LIMIT = 60; // mensagens por janela
  const RATE_WINDOW = 60000; // 60 segundos

  ws.on('message', (data) => {
    const now = Date.now();

    // Resetar janela
    if (now - meta.windowStart > RATE_WINDOW) {
      meta.messageCount = 0;
      meta.windowStart = now;
    }

    meta.messageCount++;
    if (meta.messageCount > RATE_LIMIT) {
      safeSend(ws, { type: 'error', message: 'Rate limit exceeded' });
      ws.close(1008, 'Rate limit exceeded');
      return;
    }

    // 3. Validar tamanho da mensagem
    if (data.length > 65536) { // 64KB
      ws.close(1009, 'Message too large');
      return;
    }

    // 4. Sanitizar dados recebidos — nunca confiar no cliente
    let msg;
    try {
      msg = JSON.parse(data.toString());
    } catch {
      ws.close(1007, 'Invalid JSON');
      return;
    }

    // Validar campos obrigatórios
    if (!msg.type || typeof msg.type !== 'string') {
      safeSend(ws, { type: 'error', message: 'Invalid message format' });
      return;
    }

    // Sanitizar strings (ex.: conteúdo de chat)
    if (msg.content) {
      msg.content = String(msg.content).slice(0, 2000); // truncar
      // Usar biblioteca como DOMPurify no cliente para HTML
    }

    handleMessage(ws, msg);
  });
});

// Configurar maxPayload na inicialização do servidor
const wssSecure = new WebSocketServer({
  server,
  maxPayload: 65536, // 64KB — rejeita frames maiores automaticamente
});

// WSS obrigatório em produção — nunca WS em plain text
// Verificar no cliente
if (location.protocol === 'https:' && wsUrl.startsWith('ws://')) {
  console.error('Use wss:// em produção!');
}

// DoS — limitar conexões simultâneas por IP
const connectionsByIp = new Map();
const MAX_CONNECTIONS_PER_IP = 5;

wss.on('connection', (ws, req) => {
  const ip = req.socket.remoteAddress;
  const count = (connectionsByIp.get(ip) || 0) + 1;

  if (count > MAX_CONNECTIONS_PER_IP) {
    ws.close(1008, 'Too many connections from your IP');
    return;
  }

  connectionsByIp.set(ip, count);
  ws.on('close', () => {
    const current = connectionsByIp.get(ip) || 1;
    if (current <= 1) connectionsByIp.delete(ip);
    else connectionsByIp.set(ip, current - 1);
  });
});

Notas de segurança:

  • CORS não se aplica a WebSocket — o browser envia Origin mas não bloqueia a conexão. Você deve validar Origin no servidor manualmente.
  • Sempre use WSS (wss://) em produção — WS em plain text expõe dados e tokens.
  • Nunca confie em userId ou qualquer dado vindo do cliente — sempre revalidar contra o token JWT no servidor.
  • Mensagens de erro não devem vazar detalhes internos (stack traces, queries SQL, etc.).