Startseite / Artikel

Warum WebSockets in Next.js API-Routen hängen bleiben und wie ein benutzerdefinierter Server das behebt

Erfahren Sie, warum ein WS-Server innerhalb einer pages/api-Route niemals die Handshake-Vorgänge abschließt, und wie Sie das Upgrade-Event selbst mit einem benutzerdefinierten Next.js-Server handhaben können.

Verlag: REACTAPP.TOP7 Min. Lesezeit1537 Wörter

Quellen: The Next.js WebSocket Trap: Why Your pages/api Handler Never Upgrades

Ein häufiger erster Versuch mit WebSockets in Next.js besteht darin, einen ws-Server in pages/api/socket.ts zu erstellen und sich von dort aus über den Browser daran anzuschließen. Das Ergebnis ist verwirrend: Es gibt weder Fehler noch Nachrichten, lediglich ein Socket, der im CONNECTING-Zustand bleibt, bis der Browser eine Zeitüberschreitung verursacht. Diese Anleitung erklärt, warum das geschieht, zeigt, wie man den HTTP-Server übernimmt, damit die Verbindungsherstellung tatsächlich abgeschlossen wird, und behandelt außerdem die Skalierungs- sowie Hosting-Beschränkungen, die man dafür in Kauf nehmen muss.

Warum eine API-Route die Verbindung niemals upgraden kann

Eine Next.js API-Route ist ein gewöhnlicher HTTP-Anfragen-Handler. Sie wird einmal ausgeführt, nachdem eine Anfrage bereits als normale HTTP-Anfrage weitergeleitet wurde. Ein WebSocket-Handshake hingegen ist eine HTTP-Anfrage mit einem Upgrade-Header, und er muss im upgrade-Event des Node-Servers abgefangen werden, bevor irgendein Router ihn verarbeiten kann. Bis die Route-Funktion ausgeführt wird, ist diese Gelegenheit bereits verstrichen. Die Route antwortet mit JSON, während der Client auf eine 101 Switching Protocols-Antwort wartet, und keiner der Seiten macht Fortschritte.

Die fehlerhafte Version beginnt mit den üblichen Imports, einschließlich der API-Typen von Next:

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

Dann erstellt die Route einen WebSocketServer und fügt einen Verbindungs-Handler hinzu, der Nachrichten zurückgibt, doch der exportierte Handler gibt lediglich einen JSON-Status zurück.

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

Die wss-Instanz existiert, und noServer: true ist sogar die richtige Einstellung. Was fehlt, ist eine Aufrufung von handleUpgrade. Nichts übergibt den Socket an ws, wodurch der new WebSocket(...)-Aufruf im Browser niemals die Handshake-Phase abschließt.

Übernahme der Kontrolle über den HTTP-Server

Die Lösung besteht darin, nicht länger davon auszugehen, dass eine API-Route dies übernimmt, sondern den HTTP-Server direkt zu steuern. Next.js unterstützt einen benutzerdefinierten Server: Man erstellt selbst den Node-HTTP-Server, leitet normale Anfragen an Nexts Request-Handler weiter und hört selbst auf upgrade-Ereignisse.

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

