Server en JS
WebSocket en sí es un protocolo que permite conexiones full-duplex y de larga vida.
Al ser full-duplex y basado en eventos es comunmente utilizado para stream de datos en tiempo real, reemplazando el antiguo “polling” http. Un servidor WebSocket es muchas veces manejado por un servidor web, esto no quiere decir que tenga que estar acoplado directamente en su implementación, pero es debido a que el protocolo WebSocket depende estrictamente de HTTP para iniciar la conexión.
Voy a implementar acá un servidor WebSocket en JS Vanilla.
Handshake
Este artículo omite cuestiones de seguridad como el manejo de una capa TLS/SSL y demás.
Los servidores web son, en sí, un servicio corriendo en un socket TCP, ¿Qué es un socket? Bueno, es una abstracción del sistema operativo que representa un endpoint de comunicación.
Recomiendo leer un poco más en profundidad sobre TCP Handshake para digerir mejor lo siguiente.
Lo primero que necesitamos para establecer una conexión es un servidor HTTP capaz de mejorar la conexión a WebSocket:
const http = require("http");
const crypto = require("crypto"); // Necesario más adelante
const server = http.createServer();
...
// Manejar eventos del servidor
...
server.listen(8080, () =>
console.log("WebSocket server running on ws://localhost:8080")
);
Acá es simple, definí un servidor HTTP escuchando en el puerto 8080. En este caso me beneficio mucho de la arquitectura de Nodejs, la cual es “basada en eventos”, en vez de definir una API completa para detectar una acción determinada, simplemente escuchamos un evento que el módulo http se encarga de emitir.
El cliente comienza con su intención de cambiar de protocolo, esto lo hace enviando una petición HTTP con los headers necesarios, en el HTTP Request deberían verse:
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: ...
Sec-WebSocket-Version: 13
A lo cual el servidor debe responder con el estado 101 Switching Protocols, para indicar que a partir de ese momento deja de existir HTTP y comienza una comunicación WebSocket
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: ...
En el servidor, se manejaría de esta forma
server.on("upgrade", (req, socket) => {
// 1. Validar el header upgrade
if (req.headers["upgrade"] !== "websocket") {
socket.end("HTTP/1.1 400 Bad Request");
return;
}
// 2. Generar llave
const key = req.headers["sec-websocket-key"];
const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
const acceptKey = crypto
.createHash("sha1")
.update(key + GUID)
.digest("base64");
// 3. Enviar respuesta handshake
socket.write(
[
"HTTP/1.1 101 Switching Protocols",
"Upgrade: websocket",
"Connection: Upgrade",
`Sec-WebSocket-Accept: ${acceptKey}`,
"\r\n",
].join("\r\n")
);
// --- Conexión WebSocket establecida ---
// ... manejar datos
});
Connection y Upgrade
Estos dos son los headers más importantes y son los que indican el deseo del cliente de cambiar el protocolo. Si el servidor responde con las mismas cabeceras, el protocolo requerido es soportado y se releva la conexión al protocolo especificado.
Sec-WebSocket-*
La parte de “Sec-” hace parecer que estos headers son de seguridad, pero en realidad no, son cabeceras que aseguran la integridad del protocolo. En versiones anteriores el handshake era diferente, hacerlo de esta forma hace que solo los servidores que acepten WebSocket en su última versión sean capaces de responder. También nos aseguramos de que nuestro servidor (o proxy) no cachee la respuesta como si fuera una petición HTTP normal.
El header de más delicadeza es “Sec-WebSocket-Accept”, que responde combinando el “Sec-WebSocket-Key”, enviado por el cliente, con el GUID y procesado con SHA-1. La respuesta se crearía como
Sec-WebSocket-Accept: base64(SHA1(key + GUID))
El GUID es, literalmente, un string mágico definido en la versión 13 del protocolo WebSocket.
GUID: 258EAFA5-E914-47DA-95CA-C5AB0DC85B11
Frame
En cualquier protocolo, la unidad recibe un nombre en específico. En el caso de TCP son segmentos, en IP son paquetes y en WebSockets son frames.
Los servidores que se encargan de manejar los distintos protocolos de aplicación saben exactamente el propósito de cada bit recibido en un frame WebSocket, esto es gracias al estándar del protocolo (contrato que debe cumplir cada cliente y servidor para asegurar la comunicación).
| Byte | Bits | Campo | Descripción |
|---|---|---|---|
| 0 | 7 | FIN | Bit más alto (bit 7) |
| 0 | 6 | RSV1 | Normalmente 0 |
| 0 | 5 | RSV2 | Normalmente 0 |
| 0 | 4 | RSV3 | Normalmente 0 |
| 0 | 3–0 | Opcode | Tipo de frame |
| 1 | 7 | MASK | Si el payload viene del cliente, siempre es 1 |
| 1 | 6–0 | Payload length | Longitud base (0–125, o 126/127 para extendido) |
| 2–5 | 0–31 | Masking key | Solo si MASK=1 (4 bytes) |
| N… | — | Payload data | Datos reales enmascarados o no |
Conociendo esta tabla, se puede hacer uso de operadores bit a bit o máscaras binarias. Este es un punto crítico de entendimiento para comprender el funcionamiento de cualquier servidor de protocolo.
Manejo de datos y testing
Sabiendo lo ya mencionado solo queda escuchar el evento “data”, para saber cuándo el cliente envió algo y aplicar las operaciones correspondientes.
socket.on("data", (buffer) => {
// No todos los opcodes son manejados acá
const opcode = buffer[0] & 0b00001111;
if (opcode === 0x8) {
socket.end();
return;
}
const isMasked = buffer[1] & 0b10000000;
if (!isMasked) return; // Todos los frames del cliente deben estar enmascarados
const payloadLength = buffer[1] & 0b01111111;
const mask = buffer.slice(2, 6);
const payload = buffer.slice(6, 6 + payloadLength);
// Desenmascarar
const unmasked = Buffer.alloc(payloadLength);
for (let i = 0; i < payloadLength; i++) {
unmasked[i] = payload[i] ^ mask[i % 4];
}
const message = unmasked.toString();
console.log("Client says:", message);
// 5. Enviar frame respuesta
const response = Buffer.from(message);
const header = Buffer.from([0x81, response.length]); // 0x81 = FIN + texto opcode
socket.write(Buffer.concat([header, response]));
});
Para probar el servidor es fácil, cualquier navegador moderno soporta WebSockets API, que proporciona una manera fácil de conectarse a servidores WebSocket. En este repo podés ver una implementación completa y entenderlo más a profundidad.