Building a Terminal Chat App with Raw WebSockets
This is a step-by-step build of a terminal-based chat app using the ws library — no Socket.IO, just raw WebSockets — so you can see exactly what's happening at each layer.
Setup
Two files: client.ts and server.ts. Both use the ws package.
npm install ws
npm install -D @types/ws typescript
Step 1: Connect and send a message
Server:
import { WebSocket, WebSocketServer } from 'ws';
const PORT = 8080;
const wss = new WebSocketServer({ port: PORT });
wss.on('connection', (socket) => {
console.log('client connected');
socket.on('message', (data) => {
console.log('received: %s', data);
});
});
Client:
import { WebSocket } from 'ws';
const ws = new WebSocket('ws://localhost:8080');
ws.on('open', () => {
console.log('connected to server');
ws.send('message from client');
});
Run the server, then the client — the server should log the message.
Step 2: Take real input with readline
Replace the hardcoded ws.send() with a loop that sends whatever the user types:
import readline from 'node:readline';
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
prompt: 'You > '
});
ws.on('open', () => {
rl.prompt();
rl.on('line', (text) => {
ws.send(text);
rl.prompt();
});
});
rl.on('line', ...) fires every time the user hits enter, so this keeps sending messages indefinitely.
Step 3: Fix incoming messages colliding with the prompt
When a message arrives while you have a prompt on screen, console.log() just writes wherever the cursor is — you get You >helloworld instead of a clean new line.
Clear the line before printing, then redraw the prompt:
ws.on('message', (data) => {
readline.cursorTo(process.stdout, 0);
readline.clearLine(process.stdout, 0);
console.log(data.toString());
rl.prompt(true); // true keeps whatever the user had already typed
});
Step 4: Broadcast to every other client
The server needs to track connected clients and forward each message to everyone except the sender:
const clients: WebSocket[] = [];
wss.on('connection', (socket) => {
clients.push(socket);
socket.on('message', (data) => {
clients.forEach((client) => {
if (client !== socket && client.readyState === WebSocket.OPEN) {
client.send(data);
}
});
});
socket.on('close', () => {
const index = clients.indexOf(socket);
if (index !== -1) clients.splice(index, 1);
});
});
The readyState === WebSocket.OPEN check avoids sending to a socket that's mid-disconnect.
Step 5: Send structured messages, not raw strings
ws.send() only accepts strings or buffers — passing an object directly throws:
Argument of type 'message' is not assignable to parameter of type 'BufferLike'.
Serialize before sending, parse on the way in:
ws.send(JSON.stringify(message));
// on the receiving end
const msg = JSON.parse(data.toString());
Step 6: Attach usernames on the server, not the client
Sending { user, content } from the client works, but it means any client can claim to be anyone. Instead, send the username once as a join message, and let the server remember it against that connection:
// types.ts
export type JoinMessage = { type: 'join'; user: string };
export type ChatMessage = { type: 'chat'; content: string };
export type IncomingMessage = JoinMessage | ChatMessage;
export type OutgoingMessage = { type: 'chat'; user: string; content: string };
Server:
const clients = new Map<WebSocket, string>();
socket.on('message', (data) => {
const msg: IncomingMessage = JSON.parse(data.toString());
if (msg.type === 'join') {
clients.set(socket, msg.user);
} else if (msg.type === 'chat') {
const username = clients.get(socket) ?? 'guest';
clients.forEach((_user, client) => {
if (client !== socket && client.readyState === WebSocket.OPEN) {
const broadcastMsg: OutgoingMessage = {
type: 'chat',
user: username,
content: msg.content
};
client.send(JSON.stringify(broadcastMsg));
}
});
}
});
Client, after connecting:
const user = await rl.question('Enter a username: ');
ws.send(JSON.stringify({ type: 'join', user }));
Now the username on every broadcast message comes from the server's own record, not from whatever the client claims.
Step 7: Organize the server into named functions
Once join/chat handling is in, pull the broadcast loop and each message type into their own functions instead of one large handler:
function broadcast(message: OutgoingMessage, exclude?: WebSocket) {
clients.forEach((username, client) => {
if (client !== exclude && client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(message));
}
});
}
function handleJoin(socket: WebSocket, username: string) {
clients.set(socket, username);
}
function handleChat(socket: WebSocket, content: string) {
const username = clients.get(socket) ?? 'guest';
broadcast({ type: 'chat', user: username, content }, socket);
}
function handleDisconnect(socket: WebSocket) {
clients.delete(socket);
}
socket.on('message', (data) => {
const msg: IncomingMessage = JSON.parse(data.toString());
if (msg.type === 'join') handleJoin(socket, msg.user);
else if (msg.type === 'chat') handleChat(socket, msg.content);
});
socket.on('close', () => handleDisconnect(socket));
The on('message') handler is now just a dispatcher — this matters more once you add more message types.
Next: rooms
Right now every client is in one global broadcast group. Rooms mean changing clients from Map<WebSocket, string> to Map<string, Map<WebSocket, string>>, keyed by room name, and scoping broadcast() to whichever room a client actually joined — plus a room field on the join message so the client picks a room on connect.
Top comments (0)