Use Cases (更新: 2026/6/3)

用 Claude Code 构建 WebSocket 聊天应用

用 Claude Code 构建安全 WebSocket 聊天:认证、重连、限流、广播和可运行 Node.js 示例。

用 Claude Code 构建 WebSocket 聊天应用

WebSocket 聊天看起来只是一个小功能,但它会马上暴露真实项目里的关键问题:连接如何认证,断线后怎样重连,消息怎样广播到同一个房间,用户刷屏时如何限流,服务端重启后又该如何验证功能没有坏掉。Claude Code 可以很快写出代码,但如果需求只写“做一个聊天应用”,结果通常只是一个脆弱演示。

这篇文章用浏览器原生 WebSocket API 和 Node.js 的 ws 包做一个可复制运行的最小聊天室。WebSocket 的意思是:先通过 HTTP 升级握手建立长连接,之后浏览器和服务器都可以主动发送数据。协议细节请参考 RFC 6455,浏览器 API 看 MDN WebSocket API 和 Writing WebSocket client applications。Node.js 的入口是 HTTP upgrade 事件,服务器库参考 ws README。安全测试尤其要看 OWASP WebSocket 测试指南 中的 Origin 检查。Claude Code 的基本工作方式可见 Claude Code 官方文档。

如果你还在补 API 基础,可以继续读 Claude Code API 开发、Claude Code 代码审查 和 Claude Code 安全最佳实践。

三个具体用途

第一个用途是小型社群或客服聊天。它需要房间、用户名、消息正文和最近历史。风险是没有认证和限流时,一个脚本就能持续刷屏。

第二个用途是实时仪表盘,例如构建进度、库存变化、支付事件或后台任务状态。这里最重要的不是界面,而是当消息太快时如何丢弃旧事件。MDN 提醒过,浏览器 WebSocket API 没有内建的背压能力,也就是不能自动让发送端放慢速度。

第三个用途是教程或付费资料后的答疑房间。读者可以从文章进入对话,再被引导到模板、培训或咨询页面。它对转化有帮助,但也需要日志保存、个人信息处理、禁言和删除规则。

用途为什么适合 WebSocket先决定什么
小型聊天双向消息延迟低认证、房间、历史条数
实时仪表盘服务端可主动推送丢弃策略、重连策略
教程答疑从阅读自然进入对话日志、审核、转化入口

架构图

这里的扇出指把一条消息分发给同房间的所有连接。心跳指服务端定期确认连接是否还活着。

flowchart LR
  BrowserA["浏览器标签 A"] -->|ws:// /chat| Node["Node.js server"]
  BrowserB["浏览器标签 B"] -->|ws:// /chat| Node
  Smoke["smoke-client.mjs"] -->|ws:// /chat| Node
  Node --> Auth["token and Origin check"]
  Node --> Rooms["room registry"]
  Rooms --> Fanout["fan-out to room peers"]
  Node --> History["last 50 messages"]
  Node --> Limit["rate limit and max payload"]

给 Claude Code 的提示词

请用 Node.js 20+ 和 ws 包创建一个最小 WebSocket 聊天。

要求:
- 只需要 server.js、index.html、smoke-client.mjs、package.json
- http://localhost:8080 可以打开聊天界面
- /chat 的 WebSocket upgrade 阶段必须验证 token 和 Origin
- 按 room 扇出消息
- 内存里只保留最近 50 条消息
- 每个连接限制为 10 秒 20 条消息
- 单条消息最多 500 字符
- 浏览器客户端 close 后用指数退避重连
- 发送前检查 readyState 和 bufferedAmount
- smoke-client.mjs 能连接、发送一条消息并成功退出

不要:
- 改成 Socket.IO
- 接受未认证连接
- 不验证 JSON 就使用输入
- 把内存历史伪装成生产级持久化

本地运行

把下面 4 个文件放在同一目录。先启动服务器,再在两个浏览器标签打开 http://localhost:8080,最后在另一个终端运行 npm run smoke。

mkdir ws-chat-demo
cd ws-chat-demo
npm init -y
npm install ws
npm run start

package.json:

{
  "type": "module",
  "scripts": {
    "start": "node server.js",
    "smoke": "node smoke-client.mjs"
  },
  "dependencies": {
    "ws": "^8.18.3"
  },
  "engines": {
    "node": ">=20"
  }
}

server.js

import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import { createServer } from "node:http";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { WebSocket, WebSocketServer } from "ws";

