Not so long ago, if we wanted to build a simple JavaScript chat we had
to work with Ajax, Comet or Flash component in the browser.
Ajax can be a little bad for performance, (in the case of chatting)
because it’s based on a request and response. We also needed to hold an
interval in the client which asked the server for new messages and this process
was repeated again and again.
Comet (also known as reverse Ajax and long polling) is pretty much the
same. While using Ajax, we asked the server every time for messages, with Comet
we needed to open one connection and wait. This connection remained open in the
server for a while until the server received messages for us, then the server
responded with the messages and closed the opened connection. The client, when
receiving these messages opened a new connection and waited again. One
advantage of Comet is the reduction of the request amounts, but still, we
needed to open and close many of them for each session (in case the user is
active).
And lastly, a Flash component - in this case we placed a small flash
component in our page to exchange messages with the JavaScript client, the
flash could open a socket to the server and this gave us push communication.
However, we are here to talk about Node.js and Socket.IO, so, basically,
the flash component is a good solution but in those days not every device or
browser came with flash support.
Node.js and Socket.IO
With node.js and socket.io we can enjoy better performance, a
bidirectional push communication between a server and a JavaScript client and
even the support for all the browsers. Socket.io can work with several
transports in order to support even old browsers like IE6 (RiP(.
In this tutorial, I'll show you how to create a multi chat room server
with node.js and socket.io and a basic jquery client.
Installing Socket.IO and Express
If you don’t have socket.io and/or expressjs node modules installed on your machine,
this will be a great time to do it, you can install them with npm:
$ npm install express
and...
$ npm install socket.io
Now we are ready to go, let's start with the server...
server.js
// creating global parameters and start
// listening to 'port', we are creating an express
// server and then we are binding it with socket.io
var express = require('express'),
app = express(),
server = require('http').createServer(app),
io = require('socket.io').listen(server),
port = 8080,
// hash object to save clients data,
// { socketid: { clientid, nickname }, socketid: { ... } }
chatClients = new Object();
// listening to port...
server.listen(port);
// configure express, since this server is
// also a web server, we need to define the
// paths to the static files
app.use("/styles", express.static(__dirname + '/public/styles'));
app.use("/scripts", express.static(__dirname + '/public/scripts'));
app.use("/images", express.static(__dirname + '/public/images'));
// serving the main applicaion file (index.html)
// when a client makes a request to the app root
// (http://localhost:8080/)
app.get('/', function (req, res) {
res.sendfile(__dirname + '/public/index.html');
});
// sets the log level of socket.io, with
// log level 2 we wont see all the heartbits
// of each socket but only the handshakes and
// disconnections
io.set('log level', 2);
// setting the transports by order, if some client
// is not supporting 'websockets' then the server will
// revert to 'xhr-polling' (like Comet/Long polling).
// for more configurations go to:
// https://github.com/LearnBoost/Socket.IO/wiki/Configuring-Socket.IO
io.set('transports', [ 'websocket', 'xhr-polling' ]);
// socket.io events, each connection goes through here
// and each event is emited in the client.
// I created a function to handle each event
io.sockets.on('connection', function(socket){
// after connection, the client sends us the
// nickname through the connect event
socket.on('connect', function(data){
connect(socket, data);
});
// when a client sends a messgae, he emits
// this event, then the server forwards the
// message to other clients in the same room
socket.on('chatmessage', function(data){
chatmessage(socket, data);
});
// client subscribtion to a room
socket.on('subscribe', function(data){
subscribe(socket, data);
});
// client unsubscribtion from a room
socket.on('unsubscribe', function(data){
unsubscribe(socket, data);
});
// when a client calls the 'socket.close()'
// function or closes the browser, this event
// is built in socket.io so we actually dont
// need to fire it manually
socket.on('disconnect', function(){
disconnect(socket);
});
});
// create a client for the socket
function connect(socket, data){
//generate clientId
data.clientId = generateId();
// save the client to the hash object for
// quick access, we can save this data on
// the socket with 'socket.set(key, value)'
// but the only way to pull it back will be
// async
chatClients[socket.id] = data;
// now the client objtec is ready, update
// the client
socket.emit('ready', { clientId: data.clientId });
// auto subscribe the client to the 'lobby'
subscribe(socket, { room: 'lobby' });
// sends a list of all active rooms in the
// server
socket.emit('roomslist', { rooms: getRooms() });
}
// when a client disconnect, unsubscribe him from
// the rooms he subscribed to
function disconnect(socket){
// get a list of rooms for the client
var rooms = io.sockets.manager.roomClients[socket.id];
// unsubscribe from the rooms
for(var room in rooms){
if(room && rooms[room]){
unsubscribe(socket, { room: room.replace('/','') });
}
}
// client was unsubscribed from the rooms,
// now we can delete him from the hash object
delete chatClients[socket.id];
}
// receive chat message from a client and
// send it to the relevant room
function chatmessage(socket, data){
// by using 'socket.broadcast' we can send/emit
// a message/event to all other clients except
// the sender himself
socket.broadcast.to(data.room).emit('chatmessage', { client:
chatClients[socket.id], message: data.message, room: data.room });
}
// subscribe a client to a room
function subscribe(socket, data){
// get a list of all active rooms
var rooms = getRooms();
// check if this room is exist, if not, update all
// other clients about this new room
if(rooms.indexOf('/' + data.room) < 0){
socket.broadcast.emit('addroom', { room: data.room });
}
// subscribe the client to the room
socket.join(data.room);
// update all other clients about the online
// presence
updatePresence(data.room, socket, 'online');
// send to the client a list of all subscribed clients
// in this room
socket.emit('roomclients', { room: data.room, clients:
getClientsInRoom(socket.id, data.room) });
}
// unsubscribe a client from a room, this can be
// occured when a client disconnected from the server
// or he subscribed to another room
function unsubscribe(socket, data){
// update all other clients about the offline
// presence
updatePresence(data.room, socket, 'offline');
// remove the client from socket.io room
socket.leave(data.room);
// if this client was the only one in that room
// we are updating all clients about that the
// room is destroyed
if(!countClientsInRoom(data.room)){
// with 'io.sockets' we can contact all the
// clients that connected to the server
io.sockets.emit('removeroom', { room: data.room });
}
}
// 'io.sockets.manager.rooms' is an object that holds
// the active room names as a key, returning array of
// room names
function getRooms(){
return Object.keys(io.sockets.manager.rooms);
}
// get array of clients in a room
function getClientsInRoom(socketId, room){
// get array of socket ids in this room
var socketIds = io.sockets.manager.rooms['/' + room];
var clients = [];
if(socketIds && socketIds.length > 0){
socketsCount = socketIds.lenght;
// push every client to the result array
for(var i = 0, len = socketIds.length; i < len; i++){
// check if the socket is not the requesting
// socket
if(socketIds[i] != socketId){
clients.push(chatClients[socketIds[i]]);
}
}
}
return clients;
}
// get the amount of clients in aroom
function countClientsInRoom(room){
// 'io.sockets.manager.rooms' is an object that holds
// the active room names as a key and an array of
// all subscribed client socket ids
if(io.sockets.manager.rooms['/' + room]){
return io.sockets.manager.rooms['/' + room].length;
}
return 0;
}
// updating all other clients when a client goes
// online or offline.
function updatePresence(room, socket, state){
// socket.io may add a trailing '/' to the
// room name so we are clearing it
room = room.replace('/','');
// by using 'socket.broadcast' we can send/emit
// a message/event to all other clients except
// the sender himself
socket.broadcast.to(room).emit('presence', { client:
chatClients[socket.id], state: state, room: room });
}
// unique id generator
function generateId(){
var S4 = function () {
return (((1 + Math.random()) * 0x10000) |
0).toString(16).substring(1);
};
return (S4() + S4() + "-" + S4() + "-" + S4() + "-" +
S4() + "-" + S4() + S4() + S4());
}
// show a message in console
console.log('Chat server is running and listening to port %d...', port);
Don't be afraid of this long code, actually it's not so long if you removes the comment lines :), However, I won't explain it because it's well documented so if you missed something feel free to comment to this post and I will explain...
Now let's see the client side, I will show here only the JavaScript part but if you want to download the full application, see the references at the bottom for a github link and a live demo url.
chat.io.js
(function($){
// create global app parameters...
var NICK_MAX_LENGTH = 15,
ROOM_MAX_LENGTH = 10,
lockShakeAnimation = false,
socket = null,
clientId = null,
nickname = null,
// holds the current room we are in
currentRoom = null,
// server information
serverAddress = 'localhost',
serverDisplayName = 'Server',
serverDisplayColor = '#1c5380',
// some templates we going to use in the chat,
// like message row, client and room, this
// templates will be rendered with jQuery.tmpl
tmplt = {
room: [
'<li data-roomId="${room}">',
'<span class="icon"></span> ${room}',
'</li>'
].join(""),
client: [
'<li data-clientId="${clientId}" class="cf">',
'<div class="fl clientName">',
'<span class="icon"></span>',
'${nickname}',
'</div>',
'<div class="fr composing"></div>',
'</li>'
].join(""),
message: [
'<li class="cf">',
'<div class="fl sender">${sender}: </div>',
'<div class="fl text">${text}</div>',
'<div class="fr time">${time}</div>',
'</li>'
].join("")
};
// bind DOM elements like button clicks and keydown
function bindDOMEvents(){
$('.chat-input input').on('keydown', function(e){
var key = e.which || e.keyCode;
if(key == 13) { handleMessage(); }
});
$('.chat-submit button').on('click', function(){
handleMessage();
});
$('#nickname-popup .input input').on('keydown', function(e){
var key = e.which || e.keyCode;
if(key == 13) { handleNickname(); }
});
$('#nickname-popup .begin').on('click', function(){
handleNickname();
});
$('#addroom-popup .input input').on('keydown', function(e){
var key = e.which || e.keyCode;
if(key == 13) { createRoom(); }
});
$('#addroom-popup .create').on('click', function(){
createRoom();
});
$('.big-button-green.start').on('click', function(){
$('#nickname-popup .input input').val('');
Avgrund.show('#nickname-popup');
window.setTimeout(function(){
$('#nickname-popup .input input').focus();
},100);
});
$('.chat-rooms .title-button').on('click', function(){
$('#addroom-popup .input input').val('');
Avgrund.show('#addroom-popup');
window.setTimeout(function(){
$('#addroom-popup .input input').focus();
},100);
});
$('.chat-rooms ul').on('scroll', function(){
$('.chat-rooms ul li.selected').css('top',
$(this).scrollTop());
});
$('.chat-messages').on('scroll', function(){
var self = this;
window.setTimeout(function(){
if($(self).scrollTop() + $(self).height() <
$(self).find('ul').height()){
$(self).addClass('scroll');
} else {
$(self).removeClass('scroll');
}
}, 50);
});
$('.chat-rooms ul li').live('click', function(){
var room = $(this).attr('data-roomId');
if(room != currentRoom){
socket.emit('unsubscribe',
{ room: currentRoom });
socket.emit('subscribe', { room: room });
}
});
}
// bind socket.io event handlers
// this events fired in the server
function bindSocketEvents(){
// when the connection is made, the server emiting
// the 'connect' event
socket.on('connect', function(){
// firing back the connect event to the server
// and sending the nickname for the connected client
socket.emit('connect', { nickname: nickname });
});
// after the server created a client for us, the ready event
// is fired in the server with our clientId, now we can start
socket.on('ready', function(data){
// hiding the 'connecting...' message
$('.chat-shadow').animate({ 'opacity': 0 }, 200,
function(){
$(this).hide();
$('.chat input').focus();
});
// saving the clientId localy
clientId = data.clientId;
});
// after the initialize, the server sends a list of
// all the active rooms
socket.on('roomslist', function(data){
for(var i = 0, len = data.rooms.length; i < len; i++){
// in socket.io, their is always one default room
// without a name (empty string), every socket is
// automaticaly joined to this room, however, we
// don't want this room to be displayed in the
// rooms list
if(data.rooms[i] != ''){
addRoom(data.rooms[i], false);
}
}
});
// when someone sends a message, the sever push it to
// our client through this event with a relevant data
socket.on('chatmessage', function(data){
var nickname = data.client.nickname;
var message = data.message;
//display the message in the chat window
insertMessage(nickname, message, true, false, false);
});
// when we subscribes to a room, the server sends a list
// with the clients in this room
socket.on('roomclients', function(data){
// add the room name to the rooms list
addRoom(data.room, false);
// set the current room
setCurrentRoom(data.room);
// announce a welcome message
insertMessage(serverDisplayName,
'Welcome to the room: `' + data.room +
'`... enjoy!', true, false, true);
$('.chat-clients ul').empty();
// add the clients to the clients list
addClient({ nickname: nickname, clientId: clientId },
false, true);
for(var i = 0, len = data.clients.length; i < len; i++){
if(data.clients[i]){
addClient(data.clients[i], false);
}
}
// hide connecting to room message message
$('.chat-shadow').animate({ 'opacity': 0 }, 200,
function(){
$(this).hide();
$('.chat input').focus();
});
});
// if someone creates a room the server updates us
// about it
socket.on('addroom', function(data){
addRoom(data.room, true);
});
// if one of the room is empty from clients, the server,
// destroys it and updates us
socket.on('removeroom', function(data){
removeRoom(data.room, true);
});
// with this event the server tells us when a client
// is connected or disconnected to the current room
socket.on('presence', function(data){
if(data.state == 'online'){
addClient(data.client, true);
} else if(data.state == 'offline'){
removeClient(data.client, true);
}
});
}
// add a room to the rooms list, socket.io may add
// a trailing '/' to the name so we are clearing it
function addRoom(name, announce){
// clear the trailing '/'
name = name.replace('/','');
// check if the room is not already in the list
if($('.chat-rooms ul li[data-roomId="' + name + '"]').length == 0){
$.tmpl(tmplt.room, { room: name })
.appendTo('.chat-rooms ul');
// if announce is true, show a message about this room
if(announce){
insertMessage(serverDisplayName, 'The room `' + name + '`
created...', true, false, true);
}
}
}
// remove a room from the rooms list
function removeRoom(name, announce){
$('.chat-rooms ul li[data-roomId="' + name + '"]').remove();
// if announce is true, show a message about this room
if(announce){
insertMessage(serverDisplayName, 'The room `' + name +
'` destroyed...', true, false, true);
}
}
// add a client to the clients list
function addClient(client, announce, isMe){
var $html = $.tmpl(tmplt.client, client);
// if this is our client, mark him with color
if(isMe){
$html.addClass('me');
}
// if announce is true, show a message about this client
if(announce){
insertMessage(serverDisplayName, client.nickname +
' has joined the room...', true, false, true);
}
$html.appendTo('.chat-clients ul')
}
// remove a client from the clients list
function removeClient(client, announce){
$('.chat-clients ul li[data-clientId="' +
client.clientId + '"]').remove();
// if announce is true, show a message about this room
if(announce){
insertMessage(serverDisplayName, client.nickname +
' has left the room...', true, false, true);
}
}
// every client can create a new room, when creating one, the client
// is unsubscribed from the current room and then subscribed to the
// room he just created, if he trying to create a room with the same
// name like another room, then the server will subscribe the user
// to the existing room
function createRoom(){
var room = $('#addroom-popup .input input').val().trim();
if(room && room.length <= ROOM_MAX_LENGTH &&
room != currentRoom){
// show room creating message
$('.chat-shadow').show().find('.content')
.html('Creating room: ' + room + '...');
$('.chat-shadow').animate({ 'opacity': 1 }, 200);
// unsubscribe from the current room
socket.emit('unsubscribe', { room: currentRoom });
// create and subscribe to the new room
socket.emit('subscribe', { room: room });
Avgrund.hide();
} else {
shake('#addroom-popup', '#addroom-popup .input input',
'shake', 'yellow');
$('#addroom-popup .input input').val('');
}
}
// sets the current room when the client
// makes a subscription
function setCurrentRoom(room){
currentRoom = room;
$('.chat-rooms ul li.selected').removeClass('selected');
$('.chat-rooms ul li[data-roomId="' + room + '"]')
.addClass('selected');
}
// save the client nickname and start the chat by
// calling the 'connect()' function
function handleNickname(){
var nick = $('#nickname-popup .input input').val().trim();
if(nick && nick.length <= NICK_MAX_LENGTH){
nickname = nick;
Avgrund.hide();
connect();
} else {
shake('#nickname-popup',
'#nickname-popup .input input', 'shake', 'yellow');
$('#nickname-popup .input input').val('');
}
}
// handle the client messages
function handleMessage(){
var message = $('.chat-input input').val().trim();
if(message){
// send the message to the server with the room name
socket.emit('chatmessage', { message: message,
room: currentRoom });
// display the message in the chat window
insertMessage(nickname, message, true, true);
$('.chat-input input').val('');
} else {
shake('.chat', '.chat input', 'wobble', 'yellow');
}
}
// insert a message to the chat window, this function can be
// called with some flags
function insertMessage(sender, message, showTime, isMe, isServer){
var $html = $.tmpl(tmplt.message, {
sender: sender,
text: message,
time: showTime ? getTime() : ''
});
// if isMe is true, mark this message so we can
// know that this is our message in the chat window
if(isMe){
$html.addClass('marker');
}
// if isServer is true, mark this message as a server
// message
if(isServer){
$html.find('.sender')
.css('color', serverDisplayColor);
}
$html.appendTo('.chat-messages ul');
$('.chat-messages').animate({
scrollTop: $('.chat-messages ul').height() }, 100);
}
// return a short time format for the messages
function getTime(){
var date = new Date();
return (date.getHours() < 10 ? '0' + date.getHours().toString() :
date.getHours()) + ':' + (date.getMinutes() < 10 ? '0' +
date.getMinutes().toString() : date.getMinutes());
}
// just for animation
function shake(container, input, effect, bgColor){
if(!lockShakeAnimation){
lockShakeAnimation = true;
$(container).addClass(effect);
$(input).addClass(bgColor);
window.setTimeout(function(){
$(container).removeClass(effect);
$(input).removeClass(bgColor);
$(input).focus();
lockShakeAnimation = false;
}, 1500);
}
}
// after selecting a nickname we call this function
// in order to init the connection with the server
function connect(){
// show connecting message
$('.chat-shadow .content').html('Connecting...');
// creating the connection and saving the socket
socket = io.connect(serverAddress);
// now that we have the socket we can bind events to it
bindSocketEvents();
}
// on document ready, bind the DOM elements to events
$(function(){
bindDOMEvents();
});
})(jQuery);
Summary
There are some ways to send a message from the server to the client with Socket.io, Let's explore some of them:
- socket.emit - Fire event to the client side which associated to this socket.
socket.emit('eventName', { data: 'tosend' });
- socket.broadcast.emit - Like the socket.emit, firing an event to the client but instead of firing the event to the associated socket, the event will be fired in all other connected socket except from the broadcasting one.
socket.broadcast.emit('eventName', { data: 'tosend'});
- socket.broadcast.to('roomName').emit - Sometimes we want to broadcast from one socket to all another socket in the same room rather than all the sockets in the server, in this way we can broadcast to all other sockets who joined a specific room
socket.broadcast.to('roomName').emit('eventName', {data: 'tosend'});
- io.sockets.emit - In this way we can fire an event to all the clients that connected to the server.
io.sockets.emit('eventName', { data: 'tosend'});
- io.of('/namespaceName').emit - Like rooms, in socket.io we can create namespaces, if you want to emit an event to all the clients that connected to a namespace, this is the way.
io.of('/namespaceName').emit('eventName', { data: 'tosend'});
References
- Demo of this chat application
- Download the source code from github