JavaScript

JavaScript

Tutorial JavaScript: WebSockets

WebSocket Handshake y transición HTTP a WebSocket: optimiza conexiones bidireccionales para apps en tiempo real con WSS.

Aprende JavaScript y certifícate

Establecimiento de conexión: Handshake inicial y transición del protocolo HTTP a WebSocket

Los WebSockets representan un avance significativo en la comunicación web, permitiendo una conexión bidireccional y persistente entre el navegador y el servidor. A diferencia del modelo tradicional de petición-respuesta de HTTP, WebSocket mantiene un canal de comunicación abierto, lo que resulta ideal para aplicaciones que requieren actualizaciones en tiempo real.

El proceso de handshake

El establecimiento de una conexión WebSocket comienza con un proceso llamado handshake (apretón de manos), que transforma una conexión HTTP normal en una conexión WebSocket persistente. Este proceso ocurre en varias etapas bien definidas:

  1. Solicitud inicial desde el cliente: El cliente inicia el proceso enviando una solicitud HTTP estándar, pero con cabeceras especiales que indican su intención de establecer una conexión WebSocket:
// Crear una nueva instancia de WebSocket
const socket = new WebSocket('ws://ejemplo.com/socket');

// La conexión se inicia automáticamente al crear la instancia
console.log('Iniciando handshake WebSocket...');

Cuando ejecutamos este código, el navegador envía una solicitud HTTP con las siguientes cabeceras especiales:

  • Upgrade: websocket - Indica que queremos cambiar el protocolo
  • Connection: Upgrade - Confirma la intención de actualizar la conexión
  • Sec-WebSocket-Key - Clave aleatoria generada por el cliente
  • Sec-WebSocket-Version - Versión del protocolo WebSocket (generalmente 13)

Respuesta del servidor y establecimiento de la conexión

El servidor debe responder correctamente para completar el handshake:

// Este código se ejecutaría en el servidor (Node.js con la biblioteca 'ws')
const WebSocket = require('ws');
const server = new WebSocket.Server({ port: 8080 });

server.on('connection', socket => {
  console.log('Cliente conectado - Handshake completado');
  // La conexión ya está establecida
});

Para que el handshake sea exitoso, el servidor debe responder con:

  • Código de estado HTTP 101 Switching Protocols
  • Cabecera Upgrade: websocket
  • Cabecera Connection: Upgrade
  • Cabecera Sec-WebSocket-Accept con un valor calculado a partir del Sec-WebSocket-Key enviado por el cliente

Detección del estado de la conexión

Es fundamental monitorear el estado de la conexión mediante los eventos del WebSocket:

const socket = new WebSocket('ws://ejemplo.com/socket');

// Evento disparado cuando el handshake se completa exitosamente
socket.addEventListener('open', event => {
  console.log('Conexión establecida correctamente');
});

// Evento disparado si hay problemas durante el handshake
socket.addEventListener('error', event => {
  console.error('Error en la conexión WebSocket');
});

Protocolo WSS (WebSocket Secure)

Al igual que HTTPS es la versión segura de HTTP, WSS es la versión cifrada de WebSocket:

// Establecer una conexión WebSocket segura
const secureSocket = new WebSocket('wss://ejemplo.com/socket');

El protocolo wss:// utiliza TLS/SSL para cifrar la comunicación, proporcionando las mismas ventajas de seguridad que HTTPS. Es altamente recomendado para entornos de producción, especialmente cuando se transmiten datos sensibles.

Verificación del soporte del navegador

Antes de intentar establecer una conexión WebSocket, es buena práctica verificar si el navegador soporta esta tecnología:

if ('WebSocket' in window) {
  // El navegador soporta WebSockets
  const socket = new WebSocket('ws://ejemplo.com/socket');
} else {
  // Proporcionar una alternativa o notificar al usuario
  console.log('Tu navegador no soporta WebSockets');
}

Transición del protocolo

Una vez completado el handshake, la conexión deja de ser HTTP y se convierte completamente en WebSocket. Esto significa que:

  • La conexión permanece abierta indefinidamente hasta que alguna de las partes la cierre
  • La comunicación se vuelve bidireccional y cualquiera de las partes puede iniciar el envío de mensajes
  • El formato de los datos cambia del formato HTTP a tramas binarias WebSocket
  • La sobrecarga de comunicación se reduce significativamente al eliminar las cabeceras HTTP en cada intercambio

Esta transición de protocolo es lo que permite a WebSocket ofrecer comunicación en tiempo real con una latencia mucho menor que las soluciones basadas en HTTP tradicional como polling o long polling.

