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