Strona główna / Artykuły

Dlaczego WebSockets utykają w ścieżkach API Next.js i jak własny serwer to naprawia

Dowiedz się, dlaczego serwer WS w środku trasy pages/api nigdy nie kończy procedury handshake, oraz jak samodzielnie obsłużyć zdarzenie upgrade za pomocą niestandardowego serwera Next.js.

Wydawca: REACTAPP.TOP7 min czytania1537 słów

Źródła: The Next.js WebSocket Trap: Why Your pages/api Handler Never Upgrades

Częstym pierwszym podejściem do WebSockets w Next.js jest stworzenie serwera ws w pliku pages/api/socket.ts i połączenie z nim z przeglądarki. Wynik jest mylący: nie ma błędu, nie ma komunikatu – po prostu socket pozostaje w stanie CONNECTING aż przeglądarka wygaśnie. Ten przewodnik wyjaśnia, dlaczego tak się dzieje, pokazuje, jak przejąć kontrolę nad serwerem HTTP, aby proces nawiązywania połączenia faktycznie został zakończony, oraz omawia ograniczenia skalowania i hostingu, które musimy zaakceptować w zamian.

Dlaczego trasa API nigdy nie może ulepszyć połączenia

Ścieżka API w Next.js to zwykły obsługiwacz żądań HTTP. Uruchamia się on po tym, jak żądanie zostało już skierowane jako zwykłe HTTP. Natomiast handshake WebSocket to żądanie HTTP zawierające nagłówek Upgrade, które musi zostać przechwycone w zdarzeniu upgrade serwera Node, zanim jakikolwiek router je przetworzy. Zanim funkcja ścieżki zostanie wywołana, ta szansa już minęła. Ścieżka odpowiada w formacie JSON, podczas gdy klient czeka na odpowiedź 101 Switching Protocols, i żadna ze stron nie robi postępów.

Zepsuta wersja zaczyna się od standardowych importów, w tym typów API dostarczanych przez Next:

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

Następnie ścieżka tworzy WebSocketServer i dołącza obsługiwacz połączeń, który odbija wysyłane wiadomości, ale eksportowany przez niego obsługiwacz po prostu zwraca status w formacie 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" });
}

Instancja wss istnieje, a noServer: true to nawet właściwa ustawienie. Brakuje jednak jakiegokolwiek wywołania handleUpgrade. Nic nie przekazuje socketa do ws, więc metoda new WebSocket(...) w przeglądarce nigdy nie kończy procesu nawiązywania połączenia.

Przejęcie kontroli nad serwerem HTTP

Rozwiązaniem jest zaprzestanie oczekiwania, że to ścieżka API zajmie się tym zadaniem, oraz bezpośrednia kontrola nad serwerem HTTP. Next.js obsługuje niestandardowy serwer: sam tworzysz serwer HTTP w Node, przekazujesz zwykłe żądania do obsługi żądań w Next i sam słuchasz zdarzeń upgrade.

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

