RU
UAH

Node.js + WebSocket: мини-чат в реальном времени для shared-хостинга

Что делает пример

Chat

  • Лёгкий WebSocket-чат (без БД), рассчитан на небольшое число одновременных пользователей.
  • Сервер на Express + ws, фронтенд — чистый JS (без сборщиков).
  • Ограничения ради стабильности на шареде: лимит сообщений/сек, лимит размера, heartbeat (ping/pong), авто-переподключение клиента.
  • Готов к деплою в ISPmanager (порт из process.env.PORT, статика из /public).

Подходит для быстрого старта, тестов и простых лендингов/мини-сервисов.

Структура проекта

ws-chat/
+-- package.json
+-- app.js
+-- env # опционально для локального запуска
+-- public/
    +-- index.html
    +-- client.js
    +-- style.css

package.json


{
  "name": "ws-chat",
  "version": "1.0.0",
  "description": "Minimal WebSocket chat for shared hosting (ISPmanager).",
  "main": "app.js",
  "scripts": {
    "start": "node app.js",
    "start:prod": "NODE_ENV=production node app.js"
  },
  "engines": {
    "node": ">=18.x"
  },
  "dependencies": {
    "express": "^4.19.2",
    "helmet": "^7.1.0",
    "morgan": "^1.10.0",
    "ws": "^8.18.0"
  }
}
	
	

app.js — HTTP + WebSocket сервер


// app.js
require('dotenv').config();
const http = require('http');
const path = require('path');
const express = require('express');
const helmet = require('helmet');
const morgan = require('morgan');
const { WebSocketServer } = require('ws');

const app = express();
const PORT = process.env.PORT || 3000;
const isProd = process.env.NODE_ENV === 'production';

// Безопасность и логи
app.use(helmet());
app.use(morgan(isProd ? 'combined' : 'dev'));

// Статика
app.use(express.static(path.join(__dirname, 'public')));

// Health
app.get('/health', (req, res) => res.json({ status: 'ok', uptime: process.uptime() }));

// HTTP-сервер (нужен, чтобы повесить на него WebSocket)
const server = http.createServer(app);

// ---- WebSocket ----
const wss = new WebSocketServer({ server, path: '/ws' });

// Простые настройки для shared
const MAX_CLIENTS = Number(process.env.MAX_CLIENTS || 100);   // лимит подключений
const MAX_MSG_LEN = Number(process.env.MAX_MSG_LEN || 2000);  // макс. размер текста
const MAX_MSG_RATE = Number(process.env.MAX_MSG_RATE || 5);   // сообщений в 5 сек.
const ROOM = 'main'; // одна «комната» для примера

// Хранилище подключений и счетчики
const clients = new Map(); // ws -> { id, name, last:[], alive:true }

function broadcast(data, exceptWs = null) {
  const msg = JSON.stringify(data);
  for (const [ws, meta] of clients) {
    if (ws.readyState === ws.OPEN && ws !== exceptWs) {
      ws.send(msg);
    }
  }
}

function sanitize(str = '') {
  return String(str).slice(0, MAX_MSG_LEN);
}

function rateOk(meta) {
  const now = Date.now();
  meta.last = meta.last.filter(t => now - t < 5000); // окно 5 сек
  if (meta.last.length >= MAX_MSG_RATE) return false;
  meta.last.push(now);
  return true;
}

// Heartbeat (ping/pong)
function heartbeat() {
  for (const [ws, meta] of clients) {
    if (!meta.alive) {
      try { ws.terminate(); } catch {}
      clients.delete(ws);
      broadcast({ type: 'presence', count: clients.size });
      continue;
    }
    meta.alive = false;
    try { ws.ping(); } catch {}
  }
}
setInterval(heartbeat, 15000);

// Подключение клиента
wss.on('connection', (ws, req) => {
  if (clients.size >= MAX_CLIENTS) {
    ws.close(1013, 'Server is busy'); // Try again later
    return;
  }

  const id = Math.random().toString(36).slice(2, 10);
  const meta = { id, name: null, last: [], alive: true, room: ROOM };
  clients.set(ws, meta);

  // Сразу отправим приветствие и текущее число online
  ws.send(JSON.stringify({ type: 'hello', id, room: ROOM }));
  broadcast({ type: 'presence', count: clients.size });

  ws.on('pong', () => { meta.alive = true; });

  ws.on('message', (raw) => {
    // Ожидаем JSON: {type:"join"|"chat", name?, text?}
    if (!raw || raw.length > MAX_MSG_LEN * 2) return; // простая отсечка
    let payload = null;
    try { payload = JSON.parse(String(raw)); }
    catch { return; }

    if (payload.type === 'join') {
      // Устанавливаем имя
      meta.name = sanitize(payload.name || 'Guest');
      ws.send(JSON.stringify({ type: 'joined', name: meta.name }));
      return;
    }

    if (payload.type === 'chat') {
      if (!rateOk(meta)) {
        ws.send(JSON.stringify({ type: 'error', message: 'Too many messages, slow down.' }));
        return;
      }
      const text = sanitize(payload.text || '').trim();
      if (!text) return;
      broadcast({
        type: 'chat',
        id: meta.id,
        name: meta.name || 'Guest',
        text,
        ts: Date.now()
      });
      return;
    }
  });

  ws.on('close', () => {
    clients.delete(ws);
    broadcast({ type: 'presence', count: clients.size });
  });
});

