Cómo configurar un servidor Websocket con Node.js y Express

Cómo conectar un servidor websocket a un servidor Express existente para agregar datos en tiempo real a su aplicación.

Primeros pasos

Para este tutorial, usaremos CheatCode Node.js Boilerplate. Esto nos dará acceso a un servidor Express existente al que podemos conectar nuestro servidor websocket:

Terminal

git clone https://github.com/cheatcode/nodejs-server-boilerplate.git

Después de clonar el proyecto, cd en él e instalar sus dependencias:

Terminal

cd nodejs-server-boilerplate && npm install

Finalmente, para este tutorial, necesitamos instalar dos dependencias adicionales:ws por crear nuestro servidor websocket y query-string para analizar los parámetros de consulta de nuestras conexiones websocket:

Terminal

npm i ws query-string

Después de esto, inicie el servidor de desarrollo:

Terminal

npm run dev

Creando un servidor websocket

Para comenzar, debemos configurar un nuevo servidor websocket que pueda manejar las solicitudes entrantes de websocket de los clientes. Primero, en el /index.js archivo del proyecto que acabamos de clonar, agreguemos una llamada a la función que configurará nuestro servidor websocket:

/index.js

import express from "express";
import startup from "./lib/startup";
import api from "./api/index";
import middleware from "./middleware/index";
import logger from "./lib/logger";
import websockets from './websockets';

startup()
  .then(() => {
    const app = express();
    const port = process.env.PORT || 5001;

    middleware(app);
    api(app);

    const server = app.listen(port, () => {
      if (process.send) {
        process.send(`Server running at http://localhost:${port}\n\n`);
      }
    });

    websockets(server);

    process.on("message", (message) => {
      console.log(message);
    });
  })
  .catch((error) => {
    logger.error(error);
  });

Aquí, hemos importado un hipotético websockets función de ./websockets que anticipa un index.js archivo en esa ruta (Node.js interpreta esto como ./websockets/index.js ). Dentro del .then() devolución de llamada para nuestro servidor startup() función, hemos agregado una llamada a esta función justo debajo de nuestra llamada a app.listen() . A él, le pasamos server que es el servidor HTTP devuelto por Express cuando el servidor HTTP se abre en el port pasado (en este caso 5001 ).

Una vez server está disponible, llamamos a nuestro websockets() función, pasando el HTTP server (esto es a lo que adjuntaremos el servidor websocket que crearemos en la siguiente sección).

Adjuntar un servidor websocket a un servidor express

A continuación, necesitamos crear el /websockets/index.js archivo que asumimos existirá arriba. Para mantener limpio nuestro código, vamos a crear un websockets separado directorio en la raíz del proyecto que clonamos y creamos un index.js archivo dentro de eso:

/websockets/index.js

import WebSocket from "ws";

export default (expressServer) => {
  const websocketServer = new WebSocket.Server({
    noServer: true,
    path: "/websockets",
  });

  return websocketServer;
};

Aquí, exportamos una función que toma un solo argumento de expressServer que contiene el Express app instancia que pretendemos pasar cuando llamamos a la función desde /index.js en la raíz del proyecto.

Justo dentro de esa función, creamos nuestro servidor websocket usando el Websocket.Server constructor del ws paquete que instalamos arriba. A ese constructor, le pasamos el noServer opción como true para decir "no configure un servidor HTTP junto con este servidor websocket". La ventaja de hacer esto es que podemos compartir un solo servidor HTTP (es decir, nuestro servidor Express) a través de múltiples conexiones websocket. También pasamos un path opción para especificar la ruta en nuestro servidor HTTP donde se podrá acceder a nuestro servidor websocket (en última instancia, localhost:5001/websockets ).

/websockets/index.js

import WebSocket from "ws";

export default async (expressServer) => {
  const websocketServer = new WebSocket.Server({
    noServer: true,
    path: "/websockets",
  });

  expressServer.on("upgrade", (request, socket, head) => {
    websocketServer.handleUpgrade(request, socket, head, (websocket) => {
      websocketServer.emit("connection", websocket, request);
    });
  });

  return websocketServer;
};

Extendiendo nuestro código, a continuación, debemos manejar la conexión del servidor websocket al expressServer existente . Para hacerlo, en el expressServer escuchamos un upgrade evento. Este evento se activa cada vez que nuestro servidor Express, un servidor HTTP simple, recibe una solicitud de un punto final mediante el protocolo websockets. "Actualizar" aquí dice, "necesitamos actualizar esta solicitud para manejar websockets".

Pasado a la devolución de llamada para el controlador de eventos:el .on('upgrade') part—tenemos tres argumentos request , socket y head . request representa la solicitud HTTP entrante que se realizó desde un cliente websocket, socket representa la conexión de red entre el navegador (cliente) y el servidor, y head representa el primer paquete/fragmento de datos para la solicitud entrante.

