Hola
Mi nombre es Sasha y trabajo como arquitecto en Tinkoff Business.
En este artículo, quiero hablar sobre cómo superar la restricción de los navegadores sobre la cantidad de conexiones HTTP abiertas de larga duración dentro del mismo dominio utilizando Service Worker.
Si lo desea, puede omitir el fondo, la descripción del problema, buscar una solución e inmediatamente proceder al resultado.

Antecedentes
Había una vez en Tinkoff Business un chat que funcionaba en
Websocket .
Después de un tiempo, dejó de encajar en el diseño de su cuenta personal, y en general estaba pidiendo mucho tiempo para reescribir de angular 1.6 a angular 2+. Decidí que era hora de comenzar a actualizarlo. Un colega-defensor descubrió que la interfaz de chat cambiará y sugirió al mismo tiempo rehacer la API, en particular: cambiar el transporte de websocket a
SSE (eventos enviados por el servidor) . Sugirió esto porque al actualizar la configuración de NGINX se rompieron todas las conexiones, lo que fue difícil de restaurar.
Discutimos la arquitectura de la nueva solución y llegamos a la conclusión de que recibiremos y enviaremos datos utilizando solicitudes HTTP regulares. Por ejemplo, envíe un
mensaje POST: / api / send-message , obtenga una lista de cuadros de diálogo
GET: / api / conversations-list, y así sucesivamente. Y los eventos asincrónicos como "un nuevo mensaje del interlocutor" se enviarán a través de SSE. Por lo tanto, aumentaremos la tolerancia a fallas de la aplicación: si la conexión SSE se cae, el chat seguirá funcionando, solo que no recibirá notificaciones en tiempo real.
Además del chat en websocket, estábamos persiguiendo eventos para el componente de "notificaciones delgadas". Este componente le permite enviar varias notificaciones a la cuenta personal del usuario, por ejemplo, que la importación de cuentas, que puede tardar varios minutos, se ha completado con éxito. Para abandonar completamente websocket, trasladamos este componente a una conexión SSE separada.
El problema
Cuando abre una pestaña del navegador, se crean dos conexiones SSE: una para chat y otra para notificaciones sutiles. Bueno, que se creen. Perdón o qué? ¡No sentimos pena, pero los navegadores lo sienten! Tienen un
límite en el número de conexiones persistentes concurrentes para un dominio . ¿Adivina cuánto hay en Chrome? Correcto, seis! Abrí tres pestañas: califiqué todo el grupo de conexiones y ya no puede realizar solicitudes HTTP. Esto es cierto para el protocolo HTTP / 1.x. En HTTP / 2 no existe tal problema debido a la multiplexación.
Hay un par de formas de resolver este problema a nivel de infraestructura:
- Fragmento de dominio.
- HTTP / 2.
Ambos métodos parecían caros, ya que mucha infraestructura tendría que verse afectada.
Por lo tanto, para empezar, intentamos resolver el problema en el lado del navegador. La primera idea era hacer algún tipo de transporte entre las pestañas, por ejemplo, a través de
LocalStorage o
Broadcast Channel API .
El significado es este: abrimos conexiones SSE en una sola pestaña y enviamos los datos al resto. Esta solución tampoco parecía óptima, ya que requeriría el lanzamiento de los 50 SPA, que conforman la cuenta personal de Tinkoff Business. Lanzar 50 aplicaciones también es costoso, así que seguí buscando otras formas.
Solución
Recientemente trabajé con trabajadores de servicios y pensé: ¿es posible aplicarlos en esta situación?
Para responder a esta pregunta, primero debe comprender qué hacen generalmente los trabajadores de servicios. Pueden enviar solicitudes proxy, se ve más o menos así:
self.addEventListener('fetch', event => { const response = self.caches.open('example') .then(caches => caches.match(event.request)) .then(response => response || fetch(event.request)); event.respondWith(response); });
Escuchamos eventos para solicitudes HTTP y respondemos a nuestro gusto. En este caso, estamos tratando de responder desde el caché, y si no funciona, hacemos una solicitud al servidor.
Ok, intentemos interceptar la conexión SSE y responderla:
self.addEventListener('fetch', event => { const {headers} = event.request; const isSSERequest = headers.get('Accept') === 'text/event-stream'; if (!isSSERequest) { return; } event.respondWith(new Response('Hello!')); });
En las solicitudes de red, vemos la siguiente imagen:

Y en la consola, esto:

Ya no está mal. La solicitud fue interceptada, pero el SSE no quiere una respuesta en forma de
texto / sin formato , pero quiere
texto / secuencia de eventos . ¿Cómo crear una transmisión ahora? Pero, ¿puedo incluso responder con una transmisión de un trabajador de servicio? Bueno, veamos:

Genial La clase
Response toma como cuerpo
ReadableStream . Después de leer la
documentación , puede descubrir que
ReadableStream tiene un controlador que tiene un método
enqueue () , con su ayuda puede transmitir datos. Adecuado, tómalo!
self.addEventListener('fetch', event => { const {headers} = event.request; const isSSERequest = headers.get('Accept') === 'text/event-stream'; if (!isSSERequest) { return; } const responseText = 'Hello!'; const responseData = Uint8Array.from(responseText, x => x.charCodeAt(0)); const stream = new ReadableStream({start: controller => controller.enqueue(responseData)}); const response = new Response(stream); event.respondWith(response); });

No hay ningún error, la conexión se cuelga en estado pendiente y no llegan datos del lado del cliente. Al comparar mi solicitud con una solicitud de servidor real, me di cuenta de que la respuesta estaba en los encabezados. Para las solicitudes SSE, se deben especificar los siguientes encabezados:
const sseHeaders = { 'content-type': 'text/event-stream', 'Transfer-Encoding': 'chunked', 'Connection': 'keep-alive', };
Cuando agrega estos encabezados, la conexión se abrirá correctamente, pero los datos no se recibirán en el lado del cliente. Esto es obvio, ya que no puede enviar texto aleatorio, debe haber algún formato.
En javascript.info,
se describe bien un formato de datos en el que los datos deben enviarse desde el servidor. Se puede describir fácilmente con una función:
const sseChunkData = (data: string, event?: string, retry?: number, id?: number): string => Object.entries({event, id, data, retry}) .filter(([, value]) => ![undefined, null].includes(value)) .map(([key, value]) => `${key}: ${value}`) .join('\n') + '\n\n';
Para cumplir con el formato SSE, el servidor debe enviar mensajes separados por un salto de línea doble
\ n \ n .
El mensaje consta de los siguientes campos:
- datos - cuerpo del mensaje, varios datos en una fila se interpretan como un mensaje, separados por saltos de línea \ n;
- id : actualiza la propiedad lastEventId enviada en el encabezado Last-Event-ID al volver a conectar;
- reintentar : el retraso recomendado antes de reconectarse en milisegundos no se puede establecer con JavaScript;
- evento : el nombre del evento del usuario, indicado antes de los datos.
Agregue los encabezados necesarios, cambie la respuesta al formato deseado y vea qué sucede:
self.addEventListener('fetch', event => { const {headers} = event.request; const isSSERequest = headers.get('Accept') === 'text/event-stream'; if (!isSSERequest) { return; } const sseChunkData = (data, event, retry, id) => Object.entries({event, id, data, retry}) .filter(([, value]) => ![undefined, null].includes(value)) .map(([key, value]) => `${key}: ${value}`) .join('\n') + '\n\n'; const sseHeaders = { 'content-type': 'text/event-stream', 'Transfer-Encoding': 'chunked', 'Connection': 'keep-alive', }; const responseText = sseChunkData('Hello!'); const responseData = Uint8Array.from(responseText, x => x.charCodeAt(0)); const stream = new ReadableStream({start: controller => controller.enqueue(responseData)}); const response = new Response(stream, {headers: sseHeaders}); event.respondWith(response); });

Oh mi glob! ¡Sí, hice una conexión SSE sin un servidor!
Resultado
Ahora podemos interceptar con éxito la solicitud SSE y responderla sin ir más allá del navegador.
Inicialmente, la idea era establecer una conexión con el servidor, pero solo una cosa, y desde allí enviar datos a pestañas. ¡Hagámoslo!
self.addEventListener('fetch', event => { const {headers, url} = event.request; const isSSERequest = headers.get('Accept') === 'text/event-stream';
El mismo código en github.Obtuve una solución bastante simple para una tarea no tan trivial. Pero, por supuesto, todavía hay muchos matices. Por ejemplo, debe cerrar la conexión al servidor al cerrar todas las pestañas, es totalmente compatible con el protocolo SSE, etc.
Hemos decidido con éxito todo esto. ¡Estoy seguro de que no será difícil para usted!