Server sent events: eventos enviados por el servidor con Fastly
Los server-sent events (también conocidos como sse o eventos enviados por el servidor) permiten a los servidores web enviar notificaciones de eventos en tiempo real al navegador en una respuesta HTTP de larga duración. Es una bonita parte de la plataforma web que está muy infrautilizada por los sitios web. Con Fastly puedes transmitir eventos de manera eficiente con baja latencia, ya sean salidas de vuelos, precios de acciones o alertas de noticias, a miles o incluso millones de usuarios simultáneamente.
Aquí tienes una simulación que compuse de un panel de salidas del aeropuerto utilizando eventos en tiempo real:
Así que está bastante bien, y gracias a los eventos enviados por el servidor (SSE), resulta muy sencillo. Incluso en los primeros días de la web, las personas vieron el valor de crear contenido "en vivo". Los eventos enviados por el servidor surgieron de esta necesidad y reemplazaron algunas soluciones ingeniosas, pero terriblemente complicadas.
Las cosas estaban bastante mal
En 2006, estaba al cargo de un negocio de consultoría que estaba realizando un proyecto para el Financial Times y necesitábamos enviar datos en tiempo real y de baja latencia al navegador para una función de chat en vivo llamada Markets Live (que todavía se ejecuta todos los días de la semana a las 11:00 h en Londres). El chat es entre dos periodistas que comentan la situación de los mercados y debe transmitirse a todos los que lo ven en la web. En ese momento no había ningún mecanismo en la web que hiciera esto, y los sitios a menudo usaban "sondeo", es decir, emitían una XMLHttpRequest cada segundo para ver si había contenido nuevo.
Ahora, esto está bien si eres el tipo de persona a la que le gusta organizar un ataque de denegación de servicio contra ti mismo, pero necesitaba algo un poco más eficiente que también se escalara a posibles decenas de miles de espectadores concurrentes, sin generar 10 000 peticiones por segundo que afectaran a mi servidor.
Apareció una panda de hacks a la que se conoció como Comet, un término acuñado por Alex Russell. Esencialmente, la idea era enviar una respuesta HTTP al navegador muy lentamente, haciendo que pareciera una descarga de archivo pero en realidad enviando una secuencia de eventos. Esto se basó en la capacidad del navegador para generar una respuesta HTTP progresivamente (a medida que se descarga). Incluso en ese momento, todos los navegadores soportaban esto, pero con una mezcla heterogénea de peculiaridades ahora muy divertida.
En Firefox, puedes usar un evento interactivo
de XHR, que se activa cada vez que se reciben más datos (¡bien hecho, Mozilla!). Para Opera y Safari, el cuerpo de un XHR no era accesible hasta que se realizaba la respuesta, por lo que tuvimos que cargar el contenido en un IFRAME y enviar fragmentos de algo similar a <script>top.receiveData({{...}});</script>
. Y luego estaba Internet Explorer.
Esta fue la era en la que Microsoft pensó que era una buena idea emitir ruidos de clic al navegar (que incluía IFRAME) y hacer girar un gran logotipo de IE en la esquina superior derecha de la ventana del navegador hasta que la página hubiera "terminado de cargar". La solución fue un control ActiveX que creó una página en memoria, en la que a su vez se podía cargar un IFRAME interminable. No estoy de broma, ni se me ocurriría; este es posiblemente el segundo pirateo más horrible que he implementado.
El tema es que hicimos un gran esfuerzo para hacer algo que hoy es fácil, porque todo este pirateo precipitó el desarrollo de la API de eventos enviados por el servidor.
Vale, ahora casi todo es genial
El principio de usar una respuesta HTTP basada en texto como transporte de transmisión es realmente muy simple y bastante fácil de configurar para la mayoría de las personas en el lado del servidor. Lo que necesitábamos era una forma estándar para que los navegadores activaran un evento JavaScript cada vez que se recibiera una porción de contenido en una respuesta de carga lenta. Esta es la API que proporcionan los eventos enviados por el servidor:
(new EventSource("/stream")).addEventListener("newArticle", e => {
const data = JSON.parse(e.data);
console.log(“A new article was just published!”, data);
});
El servidor responde a las peticiones /stream
(la URL en este ejemplo) con una fuente de eventos separados por doble nueva línea:
id: 1
event: newArticle
data: {"foo": 42}
id: 2
event: newArticle
data: {"foo":123, "bar":"hello"}
Bastante simple, ¿verdad? Por fabulosos que parezcan los flujos de eventos enviados por el servidor, presentan algunos inconvenientes:
La tecnología no es compatible con Internet Explorer o Edge de Microsoft.
Tu servidor necesita mantener abiertas muchas conexiones TCP inactivas, lo que puede requerir algunas optimizaciones personalizadas para que la configuración del servidor o el hardware de red se escalen correctamente.
La arquitectura de tu aplicación debe ser capaz de generar contenido y servirlo a múltiples conexiones en espera, lo que puede ser un desafío para las tecnologías de back-end aisladas de petición como PHP.
El problema 1 se puede resolver usando un polyfill, que puedes cargar fácilmente con polyfill.io. Podemos resolver los problemas 2 y 3 afrontando tu transmisión SSE con Fastly.
Sobrecargar los SSE con Fastly
Existen varias características de Fastly que hacen que los SSE funcionen particularmente bien a través de nuestra red:
El colapso por peticiones permite que una segunda petición de la misma URL, que ya estamos obteniendo desde el origen, se una a la misma petición de origen, evitando una segunda búsqueda de origen para la URL (siempre que el objeto todavía esté "fresco", ¡mira abajo!). Para peticiones normales, esto tiene la intención de evitar el problema de la estampida de caché, pero para las transmisiones, también actúa para "distribuir" una transmisión a múltiples usuarios.
La pérdida de streaming permite que se envíe el inicio de una respuesta de origen a los clientes en espera (tal vez más de uno, gracias al colapso por peticiones) antes de recibir todo el contenido del servidor de origen. Dado que las transmisiones de los SSE envían fragmentos de datos a intervalos impredecibles, es importante que el navegador reciba cada fragmento tan pronto como salga del servidor de origen.
El escudamiento agrega solicitudes de todos nuestros POP del edge en un POP designado que está físicamente cerca de tu servidor de origen, lo que permite el colapso por peticiones en todos los POP del edge y también en el POP de protección, de modo que no importa a cuántos clientes estás transmitiendo, tu origen debe ver solo una petición cada vez.
HTTP/2 permite que las respuestas se multiplexen en la misma conexión. Sin esto, una secuencia de SSE sobre HTTP/1.1 usaría una conexión TCP, reduciendo la cantidad de concurrencia disponible para cargar otros activos desde el mismo dominio de origen. Esto solía ser 4 conexiones por origen, y recientemente es más común encontrar 8 o 16 conexiones, pero vincular una de ellas permanentemente no es una buena idea. HTTP/2 resuelve eso.
Solicitamos el colapso automático (a menos que lo desactives), pero para que las peticiones se colapsen, la respuesta de origen debe ser almacenable en caché y aún "nueva" en el momento de la nueva petición. En realidad, no quieres que almacenemos en caché la secuencia del evento una vez que finalice. Si lo hiciéramos, las peticiones futuras para unirse a la transmisión solo obtendrían una respuesta instantánea que contiene un lote de eventos que ocurrieron durante un periodo anterior en el tiempo. Lo que quieres es que almacenemos en búfer la respuesta, así como que la transmitamos, para que exista un registro de caché para que se unan nuevos clientes. Eso significa que tu tiempo de vida (TTL) para la respuesta de transmisión debe ser de la misma duración que la que deseas transmitir. Digamos que tu servidor está configurado para servir transmisiones en segmentos de 30 segundos (el navegador se vuelve a conectar después de que finaliza cada segmento): la respuesta TTL de la transmisión debe ser exactamente 30 segundos (o 29, si deseas cubrir la posibilidad de errores de sincronización del reloj):
Cache-control: public, max-age=29
Es muy importante que tu servidor de origen corte las transmisiones una vez transcurrido este tiempo. Si proporciona una transmisión interminable a Fastly, mantendremos esas conexiones para siempre y agotarás el número máximo de conexiones simultáneas al origen. Nunca sirvas descargas interminables a través de Fastly.
A continuación, deberás habilitar la transmisión de errores en VCL: agrega los siguientes snippets VCL a tu servicio:
Finalmente, el escudamiento es simplemente una cuestión de elegir un nodo de escudo cuando defines tu servidor de origen, y H2 está habilitado de forma predeterminada siempre que estés sirviendo a través de HTTPS/TLS.
Con todas estas características juntas, puedes emitir una secuencia de eventos única desde el origen y hacer que esa secuencia se entregue en tiempo real a potencialmente miles o millones de usuarios.
Cuando estaba haciendo mi demostración de vuelo, también hice un módulo NodeJS para administrar eventos enviados por el servidor usando una arquitectura pub/sub. Lo encontrarás como sse-pubsub on npm.
¿No usa mucha potencia?
Sí, las conexiones SSE mantienen la radio de un dispositivo móvil encendida todo el tiempo. Debes evitar conectar una transmisión SSE en un dispositivo que tenga poca batería, o posiblemente evitar el uso de SSE a menos que el dispositivo esté enchufado. Puedes verificar el nivel de la batería utilizando la API de estado de la batería, aunque en el momento en el que escribo esto solo está disponible en Chrome.
¿Por qué no usar sencillamente websockets?
Los websockets reciben mucha más atención que SSE, y ciertamente son más potentes, flexibles y satisfacen más casos de uso. Pero este es uno de estos usos totalmente inapropiados de la palabra "sencillamente" que me frustra. No conozco a nadie que use websockets sin una abstracción del cliente como socket.io, y no conozco a nadie que use eventos enviados por el servidor con una abstracción del cliente, porque es tan ridículamente simple que no hay necesidad.
Decir "¿por qué no usar sencillamente websockets?" es como el que se pone a ver un anime con subtítulos y dice "¿Y por qué no aprender sencillamente japonés?" No es que no valga la pena aprender japonés, pero es una solución más difícil.
¿Por qué no usar web push?
Actualmente, web push tiene un soporte de navegador más limitado, y tiene algunas restricciones significativas para el desarrollador: el usuario debe dar su consentimiento explícito para recibir los impulsos (pushes), la integración del lado del cliente es sustancialmente más compleja, requiere pasar mensajes a través de servicios de distribución de terceros y los sitios deben mostrar una notificación visual al usuario cuando se recibe una notificación. En el lado positivo, las notificaciones web push son muy eficientes, por lo que no es necesario pausar si el dispositivo funciona con batería.
En el futuro, web push cubrirá muchos más casos de uso, pero está diseñado fundamentalmente para las relaciones a largo plazo entre el navegador y el servidor, mientras que los eventos enviados por el servidor seguirán siendo una excelente solución para la transmisión a páginas activas.