Building a chat server with Node.js and Redis

Published by at 31st December 2014 2:10 pm

One of the more interesting capabilities Redis offers is its support for Pub/Sub. This allows you to subscribe to a specific channel, and then react when some content is published to that channel. In this tutorial, we'll build a very simple web-based chat system that demonstrates Redis's Pub/Sub support in action. Chat systems are pretty much synonymous with Node.js - it's widely considered the "Hello, World!" of Node.js. Since we already used Node with the prior Redis tutorial, then it also makes sense to stick with it for this project too.

Installing Node.js

Since the last tutorial, I've discovered NVM, and if you're using any flavour of Unix, I highly recommend using it. It's not an option if you're using Windows, however Redis doesn't officially support Windows anyway, so if you want to follow along on a Windows machine I'd recommend using a VM.

If you followed the URL shortener tutorial, you should already have everything you need, though I'd still recommend switching to NVM as it's very convenient. We'll be using Grunt again, so you'll need to make sure you have grunt-cli installed with the following command:

$ npm install -g grunt-cli

This assumes you used NVM to install Node - if it's installed globally, you may need to use sudo.

Installing dependencies

As usual with a Node.js project, our first step is to create our package.json file:

$ npm init

Answer the questions so you end up with something like this (or just paste this into package.json and amend it as you see fit):

1{
2 "name": "babblr",
3 "version": "1.0.0",
4 "description": "Chat client",
5 "main": "index.js",
6 "scripts": {
7 "test": "grunt test --verbose"
8 },
9 "keywords": [
10 "chat"
11 ],
12 "author": "Matthew Daly <matthew@matthewdaly.co.uk> (http://matthewdaly.co.uk/)",
13 "license": "GPLv2"
14}

Now let's install our dependencies:

1$ npm install express hbs redis hiredis socket.io socket.io-client --save
2$ npm install chai grunt grunt-contrib-jshint grunt-coveralls grunt-mocha-istanbul istanbul mocha request --save-dev

These two commands will install our dependencies.

Now, if you followed on with the URL shortener tutorial, you'll notice that we aren't using Jade - instead we're going to use Handlebars. Jade is quite a nice templating system, but I find it gets in the way for larger projects - you spend too much time looking up the syntax for things you already know in HTML. Handlebars is closer to HTML so we will use that. We'll also use Socket.IO extensively on this project.

Support files

As before, we'll also use Mocha for our unit tests and Istanbul to generate coverage stats. We'll need a Grunt configuration for that, so here it is:

1module.exports = function (grunt) {
2 'use strict';
3
4 grunt.initConfig({
5 jshint: {
6 all: [
7 'test/*.js',
8 'index.js'
9 ]
10 },
11 mocha_istanbul: {
12 coverage: {
13 src: 'test', // the folder, not the files,
14 options: {
15 mask: '*.js',
16 reportFormats: ['cobertura', 'html', 'lcovonly']
17 }
18 }
19 },
20 coveralls: {
21 options: {
22 src: 'coverage/lcov.info',
23 force: false
24 },
25 app: {
26 src: 'coverage/lcov.info'
27 }
28 }
29 });
30
31 // Load tasks
32 grunt.loadNpmTasks('grunt-contrib-jshint');
33 grunt.loadNpmTasks('grunt-coveralls');
34 grunt.loadNpmTasks('grunt-mocha-istanbul');
35
36 // Register tasks
37 grunt.registerTask('test', ['jshint', 'mocha_istanbul:coverage', 'coveralls']);
38};

We also need a .bowerrc:

1{
2 "directory": "static/bower_components"
3}

And a bower.json:

1{
2 "name": "babblr",
3 "main": "index.js",
4 "version": "1.0.0",
5 "authors": [
6 "Matthew Daly <matthewbdaly@gmail.com>"
7 ],
8 "description": "A simple chat server",
9 "moduleType": [
10 "node"
11 ],
12 "keywords": [
13 "chat"
14 ],
15 "license": "GPLv2",
16 "homepage": "http://matthewdaly.co.uk",
17 "private": true,
18 "ignore": [
19 "**/.*",
20 "node_modules",
21 "bower_components",
22 "test",
23 "tests"
24 ],
25 "dependencies": {
26 "html5-boilerplate": "~4.3.0",
27 "jquery": "~2.1.1",
28 "bootstrap": "~3.3.1"
29 }
30}