Intercambio de mensajes: Formatos de datos, serialización y manejo de eventos

Una vez establecida la conexión WebSocket, el intercambio de mensajes se convierte en el aspecto central de la comunicación. A diferencia del modelo HTTP tradicional, WebSocket permite enviar y recibir datos en cualquier momento sin necesidad de iniciar nuevas conexiones, lo que resulta ideal para aplicaciones en tiempo real.

Envío y recepción de mensajes básicos

El API de WebSocket proporciona métodos simples pero potentes para la comunicación bidireccional:

const socket = new WebSocket('ws://example.com/socket');

// Enviar un mensaje de texto simple
socket.addEventListener('open', () => {
  socket.send('Hola servidor');
});

// Recibir mensajes del servidor
socket.addEventListener('message', event => {
  console.log(`Mensaje recibido: ${event.data}`);
});

El método send() es la forma principal de transmitir datos al servidor, mientras que el evento message nos permite capturar la información entrante.

Formatos de datos soportados

WebSocket admite varios tipos de datos para el intercambio de mensajes:

  • Cadenas de texto: El formato más común y sencillo de utilizar.
  • ArrayBuffer: Para datos binarios como imágenes o archivos.
  • Blob: Para grandes objetos binarios.
  • TypedArray: Para datos numéricos estructurados.
// Enviar diferentes tipos de datos
socket.send('Mensaje de texto simple');

// Enviar datos binarios
const binaryData = new Uint8Array([1, 2, 3, 4]);
socket.send(binaryData.buffer);

// Enviar un blob (por ejemplo, una imagen)
fetch('/imagen.jpg')
  .then(response => response.blob())
  .then(blob => socket.send(blob));

Serialización JSON para objetos complejos

Para transmitir estructuras de datos complejas, la serialización JSON es la técnica más utilizada:

// Enviar un objeto JavaScript serializado
const userData = {
  id: 123,
  username: 'usuario1',
  isActive: true,
  preferences: {
    theme: 'dark',
    notifications: true
  }
};

socket.send(JSON.stringify(userData));

// En el receptor, parseamos el JSON
socket.addEventListener('message', event => {
  try {
    const data = JSON.parse(event.data);
    console.log(`Usuario: ${data.username}`);
  } catch (error) {
    console.error('Mensaje recibido no es JSON válido');
  }
});

Es importante implementar un manejo adecuado de errores al deserializar, ya que no todos los mensajes podrían ser JSON válido.

Protocolos de mensajería y formatos estructurados

Para aplicaciones más complejas, es recomendable definir un protocolo de mensajería que incluya metadatos como el tipo de mensaje:

// Enviar mensaje con formato estructurado
function sendMessage(type, payload) {
  const message = {
    type,
    payload,
    timestamp: Date.now()
  };
  socket.send(JSON.stringify(message));
}

// Ejemplos de uso
sendMessage('chat', { text: 'Hola a todos' });
sendMessage('userJoined', { userId: 42, username: 'alice' });

Este enfoque facilita el enrutamiento de mensajes en el receptor:

socket.addEventListener('message', event => {
  const message = JSON.parse(event.data);
  
  switch (message.type) {
    case 'chat':
      displayChatMessage(message.payload);
      break;
    case 'userJoined':
      notifyUserJoined(message.payload);
      break;
    default:
      console.warn(`Tipo de mensaje desconocido: ${message.type}`);
  }
});

Sistema de eventos para mensajes

Podemos implementar un sistema de eventos personalizado para manejar diferentes tipos de mensajes de forma más modular:

class WebSocketClient {
  constructor(url) {
    this.socket = new WebSocket(url);
    this.eventHandlers = {};
    
    this.socket.addEventListener('message', event => {
      const message = JSON.parse(event.data);
      this.trigger(message.type, message.payload);
    });
  }
  
  on(eventName, callback) {
    if (!this.eventHandlers[eventName]) {
      this.eventHandlers[eventName] = [];
    }
    this.eventHandlers[eventName].push(callback);
  }
  
  trigger(eventName, data) {
    const handlers = this.eventHandlers[eventName] || [];
    handlers.forEach(handler => handler(data));
  }
  
  send(type, data) {
    this.socket.send(JSON.stringify({ type, payload: data }));
  }
}

Este patrón permite un código más limpio y mantenible:

const client = new WebSocketClient('ws://example.com/socket');

// Registrar manejadores para diferentes tipos de mensajes
client.on('userList', users => {
  updateUserInterface(users);
});

