Building a chat server with Node.js and Redis
Published by Matthew Daly 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 --save2$ 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';34 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: false24 },25 app: {26 src: 'coverage/lcov.info'27 }28 }29 });3031 // Load tasks32 grunt.loadNpmTasks('grunt-contrib-jshint');33 grunt.loadNpmTasks('grunt-coveralls');34 grunt.loadNpmTasks('grunt-mocha-istanbul');3536 // Register tasks37 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 test2$ 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";45// Declare the variables used6var 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();1314// Server tasks15describe('server', function () {1617 // Beforehand, start the server18 before(function (done) {19 console.log('Starting the server');20 done();21 });2223 // Afterwards, stop the server and empty the database24 after(function (done) {25 console.log('Stopping the server');26 client.flushdb();27 done();28 });2930 // Test the index route31 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 test2Running "jshint:all" (jshint) task3>> 2 files lint free.45Running "mocha_istanbul:coverage" (mocha_istanbul) task678 server9Starting the server10 Test the index route11 1) should return a page with the title Babblr12Stopping the server131415 0 passing (873ms)16 1 failing1718 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:1428 at process._tickCallback (node.js:442:13)29303132=============================================================================33Writing coverage object [/Users/matthewdaly/Projects/babblr/coverage/coverage.json]34Writing coverage reports at [/Users/matthewdaly/Projects/babblr/coverage]35=============================================================================3637=============================== 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.4546Aborted due to warnings.
With that confirmed, we can start writing code to make the test pass:
1/*jslint node: true */2'use strict';34// Declare variables used5var app, base_url, client, express, hbs, io, port, rtg, subscribe;67// Define values8express = require('express');9app = express();10port = process.env.PORT || 5000;11base_url = process.env.BASE_URL || 'http://localhost:5000';12hbs = require('hbs');1314// Set up connection to Redis15/* 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}2627// Set up templating28app.set('views', __dirname + '/views');29app.set('view engine', "hbs");30app.engine('hbs', require('hbs').__express);3132// Register partials33hbs.registerPartials(__dirname + '/views/partials');3435// Set URL36app.set('base_url', base_url);3738// Define index route39app.get('/', function (req, res) {40 res.render('index');41});4243// Serve static files44app.use(express.static(__dirname + '/static'));4546// Listen47io = 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">1213 <!-- Place favicon.ico and apple-touch-icon.png in the root directory -->1415 <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
:
12 <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>67 </body>8</html>
You'll also want to create placeholder CSS and JavaScript files:
1$ mkdir static/js2$ mkdir static/css3$ touch static/js/main.js4$ touch static/css/style.css
The test should now pass:
1$ grunt test2Running "jshint:all" (jshint) task3>> 2 files lint free.45Running "mocha_istanbul:coverage" (mocha_istanbul) task6Listening on port 5000789 server10Starting the server11 Test the index route12 ✓ should return a page with the title Babblr (41ms)13Stopping the server141516 1 passing (54ms)1718=============================================================================19Writing coverage object [/Users/matthewdaly/Projects/babblr/coverage/coverage.json]20Writing coverage reports at [/Users/matthewdaly/Projects/babblr/coverage]21=============================================================================2223=============================== Coverage summary ===============================24Statements : 100% ( 24/24 ), 5 ignored25Branches : 100% ( 6/6 ), 1 ignored26Functions : 100% ( 1/1 )27Lines : 100% ( 24/24 )28================================================================================29>> Done. Check coverage folder.3031Running "coveralls:app" (coveralls) task32>> 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 coveralls34Warning: Task "coveralls:app" failed. Use --force to continue.3536Aborted 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:
12 // Test sending a message3 describe('Test sending a message', function () {4 it("should return 'Message received'", function (done) {5 // Connect to server6 var socket = io.connect('http://localhost:5000', {7 'reconnection delay' : 0,8 'reopen delay' : 0,9 'force new connection' : true10 });1112 // Handle the message being received13 socket.on('message', function (data) {14 expect(data).to.include('Message received');15 socket.disconnect();16 done();17 });1819 // Send the message20 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 test2Running "jshint:all" (jshint) task3>> 2 files lint free.45Running "mocha_istanbul:coverage" (mocha_istanbul) task6Listening on port 5000789 server10Starting the server11 Test the index route12 ✓ should return a page with the title Babblr (337ms)13 Test sending a message14 1) should return 'Message received'15Stopping the server161718 1 passing (2s)19 1 failing2021 1) server Test sending a message should return 'Message received':22 Error: timeout of 2000ms exceeded23 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)25262728=============================================================================29Writing coverage object [/Users/matthewdaly/Projects/babblr/coverage/coverage.json]30Writing coverage reports at [/Users/matthewdaly/Projects/babblr/coverage]31=============================================================================3233=============================== Coverage summary ===============================34Statements : 100% ( 24/24 ), 5 ignored35Branches : 100% ( 6/6 ), 1 ignored36Functions : 100% ( 1/1 )37Lines : 100% ( 24/24 )38================================================================================39>>40Warning: Task "mocha_istanbul:coverage" failed. Use --force to continue.4142Aborted due to warnings.
Now, let's implement this functionality. Add this at the end of index.js
:
1// Handle new messages2io.sockets.on('connection', function (socket) {3 // Subscribe to the Redis channel4 subscribe.subscribe('ChatChannel');56 // Handle incoming messages7 socket.on('send', function (data) {8 // Publish it9 client.publish('ChatChannel', data.message);10 });1112 // Handle receiving messages13 var callback = function (channel, data) {14 socket.emit('message', data);15 };16 subscribe.on('message', callback);1718 // Handle disconnect19 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';34 // Set up the connection5 var field, socket, output;6 socket = io.connect(window.location.href);78 // Get a reference to the input9 field = $('textarea#message');1011 // Get a reference to the output12 output = $('div.conversation');1314 // Handle message submit15 $('a#submitbutton').on('click', function () {16 // Create the message17 var msg;18 msg = field.val();19 socket.emit('send', { message: msg });20 field.val('');21 });2223 // Handle incoming messages24 socket.on('message', function (data) {25 // Insert the message26 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 test2Running "jshint:all" (jshint) task3>> 2 files lint free.45Running "mocha_istanbul:coverage" (mocha_istanbul) task6Listening on port 5000789 server10Starting the server11 Test the index route12 ✓ should return a page with the title Babblr (40ms)13 Test sending a message14 ✓ should return 'Message received' (45ms)15Stopping the server161718 2 passing (101ms)1920=============================================================================21Writing coverage object [/Users/matthewdaly/Projects/babblr/coverage/coverage.json]22Writing coverage reports at [/Users/matthewdaly/Projects/babblr/coverage]23=============================================================================2425=============================== Coverage summary ===============================26Statements : 100% ( 33/33 ), 5 ignored27Branches : 100% ( 6/6 ), 1 ignored28Functions : 100% ( 5/5 )29Lines : 100% ( 33/33 )30================================================================================31>> Done. Check coverage folder.3233Running "coveralls:app" (coveralls) task34>> 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 coveralls36Warning: Task "coveralls:app" failed. Use --force to continue.3738Aborted 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.