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 APIWriting 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,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

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