Tutorial #6: Servidor WebSocket con Ratchet para Señalización en Tiempo Real

 

Tutorial #6: Servidor WebSocket con Ratchet para Señalización en Tiempo Real

🎯 Introducción

¡Bienvenido de nuevo! En esta lección, vamos a implementar un servidor WebSocket usando Ratchet, una biblioteca PHP para comunicación en tiempo real.

El servidor WebSocket será el corazón de nuestra señalización para WebRTC, permitiendo:

  • 🔄 Comunicación en tiempo real entre usuarios

  • 📨 Envío de ofertas y respuestas WebRTC

  • 👥 Gestión de usuarios en línea

  • 🚀 Notificaciones instantáneas


📦 ¿Qué es Ratchet?

Ratchet (socketo.me) es una biblioteca PHP que implementa el protocolo WebSocket.

Características Principales

CaracterísticaDescripción
🔌 WebSocketComunicación bidireccional en tiempo real
📡 Event-drivenBasado en eventos (onOpen, onMessage, etc.)
🧩 ComponentesFácil de extender con componentes
Alto rendimientoManeja múltiples conexiones simultáneas

¿Por qué usar Ratchet?

text
✅ Ventajas:
- Escrito en PHP (mismo lenguaje que el backend)
- Fácil integración con el resto del proyecto
- Muy documentado
- Compatible con Composer
- Soporte para WebSockets nativo

❌ Alternativas (Node.js):
- Necesitarías aprender JavaScript
- Requiere servidor separado
- Mayor complejidad en el proyecto

🏗️ Estructura del Servidor WebSocket

1. Archivos Necesarios

text
📁 vchat/
├── 📄 composer.json         ← Dependencias
├── 📄 server.php            ← Servidor WebSocket
└── 📁 core/
    └── 📁 classes/
        └── 📄 Chat.php      ← Lógica del WebSocket

2. Flujo de Comunicación

text
[Cliente A] ←→ [Servidor WebSocket] ←→ [Cliente B]
     ↑                ↑
     └─── WebRTC ─────┘
     
1. Cliente A → Servidor: "Quiero llamar a B"
2. Servidor → Cliente B: "A te está llamando"
3. Cliente B → Servidor: "Acepto"
4. Servidor → Cliente A: "B aceptó"
5. Cliente A ↔ Cliente B: Conexión P2P establecida

📦 Paso 1: Instalar Ratchet con Composer

1.1 Configurar composer.json

json
{
    "autoload": {
        "psr-4": {
            "MyApp\\": "core/classes"
        }
    },
    "require": {
        "cboden/ratchet": "^0.4.3"
    }
}

1.2 Ejecutar la Instalación

bash
# Ir a la carpeta del proyecto
cd C:\xampp\htdocs\vchat

# Instalar dependencias
composer install

Resultado esperado:

text
Loading composer repositories with package information
Updating dependencies (including require-dev)
Package operations: 16 installs, 0 updates, 0 removals
  - Installing evenement/evenement (v3.0.1)
  - Installing react/event-loop (v1.2.0)
  - Installing react/stream (v1.2.0)
  - Installing react/promise (v2.8.0)
  - Installing react/promise-timer (v1.7.0)
  - Installing react/cache (v1.1.1)
  - Installing react/dns (v1.8.0)
  - Installing react/socket (v1.9.0)
  - Installing ratchet/rfc6455 (v0.3)
  - Installing guzzlehttp/psr7 (1.8.3)
  - Installing psr/http-message (1.0.1)
  - Installing ralouphie/getallheaders (3.0.3)
  - Installing symfony/polyfill-mbstring (v1.23.1)
  - Installing symfony/deprecation-contracts (v2.4.0)
  - Installing symfony/polyfill-php80 (v1.23.1)
  - Installing symfony/http-foundation (v5.3.7)
  - Installing symfony/routing (v5.3.7)
  - Installing cboden/ratchet (v0.4.3)
Generating autoload files

📝 Paso 2: Crear el Servidor

