Cómo configurar un cliente Websocket con JavaScript

Cómo crear una función reutilizable que establezca un cliente websocket que se conecte a un servidor websocket existente para enviar y recibir mensajes.

Primeros pasos

Si aún no lo ha hecho, y no tiene su propio servidor websocket existente para conectarse, se recomienda que complete nuestro tutorial complementario sobre Cómo configurar un servidor Websocket con Node.js y Express.

Si ya completó ese tutorial, o si tiene un servidor websocket con el que le gustaría probar, para este tutorial, usaremos CheatCode Next.js Boilerplate como punto de partida para conectar nuestro cliente websocket :

Terminal

git clone https://github.com/cheatcode/nextjs-boilerplate.git

Después de clonar una copia del proyecto, cd en él e instalar sus dependencias:

Terminal

cd nextjs-boilerplate && npm install

A continuación, necesitamos instalar una dependencia adicional, query-string , que usaremos para analizar los parámetros de consulta de nuestra URL para pasar junto con nuestra conexión websocket:

Terminal

npm i query-string

Finalmente, inicie el servidor de desarrollo:

Terminal

npm run dev

Con eso, estamos listos para comenzar.

Construyendo el cliente websocket

Afortunadamente para nosotros, los navegadores modernos ahora de forma nativa soporte websockets. Esto significa que no necesitamos depender de ninguna biblioteca especial en el cliente para configurar nuestra conexión.

/websockets/client.js

import queryString from "query-string";
import settings from "../settings";

const websocketClient = (options = {}, onConnect = null) => {
  // We'll write our code here...
};

export default websocketClient;

Aquí, comenzamos a especificar nuestro cliente websocket. Primero, observe que estamos creando una función llamada websocketClient que pretendemos importar en otro lugar de nuestro código. La idea aquí es que, dependiendo de nuestra aplicación, podemos tener múltiples puntos de uso para websockets; este patrón nos permite hacer eso sin tener que copiar/pegar mucho código.

En cuanto a la función, la estamos configurando para aceptar dos argumentos:options , un objeto que contiene algunas configuraciones básicas para el cliente websocket y onConnect , una función de devolución de llamada a la que podemos llamar después hemos establecido una conexión con el servidor (importante si está creando una interfaz de usuario que quiere/necesita la conexión websocket establecida antes de cargar su interfaz de usuario completa).

/websockets/client.js

import queryString from "query-string";
import settings from "../settings";

const websocketClient = (options = {}, onConnect = null) => {
  let url = settings?.websockets?.url;
  let client = new WebSocket(url);

  client.addEventListener("open", () => {
    console.log(`[websockets] Connected to ${settings?.websockets?.url}`);
  });

  client.addEventListener("close", () => {
    console.log(`[websockets] Disconnected from ${settings?.websockets?.url}`);
    client = null;
  });
};

export default websocketClient;

Construyendo el cuerpo de nuestra función, necesitamos configurar nuestra conexión de cliente al servidor websocket. Para hacerlo, aquí hemos importado el /settings/index.js archivo en la raíz del repetitivo que clonamos al comienzo del tutorial. Este archivo contiene una función que extrae datos de configuración para nuestro front-end de un archivo específico del entorno ubicado en la misma carpeta en /settings desde la raíz del proyecto.

Si busca en esa carpeta, se proporcionan dos archivos de ejemplo settings-development.json y settings-production.json . El primero está diseñado para contener el desarrollo configuración del entorno, mientras que este último está diseñado para contener la producción configuración del entorno. Esta distinción es importante porque solo desea usar claves de prueba y URL en su entorno de desarrollo para evitar romper un entorno de producción.

/configuración/configuración-desarrollo.json

const settings = {
  [...]
  websockets: {
    url: "ws://localhost:5001/websockets",
  },
};

export default settings;

Si abrimos el /settings/settings-development.json archivo, vamos a agregar una nueva propiedad al settings objeto que se exporta desde el archivo llamado websockets . Estableceremos esto propiedad igual a otro objeto, con un único url propiedad establecida en la URL de nuestro servidor websocket. Aquí, estamos usando la URL que esperamos que exista del otro tutorial de CheatCode sobre cómo configurar un servidor websocket al que nos vinculamos al comienzo de este tutorial.

Si está utilizando su propio servidor websocket existente, lo configurará aquí. Cabe destacar que cuando nos conectamos a un servidor websocket, anteponemos nuestra URL con ws:// en lugar de http:// (en producción, usaríamos wss:// para una conexión segura como usamos https:// ). Esto se debe a que los websockets son un protocolo independiente del protocolo HTTP. Si anteponemos esto con http:// , nuestra conexión fallaría con un error del navegador.

