Créer un chat WebSocket avec Claude Code
Créez un chat WebSocket avec Claude Code : authentification, reconnexion, limitation et code Node.js exécutable.
Un chat WebSocket est un bon test pour Claude Code : l’interface est simple, mais les vrais sujets arrivent vite. Il faut authentifier la connexion, reconnecter le client sans provoquer une tempête de trafic, diffuser les messages dans la bonne salle, limiter le spam, valider les données reçues et garder une preuve que l’exemple fonctionne encore.
Ce guide construit un exemple copiable avec l’API WebSocket du navigateur et le paquet Node.js ws. WebSocket est une connexion persistante et bidirectionnelle : après l’upgrade HTTP, le navigateur et le serveur peuvent tous les deux envoyer des messages. Vérifiez les sources officielles : MDN WebSocket API, Writing WebSocket client applications, RFC 6455, événement upgrade de Node.js HTTP, README de ws et guide OWASP de test WebSocket. Pour l’outil, gardez aussi la documentation officielle de Claude Code.
Pour continuer dans ClaudeCodeLab, lisez aussi développement d’API avec Claude Code, revue de code avec Claude Code et bonnes pratiques de sécurité.
Trois usages concrets
Premier usage : un petit chat de communauté ou de support. Le modèle contient une salle, un nom, un texte et quelques messages récents. Sans authentification ni limite d’envoi, un seul script peut saturer le serveur.
Deuxième usage : un tableau de bord en temps réel. Déploiements, paiements, stock ou files de traitement peuvent être poussés vers le navigateur. Ici, il faut décider quels anciens événements peuvent être ignorés, car l’API WebSocket du navigateur ne fournit pas de contre-pression automatique forte.
Troisième usage : une salle de questions après un article ou une formation payante. Le lecteur peut passer naturellement du contenu à la discussion, puis vers un modèle, un produit ou une consultation. C’est utile pour la conversion, mais les règles de logs, de modération et de données personnelles doivent être écrites avant le lancement.
| Usage | Pourquoi WebSocket convient | Décision initiale |
|---|---|---|
| Petit chat | Messages bidirectionnels rapides | Authentification, salles, historique |
| Tableau en direct | Le serveur pousse les événements | Abandon des anciens événements |
| Support pédagogique | La discussion suit l’article | Logs, modération, appel à l’action |
Architecture
La diffusion signifie envoyer un message reçu à tous les clients de la même salle. Le battement de cœur sert à vérifier qu’une connexion longue est encore vivante.
flowchart LR
BrowserA["Onglet A"] -->|ws:// /chat| Node["Node.js server"]
BrowserB["Onglet 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 pour Claude Code
Crée un chat WebSocket minimal avec Node.js 20+ et le paquet ws.
Exigences:
- Utiliser seulement server.js, index.html, smoke-client.mjs et package.json
- Servir l’interface sur http://localhost:8080
- Valider token et Origin pendant l’upgrade WebSocket de /chat
- Diffuser les messages par salle
- Garder seulement les 50 derniers messages en mémoire
- Limiter chaque connexion à 20 messages par 10 secondes
- Limiter le texte à 500 caractères
- Reconnecter le client avec backoff exponentiel après close
- Vérifier readyState et bufferedAmount avant l’envoi
- smoke-client.mjs doit connecter, envoyer un message et réussir
À éviter:
- Remplacer par Socket.IO
- Accepter une connexion non authentifiée
- Utiliser du JSON sans validation
- Présenter l’historique mémoire comme une persistance de production
Exécution locale
mkdir ws-chat-demo
cd ws-chat-demo
npm init -y
npm install ws
npm run start
Remplacez 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="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Démo de chat 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>Démo de chat WebSocket</h1>
<p id="status" class="muted">hors ligne</p>
<label>Salle <input id="room" value="lobby" /></label>
<label>Nom <input id="name" value="masa" /></label>
<label>Jeton <input id="token" value="dev-token" /></label>
<button id="connect">Connecter</button>
<button id="disconnect">Déconnecter</button>
<div id="log" aria-live="polite"></div>
<form id="form">
<label>Message <input id="message" autocomplete="off" maxlength="500" /></label>
<button type="submit">Envoyer</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("La connexion n’est pas prête ; le message est en attente.", "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("connexion");
socket.addEventListener("open", () => {
reconnectAttempt = 0;
setStatus("en ligne");
flushPending();
});
socket.addEventListener("message", (event) => {
const data = JSON.parse(event.data);
renderEvent(data);
});
socket.addEventListener("close", (event) => {
setStatus(`fermé ${event.code}`);
if (!manuallyClosed) scheduleReconnect();
});
socket.addEventListener("error", () => {
writeLog("Erreur WebSocket. Vérifiez le journal du serveur.", "error");
});
}
function scheduleReconnect() {
const delay = Math.min(1000 * 2 ** reconnectAttempt, 10000) + Math.floor(Math.random() * 250);
reconnectAttempt += 1;
setStatus(`reconnexion dans ${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} anciens messages chargés.`, "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
Pièges à vérifier
| Piège | Effet | Correction |
|---|---|---|
| Pas d’authentification à l’upgrade | Une connexion non fiable devient active | Refuser avant handleUpgrade |
| Pas de contrôle Origin | Risque de détournement WebSocket intersite | Liste blanche d’origines |
| Pas de limitation | Un client peut saturer CPU et mémoire | Limiter par connexion, utilisateur et IP |
| Historique illimité en mémoire | Le processus grossit sans fin | Limiter nombre, durée et stockage |
| Reconnexion immédiate | Pic de trafic pendant la reprise | Backoff exponentiel et variation |
| Diffusion dans un seul processus | Les autres serveurs ne reçoivent rien | Redis Pub/Sub, NATS ou file |
Production et appel à l’action
Utilisez wss:// dès qu’il y a compte, paiement, support ou donnée personnelle. Vérifiez les droits à la connexion, mais aussi à l’entrée dans une salle, à l’envoi, à la suppression et à la modération. L’historique doit aller en base de données, et la diffusion multi-processus doit passer par Redis Pub/Sub, NATS ou un broker.
Si vous apprenez seul, commencez avec la fiche gratuite Claude Code. Pour des modèles et prompts réutilisables, consultez les produits ClaudeCodeLab. Pour une équipe qui veut cadrer authentification, journaux d’audit, Pub/Sub, CI et revue dans un vrai dépôt, la suite logique est la formation et consultation Claude Code.
Résultat vérifié : Masa a lancé server.js, ouvert http://localhost:8080 dans deux onglets, puis envoyé des messages dans lobby. Les deux onglets les ont reçus immédiatement. npm run smoke a envoyé et reçu hello from smoke test. Un Origin modifié renvoie 403, un token modifié renvoie 401. Demander dès le départ reconnexion, limite d’envoi, contrôle Origin et historique de 50 messages rend le diff beaucoup plus facile à relire.
PDF gratuit: cheatsheet Claude Code
Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.
Nous protégeons vos données et n'envoyons pas de spam.
À propos de l'auteur
Masa
Ingénieur spécialisé dans les workflows pratiques avec Claude Code.
Articles liés
Workflow Obsidian vers CLAUDE.md avec Claude Code
Transformer des notes Obsidian en notes CLAUDE.md concises pour reprendre les sessions sans réexpliquer.
Claude Code Revenue CTA Routing : relier articles, PDF, Gumroad et consultation
Un workflow Claude Code pour orienter les lecteurs vers PDF gratuit, Gumroad ou consultation selon l'intention.
Règles de handoff Claude Code en équipe: preuves, permissions, rollback et revenus
Un format concret pour transmettre un travail Claude Code avec preuves, permissions, rollback, PDF gratuit, Gumroad et consultation.