用 Claude Code 构建 WebSocket 聊天应用
用 Claude Code 构建安全 WebSocket 聊天:认证、重连、限流、广播和可运行 Node.js 示例。
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 任务,比后补安全措施更容易审查。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。