server.listen(PORT, () => {
  console.log(`WS chat listening on port ${PORT}`);
});

	
	
Node.js
Node.js Хостинг
Запусти проект за пару кликов!
Бесплатный SSLСовременные серверы7 дней теста бесплатно
Перейти к тарифам

Что важно для шареда:

WebSocket сервер висит на том же HTTP-сервере, а прокси (Nginx) в ISPmanager прозрачно проксирует Upgrade/Connection заголовки на приложение.
В path: '/ws' вынесли сокеты под отдельный путь.

public/index.html — страница чата


  <!doctype html>
<html lang="ru">
<head>
  <meta charset="utf-8" />
  <title>Мини-чат на WebSocket</title>
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <link href="/style.css" rel="stylesheet" />
</head>
<body>
  <main class="card">
    <header class="bar">
      <h1>Чат</h1>
      <div class="status"><span id="state">offline</span> · <span id="online">0</span> online</div>
    </header>

    <section id="join" class="join">
      <label>Ваше имя
        <input id="name" type="text" placeholder="Гость" maxlength="32" />
      </label>
      <button id="joinBtn">Войти в чат</button>
    </section>

    <section id="chat" class="chat hidden">
      <div id="log" class="log"></div>
      <form id="form" class="form" autocomplete="off">
        <input id="text" type="text" placeholder="Сообщение..." maxlength="2000" />
        <button type="submit">Отправить</button>
      </form>
    </section>
  </main>

  <script src="/client.js"></script>
</body>
</html>
  

public/client.js — логика клиента - автопереподключение

  
(() => {
  const stateEl = document.getElementById('state');
  const onlineEl = document.getElementById('online');
  const logEl = document.getElementById('log');
  const joinBox = document.getElementById('join');
  const chatBox = document.getElementById('chat');
  const joinBtn = document.getElementById('joinBtn');
  const nameInput = document.getElementById('name');
  const form = document.getElementById('form');
  const textInput = document.getElementById('text');

  let ws = null;
  let myId = null;
  let myName = null;
  let wantOpen = false;
  let retryDelay = 500; // ms (экспоненциально до 5с)

  function log(sys, who, text) {
    const div = document.createElement('div');
    div.className = sys ? 'msg sys' : 'msg';
    div.innerHTML = sys
      ? `${text}`
      : `${who || 'Guest'}: ${escapeHtml(text)}`;
    logEl.appendChild(div);
    logEl.scrollTop = logEl.scrollHeight;
  }

  function escapeHtml(s='') {
    return String(s)
      .replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
  }

  function setState(s) {
    stateEl.textContent = s;
  }

  function connect() {
    wantOpen = true;
    const proto = (location.protocol === 'https:') ? 'wss' : 'ws';
    const url = `${proto}://${location.host}/ws`;
    setState('connecting…');
    ws = new WebSocket(url);

    ws.onopen = () => {
      setState('online');
      retryDelay = 500;
      if (myName) {
        ws.send(JSON.stringify({ type: 'join', name: myName }));
      }
    };

    ws.onmessage = (ev) => {
      let data;
      try { data = JSON.parse(ev.data); } catch { return; }

      if (data.type === 'hello') {
        myId = data.id;
        return;
      }
      if (data.type === 'joined') {
        log(true, '', `Вы вошли как ${data.name}`);
        joinBox.classList.add('hidden');
        chatBox.classList.remove('hidden');
        return;
      }
      if (data.type === 'presence') {
        onlineEl.textContent = String(data.count);
        return;
      }
      if (data.type === 'chat') {
        const when = new Date(data.ts).toLocaleTimeString();
        log(false, `${data.name} (${when})`, data.text);
        return;
      }
      if (data.type === 'error') {
        log(true, '', data.message || 'Ошибка');
        return;
      }
    };

    ws.onclose = () => {
      setState('offline');
      if (!wantOpen) return;
      // авто-reconnect
      setTimeout(connect, retryDelay);
      retryDelay = Math.min(retryDelay * 2, 5000);
    };

    ws.onerror = () => {
      // Ошибку глотать не будем, onclose сделает переподключение
    };
  }

  joinBtn.addEventListener('click', () => {
    myName = (nameInput.value || 'Гость').slice(0, 32);
    if (!ws || ws.readyState !== WebSocket.OPEN) {
      connect();
      setTimeout(() => {
        ws && ws.readyState === WebSocket.OPEN && ws.send(JSON.stringify({ type: 'join', name: myName }));
      }, 200);
    } else {
      ws.send(JSON.stringify({ type: 'join', name: myName }));
    }
  });

  form.addEventListener('submit', (e) => {
    e.preventDefault();
    const txt = textInput.value.trim();
    if (!txt || !ws || ws.readyState !== WebSocket.OPEN) return;
    ws.send(JSON.stringify({ type: 'chat', text: txt }));
    textInput.value = '';
  });

  // Авто-подключение при загрузке, пользователь вводит имя и жмёт "Войти"
  // (Можно включить авто-join по умолчанию)
})();
    
  