const PORT = Number(process.env.PORT ?? 8080);
const AUTH_TOKEN = process.env.CHAT_TOKEN ?? "dev-token";
const MAX_MESSAGE_LENGTH = 500;
const HISTORY_LIMIT = 50;
const RATE_WINDOW_MS = 10_000;
const RATE_LIMIT = 20;
const CLIENT_FILE = join(fileURLToPath(new URL(".", import.meta.url)), "index.html");
const ALLOWED_ORIGINS = new Set(
  (process.env.ALLOWED_ORIGINS ?? `http://localhost:${PORT},http://127.0.0.1:${PORT}`)
    .split(",")
    .map((value) => value.trim())
    .filter(Boolean)
);

const rooms = new Map();
const histories = new Map();
const clients = new Map();

const server = createServer(async (request, response) => {
  if (request.url === "/" || request.url === "/index.html") {
    try {
      const html = await readFile(CLIENT_FILE, "utf8");
      response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
      response.end(html);
    } catch {
      response.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
      response.end("index.html was not found");
    }
    return;
  }

  if (request.url === "/healthz") {
    response.writeHead(200, { "content-type": "application/json" });
    response.end(JSON.stringify({ ok: true, clients: clients.size }));
    return;
  }

  response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
  response.end("not found");
});

const wss = new WebSocketServer({ noServer: true, maxPayload: 2048 });

server.on("upgrade", (request, socket, head) => {
  const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
  if (url.pathname !== "/chat") {
    rejectUpgrade(socket, 404, "Not Found");
    return;
  }

  const origin = request.headers.origin ?? "";
  if (!ALLOWED_ORIGINS.has(origin)) {
    rejectUpgrade(socket, 403, "Forbidden");
    return;
  }

  const token = url.searchParams.get("token");
  if (token !== AUTH_TOKEN) {
    rejectUpgrade(socket, 401, "Unauthorized");
    return;
  }

  const context = {
    name: cleanName(url.searchParams.get("name")),
    room: cleanRoom(url.searchParams.get("room"))
  };

  wss.handleUpgrade(request, socket, head, (ws) => {
    wss.emit("connection", ws, request, context);
  });
});

wss.on("connection", (ws, request, context) => {
  const client = {
    id: randomUUID(),
    name: context.name,
    room: context.room,
    isAlive: true,
    rateResetAt: Date.now() + RATE_WINDOW_MS,
    messagesInWindow: 0
  };

  clients.set(ws, client);
  joinRoom(ws, client.room);

  send(ws, { type: "system", message: `connected as ${client.name}`, clientId: client.id });
  send(ws, { type: "history", messages: histories.get(client.room) ?? [] });
  broadcast(client.room, { type: "presence", message: `${client.name} joined`, online: roomSize(client.room) });

  ws.on("pong", () => {
    client.isAlive = true;
  });

  ws.on("message", (raw, isBinary) => {
    if (isBinary) {
      ws.close(1003, "text messages only");
      return;
    }

    if (!consumeQuota(client)) {
      send(ws, { type: "error", code: "rate_limited", message: "Too many messages. Wait a moment." });
      return;
    }

    const textBody = raw.toString("utf8");
    if (textBody.length > MAX_MESSAGE_LENGTH) {
      send(ws, { type: "error", code: "too_large", message: `Message must be ${MAX_MESSAGE_LENGTH} characters or less.` });
      return;
    }

    let event;
    try {
      event = JSON.parse(textBody);
    } catch {
      send(ws, { type: "error", code: "bad_json", message: "Send JSON such as {\"type\":\"message\",\"text\":\"hi\"}." });
      return;
    }

    if (event.type !== "message") {
      send(ws, { type: "error", code: "bad_type", message: "Only message events are accepted." });
      return;
    }

    const text = String(event.text ?? "").trim();
    if (!text) {
      send(ws, { type: "error", code: "empty", message: "Message text is required." });
      return;
    }

    const message = {
      id: randomUUID(),
      room: client.room,
      from: client.name,
      text,
      sentAt: new Date().toISOString()
    };

    remember(client.room, message);
    broadcast(client.room, { type: "message", message });
  });

  ws.on("close", () => {
    leaveRoom(ws);
    clients.delete(ws);
    broadcast(client.room, { type: "presence", message: `${client.name} left`, online: roomSize(client.room) });
  });

  ws.on("error", (error) => {
    console.error("websocket error", error);
  });
});

const heartbeat = setInterval(() => {
  for (const ws of wss.clients) {
    const client = clients.get(ws);
    if (!client) continue;
    if (!client.isAlive) {
      ws.terminate();
      continue;
    }
    client.isAlive = false;
    ws.ping();
  }
}, 30_000);

server.listen(PORT, () => {
  console.log(`Chat demo: http://localhost:${PORT}`);
  console.log(`Token: ${AUTH_TOKEN}`);
});

process.on("SIGINT", () => {
  clearInterval(heartbeat);
  wss.close();
  server.close(() => process.exit(0));
});

function rejectUpgrade(socket, statusCode, message) {
  socket.write(`HTTP/1.1 ${statusCode} ${message}\r\nConnection: close\r\n\r\n`);
  socket.destroy();
}