Then install the Bower dependencies:

$ bower install

We also need a Procfile so we can run it on Heroku:

web: node index.js

Now, let's create the main file:

$ touch index.js

And our test file:

1$ mkdir test
2$ touch test/test.js

Implementing the chat server

Next, let's implement our first test. First of all, we'll verify that the index route works:

1/*jslint node: true */
2/*global describe: false, before: false, after: false, it: false */
3"use strict";
4
5// Declare the variables used
6var expect = require('chai').expect,
7 request = require('request'),
8 server = require('../index'),
9 redis = require('redis'),
10 io = require('socket.io-client'),
11 client;
12client = redis.createClient();
13
14// Server tasks
15describe('server', function () {
16
17 // Beforehand, start the server
18 before(function (done) {
19 console.log('Starting the server');
20 done();
21 });
22
23 // Afterwards, stop the server and empty the database
24 after(function (done) {
25 console.log('Stopping the server');
26 client.flushdb();
27 done();
28 });
29
30 // Test the index route
31 describe('Test the index route', function () {
32 it('should return a page with the title Babblr', function (done) {
33 request.get({ url: 'http://localhost:5000/' }, function (error, response, body) {
34 expect(body).to.include('Babblr');
35 expect(response.statusCode).to.equal(200);
36 expect(response.headers['content-type']).to.equal('text/html; charset=utf-8');
37 done();
38 });
39 });
40 });
41});

Note that this is very similar to the first test for the URL shortener, because it's doing basically the same thing.

Now, run the test and make sure it fails:

1$ grunt test
2Running "jshint:all" (jshint) task
3>> 2 files lint free.
4
5Running "mocha_istanbul:coverage" (mocha_istanbul) task
6
7
8 server
9Starting the server
10 Test the index route
11 1) should return a page with the title Babblr
12Stopping the server
13
14
15 0 passing (873ms)
16 1 failing
17
18 1) server Test the index route should return a page with the title Babblr:
19 Uncaught AssertionError: expected undefined to include 'Babblr'
20 at Request._callback (/Users/matthewdaly/Projects/babblr/test/test.js:34:33)
21 at self.callback (/Users/matthewdaly/Projects/babblr/node_modules/request/request.js:373:22)
22 at Request.emit (events.js:95:17)
23 at Request.onRequestError (/Users/matthewdaly/Projects/babblr/node_modules/request/request.js:971:8)
24 at ClientRequest.emit (events.js:95:17)
25 at Socket.socketErrorListener (http.js:1552:9)
26 at Socket.emit (events.js:95:17)
27 at net.js:441:14
28 at process._tickCallback (node.js:442:13)
29
30
31
32=============================================================================
33Writing coverage object [/Users/matthewdaly/Projects/babblr/coverage/coverage.json]
34Writing coverage reports at [/Users/matthewdaly/Projects/babblr/coverage]
35=============================================================================
36
37=============================== Coverage summary ===============================
38Statements : 100% ( 0/0 )
39Branches : 100% ( 0/0 )
40Functions : 100% ( 0/0 )
41Lines : 100% ( 0/0 )
42================================================================================
43>>
44Warning: Task "mocha_istanbul:coverage" failed. Use --force to continue.
45
46Aborted due to warnings.

With that confirmed, we can start writing code to make the test pass:

1/*jslint node: true */
2'use strict';
3
4// Declare variables used
5var app, base_url, client, express, hbs, io, port, rtg, subscribe;
6
7// Define values
8express = require('express');
9app = express();
10port = process.env.PORT || 5000;
11base_url = process.env.BASE_URL || 'http://localhost:5000';
12hbs = require('hbs');
13
14// Set up connection to Redis
15/* istanbul ignore if */
16if (process.env.REDISTOGO_URL) {
17 rtg = require("url").parse(process.env.REDISTOGO_URL);
18 client = require("redis").createClient(rtg.port, rtg.hostname);
19 subscribe = require("redis").createClient(rtg.port, rtg.hostname);
20 client.auth(rtg.auth.split(":")[1]);
21 subscribe.auth(rtg.auth.split(":")[1]);
22} else {
23 client = require('redis').createClient();
24 subscribe = require('redis').createClient();
25}
26
27// Set up templating
28app.set('views', __dirname + '/views');
29app.set('view engine', "hbs");
30app.engine('hbs', require('hbs').__express);
31
32// Register partials
33hbs.registerPartials(__dirname + '/views/partials');
34
35// Set URL
36app.set('base_url', base_url);
37
38// Define index route
39app.get('/', function (req, res) {
40 res.render('index');
41});
42
43// Serve static files
44app.use(express.static(__dirname + '/static'));
45
46// Listen
47io = require('socket.io')({
48}).listen(app.listen(port));
49console.log("Listening on port " + port);

If you compare this to the code for the URL shortener, you'll notice a few fairly substantial differences. For one thing, we set up two Redis connections, not one - that's because we need to do so when using Pub/Sub with Redis. You'll also notice that we register Handlebars (hbs) rather than Jade, and define not just a directory for views, but another directory inside it for partials. Finally, setting it up to listen at the end is a bit more involved because we'll be using Socket.IO.

Now, you can run your tests again at this point, but they won't pass because we haven't created our views. So let's do that. Create the directory views and the subdirectory partials inside it. Then add the following content to views/index.hbs:

1___HANDLEBARS0___
2 <div class="container">
3 <div class="row">
4 <div class="col-md-8">
5 <div class="conversation">
6 </div>
7 </div>
8 <div class="col-md-4">
9 <form>
10 <div class="form-group">
11 <label for="message">Message</label>
12 <textarea class="form-control" id="message" rows="20"></textarea>
13 <a id="submitbutton" class="btn btn-primary form-control">Submit</a>
14 <div>
15 </form>
16 </div>
17 </div>
18 </div>
19___HANDLEBARS1___

Add this to views/partials/header.hbs:

1<!DOCTYPE html>
2<!--[if lt IE 7]> <html class="no-js lt-ie9 lt-ie8 lt-ie7"> <![endif]-->
3<!--[if IE 7]> <html class="no-js lt-ie9 lt-ie8"> <![endif]-->
4<!--[if IE 8]> <html class="no-js lt-ie9"> <![endif]-->
5<!--[if gt IE 8]><!--> <html class="no-js"> <!--<![endif]-->
6 <head>
7 <meta charset="utf-8">
8 <meta http-equiv="X-UA-Compatible" content="IE=edge">
9 <title>Babblr</title>
10 <meta name="description" content="">
11 <meta name="viewport" content="width=device-width, initial-scale=1">
12
13 <!-- Place favicon.ico and apple-touch-icon.png in the root directory -->
14
15 <link rel="stylesheet" href="/bower_components/bootstrap/dist/css/bootstrap.min.css">
16 <link rel="stylesheet" href="/bower_components/bootstrap/dist/css/bootstrap-theme.min.css">
17 <link rel="stylesheet" href="/css/style.css">
18 </head>
19 <body>
20 <!--[if lt IE 7]>
21 <p class="browsehappy">You are using an <strong>outdated</strong> browser. Please <a href="http://browsehappy.com/">upgrade your browser</a> to improve your experience.</p>
22 <![endif]-->
23 <nav class="navbar navbar-inverse navbar-static-top" role="navigation">
24 <div class="container">
25 <div class="navbar-header">
26 <a class="navbar-brand" href="#">Babblr</a>
27 </div>
28 </div>
29 </nav>

And add this to views/partials/footer.hbs:

1
2 <script src="/bower_components/jquery/dist/jquery.min.js"></script>
3 <script src="/bower_components/bootstrap/dist/js/bootstrap.min.js"></script>
4 <script src="/socket.io/socket.io.js"></script>
5 <script src="/js/main.js"></script>
6
7 </body>
8</html>

