Node WebSocket chat

A room chat sends messages and presence updates instantly across general, music, and games, then reconnects open tabs after a deployment. Tokay runs the Express and WebSocket server together as one Web Service.

Last updated

Use it when your app needs long lived connections and shared process state instead of isolated HTTP requests. Recent messages deliberately stay in memory, which makes a restart easy to observe.

Runtime
Node.js
Framework
Express and ws
Service types
Web Service
Resources
None
Required secrets
None

What it does

Enter your name, choose a room, and send a message from the composer. Everyone currently in that room sees it with your name and time.

Here now tracks connected people and the longest open connection. Join and leave notices appear in the conversation as tabs move between rooms or close.

The header shows Connected while the socket is healthy and Reconnecting while a new server version starts.

Deploy it

Bring this folder to Tokay any way you like. Paste or upload it in the dashboard, push it with git, or hand it to your AI agent along with our agent instructions. Tokay figures out the rest.

Working in Claude, ChatGPT, VS Code, or another MCP client? Connect the Tokay MCP server and ask your agent to deploy this example. It signs in through your browser, so there is no token to paste.

New to Tokay? The getting started guide walks you through your first deploy.

Try it

  1. Open two tabs with different names and join #music in both.
  2. Send a message from one tab, then close the other and watch Here now update.
  3. Leave the first tab open while you deploy again. It shows Reconnecting and returns to #music when the new process is ready.

Deploy while connected

Change some page copy or deploy the same app again. Each open tab shows Reconnecting and returns to the same room when the new version is ready.

Recent messages disappear because this example keeps only session memory. Add Redis or a database when messages need to survive a deployment.

How it works

You only publish one address because the static page and /ws upgrade share the same HTTP server. The process keeps the latest 50 messages per room and sends a heartbeat every 30 seconds to remove dead sockets.

Incoming names and messages are bounded and rendered as text, not HTML. Add Redis or a database when the room history needs to outlive the process.

Secrets

This public chat needs no secret. Anyone with the live URL can join one of its rooms.

Grow from here

Try the receipt box when local data should survive deployments and restarts.

public/index.html Raw

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Long Room chat</title>
  <style>
    :root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,sans-serif;background:#17201d;color:#edf1e9}*{box-sizing:border-box}body{margin:0}header{height:70px;padding:0 24px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #ffffff20}header strong{font:500 1.4rem Georgia,serif}.status{color:#aebbb3}.online{color:#8bd39f}.layout{height:calc(100vh - 70px);display:grid;grid-template-columns:220px 1fr 260px}.sidebar,.people{padding:24px;border-right:1px solid #ffffff18}.people{border-right:0;border-left:1px solid #ffffff18}.sidebar label{display:block;margin:18px 0 8px;color:#aebbb3;font-size:.75rem;text-transform:uppercase;letter-spacing:.1em}.sidebar input{width:100%;padding:10px;border:1px solid #ffffff25;border-radius:8px;background:#202d28;color:white}.rooms{display:grid;gap:6px;margin-top:24px}.rooms button{padding:11px;border:0;border-radius:8px;background:transparent;color:#c6d0c9;text-align:left;font:inherit;cursor:pointer}.rooms button.active{background:#31594d;color:white}.chat{display:grid;grid-template-rows:auto 1fr auto;min-width:0}.chat-head{padding:24px;border-bottom:1px solid #ffffff18}.chat-head h1{margin:0;font:500 2.2rem Georgia,serif}.connection{margin-top:5px;color:#9faea5;font-size:.8rem}.messages{padding:24px;overflow:auto}.message{max-width:720px;margin:0 0 18px}.message strong{color:#9bd6aa}.message p{margin:4px 0;line-height:1.5}.message time{color:#829087;font-size:.7rem}.notice{margin:12px 0;color:#829087;font-size:.82rem}.compose{padding:18px 24px;border-top:1px solid #ffffff18;display:flex;gap:10px}.compose input{flex:1;padding:13px;border:1px solid #ffffff25;border-radius:9px;background:#202d28;color:white;font:inherit}.compose button{padding:0 20px;border:0;border-radius:9px;background:#ca7452;color:white;font-weight:800}.people h2{font:500 1.4rem Georgia,serif}.longest{padding:12px;border-radius:9px;background:#22302a;color:#b8c4bc;font-size:.8rem}.person{padding:10px 0;border-bottom:1px solid #ffffff15}.person span{display:block;color:#8f9d94;font-size:.72rem}@media(max-width:820px){.layout{grid-template-columns:160px 1fr}.people{display:none}}@media(max-width:560px){.layout{display:block;height:auto}.sidebar{border:0}.rooms{grid-template-columns:repeat(3,1fr)}.chat{height:calc(100vh - 265px)}}
  </style>