function cleanName(value) {
  const name = String(value ?? "guest").trim().slice(0, 24);
  return /^[\w -]+$/.test(name) ? name : "guest";
}

function cleanRoom(value) {
  const room = String(value ?? "lobby").trim().slice(0, 32);
  return /^[a-zA-Z0-9_-]+$/.test(room) ? room : "lobby";
}

function joinRoom(ws, room) {
  if (!rooms.has(room)) rooms.set(room, new Set());
  rooms.get(room).add(ws);
}

function leaveRoom(ws) {
  const client = clients.get(ws);
  if (!client) return;
  const peers = rooms.get(client.room);
  if (!peers) return;
  peers.delete(ws);
  if (peers.size === 0) rooms.delete(client.room);
}

function roomSize(room) {
  return rooms.get(room)?.size ?? 0;
}

function send(ws, payload) {
  if (ws.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify(payload));
  }
}

function broadcast(room, payload) {
  const peers = rooms.get(room);
  if (!peers) return;
  const body = JSON.stringify(payload);
  for (const peer of peers) {
    if (peer.readyState === WebSocket.OPEN) {
      peer.send(body);
    }
  }
}

function remember(room, message) {
  const history = histories.get(room) ?? [];
  history.push(message);
  while (history.length > HISTORY_LIMIT) history.shift();
  histories.set(room, history);
}

function consumeQuota(client) {
  const now = Date.now();
  if (now > client.rateResetAt) {
    client.rateResetAt = now + RATE_WINDOW_MS;
    client.messagesInWindow = 0;
  }
  client.messagesInWindow += 1;
  return client.messagesInWindow <= RATE_LIMIT;
}

index.html

