Главная / Статьи

Почему WebSockets застревают в API-маршрутах Next.js и как это исправляет пользовательский сервер

Узнайте, почему веб-сервер внутри маршрута pages/api никогда не завершает процедуру обмена сообщениями, и как самостоятельно обрабатывать событие обновления с помощью пользовательского сервера 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, удаляя их при сигналах close и error.
  • При поступлении каждого сообщения он проходит по всем открытым клиентам и пересылает текст, что превращает эхо для каждого сокета в простую комнату бродкаста.
  • После завершения выполнения метода 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.js во время разработки. Этот механизм защиты позволяет сохранять отдельность конечной точки /api/ws. Однако следует помнить, что ветка else уничтожает все остальные запросы на обновление; в зависимости от версии Next.js это также может прервать соединение в режиме реального времени. Если функция горячей загрузки перестанет работать, направляйте запросы на обновление, не соответствующие условиям, в 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 там не будут работать независимо от того, как вы напишете обработчик, поэтому убедитесь в этом перед тем, как инвестировать средства в эту функциональность.

    Основные выводы

    • Процесс установления соединения WebSocket происходит при событии upgrade на сервере, которое API-маршрут никогда не видит.
    • Собственный сервер позволяет направлять запросы на установление соединения в режим ws с параметрами noServer: true и handleUpgrade, в то время как Next обрабатывает обычный HTTP-трафик.
    • Контролируйте запросы на установление соединения по пути к файлу и тщательно продумайте, что будет происходить с запросами, которые не относятся к вам.
    • Определяйте режим ws или wss на основе протокола страницы и всегда закрывайте сокеты в процессе очистки.
  • Радиовещание в памяти возможно только с единственным экземпляром, а хостинг без сервера полностью исключает эту модель.