</head>
<body>
  <header><strong>Long Room</strong><span id="status" class="status">Connecting</span></header>
  <div class="layout">
    <aside class="sidebar"><label for="name">Your name</label><input id="name" maxlength="30"><div class="rooms"><button data-room="general"># general</button><button data-room="music"># music</button><button data-room="games"># games</button></div></aside>
    <main class="chat"><div class="chat-head"><h1 id="room"># general</h1><div id="age" class="connection">Connection age 0s</div></div><div id="messages" class="messages"></div><form id="compose" class="compose"><input id="message" maxlength="500" autocomplete="off" placeholder="Write to the room"><button>Send</button></form></main>
    <aside class="people"><h2>Here now</h2><p id="longest" class="longest">Waiting for the room</p><div id="people"></div></aside>
  </div>
  <script>
    const nameInput=document.querySelector('#name'),status=document.querySelector('#status'),roomTitle=document.querySelector('#room'),messages=document.querySelector('#messages'),people=document.querySelector('#people'),longest=document.querySelector('#longest'),age=document.querySelector('#age'),form=document.querySelector('#compose'),messageInput=document.querySelector('#message');
    let socket,room=localStorage.chatRoom||'general',connectedAt=0,retry=500,currentPeople=[];
    nameInput.value=localStorage.chatName||`Guest ${Math.floor(Math.random()*900+100)}`;
    function elapsed(start){const seconds=Math.max(0,Math.floor((Date.now()-start)/1000));return seconds<60?`${seconds}s`:`${Math.floor(seconds/60)}m ${seconds%60}s`}
    function add(item){const row=document.createElement('div');row.className=item.type==='notice'?'notice':'message';if(item.type==='notice'){row.textContent=item.text}else{const who=document.createElement('strong'),text=document.createElement('p'),time=document.createElement('time');who.textContent=item.name;text.textContent=item.text;time.textContent=new Date(item.sentAt).toLocaleTimeString([],{hour:'numeric',minute:'2-digit'});row.append(who,text,time)}messages.append(row);messages.scrollTop=messages.scrollHeight}
    function renderPeople(){people.replaceChildren(...currentPeople.map(person=>{const row=document.createElement('div'),name=document.createElement('strong'),time=document.createElement('span');row.className='person';name.textContent=person.name;time.textContent=`connected ${elapsed(person.connectedAt)}`;row.append(name,time);return row}));const first=currentPeople[0];longest.textContent=first?`Longest connection  ${first.name} at ${elapsed(first.connectedAt)}`:'The room is empty'}
    function connect(){if(socket)socket.close();status.textContent='Connecting';status.className='status';roomTitle.textContent=`# ${room}`;document.querySelectorAll('[data-room]').forEach(button=>button.classList.toggle('active',button.dataset.room===room));const scheme=location.protocol==='https:'?'wss':'ws',nextSocket=new WebSocket(`${scheme}://${location.host}/ws?room=${encodeURIComponent(room)}&name=${encodeURIComponent(nameInput.value)}`);socket=nextSocket;nextSocket.onopen=()=>{connectedAt=Date.now();retry=500;status.textContent='Connected';status.className='status online'};nextSocket.onmessage=event=>{const data=JSON.parse(event.data);if(data.type==='welcome'){messages.textContent='';data.history.forEach(add);currentPeople=data.people;renderPeople()}else if(data.type==='presence'){currentPeople=data.people;renderPeople()}else add(data)};nextSocket.onclose=()=>{if(socket!==nextSocket)return;status.textContent='Reconnecting';status.className='status';setTimeout(connect,retry);retry=Math.min(retry*2,8000)}}
    document.querySelectorAll('[data-room]').forEach(button=>button.onclick=()=>{room=button.dataset.room;localStorage.chatRoom=room;connect()});nameInput.onchange=()=>{localStorage.chatName=nameInput.value.trim();connect()};form.onsubmit=event=>{event.preventDefault();const text=messageInput.value.trim();if(text&&socket.readyState===WebSocket.OPEN){socket.send(JSON.stringify({type:'message',text}));messageInput.value=''}};setInterval(()=>{if(connectedAt)age.textContent=`Connection age ${elapsed(connectedAt)}`;renderPeople()},1000);connect();
  </script>
