Building a chat app: Chatrooms with Nodejs Websockets and Vue (PART 1)
Tags: node, webdev, docker, javascript
Published: 2024-03-04 07:57:43

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/55hap0s8y4k8ncjfpydf.gif)

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](https://github.com/hssanbzlm/chat-rooms-back).

In this first part we will discuss backend side, next part will be dedicated to frontend side. 
# Table of contents
[Project structure](#project-structure)
[Local environment](#local-environment)
[Some features](#features)
[Scalability](#scalability)
[Logging](#logging)
[Deployment](#deployment)


<br>
*********

## 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)|


<br>
*********

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

```js
const env = process.env.NODE_ENV || "development";
if (env === "development") {
  envConfig = require("./development");
} else {
  envConfig = require("./production");
}
```
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](https://github.com/remy/nodemon#application-isnt-restarting) 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ohqejyghkeze9w6194fj.jpeg)
  <figcaption>Dev architecture</figcaption>


Why Redis? Figure out the [scalability](#scalability) section

<br>
*********
## 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.

```js
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");
};

```
 

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`

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

#### 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/2o16h2ytyjdg5czag1yx.jpeg)
  <figcaption>Join room req/res</figcaption>

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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies).
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.

```js
const options= { httpOnly:true, secure: true, sameSite: "none"};
```
With this configuration, our cookie is vulnerable to [CSRF](https://owasp.org/www-community/attacks/csrf) where the server can't distinguish whether the request was made intentionally or not. To mitigate that, we used [Double submit cookie pattern](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#alternative-using-a-double-submit-cookie-pattern). In addition to sending a session cookie, the server also sends a `CSRF token cookie` with a `UUID` value. 

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

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.

```js
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");
};
```
<br>
*********
### Chat 

The core of the chat part is the [socket.io module](https://socket.io/). 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](https://socket.io/docs/v4/rooms/) 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. 

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

#### 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).

```js
//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"));
  }
};
```
#### 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. 

```js
// 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);
    }
  });
```
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.

```js
// inside socket.private-handler
socket.on("user-private:join", joinPrivate); 
```
This event must contain a unique chat room name generated from the frontend.

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

```

#### 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.
   
```js
    const sockets = await io.in(roomCode).fetchSockets();
```
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).
 
```js
const uniqueSockets = sockets
      .filter((socket, index) => sockets.findIndex(({ data }) => 
       data.userId==socket.data.userId)==index)
      .map((sockets) => sockets.data); 
        
```
<br>
*********
### Asset management
Users in the app can add an avatar image to be displayed in the UI. To handle multipart-form data, [multer middleware](https://www.npmjs.com/package/multer) is used. 

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

Then, [cloudinary](https://www.npmjs.com/package/cloudinary) middleware handles that file, uploads it, and then we store the related avatar URL inside the database.

```js
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();
};
```
Then, we store that URL in our database.
The full route of the process become: 

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

<br>
*********

## 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](https://socket.io/docs/v4/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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/y4p9yfecfjgn9o25wjoo.jpeg)
  <figcaption>Scaling sockets</figcaption>


## Logging
To keep track of logging events, we used [winston logger](https://www.npmjs.com/package/winston) 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5n5rpj7a77fbaluj11if.JPG)

  <figcaption>Dev logging</figcaption>



![Prod logs](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/76xx3dbx8c4vxesfyfim.JPG) 

  <figcaption>Prod logging</figcaption>

<br>
*********
## Deployment
One of my favorite free cloud providers is [render](https://render.com/). I deployed our Node.js server there. For the database, I used [mongoDB atlas](https://www.mongodb.com/atlas/database) and for the Redis server, I used [redis lab](https://redis.com/).
Since our app is deployed under a public domain _render_ and, for security reasons, CSRF token [can not be read in the frontend](https://community.render.com/t/third-party-cookies-must-be-enabled-to-set-cookie-on-the-browser/11597/3) 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.
<br>
*********
## 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](https://github.com/hssanbzlm/chat-rooms-back).
 I would appreciate it if you could star the repository 😁