DEV Community

Yustus Oktian
Yustus Oktian

Posted on

Chatty

Welcome!

In this tutorial, we will make our own simple chat application with command line interface (CLI) using Node JS, with the three main features:

  • Register User Info: users can register themselves in the server.
  • Obtain User Info: users can download other users information.
  • Send Chat Message: after registration, users can send chat messages to each other via broadcasting approach.

TLDR: The full code can be seen in Github


Setup

To begin this tutorial, you will need to install Node JS version 18.x.x. Make a folder called chatty. Then, open the folder in VSCode and create a file called package.json with the following value:

{
  "dependencies": {
    "socket.io": "^4.7.2",
    "socket.io-client": "^4.7.2"
  }
}
Enter fullscreen mode Exit fullscreen mode

This package.json file contains all of our dependencies for this chat application.

Run
We can now install our dependencies. Open terminal in VSCode and run npm install from project root directory.

Image description

After install, a folder called node_modules and a file called package-lock.json will be created.


1. Connection Between Server And Client

First, we create a file called server.js and add the following codes

const { Server } = require("socket.io");

const io = new Server();

io.on("connection", (socket) => {
  console.log(`user ${socket.id} is connected`);

  socket.on("disconnect", () => {
    console.log(`user ${socket.id} is disconnected`);
  });
});

io.listen(8080);
Enter fullscreen mode Exit fullscreen mode

The io is socket.io server object that we use to process Web Socket requests. This socket is run in port 8080, with two events:

  • The connection is triggered every time the server receives a new connection from client
  • The disconnect is executed whenever we lose connection with existing clients

Second, we create a file called client.js and add the following codes

const { io } = require("socket.io-client");

const domain = "localhost";
const port = 8080;

const url = "http://" + domain + ":" + port.toString();
const socket = io(url);

socket.on("connect", () => {
  console.log("Connected with id:", socket.id);
});

socket.on("disconnect", () => {
  console.log("Disconnected!");
  process.exit(0);
});
Enter fullscreen mode Exit fullscreen mode

Recall that the server is running on port 8080. Since we run both server and client in our own machine, we set the domain as localhost. The client can connect to the server by mentioning the root url of the server. We create socket client object using the io(url) method.

The client also listens to connect and disconnect events, which are triggered whenever the client connects/disconnects to the server.

Run
Lets try to run our server and client! From one terminal, run node server. Then, open another terminal, run node client.

Image description

Image description

Those images show that server and client have been connected with socket id of uPx1V2GnN9tbpsHIAAAB. Each client connection will have unique socket id.


2. Assign Username

The client can choose what username they want to use in our chat application. In this case, we need to get user input from command line.

const readlinePromises = require('node:readline/promises');
const rl = readlinePromises.createInterface({
  input: process.stdin,
  output: process.stdout,
});
Enter fullscreen mode Exit fullscreen mode

We will use readline module to get user input. We choose the promise version so that we can use the async/await function in our code. More information on this module can be found here. The rl is the readline object that we use to process command line input.

username = await rl.question("What is your username? ");
console.log(`Hello ${username}, welcome to Chatty!`);
Enter fullscreen mode Exit fullscreen mode

The rl.question() will prompt a question "What is your username?" in the console and save the user answer input in the username variable.

The whole client.js looks as follows.

const { io } = require("socket.io-client");
const readlinePromises = require('node:readline/promises');
const rl = readlinePromises.createInterface({
  input: process.stdin,
  output: process.stdout,
});

const domain = "localhost";
const port = 8080;

const url = "http://" + domain + ":" + port.toString();
const socket = io(url);

let username;

socket.on("connect", async () => {
  console.log("Connected with id:", socket.id);

  username = await rl.question("What is your username? ");
  console.log(`Hello ${username}, welcome to Chatty!`);
});

socket.on("disconnect", () => {
  console.log("Disconnected!");
  process.exit(0);
});
Enter fullscreen mode Exit fullscreen mode

Run
Try to run the client one more time (node client) and type alice as your username then ENTER, it will show you something like this.

Image description


3. Add User Registration

