Advanced chat using node.js and socket.io - Episode 1

This post is 4 years old. (Or older!) Code samples may not work, screenshots may be missing and links could be broken. Although some of the content may be relevant please take it with a pinch of salt.

A few months ago I wrote an article to show the capabilities of socket.io - a very simple & basic chat application.

I decided to beef this up and make it a useful tool that, potentially, companies could use that as well internally as a chat/conferencing tool. As always there will be several pieces to the article series where I will try to explain some cool code features as well as a cool technology called 'WebRTC'.

First of all let's have a look at the requirements; this application should:

  • Allow people to connect the server
  • Allow people to create rooms (one person can create only one room)
  • Allow other people to join the created rooms
  • Send messages to each other within the room
  • Handle the disconnect event from a room
  • Allow the room's owner to remove the room
  • Handle the disconnect from the server

Most of the functionality was already implemented in the previous version of this chat app but for the sake of completeness I'm going to explain all parts again.

I start by explaining the backend - server.js first. Essentially it is responsible for setting up the socket that the client will connect to and it's also responsible for all the room creation/message sending logic. To have a more readable and less clunky code I have created a separate module to hold all information about the rooms - that will be reused at a later stage. The first thing that we need to do in our server.js is to create the socket and import some other libraries (including room.js) as well as setup two objects that will contain information about the people and the rooms and an array that will hold the client objects.:

var io = require('socket.io');
var socket = io.listen(8000, '1.2.3.4');
var Room = require('./room.js');
var uuid = require('node-uuid');

socket.set('log level', 1);
var people = {};
var rooms = {};
var clients = [];

The next thing to look at is what happens when a person connects to the server, and there are a few things that the code will need to handle. Firstly, it needs to make sure that the people object is correctly populated. I'm using the unique ID generated by socket.io to key off the people object. As per the code snippet below - I'm also adding the name and room keys to the people object. The room key is especially important - as mentioned earlier, each person connected to the server will be able to create one room only. Upon creating a room, the right object's room key will be updated with the ID of the room (I'm going to explain this later with a code snippet), and once room key's value is not null, room creation will be forbidden. As part of the join() function the code also emits two messages to all the clients - a sort of welcome message and another one to list all the connected users - and one message to the connected client showing all the available rooms.

