WebSocket-Chat mit Claude Code bauen
Praxisguide für WebSocket-Chat mit Claude Code: Auth, Reconnects, Rate Limits und lauffähiger Node.js-Code.
Ein WebSocket-Chat wirkt klein, enthält aber viele echte Produktionsfragen: Wie wird die Verbindung authentifiziert? Was passiert bei Verbindungsabbruch? Wie werden Nachrichten nur an die richtige Raumgruppe verteilt? Wie verhindert man Spam? Und wie prüft man, dass spätere Änderungen den Chat nicht still kaputtmachen?
Dieser Leitfaden baut ein kopierbares Beispiel mit der Browser-WebSocket-API und dem Node.js-Paket ws. WebSocket bedeutet: Nach einem HTTP-Upgrade bleibt eine bidirektionale Verbindung offen, über die Browser und Server beide Nachrichten senden können. Nutze offizielle Quellen als Basis: MDN WebSocket API, Writing WebSocket client applications, RFC 6455, Node.js HTTP upgrade, ws README und den OWASP WebSocket Testing Guide. Für Claude Code selbst gilt die offizielle Claude Code Dokumentation.
Als Anschluss passen API-Entwicklung mit Claude Code, Code Review mit Claude Code und Sicherheits-Best-Practices.
Drei konkrete Anwendungsfälle
Erstens: ein kleiner Community- oder Support-Chat. Raum, Name, Text und kurzer Verlauf reichen zum Start. Ohne Authentifizierung und Limit kann aber ein einzelnes Skript den Prozess fluten.
Zweitens: ein Live-Dashboard. Build-Status, Zahlungen, Lagerbestand oder Job-Queues können sofort im Browser erscheinen. Hier ist wichtig, welche alten Ereignisse verworfen werden dürfen, denn die Browser-WebSocket-API hat keine starke eingebaute Backpressure.
Drittens: ein Fragenraum hinter einem Tutorial oder bezahlten Material. Leser können vom Artikel direkt in eine Unterhaltung wechseln. Das hilft bei Conversion, erfordert aber Regeln für Logs, Moderation und personenbezogene Daten.
| Fall | Warum WebSocket passt | Erste Entscheidung |
|---|---|---|
| Kleiner Chat | Schnelle bidirektionale Nachrichten | Auth, Räume, Verlauf |
| Live-Dashboard | Server kann Updates pushen | Verwerfen alter Events |
| Lern-Support | Gespräch folgt auf Artikel | Logs, Moderation, CTA |
Architektur
Fan-out heißt, eine eingehende Nachricht an alle Verbindungen im selben Raum zu verteilen. Heartbeat heißt, regelmäßig zu prüfen, ob eine lange Verbindung noch lebt.
flowchart LR
BrowserA["Browser-Tab A"] -->|ws:// /chat| Node["Node.js server"]
BrowserB["Browser-Tab 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"]
Prompt für Claude Code
Erstelle einen minimalen WebSocket-Chat mit Node.js 20+ und dem Paket ws.
Anforderungen:
- Nur server.js, index.html, smoke-client.mjs und package.json verwenden
- http://localhost:8080 muss die Chat-Oberfläche ausliefern
- Beim /chat WebSocket upgrade token und Origin prüfen
- Nachrichten pro Raum verteilen
- Nur die letzten 50 Nachrichten im Speicher halten
- Pro Verbindung 20 Nachrichten je 10 Sekunden erlauben
- Nachrichtentext auf 500 Zeichen begrenzen
- Browser-Client nach close mit exponentiellem Backoff neu verbinden
- Vor dem Senden readyState und bufferedAmount prüfen
- smoke-client.mjs muss verbinden, senden und erfolgreich beenden
Nicht tun:
- Nicht auf Socket.IO wechseln
- Keine unauthentifizierten Verbindungen akzeptieren
- JSON nicht ungeprüft verwenden
- Speicherverlauf nicht als Produktionspersistenz darstellen
Lokal starten
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="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>WebSocket-Chat-Demo</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-Chat-Demo</h1>
<p id="status" class="muted">offline</p>
<label>Raum <input id="room" value="lobby" /></label>
<label>Name <input id="name" value="masa" /></label>
<label>Token <input id="token" value="dev-token" /></label>
<button id="connect">Verbinden</button>
<button id="disconnect">Trennen</button>
<div id="log" aria-live="polite"></div>
<form id="form">
<label>Nachricht <input id="message" autocomplete="off" maxlength="500" /></label>
<button type="submit">Senden</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("Socket ist nicht bereit; Nachricht wartet.", "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("verbindet");
socket.addEventListener("open", () => {
reconnectAttempt = 0;
setStatus("online");
flushPending();
});
socket.addEventListener("message", (event) => {
const data = JSON.parse(event.data);
renderEvent(data);
});
socket.addEventListener("close", (event) => {
setStatus(`geschlossen ${event.code}`);
if (!manuallyClosed) scheduleReconnect();
});
socket.addEventListener("error", () => {
writeLog("WebSocket-Fehler. Serverlog prüfen.", "error");
});
}
function scheduleReconnect() {
const delay = Math.min(1000 * 2 ** reconnectAttempt, 10000) + Math.floor(Math.random() * 250);
reconnectAttempt += 1;
setStatus(`Reconnect in ${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} frühere Nachrichten geladen.`, "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
Häufige Fehler
| Fehler | Folge | Gegenmaßnahme |
|---|---|---|
| Keine Authentifizierung beim Upgrade | Unsichere Verbindung ist aktiv | Vor handleUpgrade ablehnen |
| Origin nicht geprüft | Risiko durch fremde Seiten | Erlaubte Origins festlegen |
| Kein Rate Limit | Ein Client überlastet CPU und Speicher | Nach Verbindung, Nutzer und IP begrenzen |
| Unbegrenzter Verlauf im Speicher | Prozess wächst dauerhaft | Anzahl, Alter und Speicherziel begrenzen |
| Sofortige Reconnect-Schleife | Lastspitze nach Ausfall | Exponentieller Backoff mit Jitter |
| Fan-out nur in einem Prozess | Andere Server bekommen nichts | Redis Pub/Sub, NATS oder Queue nutzen |
Produktion und Einnahmepfad
Nutze wss://, sobald Konten, Zahlungen, Support oder personenbezogene Daten beteiligt sind. Prüfe Rechte nicht nur beim Verbinden, sondern auch beim Betreten eines Raums, Senden, Löschen und Moderieren. Verlauf gehört in eine Datenbank; mehrere Node-Prozesse brauchen einen gemeinsamen Pub/Sub-Kanal.
Einzelne Entwickler starten mit dem kostenlosen Claude Code Cheatsheet. Wiederverwendbare Prompts und Setup-Material findest du unter ClaudeCodeLab Produkte. Teams, die Auth, Audit-Logs, Pub/Sub, CI und Review-Regeln in einem echten Repository festlegen wollen, sollten Claude Code Training und Beratung nutzen.
Praktisches Ergebnis: Masa hat server.js gestartet, http://localhost:8080 in zwei Tabs geöffnet und Nachrichten im Raum lobby sofort in beiden Tabs gesehen. npm run smoke sendete und empfing hello from smoke test. Ein falscher Origin führte zu 403, ein falscher Token zu 401. Reconnects, Rate Limits, Origin-Prüfung und 50 Nachrichten Verlauf von Anfang an in die Claude-Code-Aufgabe zu schreiben, machte den Diff deutlich leichter prüfbar.
Kostenloses PDF: Claude-Code-Cheatsheet
E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.
Wir schützen Ihre Daten und senden keinen Spam.
Über den Autor
Masa
Engineer für praktische Claude-Code-Workflows und Team-Einführung.
Ähnliche Artikel
Claude Code Workflow von Obsidian zu CLAUDE.md
Obsidian-Arbeitsnotizen in CLAUDE.md-Betriebsnotizen verwandeln und Kontext nicht ständig neu erklären.
Claude Code Revenue CTA Routing: Artikel zu PDF, Gumroad und Beratung führen
Ein Claude-Code-Ablauf, der Leser nach Absicht zu Gratis-PDF, Gumroad oder Beratung führt.
Claude-Code-Team-Handoff-Regeln: Belege, Berechtigungen, Rollback und Umsatzpfade
Ein praktisches Claude-Code-Handoff für Review-Belege, Berechtigungen, Rollback, Gratis-PDF, Gumroad und Beratung.