DEV Community

Abhin krishnan
Abhin krishnan

Posted on

Building a terminal based chat app with WebSockets

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
Enter fullscreen mode Exit fullscreen mode

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);
    });
});
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

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();
    });
});
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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);
    });
});
Enter fullscreen mode Exit fullscreen mode

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'.
Enter fullscreen mode Exit fullscreen mode

Serialize before sending, parse on the way in:

ws.send(JSON.stringify(message));

// on the receiving end
const msg = JSON.parse(data.toString());
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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));
            }
        });
    }
});
Enter fullscreen mode Exit fullscreen mode

Client, after connecting:

const user = await rl.question('Enter a username: ');
ws.send(JSON.stringify({ type: 'join', user }));
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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)