2.1 Archivo server.php

php
<?php
// server.php - Servidor WebSocket
use Ratchet\Server\IoServer;
use Ratchet\Http\HttpServer;
use Ratchet\WebSocket\WsServer;
use MyApp\Chat;

// Cargar el autoloader de Composer
require dirname(__DIR__) . '/vendor/autoload.php';

// Crear el servidor
$server = IoServer::factory(
    new HttpServer(
        new WsServer(
            new Chat()
        )
    ),
    8080 // Puerto del servidor WebSocket
);

echo "🔌 Servidor WebSocket ejecutándose en puerto 8080\n";
echo "📡 Esperando conexiones...\n";

// Ejecutar el servidor
$server->run();

2.2 Explicación del Código

php
// 1. IoServer::factory() - Crea el servidor
$server = IoServer::factory(
    // 2. HttpServer - Maneja peticiones HTTP (upgrade)
    new HttpServer(
        // 3. WsServer - Maneja conexiones WebSocket
        new WsServer(
            // 4. Chat - Nuestra lógica personalizada
            new Chat()
        )
    ),
    8080 // Puerto
);

Componentes:

  • IoServer: Servidor de entrada/salida

  • HttpServer: Escucha peticiones HTTP para hacer upgrade a WebSocket

  • WsServer: Maneja el protocolo WebSocket

  • Chat: Nuestra clase con la lógica de negocio


💬 Paso 3: Crear la Lógica del Chat

3.1 Archivo Chat.php

php
<?php
// core/classes/Chat.php
namespace MyApp;

use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;

class Chat implements MessageComponentInterface {
    protected $clients;
    
    public function __construct() {
        // Almacenar todas las conexiones
        $this->clients = new \SplObjectStorage;
        echo "🟢 Servidor Chat inicializado\n";
    }
    
    // ===== Cuando un cliente se conecta =====
    public function onOpen(ConnectionInterface $conn) {
        // Guardar la conexión
        $this->clients->attach($conn);
        
        echo "🔗 Nueva conexión! ID: {$conn->resourceId}\n";
        echo "📊 Conexiones activas: " . count($this->clients) . "\n";
        
        // Notificar a todos los usuarios sobre la nueva conexión
        $this->broadcast([
            'type' => 'user_connected',
            'userId' => $conn->resourceId
        ]);
    }
    
    // ===== Cuando un cliente envía un mensaje =====
    public function onMessage(ConnectionInterface $from, $msg) {
        $numRecv = count($this->clients) - 1;
        
        echo "📨 Mensaje de conexión {$from->resourceId}: $msg\n";
        echo "📤 Enviando a $numRecv cliente(s)\n";
        
        // Decodificar el mensaje JSON
        $data = json_decode($msg, true);
        
        // Procesar diferentes tipos de mensajes
        switch ($data['type'] ?? '') {
            case 'auth':
                $this->handleAuth($from, $data);
                break;
                
            case 'call_request':
                $this->handleCallRequest($from, $data);
                break;
                
            case 'call_accepted':
                $this->handleCallAccepted($from, $data);
                break;
                
            case 'call_rejected':
                $this->handleCallRejected($from, $data);
                break;
                
            case 'webrtc_signal':
                $this->handleWebRTCSignal($from, $data);
                break;
                
            case 'call_ended':
                $this->handleCallEnded($from, $data);
                break;
                
            default:
                // Broadcast simple para mensajes normales
                $this->broadcastToOthers($from, $msg);
        }
    }
    
    // ===== Cuando un cliente se desconecta =====
    public function onClose(ConnectionInterface $conn) {
        // Eliminar la conexión
        $this->clients->detach($conn);
        
        echo "🔌 Conexión {$conn->resourceId} desconectada\n";
        echo "📊 Conexiones activas: " . count($this->clients) . "\n";
        
        // Notificar a otros usuarios
        $this->broadcast([
            'type' => 'user_disconnected',
            'userId' => $conn->resourceId
        ]);
    }
    
