EN
UAH

Node.js + WebSocket: a real-time mini chat for shared hosting

What the example does

Chat

  • A lightweight WebSocket chat (no database), designed for a small number of concurrent users.
  • Server built with Express + ws, frontend in plain JS (no bundlers).
  • To ensure stability on shared hosting: message rate limiting, payload size limit, heartbeat (ping/pong), and client auto-reconnect.
  • Ready for deployment in ISPmanager (port from process.env.PORT, static files from /public).

Ideal for rapid prototyping, testing, and lightweight landing pages or mini-apps.

Project structure

ws-chat/
+-- package.json
+-- app.js
+-- env # optional for local development
+-- 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 server.


// 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}`);
});

	
	
Node.js
Fast & Reliable Node.js Hosting
Start Your Project with Just a Few Clicks!
Free SSL CertificatPowered by Modern Servers7-Day Free Trial
View Plans

Important notes for shared hosting:

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.

public/index.html — chat page.


  <!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>
  

public/client.js — client logic with auto-reconnect

  
(() => {
  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)

})();
    
  

public/style.css — minimal styles

  
: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)}
    
  

Run locally

  
npm install
npm start
# Open http://localhost:3000
    
  

Deploying to ISPmanager (shared hosting)

  • Add a domain or website in ISPmanager.
  • Upload the project to the site directory.
  • In terminal/SSH, run: npm install.
  • In the Node.js section, specify:
    • Node.js version (LTS)
    • Entry point: app.js (or npm start)
    • Optional: set NODE_ENV=production
    • Domain/subdomain binding
  • Enable HTTPS (Let’s Encrypt) — in production, use wss:// for WebSocket connections
  • Open the site and test the chat in two tabs.

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.

Advanced configuration

Limits via environment variables:

  • MAX_CLIENTS — maximum concurrent connections (default: 100).
  • MAX_MSG_LEN — maximum message length (default: 2000).
  • MAX_MSG_RATE — messages per 5 seconds (default: 5).

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.

FAQ (Frequently Asked Questions)

This demo is intended for a small audience (dozens of concurrent users). For hundreds or thousands, switch to a VDS.
Browsers block ws:// connections on https:// pages. For production, use wss:// and enable Let’s Encrypt on your domain.
Yes, if the page can reach wss://your-domain/ws. However, keep in mind domain restrictions and the CORS policy for APIs (in this case, only WebSocket is used — CORS doesn’t apply to HTTP).
Add a write to a file/DB on each chat, and send the history on connect (e.g., the last N messages).
Contact details: Ukraine, 61202, Kharkiv, Ludviga Svobody st. 26-298.
FO-P Kharitinov Oleg Sergeevich
IBAN UA073052990000026001005905889
tax.number 2961615658
PrivatBank
mail:
Documentation:
Support service: телефон + 380 57 7209279
Create a ticket