# WebSocket

## Що це таке

WebSocket — це протокол, який перетворює один запит HTTP на з'єднання, куди
обидві сторони можуть писати будь-коли і яке лишається відкритим, доки одна з
них його не закриє. Його описує RFC 6455, що вийшов у грудні 2011 року, а ту
частину, яку браузер відкриває скриптам, — об'єкт `WebSocket` — визначає
WebSockets Standard від WHATWG. У коді й документації назву часто пишуть
«websocket» або «web socket», а множина WebSockets зазвичай означає технологію
загалом.

З'єднання починається зі звичайного запиту `GET` за HTTP/1.1. Клієнт додає
`Upgrade: websocket` і `Connection: Upgrade`, `Sec-WebSocket-Version` зі
значенням 13 та `Sec-WebSocket-Key` — випадкові 16 байтів, закодовані в base64.
Сервер, який погоджується, відповідає кодом стану 101 Switching Protocols і
полем заголовка `Sec-WebSocket-Accept`. Це значення доводить, що сервер прочитав
саме це рукостискання, а не повторює стару відповідь: це ключ клієнта з
дописаним до нього фіксованим GUID, хешований SHA-1 і закодований у base64. Від
цієї миті HTTP на з'єднанні закінчується, і сторони обмінюються кадрами.

Кадр невеликий. Його заголовок займає щонайменше два байти, виростає ще на два
або вісім, коли корисне навантаження довше за 125 байтів, і ще на чотири в
кадрах від клієнта, бо ті несуть ключ маскування. Кожен кадр має код операції:
текст, що мусить бути коректним UTF-8, двійкові дані або продовження, коли
повідомлення розбито на кілька кадрів, а також три керувальні кадри — для
закриття, ping і pong. Кожен кадр, який надсилає клієнт, маскується новим
випадковим 32-бітним ключем, а сервер не маскує ніколи. Маскування — не
шифрування. Воно існує для того, щоб ворожий скрипт не міг вибрати точні байти,
які підуть мережею, і з їхньою допомогою отруїти кешувальний проксі десь на
шляху.

Адреси починаються з `ws://` або `wss://`, за замовчуванням використовують порти
80 і 443, а `wss` веде весь обмін усередині TLS. Це ті самі порти, що й у HTTP,
і так задумано: для брандмауерів і проксі, крізь які проходить рукостискання,
воно виглядає як будь-який інший вебзапит.

## Чому це важливо

HTTP — це запит і відповідь, тож сервер говорить, лише коли його питають.
Багатьом продуктам потрібне протилежне: повідомлення в чаті, ціна, що щойно
змінилася, курсор у спільному документі, фонове завдання, яке завершилося. Без
каналу, у який може писати сервер, клієнтові лишається опитування. Він питає
кожні кілька секунд, більшість відповідей лише повідомляє, що нічого не
змінилося, кожна несе повний набір полів заголовка, а новина чекає до цілого
інтервалу, перш ніж її хтось побачить. Відкрите з'єднання WebSocket прибирає
обидві витрати. Сервер пише тієї ж миті, коли щось стається, а повідомлення йде
з кількома байтами кадру замість цілої сторінки заголовків, тож затримка
зводиться до того, що додає сама мережа.

Ціна — це стан. Запит HTTP може потрапити на будь-який сервер за
балансувальником навантаження, а з'єднання WebSocket належить одному процесу,
доки живе. Цей процес тримає пам'ять під кожне відкрите з'єднання, розгортання
чи перезапуск обриває їх усі разом, а кожен проксі й балансувальник навантаження
на шляху має пропустити перемикання протоколу й терпіти з'єднання, яке може
мовчати хвилинами. Коли серверів більше одного, постає питання, якого звичайний
HTTP ніколи не ставить: якщо щось сталося на сервері A, як про це дізнається
клієнт, чиє з'єднання живе на сервері B? Звична відповідь — спільний канал
publish/subscribe, який слухають усі сервери, і це ще одна система, яку треба
підтримувати.

Тож WebSocket — це свідомий вибір, а не відправна точка. Коли новини є лише в
сервера, server-sent events, описані в HTML Standard, надсилають події звичайною
відповіддю HTTP з типом `text/event-stream`, а браузер сам перепідключається,
коли відповідь обривається. WebSocket виправдовує свої додаткові рухомі частини
тоді, коли клієнтові є що сказати не менше, ніж серверові: у чатах, іграх і
спільному редагуванні документів.

## Як це виглядає на практиці

Наведений нижче обмін відкриває з'єднання для чату. Ключ — зразкове значення,
узяте з самого RFC 6455, а значення `Sec-WebSocket-Accept` — саме те, яке
наводить стандарт і яке на цей ключ мусить повернути будь-який коректний сервер.

```http
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://example.com

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
```

Після порожнього рядка, що завершує відповідь сервера, на з'єднанні вже немає
HTTP. Браузер виконує все рукостискання одним викликом конструктора й передає
решту обробникам подій:

```js
const socket = new WebSocket("wss://example.com/chat");
socket.addEventListener("open", () => socket.send("привіт"));
socket.addEventListener("message", (event) => console.log(event.data));
socket.addEventListener("close", (event) => {
  console.log(event.code, event.reason, event.wasClean);
});
```

Подія закриття повідомляє, як завершилося з'єднання. Код 1000 — нормальне
закриття, а 1001 означає, що одна зі сторін іде: наприклад, сервер вимикається
або браузер залишає сторінку. Код 1006 означає, що з'єднання обірвалося зовсім
без кадру закриття. Жодна сторона не має права його надсилати: він існує лише
для того, щоб програма зверху могла відрізнити аварійне завершення від чистого.

