Advanced chat using node.js and socket.io - Episode 1
Older Article
This article was published 13 years ago. Some information may be outdated or no longer applicable.
A few months ago I wrote an article showing the capabilities of socket.io, a simple chat application.
Time to beef it up into something useful, something a company could potentially run internally as a chat/conferencing tool. This is a multi-part series covering some interesting code patterns and a technology called WebRTC.
The requirements. The application should:
- Allow people to connect to the server
- Allow people to create rooms (one person, one room)
- Allow others to join created rooms
- Send messages within a room
- Handle disconnects from a room
- Allow the room’s owner to remove the room
- Handle disconnects from the server
Most of this was already in the previous version, but for completeness I’ll walk through all of it.
Starting with the backend: server.js. It’s responsible for setting up the socket that clients connect to and for all the room creation/message sending logic. To keep things readable, I’ve pulled room information into a separate module that gets reused later. The first thing server.js does is create the socket, import libraries (including room.js) and set up objects for people, rooms, and an array for 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 = [];
When a person connects, several things need handling. The people object gets populated, keyed off the unique ID generated by socket.io. The code also sets name and room keys on the people object. The room key is critical: each connected person can only create one room. When they create one, their room key gets updated with the room’s ID (more on that shortly), and once that value isn’t null, room creation is blocked. The join() function also emits two messages to all clients (a welcome message and a connected users list) plus one message to the connecting client showing 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
});
Next up: room creation. Every connected person can create one room. Each room needs a unique identifier, handled by node-uuid (installable via npm install node-uuid). New rooms are objects with various properties. The room.js file is simple. Creating a new room (via the constructor) requires three parameters: name, id and owner. (The owner parameter is arguably redundant since ownership is also tracked in the rooms object 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;
Time to wire this up. When creating a room, the creator gets assigned to the room immediately using socket.io’s join() method. The function takes the room name as a parameter (sent from the client). If the person creating the room doesn’t already own one (checked by looking at the room key in the people object), we process the request. Room details get stored in the room object on the server, keyed by the unique ID from 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.');
}
});
With a room created, other connected people can join it. I double-check everything server-side (never trust data from the client). Even though I can hide or disable the ‘join’ button for the room creator and for people already in the room, someone with DevTools could re-enable it and cause problems. So I check whether the person attempting to join is the owner or is already in the room. Only if both checks fail do I add them:
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 });
}
}
});
}
});
People are online, a room exists, so now the message-sending function. Only people connected to a room can send messages, and only to people in the same room. socket.io handles this natively. By default, every person who connects gets placed into an empty room (standard socket.io behaviour). Setting client.room = room.name appends to the room list and assigns the person. For testing, try this 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 }
The / prefix on room names isn’t a typo. socket.io adds it by default, so any comparison logic needs to account for it. Here’s the chat function:
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.');
}
});
Now for handling disconnects from a room. The logic: if the owner disconnects, we destroy the room. Only owners can remove rooms, but if an owner leaves the server without explicitly removing the room, it would hang around forever (only fixable by restarting the backend, which isn’t viable). The room object’s ‘owner’ property gets checked inside the leaveRoom() function, triggered by a button click on the front-end. The code also validates that the roomID from the client is legitimate:
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);
}
});
}
});
Connected users can also choose to remove a room, but the ownership property means only owners can remove their own. When an owner leaves, the code makes sure every other person in that room gets booted out too:
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 final piece: handling full disconnects from the server. The code deletes the room where the departing person had ownership and makes sure all clients in that room get 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 spotted the contains function used to check whether an object holds a particular element. It’s not a built-in JavaScript method (unfortunately), so here’s the 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 for the backend. In the next article I’ll cover the front-end elements and some more interesting bits. The codebase for this article is on GitHub. Stay tuned.