/websockets/client.js

import queryString from "query-string";
import settings from "../settings";

const websocketClient = (options = {}, onConnect = null) => {
  let url = settings?.websockets?.url;
  let client = new WebSocket(url);

  client.addEventListener("open", () => {
    console.log(`[websockets] Connected to ${settings?.websockets?.url}`);
  });

  client.addEventListener("close", () => {
    console.log(`[websockets] Disconnected from ${settings?.websockets?.url}`);
    client = null;

    if (options?.onDisconnect) {
      options.onDisconnect();
    }
  });
};

export default websocketClient;

Volviendo a nuestro código de cliente, ahora, extraemos nuestra URL de websockets del archivo de configuración, almacenándola en una variable url declarado usando let (veremos por qué más adelante). A continuación, para establecer nuestra conexión a esa URL, en otra variable justo debajo de ella client (también usando let ), llamamos a new WebSocket() pasando el url para nuestro servidor. Aquí, WebSocket() es un nativo API del navegador.

No ve una importación aquí porque, técnicamente hablando, cuando nuestro código se carga en el navegador, el window global el contexto ya tiene WebSocket definido como una variable.

A continuación, debajo de nuestro client conexión, agregamos un par de detectores de eventos de JavaScript para dos eventos que anticipamos nuestro client emitir:open y close . Estos deben explicarse por sí mismos. La primera es una devolución de llamada que se activa cuando la conexión de nuestro servidor websocket se abre , mientras que el segundo se activa cada vez que la conexión de nuestro servidor websocket se cierra .

Aunque no es necesario en un sentido técnico, es importante tenerlos para comunicarte a ti mismo (y a otros desarrolladores) que una conexión fue exitosa o que se perdió una conexión. El último escenario ocurre cuando un servidor websocket se vuelve inalcanzable o cierra intencionalmente la conexión con el cliente. Por lo general, esto sucede cuando un servidor se reinicia o el código interno expulsa a un cliente específico (el "por qué" de esa expulsión depende de la aplicación y no está integrado en la especificación de websockets).

/websockets/client.js

import queryString from "query-string";
import settings from "../settings";

const websocketClient = (options = {}, onConnect = null) => {
  let url = settings?.websockets?.url;

  if (options.queryParams) {
    url = `${url}?${queryString.stringify(options.queryParams)}`;
  }

  let client = new WebSocket(url);

  client.addEventListener("open", () => {[...]});

  client.addEventListener("close", () => {[...]});

  const connection = {
    client,
    send: (message = {}) => {
      if (options.queryParams) {
        message = { ...message, ...options.queryParams };
      }

      return client.send(JSON.stringify(message));
    },
  };

  if (onConnect) onConnect(connection);

  return connection;
};

export default websocketClient;

Hemos añadido bastante aquí. De vuelta cerca de la parte superior, observe que hemos agregado una expectativa para un valor options.queryParams potencialmente estar presente en el options objeto pasado como primer argumento a nuestro websocketClient función.

Debido a que las conexiones websocket no nos permiten pasar un cuerpo como podemos con una solicitud HTTP POST, estamos limitados a pasar parámetros de conexión (información que identifica mejor la conexión como un userId o un chatId ) como una cadena de consulta segura para URL. Aquí, decimos "si nos pasan un objeto de queryParams en las opciones, queremos convertir ese objeto en una cadena de consulta segura para URL (algo que se parece a ?someQueryParam=thisIsAnExample ).

Aquí es donde el uso de let viene en que insinuamos antes. Si pasamos queryParams en nuestro options , queremos actualizar nuestra URL para incluirlos. En este contexto, la "actualización" es para el url variable que creamos. Como queremos reasignar el contenido de esa variable a una cadena que incluye nuestros parámetros de consulta, tenemos que usar el let variable (o, si quieres ir a la vieja escuela, var ). La razón es que si usamos el const más familiar (que significa constante ) e intenté ejecutar el url = '${url}?${queryString.stringify(options.queryParams)}'; código aquí, JavaScript lanzaría un error diciendo que no podemos reasignar una constante.

Tomando nuestro queryParams objeto, importamos el queryString paquete que agregamos anteriormente y usamos su .stringify() método para generar la cadena para nosotros. Entonces, asumiendo que la URL de nuestro servidor base es ws://localhost:5001/websockets y le pasamos un options.queryParams valor igual a { channel: 'cartoons' } , nuestra URL se actualizaría para ser igual a ws://localhost:5001/websockets?channel=cartoons .