socket.on("connection", function (client) {
client.on("join", function(name) {
roomID = null;
people[client.id] = {"name" : name, "room" : roomID};
client.emit("update", "You have connected to the server.");
socket.sockets.emit("update", people[client.id].name + " is online.")
socket.sockets.emit("update-people", people);
client.emit("roomList", {rooms: rooms});
clients.push(client); //populate the clients array with the client object
});

As per the list of requirements, the next thing to implement is the room creation. Every person connected to the server is allowed to create one room. The function responsible for this needs to make sure that every room created has a unique identifier - to achieve this I'm using node-uuid, which is installable via npm: npm install node-uuid. The newly created rooms are essentially going to be new objects that have various properties. Let me talk about about the room.js file for a moment, which is very simple. Upon creating a new room (which is also equal to a new object enabled via the constructor) three parameters are required: name, id and owner. (Probably the owner parameter is not required as the ownership will also be represented in the rooms object located in server.js.

function Room(name, id, owner) {
this.name = name;
this.id = id;
this.owner = owner;
this.people = [];
this.status = 'available';
}

Room.prototype.addPerson = function (personID) {
if (this.status === 'available') {
this.people.push(personID);
}
};

module.exports = Room;

It's time to utilise this code finally. When creating a room, I am also assigning the room's creator to the room immediately - this is achieved by using socket.io's join() method. The function accepts the room's name as a parameter - it is sent from the client. If a person creating a room doesn't have a room created (checked by looking at the room key in the people object) we process the request and attempt creating a room. The details of the room are then stored in the room object on the server, locally, which is keyed off by the unique ID generated by node-uuid.

client.on('createRoom', function (name) {
if (people[client.id].room === null) {
var id = uuid.v4();
var room = new Room(name, id, client.id);
rooms[id] = room;
socket.sockets.emit('roomList', { rooms: rooms }); //update the list of rooms on the frontend
client.room = name; //name the room
client.join(client.room); //auto-join the creator to the room
room.addPerson(client.id); //also add the person to the room object
people[client.id].room = id; //update the room key with the ID of the created room
} else {
socket.sockets.emit('update', 'You have already created a room.');
}
});

Now that there is a room created it's time to allow other (connected) people to join it. I like to double check everything on the server side (I just don't trust data received from the client) and even though I can easily hide/disable the 'join' button for the creator of the room and from people who have already joined, using Firebug or Chrome Dev Tools someone may be able to show/enable it again and use it for malicious purposes that may break the application. In light of this, I check whether the person attempting to join is the owner or whether the room contains their ID. If both of these checks fail, only then, I add the person to the room:

client.on('joinRoom', function (id) {
var room = rooms[id];
if (client.id === room.owner) {
client.emit(
'update',
'You are the owner of this room and you have already been joined.'
);
} else {
room.people.contains(client.id, function (found) {
if (found) {
client.emit('update', 'You have already joined this room.');
} else {
if (people[client.id].inroom !== null) {
//make sure that one person joins one room at a time
client.emit(
'update',
'You are already in a room (' +
rooms[people[client.id].inroom].name +
'), please leave it first to join another room.'
);
} else {
room.addPerson(client.id);
people[client.id].inroom = id;
client.room = room.name;
client.join(client.room); //add person to the room
user = people[client.id];
socket.sockets
.in(client.room)
.emit(
'update',
user.name + ' has connected to ' + room.name + ' room.'
);
client.emit('update', 'Welcome to ' + room.name + '.');
client.emit('sendRoomID', { id: id });
}
}
});
}
});

There are people online (connected to the server), a room has been created so now it's time to write the function that actually allows the message sending - bearing in mind that only people who are connected to a room can send messages and to only people who belong to the same room. Fortunately, socket.io has this feature built in. By default, every person who connects to server is placed into an empty room - this is the normal socket.io functionality. The client.room = [room.name](http://room.name) will append the list of rooms and will assign the person to the room as well. For testing purposes feel free to try something similar out inside your script:

console.log(socket.sockets.manager.roomClients[client.id]); //should return { '': true }
client.room = 'myroom';
client.join('myroom');
console.log(socket.sockets.manager.roomClients[client.id]); //should return { '': true, '/myroom': true }

Note that the / symbol is not a typing mistake, socket.io by default adds that to all room names, so when there is a comparison function, the code needs to take this into account as well. Let's see how the chat function would actually work:

client.on('send', function (msg) {
if (
socket.sockets.manager.roomClients[client.id]['/' + client.room] !==
undefined
) {
socket.sockets.in(client.room).emit('chat', people[client.id], msg);
} else {
client.emit('update', 'Please connect to a room.');
}
});

It's time to explore how to handle (preferably gracefully) if a person disconnects from a particular room. The logic that is applicable here states that if the owner of the room disconnects, we need to destroy the room as well - only owners can remove rooms, but if the owner leaves the server, the room will stay there forever and can only be removed by restarting the backend server and that's really not a good option. If you recall, the room object contains an 'owner' property, which will be utilised inside the leaveRoom() function - which is invoked by a button click from the front-end. The code will also make sure that the roomID received from the client is in fact valid:

client.on('leaveRoom', function (id) {
var room = rooms[id];
if (client.id === room.owner) {
var i = 0;
while (i < clients.length) {
if (clients[i].id == room.people[i]) {
people[clients[i].id].inroom = null;
clients[i].leave(room.name);
}
++i;
}
delete rooms[id];
people[room.owner].owns = null; //reset the owns object to null so new room can be added
socket.sockets.emit('roomList', { rooms: rooms });
socket.sockets
.in(client.room)
.emit(
'update',
'The owner (' +
user.name +
') is leaving the room. The room is removed.'
);
} else {
room.people.contains(client.id, function (found) {
if (found) {
//make sure that the client is in fact part of this room
var personIndex = room.people.indexOf(client.id);
room.people.splice(personIndex, 1);
socket.sockets.emit(
'update',
people[client.id].name + ' has left the room.'
);
client.leave(room.name);
}
});
}
});

People connected to the server and to a room can also decide to remove a room - but due to the ownership property, only owners can remove their own rooms. As seen above if a particular owner decided to leave the room, the code will make sure that every other person who belongs to that room is also removed from the room:

client.on('removeRoom', function (id) {
var room = rooms[id];
if (room) {
if (client.id === room.owner) {
//only the owner can remove the room
var personCount = room.people.length;
if (personCount > 2) {
console.log('there are still people in the room warning'); //This will be handled later
} else {
if (client.id === room.owner) {
socket.sockets
.in(client.room)
.emit(
'update',
'The owner (' + people[client.id].name + ') removed the room.'
);
var i = 0;
while (i < clients.length) {
if (clients[i].id === room.people[i]) {
people[clients[i].id].inroom = null;
clients[i].leave(room.name);
}
++i;
}
delete rooms[id];
people[room.owner].owns = null;
socket.sockets.emit('roomList', { rooms: rooms });
}
}
} else {
client.emit('update', 'Only the owner can remove a room.');
}
}
});

The very final piece is to handle if a particular user disconnects from the server completely. The way the code is written makes sure that it deletes the room where the previously connected person had a room ownership and it also makes sure that all connected clients that also belong to the room are disconnected:

client.on('disconnect', function () {
if (people[client.id]) {
if (people[client.id].inroom === null) {
socket.sockets.emit(
'update',
people[client.id].name + ' has left the server.'
);
delete people[client.id];
socket.sockets.emit('update-people', people);
} else {
if (people[client.id].owns !== null) {
var room = rooms[people[client.id].owns];
if (client.id === room.owner) {
var i = 0;
while (i < clients.length) {
if (clients[i].id === room.people[i]) {
people[clients[i].id].inroom = null;
clients[i].leave(room.name);
}
++i;
}
delete rooms[people[client.id].owns];
}
}
socket.sockets.emit(
'update',
people[client.id].name + ' has left the server.'
);
delete people[client.id];
socket.sockets.emit('update-people', people);
socket.sockets.emit('roomList', { rooms: rooms });
}
}
});

You may have noticed that I'm using a function called contains to check whether a particular object contains an element. That is not a JavaScript built-in function (unfortunately) so I'm using the following implementation:

Array.prototype.contains = function (k, callback) {
var self = this;
return (function check(i) {
if (i >= self.length) {
return callback(false);
}
if (self[i] === k) {
return callback(true);
}
return process.nextTick(check.bind(null, i + 1));
})(0);
};

That's it. In the next article I will discuss some more interesting bits as well as the front-end elements. Please find the codebase for this article on GitHub. Stay tuned.