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.
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");
}
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.
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");
};
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 });
}
});
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.
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"};
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
});
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");
};
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);
}
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"));
}
};
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);
}
});
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);
This event must contain a unique chat room name generated from the frontend.
// 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.
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).
const uniqueSockets = sockets
.filter((socket, index) => sockets.findIndex(({ data }) =>
data.userId==socket.data.userId)==index)
.map((sockets) => sockets.data);
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;
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();
};
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)
);
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
.
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.
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 π
Top comments (0)