We offer you a grace period for 3 days to use VDS to check your projects.
Ideal for rapid prototyping, testing, and lightweight landing pages or mini-apps.
ws-chat/ +-- package.json +-- app.js +-- env # optional for local development +-- 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'; // Security and logs app.use(helmet()); app.use(morgan(isProd ? 'combined' : 'dev')); // Serve static files app.use(express.static(path.join(__dirname, 'public'))); // Health app.get('/health', (req, res) => res.json({ status: 'ok', uptime: process.uptime() })); // HTTP server (used to attach the WebSocket server) const server = http.createServer(app); // ---- WebSocket ---- const wss = new WebSocketServer({ server, path: '/ws' }); // Basic config for shared hosting. const MAX_CLIENTS = Number(process.env.MAX_CLIENTS || 100); // Connection limit const MAX_MSG_LEN = Number(process.env.MAX_MSG_LEN || 2000); // Max text size const MAX_MSG_RATE = Number(process.env.MAX_MSG_RATE || 5); // Messages per 5 seconds const ROOM = 'main'; // One chat room for demo purposes. // Connection storage and counters. 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); // Time window: 5 seconds 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); // Client connection 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); // On connect, send a welcome message and current online count. 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; // Simple rate limit. let payload = null; try { payload = JSON.parse(String(raw)); } catch { return; } if (payload.type === 'join') { // Set username. 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}`); });
The WebSocket server runs on the same HTTP server, and the Nginx proxy in ISPmanager transparently forwards the Upgrade and Connection headers to the application. WebSockets are served under a separate path: /ws.
<!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <title>Mini chat using 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>Chat</h1> <div class="status"><span id="state">offline</span> · <span id="online">0</span> online</div> </header> <section id="join" class="join"> <label>Your name <input id="name" type="text" placeholder="Guest" maxlength="32" /> </label> <button id="joinBtn">Join the chat</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="Message..." maxlength="2000" /> <button type="submit">Send</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 (exponential backoff up to 5s) 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, '', `You joined as ${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 || 'Error'); return; } }; ws.onclose = () => { setState('offline'); if (!wantOpen) return; // авто-reconnect setTimeout(connect, retryDelay); retryDelay = Math.min(retryDelay * 2, 5000); }; ws.onerror = () => { // Don’t swallow the error — onclose will handle reconnection }; } joinBtn.addEventListener('click', () => { myName = (nameInput.value || 'Guest').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 = ''; }); // Auto-connect on page load: user enters a name and clicks "Join" // (Optional: enable auto-join by default) })();
: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 # Open http://localhost:3000
In ISPmanager, the Nginx reverse proxy supports WebSocket upgrade by default. If the chat fails to connect (status “offline”), check your domain and SSL settings, and contact support to confirm that the Upgrade and Connection headers are being properly proxied.
Limits via environment variables:
Room separation — add a room field to join/chat and store a map of room -> Set(ws). Moderation/logging — write messages to a file or database, and add a word filter. Authorization — accept a token or cookie on join and verify the user on the server.