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.
Ź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ń.
WebSocketServer z ustawieniem noServer: true i śledzi otwarte połączenia w strukturze Set, usuwając je przy zdarzeniach close i error.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 skodowanegows://. W środowisku produkcyjnym strona jest serwowana przez HTTPS, a przeglądarki blokują niezaszyfrowane połączeniews://z stronyhttps://, 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 awysłana przez pierwszego dociera do drugiego poprzez pętlę transmisji. - Socket otwarty na dowolnej ścieżce innej niż
/api/wszawodzi 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
upgradena serwerze, którego trasa API nigdy nie widzi. - Server niestandardowy umożliwia kierowanie żądań typu upgrade do protokołu
wsprzy użyciu parametrównoServer: trueihandleUpgrade, 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ół
wslubwssna podstawie protokołu strony i zawsze zamykaj połączenia w ramach procedury czyszczenia.
Literatura pokrewna
- Gdzie pociągnąć linię serwer-klient w stronie App Router Next.js — Praktyczny model mentalny dla komponentów serwerowych w React: co działa gdzie, jak strona posta na blogu dzieli się na części serwerową i kliencką oraz jakie zasady importu należy stosować.
- Działania serwera czy przetwarzacze trasy? Przewodnik decyzyjny dla Next.js 16 — Dowiedz się, kiedy mutacja w Next.js 16 powinna znajdować się w działaniu serwera, a kiedy potrzebuje przetwarzacza trasy, z poprawionymi przykładami dotyczącymi formularzy, błędów, podejścia optymistycznego i webhooków.
- Gdzie mają należeć funkcje autoryzacji, ładowania danych i obsługi błędów w aplikacji React: dziesięć rozwiązań — Dziesięć powszechnych błędów w obsłudze autoryzacji, ładowania danych, błędów oraz efektów ubocznych stanu w React oraz sposób, w jaki intercepcje, ładowarki, cache zapytań i granice błędów je naprawiają.
- Wzmacnianie serwera MCP w TypeScript dla rzeczywistego ruchu sieciowego — Jak przekształcić serwer MCP z stdio w Streamable HTTP z izolacją na poziomie sesji, autoryzacją typu OAuth bearer, ograniczeniami szybkości, granicami błędów oraz mechanizmami obrony przed iniekcjami.
- Gdzie narysować linię serwer-klient na stronie w Next.js App Router — Praktyczny model mentalny dla komponentów serwerowych w React: co działa gdzie, jak strona posta na blogu dzieli się na części serwerową i kliencką oraz jakie zasady importu należy przestrzegać.
- Działania serwera czy obsługi tras? Przewodnik decyzyjny dla Next.js 16 — Dowiedz się, kiedy mutacja w Next.js 16 powinna znajdować się w Server Action, a kiedy wymaga Route Handlera, z poprawionymi przykładami dotyczącymi formularzy, błędów, podejścia optymistycznego oraz webhooków.
- Wybór transportu i architektury dla aplikacji JavaScript w czasie rzeczywistym — Jak WebSockets, Socket.IO, WebRTC, brokerzy wiadomości, regiony brzegowe oraz monitorowanie współpracują ze sobą podczas tworzenia czatów, transmisji na żywo lub gier wieloosobowych w JavaScript.
- MCP bez stanu: skalowanie serwerów bez sesji ani procedur handshakingowych — Jak bezstanowy Model Context Protocol kończy sesje i procedury handshaking, oraz w jaki sposób _meta, wieloetapowe żądania, nagłówki routingu, cache i zadania zapewniają jego użyteczność.
- Czas działania przeglądarki, serwera lub procesu budowania: mapa decyzyjna architektur frontendowych — Zobacz, jak SSG, SSR, streaming, komponenty serwerowe, BFF-y, renderowanie na brzegu, monolity modułowe oraz mikrofrontendy odpowiadają na jedno pytanie: gdzie powinna odbywać się praca?
- Najpierw pomiar: dlaczego wczesna optymalizacja utrudnia uruchamianie aplikacji Next.js — Zobacz, jak przedwczesna memoizacja, szerokie granice klienta oraz warstwowe pamięci cache zwiększają złożoność aplikacji Next.js, oraz jak podejście oparte na pomiarach zapewnia im szybkość.