Межу між моделлю «запит — відповідь» і сокетом добре видно в одному проєкті з
портфоліо сайту. Коли API переводили на Huma v2,
[усі маршрути перейшли на модель запиту й відповіді Huma, крім рівно двох](/uk/portfolio/migrated-the-api-to-huma-v2-at-100-parity/):
ендпоінти WebSocket лишилися на чистому Fiber, бо там коду справді потрібен сам
сокет, а модель Huma не пасує. Фреймворк, побудований навколо форми самого HTTP,
добре описує й перевіряє запити та відповіді, а довгоживуче двостороннє
з'єднання — просто інша річ.

## Де виникають проблеми

**Сторінка з іншого сайту відкриває з'єднання з cookie ваших відвідувачів.**
Браузер виконує рукостискання як запит до хоста сервера й додає до нього cookie
цього хоста, звідки б не прийшов скрипт, що попросив з'єднання. Тому сервер,
який автентифікує за cookie і ніколи не дивиться на `Origin`, прийме з'єднання,
відкрите будь-якою сторінкою в мережі, від імені того, хто якраз її відвідує.
RFC 6455 радить серверові, призначеному лише для певних сайтів, звіряти `Origin`
з очікуваними й відмовляти решті кодом 403 Forbidden. Робіть це під час
рукостискання, до перемикання протоколу.

**Токен доступу потрапляє в журнали.** Конструктор `WebSocket` у браузері
приймає URL і необов'язковий список підпротоколів — і більше нічого, тож скрипт
не може додати поле `Authorization`. Швидкий обхід — токен в URL, де його
записують зворотні проксі й журнали доступу. RFC залишає автентифікацію
відкритою й називає як варіанти cookie, автентифікацію HTTP і автентифікацію
TLS. Працює cookie разом із перевіркою `Origin`, описаною вище, і так само
працює короткоживучий одноразовий квиток, який сторінка отримує через
автентифікований виклик HTTP і витрачає на рукостискання.

**Перезапуск перетворює всіх клієнтів на лавину.** Розгортання закриває всі
з'єднання одночасно, кожен клієнт отримує код 1006, і якщо всі вони негайно
підключаються знову й не припиняють спроб, сервер, піднявшись, отримує те, що
RFC порівнює з атакою на відмову в обслуговуванні. RFC 6455 просить клієнтів
почекати випадковий час перед першою спробою, пропонуючи від 0 до 5 секунд, а
після кожної невдачі чекати довше, наприклад з усіченою двійковою експоненційною
затримкою. Сервер, який перед зупинкою закриває свої з'єднання з кодом 1001,
принаймні повідомляє клієнтам, що обрив був запланований.

```js
function reconnectDelay(attempt) {
  if (attempt === 0) return Math.random() * 5000;
  const ceiling = Math.min(60_000, 1000 * 2 ** attempt);
  return Math.random() * ceiling;
}
```

**З'єднання вмирають, і ніхто цього не помічає.** З'єднання без трафіку не дає
жодній стороні знаку, що шлях досі існує, а співрозмовник, який зник без кадру
закриття, виглядає точнісінько як той, що просто мовчить, аж до наступного
невдалого запису. Для цього й існують ping і pong: сторона мусить відповідати на
кожен ping кадром pong, а RFC зазначає, що ping може слугувати і keepalive, і
перевіркою того, що інша сторона ще відповідає. API браузера не дає способу
надіслати ping, тож таймер має жити на сервері: пінгуйте кожне з'єднання з
фіксованим інтервалом і закривайте ті, що не відповіли кадром pong.

**Одне повідомлення з'їдає пам'ять сервера.** Поле довжини в заголовку кадру
вміщує розміри, на які не вистачить пам'яті жодного сервера, і RFC попереджає,
що ворожа сторона може надіслати один величезний кадр або нескінченну низку
дрібних фрагментів. Реалізації мусять себе захищати, тож задайте в серверній
бібліотеці максимальний розмір повідомлення й закривайте з'єднання кодом 1009,
message too big, коли співрозмовник його перевищує. Те саме накопичення буває й
у зворотному напрямку. У браузері `bufferedAmount` показує, скільки переданих у
`send()` байтів досі чекають на відправлення в мережу, і приклад у стандарті
WHATWG надсилає наступне оновлення лише тоді, коли це число дорівнює нулю. На
сервері клієнт, що читає повільно, так само наповнює вихідний буфер, тож кожному
з'єднанню потрібна межа для нього й правило, що робити, коли межу досягнуто.

## Що почитати далі

- [RFC 6455: The WebSocket Protocol](https://www.rfc-editor.org/rfc/rfc6455) —
  рукостискання, кадри, маскування й коди закриття, а також міркування безпеки,
  що стоять за кожним із них.
- [WebSockets Standard](https://websockets.spec.whatwg.org/) — API браузера:
  конструктор, `send()`, `close()`, `bufferedAmount` і подія закриття.
- [RFC 9110: HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110) — поле
  заголовка `Upgrade` і код стану 101 Switching Protocols, на які спирається
  рукостискання.
- [Server-sent events](https://html.spec.whatwg.org/multipage/server-sent-events.html)
  — односпрямована альтернатива з HTML Standard із вбудованим перепідключенням.

<https://engineer.company/uk/glossary/websocket/>
