A real-time chat server using WebSockets, built with TypeScript and the ws library on Node.js. The server supports named users, broadcasts messages to all connected clients, tracks join/leave events, and includes a browser-based test client.
Prerequisites
- Node.js 20 or later
- npm 9 or later
- A Unix-like terminal (macOS, Linux, or WSL on Windows)
- A modern web browser for the test client
Step 1: Set up the project structure
Create the file
mkdir -p websocket-chat-server
touch websocket-chat-server/.gitignore
Add the code: websocket-chat-server/.gitignore
node_modules/
dist/
*.js
*.d.ts
*.js.map
!jest.config.js
.DS_Store
*.log
tmp/
Detailed breakdown
node_modules/excludes installed dependencies, which are restored withnpm install.dist/excludes compiled TypeScript output.*.js,*.d.ts,*.js.mapexclude generated JavaScript files in the source tree. The!jest.config.jsexception keeps the test config if it were in JS format..DS_Store,*.log, andtmp/cover common OS and temporary artifacts.
Step 2: Initialize the project and install dependencies
Create the file
cd websocket-chat-server
npm init -y
Install runtime and development dependencies:
npm install ws
npm install -D typescript @types/ws @types/node vitest
Create the file
touch websocket-chat-server/tsconfig.json
Add the code: websocket-chat-server/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
Detailed breakdown
target: ES2022: Enables modern JavaScript features including top-level await andstructuredClone.module: Node16/moduleResolution: Node16: Uses Node.js native module resolution, required for correct CommonJS/ESM interop with thewspackage.outDir: dist: Compiled JavaScript goes intodist/, keeping source and output separate.rootDir: src: Tells the compiler that all source files live undersrc/, sodist/mirrors thesrc/directory structure.strict: true: Enables all strict type-checking options. This catches null reference errors and implicitanytypes at compile time.exclude: Keeps test files out of the production build. Tests are compiled separately by vitest.
Step 3: Define the message protocol
A shared module that defines the message types exchanged between server and clients.
Create the file
mkdir -p websocket-chat-server/src
touch websocket-chat-server/src/protocol.ts
Add the code: websocket-chat-server/src/protocol.ts
export interface ChatMessage {
type: "chat";
username: string;
text: string;
timestamp: number;
}
export interface SystemMessage {
type: "system";
text: string;
timestamp: number;
}
export interface SetNameMessage {
type: "set_name";
username: string;
}
export type IncomingMessage = SetNameMessage | { type: "chat"; text: string };
export type OutgoingMessage = ChatMessage | SystemMessage;
export function parseIncoming(raw: string): IncomingMessage | null {
try {
const data = JSON.parse(raw);
if (typeof data !== "object" || data === null || typeof data.type !== "string") {
return null;
}
if (data.type === "set_name" && typeof data.username === "string" && data.username.trim() !== "") {
return { type: "set_name", username: data.username.trim() };
}
if (data.type === "chat" && typeof data.text === "string" && data.text.trim() !== "") {
return { type: "chat", text: data.text.trim() };
}
return null;
} catch {
return null;
}
}
Detailed breakdown
ChatMessage/SystemMessage: The two outgoing message types the server sends. Chat messages carry a username and text. System messages announce joins, leaves, and errors.SetNameMessage: Sent by clients to register a username before chatting.IncomingMessage: A discriminated union of messages the server accepts. Thetypefield acts as the discriminator.parseIncoming(): Validates and parses raw WebSocket strings into typed messages. Returnsnullfor any malformed input instead of throwing, so the server can respond with an error message rather than crashing. Whitespace-only usernames and messages are rejected by thetrim()check.
Step 4: Build the chat server
The core server module that manages WebSocket connections, user state, and message broadcasting.
Create the file
touch websocket-chat-server/src/server.ts
Add the code: websocket-chat-server/src/server.ts
import { WebSocketServer, WebSocket } from "ws";
import { parseIncoming, OutgoingMessage } from "./protocol.js";
interface Client {
ws: WebSocket;
username: string | null;
}
export interface ChatServer {
wss: WebSocketServer;
clients: Set<Client>;
stop(): void;
}
export function createChatServer(port: number): Promise<ChatServer> {
return new Promise((resolve) => {
const wss = new WebSocketServer({ port });
const clients = new Set<Client>();
wss.on("listening", () => {
resolve({ wss, clients, stop: () => wss.close() });
});
wss.on("connection", (ws) => {
const client: Client = { ws, username: null };
clients.add(client);
send(ws, {
type: "system",
text: "Welcome! Send {\"type\":\"set_name\",\"username\":\"yourname\"} to join.",
timestamp: Date.now(),
});
ws.on("message", (data) => {
const raw = data.toString();
const msg = parseIncoming(raw);
if (msg === null) {
send(ws, {
type: "system",
text: "Invalid message format.",
timestamp: Date.now(),
});
return;
}
if (msg.type === "set_name") {
const oldName = client.username;
client.username = msg.username;
if (oldName === null) {
broadcast(clients, {
type: "system",
text: `${msg.username} joined the chat.`,
timestamp: Date.now(),
});
} else {
broadcast(clients, {
type: "system",
text: `${oldName} is now known as ${msg.username}.`,
timestamp: Date.now(),
});
}
return;
}
if (msg.type === "chat") {
if (client.username === null) {
send(ws, {
type: "system",
text: "Set your name first.",
timestamp: Date.now(),
});
return;
}
broadcast(clients, {
type: "chat",
username: client.username,
text: msg.text,
timestamp: Date.now(),
});
}
});
ws.on("close", () => {
clients.delete(client);
if (client.username !== null) {
broadcast(clients, {
type: "system",
text: `${client.username} left the chat.`,
timestamp: Date.now(),
});
}
});
});
});
}
function send(ws: WebSocket, msg: OutgoingMessage): void {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
}
}
function broadcast(clients: Set<Client>, msg: OutgoingMessage): void {
const payload = JSON.stringify(msg);
for (const client of clients) {
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.send(payload);
}
}
}
Detailed breakdown
Clientinterface: Associates a WebSocket connection with an optional username. Username isnulluntil the client sends aset_namemessage.ChatServerinterface: The return type ofcreateChatServer. Exposes the underlyingWebSocketServer, the client set (useful for testing), and astop()method for graceful shutdown.createChatServer(): Returns a promise that resolves once the server is listening. This avoids race conditions in tests where you need to connect immediately after creation.- Connection flow: On connect, the server adds the client to the set and sends a welcome system message. On disconnect, it removes the client and broadcasts a leave message if the user had a name.
- Message handling: Uses the parsed message’s
typediscriminator to route logic.set_nameregisters or renames the user.chatbroadcasts to all clients but is rejected if the user has not set a name yet. Invalid messages get a system error response. send()/broadcast(): Both checkreadyState === OPENbefore sending to avoid errors on closing connections.broadcastserializes the message once and sends the same string to all clients..jsimport extension: Required by Node16 module resolution. TypeScript resolves./protocol.jsto./protocol.tsat compile time, and the compiled output uses the.jsextension that Node.js expects.
Step 5: Create the entry point
Create the file
touch websocket-chat-server/src/main.ts
Add the code: websocket-chat-server/src/main.ts
import { createChatServer } from "./server.js";
const PORT = parseInt(process.env["PORT"] ?? "8080", 10);
async function main(): Promise<void> {
const server = await createChatServer(PORT);
console.log(`Chat server listening on ws://localhost:${PORT}`);
process.on("SIGINT", () => {
console.log("\nShutting down...");
server.stop();
process.exit(0);
});
}
main();
Detailed breakdown
- Port configuration: Reads
PORTfrom the environment, defaulting to8080.parseIntwith radix 10 ensures correct parsing. main()async wrapper: SincecreateChatServerreturns a promise, the entry point uses an async function. This keeps the top level clean.SIGINThandler: Catches Ctrl+C, closes the WebSocket server gracefully, and exits. Without this, open connections would keep the process alive after the interrupt.
Step 6: Add a browser test client
A minimal HTML page that connects to the server and provides a chat interface for manual testing.
Create the file
mkdir -p websocket-chat-server/public
touch websocket-chat-server/public/index.html
Add the code: websocket-chat-server/public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebSocket Chat</title>
<style>
body { font-family: monospace; max-width: 600px; margin: 2rem auto; }
#log { height: 300px; overflow-y: auto; border: 1px solid #ccc; padding: 0.5rem; margin-bottom: 1rem; }
.system { color: #888; }
.chat { color: #000; }
input { width: 70%; padding: 0.3rem; }
button { padding: 0.3rem 1rem; }
</style>
</head>
<body>
<h1>WebSocket Chat</h1>
<div id="log"></div>
<input id="input" type="text" placeholder="Type a message..." autofocus>
<button onclick="sendMessage()">Send</button>
<script>
const log = document.getElementById("log");
const input = document.getElementById("input");
const ws = new WebSocket(`ws://${location.hostname}:8080`);
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
const div = document.createElement("div");
if (msg.type === "system") {
div.className = "system";
div.textContent = `[system] ${msg.text}`;
} else {
div.className = "chat";
div.textContent = `[${msg.username}] ${msg.text}`;
}
log.appendChild(div);
log.scrollTop = log.scrollHeight;
};
ws.onclose = () => {
const div = document.createElement("div");
div.className = "system";
div.textContent = "[system] Disconnected.";
log.appendChild(div);
};
function sendMessage() {
const text = input.value.trim();
if (!text) return;
if (!text.startsWith("{")) {
ws.send(JSON.stringify({ type: "chat", text }));
} else {
ws.send(text);
}
input.value = "";
}
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") sendMessage();
});
</script>
</body>
</html>
Detailed breakdown
- WebSocket connection: Connects to
ws://localhost:8080using the browser’s nativeWebSocketAPI. - Message display: Parses incoming JSON and renders system messages in gray and chat messages in black with the username prefix.
- Send logic: If the input starts with
{, it is sent raw (so you can type{"type":"set_name","username":"alice"}directly). Otherwise, it is wrapped in a chat message object. This dual mode makes manual testing of both protocols easy. - Enter key handler: Submits on Enter without needing to click the Send button.
- This file is for manual testing only and is not part of the server application.
Step 7: Add a Makefile
Create the file
touch websocket-chat-server/Makefile
Add the code: websocket-chat-server/Makefile
.DEFAULT_GOAL := help
.PHONY: help install build start test clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*## "}; {printf " %-12s %s\n", $$1, $$2}'
install: ## Install dependencies
npm install
build: ## Compile TypeScript to JavaScript
npx tsc
start: build ## Build and start the chat server
node dist/main.js
test: ## Run the test suite
npx vitest run
clean: ## Remove build artifacts and dependencies
rm -rf dist/ node_modules/
Detailed breakdown
helpas default target: Runningmakewith no arguments prints a formatted list of targets by scanning for##comments. This satisfies the Makefile default target rule.install: Runsnpm installto restore dependencies frompackage.json.build: Compiles TypeScript using thetsconfig.jsonconfiguration. Output goes todist/.start: Depends onbuild, then runs the compiled entry point. This ensures you never run stale JavaScript.test: Runs vitest in single-run mode (runflag) so it exits after completing all tests instead of watching.clean: Removes both compiled output and installed dependencies for a fresh start.
Step 8: Write tests
Create the file
touch websocket-chat-server/src/protocol.test.ts
touch websocket-chat-server/src/server.test.ts
Add the code: websocket-chat-server/src/protocol.test.ts
import { describe, it, expect } from "vitest";
import { parseIncoming } from "./protocol.js";
describe("parseIncoming", () => {
it("parses a valid set_name message", () => {
const msg = parseIncoming('{"type":"set_name","username":"alice"}');
expect(msg).toEqual({ type: "set_name", username: "alice" });
});
it("trims whitespace from username", () => {
const msg = parseIncoming('{"type":"set_name","username":" bob "}');
expect(msg).toEqual({ type: "set_name", username: "bob" });
});
it("rejects empty username", () => {
const msg = parseIncoming('{"type":"set_name","username":" "}');
expect(msg).toBeNull();
});
it("parses a valid chat message", () => {
const msg = parseIncoming('{"type":"chat","text":"hello world"}');
expect(msg).toEqual({ type: "chat", text: "hello world" });
});
it("trims whitespace from chat text", () => {
const msg = parseIncoming('{"type":"chat","text":" hi "}');
expect(msg).toEqual({ type: "chat", text: "hi" });
});
it("rejects empty chat text", () => {
const msg = parseIncoming('{"type":"chat","text":""}');
expect(msg).toBeNull();
});
it("returns null for invalid JSON", () => {
expect(parseIncoming("not json")).toBeNull();
});
it("returns null for unknown message type", () => {
expect(parseIncoming('{"type":"unknown"}')).toBeNull();
});
it("returns null for non-object input", () => {
expect(parseIncoming('"just a string"')).toBeNull();
});
it("returns null for null JSON", () => {
expect(parseIncoming("null")).toBeNull();
});
});
Detailed breakdown
- Tests cover all branches of
parseIncoming: validset_name, validchat, whitespace trimming, empty values, invalid JSON, unknown types, and non-object inputs. - Each test calls
parseIncomingdirectly with a raw string and asserts the returned object ornull. No mocking is needed since the function is pure. - The tests document the protocol contract — any future change to message parsing will be caught here.
Add the code: websocket-chat-server/src/server.test.ts
import { describe, it, expect, afterEach } from "vitest";
import WebSocket from "ws";
import { createChatServer, ChatServer } from "./server.js";
let server: ChatServer;
function connect(port: number): Promise<WebSocket> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`ws://localhost:${port}`);
ws.on("open", () => resolve(ws));
ws.on("error", reject);
});
}
function nextMessage(ws: WebSocket): Promise<Record<string, unknown>> {
return new Promise((resolve) => {
ws.once("message", (data) => {
resolve(JSON.parse(data.toString()));
});
});
}
function sendAndReceive(
sender: WebSocket,
receiver: WebSocket,
msg: Record<string, unknown>
): Promise<Record<string, unknown>> {
const promise = nextMessage(receiver);
sender.send(JSON.stringify(msg));
return promise;
}
afterEach(() => {
if (server) {
server.clients.forEach((c) => c.ws.close());
server.stop();
}
});
describe("ChatServer", () => {
it("sends a welcome message on connect", async () => {
server = await createChatServer(0);
const port = (server.wss.address() as { port: number }).port;
const ws = await connect(port);
const msg = await nextMessage(ws);
expect(msg.type).toBe("system");
expect(msg.text).toContain("Welcome");
ws.close();
});
it("broadcasts join when user sets name", async () => {
server = await createChatServer(0);
const port = (server.wss.address() as { port: number }).port;
const ws1 = await connect(port);
await nextMessage(ws1); // welcome
const ws2 = await connect(port);
await nextMessage(ws2); // welcome
const joinMsg = sendAndReceive(
ws1,
ws2,
{ type: "set_name", username: "alice" }
);
// ws1 also receives join, consume it
nextMessage(ws1);
const received = await joinMsg;
expect(received.type).toBe("system");
expect(received.text).toContain("alice joined");
ws1.close();
ws2.close();
});
it("broadcasts chat messages to all clients", async () => {
server = await createChatServer(0);
const port = (server.wss.address() as { port: number }).port;
const ws1 = await connect(port);
await nextMessage(ws1); // welcome
const ws2 = await connect(port);
await nextMessage(ws2); // welcome
// Set names
ws1.send(JSON.stringify({ type: "set_name", username: "alice" }));
await nextMessage(ws1); // join broadcast
await nextMessage(ws2); // join broadcast
ws2.send(JSON.stringify({ type: "set_name", username: "bob" }));
await nextMessage(ws1); // join broadcast
await nextMessage(ws2); // join broadcast
// Send chat
const chatPromise = nextMessage(ws1);
ws2.send(JSON.stringify({ type: "chat", text: "hello everyone" }));
const chatMsg = await chatPromise;
expect(chatMsg.type).toBe("chat");
expect(chatMsg.username).toBe("bob");
expect(chatMsg.text).toBe("hello everyone");
ws1.close();
ws2.close();
});
it("rejects chat before name is set", async () => {
server = await createChatServer(0);
const port = (server.wss.address() as { port: number }).port;
const ws = await connect(port);
await nextMessage(ws); // welcome
const errMsg = sendAndReceive(ws, ws, { type: "chat", text: "hi" });
const received = await errMsg;
expect(received.type).toBe("system");
expect(received.text).toContain("Set your name first");
ws.close();
});
it("rejects invalid messages", async () => {
server = await createChatServer(0);
const port = (server.wss.address() as { port: number }).port;
const ws = await connect(port);
await nextMessage(ws); // welcome
const promise = nextMessage(ws);
ws.send("garbage");
const received = await promise;
expect(received.type).toBe("system");
expect(received.text).toContain("Invalid");
ws.close();
});
it("broadcasts leave when named user disconnects", async () => {
server = await createChatServer(0);
const port = (server.wss.address() as { port: number }).port;
const ws1 = await connect(port);
await nextMessage(ws1); // welcome
const ws2 = await connect(port);
await nextMessage(ws2); // welcome
// Set name for ws1
ws1.send(JSON.stringify({ type: "set_name", username: "alice" }));
await nextMessage(ws1); // join broadcast
await nextMessage(ws2); // join broadcast
// Disconnect ws1, ws2 should get leave message
const leavePromise = nextMessage(ws2);
ws1.close();
const leaveMsg = await leavePromise;
expect(leaveMsg.type).toBe("system");
expect(leaveMsg.text).toContain("alice left");
ws2.close();
});
it("supports renaming", async () => {
server = await createChatServer(0);
const port = (server.wss.address() as { port: number }).port;
const ws = await connect(port);
await nextMessage(ws); // welcome
// Set initial name
ws.send(JSON.stringify({ type: "set_name", username: "alice" }));
await nextMessage(ws); // join
// Rename
const renamePromise = nextMessage(ws);
ws.send(JSON.stringify({ type: "set_name", username: "alice2" }));
const renameMsg = await renamePromise;
expect(renameMsg.type).toBe("system");
expect(renameMsg.text).toContain("alice is now known as alice2");
ws.close();
});
});
Detailed breakdown
connect(): Helper that returns a promise resolving to an open WebSocket connection. Simplifies test setup by eliminating callback nesting.nextMessage(): Returns a promise for the next message on a WebSocket. Usesonceto avoid accumulating listeners across multiple calls.sendAndReceive(): Combines sending a message on one socket and waiting for a response on another. This pattern is used throughout to test broadcast behavior.- Port 0: Passing port
0tocreateChatServerlets the OS assign a random available port. This eliminates port conflicts when tests run in parallel or when port 8080 is already in use. afterEachcleanup: Closes all client connections and stops the server after each test to prevent connection leaks and port reuse errors.TestListBooksEmptyanalogy — “sends a welcome message”: The first test verifies the simplest interaction — connecting and receiving the welcome system message.- Broadcast tests: The join, chat, and leave tests all connect two clients and verify that actions by one client produce the expected messages on the other.
- Rejection tests: “rejects chat before name” and “rejects invalid messages” verify error handling paths. Both confirm the server responds with a system error rather than crashing.
- Rename test: Verifies the
set_nameflow when a user already has a name, producing a “now known as” message instead of a “joined” message.
Step 9: Run and validate
Run the full test suite:
cd websocket-chat-server
npm install
npx vitest run
Expected output:
✓ src/protocol.test.ts (10 tests)
✓ src/server.test.ts (7 tests)
Test Files 2 passed (2)
Tests 17 passed (17)
Verify the default Makefile target:
make
Expected output:
help Show this help screen
install Install dependencies
build Compile TypeScript to JavaScript
start Build and start the chat server
test Run the test suite
clean Remove build artifacts and dependencies
Build and start the server for manual testing:
make start
In a second terminal, test with a WebSocket client:
npx wscat -c ws://localhost:8080
> {"type":"set_name","username":"alice"}
> {"type":"chat","text":"Hello from the terminal!"}
Or open public/index.html in a browser, type {"type":"set_name","username":"bob"} in the input box, then send chat messages normally.
Troubleshooting
Cannot find module './protocol.js'at runtime: Make sure you runmake build(ornpx tsc) before starting the server. The.jsimports in TypeScript source require compiled output indist/.EADDRINUSEerror: Port 8080 is already in use. Stop the other process or set a different port withPORT=9090 make start.- Tests hang instead of completing: Ensure every test calls
ws.close()on all opened connections. TheafterEachcleanup should handle this, but leaked connections can cause vitest to wait for open handles. - Browser client does not connect: The HTML file connects to
ws://localhost:8080. Make sure the server is running and the port matches.
Recap
This article built a WebSocket chat server with user names, broadcast messaging, join/leave notifications, and rename support. The project uses TypeScript with the ws library for the server, a shared protocol module for type-safe message parsing, and vitest for testing with real WebSocket connections on random ports. A browser-based HTML client is included for interactive testing.
Next improvements to consider:
- Add chat rooms so users can join named channels
- Persist message history to a file or SQLite database
- Add rate limiting to prevent message flooding
- Serve the HTML client directly from the Node.js server using
http.createServer