vyvchy
    Теми розділу

    03 · Веб і мережі для AQA

    WebSockets і реалтайм

    Зміст

    Класичний веб побудований на простій угоді: браузер щось просить, сервер відповідає, обмін завершується. Ця модель чудово працює для сторінок, форм і REST-API. Але щойно застосунку треба, щоб дані зʼявлялися самі — нове повідомлення в чаті, зміна ціни на біржі, оновлення статусу замовлення — модель «спитав-отримав» починає тріщати. WebSocket — це відповідь на питання «як зробити, щоб сервер міг заговорити першим».

    Для AQA тема має подвійну вагу. По-перше, реалтайм-функції треба тестувати, а поводяться вони інакше, ніж звичайні запити. По-друге, WebSocket — стабільне джерело флаку (flakiness) в автотестах, бо повідомлення приходять асинхронно й непередбачувано в часі.

    Чому звичайного HTTP не вистачає

    У HTTP ініціатором завжди є клієнт. Сервер не має способу «постукати» в браузер сам — він може лише відповісти на вже надісланий запит. Це модель «запит-відповідь» (request-response), і в межах одного обміну вона поводиться напівдуплексно (half-duplex): спершу до кінця говорить один бік, потім інший. Сервер мовчить, доки його не спитають.

    Коли розробникам потрібен потік свіжих даних поверх такого HTTP, вони історично використовували два обхідні прийоми.

    Короткий опитувальний цикл (short polling) — клієнт раз у N секунд шле запит «є щось нове?», і сервер одразу відповідає, навіть якщо нового нема.

    setInterval(async () => {
      const res = await fetch('/api/messages?since=' + lastId);
      const data = await res.json();
      if (data.length) render(data);
    }, 3000);

    Проблеми очевидні: опитувати рідко — дані запізнюються; часто — маса порожніх запитів і зайве навантаження на сервер. Середня затримка доставки дорівнює половині інтервалу.

    Довгий опитувальний цикл (long polling) — клієнт шле запит, а сервер не відповідає одразу, а «тримає» зʼєднання відкритим, доки не зʼявиться подія (або не спрацює таймаут). Отримавши відповідь, клієнт одразу шле наступний запит.

    GET /api/messages/wait HTTP/1.1
    Host: example.com

    Long polling дає майже реалтайм і працює скрізь, де є звичайний HTTP, тому довго був стандартом для чатів. Але кожне повідомлення — це окремий цикл «відповідь + новий запит» із повним набором заголовків (headers), а канал односпрямований: сервер віддає дані лише у відповідь на черговий запит клієнта.

    ПідхідЗатримкаНавантаженняНапрямок
    Short pollingвисока (до інтервалу)багато порожніх запитівклієнт → сервер
    Long pollingнизькаменше запитів, але зʼєднання «висять»клієнт ініціює, сервер відповідає з даними
    WebSocketнайнижчаодне зʼєднання на весь часдвобічний

    Що таке WebSocket

    WebSocket — це протокол (RFC 6455), який дає одне постійне зʼєднання поверх одного TCP-зʼєднання, де обидва боки можуть слати дані будь-коли. Це і є повний дуплекс (full-duplex): сервер може надіслати повідомлення, не чекаючи запиту, і клієнт теж — одночасно, в обидва боки, як телефонна розмова, а не як рація.

    Важлива деталь для розуміння: WebSocket — це окремий протокол, а не «HTTP, який тримається відкритим». Він лише починається як HTTP-запит, а після рукостискання те саме TCP-зʼєднання переходить на власний бінарний формат кадрів (frame). Тому зникає накладка HTTP: не треба щоразу слати заголовки, куки, повторювати автентифікацію. Заголовок кадру — це лічені байти (проти сотень байтів HTTP-заголовків), тож реалтайм виходить дешевшим і швидшим, ніж будь-який polling.

    Кадр несе кілька службових полів: біт завершення повідомлення, код операції (opcode) — текст, бінарні дані, закриття, ping чи pong — і корисне навантаження. Одна деталь протоколу варта згадки, бо пояснює логіку рукостискання: кожен кадр від клієнта до сервера маскується випадковим 32-бітним ключем (сервер свої кадри не маскує). Це не про приватність — за формулюванням RFC, маскування потрібне, щоб не збивати з пантелику проміжні вузли (зокрема перехоплювальні проксі), і з міркувань безпеки: воно закриває клас атак, коли зловмисник намагається протягнути крізь кеш проксі дані, схожі на легітимну HTTP-відповідь (отруєння кешу).

    Upgrade-handshake: як HTTP переходить у WebSocket

    Зʼєднання починається звичайним HTTP-запитом GET зі спеціальними заголовками, які означають «хочу перемкнутися на WebSocket». Це називають рукостисканням (handshake).

    Запит від браузера:

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

    Ключові заголовки:

    ЗаголовокРоль
    Upgrade: websocketпросить змінити протокол
    Connection: Upgradeпідтверджує намір перемкнутися
    Sec-WebSocket-Keyвипадковий ключ клієнта (base64), захист від випадкового апгрейду кешами/проксі
    Sec-WebSocket-Version: 13версія протоколу (13 — актуальна за RFC 6455)

    Якщо сервер згоден, він відповідає статусом 101 Switching Protocols — це і є момент, коли зʼєднання перестає бути HTTP:

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

    Значення Sec-WebSocket-Accept сервер обчислює детерміновано: бере ключ клієнта, дописує до нього фіксовану константу-GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11, рахує SHA-1 і кодує результат у base64. Клієнт повторює те саме обчислення й звіряє. Це не автентифікація й не шифрування — лише доказ, що на іншому боці справді WebSocket-сервер, який зрозумів запит, а не випадковий кеш чи проксі, що механічно повторив відповідь.

    Після 101 те саме TCP-зʼєднання вже несе WebSocket-кадри, а не HTTP-повідомлення. У рукостисканні можна також домовитися про піддипротокол (Sec-WebSocket-Protocol) і розширення на кшталт стиснення (Sec-WebSocket-Extensions: permessage-deflate).

    СерверКлієнтСерверКлієнтTCP-зʼєднання переходить на WebSocket-кадриПовний дуплекс — обидва боки шлють будь-колиGET /chat, Upgrade: websocket, Sec-WebSocket-Key101 Switching Protocols, Sec-WebSocket-Acceptкадр (маскований клієнтом)кадр (без маски)СерверКлієнтСерверКлієнтTCP-зʼєднання переходить на WebSocket-кадриПовний дуплекс — обидва боки шлють будь-колиGET /chat, Upgrade: websocket, Sec-WebSocket-Key101 Switching Protocols, Sec-WebSocket-Acceptкадр (маскований клієнтом)кадр (без маски)

    ws vs wss

    Схема URL визначає, чи буде зʼєднання зашифроване.

    СхемаЩо цеПорт за замовчуваннямШифрування
    ws://WebSocket поверх звичайного TCP80немає
    wss://WebSocket поверх TLS443так (TLS)

    Співвідношення таке саме, як http до https: wss — це той самий WebSocket, але всередині TLS-тунелю. У проді використовують лише wss. Крім приватності є суто практична причина: проміжні проксі й корпоративні мережі часто ламають незашифрований ws, не розуміючи апгрейд, а зашифрований wss для них — непрозорий тунель, який вони пропускають як є. Ще одне правило браузерів: зі сторінки, відкритої по https, не можна відкрити ws:// — це змішаний контент (mixed content), і браузер його блокує (мовчки, з помилкою в консолі; обійти це заголовком чи прапорцем не можна).

    Життєвий цикл: readyState і події

    У браузері WebSocket — це обʼєкт із власним станом і чотирма подіями життєвого циклу. Поточний стан читається з властивості readyState:

    КонстантаЗначенняСтан
    WebSocket.CONNECTING0триває рукостискання
    WebSocket.OPEN1зʼєднання відкрите, можна слати
    WebSocket.CLOSING2почалося закриття
    WebSocket.CLOSED3закрите або не вдалося відкрити

    open

    помилка

    close

    обрив 1006

    new WebSocket

    CONNECTING = 0

    OPEN = 1

    CLOSED = 3

    CLOSING = 2

    open

    помилка

    close

    обрив 1006

    new WebSocket

    CONNECTING = 0

    OPEN = 1

    CLOSED = 3

    CLOSING = 2

    const socket = new WebSocket('wss://example.com/chat');
    
    socket.addEventListener('open', () => {
      // зʼєднання встановлене, можна слати
      socket.send(JSON.stringify({ type: 'auth', token: 'abc123' }));
    });
    
    socket.addEventListener('message', (event) => {
      // event.data — рядок або бінарні дані
      const msg = JSON.parse(event.data);
      console.log('прийшло:', msg);
    });
    
    socket.addEventListener('close', (event) => {
      console.log('закрито', event.code, event.reason, event.wasClean);
    });
    
    socket.addEventListener('error', () => {
      // деталей навмисно мало — з міркувань безпеки
      console.log('сталася помилка зʼєднання');
    });
    • open — рукостискання успішне, зʼєднання готове. До цієї події send() кидатиме помилку.
    • message — прийшли дані. event.data — це або текст (типово JSON-рядок), або бінарні дані (Blob/ArrayBuffer). Кожне повідомлення приходить окремою подією; порядок збережений, але момент приходу непередбачуваний.
    • close — зʼєднання закрилося. У CloseEvent є code, reason і wasClean (чи це було охайне закриття, чи обрив).
    • error — щось пішло не так. Навмисно віддає мало інформації; майже завжди слідом іде close.

    Ще одна корисна властивість — bufferedAmount: кількість байтів, які ви вже передали в send(), але які ще не пішли в мережу. Якщо слати швидше, ніж канал встигає віддавати, буфер росте — це сигнал робити зворотний тиск (backpressure), а не сліпо накопичувати дані.

    Коди закриття задані протоколом. Найпоширеніші:

    КодЗначення
    1000Нормальне закриття
    1001Going away (сторінка закривається або сервер вимикається)
    1002Помилка протоколу
    1006Аварійний обрив: close-кадру не було; код ставить сама реалізація й по мережі він не передається
    1009Повідомлення завелике
    1011Внутрішня помилка сервера

    Діапазони теж значущі: 1000–2999 зарезервовані для потреб самого протоколу, 3000–3999 — для бібліотек, фреймворків і застосунків (реєструються в IANA), 4000–4999 — для приватного вжитку за домовленістю сторін. Код 1006 варто памʼятати окремо: якщо ви його бачите, зʼєднання просто зникло (втрата мережі, вбитий процес, таймаут проксі), а не було охайно закрите.

    Heartbeat і ping-pong

    Відкрите зʼєднання може «мовчати» годинами. Проблема в тому, що проміжні вузли — балансувальники, проксі, NAT — часто закривають ідле-зʼєднання за таймаутом, і жоден бік не дізнається про це одразу. Тому потрібен спосіб перевіряти, що канал живий, і не давати йому «протухнути».

    На рівні протоколу для цього є керівні кадри ping і pong: один бік шле ping, інший зобовʼязаний відповісти pong. Це вбудований механізм WebSocket. Важливий нюанс для тестувальника: браузерний WebSocket API не дає керувати ping/pong із JavaScript. Браузер сам відповідає pong на серверний ping, але події «прийшов ping» у JS немає, і надіслати ping вручну зі сторінки не можна.

    Саме тому багато застосунків будують власний heartbeat поверх звичайних повідомлень — шлють JSON на кшталт { "type": "ping" } і чекають { "type": "pong" }, або просто регулярний «keepalive»:

    setInterval(() => {
      if (socket.readyState === WebSocket.OPEN) {
        socket.send(JSON.stringify({ type: 'ping' }));
      }
    }, 25000);

    Прикладний heartbeat вирішує ще одну задачу: якщо очікуваний pong не прийшов за N секунд, клієнт вважає зʼєднання мертвим, закриває його й перепідключається, не чекаючи, доки операційна система помітить обрив TCP (а це буває дуже довго).

    Будь-де, де важлива низька затримка й ініціатива на боці сервера:

    • Чати й месенджери — повідомлення мусять приходити миттєво, без опитування.
    • Нотифікації — «вам відповіли», «зʼявилося завдання», лічильники непрочитаного.
    • Трейдинг і котирування — ціни змінюються десятки разів на секунду; тут polling неприйнятний за затримкою.
    • Спільне редагування — курсори й зміни інших користувачів у реальному часі (документи, дошки, дизайн-редактори).
    • Живі дашборди й моніторинг — метрики, логи, статуси прогонів, що оновлюються самі.

    Показовий приклад із нашої предметної області: сторінка запуску (run) у системі керування тестами, де статуси тестів і лічильники «капають» у міру виконання, — типовий кандидат на WebSocket або SSE.

    SSE проти WebSocket

    Server-Sent Events (SSE) — це другий інструмент реалтайму, і його часто плутають із WebSocket. Головна відмінність — у напрямку.

    ОзнакаWebSocketSSE
    Напрямокдвобічний (full-duplex)лише сервер → клієнт
    Протоколвласний, поверх TCP (після HTTP-апгрейду)звичайний HTTP, потік text/event-stream
    Формат данихтекст і бінарні данілише текст (UTF-8)
    API у браузеріWebSocketEventSource
    Автоперепідключеннятреба писати самомувбудоване (з Last-Event-ID)
    Обмеження на кількістьпрактично немаєна HTTP/1.1 — ~6 зʼєднань на домен

    SSE — це, по суті, одна нескінченна HTTP-відповідь, яку сервер поступово «доливає»:

    GET /events HTTP/1.1
    Accept: text/event-stream
    data: {"price": 42.10}
    
    data: {"price": 42.15}
    
    const es = new EventSource('/events');
    es.onmessage = (e) => console.log(JSON.parse(e.data));

    SSE простіший, працює поверх звичайного HTTP (легше дружить із проксі й балансувальниками) і сам перепідключається після обриву, підставляючи Last-Event-ID, щоб не втратити події. Його слабина — однобічність (клієнт не може слати назад тим самим каналом) і обмеження браузера на кількість одночасних зʼєднань на домен по HTTP/1.1 (по HTTP/2 це обмеження практично знімається за рахунок мультиплексування — багато потоків ділять одне TCP-зʼєднання).

    Коли що обирати:

    • Потрібен двобічний обмін (чат, гра, спільне редагування, команди від клієнта) → WebSocket.
    • Потрібні лише оновлення від сервера (стрічка новин, котирування, прогрес, нотифікації) → SSE: простіше, дешевше в підтримці, з безкоштовним перепідключенням.
    • Оновлення рідкісні або допустима затримка в секунди → інколи чесніше залишити звичайний polling: менше рухомих частин, легше тестувати.

    Чи переживає WebSocket перезавантаження сторінки

    Ні. WebSocket живе в контексті виконання JavaScript конкретної вкладки. Жорстке перезавантаження (reload) або перехід за посиланням знищує цей контекст разом з обʼєктом WebSocket, і зʼєднання закривається — сервер побачить close (нерідко з кодом 1001 або аварійний 1006). Після завантаження нової сторінки все починається з нуля: новий handshake, новий стан, повторна автентифікація й повторна підписка на потрібні події.

    Виняток лише один, і він насправді не суперечить правилу: у SPA «мʼяка» навігація без перезавантаження (коли змінюється лише вміст, а сторінка не грузиться заново) може зберегти зʼєднання — але тільки якщо сокет живе в постійному модулі чи сторі застосунку, а не привʼязаний до компонента, який розмонтувався.

    Для AQA це прямий наслідок у тестах: після кожного page.reload() або переходу на нову сторінку стан реалтайму скидається. Не можна розраховувати, що підписка або отримані до перезавантаження повідомлення «доживуть» до наступного кроку.

    Поведінка при втраті мережі та reconnect

    Коли мережа зникає, WebSocket не завжди помічає це миттєво. Якщо був охайний обрив — прилетить close. Якщо звʼязок просто пропав (Wi-Fi відвалився, проксі вбив ідле-зʼєднання) — сторона може довго вважати, що зʼєднання живе, доки не спрацює heartbeat або TCP-таймаут. Ось чому прикладний ping/pong із попереднього розділу такий важливий: він робить «тихий» обрив помітним.

    Логіку перепідключення (reconnect) для WebSocket доводиться писати вручну — на відміну від SSE, вбудованого автоперепідключення тут немає. Типова, добре поводжена реалізація:

    let attempt = 0;
    
    function connect() {
      const socket = new WebSocket('wss://example.com/chat');
    
      socket.addEventListener('open', () => { attempt = 0; });
    
      socket.addEventListener('close', (e) => {
        if (e.code === 1000) return; // закрили навмисно — не перепідключаємось
        const delay = Math.min(1000 * 2 ** attempt, 30000); // експоненційна пауза
        attempt++;
        setTimeout(connect, delay);
      });
    }
    connect();

    Так, code 1000

    Ні

    Так

    Ні

    Подія close

    Закрили навмисно?

    Стоп, не перепідключаємось

    Пауза: експоненційний backoff плюс jitter

    Новий connect

    Зʼєднання відкрилось?

    attempt = 0, відновити стан і підписки

    Так, code 1000

    Ні

    Так

    Ні

    Подія close

    Закрили навмисно?

    Стоп, не перепідключаємось

    Пауза: експоненційний backoff плюс jitter

    Новий connect

    Зʼєднання відкрилось?

    attempt = 0, відновити стан і підписки

    Тут два важливі прийоми: експоненційне збільшення паузи (backoff), щоб не бомбардувати сервер запитами під час аварії (на практиці до паузи ще додають випадковий розкид — jitter, щоб клієнти не ломилися назад синхронно), і розрізнення «нас закрили навмисно» від «звʼязок обірвався». Після перепідключення застосунок мусить відновити стан — заново автентифікуватися, підписатися на канали й, в ідеалі, дозапросити пропущені за час обриву повідомлення (наприклад, за останнім відомим id). Якщо цього нема — після короткого обриву мережі частина даних просто зникне. Це класичний баг, який варто перевіряти окремо.

    WebSocket очима AQA: як тестувати

    Дивимося повідомлення в DevTools

    Найшвидший спосіб побачити, що реально ходить каналом, — вкладка Network у DevTools. WebSocket-зʼєднання відображається як запит зі статусом 101 Switching Protocols; список можна звузити фільтром WS. Клік по цьому запиту відкриває вкладку з повідомленнями (у Chrome — Messages) — там видно кожен кадр: напрямок (стрілка вгору — надіслано клієнтом, вниз — прийшло від сервера), вміст і час. Вхідні й вихідні кадри додатково розрізняються кольором тла.

    На що дивитися під час дослідницького тестування:

    • заголовки апгрейду в запиті (чи справді Upgrade: websocket, чи є Sec-WebSocket-*);
    • реальний вміст кадрів — формат JSON, назви полів, чи є heartbeat-повідомлення;
    • момент і код закриття, якщо зʼєднання рветься.

    Нюанс. Назви вкладок і колонок («Messages» тощо) різняться між Chrome і Firefox та між версіями DevTools — суть однакова: окремий перегляд кадрів обраного зʼєднання.

    Автоматизація на Playwright

    Playwright уміє спостерігати за WebSocket через подію page.on('websocket') і слухати кадри:

    page.on('websocket', ws => {
      console.log('WS відкрито:', ws.url());
      ws.on('framesent',     f => console.log('→', f.payload));
      ws.on('framereceived', f => console.log('←', f.payload));
      ws.on('close',          () => console.log('WS закрито'));
    });

    Це дає змогу перевіряти, що клієнт справді підписався, що прийшло очікуване повідомлення, або що після дії в UI пішов правильний кадр. Для повного контролю Playwright має також перехоплення й мокання WebSocket через page.routeWebSocket() — можна підмінити сервер і віддавати наперед задані повідомлення, ізолювавши тест від реального бекенду. Ця можливість зʼявилася у Playwright 1.48, тож у старіших версіях проєкту її не буде — варто перевірити версію перед використанням.

    Cypress історично WebSocket-трафік не перехоплює (cy.intercept працює з HTTP/XHR/fetch, але не з WS), тому там мокають на рівні застосунку або клієнтської бібліотеки.

    Чому WebSocket складніше автоматизувати

    Причини лежать у самій природі протоколу, і всі вони — джерела флаку:

    • Немає кореляції «запит-відповідь». У HTTP кожен запит має свою відповідь, і на неї легко почекати. У WebSocket повідомлення приходять коли завгодно й не привʼязані до конкретної дії — тест не може просто «дочекатися відповіді на цей запит».
    • Асинхронність і час. Повідомлення прилітають із затримкою, яку тест не контролює. Спокуса поставити waitForTimeout(2000) — прямий шлях до нестабільних тестів. Правильно — чекати на подію-наслідок (появу елемента в UI, конкретний кадр), а не на фіксований час.
    • Стан живе між діями. Одне зʼєднання тримає стан підписок, автентифікацію, чергу повідомлень. Це ускладнює ізоляцію стану між тестами: сокет, відкритий в одному тесті, або нотифікація, породжена іншим тестом на спільному середовищі, можуть протекти й зіпсувати сусідній прогін.
    • Порядок і гонки. Кілька повідомлень можуть прийти майже одночасно; UI може відрендерити їх у неочікуваному порядку. Асерти на «кінцевий стан» надійніші, ніж на проміжні.
    • Перепідключення. Тест може ненавмисно спровокувати reconnect (наприклад, паузою чи перемиканням вкладок), і після нього застосунок опиниться в іншому стані, ніж очікував сценарій.
    • Локатори на динамічний вміст. Дані, що «капають» по WebSocket, зʼявляються поступово, тому локатори мусять бути стійкі до того, що елемент виникає не одразу, а асерти — до того, що список ще добудовується.

    Практичний висновок: реалтайм-функції варто тестувати або через стійкі очікування UI-наслідків (найближче до досвіду користувача), або через мокання WebSocket (детерміновано, швидко, ізольовано) — залежно від того, що ви перевіряєте: інтеграцію з реальним сервером чи поведінку самого фронтенду. Найкрихкіший варіант — асертити на реальні повідомлення від живого бекенду з таймінгом «на око»; його лишають лише для наскрізних сценаріїв і завжди підпирають надійними очікуваннями замість пауз.