⬇️ HTTP Push: Eventos enviados por el servidor (server-sent events).

El modelo cliente-servidor para sistemas distribuidos nos permite dividir concretamente tareas entre dos entidades:

  • El servidor: que se encarga de proveer recursos o servicios
  • El cliente: que obtiene o consume dichos recursos o servicios.

En las aplicaciones web este modelo se aplica principalmente en conjunto con el ciclo de petición-respuesta (request-response), que siempre inicia con el cliente realizando una petición al servidor para obtener recursos, y como resultado, el servidor le envía al cliente una respuesta adecuada.

Este modelo es atómico, es decir, una petición equivale a una respuesta. Por lo tanto si el cliente desea obtener más datos o servicios debe lanzar más peticiones. Cada una de estas peticiones tiene una conexión asociada, con un máximo tiempo de espera antes de ser cerrada (time-to-live o TTL).

Pero este modelo no es el único, existe también HTTP Push, que es una característica importante de los servidores que permite enviar datos a un cliente sin que este lo haya solicitado explícitamente. Al igual que el modelo básico, comienza medianteuna conexión iniciada por el cliente, la diferencia está en que esta conexión permanece abierta para el envío de datos.

Server-Sent Events

Los eventos enviados por el servidor (Server-Sent events) es una técnica que implementa HTTP Push. Cabe aclarar que esta técnica no es bi-direccional, es decir, el cliente no puede enviar datos al servidor mediante esta conexión.

Características

Como se menciona anteriormente, para poder operar el servidor requiere de una petición inicial realizada por el cliente. Al recibir esta petición el servidor responde con unos encabezados de mensaje explícitos. En el caso de HTTP/2, estos son:

{
   ':status': 200,
   'Content-Type': 'text/event-stream',
}

El cliente debe ser capaz de interpretar este encabezado correctamente y mantener una conexión con el servidor, este es el caso de todos los navegadores web.

Una vez establecida la conexión durable el servidor envía mensajes en un formato específico para poder tener la interpretación correcta. Dichos mensajes pueden ser genéricos o pueden ser eventos con un tipo específico asociado.

// Mensajes genéricos
// Sólo requieren el campo `data`
// y estar separados por dos saltos de línea (\n)

data: prueba uno
\n
\n
data: prueba dos
// Mensajes con un tipo específico
// Requieren una especificación del campo `event`
// El cliente también hace uso del campo `id` para mejor organización

id: 1
event: tipo_uno
data: Este es un mensaje tipo uno, id 1
\n
\n
id: 2
event: tipo_dos
data: Este es un mensaje tipo dos, id 2

⚠️ Los saltos de línea necesarios son muy importantes, de lo contrario el cliente no interpretará bien los mensajes.

También es importante mencionar que los mensajes sólo pueden ser enviados en formato UTF-8, datos binarios no están soportados.

Ejemplo de servidor en Node.js

Para el ejemplo crearemos un servidor en typescript utilizando la implementación de HTTP/2 en Node.js.

# creando el directorio
; mkdir server-sent-events && cd server-sent-events

# creando el proyecto
; yarn init -y
; yarn add -D typescript @types/node
; npx tsc --init

Antes de comenzar, dado que HTTP/2 sólo funciona sobre SSL, se requiere crear un par de llaves en nuestra computadora local para el certificado. Esto se realiza utilizando el comando openssl.

; openssl req -x509 -newkey rsa:2048 -nodes -sha256 -subj '/CN=localhost'  -keyout localhost-privkey.pem -out localhost-cert.pem

Una vez creados los archivos podemos utilizarlos para crear una instancia del servidor

// src/index.ts

import { createSecureServer } from "node:http2";
import { readFileSync, createReadStream } from "fs";

// Define aquí 👇 correctamente la ruta de las llaves
const server = createSecureServer({
    key: readFileSync("./localhost-privkey.pem"),
    cert: readFileSync("./localhost-cert.pem"),
});

