How I Built a Command-Line Chat Application: The Server Code Explained

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.

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.

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.

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.

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.

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.

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.

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.

Configuration and Utility

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

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.

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.

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.

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.

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.

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