Головна / Статті

Чому WebSockets «застрягають» у API-шляхах Next.js та як це виправляє користувацький сервер

Дізнайтеся, чому веб-сервер у маршруті pages/api ніколи не завершує процедуру взаємодії, та як самостійно обробляти подію upgrade за допомогою власного сервера Next.js.

Видавець: REACTAPP.TOP7 хв читання1537 слів

Джерела: The Next.js WebSocket Trap: Why Your pages/api Handler Never Upgrades

Поширеною першою спробою використання WebSockets у Next.js є створення сервера ws у файлі pages/api/socket.ts та підключення до нього з браузера. Результат є заплутаним: жодних помилок, жодних повідомлень — лише сокет у стані CONNECTING доти, доки браузер не вичекає таймауту. У цьому посібнику пояснюється, чому це відбувається, показано, як взяти під контроль HTTP-сервер, щоб процес укладення з’єднання справді завершився, а також розглядаються обмеження щодо масштабування та хостингу, які ви приймаєте у заміну.

Чому API-шлях ніколи не може покращити якість з’єднання

Шлях API у Next.js — це звичайний обробник запитів HTTP. Він виконується після того, як запит вже було направлено як звичайний HTTP-запит. Однак рукостискання через WebSocket — це HTTP-запит із заголовком Upgrade, який необхідно перехопити у події upgrade сервера Node ще до того, як його обробить будь-який маршрутизатор. До моменту виконання функції шляху ця можливість вже минула. Шлях відповідає у форматі JSON, тоді як клієнт чекає відповіді 101 Switching Protocols, і жодна з сторін не робить прогресу.

Неправильна версія починається зі звичайних імпортів, включаючи типи API від Next:

// pages/api/socket.ts  — DOES NOT WORK
import { WebSocketServer } from "ws";
import type { NextApiRequest, NextApiResponse } from "next";

Потім шлях створює WebSocketServer та приєднує обробник з’єднання, який відтворює повідомлення, але експортований обробник просто повертає статус у форматі JSON.

const wss = new WebSocketServer({ noServer: true });wss.on("connection", (ws) => {
  ws.on("message", (msg) => ws.send(`echo: ${msg}`));
});export default function handler(_req: NextApiRequest, res: NextApiResponse) {
  res.status(200).json({ status: "ws server" });
}

Інстанція wss існує, і параметр noServer: true є правильним налаштуванням. Однак бракує виклику функції handleUpgrade. Ніщо не передає сокет у режим ws, тому метод new WebSocket(...) браузера ніколи не завершує процедуру встановлення з’єднання.

Керування HTTP-сервером

Рішення полягає у тому, щоб перестати очікувати, що це зробить спеціальний API-шлях, та керувати HTTP-сервером безпосередньо. Next.js підтримує користувацький сервер: ви самі створюєте HTTP-сервер на Node, передаєте звичайні запити обробнику запитів Next та самостійно прослуховуєте події upgrade.

// server.ts
import { createServer } from "node:http";
import next from "next";
import { WebSocketServer, type WebSocket } from "ws";
import { parse } from "node:url";