You'll also want to create placeholder CSS and JavaScript files:

1$ mkdir static/js
2$ mkdir static/css
3$ touch static/js/main.js
4$ touch static/css/style.css

The test should now pass:

1$ grunt test
2Running "jshint:all" (jshint) task
3>> 2 files lint free.
4
5Running "mocha_istanbul:coverage" (mocha_istanbul) task
6Listening on port 5000
7
8
9 server
10Starting the server
11 Test the index route
12 ✓ should return a page with the title Babblr (41ms)
13Stopping the server
14
15
16 1 passing (54ms)
17
18=============================================================================
19Writing coverage object [/Users/matthewdaly/Projects/babblr/coverage/coverage.json]
20Writing coverage reports at [/Users/matthewdaly/Projects/babblr/coverage]
21=============================================================================
22
23=============================== Coverage summary ===============================
24Statements : 100% ( 24/24 ), 5 ignored
25Branches : 100% ( 6/6 ), 1 ignored
26Functions : 100% ( 1/1 )
27Lines : 100% ( 24/24 )
28================================================================================
29>> Done. Check coverage folder.
30
31Running "coveralls:app" (coveralls) task
32>> Failed to submit 'coverage/lcov.info' to coveralls: Bad response: 422 {"message":"Couldn't find a repository matching this job.","error":true}
33>> Failed to submit coverage results to coveralls
34Warning: Task "coveralls:app" failed. Use --force to continue.
35
36Aborted due to warnings.

Don't worry about the coveralls task failing, as that only needs to pass when it runs on Travis CI.

So we now have our main route in place. The next step is to actually implement the chat functionality. Add this code to the test file:

1
2 // Test sending a message
3 describe('Test sending a message', function () {
4 it("should return 'Message received'", function (done) {
5 // Connect to server
6 var socket = io.connect('http://localhost:5000', {
7 'reconnection delay' : 0,
8 'reopen delay' : 0,
9 'force new connection' : true
10 });
11
12 // Handle the message being received
13 socket.on('message', function (data) {
14 expect(data).to.include('Message received');
15 socket.disconnect();
16 done();
17 });
18
19 // Send the message
20 socket.emit('send', { message: 'Message received' });
21 });
22 });

This code should be fairly straightforward to understand. First, we connect to the server. Then, we set up a handler to verify the content of the message when it gets sent. Finally, we send the message. Let's run the tests to make sure we get the expected result:

1$ grunt test
2Running "jshint:all" (jshint) task
3>> 2 files lint free.
4
5Running "mocha_istanbul:coverage" (mocha_istanbul) task
6Listening on port 5000
7
8
9 server
10Starting the server
11 Test the index route
12 ✓ should return a page with the title Babblr (337ms)
13 Test sending a message
14 1) should return 'Message received'
15Stopping the server
16
17
18 1 passing (2s)
19 1 failing
20
21 1) server Test sending a message should return 'Message received':
22 Error: timeout of 2000ms exceeded
23 at null.<anonymous> (/Users/matthewdaly/Projects/babblr/node_modules/mocha/lib/runnable.js:159:19)
24 at Timer.listOnTimeout [as ontimeout] (timers.js:112:15)
25
26
27
28=============================================================================
29Writing coverage object [/Users/matthewdaly/Projects/babblr/coverage/coverage.json]
30Writing coverage reports at [/Users/matthewdaly/Projects/babblr/coverage]
31=============================================================================
32
33=============================== Coverage summary ===============================
34Statements : 100% ( 24/24 ), 5 ignored
35Branches : 100% ( 6/6 ), 1 ignored
36Functions : 100% ( 1/1 )
37Lines : 100% ( 24/24 )
38================================================================================
39>>
40Warning: Task "mocha_istanbul:coverage" failed. Use --force to continue.
41
42Aborted due to warnings.

Now, let's implement this functionality. Add this at the end of index.js:

1// Handle new messages
2io.sockets.on('connection', function (socket) {
3 // Subscribe to the Redis channel
4 subscribe.subscribe('ChatChannel');
5
6 // Handle incoming messages
7 socket.on('send', function (data) {
8 // Publish it
9 client.publish('ChatChannel', data.message);
10 });
11
12 // Handle receiving messages
13 var callback = function (channel, data) {
14 socket.emit('message', data);
15 };
16 subscribe.on('message', callback);
17
18 // Handle disconnect
19 socket.on('disconnect', function () {
20 subscribe.removeListener('message', callback);
21 });
22});

We'll go through this. First, we create a callback for when a new connection is received. Inside the callback, we then subscribe to a Pub/Sub channel in Redis called ChatChannel.

Then, we define another callback so that on a send event from Socket.IO, we get the message and publish it to ChatChannel. After that, we define another callback to handle receiving messages, and set it to run when a new message is published to ChatChannel. Finally, we set up a callback to handle removing the listener when a user disconnects.

Note the two different connections to Redis - client and subscribe. As mentioned earlier, you need to use two connections to Redis when using Pub/Sub. This is because a client subscribed to one or more channels should not issue commands, so we use subscribe as a dedicated connection to handle subscriptions, and use client to publish new messages.

We'll also need a bit of client-side JavaScript to handle sending and receiving messages. Amend main.js as follows:

1$(document).ready(function () {
2 'use strict';
3
4 // Set up the connection
5 var field, socket, output;
6 socket = io.connect(window.location.href);
7
8 // Get a reference to the input
9 field = $('textarea#message');
10
11 // Get a reference to the output
12 output = $('div.conversation');
13
14 // Handle message submit
15 $('a#submitbutton').on('click', function () {
16 // Create the message
17 var msg;
18 msg = field.val();
19 socket.emit('send', { message: msg });
20 field.val('');
21 });
22
23 // Handle incoming messages
24 socket.on('message', function (data) {
25 // Insert the message
26 output.append('<p>Anonymous Coward : ' + data + '</p>');
27 });
28});

Here we have one callback that handles sending messages, and another that handles receiving messages. Note that every message will be preceded with Anonymous Coward - we won't implement user names at this point (though I plan it for a future instalment).

We'll also add a little bit of additional styling:

1div.conversation {
2 height: 500px;
3 overflow-y: scroll;
4 border: 1px solid #000;
5 padding: 10px;
6}

Now, if you run your tests, they should pass:

1$ grunt test
2Running "jshint:all" (jshint) task
3>> 2 files lint free.
4
5Running "mocha_istanbul:coverage" (mocha_istanbul) task
6Listening on port 5000
7
8
9 server
10Starting the server
11 Test the index route
12 ✓ should return a page with the title Babblr (40ms)
13 Test sending a message
14 ✓ should return 'Message received' (45ms)
15Stopping the server
16
17
18 2 passing (101ms)
19
20=============================================================================
21Writing coverage object [/Users/matthewdaly/Projects/babblr/coverage/coverage.json]
22Writing coverage reports at [/Users/matthewdaly/Projects/babblr/coverage]
23=============================================================================
24
25=============================== Coverage summary ===============================
26Statements : 100% ( 33/33 ), 5 ignored
27Branches : 100% ( 6/6 ), 1 ignored
28Functions : 100% ( 5/5 )
29Lines : 100% ( 33/33 )
30================================================================================
31>> Done. Check coverage folder.
32
33Running "coveralls:app" (coveralls) task
34>> Failed to submit 'coverage/lcov.info' to coveralls: Bad response: 422 {"message":"Couldn't find a repository matching this job.","error":true}
35>> Failed to submit coverage results to coveralls
36Warning: Task "coveralls:app" failed. Use --force to continue.
37
38Aborted due to warnings.

If you now run the following command:

$ node index.js

Then visit http://localhost:5000, you should be able to create new messages. If you then open it up in a second tab, you can see messages added in one tab appear in another. Deploying to Heroku using Redis To Go will be straightforward, and you can then access it from multiple devices and see new chat messages appear in real time.

Wrapping up

This illustrates just how straightforward it is to use Redis's Pub/Sub capability. The chat system is still quite limited, so in a future instalment we'll develop it further. You can get the source code from the Github repository - just switch to the lesson-1 tag.