How I Built a Command-Line Chat Application: The Server Code Explained
Published: 2023-06-08 21:05:28

---
title: How I Built a Command-Line Chat Application: The Server Code Explained
series: How I Built a Command-Line Chat Application
published: true
date: 2023-06-07 22:14:38 UTC
tags: 
canonical_url: https://hayatscodes.hashnode.dev/how-i-built-a-command-line-chat-application-the-server-code-explained
---

<!-- TOC start (generated with https://github.com/derlin/bitdowntoc) -->

- [Database Schema](#database-schema)
    + [User and Chat Room Models](#user-and-chat-room-models)
- [API Routes](#api-routes)
    + [Authentication Route Handlers](#authentication-route-handlers)
    + [Authentication Router](#authentication-router)
    + [Chat Room Route Handlers](#chat-room-route-handlers)
    + [Chat Room Router](#chat-room-router)
- [Configuration and Utility](#configuration-and-utility)
    + [MongoDB Configuration](#mongodb-configuration)
    + [Redis Setup](#redis-setup)
- [The Express App, Socket Server, and HTTP Server](#the-express-app-socket-server-and-http-server)
    + [The Express App](#the-express-app)
    + [The Socket Server Instance](#the-socket-server-instance)
    + [HTTP Server](#http-server)

<!-- TOC end -->

Building a command-line chat application involves several components working seamlessly together. In this article, we'll dive into the server-side code that powers the application. We'll explore the database schema, API routes for authentication, chat room routes, configuration, and utility files, as well as the Express app, socket server, and HTTP server setup. So, let's begin our journey through the server code and unravel the backend logic behind this command-line chat application.

<a name="database-schema"></a>
## Database Schema

Starting from the server's `/src` folder, specifically the `/models` folder, let's take a look into the database schemas of both the user and the chat room.

<a name="user-and-chat-room-models"></a>
#### User and Chat Room Models

```
// user.model.js
const mongoose = require('mongoose');

const userSchema = new mongoose.Schema({
    username: String,
    email: String,
    password: String,
  });

const User = mongoose.model('User', userSchema);

module.exports = User;


// chatRoom.model.js
const mongoose = require('mongoose');

const chatRoomSchema = new mongoose.Schema({
  roomName: {
    type: String,
    required: true,
  },
}, { timestamps: true }
);

const ChatRoom = mongoose.model('ChatRoom', chatRoomSchema);

module.exports = ChatRoom;

```

The two schemas use `mongoose` to create a valid MongoDB schema.

`user.model.js` defines a `userSchema` object with three fields: `username`, `email`, and `password`. This stores the user information in a MongoDB collection for authentication.

`chatRoom.model.js` defines a `chatRoomSchema` object with only one field: `roomName`. This allows for the addition and retrieval of chat rooms from the database.

They're both exported for use in the `/routes` folder.

<a name="api-routes"></a>
## API Routes

Moving into the `/routes` folder, it has two folders: `/auth`, which contains the authentication routes and handler functions; and `/chatRoom`, which contains the chat room route and handler functions.

<a name="authentication-route-handlers"></a>
#### Authentication Route Handlers

```
// auth.controller.js
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const User = require("../../models/user.model");
const redisClient = require('../../utils/redisClient');

// User Registration
async function registerUser(req, res) {
  try {
    const { username, email, password } = req.body;

    // Check if the username or email already exists
    const existingUser = await User.findOne().or([{ username }, { email }]);
    if (existingUser) {
      return res.status(400).json({ message: 'Username or email already exists' });
    }

    // Hash the password
    const hashedPassword = await bcrypt.hash(password, 10);

    // Create a new user
    const newUser = new User({
      username,
      email,
      password: hashedPassword,
    });

    // Save the user to the database
    await newUser.save();
    res.json({ message: 'Registration successful' });
  } catch (error) {
    console.error('Registration error', error);
    res.status(500).json({ message: 'Registration error' });
  }
}

async function loginUser(req, res) {
  try {
    const { username, password } = req.body;

    // Check if the username exists
    const user = await User.findOne({ username });
    if (!user) {
      return res.status(400).json({ message: 'Invalid username or password' });
    }

    // Compare the password
    const isPasswordValid = await bcrypt.compare(password, user.password);
    if (!isPasswordValid) {
      return res.status(400).json({ message: 'Invalid username or password' });
    }

    // Generate a JWT
    const token = jwt.sign({ userId: user._id }, process.env.SECRET_KEY);
    await redisClient.set(username, token);

    res.json({ token, message: 'Login successful' });
  } catch (error) {
    console.error('Login error', error);
    res.status(500).json({ message: 'Login error' });
  }
}

async function getToken(req, res) {
  try {
    const username = req.params.id;
    const token = await redisClient.get(username);
    if (token) {
      res.status(200).json(token);
    } else {
      res.status(400).json({ message: 'No token found' })
    }
  } catch (error) {
    res.status(500).json({ message: 'Couldn\'t get token' });
  }

}

module.exports = {
  registerUser,
  loginUser,
  getToken
}

```

`auth.controller.js` contains three handler functions: `registerUser`, `loginUser`, and `getToken`.

The `registerUser` function handles user registration. It initially compares the passed user details in the `req.body` object with all the user data stored in the database for uniqueness before proceeding to hash the user password for security. If there are no errors, the user details, including the hashed password, are saved to the database for persistence.

The `loginUser` function handles user login. It starts by checking for a valid user name being passed and then proceeds to compare the user password from the request with the user-stored hashed password in the database. With no errors, a new token is generated using the `jsonwebtoken` library. The token is then stored in a Redis database with the user's username set as the key.

The `getToken` function handles token transfers through HTTP requests. It simply gets a stored token with the user's `username` from the Redis database.

<a name="authentication-router"></a>
#### Authentication Router

```
// auth.route.js
const express = require('express');
const { registerUser, loginUser, getToken } = require('./auth.controller');

const authRouter = express.Router();

// Register API endpoint
authRouter.post('/register', registerUser);

// Login API endpoint
authRouter.post('/login', loginUser);

// Token API endpoint
authRouter.get('/tokens/:id', getToken);

module.exports = authRouter;

```

`auth.route.js` defines an `authRouter` variable, which is initialized as an Express router. The `POST` and `GET` HTTP methods are attached to the router to handle requests for the corresponding endpoints using the handler functions imported from `auth.controller.js`.

<a name="chat-room-route-handlers"></a>
#### Chat Room Route Handlers

```
// chatRoom.controller.js
const ChatRoom = require("../../models/chatRoom.model");

async function createChatRoom(req, res) {
    try {
        const { roomName } = req.body;
        const isRoomExist = await ChatRoom.findOne({ roomName });
        if (isRoomExist) {
            res.status(409).json({ message: 'Chat room already exists' });
        } else {
            const chatRoom = new ChatRoom(req.body);
            const savedChatRoom = await chatRoom.save();
            res.status(201).json(savedChatRoom.roomName);
        }
    } catch (err) {
        res.status(500).json({message: 'Chat room creation failed'});
    }
}

async function joinChatRoom(req, res) {
    try {
        const chatRooms = await ChatRoom.find({}, 'roomName');
        const roomNames = chatRooms.map((chatRoom) => chatRoom.roomName);
        res.status(200).json(roomNames);
    } catch(err) {
        res.status(500).json({message: 'couldn\'t join chat room'});
    }
}

module.exports = {
    createChatRoom,
    joinChatRoom,
}

```

Two handler functions can be seen in the above code: `createChatRoom` and `joinChatRoom`.

`createChatRoom` handles chat room creation. It starts by checking if the room in the request body exists in the database and then proceeds to save the chat room to the database if there were no errors.

`joinChatRoom` on the other hand handles user joining of chat room. It simply retrieves all the created chat rooms from the database.

<a name="chat-room-router"></a>
#### Chat Room Router

```
// chatRoom.route.js
const express = require('express');
const { createChatRoom, joinChatRoom } = require('./chatRoom.controller');

const chatRoomRouter = express.Router();

chatRoomRouter.post('/chatrooms', createChatRoom);
chatRoomRouter.get('/chatrooms', joinChatRoom);

module.exports = chatRoomRouter;

```

`chatRoom.route.js` uses the handler functions in `chatRoom.controller.js` to handle `POST` and `GET` requests for the `/chatrooms` endpoint.

<a name="configuration-and-utility"></a>
## Configuration and Utility

The `/config` and `/utils` folders contain the MongoDB and Redis connection setups, respectively.

<a name="mongodb-configuration"></a>
#### MongoDB Configuration

```
// /mongo.js
const mongoose = require('mongoose');
require('dotenv').config();

const uri = process.env.MONGO_URI

async function mongoConnect() {
    await mongoose.connect(uri, {
        useNewUrlParser: true,
        useUnifiedTopology: true,
    });
}

module.exports = mongoConnect;

```

`mongo.js`, in the `/config` folder, exports a `mongoConnect` function that uses the environmental variable `MONGO_URI` to connect to the MongoDB database.

<a name="redis-setup"></a>
#### Redis Setup

```
// /utils/redisClient.js
const { createClient } = require('redis');
let redisClient;

if (process.env.NODE_ENV === 'production') {
    // Create a Redis client with the production redis url
    redisClient = createClient({
        url: `${process.env.REDIS_URL}`
    });

} else {
    // Create a Redis client with the default port
    redisClient = createClient();
}

redisClient.on('error', err => console.log('Redis Client Error', err));

module.exports = redisClient;

```

In the `/utils` folder, `redisClient.js` checks whether the code is running in production. If it is, `redisClient` is created with an object option that includes a `url` property. The value of this property is set to `process.env.REDIS_URL`, which is provided by Railway, a code deployment service. This allows for easy connection to a running Redis database. And if the code is not being run in production, `redisClient` is configured to connect to a running Redis database on the default port `6379` of the host's machine.

<a name="the-express-app-socket-server-and-http-server"></a>
## The Express App, Socket Server, and HTTP Server

At the root of the `/server` folder, there is `app.js`, the express app configuration; `socketManager.js`, the server's socket connections manager; and `server.js`, the HTTP server setup.

<a name="the-express-app"></a>
#### The Express App

```
// app.js
const express = require('express');
const authRouter = require('./src/routes/auth/auth.route');
const chatRoomRouter = require('./src/routes/chatRoom/chatRoom.route');

const app = express();
app.use(express.json());

app.use('/auth', authRouter);
app.use('/api', chatRoomRouter);

module.exports = app;

```

`app.js` exports an express `app` variable that uses both the `authRouter` and `chatRoomRouter` to handle requests for the `/auth` and `/api` endpoints.

<a name="the-socket-server-instance"></a>
#### The Socket Server Instance

```
// socketManager.js
const jwt = require('jsonwebtoken');
const User = require('./src/models/user.model');

module.exports = (io) => {
    // Authentication middleware
    io.use(async (socket, next) => {
        try {
            const token = socket.handshake.auth.token;

            // Verify and decode the JWT
            const decoded = jwt.verify(token, process.env.SECRET_KEY);

            // Get the user information from the database
            const user = await User.findById(decoded.userId);
            if (!user) {
                throw new Error('User not found');
            }

            // Attach the username property of the user object to the socket
            socket.username = user.username;
            next();
        } catch (error) {
            console.error('Authentication error', error);
            next(new Error('Authentication error'));
        }
    });

    io.on('connection', (socket) => {

        // Create a Map to track the room for each socket connection
        const socketRoomMap = new Map();

        // Handle 'join' event when a client joins the chat room
        socket.on('join', (room) => {
            // Emits the username to the client
            socket.emit('username', socket.username);

            socket.join(room);
            // console.log(socket.rooms);
            socketRoomMap.set(socket.username, room); // Store the room information for the socket connection
            socket.emit('joined', `You joined ${room}`);
            socket.broadcast.to(room).emit('user joined', `${socket.username} joined ${room}`);
        });

        // Handle 'chat message' event when a client sends a message
        socket.on('chat message', (room, message) => {
            socket.broadcast.to(room).emit('chat message', `${socket.username}: ${message}`);
        });

        // Handle 'disconnect' event when a client disconnects
        socket.on('disconnecting', () => {
            const room = socketRoomMap.get(socket.username); // Retrieve the room information for the socket connection
            if (room) {
                socket.broadcast.to(room).emit('user left', `${socket.username} left the chat room`);
                socketRoomMap.delete(socket.username); // Remove the room information for the socket connection
            }
        });
    });
}

```

`socketManager.js` exports an anonymous function that takes a single parameter, `io`. The parameter is an instance of the `socket.io` server and is used to manage socket connections.

It starts by using an authentication middleware that verifies the token sent by the connecting socket client. It then assigns the user associated with the token to the `socket.username` object property. This happens before any connection to the server to make sure only authenticated users can make requests to the `socket.io` server instance.

After successful authentication, the socket server instance (`io`) listens for the `connection` event. The event is triggered when a client successfully connects to the `socket.io` server instance.

A callback function that receives the connected socket client is then executed when the event is triggered. Inside the callback function, three event listeners are attached to the connected socket client: the `join`, `chat message`, and `disconnecting` events.

When the `join` event is triggered, the connected socket client first emits a `username` event. This event takes the `socket.username` object property in the `socket.io` server instance and sends it to the client. It then proceeds to join the `room` it received from the client. Also, it stores the `room` in a map object, with the key being the user's username. This is to be used when the user disconnects. It also emits the `joined` event to the connected client and the `user joined` event to other users in the chat room.

Next, when the `chat message` event is triggered, the callback function in the event simply broadcasts the received `message` from the client to all the users in the specified `room` except for the broadcasting user itself.

The `disconnecting` event is triggered when a user is about to disconnect from the `socket.io` server instance. The callback function defined in the event starts by retrieving the room saved in the map object with the disconnecting user's `socket.username` property. A `user left` event is then broadcasted to all the users in the retrieved room except the disconnecting user.

<a name="http-server"></a>
#### HTTP Server

```
const http = require('http');
const ioServer = require('socket.io');
const redisClient = require('./src/utils/redisClient');
const app = require('./app');
const mongoConnect = require('./src/config/mongo');
const socketManager = require('./socketManager');
require('dotenv').config();

// Create server from express app
const server = http.createServer(app);

// set up the socket server and allow all resource to access the server
const io = ioServer(server, { cors: { origin: "*" } });

// Manage socket connections
socketManager(io);

server.listen(3001, async () => {
    // Connect to Mongo
    await mongoConnect();
    // connect to redis
    await redisClient.connect();
    console.log('Server started running...');
});

```

`server.js` is the entry point of the `/server` folder. It starts by creating an HTTP server that uses an Express app to handle requests. It then proceeds to set up a `socket.io` server instance with the HTTP server. The socket instance is then passed as an argument to the `socketManager` function to manage socket connections. Finally, the HTTP server listens for requests on port `3001` and waits for a successful connection to the MongoDB and Redis databases before starting the server.

The server code was deployed to [Railway](https://railway.app?referralCode=8W0n7L) for real-time communication between users around the world.

In the next section, we explain the client setup and the main features of the chat app.