Наведений нижче скрипт сервера виконує чотири завдання:

  • Він запускає Next у режимі розробки чи продакшну та отримує стандартний обробник запитів.
  • Він створює WebSocketServer із параметром noServer: true та відстежує відкриті сокети у структурі Set, видаляючи їх під час закриття або виникнення помилки.
  • При кожному надходженні повідомлення він проходить по всіх відкритих клієнтах та передає текст, що перетворює ехо для кожного сокета на просту кімнату мовлення.
  • Після того, як app.prepare() завершується, створюється HTTP-сервер, і у прослуховувачі upgrade перевіряється шлях: /api/ws передається до функції wss.handleUpgrade, а все інше знищується. Нарешті відбувається прослуховування на порту PORT або 3000.
  • const dev = process.env.NODE_ENV !== "production";
    const app = next({ dev });
    const handle = app.getRequestHandler();const wss = new WebSocketServer({ noServer: true });
    const clients = new Set<WebSocket>();wss.on("connection", (ws) => {
      clients.add(ws);
      ws.on("message", (data) => {
        const text = data.toString();
        for (const client of clients) {
          if (client.readyState === client.OPEN) client.send(text);
        }
      });
      ws.on("close", () => clients.delete(ws));
      ws.on("error", () => clients.delete(ws));
    });app.prepare().then(() => {
      const server = createServer((req, res) => handle(req, res, parse(req.url!, true)));  server.on("upgrade", (req, socket, head) => {
        const { pathname } = new URL(req.url ?? "/", "http://localhost");
        if (pathname === "/api/ws") {
          wss.handleUpgrade(req, socket, head, (ws) => {
            wss.emit("connection", ws, req);
          });
        } else {
          socket.destroy();
        }
      });  const port = Number(process.env.PORT ?? 3000);
      server.listen(port, () => console.log(`ready on http://localhost:${port}`));
    });
    

    Два аспекти, які визначають дизайн

    По-перше, параметр noServer: true вказує ws не створювати власний прослуховувач, оскільки запити на апгрейд будуть надсилатися вручну.

    По-друге, перевірка шляху до файлу має більше значення, ніж здається. Без неї кожна запит на оновлення, що надходить на сервер, потраплятиме до функції handleUpgrade, включаючи сокет для гарячої заміни модулів, який використовується Next під час розробки. Цей механізм захисту зберігає кінцеву точку /api/ws окремою. Однак слід пам’ятати, що варіант else знищує всі інші оновлення; залежно від версії Next.js це також може перервати з’єднання HMR під час розробки. Якщо гаряче завантаження перестає працювати, направляйте оновлення, які не відповідають критеріям, до Next замість того, щоб їх знищувати (дивіться розділ про користувацький сервер у поточній документації Next.js для інформації про способи цього здійснення).

    Порядок аргументів у handleUpgrade легко помилитися. Як описано для ws 8.18.3, він є handleUpgrade(request, socket, head, callback). Усередині калебеку ви самі генеруєте сигнал connection на сервері, тому обробник wss.on("connection") працює точно так само, як і у стандартному сервері.

    Клієнтський компонент

    Бокова частина браузера навмисно залишена простою. Як клієнтський компонент, він починається з директиви "use client" та необхідних для цього хуків React.

    "use client";
    import { useEffect, useState } from "react";
    

    LiveFeed відкриває сокет до того самого шляху, який захищає сервер, додає кожне надіслане повідомлення до стану та відображає список.

    export function LiveFeed() {
      const [messages, setMessages] = useState<string[]>([]);  useEffect(() => {
        const proto = window.location.protocol === "https:" ? "wss" : "ws";
        const ws = new WebSocket(`${proto}://${window.location.host}/api/ws`);    ws.onmessage = (event) => {
          setMessages((prev) => [...prev, event.data as string]);
        };    return () => ws.close();
      }, []);  return (
        <ul>
          {messages.map((msg, i) => (
            <li key={i}>{msg}</li>
          ))}
        </ul>
      );
    }
    

    Два варіанти тут запобігають справжнім помилкам:

    • Протокол отримується з window.location.protocol, а не шляхом жорсткого вказування ws://. У продакшені сторінка подається через HTTPS, і браузери блокують незашифроване з’єднання ws:// зі сторінки https://, тому жорстке вказування протоколу призведе до проблем лише після розгортання.
    • Ефект повертає функцію для очищення, яка викликає ws.close(). У режимі розробки React Strict Mode виконує ефекти двічі, тому без цієї функції для очищення при кожному завантаженні сторінки буде утворюватися один зайвий сокет.

    Запуск сервера

    Почніть застосунок за допомогою команди tsx server.ts або її скомпільованої версії у продакшені, а не next dev. Відмова від команди розробки без налаштувань заради контролю над HTTP-сервером — це справжня ціна використання нативних WebSockets у такій конфігурації.

    Перевірка поведінки

    Швидка ручна перевірка, проведена на Node 24 з використанням ws 8.18.3, підтверджує кожен аспект:

    • Коли підключені два клієнти, повідомлення на кшталт hello from a, надіслане першим, доходить до другого через цикл трансляції.
    • Сокет, відкритий за будь-якою адресою, крім /api/ws, негайно зазнає невдачі, а не залишається у стані очікування, оскільки socket.destroy() миттєво його закриває, на відміну від версії з API-шляхом.
    • Звичайний HTTP-шлях на тому самому сервері все одно повертає свій вміст, що доводить, що механізми обробки запитів у Next та процес підвищення рівня до WebSocket використовують одного слухача.

    Де цей підхід перестає ефективно масштабуватися

    Використання Set клієнтів у пам’яті в межах одного процесу є прийнятним, доки ви не запускаєте більше однієї інстанції. За наявності двох подів за балансувальником навантаження повідомлення, отримане на поді A, ніколи не доходить до клієнта, підключеного до поду B, оскільки кожен процес має власний набір clients. У такому разі потрібен шар публікації/підписки, зазвичай Redis, для розсилки повідомлень між інстанціями, або керована сервісна платформа для реального часу. Щоб детальніше дізнатися про кімнати, постійне зберігання даних та їх розсилку, перегляньте розробку бекендів для чату в реальному часі. Не будуйте цю систему відразу з самого початку: один процес Node підтримує багато одночасних сокетів, і багато додатків ніколи не досягають меж їхньої продуктивності.

    Обмеження хостингу

    Власний сервер також виводить вас з частини безсерверної моделі розгортання Next. Для WebSockets потрібен процес з довгим терміном існування, а не виклик функції, як у Lambda. Якщо ваш хост виконує лише безсерверні функції, WebSockets там не будуть працювати, незалежно від того, як ви написали обробник, тож підтвердьте це перед тим, як інвестувати у цю функцію.

    Ключові моменти

    • Обмін даними через WebSockets відбувається під час події upgrade на сервері, яку API-шлях ніколи не бачить.
    • Власний сервер дозволяє направляти запити на режим ws за допомогою параметрів noServer: true та handleUpgrade, тоді як Next обробляє звичайний HTTP.
    • Захищайте запити на режим ws за допомогою шляху файлу та уважно ставіться до того, що відбувається з запитами, які не належать вам.
    • Визначайте режим ws або wss на основі протоколу сторінки та завжди закривайте сокети під час очищення.
  • Трансляція в пам’яті працює лише з одним екземпляром, а хостинг без сервера повністю виключає цю модель.