Building a real-time chat with WebSocketСоздаём real-time чат на WebSocket
What we'll buildЧто мы построим
A minimal but honest chat: a Node.js server that tracks rooms and broadcasts messages, and a browser client that joins a room, renders messages live, shows who's typing and survives a dropped connection. We'll define a tiny JSON message protocol and grow it.
Минимальный, но честный чат: сервер на Node.js, который ведёт комнаты и рассылает сообщения, и браузерный клиент, который входит в комнату, отображает сообщения вживую, показывает «печатает…» и переживает обрыв связи. Определим крошечный JSON-протокол сообщений и нарастим его.
A message protocolПротокол сообщений
Every frame is a JSON object with a type discriminator. Keeping types explicit makes the server a clean switch statement and the client easy to extend.
// client → server { "type": "join", "room": "general", "name": "Ada" } { "type": "msg", "text": "hello!" } { "type": "typing" } // server → client { "type": "msg", "name": "Ada", "text": "hello!", "ts": 1716000000 } { "type": "system", "text": "Ada joined" } { "type": "typing", "name": "Ada" }
Каждый кадр — JSON-объект с дискриминатором type. Явные типы превращают сервер в аккуратный switch, а клиент легко расширять.
// клиент → сервер { "type": "join", "room": "general", "name": "Ada" } { "type": "msg", "text": "hello!" } { "type": "typing" } // сервер → клиент { "type": "msg", "name": "Ada", "text": "hello!", "ts": 1716000000 } { "type": "system", "text": "Ada joined" } { "type": "typing", "name": "Ada" }
The serverСервер
Rooms are just a Map of room name to a Set of sockets. Broadcasting is a loop over that set.
import { WebSocketServer } from "ws"; const wss = new WebSocketServer({ port: 8080 }); const rooms = new Map(); // room → Set<ws> function broadcast(room, payload, except) { const data = JSON.stringify(payload); for (const peer of rooms.get(room) ?? []) { if (peer !== except && peer.readyState === 1) peer.send(data); } } wss.on("connection", (ws) => { ws.on("message", (raw) => { let m; try { m = JSON.parse(raw); } catch { return; } switch (m.type) { case "join": { ws.room = m.room; ws.name = (m.name ?? "anon").slice(0, 32); if (!rooms.has(ws.room)) rooms.set(ws.room, new Set()); rooms.get(ws.room).add(ws); broadcast(ws.room, { type: "system", text: `${ws.name} joined` }); break; } case "msg": if (ws.room && m.text) broadcast(ws.room, { type: "msg", name: ws.name, text: String(m.text).slice(0, 2000), ts: Date.now() }); break; case "typing": broadcast(ws.room, { type: "typing", name: ws.name }, ws); break; } }); ws.on("close", () => { rooms.get(ws.room)?.delete(ws); if (ws.room) broadcast(ws.room, { type: "system", text: `${ws.name} left` }); }); });
Notice the.slice()caps and thetry/catcharoundJSON.parse. Never trust a client frame — validate length and shape before you broadcast it to everyone.
Комнаты — это просто Map из имени комнаты в Set сокетов. Рассылка — цикл по этому множеству.
import { WebSocketServer } from "ws"; const wss = new WebSocketServer({ port: 8080 }); const rooms = new Map(); // комната → Set<ws> function broadcast(room, payload, except) { const data = JSON.stringify(payload); for (const peer of rooms.get(room) ?? []) { if (peer !== except && peer.readyState === 1) peer.send(data); } } wss.on("connection", (ws) => { ws.on("message", (raw) => { let m; try { m = JSON.parse(raw); } catch { return; } switch (m.type) { case "join": { ws.room = m.room; ws.name = (m.name ?? "anon").slice(0, 32); if (!rooms.has(ws.room)) rooms.set(ws.room, new Set()); rooms.get(ws.room).add(ws); broadcast(ws.room, { type: "system", text: `${ws.name} joined` }); break; } case "msg": if (ws.room && m.text) broadcast(ws.room, { type: "msg", name: ws.name, text: String(m.text).slice(0, 2000), ts: Date.now() }); break; case "typing": broadcast(ws.room, { type: "typing", name: ws.name }, ws); break; } }); ws.on("close", () => { rooms.get(ws.room)?.delete(ws); if (ws.room) broadcast(ws.room, { type: "system", text: `${ws.name} left` }); }); });
Обратите внимание на ограничения.slice()иtry/catchвокругJSON.parse. Никогда не доверяйте кадру клиента — проверяйте длину и форму, прежде чем рассылать его всем.
The clientКлиент
The client renders by type and escapes text before inserting it — the single most important line for a chat, since messages come from strangers.
const ws = new WebSocket("wss://rt.example.com/chat"); ws.onopen = () => ws.send(JSON.stringify( { type: "join", room: "general", name: myName })); ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.type === "msg") addLine(`${m.name}: ${m.text}`); if (m.type === "system") addLine(m.text, "sys"); if (m.type === "typing") showTyping(m.name); }; function addLine(text, cls = "") { const li = document.createElement("li"); li.textContent = text; // textContent escapes — never innerHTML here li.className = cls; log.append(li); log.scrollTop = log.scrollHeight; } sendBtn.onclick = () => { ws.send(JSON.stringify({ type: "msg", text: input.value })); input.value = ""; };
Клиент отрисовывает по типу и экранирует текст перед вставкой — самая важная строка для чата, ведь сообщения приходят от незнакомцев.
const ws = new WebSocket("wss://rt.example.com/chat"); ws.onopen = () => ws.send(JSON.stringify( { type: "join", room: "general", name: myName })); ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.type === "msg") addLine(`${m.name}: ${m.text}`); if (m.type === "system") addLine(m.text, "sys"); if (m.type === "typing") showTyping(m.name); }; function addLine(text, cls = "") { const li = document.createElement("li"); li.textContent = text; // textContent экранирует — никогда не innerHTML тут li.className = cls; log.append(li); log.scrollTop = log.scrollHeight; }; sendBtn.onclick = () => { ws.send(JSON.stringify({ type: "msg", text: input.value })); input.value = ""; };
Making it production-readyДоводим до продакшена
The skeleton works on one machine. To ship it, layer on the patterns from the rest of the series:
- Wrap the client in the reconnecting socket and re-send
joinon everyopen. - Add heartbeats so ghosts are evicted from rooms.
- Authenticate the connection with the ticket pattern and set
ws.namefrom the verified identity, not from client input. - To run more than one node, replace the in-memory
broadcastwith a pub/sub backplane.
Скелет работает на одной машине. Чтобы выпустить его, наслоите паттерны из остальной серии:
- Оберните клиент в переподключающийся сокет и пересылайте
joinпри каждомopen. - Добавьте heartbeat, чтобы «призраки» вычищались из комнат.
- Аутентифицируйте соединение паттерном «тикет» и берите
ws.nameиз проверенной личности, а не из ввода клиента. - Для работы более чем на одном узле замените in-memory
broadcastна шину pub/sub.