WebSocket Gateway
O WebSocket Gateway é o ponto central de comunicação em tempo real, gerenciando conexões, autenticação e distribuição de eventos.
Implementação
Gateway Principal
typescript
// websocket.gateway.ts
@WebSocketGateway({
cors: {
origin: process.env.FRONTEND_URL,
credentials: true,
},
namespace: '/',
})
export class WebSocketGatewayService
implements OnGatewayConnection, OnGatewayDisconnect
{
@WebSocketServer()
server: Server;
private readonly logger = new Logger('WebSocketGateway');
private connectedUsers = new Map<string, bigint>(); // socketId -> userId
constructor(
private sessionService: SessionService,
private trackingService: WebSocketTrackingService,
) {}
async handleConnection(client: Socket): Promise<void> {
try {
// 1. Tentar autenticar
const sessionId = this.extractSessionId(client);
if (sessionId) {
const session = await this.sessionService.validate(sessionId);
if (session) {
// Conexão autenticada
const userId = session.userId;
// Registrar conexão
this.connectedUsers.set(client.id, userId);
await this.trackingService.addConnection(userId, client.id);
// Entrar na sala do usuário
const room = `user:${userId}`;
client.join(room);
this.logger.log(
`Client ${client.id} connected (User: ${userId}, Room: ${room})`,
);
// Confirmar conexão
client.emit('connected', {
message: 'Connected to live drops',
userId: userId.toString(),
room,
});
return;
}
}
// 2. Conexão pública (não autenticada)
this.logger.log(`Client ${client.id} connected (public)`);
client.emit('connected', {
message: 'Connected to live drops (public)',
});
} catch (error) {
this.logger.error(`Connection error: ${error.message}`);
client.emit('error', { message: 'Connection failed' });
}
}
async handleDisconnect(client: Socket): Promise<void> {
const userId = this.connectedUsers.get(client.id);
if (userId) {
await this.trackingService.removeConnection(userId, client.id);
this.connectedUsers.delete(client.id);
this.logger.log(`Client ${client.id} disconnected (User: ${userId})`);
} else {
this.logger.log(`Client ${client.id} disconnected (public)`);
}
}
private extractSessionId(client: Socket): string | null {
// 1. Tentar do auth
if (client.handshake.auth?.sessionId) {
return client.handshake.auth.sessionId;
}
// 2. Tentar do cookie
const cookies = client.handshake.headers.cookie;
if (cookies) {
const sessionCookie = cookies
.split(';')
.find((c) => c.trim().startsWith('sessionId='));
if (sessionCookie) {
return sessionCookie.split('=')[1].trim();
}
}
return null;
}
}Métodos de Emissão
typescript
// websocket.gateway.ts (continuação)
// Emitir para usuário específico
emitToUser(userId: bigint, event: string, data: any): void {
const room = `user:${userId}`;
this.server.to(room).emit(event, data);
this.logger.debug(`Emitted ${event} to ${room}`);
}
// Emitir para todos (broadcast)
broadcast(event: string, data: any): void {
this.server.emit(event, data);
this.logger.debug(`Broadcast ${event} to all`);
}
// Emitir para sala específica
emitToRoom(room: string, event: string, data: any): void {
this.server.to(room).emit(event, data);
this.logger.debug(`Emitted ${event} to room ${room}`);
}
// Atualização de saldo
emitBalanceUpdate(userId: bigint, balanceCents: bigint): void {
const room = `user:${userId}`;
const balanceFormatted = Number(balanceCents) / 100;
this.logger.log(
`[Balance Update] User: ${userId}, Balance: ${balanceFormatted}, Room: ${room}`,
);
// Verificar se há clientes na sala
const roomClients = this.server.sockets.adapter.rooms.get(room);
this.logger.log(`[Balance Update] Clients in room ${room}: ${roomClients?.size || 0}`);
this.server.to(room).emit('balance:updated', {
balance: balanceFormatted,
balanceCents: balanceCents.toString(),
timestamp: new Date().toISOString(),
});
this.logger.log(`[Balance Update] Event emitted to room ${room}`);
}
// Live drop
emitLiveDrop(drop: LiveDropData): void {
this.server.emit('live:drop', {
id: drop.id,
itemName: drop.itemName,
itemImage: drop.itemImageUrl,
rarity: drop.rarity,
valueCents: drop.valueCents,
userName: drop.userName,
userAvatar: drop.userAvatar,
caseId: drop.caseId,
caseName: drop.caseName,
timestamp: new Date().toISOString(),
});
}
// Notificação
emitNotification(userId: bigint, notification: Notification): void {
this.emitToUser(userId, 'notification', {
id: notification.id,
type: notification.type,
title: notification.title,
message: notification.message,
data: notification.data,
createdAt: notification.createdAt,
});
}Tracking Service
Rastreia conexões ativas por usuário para diagnóstico e métricas.
typescript
// websocket-tracking.service.ts
@Injectable()
export class WebSocketTrackingService {
constructor(private redis: RedisService) {}
async addConnection(userId: bigint, socketId: string): Promise<void> {
const key = `ws:user:${userId}:connections`;
await this.redis.sadd(key, socketId);
await this.redis.expire(key, 86400); // 24h TTL
// Incrementar contador global
await this.redis.incr('ws:connections:total');
await this.redis.incr('ws:connections:authenticated');
}
async removeConnection(userId: bigint, socketId: string): Promise<void> {
const key = `ws:user:${userId}:connections`;
await this.redis.srem(key, socketId);
// Decrementar contador global
await this.redis.decr('ws:connections:total');
await this.redis.decr('ws:connections:authenticated');
}
async getUserConnections(userId: bigint): Promise<string[]> {
const key = `ws:user:${userId}:connections`;
return await this.redis.smembers(key);
}
async isUserOnline(userId: bigint): Promise<boolean> {
const connections = await this.getUserConnections(userId);
return connections.length > 0;
}
async getOnlineUsersCount(): Promise<number> {
const count = await this.redis.get('ws:connections:authenticated');
return parseInt(count || '0', 10);
}
}Módulo WebSocket
typescript
// websocket.module.ts
@Module({
imports: [
SessionModule,
RedisModule,
],
providers: [
WebSocketGatewayService,
WebSocketTrackingService,
],
exports: [
WebSocketGatewayService,
WebSocketTrackingService,
],
})
export class WebSocketModule {}Configuração no Main
typescript
// main.ts
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
);
// Configurar Redis Adapter para WebSocket
const redisIoAdapter = new RedisIoAdapter(app);
await redisIoAdapter.connectToRedis();
app.useWebSocketAdapter(redisIoAdapter);
await app.listen(3000, '0.0.0.0');
}Handlers de Eventos
Eventos do Cliente
typescript
// websocket.gateway.ts
@SubscribeMessage('ping')
handlePing(client: Socket): void {
client.emit('pong', { timestamp: Date.now() });
}
@SubscribeMessage('join:battle')
async handleJoinBattle(
client: Socket,
payload: { battleId: string },
): Promise<void> {
const userId = this.connectedUsers.get(client.id);
if (!userId) {
client.emit('error', { message: 'Not authenticated' });
return;
}
// Entrar na sala da batalha
const room = `battle:${payload.battleId}`;
client.join(room);
client.emit('battle:joined', { room });
}
@SubscribeMessage('leave:battle')
async handleLeaveBattle(
client: Socket,
payload: { battleId: string },
): Promise<void> {
const room = `battle:${payload.battleId}`;
client.leave(room);
client.emit('battle:left', { room });
}
@SubscribeMessage('subscribe:case')
async handleSubscribeCase(
client: Socket,
payload: { caseId: string },
): Promise<void> {
const room = `case:${payload.caseId}`;
client.join(room);
client.emit('case:subscribed', { room });
}Cliente Frontend
Context Provider
tsx
// contexts/WebSocketContext.tsx
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { io, Socket } from 'socket.io-client';
import { useAuth } from './AuthContext';
interface WebSocketContextValue {
socket: Socket | null;
isConnected: boolean;
balance: number;
subscribe: (event: string, handler: (data: any) => void) => () => void;
}
const WebSocketContext = createContext<WebSocketContextValue | null>(null);
export function WebSocketProvider({ children }: { children: React.ReactNode }) {
const [socket, setSocket] = useState<Socket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [balance, setBalance] = useState(0);
const { user, sessionId } = useAuth();
useEffect(() => {
const socketInstance = io(process.env.NEXT_PUBLIC_WS_URL!, {
auth: sessionId ? { sessionId } : undefined,
withCredentials: true,
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionAttempts: 5,
});
socketInstance.on('connect', () => {
console.log('[WebSocket] Connected:', socketInstance.id);
setIsConnected(true);
});
socketInstance.on('disconnect', () => {
console.log('[WebSocket] Disconnected');
setIsConnected(false);
});
socketInstance.on('connected', (data) => {
console.log('[WebSocket] Server confirmed:', data);
});
socketInstance.on('balance:updated', (data) => {
console.log('[WebSocket] Balance updated:', data.balance);
setBalance(data.balance);
});
socketInstance.on('connect_error', (error) => {
console.error('[WebSocket] Connection error:', error);
});
setSocket(socketInstance);
return () => {
socketInstance.disconnect();
};
}, [sessionId]);
const subscribe = useCallback(
(event: string, handler: (data: any) => void) => {
if (!socket) return () => {};
socket.on(event, handler);
return () => {
socket.off(event, handler);
};
},
[socket],
);
return (
<WebSocketContext.Provider
value={{ socket, isConnected, balance, subscribe }}
>
{children}
</WebSocketContext.Provider>
);
}
export function useWebSocket() {
const context = useContext(WebSocketContext);
if (!context) {
throw new Error('useWebSocket must be used within WebSocketProvider');
}
return context;
}Hook de Eventos
tsx
// hooks/useSocketEvent.ts
import { useEffect } from 'react';
import { useWebSocket } from '../contexts/WebSocketContext';
export function useSocketEvent<T = any>(
event: string,
handler: (data: T) => void,
) {
const { subscribe } = useWebSocket();
useEffect(() => {
const unsubscribe = subscribe(event, handler);
return unsubscribe;
}, [event, handler, subscribe]);
}
// Uso:
function LiveDropsFeed() {
const [drops, setDrops] = useState<Drop[]>([]);
useSocketEvent('live:drop', (drop) => {
setDrops((prev) => [drop, ...prev.slice(0, 19)]);
});
return (
<div className="live-drops">
{drops.map((drop) => (
<DropCard key={drop.id} drop={drop} />
))}
</div>
);
}Debugging
Logs do Backend
typescript
// Habilitar logs detalhados
const logger = new Logger('WebSocketGateway');
// Em cada evento
logger.debug(`[Event] ${event} - Data: ${JSON.stringify(data)}`);
logger.log(`[Room] ${room} - Clients: ${this.server.sockets.adapter.rooms.get(room)?.size}`);Logs do Frontend
typescript
// Adicionar logs detalhados
socketInstance.onAny((event, ...args) => {
console.log(`[WebSocket] Event: ${event}`, args);
});Verificar Conexões
typescript
// Admin endpoint para debug
@Get('admin/websocket/status')
async getWebSocketStatus() {
const rooms = this.server.sockets.adapter.rooms;
const sockets = await this.server.fetchSockets();
return {
totalConnections: sockets.length,
rooms: Array.from(rooms.keys()),
connectedUsers: this.connectedUsers.size,
};
}Troubleshooting
Conexão Não Estabelece
- Verificar CORS configuration
- Verificar se Redis está acessível
- Verificar cookies (SameSite, Secure)
- Verificar se o load balancer suporta WebSocket
Eventos Não Chegam
typescript
// Verificar se cliente está na sala correta
const room = `user:${userId}`;
const clients = this.server.sockets.adapter.rooms.get(room);
console.log(`Clients in ${room}:`, clients?.size || 0);
// Verificar se evento está sendo emitido
this.server.to(room).emit('test', { message: 'Test event' });Múltiplas Conexões
typescript
// Limitar conexões por usuário
const MAX_CONNECTIONS_PER_USER = 5;
async handleConnection(client: Socket) {
// ... autenticação ...
const connections = await this.trackingService.getUserConnections(userId);
if (connections.length >= MAX_CONNECTIONS_PER_USER) {
// Desconectar conexão mais antiga
const oldestSocketId = connections[0];
const oldSocket = this.server.sockets.sockets.get(oldestSocketId);
if (oldSocket) {
oldSocket.disconnect(true);
}
}
}