Building a chat app: Chatrooms with Nodejs Websockets and Vue (PART 1)

Hssan Bouzlima - Mar 4 - - Dev Community

Hello! This is the first post in the series. Here is my app, where I share my apps. I'm going to share with you How I made a chat application from scratch. Chat rooms ! Let's dive into it.

chat rooms

The main features of this application are:

  • Creating an account
  • Creating a room
  • Joining a room
  • Chatting in public rooms
  • Chatting in private room
  • Search connected users
  • Get typing status
  • Update profile (full name and avatar)
  • Destroy a room

Unfortunately, I can't discuss all the code, I will focus on some areas. If you find something unclear or missing feel free to comment. All the code is available on Github.

In this first part we will discuss backend side, next part will be dedicated to frontend side.

Table of contents

Project structure
Local environment
Some features
Scalability
Logging
Deployment


Project structure:

For easy maintainability, I choose to split my folders/files as follows:

Folder/File Role
index.js Entrypoint
Server.js Server setup (DB connect, routes, middlewares)
Config Contains distinct environment variables related to each environment
Logger Setup logging library
Middlewares Functions used in express, can either end the request or call next middleware
Controllers Similar to middlewares, except they can only end a request. With that being said, they exist either in the end of express router or they used uniquely in a route
Routes Different express routers
Listeners Socket listeners
Utils Isolated functions not related to req/res cycle
Database Database instance: Mongodb
Models Representing our DB documents. An instance of a model is a document
Dockerfile App image
Docker-compose Run multi container app (Nodejs,Mongo,redis)

Local environment:

I configured an env file for each environment, development.js is used by default.



const env = process.env.NODE_ENV || "development";
if (env === "development") {
  envConfig = require("./development");
} else {
  envConfig = require("./production");
}


Enter fullscreen mode Exit fullscreen mode

To set up a dev environment, I used Docker and docker-compose to run different dependencies. When using nodemon with Docker, don't forget to add --legacy--watch to be able to modify your code and restart the server automatically. With this setup, you need only Docker installed on your machine to be able to run this app locally! Docker magic.

DEV environment


Dev architecture

Why Redis? Figure out the scalability section


Features

In this part, we will talk about the Auth strategy, chat, and how we manage assets.

Auth strategy

To be able to connect, you need to create a user account, where you specify your unique username and full name. Once you create an account, you can join a room.

Create a room

You are allowed to create a room only if you have a user account. Once this condition is met, you specify your username as well as a room name in a post request. If it succeeds, a room code (UUID) is generated for you and can be shared with whoever you want to join you in a room.



module.exports.createRoom = async (req, res) => {
  const { roomName, userName } = req.body;
  const roomCode = uuid.v4();
  const createdBy = await user.findOne({ userName });
  if (createdBy && roomCode) {
    const doc = await chatRoom.create({ roomName, roomCode, createdBy });
    if (doc) res.status(201).send(roomCode);
    else res.status(404).send("Error creating a room");
  } else res.status(404).send("Error creating a room");
};



Enter fullscreen mode Exit fullscreen mode

In that condition, you are considered an admin, so you can destroy that room anytime you want, and all messages shared in that room are going to be deleted. This is done through mongoose post hook



chatRoomSchema.post("findOneAndDelete", async (doc) => {
  if (doc._id) {
    const roomId = doc._id;
    await messageShema.deleteMany({ receiver: roomId });
  }
});


Enter fullscreen mode Exit fullscreen mode

Join a room

Given that you have a room code, you send a post request specifying your username and room code. The server checks this information, if it is valid, a cookie session is created, and consequently, you are allowed to chat.
A cookie session is created with JWT containing a combination of user and room information.

Join a room


Join room req/res

Therefore, further requests will automatically send that cookie to the server. A special middleware protect.middleware.js checks the validity of the cookie. If it is valid, this middleware will attach the decoded token (user and room information) to the request.