<!doctype html>
<html lang="zh">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>WebSocket 聊天演示</title>
    <style>
      body { font-family: system-ui, sans-serif; max-width: 760px; margin: 32px auto; padding: 0 16px; }
      label { display: block; margin-top: 12px; }
      input, button { font: inherit; padding: 8px; }
      #log { border: 1px solid #ccc; min-height: 240px; padding: 12px; overflow: auto; }
      .message { margin: 0 0 8px; }
      .muted { color: #666; }
      .error { color: #b00020; }
    </style>
  </head>
  <body>
    <h1>WebSocket 聊天演示</h1>
    <p id="status" class="muted">离线</p>

    <label>房间 <input id="room" value="lobby" /></label>
    <label>名称 <input id="name" value="masa" /></label>
    <label>令牌 <input id="token" value="dev-token" /></label>
    <button id="connect">连接</button>
    <button id="disconnect">断开</button>

    <div id="log" aria-live="polite"></div>

    <form id="form">
      <label>消息 <input id="message" autocomplete="off" maxlength="500" /></label>
      <button type="submit">发送</button>
    </form>

    <script>
      const statusEl = document.querySelector("#status");
      const logEl = document.querySelector("#log");
      const formEl = document.querySelector("#form");
      const roomEl = document.querySelector("#room");
      const nameEl = document.querySelector("#name");
      const tokenEl = document.querySelector("#token");
      const messageEl = document.querySelector("#message");
      const connectEl = document.querySelector("#connect");
      const disconnectEl = document.querySelector("#disconnect");

      let socket;
      let reconnectTimer;
      let reconnectAttempt = 0;
      let manuallyClosed = false;
      const pendingMessages = [];

      connectEl.addEventListener("click", connect);
      disconnectEl.addEventListener("click", () => {
        manuallyClosed = true;
        clearTimeout(reconnectTimer);
        if (socket) socket.close(1000, "user disconnected");
      });

      formEl.addEventListener("submit", (event) => {
        event.preventDefault();
        const text = messageEl.value.trim();
        if (!text) return;
        const payload = JSON.stringify({ type: "message", text });

        if (socket?.readyState === WebSocket.OPEN && socket.bufferedAmount < 64 * 1024) {
          socket.send(payload);
        } else {
          pendingMessages.push(payload);
          writeLog("连接未就绪,消息已排队。", "muted");
        }

        messageEl.value = "";
      });

      function connect() {
        manuallyClosed = false;
        clearTimeout(reconnectTimer);
        if (socket && socket.readyState === WebSocket.OPEN) return;

        const params = new URLSearchParams({
          token: tokenEl.value,
          name: nameEl.value,
          room: roomEl.value
        });
        const protocol = location.protocol === "https:" ? "wss:" : "ws:";
        socket = new WebSocket(`${protocol}//${location.host}/chat?${params.toString()}`);
        setStatus("连接中");

        socket.addEventListener("open", () => {
          reconnectAttempt = 0;
          setStatus("在线");
          flushPending();
        });

        socket.addEventListener("message", (event) => {
          const data = JSON.parse(event.data);
          renderEvent(data);
        });

        socket.addEventListener("close", (event) => {
          setStatus(`已关闭 ${event.code}`);
          if (!manuallyClosed) scheduleReconnect();
        });

        socket.addEventListener("error", () => {
          writeLog("WebSocket 错误,请查看服务器日志。", "error");
        });
      }

      function scheduleReconnect() {
        const delay = Math.min(1000 * 2 ** reconnectAttempt, 10000) + Math.floor(Math.random() * 250);
        reconnectAttempt += 1;
        setStatus(`${delay}ms 后重连`);
        reconnectTimer = setTimeout(connect, delay);
      }

      function flushPending() {
        while (pendingMessages.length > 0 && socket.readyState === WebSocket.OPEN && socket.bufferedAmount < 64 * 1024) {
          socket.send(pendingMessages.shift());
        }
      }

      function renderEvent(data) {
        if (data.type === "message") {
          writeLog(`${data.message.from}: ${data.message.text}`);
        } else if (data.type === "history") {
          writeLog(`已加载 ${data.messages.length} 条历史消息。`, "muted");
          data.messages.forEach((message) => writeLog(`${message.from}: ${message.text}`));
        } else if (data.type === "error") {
          writeLog(`${data.code}: ${data.message}`, "error");
        } else {
          writeLog(data.message ?? JSON.stringify(data), "muted");
        }
      }

      function setStatus(value) {
        statusEl.textContent = value;
      }

      function writeLog(value, className = "message") {
        const line = document.createElement("p");
        line.className = className;
        line.textContent = value;
        logEl.append(line);
        logEl.scrollTop = logEl.scrollHeight;
      }

      connect();
    </script>
  </body>
</html>

smoke-client.mjs

import { WebSocket } from "ws";

const url = new URL("ws://localhost:8080/chat");
url.searchParams.set("token", process.env.CHAT_TOKEN ?? "dev-token");
url.searchParams.set("name", "smoke");
url.searchParams.set("room", "lobby");

const ws = new WebSocket(url, {
  headers: {
    Origin: "http://localhost:8080"
  }
});

const timeout = setTimeout(() => {
  console.error("smoke test timed out");
  ws.terminate();
  process.exit(1);
}, 5000);

ws.on("open", () => {
  ws.send(JSON.stringify({ type: "message", text: "hello from smoke test" }));
});

ws.on("message", (raw) => {
  const data = JSON.parse(raw.toString("utf8"));
  console.log(data);
  if (data.type === "message" && data.message.text === "hello from smoke test") {
    clearTimeout(timeout);
    ws.close(1000, "done");
  }
});

ws.on("close", (code, reason) => {
  console.log(`closed ${code} ${reason.toString()}`);
  process.exit(code === 1000 ? 0 : 1);
});

ws.on("error", (error) => {
  clearTimeout(timeout);
  console.error(error);
  process.exit(1);
});
npm run smoke

常见失败模式

失败后果对策
upgrade 时不认证未信任连接进入系统在 handleUpgrade 前拒绝
不检查 Origin可能被跨站 WebSocket 劫持用允许列表固定来源
没有限流单个客户端拖垮 CPU 和内存按连接、用户、IP 限制
历史无限保存在内存进程越跑越大限制条数、时间和保存策略
立即重连循环故障恢复时形成流量峰值使用指数退避和随机抖动
只支持单进程扇出多台服务器之间消息丢失加 Redis Pub/Sub、NATS 或队列

生产化注意点与转化入口

涉及登录、支付、客服或个人信息时,必须使用 wss://。权限也不能只在连接时检查,加入房间、发送消息、删除消息都应重新判断。历史记录要从内存迁移到数据库;多进程部署时,扇出要通过共享消息通道完成。

如果你是个人学习者,可以先领取免费 Claude Code 速查表并把这个示例跑通。需要可复用提示词和设置模板时,看 ClaudeCodeLab 产品。团队需要把认证、审计日志、Pub/Sub、CI 和代码审查放进真实仓库时,可以从培训与咨询开始。

实际测试结果:Masa 用 server.js 启动后,在两个浏览器标签打开 http://localhost:8080,同一个 lobby 房间的消息可以即时互相显示。npm run smoke 成功发送并收到 hello from smoke test。修改 Origin 会得到 403,修改 token 会得到 401。把重连、限流、Origin 检查和 50 条历史限制一开始就写进 Claude Code 任务,比后补安全措施更容易审查。

#Claude Code #WebSocket #聊天 #实时通信 #Node.js
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

让 Claude Code 真正进入可验证的工作流

先用免费 PDF 固定基础,再用 Gumroad 教材复用工作流;如果涉及团队导入、权限或收入路径,可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。