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: chatO Sec-WebSocket-Accept é calculado: base64(sha1(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")).
Framing
Cada mensagem WS é enviada em frames:
FIN— indica último frame do fragmentoopcode— 0x1 (text), 0x2 (binary), 0x8 (close), 0x9 (ping), 0xA (pong)masking— cliente SEMPRE mascara frames; servidor NUNCA mascarapayload 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_bitspermessage-deflate comprime o payload com zlib — reduz bandwidth mas aumenta CPU.
Estados da Conexão
| Constante | Valor | Descrição |
|---|---|---|
| CONNECTING | 0 | Handshake em andamento |
| OPEN | 1 | Conexão estabelecida |
| CLOSING | 2 | Close handshake iniciado |
| CLOSED | 3 | Conexã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ódigo | Significado |
|---|---|
| 1000 | Normal closure — encerramento limpo |
| 1001 | Going away — página fechando, servidor reiniciando |
| 1002 | Protocol error |
| 1003 | Unsupported data type |
| 1006 | Anormal — sem close frame (queda de rede) |
| 1008 | Policy violation — ex.: auth falhou |
| 1009 | Message too big |
| 1011 | Internal 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ística | WebSocket | SSE | HTTP Long Polling |
|---|---|---|---|
| Direção | Full-duplex | Server→Client apenas | Simulado half-duplex |
| Browser support | Excelente | Excelente (IE via polyfill) | Universal |
| Proxy/firewall | Pode bloquear | HTTP padrão — amigável | HTTP padrão — amigável |
| Auto-reconnect | Manual | Nativo (EventSource) | Manual |
| Binary support | Nativo | Não (base64 workaround) | Via multipart |
| Overhead por msg | Mínimo (2-14 bytes) | Pequeno (HTTP headers 1x) | Alto (headers completos) |
| Estado no servidor | Conexão persistente | Conexão persistente | Sem estado |
| Escalabilidade | Complexa (sticky) | Mais simples | Simples (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 extraNota 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
Originmas não bloqueia a conexão. Você deve validarOriginno servidor manualmente. - Sempre use WSS (
wss://) em produção — WS em plain text expõe dados e tokens. - Nunca confie em
userIdou 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.).