Accueil / Articles

Pourquoi les WebSockets bloquent dans les routes API de Next.js et comment un serveur personnalisé y remédie

Découvrez pourquoi un serveur WebSocket à l’intérieur d’une route pages/api ne termine jamais la séquence de connexion, et comment gérer vous-même l’événement d’upgrade avec un serveur Next.js personnalisé.

Éditeur : REACTAPP.TOP7 min de lecture1537 mots

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

Une première approche courante pour utiliser WebSockets dans Next.js consiste à créer un serveur ws dans pages/api/socket.ts et à s’y connecter depuis le navigateur. Le résultat est déroutant : pas d’erreur, pas de message, simplement un socket resté dans l’état CONNECTING jusqu’à ce que le navigateur expire. Ce guide explique pourquoi cela se produit, montre comment prendre le contrôle du serveur HTTP afin que la mise en relation s’achève réellement, et aborde les limites de scalabilité et d’hébergement que l’on accepte en échange.

Pourquoi une route API ne peut jamais améliorer la connexion

Une route API de Next.js est un gestionnaire de requêtes HTTP ordinaire. Elle s’exécute une fois que la requête a déjà été acheminée en tant qu’HTTP classique. En revanche, une mise en relation WebSocket est une requête HTTP contenant l’en-tête Upgrade, et elle doit être interceptée via l’événement upgrade du serveur Node avant que tout routeur ne la traite. Au moment où la fonction de la route s’exécute, cette opportunité est déjà passée. La route répond par du JSON tandis que le client attend une réponse 101 Switching Protocols, et aucune des deux parties ne progresse.

La version défectueuse commence par les imports habituels, y compris les types API de Next :

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

La route crée ensuite un WebSocketServer et y associe un gestionnaire de connexion qui répète les messages, mais son gestionnaire exporté se contente de renvoyer un statut 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" });
}

L’instance wss existe, et noServer: true est même la bonne configuration. Ce qui manque, c’est toute appel à handleUpgrade. Rien ne transmet le socket à ws, ce qui empêche new WebSocket(...) du navigateur de finaliser la connexion.

Prendre en charge le serveur HTTP

La solution consiste à cesser d’attendre qu’une route API s’en charge et à contrôler directement le serveur HTTP. Next.js prend en charge un serveur personnalisé : vous créez vous-même le serveur HTTP Node, vous transmettez les requêtes normales au gestionnaire de requêtes de Next, et vous écoutez les événements upgrade vous-même.

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

