# Guía de Implementación: WebSocket **Versión:** 1.0.0 **Tiempo estimado:** 1-2 horas **Complejidad:** Media --- ## Pre-requisitos - [ ] Proyecto NestJS existente - [ ] Sistema de autenticación JWT funcionando - [ ] (Opcional) Redis para escalabilidad horizontal --- ## Paso 1: Instalar Dependencias ```bash npm install @nestjs/websockets @nestjs/platform-socket.io socket.io # Ya deberías tener @nestjs/jwt del módulo de auth ``` --- ## Paso 2: Crear Estructura de Directorios ```bash mkdir -p src/modules/websocket/gateways mkdir -p src/modules/websocket/services mkdir -p src/modules/websocket/guards mkdir -p src/modules/websocket/types ``` --- ## Paso 3: Definir Tipos y Eventos ```typescript // src/modules/websocket/types/websocket.types.ts /** * Enum de eventos WebSocket * Usar namespace:action para claridad */ export enum SocketEvent { // Conexión AUTHENTICATED = 'authenticated', ERROR = 'error', // Notificaciones NEW_NOTIFICATION = 'notification:new', NOTIFICATION_READ = 'notification:read', NOTIFICATION_DELETED = 'notification:deleted', UNREAD_COUNT_UPDATED = 'notification:unread_count', MARK_AS_READ = 'notification:mark_read', // Genéricos DATA_UPDATED = 'data:updated', USER_ONLINE = 'user:online', USER_OFFLINE = 'user:offline', } /** * Datos del usuario en el socket */ export interface SocketUserData { userId: string; email: string; role: string; tenantId?: string; } /** * Payload base con timestamp */ export interface BasePayload { timestamp: string; } /** * Payload de notificación */ export interface NotificationPayload extends BasePayload { notification: { id: string; title: string; message: string; type: string; data?: Record; }; } ``` --- ## Paso 4: Crear Guard de Autenticación JWT ```typescript // src/modules/websocket/guards/ws-jwt.guard.ts import { CanActivate, ExecutionContext, Injectable, Logger, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { WsException } from '@nestjs/websockets'; import { Socket } from 'socket.io'; /** * Socket autenticado con datos del usuario */ export interface AuthenticatedSocket extends Socket { userData?: { userId: string; email: string; role: string; tenantId?: string; }; } @Injectable() export class WsJwtGuard implements CanActivate { private readonly logger = new Logger(WsJwtGuard.name); constructor(private readonly jwtService: JwtService) {} async canActivate(context: ExecutionContext): Promise { try { const client: AuthenticatedSocket = context.switchToWs().getClient(); // Extraer token de auth o query params const token = client.handshake.auth?.token || client.handshake.query?.token; if (!token || typeof token !== 'string') { this.logger.warn('WebSocket: conexión sin token'); throw new WsException('Token de autenticación requerido'); } // Verificar JWT const payload = await this.jwtService.verifyAsync(token); // Adjuntar datos al socket client.userData = { userId: payload.sub, email: payload.email, role: payload.role, tenantId: payload.tenant_id, }; this.logger.log(`WebSocket autenticado: ${client.userData.email}`); return true; } catch (error) { const msg = error instanceof Error ? error.message : 'Error desconocido'; this.logger.warn(`WebSocket auth falló: ${msg}`); throw new WsException('Autenticación fallida'); } } } ``` --- ## Paso 5: Crear Gateway Principal ```typescript // src/modules/websocket/gateways/notifications.gateway.ts import { WebSocketGateway, WebSocketServer, SubscribeMessage, OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, ConnectedSocket, MessageBody, } from '@nestjs/websockets'; import { Logger, UseGuards } from '@nestjs/common'; import { Server } from 'socket.io'; import { WsJwtGuard, AuthenticatedSocket } from '../guards/ws-jwt.guard'; import { SocketEvent } from '../types/websocket.types'; @WebSocketGateway({ cors: { origin: process.env.CORS_ORIGIN?.split(',') || [ 'http://localhost:3000', 'http://localhost:5173', ], credentials: true, methods: ['GET', 'POST'], }, path: '/socket.io/', transports: ['websocket', 'polling'], }) export class NotificationsGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { @WebSocketServer() server!: Server; private readonly logger = new Logger(NotificationsGateway.name); // Map: userId -> Set de socketIds private userSockets = new Map>(); /** * Lifecycle: Gateway inicializado */ afterInit(server: Server) { this.logger.log('WebSocket Gateway inicializado'); } /** * Lifecycle: Cliente conectado */ @UseGuards(WsJwtGuard) async handleConnection(client: AuthenticatedSocket) { const userId = client.userData?.userId; const userEmail = client.userData?.email; if (!userId) { this.logger.warn('Conexión rechazada: sin datos de usuario'); client.disconnect(); return; } this.logger.log(`Cliente conectado: ${userEmail} (${client.id})`); // Registrar socket del usuario if (!this.userSockets.has(userId)) { this.userSockets.set(userId, new Set()); } this.userSockets.get(userId)!.add(client.id); // Unirse a room personal await client.join(`user:${userId}`); this.logger.debug(`Socket ${client.id} unido a room: user:${userId}`); // Emitir evento de autenticación exitosa client.emit(SocketEvent.AUTHENTICATED, { success: true, userId, email: userEmail, socketId: client.id, }); } /** * Lifecycle: Cliente desconectado */ async handleDisconnect(client: AuthenticatedSocket) { const userId = client.userData?.userId; const userEmail = client.userData?.email; if (userId) { const sockets = this.userSockets.get(userId); if (sockets) { sockets.delete(client.id); if (sockets.size === 0) { this.userSockets.delete(userId); } } } this.logger.log(`Cliente desconectado: ${userEmail} (${client.id})`); } /** * Handler: Marcar notificación como leída */ @UseGuards(WsJwtGuard) @SubscribeMessage(SocketEvent.MARK_AS_READ) async handleMarkAsRead( @ConnectedSocket() client: AuthenticatedSocket, @MessageBody() data: { notificationId: string }, ) { try { const userId = client.userData!.userId; const { notificationId } = data; this.logger.debug( `Usuario ${userId} marca notificación ${notificationId} como leída`, ); // Aquí podrías llamar a NotificationService.markAsRead(notificationId, userId) // await this.notificationService.markAsRead(notificationId, userId); // Confirmar al cliente client.emit(SocketEvent.NOTIFICATION_READ, { notificationId, success: true, }); return { success: true }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Error desconocido'; this.logger.error('Error marcando como leída:', error); client.emit(SocketEvent.ERROR, { message: 'Error al marcar notificación como leída', }); return { success: false, error: errorMessage }; } } // ===================================================== // Métodos públicos para emitir eventos // ===================================================== /** * Emitir a un usuario específico (todos sus dispositivos) */ emitToUser(userId: string, event: SocketEvent, data: any) { const room = `user:${userId}`; this.server.to(room).emit(event, { ...data, timestamp: new Date().toISOString(), }); this.logger.debug(`Emitido ${event} a usuario ${userId}`); } /** * Emitir a múltiples usuarios */ emitToUsers(userIds: string[], event: SocketEvent, data: any) { userIds.forEach((userId) => { this.emitToUser(userId, event, data); }); this.logger.debug(`Emitido ${event} a ${userIds.length} usuarios`); } /** * Broadcast a todos los conectados */ broadcast(event: SocketEvent, data: any) { this.server.emit(event, { ...data, timestamp: new Date().toISOString(), }); this.logger.debug(`Broadcast ${event} a todos los usuarios`); } /** * Emitir a una room específica */ emitToRoom(room: string, event: SocketEvent, data: any) { this.server.to(room).emit(event, { ...data, timestamp: new Date().toISOString(), }); this.logger.debug(`Emitido ${event} a room ${room}`); } // ===================================================== // Métodos de utilidad // ===================================================== /** * Obtener cantidad de usuarios conectados */ getConnectedUsersCount(): number { return this.userSockets.size; } /** * Verificar si usuario está conectado */ isUserConnected(userId: string): boolean { return ( this.userSockets.has(userId) && this.userSockets.get(userId)!.size > 0 ); } /** * Obtener cantidad de sockets de un usuario */ getUserSocketCount(userId: string): number { return this.userSockets.get(userId)?.size || 0; } } ``` --- ## Paso 6: Crear Servicio de WebSocket ```typescript // src/modules/websocket/services/websocket.service.ts import { Injectable, Logger } from '@nestjs/common'; import { NotificationsGateway } from '../gateways/notifications.gateway'; import { SocketEvent } from '../types/websocket.types'; /** * WebSocketService * * API limpia para que otros módulos emitan eventos en tiempo real */ @Injectable() export class WebSocketService { private readonly logger = new Logger(WebSocketService.name); constructor(private readonly gateway: NotificationsGateway) {} /** * Emitir notificación a un usuario */ emitNotificationToUser(userId: string, notification: any) { this.gateway.emitToUser(userId, SocketEvent.NEW_NOTIFICATION, { notification, }); this.logger.debug(`Notificación enviada a usuario ${userId}`); } /** * Emitir notificación a múltiples usuarios */ emitNotificationToUsers(userIds: string[], notification: any) { this.gateway.emitToUsers(userIds, SocketEvent.NEW_NOTIFICATION, { notification, }); this.logger.debug(`Notificación enviada a ${userIds.length} usuarios`); } /** * Actualizar contador de no leídas */ emitUnreadCountUpdate(userId: string, unreadCount: number) { this.gateway.emitToUser(userId, SocketEvent.UNREAD_COUNT_UPDATED, { unreadCount, }); this.logger.debug(`Contador (${unreadCount}) enviado a usuario ${userId}`); } /** * Notificación eliminada */ emitNotificationDeleted(userId: string, notificationId: string) { this.gateway.emitToUser(userId, SocketEvent.NOTIFICATION_DELETED, { notificationId, }); this.logger.debug( `Eliminación ${notificationId} enviada a usuario ${userId}`, ); } /** * Emitir datos actualizados genérico */ emitDataUpdated(userId: string, dataType: string, data: any) { this.gateway.emitToUser(userId, SocketEvent.DATA_UPDATED, { type: dataType, data, }); } /** * Broadcast a todos los usuarios */ broadcast(event: SocketEvent, data: any) { this.gateway.broadcast(event, data); } /** * Emitir a room específica */ emitToRoom(room: string, event: SocketEvent, data: any) { this.gateway.emitToRoom(room, event, data); } /** * Verificar si usuario está conectado */ isUserConnected(userId: string): boolean { return this.gateway.isUserConnected(userId); } /** * Obtener usuarios conectados */ getConnectedUsersCount(): number { return this.gateway.getConnectedUsersCount(); } /** * Obtener cantidad de sockets de un usuario */ getUserSocketCount(userId: string): number { return this.gateway.getUserSocketCount(userId); } } ``` --- ## Paso 7: Crear Módulo ```typescript // src/modules/websocket/websocket.module.ts import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { NotificationsGateway } from './gateways/notifications.gateway'; import { WebSocketService } from './services/websocket.service'; import { WsJwtGuard } from './guards/ws-jwt.guard'; @Module({ imports: [ JwtModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: (config: ConfigService) => ({ secret: config.get('JWT_SECRET'), signOptions: { expiresIn: config.get('JWT_EXPIRES_IN', '7d'), }, }), }), ], providers: [NotificationsGateway, WebSocketService, WsJwtGuard], exports: [WebSocketService], }) export class WebSocketModule {} // Barrel export // src/modules/websocket/index.ts export * from './websocket.module'; export * from './services/websocket.service'; export * from './types/websocket.types'; export * from './guards/ws-jwt.guard'; ``` --- ## Paso 8: Registrar en AppModule ```typescript // src/app.module.ts import { Module } from '@nestjs/common'; import { WebSocketModule } from './modules/websocket'; @Module({ imports: [ // ... otros módulos WebSocketModule, ], }) export class AppModule {} ``` --- ## Paso 9: Uso en Otros Servicios ```typescript // src/modules/notifications/services/notification.service.ts import { Injectable } from '@nestjs/common'; import { WebSocketService } from '@/modules/websocket'; @Injectable() export class NotificationService { constructor( private readonly notificationRepo: Repository, private readonly wsService: WebSocketService, ) {} async create(userId: string, dto: CreateNotificationDto) { // 1. Guardar en BD const notification = this.notificationRepo.create({ ...dto, user_id: userId, }); await this.notificationRepo.save(notification); // 2. Emitir por WebSocket si está conectado if (this.wsService.isUserConnected(userId)) { this.wsService.emitNotificationToUser(userId, notification); } // 3. Actualizar contador const unread = await this.countUnread(userId); this.wsService.emitUnreadCountUpdate(userId, unread); return notification; } async markAsRead(notificationId: string, userId: string) { await this.notificationRepo.update(notificationId, { status: 'read' }); // Actualizar contador const unread = await this.countUnread(userId); this.wsService.emitUnreadCountUpdate(userId, unread); } } ``` --- ## Paso 10: Cliente Frontend (React) ### Instalación ```bash npm install socket.io-client ``` ### Hook de WebSocket ```typescript // src/hooks/useSocket.ts import { useEffect, useRef, useCallback, useState } from 'react'; import { io, Socket } from 'socket.io-client'; import { useAuth } from './useAuth'; interface UseSocketReturn { socket: Socket | null; isConnected: boolean; on: (event: string, handler: (data: any) => void) => () => void; emit: (event: string, data: any) => void; } export function useSocket(): UseSocketReturn { const { token } = useAuth(); const socketRef = useRef(null); const [isConnected, setIsConnected] = useState(false); useEffect(() => { if (!token) return; const socket = io(import.meta.env.VITE_API_URL || 'http://localhost:3000', { path: '/socket.io/', transports: ['websocket', 'polling'], auth: { token }, reconnectionAttempts: 5, reconnectionDelay: 1000, }); socket.on('connect', () => { console.log('Socket conectado'); setIsConnected(true); }); socket.on('disconnect', (reason) => { console.log('Socket desconectado:', reason); setIsConnected(false); }); socket.on('authenticated', (data) => { console.log('Autenticado:', data.email); }); socket.on('error', (error) => { console.error('Error de socket:', error); }); socketRef.current = socket; return () => { socket.disconnect(); socketRef.current = null; }; }, [token]); const on = useCallback( (event: string, handler: (data: any) => void) => { socketRef.current?.on(event, handler); return () => { socketRef.current?.off(event, handler); }; }, [], ); const emit = useCallback((event: string, data: any) => { socketRef.current?.emit(event, data); }, []); return { socket: socketRef.current, isConnected, on, emit, }; } ``` ### Uso en Componente ```tsx // src/components/NotificationBell.tsx import { useEffect, useState } from 'react'; import { useSocket } from '@/hooks/useSocket'; import { Badge, IconButton } from '@mui/material'; import NotificationsIcon from '@mui/icons-material/Notifications'; export function NotificationBell() { const { on, isConnected } = useSocket(); const [unreadCount, setUnreadCount] = useState(0); useEffect(() => { // Escuchar actualizaciones del contador const unsubscribe = on('notification:unread_count', (data) => { setUnreadCount(data.unreadCount); }); return unsubscribe; }, [on]); useEffect(() => { // Escuchar nuevas notificaciones const unsubscribe = on('notification:new', (data) => { // Mostrar toast o actualizar lista console.log('Nueva notificación:', data.notification); setUnreadCount((prev) => prev + 1); }); return unsubscribe; }, [on]); return ( ); } ``` --- ## Variables de Entorno ```env # Backend JWT_SECRET=your-secret-key JWT_EXPIRES_IN=7d CORS_ORIGIN=http://localhost:3000,http://localhost:5173 # Frontend VITE_API_URL=http://localhost:3000 ``` --- ## Checklist de Implementación - [ ] Dependencias instaladas (@nestjs/websockets, socket.io) - [ ] Tipos y eventos definidos - [ ] WsJwtGuard creado - [ ] NotificationsGateway implementado - [ ] WebSocketService creado - [ ] Módulo registrado en AppModule - [ ] Variables de entorno configuradas - [ ] Build pasa sin errores - [ ] Test: conectar desde frontend - [ ] Test: recibir evento de notificación --- ## Verificar Funcionamiento ### Backend ```bash # Ver logs al iniciar npm run start:dev # Debería mostrar: WebSocket Gateway inicializado ``` ### Frontend ```javascript // En consola del navegador const socket = io('http://localhost:3000', { path: '/socket.io/', auth: { token: 'your-jwt-token' }, }); socket.on('authenticated', (data) => console.log('Auth:', data)); socket.on('notification:new', (data) => console.log('Notif:', data)); ``` --- ## Troubleshooting ### "Authentication token required" - Verificar que el token se envía en `auth: { token }` - Verificar que el token JWT es válido ### "CORS error" - Verificar que `CORS_ORIGIN` incluya el origen del frontend - Verificar que `credentials: true` está configurado ### Conexión se desconecta inmediatamente - Verificar que el guard no está rechazando la conexión - Revisar logs del backend para ver el motivo ### Eventos no llegan al cliente - Verificar que el cliente está en el room correcto (`user:{userId}`) - Verificar que el evento se emite con el nombre correcto --- **Versión:** 1.0.0 **Sistema:** SIMCO Catálogo