    // ===== Cuando ocurre un error =====
    public function onError(ConnectionInterface $conn, \Exception $e) {
        echo "❌ Error en conexión {$conn->resourceId}: {$e->getMessage()}\n";
        $conn->close();
    }
    
    // ===== Métodos de ayuda =====
    
    // Manejar autenticación
    protected function handleAuth($conn, $data) {
        $userId = $data['userId'] ?? null;
        $username = $data['username'] ?? null;
        
        if ($userId && $username) {
            // Guardar datos del usuario en la conexión
            $conn->userId = $userId;
            $conn->username = $username;
            $conn->isBusy = false;
            
            echo "✅ Usuario autenticado: $username (ID: $userId)\n";
            
            // Enviar confirmación
            $conn->send(json_encode([
                'type' => 'auth_success',
                'userId' => $userId,
                'username' => $username
            ]));
            
            // Notificar a todos los usuarios en línea
            $this->broadcastOnlineUsers();
        }
    }
    
    // Manejar solicitud de llamada
    protected function handleCallRequest($from, $data) {
        $receiverId = $data['receiverId'] ?? null;
        $callerId = $data['callerId'] ?? null;
        $callerName = $data['callerName'] ?? 'Usuario';
        $offer = $data['offer'] ?? null;
        
        if (!$receiverId || !$callerId || !$offer) {
            return;
        }
        
        echo "📞 Solicitud de llamada de $callerName a usuario $receiverId\n";
        
        // Buscar al receptor
        $receiver = $this->findConnection($receiverId);
        
        if (!$receiver) {
            // Receptor no está en línea
            $from->send(json_encode([
                'type' => 'user_offline',
                'receiverId' => $receiverId
            ]));
            return;
        }
        
        // Verificar si el receptor está ocupado
        if ($receiver->isBusy) {
            $from->send(json_encode([
                'type' => 'call_busy',
                'receiverId' => $receiverId
            ]));
            return;
        }
        
        // Marcar al emisor como ocupado
        $from->isBusy = true;
        
        // Enviar notificación al receptor
        $receiver->send(json_encode([
            'type' => 'incoming_call',
            'callerId' => $callerId,
            'callerName' => $callerName,
            'offer' => $offer,
            'roomId' => $data['roomId'] ?? uniqid('call_')
        ]));
    }
    
    // Manejar aceptación de llamada
    protected function handleCallAccepted($from, $data) {
        $callerId = $data['callerId'] ?? null;
        $answer = $data['answer'] ?? null;
        
        if (!$callerId || !$answer) {
            return;
        }
        
        echo "✅ Llamada aceptada por usuario {$from->userId}\n";
        
        // Marcar al receptor como ocupado
        $from->isBusy = true;
        
        // Buscar al emisor
        $caller = $this->findConnection($callerId);
        
        if ($caller) {
            $caller->send(json_encode([
                'type' => 'call_connected',
                'receiverId' => $from->userId,
                'answer' => $answer,
                'roomId' => $data['roomId']
            ]));
        }
    }
    
    // Manejar rechazo de llamada
    protected function handleCallRejected($from, $data) {
        $callerId = $data['callerId'] ?? null;
        
        if (!$callerId) {
            return;
        }
        
        echo "❌ Llamada rechazada por usuario {$from->userId}\n";
        
        // Liberar al emisor
        $caller = $this->findConnection($callerId);
        if ($caller) {
            $caller->isBusy = false;
            $caller->send(json_encode([
                'type' => 'call_rejected',
                'receiverId' => $from->userId
            ]));
        }
    }
    
    // Manejar señales WebRTC (ICE candidates)
    protected function handleWebRTCSignal($from, $data) {
        $targetId = $data['targetId'] ?? null;
        $candidate = $data['candidate'] ?? null;
        
        if (!$targetId || !$candidate) {
            return;
        }
        
        $target = $this->findConnection($targetId);
        
        if ($target) {
            $target->send(json_encode([
                'type' => 'webrtc_signal',
                'fromId' => $from->userId,
                'candidate' => $candidate
            ]));
        }
    }
    
