A WebSocket broadcast server and client in one CLI
A Node.js CLI that runs a WebSocket server and an interactive client: every message one client sends goes to all the others, with heartbeats and clean shutdown.
broadcast-server is my solution to the Broadcast Server project on roadmap.sh. It is one command with two modes: start runs a WebSocket server, and connect opens an interactive client in the terminal. Every message a client types is sent to every other connected client, like a very small chat room.
$ broadcast-server connect --name alice
Connected as alice (2 online). Type a message and press Enter, /quit to leave.
> hey bob!
> [10:44:05 PM] bob: hi alice 👋
> * carol joined (3 online)
Try it#
It needs Node.js 20 or newer:
git clone https://github.com/0xgitpushpray/broadcast-server.git
cd broadcast-server
npm install
npm link
npm link puts the broadcast-server command on your path. Then start the server in one terminal and connect clients from others:
broadcast-server start
broadcast-server connect --name alice
broadcast-server connect --name bob
--port and --host work for both commands; the port defaults to $PORT or 8080. Leave a client with /quit, Ctrl+C or Ctrl+D.
How it works#
The server keeps every open connection in a Map, with the client’s id, name and heartbeat state. When a message arrives, it trims it, adds the sender’s name and a timestamp, and sends it to every other open socket. The sender is skipped, because they already see what they typed.
function broadcast(payload, exclude) {
const data = JSON.stringify(payload);
for (const ws of clients.keys()) {
if (ws !== exclude && ws.readyState === WebSocket.OPEN) ws.send(data);
}
}
Clients send plain text. The server answers with JSON frames of five types: welcome when you connect, join and leave when someone else does (with the online count), message for a broadcast, and error for something it rejects, such as a binary frame. The name travels as a query parameter, so any WebSocket client can join, not only the bundled one:
npx wscat -c "ws://localhost:8080?name=wscat"
The only runtime dependency is ws. Argument parsing uses Node’s built-in util.parseArgs and the prompt uses readline.
Handling clients that disappear#
A client that quits normally sends a close frame, and the server removes it and tells everyone else it left. A laptop that goes to sleep or loses its network sends nothing. Its socket stays open on the server, and it would stay in the online count indefinitely.
The fix is a heartbeat. Every 30 seconds the server pings each client and marks it as not alive; the client’s pong marks it alive again. A client still marked not alive at the next check never answered, so the server terminates it:
for (const [ws, client] of clients) {
if (!client.isAlive) {
ws.terminate();
continue;
}
client.isAlive = false;
ws.ping();
}
Shutting down cleanly#
Ctrl+C or SIGTERM on the server does not just exit. It stops accepting new connections, sends every client close code 1001 Going Away, and waits for them to close. Any client that has not finished the close handshake after 3 seconds is cut off. Clients print Disconnected: Server shutting down and exit. A second Ctrl+C on the server forces it to exit straight away.
Startup errors get a plain message too. A port that is taken prints Error: Port 8080 is already in use, and connecting to a server that is not running prints Could not connect to ws://localhost:8080. Is the server running?. Messages over 16 KB are rejected, and names are stripped of control characters and cut to 32 characters.
Keeping the prompt readable#
When a message arrives while you are typing, writing it straight to the terminal would print it in the middle of your half-typed line. The client clears the current line, prints the message, and redraws the prompt with what you had typed:
function print(line) {
if (rl && output.isTTY) {
readline.clearLine(output, 0);
readline.cursorTo(output, 0);
}
output.write(`${line}\n`);
rl?.prompt(true);
}
Tests#
npm test uses Node’s built-in test runner. The server tests start real servers on random ports and connect real clients, so they test the actual sockets. They cover broadcasting, the sender not getting its own message back, join and leave events, blank and binary messages, shutdown with 1001, port conflicts, and the heartbeat. The heartbeat test runs a server with a 50 ms interval and a client with automatic pong replies turned off, then checks that the server drops it. GitHub Actions runs the suite on Node 20, 22 and 24.