public/style.css — мини-стили

  
:root{
  --bg:#0f172a; --card:#111827; --text:#e2e8f0; --muted:#94a3b8;
  --primary:#22c55e; --primary-hover:#16a34a;
}
*{box-sizing:border-box}
html,body{margin:0;font:16px/1.6 system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;background:var(--bg);color:var(--text)}
.card{max-width:800px;margin:6vh auto;background:var(--card);border-radius:16px;padding:20px;box-shadow:0 10px 30px rgba(0,0,0,.25)}
.bar{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
.status{color:var(--muted)}
.join{display:grid;gap:10px}
.hidden{display:none}
.chat{display:grid;gap:10px}
.log{height:45vh;overflow:auto;background:#0b1220;border:1px solid #1f2937;border-radius:10px;padding:10px}
.msg{margin:6px 0}
.msg.sys{color:var(--muted);font-style:italic}
.form{display:flex;gap:10px}
input[type="text"]{flex:1;padding:10px 12px;border:1px solid #1f2937;border-radius:10px;background:#0b1220;color:var(--text)}
button{padding:10px 14px;border:0;border-radius:10px;background:var(--primary);color:#052e16;font-weight:700;cursor:pointer}
button:hover{background:var(--primary-hover)}
    
  

Локальный запуск

  
npm install
npm start
# Откройте http://localhost:3000
    
  

Деплой в ISPmanager (shared)

  • Создайте домен/сайт в ISPmanager.
  • Загрузите проект в каталог сайта.
  • В терминале/SSH выполните: npm install.
  • В разделе Node.js укажите:
    • Версию Node.js (LTS)
    • Старт: app.js (или команда npm start)
    • (Опционально) NODE_ENV=production
    • Привязку к домену/поддомену
  • Включите HTTPS (Let’s Encrypt) — WebSocket на проде лучше вести по wss://
  • Откройте сайт и протестируйте чат в двух вкладках.

В ISPmanager обратный прокси Nginx по умолчанию поддерживает WebSocket-upgrade. Если чат не коннектится (статус «offline»), проверьте домен/SSL и обратитесь в поддержку, чтобы подтвердить проксирование заголовков Upgrade/Connection.

Тонкая настройка

Лимиты через переменные окружения:

  • MAX_CLIENTS — максимум одновременных подключений (по умолчанию 100).
  • MAX_MSG_LEN — максимальная длина сообщения (по умолчанию 2000).
  • MAX_MSG_RATE — сообщений за 5 секунд (по умолчанию 5).

Разделение на комнаты — добавьте поле room в join/chat и храните мапу room -> Set(ws).
Модерация/логирование — пишите сообщения в файл/БД, добавьте фильтр слов.
Авторизация — примите токен/куку при join и проверяйте пользователя на сервере.

Частые вопросы

Это демо для малой аудитории (десятки одновременных пользователей). Для сотен/тысяч — переходите на VDS.
Браузеры блокируют ws:// на страницах https://. Для продакшена используйте wss:// включите Let’s Encrypt на домене.
Да, если страница может достучаться до wss://ваш-домен/ws. Но учитывайте ограничения домена и политику CORS для API (у нас только WS, CORS к HTTP не относится).
Добавьте запись в файл/БД при каждом chat и выдавайте историю при подключении (например, последние N сообщений).
Реквизиты: Украина, 61202, Харьков, пр. Людвига Свободы 26/298.
ФО-П Харитинов Олег Сергеевич
IBAN: UA073052990000026001005905889
МФО 305299
ИНН 2961615658
ПАТ КБ "ПриватБанк"
mail:
Документы:
Служба поддержки: телефон + 380 57 7209279
создать тикет

Выберите язык