Das untenstehende Server-Skript erledigt vier Aufgaben:

  • Es startet Next im Entwicklungs- oder Produktionsmodus und holt den Standard-Request-Handler ab.
  • Es erstellt einen WebSocketServer mit noServer: true und verfolgt die geöffneten Sockets in einem Set, wobei diese bei close und error entfernt werden.
  • Bei jeder eingehenden Nachricht wird durch jeden geöffneten Client geschleift und der Text weitergeleitet, wodurch ein Echo pro Socket in einen einfachen Broadcast-Raum umgewandelt wird.
  • Nachdem app.prepare() abgeschlossen ist, wird der HTTP-Server erstellt, und im upgrade-Listener wird der Pfad überprüft: /api/ws wird an wss.handleUpgrade übergeben, alles andere wird zerstört. Schließlich wird auf PORT oder 3000 gelauscht.
  • 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}`));
    });
    

    Die beiden Aspekte, die das Design bestimmen

    Zunächst sagt noServer: true an, dass ws keinen eigenen Listener öffnen soll, da die Upgrade-Anfragen manuell bereitgestellt werden.

    Zweitens ist die Überprüfung des Pfadnamens wichtiger, als es auf den ersten Blick scheint. Ohne sie würde jeder Upgrade-Antrag, der beim Server ankommt, in Ihre Funktion handleUpgrade weitergeleitet werden – einschließlich des Sockets für den Hot-Module-Replacement, den Next in der Entwicklung verwendet. Der Schutzmechanismus hält Ihren Endpunkt /api/ws getrennt. Beachten Sie jedoch, dass der else-Zweig alle anderen Upgrades zerstört; je nach Next.js-Version kann dadurch auch die Entwicklungs-HMR-Verbindung unterbrochen werden. Wenn das Hot Reload nicht mehr funktioniert, leiten Sie nicht passende Upgrades lieber an Next weiter, anstatt sie zu zerstören (prüfen Sie dazu den Abschnitt zum benutzerdefinierten Server in den aktuellen Next.js-Dokumenten nach der unterstützten Vorgehensweise).

    Die Argumentreihenfolge von handleUpgrade lässt sich leicht falsch anwenden. Wie in der Dokumentation zu ws 8.18.3 beschrieben, lautet sie handleUpgrade(request, socket, head, callback). Innerhalb des Callbacks senden Sie selbst connection an den Server, sodass der Handler wss.on("connection") genauso funktioniert wie bei einem eigenständigen Server.

    Die Client-Komponente

    Auf der Browser-Seite wird absichtlich auf Überfluss verzichtet. Als Client-Komponente beginnt sie mit der Direktive "use client" sowie den benötigten React-Hooks.

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

    LiveFeed öffnet einen Socket zum gleichen Pfad, den der Server schützt, fügt jede eingehende Nachricht dem State hinzu und rendernt anschließend die Liste.

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

    Zwei Entscheidungen hier verhindern echte Fehler:

    • Das Protokoll wird aus window.location.protocol abgeleitet anstelle von einer fest codierten ws://-Angabe. In der Produktion wird die Seite über HTTPS bereitgestellt, und Browser blockieren eine unverschlüsselte ws://-Verbindung von einer https://-Seite, sodass eine fest codierte Angabe erst nach der Bereitstellung zum Problem werden würde.
    • Das Effect ruft eine Aufräumfunktion auf, die ws.close() aufruft. In der Entwicklung läuft React Strict Mode die Effects zweimal ab, wodurch ohne diese Aufräumfunktion bei jedem Neuladen ein zusätzlicher Socket übrig bliebe.

    Server starten

    Starten Sie die Anwendung mit tsx server.ts oder dem entsprechenden kompilierten Programm in der Produktion, anstelle von next dev. Der Verzicht auf den konfigurationsfreien Entwicklungs-Befehl zugunsten der Kontrolle über den HTTP-Server ist die eigentliche Kostenfaktor für native WebSockets in dieser Konfiguration.

    Verhalten überprüfen

    Eine schnelle manuelle Überprüfung unter Node 24 mit ws 8.18.3 bestätigt jeden Punkt:

    • Sobald zwei Clients verbunden sind, erreicht eine vom ersten Client gesendete Nachricht wie hello from a über den Broadcast-Loop den zweiten Client.
    • Ein Socket, der an einem anderen Pfad als /api/ws eröffnet wird, versagt sofort anstatt hängenzubleiben, da socket.destroy() ihn umgehend schließt, anstatt ihn wie bei der API-Routenversion im Unklaren zu lassen.
    • Eine gewöhnliche HTTP-Route auf demselben Server gibt weiterhin ihren Inhalt zurück, was beweist, dass die Anfragenverarbeitung von Next und der WebSocket-Upgrade-Prozess denselben Listener teilen.

    Wo dieser Ansatz nicht mehr skalierbar ist

    Ein in-Memory-Set mit Clients innerhalb eines einzigen Prozesses ist in Ordnung, solange man nicht mehr als eine Instanz betreibt. Bei zwei Pods hinter einem Load Balancer erreicht eine auf Pod A empfangene Nachricht niemals einen Client, der mit Pod B verbunden ist, da jeder Prozess sein eigenes clients-Set besitzt. In diesem Fall benötigt man eine Pub/Sub-Schicht, meist Redis, um Nachrichten auf alle Instanzen zu verteilen, oder einen verwalteten Echtzeitdienst. Für eine ausführlichere Betrachtung von Räumen, Persistenz und Verteilung siehe Design von Echtzeit-Chat-Backends. Bauen Sie das nicht bereits am ersten Tag auf: Ein Node-Prozess kann viele gleichzeitige Sockets verwalten, und viele Anwendungen wachsen nie über diese Kapazität hinaus.

    Hostingsbeschränkungen

    Ein benutzerdefinierter Server entfernt Sie außerdem aus einem Teil von Nexts serverlosen Deployment-Modell. WebSockets benötigen einen langlebigen Prozess, nicht eine Funktionsaufruf wie bei Lambda. Wenn Ihr Host nur serverlose Funktionen ausführt, werden WebSockets dort unabhängig davon, wie Sie den Handler schreiben, nicht funktionieren – überprüfen Sie das daher vor dem Investieren in diese Funktion.

    Kernpunkte

    • Die WebSocket-Handshake findet im upgrade-Event des Servers statt, welches eine API-Route niemals wahrnimmt.
    • Ein benutzerdefinierter Server ermöglicht es Ihnen, Upgrades an ws mit noServer: true und handleUpgrade zu leiten, während Next den normalen HTTP-Verkehr handhabt.
    • Schützen Sie Upgrades durch Pfadnamen und überlegen Sie sorgfältig, was mit Upgrades geschieht, die nicht von Ihnen stammen.
    • Ermitteln Sie ws oder wss aus dem Seitenprotokoll und schließen Sie die Sockets stets im Zuge der Aufräumarbeiten.
  • Die In-Memory-Versorgung ist nur für Einzelmomente möglich, und serverloses Hosting schließt dieses Muster völlig aus.