Poniższy skrypt serwera wykonuje cztery zadania:

  • Zapуска Next w trybie rozwojowym lub produkcyjnym i pobiera domyślną obsługę żądań.
  • Tworzy WebSocketServer z ustawieniem noServer: true i śledzi otwarte połączenia w strukturze Set, usuwając je przy zdarzeniach close i error.
  • Dla każdej przychodzącej wiadomości przechodzi przez wszystkie otwarte połączenia klienta i przekazuje tekst, co zamienia echo dla poszczególnych połączeń w prosty pokój transmisyjny.
  • Gdy metoda app.prepare() zostanie zakończona, tworzy serwer HTTP i w słuchaczu upgrade sprawdza ścieżkę: /api/ws jest przekazywana do metody wss.handleUpgrade, a wszystko inne jest niszczone. Na koniec serwer słucha na porcie PORT lub 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}`));
    });
    

    Dwa elementy decydujące o projekcie

    Najpierw ustawienie noServer: true mówi modułowi ws, aby nie tworzył własnego słuchacza, ponieważ prośby o aktualizację połączenia będą podawane ręcznie.

    Po drugie, sprawdzanie ścieżki pliku ma większe znaczenie, niż się wydaje. Bez niego każda prośba o aktualizację trafiająca na serwer zostałaby przekazana do funkcji handleUpgrade, w tym również do mechanizmu wymiany modułów w czasie rzeczywistym używanego przez Next podczas rozwoju. Ten mechanizm ochronny utrzymuje endpoint /api/ws oddzielony. Należy jednak pamiętać, że gałąź else niszczy wszystkie inne aktualizacje; w zależności od wersji Next.js może to również przerwać połączenie HMR służące do rozwoju. Jeśli funkcja szybkiego ładowania przestanie działać, należy kierować nieodpowiednie aktualizacje do Next zamiast je niszczyć (sprawdź sekcję dotyczącą niestandardowego serwera w aktualnych dokumentach Next.js, aby dowiedzieć się, jak to zrobić).

    Kolejność argumentów w handleUpgrade łatwo pomylić. Zgodnie z dokumentacją ws 8.18.3, ma ona postać handleUpgrade(request, socket, head, callback). Wewnątrz funkcji zwrotnej sam wywołujesz connection na serwerze, więc obsłużnik wss.on("connection") działa dokładnie tak samo jak w przypadku serwera samodzielnego.

    Komponent klienta

    Część przeglądarki jest celowo prosta. Jako komponent klienta zaczyna się od dyrektywy "use client" oraz niezbędnych hooków React.

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

    LiveFeed otwiera socket do tej samej ścieżki, którą chroni serwer, dodaje każdą przychodzącą wiadomość do stanu i renderuje listę.

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

    Dwa wybory tutaj zapobiegają prawdziwym błędom:

    • Protokół pochodzi od window.location.protocol, zamiast używać bezpośrednio skodowanego ws://. W środowisku produkcyjnym strona jest serwowana przez HTTPS, a przeglądarki blokują niezaszyfrowane połączenie ws:// z strony https://, więc skodowanie bezpośrednie spowodowałoby błąd dopiero po wdrożeniu.
    • Efekt zwraca funkcję czyszczenia, która wywołuje ws.close(). W środowisku rozwojowym React Strict Mode uruchamia efekty dwa razy, więc bez funkcji czyszczenia przy każdym uruchomieniu utrzymywałby się jeden dodatkowy sokiet.

    Ruch serwera

    Zainstaluj aplikację za pomocą tsx server.ts lub odpowiednika po skompilowaniu w środowisku produkcyjnym, zamiast next dev. Rezygnacja z polecenia rozwojowego wymagającego żadnej konfiguracji na rzecz pełnego kontrolowania serwera HTTP stanowi rzeczywisty koszt używania natywnych WebSockets w tym ustawieniu.

    Weryfikacja zachowania

    Szybka ręczna weryfikacja przeprowadzona na Node 24 z ws 8.18.3 potwierdza każdy z tych punktów:

    • Gdy połączone są dwa klienty, wiadomość taka jak hello from a wysłana przez pierwszego dociera do drugiego poprzez pętlę transmisji.
    • Socket otwarty na dowolnej ścieżce innej niż /api/ws zawodzi natychmiast, zamiast pozostawać w stanie niepewnym, ponieważ socket.destroy() zamknął go od razu, zamiast pozostawić w nieokreślonym stanie jak w wersji z API-route.
    • Zwykła trasa HTTP na tym samym serwerze nadal zwraca swój treść, co dowodzi, że obsługa żądań w Next i proces aktualizacji do WebSocket dzielą ten sam słuchacz.

    Gdzie ta metoda przestaje się skalować

    W pojedynczym procesie zbiór Set klientów w pamięci jest w porządku, dopóki nie uruchamiasz więcej niż jednej instancji. Przy dwóch kontenerach za balansem obciążeń wiadomość otrzymana w kontenerze A nigdy nie dotrze do klienta połączonego z kontenerem B, ponieważ każdy proces ma swój własny zbiór clients. W takiej sytuacji potrzebujesz warstwy pub/sub, najczęściej Redis, aby rozpowszechniać wiadomości między instancjami, lub zarządzanego usługi w czasie rzeczywistym. Aby dowiedzieć się więcej na temat sal, trwałości danych i rozpowszechniania wiadomości, zapoznaj się z projektowaniem backendów do czatów w czasie rzeczywistym. Nie buduj tego od razu – jeden proces Node obsługuje wiele jednoczesnych sokietów, a wiele aplikacji nigdy nie wymaga czegoś więcej.

    Ograniczenia hostingu

    Server niestandardowy pozwala również uniknąć użycia części modelu implementacji bezserwerowej Next. WebSockets wymagają procesu długotrwałego, a nie wywołania funkcji takiej jak Lambda. Jeśli Twój host uruchamia tylko funkcje bezserwerowe, WebSockets nie będą tam działać, niezależnie od tego, jak napiszesz obsługę, więc upewnij się co do tego przed inwestowaniem w tę funkcjonalność.

    Główne wnioski

    • Proces nawiązywania połączenia WebSocket odbywa się przy zdarzeniu upgrade na serwerze, którego trasa API nigdy nie widzi.
    • Server niestandardowy umożliwia kierowanie żądań typu upgrade do protokołu ws przy użyciu parametrów noServer: true i handleUpgrade, podczas gdy Next zajmuje się normalnym HTTP.
    • Zabezpiecz żądania upgrade według ścieżki pliku i staraj się dokładnie planować, co dzieje się z żądaniami, które nie pochodzą od Ciebie.
    • Określ protokół ws lub wss na podstawie protokołu strony i zawsze zamykaj połączenia w ramach procedury czyszczenia.
  • Transmisja w pamięci jest dostępna tylko dla pojedynczej instancji, a hosting bez serwera całkowicie wyklucza ten model.
  • Literatura pokrewna