client.on('newMessage', message => {
  addMessageToChat(message);
});

// Enviar mensajes
client.send('requestUserList', { roomId: 'general' });

Compresión de datos

Para optimizar el rendimiento en conexiones lentas o cuando se transmiten grandes volúmenes de datos, podemos implementar compresión:

// Función para comprimir datos antes de enviarlos
async function sendCompressed(socket, data) {
  // Convertir a JSON y luego a Uint8Array
  const jsonString = JSON.stringify(data);
  const textEncoder = new TextEncoder();
  const uint8Array = textEncoder.encode(jsonString);
  
  // Comprimir usando CompressionStream (API moderna)
  const compressedStream = new CompressionStream('gzip');
  const writer = compressedStream.writable.getWriter();
  writer.write(uint8Array);
  writer.close();
  
  // Leer el resultado comprimido
  const reader = compressedStream.readable.getReader();
  const chunks = [];
  
  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    chunks.push(value);
  }
  
  // Combinar chunks y enviar
  const compressedData = new Blob(chunks);
  socket.send(compressedData);
}

Validación de mensajes

La validación de datos es crucial para mantener la seguridad y estabilidad:

function validateMessage(message) {
  // Verificar estructura básica
  if (!message || typeof message !== 'object') {
    return false;
  }
  
  // Verificar campos requeridos
  if (!message.type || !message.payload) {
    return false;
  }
  
  // Validaciones específicas según el tipo
  switch (message.type) {
    case 'chat':
      return typeof message.payload.text === 'string' && 
             message.payload.text.length <= 500;
    case 'userAction':
      return ['join', 'leave', 'typing'].includes(message.payload.action);
    default:
      return false;
  }
}

// Uso en el receptor
socket.addEventListener('message', event => {
  try {
    const message = JSON.parse(event.data);
    if (validateMessage(message)) {
      processMessage(message);
    } else {
      console.warn('Mensaje recibido no válido');
    }
  } catch (error) {
    console.error('Error al procesar mensaje');
  }
});

Manejo de mensajes binarios

Para trabajar con datos binarios como imágenes o archivos:

socket.binaryType = 'arraybuffer'; // También puede ser 'blob'

socket.addEventListener('message', async event => {
  // Verificar si es un mensaje binario
  if (event.data instanceof ArrayBuffer) {
    // Procesar datos binarios
    const view = new Uint8Array(event.data);
    processImageData(view);
  } else {
    // Procesar mensaje de texto normal
    const textData = event.data;
    console.log(`Texto recibido: ${textData}`);
  }
});

La propiedad binaryType determina cómo se representarán los datos binarios recibidos, permitiéndonos elegir el formato más conveniente para nuestra aplicación.

Gestión del ciclo de vida: Reconexión automática, heartbeats y cierre controlado

La gestión del ciclo de vida de una conexión WebSocket es fundamental para crear aplicaciones robustas y resistentes a fallos. Una conexión WebSocket puede interrumpirse por diversos motivos: problemas de red, reinicio del servidor o tiempos de inactividad prolongados. Implementar estrategias adecuadas para manejar estas situaciones garantiza una experiencia de usuario fluida y consistente.

Monitorización del estado de la conexión

El primer paso para gestionar el ciclo de vida es monitorizar constantemente el estado de la conexión mediante los eventos nativos de WebSocket:

const socket = new WebSocket('wss://example.com/socket');

socket.addEventListener('open', () => {
  console.log('Conexión establecida');
  setConnectionStatus('connected');
});

socket.addEventListener('close', event => {
  console.log(`Conexión cerrada: código ${event.code}, razón: ${event.reason}`);
  setConnectionStatus('disconnected');
  
  // El código 1000 indica cierre normal
  if (event.code !== 1000) {
    handleReconnection();
  }
});

socket.addEventListener('error', error => {
  console.error('Error en la conexión WebSocket');
  setConnectionStatus('error');
});

Los códigos de cierre proporcionan información valiosa sobre el motivo de la desconexión:

  • 1000: Cierre normal
  • 1001: El endpoint se está yendo (por ejemplo, servidor reiniciándose)
  • 1002-1015: Varios errores de protocolo
  • 4000+: Códigos personalizados definidos por la aplicación

Implementación de reconexión automática

La reconexión automática es esencial para mantener la comunicación a pesar de interrupciones temporales:

