首页 / 文章

为什么 WebSockets 会在 Next.js API 路由中卡住,以及自定义服务器如何解决此问题

了解为何 pages/api 路由中的 WebSocket 服务器永远无法完成握手,以及如何使用自定义的 Next.js 服务器自行处理升级事件。

发布者:REACTAPP.TOP阅读约 7 分钟1537 词

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

在 Next.js 中使用 WebSockets 的常见初步尝试是在 pages/api/socket.ts 中创建一个 ws 服务器,然后从浏览器连接到该服务器。这样做会导致混乱:没有错误提示,也没有任何消息,只是有一个处于 CONNECTING 状态的套接字,直到浏览器超时。本指南将解释为何会出现这种情况,展示如何接管 HTTP 服务器以使握手真正完成,并介绍为此需要接受的扩展性和托管限制。

为何 API 路由无法升级连接

Next.js API 路由只是一个普通的 HTTP 请求处理程序。它会在请求已被作为普通 HTTP 请求路由之后才运行一次。而 WebSocket 握手则是一种带有 Upgrade 标头的 HTTP 请求,必须在任何路由器处理之前,在 Node 服务器的 upgrade 事件中被捕获。等到路由函数执行时,这个机会就已经错过了。此时路由会以 JSON 格式回复,而客户端仍在等待 101 Switching Protocols 响应,双方都无法继续推进。

有问题的版本首先进行常规的导入操作,包括 Next.js 的 API 类型:

// 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 支持自定义服务器:你可以自行创建 Node HTTP 服务器,将普通请求传递给 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,并获取默认的请求处理程序。
  • 它创建一个noServer: true的WebSocketServer,并将已打开的套接字存储在Set中,在套接字关闭或出现错误时将其移除。
  • 对于每条接收到的消息,它会遍历所有已连接的客户端并将消息转发,从而将基于单个套接字的回显功能转变为简单的广播模式。
  • 当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在开发时使用的热模块替换功能所涉及的请求。该保护机制将/api/ws接口单独隔离出来。不过需要注意的是,else分支会终止所有其他升级操作;根据你所使用的Next.js版本,这还可能切断开发时的热重载连接。如果热重载功能失效,请将不匹配的升级请求转发给Next,而非直接终止它们(具体操作方式可参考当前Next.js文档中的自定义服务器相关章节)。

    handleUpgrade的参数顺序很容易出错。根据ws 8.18.3的文档说明,其正确形式为handleUpgrade(request, socket, head, callback)。在回调函数中,需要由服务器端自行触发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提供的,而浏览器会阻止https://页面建立未加密的ws://连接,因此硬编码方式只有在部署后才会出现问题。
    • 该效应会返回一个用于调用ws.close()的清理函数。在开发模式下,React严格模式会两次执行效应函数,若没有清理函数,每次组件挂载时都会多占用一个套接字。

    运行服务器

    应使用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 的部分无服务器部署模式。WebSocket 需要长期运行的进程,而非像 Lambda 这样的函数调用。如果你的主机仅运行无服务器函数,那么无论你如何编写处理程序,WebSocket 都无法正常工作,因此在投入该功能之前请务必确认这一点。

    关键要点

    • WebSocket 握手发生在服务器的 upgrade 事件上,API 路由无法感知这一事件。
    • 自定义服务器允许你通过设置 noServer: true 和 handleUpgrade 将升级请求路由到 ws,而 Next 则负责处理常规的 HTTP 请求。
    • 需根据路径名来控制升级请求,同时要明确处理非自己负责的升级请求的方式。
    • 应根据页面协议确定使用 ws 还是 wss,并在清理时始终关闭套接字。
  • 内存广播仅支持单实例模式,而无服务器托管则完全排除了这种实现方式。