We use Javascript Map object to store list of user along with their information. This map works like a key-value store database, but store only in-memory.

const users = new Map(); // use this to store users info
Enter fullscreen mode Exit fullscreen mode

We can now use users.set() to save and users.get() to retrieve data from our store.

First, we add a new channel in our web socket server.js called register.

socket.on("register", (data) => {
  const value = {
    socketId: data.socketId,
    time: data.time
  };
  users.set(data.username, value);
  socket.broadcast.emit("register", data);
});
Enter fullscreen mode Exit fullscreen mode

We send three data through this channel.

  • The username for the client username.
  • The socketId for the unique socket.io id per client.
  • The time is the time client connect to our server.

This data object will be stored in users and the username will be used as key while socketId and time act as values.

After that, we broadcast this data to other clients that currently connected to the server on register channel using socket.broadcast.emit().

Second, we add the same channel in client.js.

socket.on("connect", async () => {
  console.log("Connected with id:", socket.id);
  //...

  const data = {
    username: username,
    socketId: socket.id,
    time: Math.floor(Date.now() / 1000) // epoch time
  };
  socket.emit("register", data);
});
Enter fullscreen mode Exit fullscreen mode

We construct data, which includes username, socketId, and time. The time is formatted in the Epoch style. Then, we transmit this data to our server on register channel using socket.emit() method. This process is executed right after the client connected to the server.

Our client also listens to register events.

socket.on("register", (data) => {
  const value = {
    socketId: data.socketId,
    time: data.time
  };
  users.set(data.username, value);

  console.log(`New user added ${data.username} with socket id ${data.socketId}`);
});
Enter fullscreen mode Exit fullscreen mode

Similar to our server, we save the data being sent through register channel in users. In this case, clients only store data from other clients.

The whole server.js will look something like this

const { Server } = require("socket.io");

const io = new Server();
const users = new Map();

io.on("connection", (socket) => {
  console.log(`user ${socket.id} is connected`);

  socket.on("disconnect", () => {
    console.log(`user ${socket.id} is disconnected`);
  });

  socket.on("register", (data) => {
    const value = {
      socketId: data.socketId,
      time: data.time
    };
    users.set(data.username, value);
    socket.broadcast.emit("register", data);
  });
});

io.listen(8080);
Enter fullscreen mode Exit fullscreen mode

While client.js looks like this

const { io } = require("socket.io-client");
const readlinePromises = require('node:readline/promises');
const rl = readlinePromises.createInterface({
  input: process.stdin,
  output: process.stdout,
});

const domain = "localhost";
const port = 8080;

const url = "http://" + domain + ":" + port.toString();
const socket = io(url);
const users = new Map();

let username;

socket.on("connect", async () => {
  console.log("Connected with id:", socket.id);

  username = await rl.question("What is your username? ");
  console.log(`Hello ${username}, welcome to Chatty!`);

  const data = {
    username: username,
    socketId: socket.id,
    time: Math.floor(Date.now() / 1000) // epoch time
  };
  socket.emit("register", data);
});

socket.on("disconnect", () => {
  console.log("Disconnected!");
  process.exit(0);
});

socket.on("register", (data) => {
  const value = {
    socketId: data.socketId,
    time: data.time
  };
  users.set(data.username, value);

  console.log(`New user added ${data.username} with socket id ${data.socketId}`);
});
Enter fullscreen mode Exit fullscreen mode

Run
Try to run node server. Then open another terminal and run node client, use alice as username.

Image description

After that, open another terminal and run node client, this time, use bob as username.

Image description

Go back to alice terminal, you will see bob data registration in the last line of console.

Image description

Okay, now we know that the user registration is working, and alice can get bob data. However, bob does not know any information about alice. He does not receive broadcast of register channel because alice register first before bob when bob still offline.

To solve this issue, we need to create another channel to retrieve user info.


4. Retrieve User Information

First, we create a channel called user-info in server.js.

socket.on("user-info", () => {
  const data = {
    keys: Array.from(users.keys()),
    values: Array.from(users.values())
  };
  socket.emit("user-info", data);
});
Enter fullscreen mode Exit fullscreen mode