    // Manejar finalización de llamada
    protected function handleCallEnded($from, $data) {
        $otherUserId = $data['otherUserId'] ?? null;
        
        echo "🔚 Llamada finalizada por usuario {$from->userId}\n";
        
        // Liberar al emisor
        $from->isBusy = false;
        
        // Buscar al otro usuario
        if ($otherUserId) {
            $other = $this->findConnection($otherUserId);
            if ($other) {
                $other->isBusy = false;
                $other->send(json_encode([
                    'type' => 'call_ended_by_other',
                    'userId' => $from->userId
                ]));
            }
        }
    }
    
    // ===== Funciones de utilidad =====
    
    // Buscar conexión por userId
    protected function findConnection($userId) {
        foreach ($this->clients as $client) {
            if (isset($client->userId) && $client->userId == $userId) {
                return $client;
            }
        }
        return null;
    }
    
    // Obtener usuarios en línea
    protected function getOnlineUsers() {
        $users = [];
        foreach ($this->clients as $client) {
            if (isset($client->userId) && isset($client->username)) {
                $users[] = [
                    'id' => $client->userId,
                    'username' => $client->username,
                    'isBusy' => $client->isBusy ?? false
                ];
            }
        }
        return $users;
    }
    
    // Transmitir usuarios en línea a todos
    protected function broadcastOnlineUsers() {
        $users = $this->getOnlineUsers();
        $message = json_encode([
            'type' => 'online_users',
            'users' => $users
        ]);
        
        $this->broadcast($message);
    }
    
    // Enviar a todos los clientes
    protected function broadcast($message) {
        if (is_array($message)) {
            $message = json_encode($message);
        }
        
        foreach ($this->clients as $client) {
            $client->send($message);
        }
    }
    
    // Enviar a todos excepto al emisor
    protected function broadcastToOthers($from, $message) {
        if (is_array($message)) {
            $message = json_encode($message);
        }
        
        foreach ($this->clients as $client) {
            if ($from !== $client) {
                $client->send($message);
            }
        }
    }
}

🚀 Paso 4: Ejecutar el Servidor

4.1 Desde Terminal/CMD

Windows 🪟

cmd
# Ir a la carpeta del proyecto
cd C:\xampp\htdocs\vchat

# Ejecutar el servidor
php server.php

macOS/Linux 🐧🍎

bash
# Ir a la carpeta del proyecto
cd /opt/lampp/htdocs/vchat

# Ejecutar el servidor
php server.php

4.2 Resultado Esperado

text
🔌 Servidor WebSocket ejecutándose en puerto 8080
📡 Esperando conexiones...
🟢 Servidor Chat inicializado

4.3 Mantener el Servidor Corriendo

Opción 1: Usar screen (Linux)

bash
# Instalar screen
sudo apt-get install screen

# Crear una sesión
screen -S websocket

# Ejecutar el servidor
php server.php

# Desconectar: Ctrl+A luego D
# Reconectar: screen -r websocket

Opción 2: Usar nohup (Linux/macOS)

bash
nohup php server.php > websocket.log 2>&1 &

Opción 3: Usar pm2 (Node.js)

bash
# Instalar pm2
npm install -g pm2

# Ejecutar con pm2
pm2 start "php server.php" --name websocket
pm2 save
pm2 startup

Opción 4: Servicio de Windows

cmd
# Usar NSSM (Non-Sucking Service Manager)
nssm install WebSocketServer "C:\xampp\php\php.exe" "C:\xampp\htdocs\vchat\server.php"

🔌 Paso 5: Probar el Servidor WebSocket

5.1 Cliente de Prueba (HTML)

html
<!DOCTYPE html>
<html>
<head>
    <title>Test WebSocket</title>