/websockets/client.js

import queryString from "query-string";
import settings from "../settings";

const websocketClient = (options = {}, onConnect = null) => {
  [...]

  let client = new WebSocket(url);

  client.addEventListener("open", () => {[...]});

  client.addEventListener("close", () => {[...]});

  const connection = {
    client,
    send: (message = {}) => {
      if (options.queryParams) {
        message = { ...message, ...options.queryParams };
      }

      return client.send(JSON.stringify(message));
    },
  };

  if (onConnect) onConnect(connection);

  return connection;
};

export default websocketClient;

Volviendo al final de nuestra función, hemos agregado un nuevo objeto connection como un const que incluye dos propiedades:client que se establece en el client variable que contiene nuestra conexión websocket y send , establecido en una función personalizada que estamos definiendo para ayudarnos a enviar mensajes.

Uno de los conceptos centrales en un servidor websocket es la capacidad de enviar mensajes de ida y vuelta entre el cliente y el servidor (piense en su conexión websocket como un trozo de cuerda con dos latas conectadas a cada extremo). Cuando enviamos mensajes, ya sea desde el cliente o el servidor, debemos convertirlos (es decir, establecerlos o transformarlos en un tipo diferente de datos) como un valor de cadena 'like this' .

Aquí, nuestro send La función se agrega como una conveniencia para ayudarnos a simplificar el paso de objetos completos como una cadena. La idea aquí es que, cuando usamos nuestro código, al llamar a nuestro websocketClient función, recibiremos este connection objeto. En nuestro código, entonces, podremos llamar a connection.send({ someData: 'hello there' }) sin tener que encadenar el objeto que pasamos manualmente.

Además, además de encadenar nuestro mensaje, este código también incluye cualquier queryParams que se pasaron. Esto es útil porque es posible que necesitemos hacer referencia a esos valores cuando manejamos la conexión del cliente en nuestro servidor websocket o, cada vez que recibimos un mensaje de un cliente conectado (por ejemplo, pasar un ID de usuario junto con un mensaje a identificar quién lo envió).

Justo antes de devolver connection en la parte inferior de nuestra función, observe que condicionalmente hacemos una llamada a onConnect (la función de devolución de llamada que se llamará después se establece nuestra conexión). Técnicamente hablando, aquí, no estamos esperando a que se establezca la conexión real antes de llamar a esta devolución de llamada.

Una conexión websocket debe establecerse casi instantáneamente, por lo que para cuando se evalúe este código, podemos esperar que exista una conexión de cliente. En el caso de que la conexión a un servidor fuera lenta, nos gustaría considerar mover la llamada a onConnect dentro de la devolución de llamada del detector de eventos para el open evento arriba.

/websockets/client.js

import queryString from "query-string";
import settings from "../settings";

const websocketClient = (options = {}, onConnect = null) => {
  let url = settings?.websockets?.url;

  if (options.queryParams) {
    url = `${url}?${queryString.stringify(options.queryParams)}`;
  }

  let client = new WebSocket(url);

  client.addEventListener("open", () => {
    console.log(`[websockets] Connected to ${settings?.websockets?.url}`);
  });

  client.addEventListener("close", () => {
    console.log(`[websockets] Disconnected from ${settings?.websockets?.url}`);
    client = null;
  });

  client.addEventListener("message", (event) => {
    if (event?.data && options.onMessage) {
      options.onMessage(JSON.parse(event.data));
    }
  });

  const connection = {
    client,
    send: (message = {}) => {
      if (options.queryParams) {
        message = { ...message, ...options.queryParams };
      }

      return client.send(JSON.stringify(message));
    },
  };

  return connection;
};

export default websocketClient;

Una cosa más para colarse. Mientras configuramos nuestro cliente websocket para enviar mensajes, aún no lo hemos configurado para recibir mensajes.

Cuando se envía un mensaje a clientes conectados (a menos que se maneje intencionalmente, un mensaje enviado por un servidor websocket se enviará a todos clientes conectados), esos clientes reciben ese mensaje a través del message evento en su client conexión.

Aquí, hemos agregado un nuevo detector de eventos para el message evento. Condicionalmente, suponiendo que se envió un mensaje real (en el event.data campo) y que tenemos un onMessage función de devolución de llamada en nuestras opciones, llamamos a esa función, pasando el JSON.parse 'd versión del mensaje. Recuerde, los mensajes se envían de ida y vuelta como cadenas. Aquí, asumimos que el mensaje que recibimos de nuestro servidor es un objeto en forma de cadena y queremos convertirlo en un objeto JavaScript.