Le script serveur ci-dessous effectue quatre tâches :

  • Il lance Next en mode développement ou de production et obtient le gestionnaire de requêtes par défaut.
  • Il crée un WebSocketServer avec noServer: true et suive les sockets ouverts dans un Set, en les supprimant lors des événements close et error.
  • Au fur et à mesure des messages reçus, il parcourt tous les clients connectés et transmet le texte, ce qui transforme un écho par socket en une simple salle de diffusion.
  • Lorsque app.prepare() est terminé, il crée le serveur HTTP et, dans l’écouteur upgrade, vérifie le chemin : /api/ws est transmis à wss.handleUpgrade, tandis que tout autre chemin est supprimé. Enfin, il écoute sur PORT ou 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}`));
    });
    

    Les deux éléments clés du design

    Tout d’abord, noServer: true indique à ws de ne pas ouvrir d’écouteur propre, car les demandes de mise à niveau seront fournies manuellement.

    Deuxièmement, la vérification du chemin est plus importante qu’il n’y paraît. Sans elle, toute demande de mise à niveau arrivant sur le serveur serait traitée par votre fonction handleUpgrade, y compris celle liée au remplacement en temps réel de modules que Next utilise pendant le développement. Ce mécanisme de protection permet de maintenir votre point d’entrée /api/ws séparé. Cependant, sachez que la branche else détruit toutes les autres mises à niveau ; selon la version de Next.js que vous utilisez, cela peut également interrompre la connexion HMR en mode développement. Si le chargement en temps réel cesse de fonctionner, redirigez les mises à niveau qui ne correspondent pas vers Next au lieu de les supprimer (consultez la section sur le serveur personnalisé dans la documentation actuelle de Next.js pour connaître la méthode recommandée).

    L’ordre des arguments de handleUpgrade est facile à confondre. Comme indiqué dans la documentation de ws 8.18.3, il s’agit de handleUpgrade(request, socket, head, callback). À l’intérieur du callback, c’est vous-même qui émettez connection sur le serveur, de sorte que le gestionnaire wss.on("connection") fonctionne exactement comme pour un serveur indépendant.

    Le composant client

    La partie côté navigateur est délibérément simple. En tant que composant client, il commence par la directive "use client" ainsi que les hooks React nécessaires.

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

    LiveFeed ouvre un socket vers le même chemin protégé par le serveur, ajoute chaque message reçu à l’état, puis affiche la 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>
      );
    }
    

    Deux choix ici permettent d’éviter de vrais bugs :

    • Le protocole est dérivé de window.location.protocol au lieu d’être codé en dur avec ws://. En production, la page est servie via HTTPS, et les navigateurs bloquent une connexion ws:// non chiffrée depuis une page https:// ; donc le codage en dur ne fonctionnerait que après déploiement.
    • Cet effet retourne une fonction de nettoyage qui appelle ws.close(). En développement, le mode strict de React exécute les effets deux fois, si bien qu’en l’absence de cette fonction de nettoyage, un socket supplémentaire serait laissé ouvert à chaque chargement de la page.

    Démarrage du serveur

    Démarrez l’application avec tsx server.ts, ou son équivalent compilé en production, plutôt qu’avec next dev. Abandonner la commande de développement sans configuration en échange du contrôle total sur le serveur HTTP représente le véritable coût des WebSockets natifs dans cette configuration.

    Vérification du comportement

    Vérification manuelle rapide effectuée sur Node 24 avec ws 8.18.3 confirme chaque aspect :

    • Lorsque deux clients sont connectés, un message tel que hello from a envoyé par le premier arrive au second via la boucle de diffusion.
    • Un socket ouvert sur un chemin autre que /api/ws échoue immédiatement sans rester bloqué, car socket.destroy() le ferme aussitôt au lieu de le laisser en attente comme c’est le cas avec la version basée sur des routes API.
    • Une route HTTP ordinaire sur le même serveur renvoie toujours son corps, ce qui prouve que le traitement des requêtes de Next et l’upgrade WebSocket partagent un même écouteur.

    Où cette approche perd en capacité de scaling

    Un Set en mémoire contenant des clients au sein d’un seul processus est suffisant tant que vous n’exécutez qu’une seule instance. Avec deux pods derrière un équilibreur de charge, un message reçu sur le pod A n’atteint jamais un client connecté au pod B, car chaque processus dispose de son propre ensemble de clients. Dans ce cas, vous avez besoin d’une couche pub/sub, le plus souvent Redis, pour diffuser les messages entre les instances, ou d’un service en temps réel géré. Pour en savoir plus sur les salles de discussion, la persistance des données et la diffusion des messages, consultez la conception de backends de chat en temps réel. Ne construisez pas ce système dès le début : un processus Node peut gérer de nombreux sockets simultanés, et de nombreuses applications n’en ont jamais besoin au-delà de cette capacité.

    Contraintes d’hébergement

    Points clés

    • La négociation WebSocket a lieu lors de l’événement upgrade du serveur, que les routes API ne perçoivent jamais.
    • Un serveur personnalisé vous permet d’orienter les mises à niveau vers ws en utilisant noServer: true et handleUpgrade, tandis que Next gère le HTTP normal.
    • Sécurisez les mises à niveau en fonction du chemin, et soyez attentif à ce qui arrive aux mises à niveau qui ne vous appartiennent pas.
    • Déterminez le protocole ws ou wss en fonction du protocole de la page, et fermez toujours les sockets lors du nettoyage.
  • La diffusion en mémoire n’est possible qu’avec une seule instance, et l’hébergement sans serveur exclut complètement ce modèle.