DEV Community

Sasan Dehghanian
Sasan Dehghanian

Posted on

Implementation of Real-Time Chatroom system using NodeJS, Socket-io, mongoDB

"Socket.IO is a library that enables real-time, bidirectional and event-based communication between the browser and the server". Essentially, socket.io allows realtime communication between applications instantly. It works by allowing apps to emit events to other apps, and the apps receiving the events can handle them the way they like. It also provides namespacing and chatrooms to segregate traffic. One of the best uses of WebSockets and Socket.io is in a real-time chat app.
In this article, we will build a real-time chat room system from scratch. We will not talk about the frontend (client-side), as a result, we use a pre-prepared React project for the frontend, and Express (Node.js) for the backend. The Socket.io server will be used on the backend, and the authorization will be provided by MongoDB’s database and Mongoose package. So in this blog, I will try to explain the basics behind how a chat room works, but If you need the CSS(styling part)and React file please feel free to check my GitHub because I’ll put the link of my GitHub repo.

I hope you’re excited!
This is a preview of what we’ll building
Project review

Pre-requisites:
Basic knowledge of Javascript, MongoDB, Express, React is required. I assume that you have npm and node installed and knows how they worked (at least the basics).
So Let’s get started.
The first step is to create an index.js file in the server side root and write the following code on your terminal/command line window:
npm i express socket.io mongoose cors

Once it’s done, You can require modules and running the server by using the following codes:

const express = require('express');
const app = express(); 
const http = require('http').createServer(app);
const mongoose = require('mongoose');
const socketio = require('socket.io');
const io = socketio(http);  
const mongoDB = "Your MongoDB Connection Address";

const PORT = process.env.PORT || 5000;
app.use(express.json());  //it help us to send our data to the client side
mongoose.connect(mongoDB, 
{useNewUrlParser: true, useUnifiedTopology: true })
.then(() => console.log('connected'))
.catch(err => console.log(err))