Cookie

Cookies are characterized by many attributes.
In our case, since we want to use a session cookie, we do not specify the expires attribute. Secure attribute to send only the cookie over HTTPS, and httpOnly to prevent access to the cookie from JavaScript. SameSite is none since our API and server are completely in different domains.



const options= { httpOnly:true, secure: true, sameSite: "none"};


Enter fullscreen mode Exit fullscreen mode

With this configuration, our cookie is vulnerable to CSRF where the server can't distinguish whether the request was made intentionally or not. To mitigate that, we used Double submit cookie pattern. In addition to sending a session cookie, the server also sends a CSRF token cookie with a UUID value.



const csrfToken = signToken(uuid.v4(), xsrfSecret);
      res.cookie(xsrfTokenName, csrfToken, {
        sameSite: "none",
        secure: true,
        httpOnly:false
      });


Enter fullscreen mode Exit fullscreen mode

For further requests, that token will be attached to the header req.headers["x-xsrf-token"] and the server checks if that token is valid or not, along the session cookie.



const protect = (req, res, next) => {
  const authToken = req.cookies[authTokenName];
  const csrfToken = req.headers["x-xsrf-token"];
  if (authToken && csrfToken) {
    const userInfo = verifyToken(authToken, authTokenKey);
    const isValid = verifyToken(csrfToken, xsrfSecret);
    if (userInfo && isValid) {
      req.userInfo = userInfo;
      next();
    } else res.status(401).end("You are not authorized");
  } else res.status(401).end("You are not authorized");
};


Enter fullscreen mode Exit fullscreen mode

Chat

The core of the chat part is the socket.io module. We set up our socket server in a separate folder called listeners. Where we specified socket middleware, chat-room, private room, utils and created the websocket server as well.
Chat is based on the room concept of socket-io. Where multiple sockets belong to the same room, receive/emit events related to that room. 
Join a room: socket.join('some room')
In our app and for public rooms, the room is represented by its unique code which is generated at the moment it is created. Private rooms are technically similar to public rooms, except, they hold only two sockets, and the room identifier is generated from the frontend when two users of the same public room decide to chat.

listeners/index.js

This is the socket entrypoint where the server is set up, and various listeners are called. The setup is done through a function that will be called in server.js when we get our express server instance.



// listeners/index.js: setup function
module.exports = async(server)=> {
const io = new Server(server, {
    cors: {
      origin: envConfig.originUrl,
      credentials: true,
    },
  });
  io.use(socketMiddleware);
}


Enter fullscreen mode Exit fullscreen mode

socket middleware

It is triggered only for the first socket connection. This middleware ensures that a user is authorized to chat. A user is considered authorized if a socket contains a valid cookie header (created while joining a room). Once it is validated, this middleware attaches user data to the socket (It will be used later).



//socket.middleware.js

  module.exports = (socket, next) => {
  const cookie = socket.handshake.headers.cookie;
  if (cookie) {
    const authToken = cookie
      .split(";")
      .find((v) => v.includes(`${authTokenName}=`))
      ?.split(`${authTokenName}=`)[1];
    const userInfo = authToken
      ? verifyToken(authToken, authTokenKey)
      : undefined;
    if (userInfo) {
      socket.data = userInfo;
      next();
    } else {
      socket.disconnect();
      next(new Error("Invalid"));
    }
  } else {
    socket.disconnect();
    next(new Error("Invalid"));
  }
};


Enter fullscreen mode Exit fullscreen mode

register event listeners

After surpassing the middleware, different listeners get registered to manage emitting/receiving socket events. socket.room-handler.js for events related to the public room, and socket.private-habdler.js for events related to the private room. Automatically, a user joins a public room.



// listeners/index.js: inside setup function
io.on("connection", async (socket) => {
    try {
// emitters and listeners of public room
      await registerRoomHandler(io, socket); 
// emitters and listeners of private room
      await registerPrivateHandler(io, socket);
    } catch (err) {
      logger.error(err);
    }
  });