¡Eso es todo para nuestra implementación! Ahora, pongamos nuestro cliente en uso y verifiquemos que todo funcione como se esperaba.

Usando el cliente websocket

Para poner a nuestro cliente en uso, vamos a conectar un nuevo componente de página en el modelo que clonamos al comienzo de este tutorial. Vamos a crear una nueva página en /pages/index.js ahora y vea lo que debemos hacer para integrar nuestro cliente websocket.

/páginas/index.js

import React from "react";
import PropTypes from "prop-types";
import websocketClient from "../websockets/client";

import StyledIndex from "./index.css";

class Index extends React.Component {
  state = {
    message: "",
    received: [],
    connected: false,
  };

  componentDidMount() {
    websocketClient(
      {
        queryParams: {
          favoritePizza: "supreme",
        },
        onMessage: (message) => {
          console.log(message);
          this.setState(({ received }) => {
            return {
              received: [...received, message],
            };
          });
        },
        onDisconnect: () => {
          this.setState({ connected: false });
        },
      },
      (websocketClient) => {
        this.setState({ connected: true }, () => {
          this.websocketClient = websocketClient;
        });
      }
    );
  }

  handleSendMessage = () => {
    const { message } = this.state;
    this.websocketClient.send({ message });
    this.setState({ message: "" });
  };

  render() {
    const { message, connected, received } = this.state;

    return (
      <StyledIndex>
        <div className="row">
          <div className="col-sm-6">
            <label className="form-label">Send a Message</label>
            <input
              className="form-control mb-3"
              type="text"
              name="message"
              placeholder="Type your message here..."
              value={message}
              onChange={(event) =>
                this.setState({ message: event.target.value })
              }
            />
            <button
              className="btn btn-primary"
              onClick={this.handleSendMessage}
            >
              Send Message
            </button>
          </div>
          <div className="row">
            <div className="col-sm-12">
              <div className="messages">
                <header>
                  <p>
                    <i
                      className={`fas ${connected ? "fa-circle" : "fa-times"}`}
                    />{" "}
                    {connected ? "Connected" : "Not Connected"}
                  </p>
                </header>
                <ul>
                  {received.map(({ message }, index) => {
                    return <li key={`${message}_${index}`}>{message}</li>;
                  })}
                  {connected && received.length === 0 && (
                    <li>No messages received yet.</li>
                  )}
                </ul>
              </div>
            </div>
          </div>
        </div>
      </StyledIndex>
    );
  }
}

Index.propTypes = {
  // prop: PropTypes.string.isRequired,
};

export default Index;

Analicemos la idea general aquí y luego concentrémonos en las cosas del websocket. Lo que estamos haciendo aquí es configurar un componente React que genera una entrada, un botón y una lista de mensajes recibidos de nuestro servidor websocket. Para demostrar el uso de nuestro cliente, nos conectaremos al cliente y luego enviaremos mensajes al servidor. Esperamos (veremos esto más adelante) que nuestro servidor nos envíe un mensaje en forma de ping pong donde el servidor reconoce nuestro mensaje devolviendo el suyo propio.

En el render() función aquí, usamos una combinación de Bootstrap (incluido con el modelo que clonamos para este tutorial) y un poco de CSS personalizado implementado usando styled-components a través del <StyledIndex /> componente que hemos importado en la parte superior de nuestro archivo de componentes.

Los detalles del CSS no son importantes aquí, pero asegúrese de agregar el siguiente archivo en /pages/index.css.js (preste atención a la extensión .css.js para que la importación aún funcione en su componente en /pages/index.js ). El código que mostramos a continuación seguirá funcionando sin él, pero no se parecerá al ejemplo que mostramos a continuación.

/páginas/index.css.js

import styled from "styled-components";

export default styled.div`
  .messages {
    background: var(--gray-1);
    margin-top: 50px;

    header {
      padding: 20px;
      border-bottom: 1px solid #ddd;
    }

    header p {
      margin: 0;

      i {
        font-size: 11px;
        margin-right: 5px;
      }

      .fa-circle {
        color: lime;
      }
    }

    ul {
      padding: 20px;
      list-style: none;
      margin: 0;
    }
  }
`;

Volviendo al componente, queremos centrarnos en dos métodos:nuestro componentDidMount y handleSendMessage :

/páginas/index.js

