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 with npm install.
  • dist/ excludes compiled TypeScript output.
  • *.js, *.d.ts, *.js.map exclude generated JavaScript files in the source tree. The !jest.config.js exception keeps the test config if it were in JS format.
  • .DS_Store, *.log, and tmp/ 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 and structuredClone.
  • module: Node16 / moduleResolution: Node16: Uses Node.js native module resolution, required for correct CommonJS/ESM interop with the ws package.
  • outDir: dist: Compiled JavaScript goes into dist/, keeping source and output separate.
  • rootDir: src: Tells the compiler that all source files live under src/, so dist/ mirrors the src/ directory structure.
  • strict: true: Enables all strict type-checking options. This catches null reference errors and implicit any types 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. The type field acts as the discriminator.
  • parseIncoming(): Validates and parses raw WebSocket strings into typed messages. Returns null for 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 the trim() 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

  • Client interface: Associates a WebSocket connection with an optional username. Username is null until the client sends a set_name message.
  • ChatServer interface: The return type of createChatServer. Exposes the underlying WebSocketServer, the client set (useful for testing), and a stop() 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 type discriminator to route logic. set_name registers or renames the user. chat broadcasts 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 check readyState === OPEN before sending to avoid errors on closing connections. broadcast serializes the message once and sends the same string to all clients.
  • .js import extension: Required by Node16 module resolution. TypeScript resolves ./protocol.js to ./protocol.ts at compile time, and the compiled output uses the .js extension 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 PORT from the environment, defaulting to 8080. parseInt with radix 10 ensures correct parsing.
  • main() async wrapper: Since createChatServer returns a promise, the entry point uses an async function. This keeps the top level clean.
  • SIGINT handler: 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:8080 using the browser’s native WebSocket API.
  • 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

  • help as default target: Running make with no arguments prints a formatted list of targets by scanning for ## comments. This satisfies the Makefile default target rule.
  • install: Runs npm install to restore dependencies from package.json.
  • build: Compiles TypeScript using the tsconfig.json configuration. Output goes to dist/.
  • start: Depends on build, then runs the compiled entry point. This ensures you never run stale JavaScript.
  • test: Runs vitest in single-run mode (run flag) 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: valid set_name, valid chat, whitespace trimming, empty values, invalid JSON, unknown types, and non-object inputs.
  • Each test calls parseIncoming directly with a raw string and asserts the returned object or null. 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. Uses once to 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 0 to createChatServer lets the OS assign a random available port. This eliminates port conflicts when tests run in parallel or when port 8080 is already in use.
  • afterEach cleanup: Closes all client connections and stops the server after each test to prevent connection leaks and port reuse errors.
  • TestListBooksEmpty analogy — “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_name flow 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 run make build (or npx tsc) before starting the server. The .js imports in TypeScript source require compiled output in dist/.
  • EADDRINUSE error: Port 8080 is already in use. Stop the other process or set a different port with PORT=9090 make start.
  • Tests hang instead of completing: Ensure every test calls ws.close() on all opened connections. The afterEach cleanup 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