Enter fullscreen mode Exit fullscreen mode

To join a private room, where a user can chat privately with an individual user belonging to the same room, a special event must be triggered.



// inside socket.private-handler
socket.on("user-private:join", joinPrivate); 


Enter fullscreen mode Exit fullscreen mode

This event must contain a unique chat room name generated from the frontend.



// inside socket.private-handler
const joinPrivate = (privateChatName) => {
    socket.join(privateChatName);
  };



Enter fullscreen mode Exit fullscreen mode

connected users

One of the features is the ability to see connected users in the same room. socket-io provides us with a special function, fetchSockets. This method returns all connected users to the same room.



    const sockets = await io.in(roomCode).fetchSockets();


Enter fullscreen mode Exit fullscreen mode

However, in our app, a user is allowed to connect from multiple devices, so a connected user can have multiple sockets at the same time. Then, the result returned from fetchSockets needs to be filtered out according to user data attached to the socket (this data is added to the socket middleware in the first connection).



const uniqueSockets = sockets
      .filter((socket, index) => sockets.findIndex(({ data }) => 
       data.userId==socket.data.userId)==index)
      .map((sockets) => sockets.data); 



Enter fullscreen mode Exit fullscreen mode

Asset management

Users in the app can add an avatar image to be displayed in the UI. To handle multipart-form data, multer middleware is used.



const multer = require("multer");
const storage = multer.memoryStorage();
const upload = multer({ storage });
module.exports = upload;


Enter fullscreen mode Exit fullscreen mode

Then, cloudinary middleware handles that file, uploads it, and then we store the related avatar URL inside the database.



module.exports.saveAvatar = async (req, res, next) => {
  if (req.file) {
    const b64 = Buffer.from(req.file.buffer).toString("base64");
    let dataURI = "data:" + req.file.mimetype + ";base64," + b64;
    const response = await cloudinary.uploader.upload(dataURI, {
      resource_type: "auto",
    });
    if (response.secure_url) {
      req.avatar = response.secure_url;
      next();
    } else res.satus(501).send("Error while saving new avatar");
  } else next();
};


Enter fullscreen mode Exit fullscreen mode

Then, we store that URL in our database.
The full route of the process become:



router.put(
  "/update",
  protect,
  upload.single("avatar"),
  asyncErrorHandler(saveAvatar),
  asyncErrorHandler(updateUser)
);


Enter fullscreen mode Exit fullscreen mode

Scalability

With current implementation, the moment we need to scale horizontally, we may face a huge problem. Two users in the same room may connect to unrelated websocket servers. Therefore, the server can't notice any events sent/received from one user to another.
Socket-io provides us with a ready-to-use component called adapter which is responsible for broadcasting any event to all listening clients.
In this app, we used redis-adapter, and we set up a Redis server locally with a Docker image.

scale socket


Scaling sockets

Logging

To keep track of logging events, we used winston logger which is a flexible library with a lot of configurations options.
In our chat app, it is configured to use different methods in different environments. In dev, logs are kept in the logs folder, while in prod, logs are made with the console.

Dev logs

Dev logging

Prod logs

Prod logging


Deployment

One of my favorite free cloud providers is render. I deployed our Node.js server there. For the database, I used mongoDB atlas and for the Redis server, I used redis lab.
Since our app is deployed under a public domain render and, for security reasons, CSRF token can not be read in the frontend the only solution is to create a custom domain in production. Do you know a workaround? Another strategy in this case to mitigate CSRF? Let me know.


And we are ready🔥

Our API are all set !
These were the most important parts, I guess, related to the development of the backend side of this application. Do not hesitate to inquire for additional information, offer suggestions or clarify any uncertainties.

The project is available on Github.
I would appreciate it if you could star the repository 😁

. . . . . . . . . . . .