server.on('error', (err) => {
    console.error("😔 Error:", err);
});

server.listen(8443, () => { console.log("Servidor corriendo en puerto 8443"); });

Ahora agregaremos la funcionalidad para poder enviar datos utilizando server-sent events, para esto crearemos un callback en el evento stream del servidor. Esta función implementa las respuestas para dos rutas:

  • / (raíz): retornará una página HTML muy sencilla en la cual aparecerán los eventos que el servidor envía
  • /sse: será la ruta desde donde se recibirán los eventos.
// src/index.ts

// [...]

server.on('stream', (stream, headers) => {
    const path = headers[":path"];
    if (path === "/sse") {
        stream.respond({
            ':status': 200,
            'Content-Type': 'text/event-stream',
        });

        stream.write('\ndata: este es un mensaje simple - 1\n\n');
        
        setTimeout(() => {
            stream.write(`id: ${(new Date()).getTime()}\n`);
            stream.write('event: test\n');
            stream.write("data: este es un mensaje con un tipo asociado - 1\n\n");
        }, 5000);

        setTimeout(() => {
            stream.write('\ndata: este es un mensaje simple - 2\n\n');
        }, 7500); 

        setTimeout(() => {
            stream.write(`id: ${(new Date()).getTime()}\n`);
            stream.write('event: test\n');
            stream.write("data: este es un mensaje con un tipo asociado - 2\n\n");
        }, 10000);
    } else if (path === "/") {
        stream.respond({
            'content-type': 'text/html; charset:utf-8',
            ':status': 200,
        });
        createReadStream(__dirname + "/index.html").pipe(stream);
    } else {
        stream.respond({
            ':status': 404,
        });
        stream.end("Not found");
    }

    stream.on("close", () => {
        console.log("Client disconnected");
    });
});

// [...]

En el código anterior se puede observar el envío de cuatro eventos, dos simples y dos del tipo “test”. Dichos eventos son enviados con algunos milisegundos de separación.

El cliente

En el navegador se require hacer uso de la clase [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) para poder escuchar los eventos enviados por el servidor y reaccionar correspondientemente ante ellos. Concretamente necesitamos crear una instancia de dicha clase y agregar callbacks que serán ejecutados cuando los mensajes sean recibidos.

Para los mensajes simples (que no tienen un tipo asociado) se requiere implementar una función en EventSource.onmessage. Para los eventos con un tipo asociado debemos escuchar específicamente a un evento creado que corresponde a su tipo, por ejemplo, si tenemos un mensaje que especifica event: mi_evento entonces debemos agregar un callback a EventSource.on('mi_evento'). En este caso concreto implementamos las escuchas de la siguiente manera.

// src/index.html

<!DOCTYPE html>
<html>

<body>
    <h3>Estos son eventos sencillos</h3>
    <ul id="simple_events__ul"></ul>

    <h3>Estos son eventos tipo "test"</h3>
    <ul id="tests__ul"></ul>

    <script type="text/javascript">
        const evtSource = new EventSource('https://localhost:8443/sse');
        const eventsUl = document.getElementById("simple_events__ul");
        const testsUl = document.getElementById("tests__ul");

        evtSource.onmessage = (event) => {
            eventsUl.innerHTML += `<li>${event.data}</li>`;
        };

        evtSource.addEventListener("test", (event) => {
            testsUl.innerHTML += `<li>${event.data}</li>`;
        });

        evtSource.onerror = (err) => {
            console.error("Event source failed:", err);
        };                
    </script>
</body>

</html>

El arribo de mensajes en este ejemplo se observa a continuación 👇

Eventos enviados desde el servidor

Conclusión

Server-sent events es una técnica muy útil cuando se tiene una aplicación que recibe actualizaciones pero no requiere enviar datos de vuelta al servidor mediante el mismo canal, por ejemplo una lista de noticias en vivo.

Está soportada por todos los navegadores e incluso tiene polyfills en caso de que cierto navegador antiguo no lo soporte.