</body>
</html>

package.json Raw

{
  "name": "node-websocket-chat",
  "version": "1.0.0",
  "private": true,
  "engines": { "node": ">=24" },
  "scripts": { "start": "node server.js" },
  "dependencies": {
    "express": "5.2.1",
    "ws": "8.21.1"
  }
}

server.js Raw

const crypto = require("node:crypto");
const http = require("node:http");
const path = require("node:path");
const express = require("express");
const { WebSocketServer, WebSocket } = require("ws");

const allowedRooms = new Set(["general", "music", "games"]);
const clients = new Map();
const history = new Map([...allowedRooms].map(room => [room, []]));
const app = express();
app.use(express.static(path.join(__dirname, "public")));
app.get("/health", (_request, response) => response.json({ ok: true }));

const server = http.createServer(app);
const sockets = new WebSocketServer({ server, path: "/ws" });

function clean(value, fallback, maxLength) {
  const text = String(value || "").trim().replace(/\s+/g, " ").slice(0, maxLength);
  return text || fallback;
}

function send(socket, message) {
  if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
}

function broadcast(room, message) {
  for (const [socket, person] of clients) {
    if (person.room === room) send(socket, message);
  }
}

function peopleIn(room) {
  return [...clients.values()]
    .filter(person => person.room === room)
    .map(person => ({ id: person.id, name: person.name, connectedAt: person.connectedAt }))
    .sort((left, right) => left.connectedAt - right.connectedAt);
}

function updatePresence(room) {
  broadcast(room, { type: "presence", people: peopleIn(room) });
}

function addHistory(room, message) {
  const messages = history.get(room);
  messages.push(message);
  if (messages.length > 50) messages.shift();
}

sockets.on("connection", (socket, request) => {
  const url = new URL(request.url, "http://localhost");
  const requestedRoom = clean(url.searchParams.get("room"), "general", 20).toLowerCase();
  const room = allowedRooms.has(requestedRoom) ? requestedRoom : "general";
  const person = {
    id: crypto.randomUUID(),
    name: clean(url.searchParams.get("name"), `Guest ${Math.floor(Math.random() * 900 + 100)}`, 30),
    room,
    connectedAt: Date.now(),
    isAlive: true,
  };
  clients.set(socket, person);
  send(socket, { type: "welcome", person, room, history: history.get(room), people: peopleIn(room) });
  const joined = { type: "notice", id: crypto.randomUUID(), text: `${person.name} joined #${room}`, sentAt: Date.now() };
  addHistory(room, joined);
  broadcast(room, joined);
  updatePresence(room);

  socket.on("pong", () => { person.isAlive = true; });
  socket.on("message", raw => {
    let input;
    try { input = JSON.parse(raw.toString()); } catch { return; }
    if (input.type !== "message") return;
    const text = clean(input.text, "", 500);
    if (!text) return;
    const message = { type: "message", id: crypto.randomUUID(), name: person.name, text, sentAt: Date.now() };
    addHistory(room, message);
    broadcast(room, message);
  });
  socket.on("close", () => {
    if (!clients.delete(socket)) return;
    const left = { type: "notice", id: crypto.randomUUID(), text: `${person.name} left #${room}`, sentAt: Date.now() };
    addHistory(room, left);
    broadcast(room, left);
    updatePresence(room);
  });
});

const heartbeat = setInterval(() => {
  for (const [socket, person] of clients) {
    if (!person.isAlive) {
      socket.terminate();
      continue;
    }
    person.isAlive = false;
    socket.ping();
  }
}, 30000);

const port = Number(process.env.PORT || 3000);
server.listen(port, "0.0.0.0", () => console.log(`WebSocket rooms ready on port ${port}`));

process.on("SIGTERM", () => {
  clearInterval(heartbeat);
  for (const socket of clients.keys()) socket.close(1012, "Service restarting");
  server.close(() => process.exit(0));
});

Built from revision 52c7b7f

Guide: Deploy a WebSocket server