workspace-v1/shared/libs/websocket/IMPLEMENTATION.md
Adrian Flores Cortes 967ab360bb Initial commit: Workspace v1 with 3-layer architecture
Structure:
- control-plane/: Registries, SIMCO directives, CI/CD templates
- projects/: Gamilit, ERP-Suite, Trading-Platform, Betting-Analytics
- shared/: Libs catalog, knowledge-base

Key features:
- Centralized port, domain, database, and service registries
- 23 SIMCO directives + 6 fundamental principles
- NEXUS agent profiles with delegation rules
- Validation scripts for workspace integrity
- Dockerfiles for all services
- Path aliases for quick reference

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 00:35:19 -06:00

19 KiB

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

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

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

// 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<string, any>;
  };
}

Paso 4: Crear Guard de Autenticación JWT

// 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<boolean> {
    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

// 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<string, Set<string>>();

  /**
   * 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

// 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

// 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<string>('JWT_SECRET'),
        signOptions: {
          expiresIn: config.get<string>('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

// 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

// 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<Notification>,
    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

npm install socket.io-client

Hook de WebSocket

// 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<Socket | null>(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

// 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 (
    <IconButton color={isConnected ? 'primary' : 'default'}>
      <Badge badgeContent={unreadCount} color="error">
        <NotificationsIcon />
      </Badge>
    </IconButton>
  );
}

Variables de Entorno

# 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

# Ver logs al iniciar
npm run start:dev
# Debería mostrar: WebSocket Gateway inicializado

Frontend

// 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