http.listen(PORT, () => {
            console.log(`listening on port ${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

Before we continue I think there exist some tips that you should know them:

The CORS Errors:
I believe everybody is struggling with CORS errors. Solving these errors is no more challenging, by setting up CORS configuration and applying the following codes;

const cors = require('cors');
const corsOptions = {
            origin: 'http://localhost:3000', // your frontend server address
            credentials: true,
            optionsSuccessStatus: 200 
}   
app.use(cors(corsOptions));

Enter fullscreen mode Exit fullscreen mode

However, if you have a CORS error in connecting to Socket-io, the io should be configured as follows;

const io = socketio(http,{
            cors: {
            origin: "http://localhost:3000", // your frontend server address
            methods: ["GET", "POST"]
            }
});

Enter fullscreen mode Exit fullscreen mode

Creating of MongoDB models:
We have three models as Message.js, Room.js, and User.js. Each model has a specific configuration. Room.js saves just the room’s name, though, User.js stores the name, e-mail, and password of users for authentication. Message.js stores name, user_id, room_id, text, and timeStamps fields, which helps us reach information about the sender of each text. Because there are no differences in building these models, I help you in creating the User.js model. It is worth mentioning that you can see two other models in my GitHub.
Let's dive into creating User.js model
In this model, the input fields must be validated by installing a validator package in the terminal, and the passwords should be hashed by installing a bcrypt package.
We also use a pre-save hook in this model to hash the passwords before storing them on the database. Pre is a middleware defined on the schema level and can modify the query or the document itself as it is executed. A Pre-save hook is a middleware that is executed when a document is saved.

const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const { isEmail } = require('validator');
const userSchema = new mongoose.Schema({
    name: {
        type: String,
        required: [true, 'Please enter a name']
    },
    email: {
        type: String,
        required: [true, 'Please enter a email'],
        unique: true,
        lowercase: true,
        validate: [isEmail, 'Please enter a valid email address']
    },
    password: {
        type: String,
        required: [true, 'Please enter a password'],
        minlength: [6, 'The password should be at least 6 characters long']
    },
})
userSchema.pre('save', async function (next) {
    const salt = await bcrypt.genSalt();
    this.password = await bcrypt.hash(this.password, salt);
    next()
})
const User = mongoose.model('user', userSchema);
module.exports = User;
Enter fullscreen mode Exit fullscreen mode

Implementation of routing:
Routing defines how the client requests are handled by the application endpoints. There are two methods for implementing routes: by using a framework and without using a framework. In this project, we use an express framework.
After creating database models we need to implement essential routes, namely /signup, /login, /logout, and /verifyuser. We use verifyuser route to investigate authorization on the client-side in order to guide the user, who has not logged in yet, to the login route, and prevent their access to the chats.
First, we need to create a routes folder in the server side’s root and make a file in this folder, and name it authRoute.js, and then write the below codes:

const { Router } = require('express');
const authController = require('../controllers/authControllers');
const router = Router();
router.post('/signup', authController.signup)
router.post('/login', authController.login)
router.get('/logout', authController.logout)
router.get('/verifyuser',authController.verifyuser)

module.exports = router;

Enter fullscreen mode Exit fullscreen mode

Then, for using authRoute.js file you should add this short code into your index.js file

const authRoutes = require('./routes/authRoutes');
app.use(authRoutes);

Enter fullscreen mode Exit fullscreen mode

Creating controller file:
First, we need to register our users, for this, we use the input data and save them in the database (As we use the pre-save hook for our passwords there is no need for hashing them here). Then, with the help of the jsonwebtoken package, we build a token and save it as a cookie (For creating the token we build a function and name it createJWT). Finally, we return the built user to the client-side through json command.
Obviously, for reading the cookies it is necessary to install the cookie-parser package, and use it as follows in your index.js file:

const cookieParser = require('cookie-parser');
app.use(cookieParser());
Enter fullscreen mode Exit fullscreen mode

As you may already know, for writing a code we need to create a folder named controllers in the server side’s root and make a file in this folder and name it authController.js, and then write the below codes:

const User = require('../models/User');
const jwt = require('jsonwebtoken');
const maxAge = 24 * 60 * 60   // equal one day in second
const createJWT = id => {
    return jwt.sign({ id }, 'chatroom secret', {
        expiresIn: maxAge
    })
}
Enter fullscreen mode Exit fullscreen mode

• 'chatroom secret' we use it for decoding the token

Signup function:

module.exports.signup = async (req, res) => {
    const { name, email, password } = req.body;
    try {
        const user = await User.create({ name, email, password });
        const token = createJWT(user._id);
      // create a cookie name as jwt and contain token and expire after 1 day
      // in cookies, expiration date calculate by milisecond
        res.cookie('jwt', token, { httpOnly: true, maxAge: maxAge * 1000 })
        res.status(201).json({ user });
    } catch (error) {
        let errors = alertError(error);
        res.status(400).json({ errors });
    }

}
Enter fullscreen mode Exit fullscreen mode

Login function:
Although mongoose enjoys the create methods, which we use it to create a user in the signup function, it has not login method and we should set it manually at the end of the user.js model by using the following codes:

userSchema.statics.login = async function (email, password){
    const user = await this.findOne({email});
    if(user){
        const isAuthenticated = await bcrypt.compare(password,user.password);
        if(isAuthenticated){
            return user;
        }else{
            throw Error('Incorrect password');
        }
    }else{
        throw Error('Incorrect email');
    }
}
Enter fullscreen mode Exit fullscreen mode

This method needs users' email and passwords. If the person’s information is available in the database, it returns this information else it returns an error. In the case of returning the user information, with the use of the createJWT function we create a cookie. Finally, returning the user information or the error to the client-side.

module.exports.login = async (req, res) => {
    const { email, password } = req.body;
    try {
        const user = await User.login(email, password );
        const token = createJWT(user._id);
        res.cookie('jwt', token, { httpOnly: true, maxAge: maxAge * 1000 })
        res.status(201).json({ user });
    } catch (error) {
        let errors = alertError(error);
        res.status(400).json({ errors });
    }
}
Enter fullscreen mode Exit fullscreen mode

Logout function:
Now, we should build an empty alternative cookie that expires after 1ms. After that, the {logout:true} should be sent to the client-side

module.exports.logout = (req, res) => {
    res.cookie('jwt',"",{maxAge:1});
    res.status(200).json({logout: true});
}
Enter fullscreen mode Exit fullscreen mode

Verifyuser function:
On the client side, we use this function to check the users' logging. Doing this checking is possible by decoding the JWT cookie and checking the existence of the user in our database. Decoding the token should be done by verify method on the jsonwebtoken package. If the user has already logged in we return the user information to the client-side.

module.exports.verifyuser = (req, res, next)=>{
    const token = req.cookies.jwt;
    if(token){
        jwt.verify(token,'chatroom secret',async (err,decodedToken)=>{
            if(err){
                console.log(err.message);
            }else{
                let user = await User.findById(decodedToken.id);
                res.json(user);
                next();
            }
        })
    }else{
        next();
    }
}
Enter fullscreen mode Exit fullscreen mode

let’s start working on the socket.io logic:
Now we return to index.js to start working with Socket.io, but before that, we should require our models in three variables namely Room, Message, and User.
To clean code our project, first, we should create a file named util.js in the server side root folder and then build addUser, getUser, and removeUser functions in this file. Finally, we must require these functions in the index.js file.

Util.js file
In this file, information of all users in each room will save in the users array.
In the addUser function, first, we check the existence of user information in the users array. If the user doesn’t exist in the users array, we should add it by push method to this array. In the end, this function returns the user.
In the removeUser function, we will receive the Socket id of the logged-out user, and we should look for this user’s index in the users array. Finally, by using the splice method, we remove that user from the users array.
In the getUser function, we receive the socket id, and we require the user’s information from the users array, then return it.

const users = [];
const addUser = ({ socket_id, name, user_id, room_id }) => {
    const exist = users.find(user => user.room_id === room_id && user.user_id === user_id);
    if (exist) {
        return { error: 'User already exist in this room' }
    }
    const user = { socket_id, name, user_id, room_id };
    users.push(user)
    console.log('users list', users)
    return { user }
}

const removeUser = (socket_id) => {
    const index = users.findIndex(user => user.socket_id === socket_id);
    if (index !== -1) {
        return users.splice(index, 1)[0]
    }
}
const getUser = (socket_id) => users.find(user => user.socket_id === socket_id)
module.exports = { addUser, removeUser, getUser }
Enter fullscreen mode Exit fullscreen mode

Implementing Socket on NodeJS:
We can have access to our socket by using io.on(‘connection’,(socket)=>{ … }) code, and also we can add our changes to the socket, through this code.
In the socket.io, we use code socket.emit('channel name',variable or text message to send) for sending, and code socket.on('channel name',variable to receive) for requiring information and the variables. Now, you should know how we send our rooms from the database to the client-side.
In the join channel, we receive user information from the client-side and save it in the users array by using the addUser function. After that, by using code socket.join(room_id), we can save the user in the desired room, and other users will see the person’s post on the condition that they are a member of that room. In this way, we organize our sockets.
In the channel of 'get-message-history', we receive rooms id from the client-side and require rooms chats through the message model. Then, we return the result to the client-side. As a result, the logged-in user is able to see past messages which are saved in the database.

io.on('connection', (socket) => {
    console.log(socket.id);
    Room.find().then(result => {
        socket.emit('output-rooms', result)
    })
    socket.on('create-room', name => {
        const room = new Room({ name });
        room.save().then(result => {
            io.emit('room-created', result)
        })
    })
    socket.on('join', ({ name, room_id, user_id }) => {
        const { error, user } = addUser({
            socket_id: socket.id,
            name,
            room_id,
            user_id
        })
        socket.join(room_id);
        if (error) {
            console.log('join error', error)
        } else {
            console.log('join user', user)
        }
    })
    socket.on('sendMessage', (message, room_id, callback) => {
        const user = getUser(socket.id);
        const msgToStore = {
            name: user.name,
            user_id: user.user_id,
            room_id,
            text: message
        }
        console.log('message', msgToStore)
        const msg = new Message(msgToStore);
        msg.save().then(result => {
            io.to(room_id).emit('message', result);
            callback()
        })

    })
    socket.on('get-messages-history', room_id => {
        Message.find({ room_id }).then(result => {
            socket.emit('output-messages', result)
        })
    })
    socket.on('disconnect', () => {
        const user = removeUser(socket.id);
    })
});
Enter fullscreen mode Exit fullscreen mode

Finally, I hope you all liked this article, and if you have any questions, you can put them in the comment section. I’ll get back as soon as I can. Thanks again for your time. Wish you all the best in your future endeavors.

Sincerely,
Sasan Dehghanian

Discussion (0)