import React from "react";
import PropTypes from "prop-types";
import websocketClient from "../websockets/client";

import StyledIndex from "./index.css";

class Index extends React.Component {
  state = {
    message: "",
    received: [],
    connected: false,
  };

  componentDidMount() {
    websocketClient(
      {
        queryParams: {
          favoritePizza: "supreme",
        },
        onMessage: (message) => {
          console.log(message);
          this.setState(({ received }) => {
            return {
              received: [...received, message],
            };
          });
        },
        onDisconnect: () => {
          this.setState({ connected: false });
        },
      },
      (websocketClient) => {
        this.setState({ connected: true }, () => {
          this.websocketClient = websocketClient;
        });
      }
    );
  }

  handleSendMessage = () => {
    const { message } = this.state;
    this.websocketClient.send({ message });
    this.setState({ message: "" });
  };

  render() {
    const { message, connected, received } = this.state;

    return (
      <StyledIndex>
        [...]
      </StyledIndex>
    );
  }
}

Index.propTypes = {
  // prop: PropTypes.string.isRequired,
};

export default Index;

Aquí, en el componentDidMount función, hacemos una llamada a nuestro websocketClient() función que hemos importado desde nuestro /websockets/client.js expediente. Cuando lo llamamos, pasamos los dos argumentos esperados:primero, un options objeto que contiene algo de queryParams , un onMessage función de devolución de llamada y un onDisconnect devolución de llamada, y segundo, un onConnect función de devolución de llamada que recibirá nuestra instancia de cliente websocket una vez que esté disponible.

Para el queryParams, aquí solo estamos pasando algunos datos de ejemplo para mostrar cómo funciona esto.

En el onMessage devolución de llamada, tomamos el mensaje (recuerde, este será un objeto JavaScript analizado de la cadena de mensaje que recibimos del servidor) y luego lo configuramos en el estado de nuestro componente concatenándolo con los mensajes existentes que hemos received . Aquí, el ...received parte dice "agregue los mensajes recibidos existentes a esta matriz". En efecto, obtenemos una serie de objetos de mensaje que contienen tanto los mensajes recibidos anteriormente como el mensaje que estamos recibiendo ahora.

Finalmente, para el options , también agregamos un onDisconnect devolución de llamada que establece el connected estado en el componente (lo usaremos para determinar una conexión exitosa) a false si perdemos la conexión.

Abajo en el onConnect devolución de llamada (el segundo argumento pasado a websocketClient() ) hacemos una llamada a this.setState() configurando connected a verdadero y luego, la parte importante, asignamos el websocketClient instancia que se nos pasó a través del onConnect devolver la llamada y configurarlo en el componente de React instancia como this.websocketClient .

La razón por la que queremos hacer esto está en handleSendMessage . Este mensaje se llama cada vez que se presiona el botón en nuestro render() se hace clic en el método. Al hacer clic, obtenemos el valor actual para message (establecemos esto en el estado como this.state.message cada vez que cambie la entrada) y luego llame a this.websocketClient.send() . Recuerda que el send() La función que llamamos aquí es la misma que conectamos y asignamos al connection objeto de nuevo en /websockets/client.js .

Aquí, pasamos nuestro mensaje como parte de un objeto y esperamos .send() para convertir eso en una cadena antes de enviarlo al servidor.

Esa es la carne y las papas. Abajo en el render() función, una vez que nuestro this.state.received array tiene algunos mensajes, los representamos como <li></li> simples etiquetas abajo en el <div className="messages"></div> bloquear.

Con eso, cuando cargamos nuestra aplicación en el navegador y visitamos http://localhost:5000 , deberíamos ver nuestro formulario simple y (suponiendo que nuestro servidor websocket se esté ejecutando) ¡un estado "Conectado" debajo de la entrada! Si envía un mensaje, debería ver una respuesta del servidor.

Nota :Nuevamente, si no ha completado el tutorial de CheatCode sobre cómo configurar un servidor websocket, asegúrese de seguir las instrucciones allí para tener un servidor que funcione y asegúrese de iniciarlo.

Terminando

En este tutorial, aprendimos cómo configurar un cliente websocket usando el WebSocket nativo en el navegador. clase. Aprendimos a escribir una función contenedora que establece una conexión con nuestro servidor, procesa los parámetros de consulta y maneja todos los eventos básicos de websocket, incluidos:open , close y message .

También aprendimos cómo conectar nuestro cliente websocket dentro de un componente React y cómo enviar mensajes a través de ese cliente desde un formulario dentro de nuestro componente.