The server gets all keys and values from users key-value store. Then convert them into arrays using Array.from() method. Those keys and values then will be sent to client through the user-info channel.

Second, we also create user-info in client.js.

socket.on("connect", async () => {
  console.log("Connected with id:", socket.id);
  socket.emit("user-info");
  //...
})
Enter fullscreen mode Exit fullscreen mode

Right after the client connects to the server, the client sends message through user-info channel to get updated list of users from the server.

socket.on("user-info", (data) => {
  for (let i = 0; i < data.keys.length; i++) {
    const k = data.keys[i];
    const v = data.values[i];
    const value = {
      socketId: v.socketId,
      time: v.time
    };
    users.set(k, value);
  }
});
Enter fullscreen mode Exit fullscreen mode

The client also listens to user-info events. Upon receiving such events, the client loops through all the keys and values and then store each of them in their own users data store.

In summary, the user-info channel allows the client to copy the contents of users in the server.

To check list of users stored in client's data store, we will create a command line action prompt. For example, when the client type !users in their console, the system will display list of current users.

To do so, we will use readline module.

// triggered on end-of-line input (\n, \r, or \r\n)
rl.on("line", (input) => {
  if (input == "!users") {
    console.log(users);
  }
});

// triggered on CTRL+C like command
rl.on('SIGINT', () => {
  process.exit(0);
});
Enter fullscreen mode Exit fullscreen mode

The line action will be triggered every time the user press ENTER. In this case, when user type !users and press ENTER, the system will dump users to the console.

The SIGINT is triggered when the client press CTRL+C from the console. In this case, this action will close the program.

The whole server.js will looks like this.

const { Server } = require("socket.io");

const io = new Server();
const users = new Map();

io.on("connection", (socket) => {
  console.log(`user ${socket.id} is connected`);

  socket.on("disconnect", () => {
    console.log(`user ${socket.id} is disconnected`);
  });

  socket.on("user-info", () => {
    const data = {
      keys: Array.from(users.keys()),
      values: Array.from(users.values())
    };
    socket.emit("user-info", data);
  });

  socket.on("register", (data) => {
    const value = {
      socketId: data.socketId,
      time: data.time
    };
    users.set(data.username, value);
    socket.broadcast.emit("register", data);
  });
});

io.listen(8080);
Enter fullscreen mode Exit fullscreen mode

The updated client.js is as follows.

const { io } = require("socket.io-client");
const readlinePromises = require('node:readline/promises');
const rl = readlinePromises.createInterface({
  input: process.stdin,
  output: process.stdout,
});

const domain = "localhost";
const port = 8080;

const url = "http://" + domain + ":" + port.toString();
const socket = io(url);
const users = new Map();

let username;

socket.on("connect", async () => {
  console.log("Connected with id:", socket.id);
  socket.emit("user-info");

  username = await rl.question("What is your username? ");
  console.log(`Hello ${username}, welcome to Chatty!`);

  const data = {
    username: username,
    socketId: socket.id,
    time: Math.floor(Date.now() / 1000) // epoch time
  };
  socket.emit("register", data);
});

socket.on("disconnect", () => {
  console.log("Disconnected!");
  process.exit(0);
});

socket.on("user-info", (data) => {
  for (let i = 0; i < data.keys.length; i++) {
    const k = data.keys[i];
    const v = data.values[i];
    const value = {
      socketId: v.socketId,
      time: v.time
    };
    users.set(k, value);
  }
});

socket.on("register", (data) => {
  const value = {
    socketId: data.socketId,
    time: data.time
  };
  users.set(data.username, value);
});

// triggered on end-of-line input (\n, \r, or \r\n)
rl.on("line", (input) => {
  if (input == "!users") {
    console.log(users);
  }
});

// triggered on CTRL+C like command
rl.on('SIGINT', () => {
  process.exit(0);
});
Enter fullscreen mode Exit fullscreen mode

Run

Run the following command sequentially:

  • Run the node server
  • Open new terminal and run node client. Type alice as username.
  • Open another terminal and run node client. Type bob as username.
  • Then, in both alice and bob terminal, you can type !users

