Для проверки работы Ваших проектов на наших VDS предлагаем Вам льготный период, оформите заказ на 3 дня.
Подходит для быстрого старта, тестов и простых лендингов/мини-сервисов.
ws-chat/ +-- package.json +-- app.js +-- env # опционально для локального запуска +-- public/ +-- index.html +-- client.js +-- style.css
{ "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 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}`); });
WebSocket сервер висит на том же HTTP-сервере, а прокси (Nginx) в ISPmanager прозрачно проксирует Upgrade/Connection заголовки на приложение. В path: '/ws' вынесли сокеты под отдельный путь.
<!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>
(() => { 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 по умолчанию) })();
: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 обратный прокси Nginx по умолчанию поддерживает WebSocket-upgrade. Если чат не коннектится (статус «offline»), проверьте домен/SSL и обратитесь в поддержку, чтобы подтвердить проксирование заголовков Upgrade/Connection.
Лимиты через переменные окружения:
Разделение на комнаты — добавьте поле room в join/chat и храните мапу room -> Set(ws). Модерация/логирование — пишите сообщения в файл/БД, добавьте фильтр слов. Авторизация — примите токен/куку при join и проверяйте пользователя на сервере.