[Из песочницы] SSEGWSW: Server-Sent Events Gateway by Service Workers

Привет!

Меня зовут Саша и я работаю архитектором в Тинькофф Бизнес.

В этой статье хочу рассказать о том, как преодолеть ограничение браузеров на количество открытых долгоживущих HTTP-соединений в рамках одного домена при помощи service worker.

Если хотите — смело пропускайте предысторию, описание проблемы, поиск решения и сразу переходите к результату.

SSEGWSW

Предыстория


Давным-давно в Тинькофф Бизнесе был чат, который работал на Websocket.

Спустя какое-то время он перестал вписываться в дизайн личного кабинета, да и вообще давно напрашивался на переписывание с angular 1.6 на angular 2+. Я решил, что пора бы заняться его обновлением. Коллега-бэкендер узнал, что будет меняться фронтенд чата, и предложил заодно переделать API, в частности — поменять транспорт с websocket на SSE (server-sent events). Он предложил это, потому что при обновлении конфига NGINX рвались все соединения, которые потом больно было восстанавливать.

Мы обсудили архитектуру нового решения и пришли к тому, что получать и отправлять данные будем обычными HTTP-запросами. Например, отправить сообщение POST: /api/send-message, получить список диалогов GET: /api/conversations-list и так далее. А асинхронные события вроде «пришло новое сообщение от собеседника» будем отправлять через SSE. Так мы повысим отказоустойчивость приложения: если отвалится SSE-соединение, чат все равно будет работать, только не будет получать уведомления в realtime.

Кроме чата в websocket у нас гонялись события для компонента «тонкие нотификации». Этот компонент позволяет отправлять в личный кабинет пользователя различные уведомления, например о том, что импорт счетов, который может занимать несколько минут, успешно завершился. Чтобы полностью отказаться от websocket, мы перенесли и этот компонент в отдельное SSE-соединение.

Проблема


При открытии одной вкладки браузера создаются два SSE-соединения: одно на чат и одно на тонкие нотификации. Ну ладно, пусть создаются. Жалко, что ли? Нам-то не жалко, а вот браузерам жалко! У них есть ограничение на количество одновременных постоянных соединений для домена. Угадайте, сколько в Chrome? Правильно, шесть! Открыл три вкладки — забил весь пул соединений и больше не можешь делать HTTP-запросы. Это справедливо для протокола HTTP/1.x. В HTTP/2 такой проблемы нет за счет мультиплексирования.

Есть пара способов решить эту проблему на уровне инфраструктуры:

  1. Domain sharding.
  2. HTTP/2.


Оба этих способа показались дорогими, так как придется затрагивать достаточно много инфраструктуры.

Поэтому для начала мы попробовали решить проблему на стороне браузера. Первой идеей было сделать какой-нибудь транспорт между вкладками, например через LocalStorage или Broadcast Channel API.

Смысл такой: открываем SSE-соединения только на одной вкладке и рассылаем данные на остальные. Это решение тоже не выглядело оптимальным, так как оно требовало бы релиза всех 50 SPA, из которых состоит личный кабинет Тинькофф Бизнеса. Релизить 50 приложений тоже дорого, поэтому я продолжил искать другие пути.

Решение


Недавно я работал с service workers и подумал:, а можно ли применить их в данной ситуации?

Чтобы ответить на этот вопрос, сначала нужно понять, что вообще умеют service workers? Они умеют проксировать запросы, это выглядит примерно так:

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);
});


Мы слушаем события на HTTP-запросы и отвечаем как захотим. В данном случае пытаемся ответить из кэша, а если не получится, то делаем запрос на сервер.

Хорошо, давайте попробуем перехватить SSE-соединение и ответить ему:

self.addEventListener('fetch', event => {
   const {headers} = event.request;
   const isSSERequest = headers.get('Accept') === 'text/event-stream';

   if (!isSSERequest) {
       return;
   }

   event.respondWith(new Response('Hello!'));
});


В сетевых запросах видим такую картину:

image

А в консоли такую:

image

Уже неплохо. Запрос перехватили, но SSE не хочет ответ в виде text/plain, а хочет text/event-stream. Как бы теперь создать поток? А я вообще смогу ответить потоком из service worker? Ну давайте посмотрим:

image

Отлично! Класс Response принимает в качестве body ReadableStream. Почитав документацию, можно узнать, что у ReadableStream есть controller, у которого есть метод enqueue () — с его помощью можно стримить данные. Подходит, беру!

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);
});


image

Ошибки нет, соединение висит в статусе pending и никаких данных на сторону клиента не приходит. Сравнив свой запрос с реальным запросом на сервер, я понял, что дело в заголовках ответа. Для SSE-запросов необходимо указать следующие заголовки:

const sseHeaders = {
   'content-type': 'text/event-stream',
   'Transfer-Encoding': 'chunked',
   'Connection': 'keep-alive',
};


При добавлении этих заголовков соединение успешно откроется, но данные так и не будут получены на стороне клиента. Это очевидно, так как нельзя просто отправить рандомный текст — должен быть какой-то формат.

На javascript.info хорошо описан формат данных, в котором нужно отправлять данные с сервера. Его можно легко описать одной функцией:

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';


Для соответствия формату SSE сервер должен отправлять сообщения, разделенные двойным переносом строки \n\n.

Сообщение состоит из следующих полей:

  • data — тело сообщения, несколько data подряд интерпретируются как одно сообщение, разделенное переносами строк \n;
  • id — обновляет свойство lastEventId, отправляемое в заголовке Last-Event-ID при переподключении;
  • retry — рекомендованная задержка перед переподключением в миллисекундах, не может быть установлена с помощью JavaScript;
  • event — имя пользовательского события, указывается перед data.


Добавим нужные заголовки, изменим ответ под нужный формат и посмотрим, что получится:

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);
});


image

Oh my Glob! Да я же сделал SSE-соединение без сервера!

Результат


Теперь мы можем успешно перехватить SSE-запрос и ответить на него, не выходя за рамки браузера.

Изначально идея была в том, чтобы устанавливать соединение с сервером, но только одно — и из него рассылать данные на вкладки. Давайте же сделаем это!

self.addEventListener('fetch', event => {
   const {headers, url} = event.request;
   const isSSERequest = headers.get('Accept') === 'text/event-stream';

   // Обрабатываем только SSE-соединения
   if (!isSSERequest) {
       return;
   }

   // Заголовки ответа для SSE
   const sseHeaders = {
       'content-type': 'text/event-stream',
       'Transfer-Encoding': 'chunked',
       'Connection': 'keep-alive',
   };
   // Функция, форматирующая данные для SSE
   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';
   // Таблица с серверными соединениями, где ключ — url, значение — EventSource
   const serverConnections = {};
   // Для каждого url открываем только одно соединение с сервером и используем его для последующих запросов
   const getServerConnection = url => {
       if (!serverConnections[url]) serverConnections[url] = new EventSource(url);

       return serverConnections[url];
   };
   // При получении сообщения с сервера пересылаем его в браузер
   const onServerMessage = (controller, {data, type, retry, lastEventId}) => {
       const responseText = sseChunkData(data, type, retry, lastEventId);
       const responseData = Uint8Array.from(responseText, x => x.charCodeAt(0));
       controller.enqueue(responseData);
   };
   const stream = new ReadableStream({
       start: controller => getServerConnection(url).onmessage = onServerMessage.bind(null, controller)
   });
   const response = new Response(stream, {headers: sseHeaders});

   event.respondWith(response);
});


Этот же код на github.
У меня получилось довольно простое решение для не самой тривиальной задачи. Но, конечно, тут еще остается много нюансов. Например, нужно закрывать соединение с сервером при закрытии всех вкладок, полностью поддерживать протокол SSE и так далее.

Все это мы успешно решили — уверен, и для вас это не составит труда!

© Habrahabr.ru