A continuación, dentro de la devolución de llamada para el controlador de eventos, hacemos una llamada a websocketServer.handleUpgrade() , pasando junto con el request , socket y head . Lo que decimos con esto es "se nos pide que actualicemos esta solicitud HTTP a una solicitud websocket, así que realice la actualización y luego devuélvanos la conexión actualizada".

Esa conexión mejorada, entonces, se pasa a la devolución de llamada que hemos agregado como el cuarto argumento para websocketServer.handleUpgrade() . Con esa conexión actualizada, debemos manejar la conexión; para que quede claro, esta es la conexión del cliente websocket ahora conectada. Para hacerlo, "entregamos" la conexión actualizada websocket y el request original emitiendo un evento en el websocketServer con el nombre connection .

Manejo de conexiones websocket entrantes

En este punto, hemos actualizado nuestro servidor Express HTTP existente, sin embargo, no hemos manejado completamente la solicitud entrante. En la última sección, llegamos al punto en el que podemos actualizar la solicitud HTTP entrante de un cliente websocket a una verdadera conexión websocket, sin embargo, no hemos manejado esa conexión.

/websockets/index.js

import WebSocket from "ws";
import queryString from "query-string";

export default async (expressServer) => {
  const websocketServer = new WebSocket.Server({[...]});

  expressServer.on("upgrade", (request, socket, head) => {[...]});

  websocketServer.on(
    "connection",
    function connection(websocketConnection, connectionRequest) {
      const [_path, params] = connectionRequest?.url?.split("?");
      const connectionParams = queryString.parse(params);

      // NOTE: connectParams are not used here but good to understand how to get
      // to them if you need to pass data with the connection to identify it (e.g., a userId).
      console.log(connectionParams);

      websocketConnection.on("message", (message) => {
        const parsedMessage = JSON.parse(message);
        console.log(parsedMessage);
      });
    }
  );

  return websocketServer;
};

Para manejar esa conexión, necesitamos escuchar el connection evento que emitimos en la última sección. Para hacerlo, hacemos una llamada al websocketServer.on('connection') pasándole una función de devolución de llamada que manejará la conexión websocket entrante y la solicitud que la acompaña.

Para aclarar, la diferencia entre el websocketConnection y el connectionRequest es que el primero representa la conexión de red abierta y de larga duración entre el navegador y el servidor, mientras que el connectionRequest representa la solicitud original para abrir esa conexión.

Centrándonos en la devolución de llamada que hemos pasado a nuestro .on('connection') manejador, hacemos algo especial. Según la implementación de websockets, no hay forma de pasar datos (por ejemplo, la identificación de un usuario u otra información de identificación) en el cuerpo de una solicitud de websocket (similar a cómo puede pasar un cuerpo con una solicitud HTTP POST).

En cambio, debemos incluir cualquier información de identificación en los parámetros de consulta de la URL de nuestro servidor websocket cuando nos conectamos al servidor a través de un cliente websocket (más sobre esto en la siguiente sección). Lamentablemente, estos parámetros de consulta no analizado por nuestro servidor websocket, por lo que debemos hacerlo manualmente.

Para extraer los parámetros de consulta en un objeto JavaScript, desde el connectionRequest , tomamos la URL para la que se realizó la solicitud (esta es la URL a la que el cliente websocket realiza la solicitud de conexión) y la dividimos en el ? . Hacemos esto porque no nos importa ninguna parte de la URL antes y hasta el ? o nuestros parámetros de consulta en forma de URL.

Usando la desestructuración de matrices de JavaScript, tomamos el resultado de nuestro .split('?') y suponga que devuelve una matriz con dos valores:la parte de la ruta de la URL y los parámetros de consulta en forma de URL. Aquí, etiquetamos la ruta como _path para sugerir que no estamos usando ese valor (prefijando un _ guión bajo a un nombre de variable es una forma común de indicar esto en todos los lenguajes de programación). Luego, "arrancamos" el params valor que se separó de la URL. Para ser claros, asumiendo que la URL en la solicitud se parece a ws://localhost:5001/websockets?test=123&test2=456 esperamos que algo como esto esté en la matriz:

['ws://localhost:5001/websockets', 'test=123&test2=456']

Tal como existen, el params (en el ejemplo anterior test=123&test2=456 ) son inutilizables en nuestro código. Para hacerlos utilizables, extraemos el queryString.parse() método del query-string paquete que instalamos anteriormente. Este método toma una cadena de consulta con formato de URL y la convierte en un objeto de JavaScript. El resultado final considerando la URL de ejemplo anterior sería:

{ test: '123', test2: '456' }

