Server-Sent Events con ExpressJS

Recientemente tuve una excusa para jugar con la API de Server-Sent Events en el navegador web, y utilizar un microservicio ExpressJS como proveedor de eventos en tiempo real.

Server-Sent Events es una API que permite a una página web incorporar eventos push enviados desde un servidor. A diferencia de un websocket, Server-Sent Events sólo permite comunicación unidireccional enviada desde el servidor al cliente, pero el cliente no tiene la posibilidad de comunicarle nada al servidor. Sin embargo, en casos donde solamente queremos que el servidor nos pueda mandar mensajes en tiempo real y reaccionar a ellos, puede ser más que suficiente.

Además, a diferencia de WebSocket, que normalmente requiere una biblioteca específica para hacer el ugprade a websocket y la gestión de eventos, el protocolo SSE es lo suficientemente simple como para poder usarlo con casi cualquier lenguaje de programación, porque por fuera es una petición HTTP regular. Yo lo voy a usar con ExpressJS, pero en MDN hay un ejemplo para conectarlo desde PHP. Ojo, no Symfony, Laravel o algo, sino puro archivo events.php sin framework. En frontend, el cliente de SSE es compatible con todos los navegadores, y además lleva disponible desde hace años: Chrome 6 y Firefox 6 ya lo soportaban.

Cómo funciona el protocolo en líneas generales

El protocolo de SSE es una petición HTTP estandar, con la diferencia de que el servidor mantiene la conexión abierta con el navegador web, que permanece conectado hasta que se cierre por causa de fuerza mayor, la red se caiga, o simplemente, se cierre la pestaña.

Normalmente en una petición HTTP, como puede ser una petición para pedir un JSON en una API REST, se considera que la petición ha terminado cuando se sirve la última línea de la respuesta (la llave de cierre). Quitando HTTP 2 o keep-alives, podríamos asumir que un servidor limitado cerraría la conexión tras mandar el cuerpo: para qué la querrías mantener abierta si no hay nada más que decir.

En Server-Sent Events, precisamente mantenemos la conexión abierta, de tal manera que si en el futuro (ponle 5 segundos después), queremos mandar más eventos, simplemente pueda seguir empujando esos eventos al navegador web.

Un evento tiene la siguiente forma: se trata de una o varias líneas que empiezan por la palabra data seguida de dos puntos. Por ejemplo:

data: Mi evento

Cuando un evento termina, mandamos una línea en blanco. Cada evento será cada uno de esos párrafos que manda el servidor al cliente. Así que pongamos que un cliente recibe el siguiente mensaje desde el servidor:

data: {"event": "AVAILABLE", "user": 1234}

data: {
data:  "event": "STATUS",
data:  "user": 1234,
data:  "status": "AFK"
data: }

data: {"event": DISCONNECT, "user": 1234}

En este caso se han recibido tres eventos. Uno es un JSON[event=AVAILABLE, user=1234], otro es un JSON[event=STATUS, user=1234, status=AFK] y otro es un JSON[event=DISCONNECT, user=1234]. Cada párrafo es un evento, y además un evento puede ocupar varias líneas siempre que prefijemos ese data: a cada una de las líneas.

Algunas notas importantes:

  • El tipo de contenido es text/event-stream y es necesario que el Content-Type del endpoint sea ese, por ejemplo estableciendo la cabecera a mano si hace falta. En caso contrario, el navegador podría cerrar la conexión inmediatamente.
  • La data de tu evento es un string siempre. Cuando quieras mandar JSON u otro tipo especializado, tendrás que convertirlo a string (por ejemplo, mediante JSON.stringify). Luego tendrás que recordar de hacer JSON.parse en front.
  • Como extra, la primera línea de cada evento puede empezar por event en vez de data. De este modo, puedes categorizar tus eventos por tipo. Esto luego nos puede venir bien, porque en client-side podemos ponerle un listener diferente a cada tipo de evento. En este ejemplo, tengo un evento de tipo available, otro de tipo statusUpdate y otro de tipo unavailable. Cada tipo de evento puede tener su propio payload.
event: available
data: 1234

event: statusUpdate
data: {"presence": "do_not_disturb", "text": "Ocupado"}

event: unavailable
data: 1234

Cómo hago un endpoint de este tipo en ExpressJS

En Express es relativamente fácil de conseguir esto. En vez de utilizar el método send(), podemos usar el método write() para ir empujando lentamente la data de cada evento. Aquí hago un endpoint de este tipo, cambiándole la cabecera para que sea text/event-stream y enviando un evento inicial:

app.get("/events", (req, res) => {
  res.set("content-type", "text/event-stream");
  res.write("data: 1234\n\n");
});

En este ejemplo un poco más realista, hago que nada más conectarse, se mande un evento de tipo connect, por cada item de un array:

const users = ["Alice", "Bob", "Charlotte", "Danny"];
app.get("/events", (req, res) => {
  res.set("content-type", "text/event-stream");
  users.forEach((user) => {
    res.write("event: connect\n");
    res.write(`data: ${user}\n`);
    res.write("\n");
  });
});

Dentro del handler podría hacer cualquier cosa y emitir un evento a consecuencia de otro. Por ejemplo, aquí mando un evento al navegador cada vez que pase algo en otro listener llamado chat. Si por lo que sea queremos cerrar la conexión voluntariamente, también podemos usar end para cerrar el stream de eventos.

const users = ["Alice", "Bob", "Charlotte", "Danny"];
app.get("/events", (req, res) => {
  res.set("content-type", "text/event-stream");

  chat.on("message", (payload) => {
    res.write("event: message\n");
    res.write(`data: ${payload.content}\n`);
    res.write("\n");
  });
  
  chat.on("error", () => {
    res.end();
  });
});

Cómo usar EventSource en el navegador

El otro lado de la conexión lo tendríamos que hacer en el navegador mediante JavaScript. Para ello usamos un EventSource. Su constructor recibe como parámetro la URL de la fuente de eventos a la que nos conectamos. Ten en cuenta que la semántica de CORS aplica aquí, así que si vas a hacer peticiones a otro dominio tendrás que ajustar cosas si no quieres ver errores. En este caso, creo mi EventSource:

const stream = new EventSource("/events");

Y ahora todo lo que tengo que hacer es agregar un event listener para cada tipo de evento que quiera interceptar. ¿Qué tipos de eventos hay? Justo los que enviamos desde backend. Es decir, que si mandas un evento que empieza por event: message, lo podrás interceptar si agregas un:

stream.addEventListener("message", (e) => {
  // Handler de este evento
});

El callback de tu evento recibe un parámetro con la metainformación del evento. El contenido del evento, como tal, viene dentro del campo data. Como dije, el servidor manda el evento como una cadena de texto, así que si previamente enviamos un JSON serializado, lo tendríamos que volver a convertir a JSON. Aquí pongo un ejemplo más completo para la siguiente secuencia de eventos:

event: userOnline
data: {"user": "Alice", "status": "online"}

event: userOnline
data: {"user": "Bob", "status": "online"}

event: userOnline
data: {"user": "Alice", "status": "do_not_disturb"}

Con el siguiente código, podríamos interceptar cada uno de esos eventos e imprimir su JSON:

const stream = new EventSource("/events"); // la URL del endpoint
stream.addEventListener("userOnline", (e) => {
  const payload = JSON.parse(e.data);
  console.log(`${payload.user}: ${payload.status}`);
});

Esto imprimirá en la consola del navegador los tres objetos JSON:

Alice: online
Bob: online
Alice: do_not_disturb

A partir de aquí, puedes hacer lo que quieras con el evento, como actualizar el DOM, mostrar errores, provocar otras peticiones…

Una cosa muy interesante de EventSource es que tiene la capacidad de reconectarse. Eso significa que si la conexión se cae (por ejemplo, porque reinicias el servidor o porque se pierde la conexión de red), el navegador volverá a reconectarse cada pocos segundos hasta que se pueda volver a establecer la conexión otra vez. En general, si quieres tratar errores en tu conexión, puedes tratar el evento error.

stream.addEventListener("error", (e) => {
  // Hacer algo como avisar al user o guardar el error.
});

En caso de que no se pueda establecer la conexión inicial también se va a disparar este listener, aunque ten en cuenta que la reconexión automática no va a funcionar si falla al conectarse por primera vez.

Un LiveReload para pobres

Por ejemplo, yo lo estoy usando entre otras cosas para hacer un livereload de los pobres. Supongamos que quiero forzar al frontend a recargarse cuando haga un cambio en backend, como puede ser agregar un endpoint nuevo o cambiar un comportamiento. Si por lo que sea no se puede ir al navegador a pulsar F5, podría ser útil tener un evento server-sent que provoque que la página se recargue por su cuenta.

Yo lo que estoy haciendo para esto es enviar un evento que mande un número de versión.

res.send("event: protocolVersion\n");
res.send("data: 3\n\n");

Y cuando hago un cambio sensible en el servidor que provoque que haya que recargar la aplicación, lo incremento antes de hacer Ctrl-C y volver a lanzar el servidor.

res.send("event: protocolVersion\n");
res.send("data: 4\n\n");

Finalmente, en el navegador web aplico este código que lee la versión la primera vez que se recibe un número, y que comprueba cada vez que le llegue un número nuevo si es mayor que el inicial. Cuando eso ocurra, recargará la pestaña:

let version = null;
stream.on("protocolVersion", (e) => {
  const code = parseInt(e.data);
  if (version === null)
    version = code;
  else if (version < code)
    window.location.reload();
});