Image description

Image description

You can see that now, alice has bob info, and bob has alice info.


5. Send Chat Message

In our final step, we will create a channel called chat to send and broadcast our chat messages.

Add the following code in server.js.

socket.on("chat", (data) => {
  socket.broadcast.emit("chat", data);
});
Enter fullscreen mode Exit fullscreen mode

Then, add this to the client.js.

socket.on("chat", (data) => {
  console.log(`${data.sender}: ${data.msg}`);
});

// triggered on end-of-line input (\n, \r, or \r\n)
rl.on("line", (input) => {
  if (input == "!users") {
    console.log(users);
  } else {
    const data = {
      "sender": username,
      "msg": input
    };
    socket.emit("chat", data);
  }
});
Enter fullscreen mode Exit fullscreen mode

The client listens to chat events, and then prints the sender and msg to the console.

The client can send message by typing any value and then press ENTER, this will trigger the line and then send the input to the server via chat channel.

The final server.js code is like this

const { Server } = require("socket.io");

const io = new Server();
const users = new Map();

io.on("connection", (socket) => {
  console.log(`user ${socket.id} is connected`);

  socket.on("disconnect", () => {
    console.log(`user ${socket.id} is disconnected`);
  });

  socket.on("user-info", () => {
    const data = {
      keys: Array.from(users.keys()),
      values: Array.from(users.values())
    };
    socket.emit("user-info", data);
  });

  socket.on("register", (data) => {
    const value = {
      socketId: data.socketId,
      time: data.time
    };
    users.set(data.username, value);
    socket.broadcast.emit("register", data);
  });

  socket.on("chat", (data) => {
    socket.broadcast.emit("chat", data);
  });
});

io.listen(8080);
Enter fullscreen mode Exit fullscreen mode

While, the final client.js code is like this.

const { io } = require("socket.io-client");
const readlinePromises = require('node:readline/promises');
const rl = readlinePromises.createInterface({
  input: process.stdin,
  output: process.stdout,
});

const domain = "localhost";
const port = 8080;

const url = "http://" + domain + ":" + port.toString();
const socket = io(url);
const users = new Map();

let username;

socket.on("connect", async () => {
  console.log("Connected with id:", socket.id);
  socket.emit("user-info");

  username = await rl.question("What is your username? ");
  console.log(`Hello ${username}, welcome to Chatty!`);

  const data = {
    username: username,
    socketId: socket.id,
    time: Math.floor(Date.now() / 1000) // epoch time
  };
  socket.emit("register", data);
});

socket.on("disconnect", () => {
  console.log("Disconnected!");
  process.exit(0);
});

socket.on("user-info", (data) => {
  for (let i = 0; i < data.keys.length; i++) {
    const k = data.keys[i];
    const v = data.values[i];
    const value = {
      socketId: v.socketId,
      time: v.time
    };
    users.set(k, value);
  }
});

socket.on("register", (data) => {
  const value = {
    socketId: data.socketId,
    time: data.time
  };
  users.set(data.username, value);
});

socket.on("chat", (data) => {
  console.log(`${data.sender}: ${data.msg}`);
});

// triggered on end-of-line input (\n, \r, or \r\n)
rl.on("line", (input) => {
  if (input == "!users") {
    console.log(users);
  } else {
    const data = {
      "sender": username,
      "msg": input
    };
    socket.emit("chat", data);
  }
});

// triggered on CTRL+C like command
rl.on('SIGINT', () => {
  process.exit(0);
});
Enter fullscreen mode Exit fullscreen mode

Run
Run the following commands:

  • Run the node server.
  • Open new terminal, run node client, put alice as username.
  • Open another terminal, run node client, put bob as username.
  • Open yet another terminal, run node client, put trudy as username.
  • In alice, bob, and trudy terminal, type any messages.

Image description

Image description

Image description

You can see that alice, bob, and trudy can send messages via broadcasting, like in a group chat.


That's all for this tutorial.
Best of luck!

Top comments (0)