Con esto, ahora podemos hacer referencia a nuestros parámetros de consulta en nuestro código a través del connectionParams variable. Aquí no hacemos nada con ellos, pero esta información se incluye porque, francamente, es frustrante descifrar esa parte.

/websockets/index.js

import WebSocket from "ws";
import queryString from "query-string";

export default async (expressServer) => {
  const websocketServer = new WebSocket.Server({
    noServer: true,
    path: "/websockets",
  });

  expressServer.on("upgrade", (request, socket, head) => {
    websocketServer.handleUpgrade(request, socket, head, (websocket) => {
      websocketServer.emit("connection", websocket, request);
    });
  });

  websocketServer.on(
    "connection",
    function connection(websocketConnection, connectionRequest) {
      const [_path, params] = connectionRequest?.url?.split("?");
      const connectionParams = queryString.parse(params);

      // NOTE: connectParams are not used here but good to understand how to get
      // to them if you need to pass data with the connection to identify it (e.g., a userId).
      console.log(connectionParams);

      websocketConnection.on("message", (message) => {
        const parsedMessage = JSON.parse(message);
        console.log(parsedMessage);
        websocketConnection.send(JSON.stringify({ message: 'There be gold in them thar hills.' }));
      });
    }
  );

  return websocketServer;
};

Arriba, tenemos nuestra implementación completa del servidor websocket. Lo que hemos agregado es un controlador de eventos para cuando nuestro websocketConnection recibe un mensaje entrante (la idea de websockets es mantener abierta una conexión de larga duración entre el navegador y el servidor a través de la cual se pueden enviar y recibir mensajes).

Aquí, cuando entra un evento de mensaje, en la devolución de llamada pasada al controlador de eventos, tomamos un único message propiedad como una cadena. Aquí, asumimos que nuestro message es un objeto de JavaScript en forma de cadena, por lo que usamos JSON.parse() para convertir esa cadena en un objeto JavaScript con el que podamos interactuar en nuestro código.

Finalmente, para mostrar la respuesta a un mensaje del servidor, llamamos al websocketConnection.send() , devolviendo un objeto en cadena (supondremos que el cliente también está anticipando que se pasa un objeto JavaScript en cadena en sus mensajes entrantes).

Probando el servidor websocket

Debido a que no mostraremos cómo configurar un cliente websocket en un front-end en este tutorial, usaremos una extensión de navegador Chrome/Brave llamada Smart Websocket Client que nos brinda un pseudo front-end que podemos usar para probar cosas.

En la parte superior, tenemos nuestro servidor HTTP/websocket ejecutándose en una terminal (este es el servidor de desarrollo del proyecto que clonamos al comienzo de este proyecto) y en la parte inferior, tenemos la extensión Smart Websocket Client abierta en el navegador. (Valiente).

Primero, ingresamos la URL donde esperamos que exista nuestro servidor websocket. Tenga en cuenta que en lugar del habitual http:// que anteponemos a una URL cuando nos conectamos a un servidor, porque queremos abrir un websocket conexión, prefijamos nuestra URL con ws:// (De manera similar, en producción, si tenemos habilitado SSL, querríamos usar wss:// para "websockets seguros").

Porque esperamos que nuestro servidor se ejecute en el puerto 5001 (el puerto predeterminado para el proyecto sobre el que estamos construyendo esto y donde nuestro servidor HTTP acepta solicitudes), usamos localhost:5001 , seguido de /websockets?userId=123 para decir "en este servidor, navegue hasta el /websockets ruta donde está conectado nuestro servidor websocket e incluya el parámetro de consulta userId establecido en el valor 123 ."

Cuando hacemos clic en el botón "Conectar" en la extensión, obtenemos una conexión abierta a nuestro servidor websocket. A continuación, para probarlo, en el área de texto debajo del botón "Enviar", ingresamos un objeto en forma de cadena escrito previamente (creado al ejecutar JSON.stringify({ howdy: "tester" }) en la consola del navegador) y luego haga clic en el botón "Enviar" para enviar ese objeto en cadena al servidor.

Si observamos la terminal del servidor en la parte superior, podemos ver el userId el parámetro de consulta se analiza desde la URL cuando nos conectamos y cuando enviamos un mensaje, vemos que el mensaje se cerró en el servidor y obtenemos el { message: "There be gold in them thar hills." } esperado mensaje a cambio en el cliente.

Terminando

En este tutorial, aprendimos cómo configurar un servidor websocket y adjuntarlo a un servidor Express HTTP existente. Aprendimos cómo inicializar el servidor websocket y luego usar el upgrade evento en las solicitudes de conexión entrantes para admitir el protocolo websockets.

Finalmente, vimos cómo enviar y recibir mensajes a nuestros clientes conectados y cómo usar JSON.stringify() y JSON.parse() para enviar objetos a través de websockets.