Creating a Real time chat app with Vue, Socket.io and NodeJS - Part 2

January 6, 2023 (1y ago)

This article is a continuation of the series on creating a real-time chat application with Vue, Socket.io, and Node.js. If you haven't read Part 1 yet, it's recommended to do so first to understand the context of this article.

You can find the part 1 of this article here.

Introduction to Rooms

We introduce a new term called rooms. A room in socket.io is a random channel that sockets can join and leave. It can be used to broadcast events to a subset of clients/users. But, the concept of rooms is only available on the server(i.e. the client does not have access to the list of rooms it has joined).

What we will use the room for?

We will be using the rooms for connecting multiple users to talk to each other by joining a room made from their user id or a room for a topic(say some office event, football scores, etc) where we don't want to broadcast it to everybody, just allow the subscribed people to listen to it and receive messages for that particular event.

How will the two users communicate?

In a chat application we always have user ids for every user whether there's a channel between two users(one-to-one) or 100 users, they will always have a channel id and user id of users which would always be unique. We will use unique user id to be a contender for our room name.

Firstly, we need to fetch the user id from the client side. The user can be authenticated with the token that we saw in our previous article. So, we will extract the information on the backend from the token or JWT(whichever way you prefer) and this adds a layer of security to our application so that only authenticated users can connect.

I will be using an input on the top of my page to enter a token which will be a JWT in my case. You can have the token in your app whichever way you like, and you might also have an app in production that would have already stored the token in local storage or cookies.

Authentication token as input

Let's create the input field for getting the token. Go to App.vue file and remove all the boilerplate code and paste the following code to create a form and take token as input.

<form @submit.prevent="submitToken">
  <input type="text" placeholder="Enter token" v-model="token" />
  <button type="submit">Submit</button>
</form>

Inside the script tag of your App.vue file

import SocketioService from './services/socketio.service.js';

export default {
  name: 'App',
  components: {
  },
  data() {
    return {
      token: '',
    };
  },
  methods: {
    submitToken() {
      console.log(this.token);
      SocketioService.setupSocketConnection(this.token);
    },
  },
  beforeUnmount() {
    SocketioService.disconnect();
  }
}

Since I will be authenticating with the token and that token will be passed by an input, I will call my setupSocketConnection method on submitting the form with the token input and pick the token from the model. We will handle the backend of socketio middleware further which will verify the token.

In our socketio.service.js file, handle the token param in the setup method.

setupSocketConnection(token) {
  this.socket = io(process.env.VUE_APP_SOCKET_ENDPOINT, {
    auth: {
      token,
    },
  });
  console.log(`Connecting socket...`);
}

Following the previous article we will pass the token in auth and change the static 'cde' to the token passed in by the UI input.

Token Generation

Let's generate a random token from jwt.io. I will add a id parameter here and a myRandomHash secret to sign it. Remember that this token is only for this article explanation, in real world scenario you might already have a token in your app. JWT websiteJWT website screenshot Copy the encoded token from the left box and paste it into the input box we just made above. Token input

Handling token authentication in NodeJS

First, to validate and parse the JWT we need to install the module jsonwebtoken in our node project.

npm i jsonwebtoken --save

In our Node index.js file, modify the io middleware like this.

const jwt = require('jsonwebtoken');

// jwt secret
const JWT_SECRET = 'myRandomHash';

io.use(async (socket, next) => {
  // fetch token from handshake auth sent by FE
  const token = socket.handshake.auth.token;
  try {
    // verify jwt token and get user data
    const user = await jwt.verify(token, JWT_SECRET);
    console.log('user', user);
    // save the user data into socket object, to be used further
    socket.user = user;
    next();
  } catch (e) {
    // if token is invalid, close connection
    console.log('error', e.message);
    return next(new Error(e.message));
  }
});

Here our middleware is verifying for the JWT token and extracting the user data, if the token is invalid or expired we throw an error and don't let the code move forward. We are also storing the user info into the socket object which would make our life easy when we have to extract the user info who sent the event in future events.

Now that we have our authentication and user information ready, let's create a simple UI for the chat messages and input box.

Creating the UI