class ReconnectingWebSocket {
  constructor(url, options = {}) {
    this.url = url;
    this.options = options;
    this.socket = null;
    this.isConnected = false;
    this.reconnectAttempts = 0;
    this.maxReconnectAttempts = options.maxReconnectAttempts || 10;
    this.reconnectInterval = options.reconnectInterval || 1000;
    this.listeners = { message: [], open: [], close: [], error: [] };
    
    this.connect();
  }
  
  connect() {
    this.socket = new WebSocket(this.url);
    
    this.socket.addEventListener('open', event => {
      this.isConnected = true;
      this.reconnectAttempts = 0;
      this.triggerEvent('open', event);
    });
    
    this.socket.addEventListener('close', event => {
      this.isConnected = false;
      this.triggerEvent('close', event);
      
      if (event.code !== 1000 && this.reconnectAttempts < this.maxReconnectAttempts) {
        const delay = this.getReconnectDelay();
        setTimeout(() => this.reconnect(), delay);
      }
    });
    
    this.socket.addEventListener('message', event => {
      this.triggerEvent('message', event);
    });
    
    this.socket.addEventListener('error', event => {
      this.triggerEvent('error', event);
    });
  }
  
  reconnect() {
    this.reconnectAttempts++;
    console.log(`Intento de reconexión ${this.reconnectAttempts}...`);
    this.connect();
  }
  
  getReconnectDelay() {
    // Implementación de backoff exponencial
    return Math.min(30000, this.reconnectInterval * Math.pow(1.5, this.reconnectAttempts));
  }
  
  addEventListener(type, callback) {
    if (this.listeners[type]) {
      this.listeners[type].push(callback);
    }
  }
  
  triggerEvent(type, event) {
    (this.listeners[type] || []).forEach(callback => callback(event));
  }
  
  send(data) {
    if (this.isConnected) {
      this.socket.send(data);
      return true;
    }
    return false;
  }
  
  close(code = 1000, reason = '') {
    if (this.socket) {
      this.socket.close(code, reason);
    }
  }
}

Esta implementación incluye un backoff exponencial, una técnica que aumenta progresivamente el tiempo entre intentos de reconexión para evitar sobrecargar el servidor.

Heartbeats para mantener la conexión activa

Los heartbeats (latidos) son mensajes periódicos que verifican si la conexión sigue activa, especialmente útiles para evitar que intermediarios como proxies o firewalls cierren conexiones inactivas:

class HeartbeatWebSocket {
  constructor(url, options = {}) {
    this.url = url;
    this.socket = new WebSocket(url);
    this.heartbeatInterval = options.heartbeatInterval || 30000;
    this.heartbeatTimer = null;
    this.missedHeartbeats = 0;
    this.maxMissedHeartbeats = options.maxMissedHeartbeats || 3;
    
    this.setupEventListeners();
  }
  
  setupEventListeners() {
    this.socket.addEventListener('open', () => {
      this.startHeartbeat();
    });
    
    this.socket.addEventListener('message', event => {
      // Resetear contador si recibimos un pong
      if (event.data === 'pong') {
        this.missedHeartbeats = 0;
      }
    });
    
    this.socket.addEventListener('close', () => {
      this.stopHeartbeat();
    });
  }
  
  startHeartbeat() {
    this.heartbeatTimer = setInterval(() => {
      if (this.socket.readyState === WebSocket.OPEN) {
        this.socket.send('ping');
        this.missedHeartbeats++;
        
        // Si perdimos demasiados heartbeats, cerramos y reconectamos
        if (this.missedHeartbeats >= this.maxMissedHeartbeats) {
          console.warn('Demasiados heartbeats perdidos, reconectando...');
          this.socket.close();
          // Aquí iría la lógica de reconexión
        }
      }
    }, this.heartbeatInterval);
  }
  
  stopHeartbeat() {
    clearInterval(this.heartbeatTimer);
  }
}

En el lado del servidor, es necesario responder a estos mensajes:

// Ejemplo en Node.js con la biblioteca 'ws'
const WebSocket = require('ws');
const server = new WebSocket.Server({ port: 8080 });

server.on('connection', socket => {
  socket.on('message', message => {
    if (message.toString() === 'ping') {
      socket.send('pong');
    }
  });
});

Cierre controlado de conexiones

El cierre controlado de una conexión WebSocket es crucial para liberar recursos y notificar adecuadamente a ambas partes:

function closeConnection(socket, code = 1000, reason = 'Cierre normal') {
  // Verificar si la conexión está abierta
  if (socket && socket.readyState === WebSocket.OPEN) {
    // Enviar mensaje de despedida antes de cerrar
    socket.send(JSON.stringify({
      type: 'disconnect',
      payload: { reason }
    }));
    
    // Dar tiempo para que el mensaje se envíe antes de cerrar
    setTimeout(() => {
      socket.close(code, reason);
    }, 100);
    
    return true;
  }
  return false;
}