</head>
<body>
    <h1>Cliente WebSocket</h1>
    <div id="messages"></div>
    <input type="text" id="messageInput" placeholder="Escribe un mensaje">
    <button onclick="sendMessage()">Enviar</button>

    <script>
        // Conectar al servidor WebSocket
        const ws = new WebSocket('ws://localhost:8080');
        
        // Cuando se abre la conexión
        ws.onopen = () => {
            console.log('Conectado al servidor');
            document.getElementById('messages').innerHTML += '<p>✅ Conectado</p>';
            
            // Autenticar
            ws.send(JSON.stringify({
                type: 'auth',
                userId: 1,
                username: 'UsuarioPrueba'
            }));
        };
        
        // Cuando se recibe un mensaje
        ws.onmessage = (event) => {
            const data = JSON.parse(event.data);
            console.log('Mensaje recibido:', data);
            
            document.getElementById('messages').innerHTML += 
                `<p>📨 ${JSON.stringify(data)}</p>`;
        };
        
        // Cuando se cierra la conexión
        ws.onclose = () => {
            console.log('Desconectado');
            document.getElementById('messages').innerHTML += '<p>❌ Desconectado</p>';
        };
        
        // Enviar mensaje
        function sendMessage() {
            const input = document.getElementById('messageInput');
            const msg = input.value;
            
            ws.send(JSON.stringify({
                type: 'message',
                content: msg
            }));
            
            input.value = '';
        }
    </script>
</body>
</html>

5.2 Probar con Múltiples Clientes

  1. Abre dos navegadores diferentes (Chrome y Firefox)

  2. En cada uno, abre la página de prueba

  3. Envía mensajes desde uno y verás que el otro los recibe


📊 Mensajes del Servidor

Tipos de Mensajes

TipoDirecciónDescripción
authCliente → ServidorAutenticar usuario
auth_successServidor → ClienteAutenticación exitosa
online_usersServidor → ClienteLista de usuarios en línea
incoming_callServidor → ClienteAlguien te llama
call_requestCliente → ServidorSolicitar llamada
call_acceptedCliente → ServidorAceptar llamada
call_rejectedCliente → ServidorRechazar llamada
call_connectedServidor → ClienteConexión establecida
webrtc_signalCliente ↔ ServidorSeñales WebRTC
call_endedCliente → ServidorFinalizar llamada

🛠️ Solución de Problemas

Error: "Address already in use"

Problema: El puerto 8080 ya está ocupado.

Soluciones:

Opción 1: Cambiar el puerto

php
// server.php
$server = IoServer::factory(
    new HttpServer(
        new WsServer(
            new Chat()
        )
    ),
    8081 // Cambiar a otro puerto
);

Opción 2: Liberar el puerto

cmd
# Windows - Encontrar proceso usando el puerto 8080
netstat -ano | findstr :8080
taskkill /PID [PID] /F

# Linux
sudo lsof -i :8080
sudo kill -9 [PID]

Error: "Class MyApp\Chat not found"

Problema: El autoloader no encuentra la clase.

Solución:

bash
# Regenerar el autoloader
composer dump-autoload

Error: "Connection refused"

Problema: El servidor WebSocket no está corriendo.

Solución:

bash
# Verificar que el servidor está ejecutándose
php server.php

# Verificar el puerto
netstat -an | grep 8080

📚 Resumen

En esta lección hemos:

  1. Instalado Ratchet con Composer

  2. Creado el servidor WebSocket (server.php)

  3. Implementado la lógica de chat (Chat.php)

  4. Configurado la autenticación de usuarios

  5. Manejado llamadas WebRTC (oferta, respuesta, candidatos)

  6. Probado el servidor con múltiples clientes


🚀 Próximo Paso

En la siguiente lección, vamos a:

  • Conectar el frontend JavaScript con el servidor WebSocket

  • Implementar la interfaz de usuario

  • Integrar WebRTC para las videollamadas


¡Excelente trabajo! El servidor WebSocket está listo para manejar la señalización. 

Comentarios

Entradas más populares de este blog

Tutorial Completo: Chat de Video en Vivo con PHP + WebRTC