Go to App.vue file in your Vue project and paste the following code

<div class="App">
  <form @submit.prevent="submitToken">
    <input type="text" placeholder="Enter token" v-model="token" />
    <button type="submit">Submit</button>
  </form>
  <div class="box">
    <div class="messages"></div>
    <form class="input-div" @submit.prevent="submitMessage">
      <input type="text" placeholder="Type in text" 
      v-model="inputMessageText" />
      <button type="submit">Submit</button>
    </form>
  </div>
</div>

In App.css file paste the following

.App {
  padding: 1rem;
}

.box {
  width: fit-content;
  height: 400px;
  border: solid 1px #000;
  display: flex;
  flex-direction: column;
  margin-top: 1rem;
}

.messages {
  flex-grow: 1;
}

.input-div {
  display: flex;
  width: 100%;
}

This code adds a box with a border which would be the chat box that shows the messages and an input container at the bottom for writing your message and sending it to the server.

Our page would like this with a box. Don't mind the UI, you can customize it in any way like.

Sending a message

To send a message we need to add an onSubmit on our message form and fetch the current message written in the input field. Let's do the handling in App.vue file inside the script.

import SocketioService from './services/socketio.service.js';

export default {
  name: 'App',
  components: {
  },
  data() {
    return {
      token: '',
      inputMessageText: ''
    };
  },
  methods: {
    submitToken() {
      console.log(this.token);
      SocketioService.setupSocketConnection(this.token);
    },
    submitMessage() {
      const CHAT_ROOM = "myRandomChatRoomId";
      const message = this.inputMessageText;
      SocketioService.sendMessage({message, roomName: CHAT_ROOM}, cb => {
        console.log(cb);
      });
    }
  },
  beforeUnmount() {
    SocketioService.disconnect();
  }
}

In socketio.service file

// Handle message receive event
subscribeToMessages(cb) {
  if (!this.socket) return(true);
  this.socket.on('message', msg => {
    console.log('Room event received!');
    return cb(null, msg);
  });
}
  
sendMessage({message, roomName}, cb) {
  if (this.socket) this.socket.emit('message', { message, roomName }, cb);
}

The method submitMessage takes the value from message input and calls a sendMessage method on the socket service which emits the message to the server along with a callback which is forwarded to the server and used for acknowledgment by the server and the last param is the CHAT_ROOM id (declared on the top as a constant since this is just a demo) which would be used on the server to identify the room. Why? Let's understand.

User wants to communicate to another user or group of users

We have 2 users, one wants to send a message to another user, and they both would be in a channel which will be a unique identifier, the same goes for group chat. So, when we send the message event to the server, the server would have to pass it on to the other users. How will the server know that the message is sent to which channel?

Purpose of CHANNEL ID

CHANNEL ID is the identifier that helps the server identify the channel. But, What will the server use it for? Now that we have the channel, the server has to send this message to each user in the channel, it can be 1 or 100.

So, to make it easy to publish all the events to those users, we will find all the participating channels of a connected user upon the io.on("connection") event and make the user socket instance join all those channels in loop. So, this eases the process of sending the message event to that channel id/room containing the relevant users without any overhead.

Let's do that on the server side

io.on("connection", (socket) => {
  // join user's own room
  socket.join(socket.user.id);
  console.log("a user connected");
  socket.on("disconnect", () => {
    console.log("user disconnected");
  });
  socket.on("my message", (msg) => {
    console.log("message: " + msg);
    io.emit("my broadcast", `server: ${msg}`);
  });

  socket.on("join", (roomName) => {
    console.log("join: " + roomName);
    socket.join(roomName);
  });

  socket.on("message", ({ message, roomName }) => {
    console.log("message: " + message + " in " + roomName);
    // send socket to all in room except sender
    socket.to(roomName).emit("message", message);
    callback({
      status: "ok"
    });
    // send to all including sender
    // io.to(roomName).emit("message", message);
  });
});

For demo purpose as I don't have a database connected so, I will make the socket join the static channel ID we defined above in Vue myRandomChatRoomId and do this under the socket.join in io.connection

socket.join('myRandomChatRoomId');

This way all the users who connect will join a common channel which would trigger events for all the users if someone sends a message.

