In this article, we are going to create a chat application that connects people, anonymously, to different rooms together in pairs of two. The chat application would make use of Express.js for the server-side code, listen to web socket communication using Socket.io and the client-side will be developed with vanilla JavaScript.
Setting up our project
- We will create a directory named
chat-app
and change the directory to the directory using the command.
$ mkdir chat-app && cd chat-app
- Initialize our Node application by running the command.
$ yarn init -y
- Install express in our project using yarn by running the command.
$ yarn add express
We will create a JavaScript file, named
app.js
, and create a simple Node HTTP server.Next, we will import express into our application, create an express app and start the server to listen to requests on port
8001
.
// app.js
const http = require("http")
const express = require("express")
const app = express()
app.get("/index", (req, res) => {
res.send("Welcome home")
})
const server = http.createServer(app)
server.on("error", (err) => {
console.log("Error opening server")
})
server.listen(8001, () => {
console.log("Server working on port 8001")
})
- Now we can start the application by running the command.
$ node app.js
You can visit [http://localhost:8001/index](http://localhost:8001/index)
on your browser to test that the application works
Initializing socket.io on the server-side
To initialize a socket on the server side, follow the following steps.
-
Install socket.io dependency into our application by running the command.
$ yarn add socket.io
-
Import socket.io into our code, create a new socket server and then add an event listener to the socket to listen if a connection is made.
// app.js const http = require("http"); const { Server } = require("socket.io"); const express = require("express"); const app = express(); app.get("/index", (req, res) => { res.send("Welcome home"); }); const server = http.createServer(app); const io = new Server(server); io.on("connection", (socket) => { console.log("connected"); }); server.on("error", (err) => { console.log("Error opening server"); }); server.listen(8001, () => { console.log("Server working on port 3000"); });
Initializing socket.io on the client-side
We would be creating a simple UI using Vanilla JavaScript and we serve the web page as a static file from our express application.
We’d create a public directory including files to build up our UI, making our project structure look like this.
chat-app/
|- node_modules/
|- public/
|- index.html
|- main.js
|- app.js
|- package.json
|- yarn.lock
We are going to be making use of Tailwind CSS to style the Client UI to reduce the amount of custom CSS we’d be writing.
In the index.html
, create a template for our chat window.
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdn.tailwindcss.com"></script>
<title>Anon Chat App</title>
</head>
<body>
<div class="flex-1 p:2 sm:p-6 justify-between flex flex-col h-screen">
<div id="messages" class="flex flex-col space-y-4 p-3 overflow-y-auto scrollbar-thumb-blue scrollbar-thumb-rounded scrollbar-track-blue-lighter scrollbar-w-2 scrolling-touch">
</div>
<div class="border-t-2 border-gray-200 px-4 pt-4 mb-2 sm:mb-0">
<div class="relative flex">
<input type="text" placeholder="Write your message!" class="w-full focus:outline-none focus:placeholder-gray-400 text-gray-600 placeholder-gray-600 pl-12 bg-gray-200 rounded-md py-3">
<div class="absolute right-0 items-center inset-y-0 hidden sm:flex">
<button type="button" class="inline-flex items-center justify-center rounded-lg px-4 py-3 transition duration-500 ease-in-out text-white bg-blue-500 hover:bg-blue-400 focus:outline-none">
<span class="font-bold">Send</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-6 w-6 ml-2 transform rotate-90">
<path d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z"></path>
</svg>
</button>
</div>
</div>
</div>
</div>
<script src="/socket.io/socket.io.js"></script>
<script src="./main.js"></script>
</body>
</html>
In the HTML file above we included two JavaScript files, the first one to initialize socket.io on the client-side and another main.js
file to write our custom JavaScript code.
Then in the main.js
file, we would create a function that is able to add a message to the chatbox. The function createMessage
will expect two arguments. The first argument is the message string and the second argument is a boolean to determine if the message is from the user or from another external user.
// main.js
const messageBox = document.querySelector("#messages");
function createMessage(text, ownMessage = false) {
const messageElement = document.createElement("div");
messageElement.className = "chat-message";
const subMesssageElement = document.createElement("div");
subMesssageElement.className =
"px-4 py-4 rounded-lg inline-block rounded-bl-none bg-gray-300 text-gray-600";
if (ownMessage) {
subMesssageElement.className += " float-right bg-blue-800 text-white";
}
subMesssageElement.innerText = text;
messageElement.appendChild(subMesssageElement);
messageBox.appendChild(messageElement);
}
createMessage("Welcome to vahalla");
createMessage("Who are you to talk to me", true);
Change the code in the server application, app.js
make use of static files to render the client UI.
// app.js
const http = require("http");
const { Server } = require("socket.io");
const express = require("express");
const path = require("path");
const app = express();
app.use(express.static(path.join(__dirname, "public")));
const server = http.createServer(app);
const io = new Server(server);
io.on("connection", (socket) => {
console.log("connected");
});
server.on("error", (err) => {
console.log("Error opening server");
});
server.listen(8001, () => {
console.log("Server working on port 8001");
});
NOTE: To view the changes made in our application, we have to stop the running server application and re-run it for the new changes to take effect. So we are making use of
nodemon
to automate this process for us.
Install nodemon by running.
$ npm install -g nodemon
Then run the node application using nodemon.
$ nodemon ./app.js
Open [http://localhost:8001](http://localhost:3000)
on your browser to view what the chat app would look like.
Creating different rooms for Web Socket communication
To keep track of the rooms created and the number of users connected to each room, we will create a Room
class to manage this data for us.
We’d create a new file named room.js
in the root directory of our project. Then we create the Room
class and have the constructor initialize a property for keeping the state of our room.
// room.js
// the maximum number of people allowed in each room
const ROOM_MAX_CAPACITY = 2;
class Room {
constructor() {
this.roomsState = [];
}
}
module.exports = Room;
The roomsState
is an array of objects that keeps the information about each room ID created and the number of users in that room. So a typical roomsState
would look like this.
// rooms state
[
{
roomID: "some id",
users: 1
},
{
roomID: "a different id",
users: 2
}
]
Next, add a method to join a room. The method will loops through the room to check if any rooms have a number of users that are less than the maximum number of participants allowed in each room. If all room in the list is occupied, it would create a new room and initialize the number of users in that room to 1.
To generate a unique id, we would be making use of a package known as uuid in our application.
Install uuid by running this command in our terminal.
$ yarn add uuid
Then import the package into our application by running as follows.
// room.js
const { v4: uuidv4 } = require("uuid");
class Room {
constructor() {
/**/
}
joinRoom() {
return new Promise((resolve) => {
for (let i = 0; i < this.roomsState.length; i++) {
if (this.roomsState[i].users < ROOM_MAX_CAPACITY) {
this.roomsState[i].users++;
return resolve(this.roomsState[i].id);
}
}
// else generate a new room id
const newID = uuidv4();
this.roomsState.push({
id: newID,
users: 1,
});
return resolve(newID);
});
}
}
module.exports = Room;
NOTE: Making use of an array to manage the rooms' state is, obviously, not the best way to do so. Imagine having thousands of rooms in your application and you have to loop through each room for each join request. It would execute at O(n). For the purpose of this tutorial, we will stick to this approach.
We’d add another method to the Room
class, leaveRoom()
, to reduce the number of users in a particular room.
// room.js
class Room {
constructor() {
/**/
}
joinRoom() {}
leaveRoom(id) {
this.roomsState = this.roomsState.filter((room) => {
if (room.id === id) {
if (room.users === 1) {
return false;
} else {
room.users--;
}
}
return true;
});
}
}
module.exports = Room;
The leaveRoom()
method takes a room ID, and loops through the array of rooms to find if any of the rooms match the ID provided in the argument.
If it finds the matching room, it checks if the user in the room is one so as to delete that particular room state.
If the user in the room is greater than 1, the leaveRoom()
method just deducts the number of users in that room by one.
Finally, our room.js
code should be similar to this.
// room.js
const { v4: uuidv4 } = require("uuid");
// the maximum number of people allowed in a room
const ROOM_MAX_CAPACITY = 2;
class Room {
constructor() {
this.roomsState = [];
}
joinRoom() {
return new Promise((resolve) => {
for (let i = 0; i < this.roomsState.length; i++) {
if (this.roomsState[i].users < ROOM_MAX_CAPACITY) {
this.roomsState[i].users++;
return resolve(this.roomsState[i].id);
}
}
const newID = uuidv4();
this.roomsState.push({
id: newID,
users: 1,
});
return resolve(newID);
});
}
leaveRoom(id) {
this.roomsState = this.roomsState.filter((room) => {
if (room.id === id) {
if (room.users === 1) {
return false;
} else {
room.users--;
}
}
return true;
});
}
}
module.exports = Room;
Joining and leaving the rooms.
To create different channels for users in our chat application, we would be creating rooms for them.
socket.io allows us to create arbitrary channels that sockets can join and leave. It can be used to broadcast events to a subset of clients.
(source: https://socket.io/docs/v3/rooms/)
To join a room, we would join a room with a unique room ID.
io.on("connection", socket => {
// join a room
socket.join("some room id");
socket.to("some room id").emit("some event");
});
In our server application, once a new users join the connection, the Room.joinRoom()
returns a unique ID which is our unique room ID. So we can join and leave room in our rooms as follow.
// app.js
io.on("connection", async (socket) => {
const roomID = await room.joinRoom();
// join room
socket.join(roomID);
socket.on("disconnect", () => {
// leave room
room.leaveRoom(roomID);
});
});
Sending and receiving messages
Now, we’d move back to our client-side code to emit events for messages sent from the client. And also listen to message events coming from the server and write that message to our chatbox.
// main.js
socket.on("receive-message", (message) => {
createMessage(message);
});
sendButton.addEventListener("click", () => {
if (textBox.value != "") {
socket.emit("send-message", textBox.value);
createMessage(textBox.value, true);
textBox.value = "";
}
});
NOTE: In our chat application, we directly add the message from user to the chatbox without confirming if the message is received by the socket server. This is not usually the case.
Then on our express application.
// app.js
io.on("connection", async (socket) => {
const roomID = await room.joinRoom();
// join room
socket.join(roomID);
socket.on("send-message", (message) => {
socket.to(roomID).emit("receive-message", message);
});
socket.on("disconnect", () => {
// leave room
room.leaveRoom(roomID);
});
});
Making our express application code look like this finally.
// app.js
const http = require("http");
const { Server } = require("socket.io");
const express = require("express");
const path = require("path");
const Room = require("./room");
const app = express();
app.use(express.static(path.join(__dirname, "public")));
const server = http.createServer(app);
const io = new Server(server);
const room = new Room();
io.on("connection", async (socket) => {
const roomID = await room.joinRoom();
// join room
socket.join(roomID);
socket.on("send-message", (message) => {
socket.to(roomID).emit("receive-message", message);
});
socket.on("disconnect", () => {
// leave room
room.leaveRoom(roomID);
});
});
server.on("error", (err) => {
console.log("Error opening server");
});
server.listen(8001, () => {
console.log("Server working on port 8001");
});
And our client side JavaScript looking like this.
// main.js
const messageBox = document.querySelector("#messages");
const textBox = document.querySelector("input");
const sendButton = document.querySelector("button");
function createMessage(text, ownMessage = false) {
const messageElement = document.createElement("div");
messageElement.className = "chat-message";
const subMesssageElement = document.createElement("div");
subMesssageElement.className =
"px-4 py-4 rounded-lg inline-block rounded-bl-none bg-gray-300 text-gray-600";
if (ownMessage) {
subMesssageElement.className += " float-right bg-blue-800 text-white";
}
subMesssageElement.innerText = text;
messageElement.appendChild(subMesssageElement);
messageBox.appendChild(messageElement);
}
const socket = io();
socket.on("connection", (socket) => {
console.log(socket.id);
});
socket.on("receive-message", (message) => {
createMessage(message);
});
sendButton.addEventListener("click", () => {
if (textBox.value != "") {
socket.emit("send-message", textBox.value);
createMessage(textBox.value, true);
textBox.value = "";
}
});
Testing our chat app
To text our chat app, we will open four different browsers to confirm that two rooms are created.
Conclusion
If you see this, it means that we read thus far and probably have the chat app running on our machine.
You can find the code from this article in this GitHub repository.
To include more challenges, these are features you can include in the chat application
- Inform users if people left or joined the room
- Refactor the rooms state array to a more efficient data structure
- Allow pairing based on topic selection (You’d need to configure this in the Room object)
To read more about socket.io, you can visit the official documentation.
If you enjoy reading this article, you can consider buying me a coffee.