// Ejemplo de uso para diferentes escenarios
function handleUserLogout() {
  closeConnection(socket, 1000, 'Usuario cerró sesión');
}

function handleAppShutdown() {
  closeConnection(socket, 1001, 'Aplicación cerrándose');
}

// Manejar cierre desde el navegador
window.addEventListener('beforeunload', () => {
  closeConnection(socket, 1001, 'Navegador cerrándose');
});

Gestión de estado durante reconexiones

Mantener el estado de la aplicación durante reconexiones es fundamental para una experiencia fluida:

class StatefulWebSocket {
  constructor(url) {
    this.url = url;
    this.socket = null;
    this.messageQueue = [];
    this.sessionId = null;
    this.lastSequenceReceived = 0;
    
    this.connect();
  }
  
  connect() {
    this.socket = new WebSocket(this.url);
    
    this.socket.addEventListener('open', () => {
      // Si tenemos un ID de sesión, intentamos restaurar la sesión
      if (this.sessionId) {
        this.socket.send(JSON.stringify({
          type: 'restore_session',
          sessionId: this.sessionId,
          lastSequence: this.lastSequenceReceived
        }));
      }
      
      // Enviar mensajes en cola
      this.flushQueue();
    });
    
    this.socket.addEventListener('message', event => {
      const data = JSON.parse(event.data);
      
      // Guardar ID de sesión si el servidor lo proporciona
      if (data.sessionId) {
        this.sessionId = data.sessionId;
      }
      
      // Actualizar secuencia para seguimiento de mensajes
      if (data.sequence) {
        this.lastSequenceReceived = data.sequence;
      }
    });
  }
  
  send(data) {
    const message = JSON.stringify(data);
    
    if (this.socket && this.socket.readyState === WebSocket.OPEN) {
      this.socket.send(message);
    } else {
      // Guardar mensaje para enviarlo cuando reconectemos
      this.messageQueue.push(message);
    }
  }
  
  flushQueue() {
    while (this.messageQueue.length > 0 && 
           this.socket.readyState === WebSocket.OPEN) {
      const message = this.messageQueue.shift();
      this.socket.send(message);
    }
  }
}

Detección proactiva de problemas de conexión

Implementar detección proactiva de problemas puede mejorar significativamente la experiencia:

class ProactiveWebSocket {
  constructor(url) {
    this.url = url;
    this.socket = new WebSocket(url);
    this.lastMessageTime = Date.now();
    this.connectionMonitor = null;
    this.monitorInterval = 5000;
    
    this.socket.addEventListener('message', () => {
      this.lastMessageTime = Date.now();
    });
    
    this.socket.addEventListener('open', () => {
      this.startConnectionMonitoring();
    });
    
    this.socket.addEventListener('close', () => {
      this.stopConnectionMonitoring();
    });
  }
  
  startConnectionMonitoring() {
    this.connectionMonitor = setInterval(() => {
      const currentTime = Date.now();
      const timeSinceLastMessage = currentTime - this.lastMessageTime;
      
      // Si no hemos recibido mensajes en mucho tiempo, verificamos la conexión
      if (timeSinceLastMessage > 60000) {
        this.checkConnection();
      }
    }, this.monitorInterval);
  }
  
  stopConnectionMonitoring() {
    clearInterval(this.connectionMonitor);
  }
  
  checkConnection() {
    // Enviar un ping para verificar la conexión
    if (this.socket.readyState === WebSocket.OPEN) {
      try {
        this.socket.send('ping');
      } catch (error) {
        console.warn('Error al enviar ping, la conexión podría estar rota');
        this.socket.close(1006, 'Conexión detectada como rota');
      }
    }
  }
}

La implementación adecuada de estas estrategias de gestión del ciclo de vida garantiza conexiones WebSocket robustas y resilientes, proporcionando a los usuarios una experiencia fluida incluso en condiciones de red adversas.

Aprende JavaScript online

Otras lecciones de JavaScript

Accede a todas las lecciones de JavaScript y aprende con ejemplos prácticos de código y ejercicios de programación con IDE web sin instalar nada.

Accede GRATIS a JavaScript y certifícate

Ejercicios de programación de JavaScript

Evalúa tus conocimientos de esta lección WebSockets con nuestros retos de programación de tipo Test, Puzzle, Código y Proyecto con VSCode, guiados por IA.