Similarly, you can also call socket.leave(roomName) to remove a user from the room.

Let's try this out opening two different tabs and generating two jwt's (I changed the name and id parameter in jwt.io website to create two different users) which impersonate two different users and sending a message from one to see it being received by another. So, we receive the message here on one user and not on the sender as I have already explained above the way we are sending it to all in room except the sender.

Congratulations, we have a running chat application. We just need to handle the message in the UI to append it in the box above our message input box.

Appending the incoming message

But first, we need to attach some information to the incoming message for the receiver to show names and differentiate between user ids. Let's see how can we do it.

On the backend in index.js file

socket.on('message', ({message, roomName}, callback) => {
  console.log("message: " + message + " in " + roomName);

  // generate data to send to receivers
  const outgoingMessage = {
    name: socket.user.name,
    id: socket.user.id,
    message,
  };
  // send socket to all in room except sender
  socket.to(roomName).emit("message", outgoingMessage);
  callback({
    status: "ok"
  });
  // send to all including sender
  // io.to(roomName).emit("message", message);
});

We append user data keys into the message sent to receivers so that it's easy for the to append it in the UI and perform actions based on user id.

Let's append the same into the box now.

In App.vue file, let's handle the state for messages and append them, first handle the HTML

<div class="box">
  <div class="messages">
    <div v-for="user in messages" :key="user.id">
      {{user.name}}: {{user.message}}
    </div>
  </div>
  <div class="messages"></div>
  <form class="input-div" @submit.prevent="submitMessage">
    <input type="text" placeholder="Type in text" 
    v-model="inputMessageText" />
    <button type="submit">Submit</button>
  </form>
</div>

And now the logical part, we update the following methods to handle the incoming message and also we clear the input on the sender and append the sender's message too into the box.

<script>
  import SocketioService from "./services/socketio.service.js";

  // static data only for demo purposes, in real world scenario, this would be already stored on client
  const SENDER = {
    id: "123",
    name: "John Doe",
  };

  export default {
    name: "App",
    components: {},
    data() {
      return {
        token: "",
        inputMessageText: "",
        messages: [],
      };
    },
    methods: {
      submitToken() {
        console.log(this.token);
        SocketioService.setupSocketConnection(this.token);
        SocketioService.subscribeToMessages((err, data) => {
          console.log(data);
          this.messages.push(data);
        });
      },
      submitMessage() {
        const CHAT_ROOM = "myRandomChatRoomId";
        const message = this.inputMessageText;
        SocketioService.sendMessage({ message, roomName: CHAT_ROOM }, (cb) => {
          // callback is acknowledgement from server
          console.log(cb);
          this.messages.push({
            message,
            ...SENDER,
          });
          // clear the input after the message is sent
          this.inputMessageText = "";
        });
      },
    },
    beforeUnmount() {
      SocketioService.disconnect();
    },
  };
</script>

There we go, we have the incoming message in the chat box and the same way the sender would have his message appended in the chat box.

BONUS: Custom topics

We can also use the rooms for custom topics that a group of users want to subscribe on, let's say football scores or product launches. Since they would be joined by a limited number of users, we can't use *broadcast *here.

We can achieve this by sending an event to the server that the client wants to join this room, or any other way the backend wants to do it since rooms can only be joined and left at the server side. For example, we can create a listener on the backend like

socket.on("join", (roomName) => {
  console.log("join: " + roomName);
  socket.join(roomName);
});

And emit the join event with a *roomName *from the client.

export const joinRoom = (roomName) => {
  socket.emit("join", roomName);
};

And, call the joinRoom inside our App.vue with a roomName specified.


You can check all of the above written example code at my github.

This concludes my article about collaborating multiple users with the concept of Rooms and socket.io . I have covered every aspect from authentication to rooms and sending messages to others. We will talk about adding a database(Firebase or MongoDB), scaling the server to multiple instances by using Socket.io Redis Adapter in the future articles. All of this information cannot be covered in a single article.


Liked my work. Buy me a coffee.

Do write down your reviews or send in a mail from the contact form if you have any doubts and do remember to subscribe for more content like this.