UA
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
створити тікет

Виберіть мову

Українська
English
Русский