Photo by Maxwell Nelson on Unsplash
How I Built a Command-Line Chat Application: The Server Code Explained
Table of contents
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.