⚠️ Maintenance. Notice: the site is currently in maintenance mode, so the amount of available material is temporarily limited.
Home / Tutorials / Real-time chatReal-time чат

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.

protocol.md
// 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, а клиент легко расширять.

protocol.md
// клиент → сервер
{ "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.

chat-server.js
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 the try/catch around JSON.parse. Never trust a client frame — validate length and shape before you broadcast it to everyone.

Комнаты — это просто Map из имени комнаты в Set сокетов. Рассылка — цикл по этому множеству.

chat-server.js
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.

chat-client.js
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 = "";
};

Клиент отрисовывает по типу и экранирует текст перед вставкой — самая важная строка для чата, ведь сообщения приходят от незнакомцев.

chat-client.js
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 join on every open.
  • Add heartbeats so ghosts are evicted from rooms.
  • Authenticate the connection with the ticket pattern and set ws.name from the verified identity, not from client input.
  • To run more than one node, replace the in-memory broadcast with a pub/sub backplane.

Скелет работает на одной машине. Чтобы выпустить его, наслоите паттерны из остальной серии:

  • Оберните клиент в переподключающийся сокет и пересылайте join при каждом open.
  • Добавьте heartbeat, чтобы «призраки» вычищались из комнат.
  • Аутентифицируйте соединение паттерном «тикет» и берите ws.name из проверенной личности, а не из ввода клиента.
  • Для работы более чем на одном узле замените in-memory broadcast на шину pub/sub.