« Writing an AngularJS App with Socket.IO

[2012.07.17]

I've written before about writing an AngularJS app with Express, but this time I'll be writing about how to integrate Socket.IO to add real-time features to an AngularJS application. In this tutorial, I'm going to walk through writing a instant messaging app. This builds upon my earlier tutorial (using a similar node.js stack on the server), so I recommend checking that out first if you haven't.

And, as always, you can get the finished product on Github.

The Angular Socket.io Seed

There's a bit of boilerplate to getting Socket.IO set up and integrated with Express, so I created the Angular Socket.IO Seed.

To get started, you can either clone the angular-node-seed repo from Github:

λ → git clone git://github.com/btford/angular-socket-io-seed my-project

or download it as a zip.

Once you have the seed, you need to grab a few dependencies with npm. Open a terminal to the directory with the seed, and run:

λ → npm install

With these dependencies installed, you can run the skeleton app:

λ → node app.js

and see it in your browser at http://localhost:3000 to ensure that the seed is working as expected.

Deciding on App Features

There are more than a few different ways to write a chat application, so let's describe the minimal features that ours will have. There will be just one chat room that all users will belong to. Users can choose and change their name, but the names must be unique. The server will enforce this uniqueness and announce when users change their names. The client should expose a list of messages, and a list of users currently in the chat room.

A Simple Front End

With this specification, we can make a simple front end with Jade that provies the necessary UI elements. Open views/index.jade and add this inside of block body:

div(ng-controller='AppCtrl')
.col
  h3 Messages
  .overflowable
    p(ng-repeat='message in messages') {{message.user}}: {{message.text}}

.col
  h3 Users
  .overflowable
    p(ng-repeat='user in users') {{user}}

.clr
  form(ng-submit='sendMessage()')
    | Message: 
    input(size='60', ng-model='message')
    input(type='submit', value='Send')

.clr
  h3 Change your name
  p Your current user name is {{name}}
  form(ng-submit='changeName()')
    input(ng-model='newName')
    input(type='submit', value='Change Name')

Open public/css/app.css and add the CSS to provide columns and overflows:

/* app css stylesheet */

.overflowable {
  height: 240px;
  overflow-y: auto;
  border: 1px solid #000;
}

.overflowable p {
  margin: 0;
}

/* poor man's grid system */
.col {
  float: left;
  width: 350px;
}

.clr {
  clear: both;
}

Interacting with Socket.IO

Although Socket.IO exposes an io variable on the window, it's better to encapsulate it in AngularJS's Dependency Injection system. So, we'll start by writing a service to wrap the socket object returned by Socket.IO. This is awesome, because it will make it much easier to test our controller later. Open public/js/services.js and replace the contents with:

app.factory('socket', function ($rootScope) {
  var socket = io.connect();
  return {
    on: function (eventName, callback) {
      socket.on(eventName, function () {  
        var args = arguments;
        $rootScope.$apply(function () {
          callback.apply(socket, args);
        });
      });
    },
    emit: function (eventName, data, callback) {
      socket.emit(eventName, data, function () {
        var args = arguments;
        $rootScope.$apply(function () {
          if (callback) {
            callback.apply(socket, args);
          }
        });
      })
    }
  };
});

Notice that at we wrap each socket callback in $scope.$apply. This tells AngularJS that it needs to check the state of the application and update the templates if there was a change after running the callback passed to it. Internally, $http works in the same way; after some XHR returns, it calls $scope.$apply, so that AngularJS can update its views accordingly.

Note that this service doesn't wrap the entire Socket.IO API (that's left as an exercise for the reader ;P ). However, it covers the methods used in this tutorial, and should point you in the right direction if you want to expand on it. I may revisit writing a complete wrapper, but that's beyond the scope of this article.

Now, within our controller, we can ask for the socket object, much like we would with $http:

function AppCtrl($scope, socket) {
  /* Controller logic */
}

Inside the controller, let's add logic for sending and receiving messages. Open js/public/controllers.js and replace the contents with the following:

function AppCtrl($scope, socket) {

  // Socket listeners
  // ================

  socket.on('init', function (data) {
    $scope.name = data.name;
    $scope.users = data.users;
  });

  socket.on('send:message', function (message) {
    $scope.messages.push(message);
  });

  socket.on('change:name', function (data) {
    changeName(data.oldName, data.newName);
  });

  socket.on('user:join', function (data) {
    $scope.messages.push({
      user: 'chatroom',
      text: 'User ' + data.name + ' has joined.'
    });
    $scope.users.push(data.name);
  });

  // add a message to the conversation when a user disconnects or leaves the room
  socket.on('user:left', function (data) {
    $scope.messages.push({
      user: 'chatroom',
      text: 'User ' + data.name + ' has left.'
    });
    var i, user;
    for (i = 0; i < $scope.users.length; i++) {
      user = $scope.users[i];
      if (user === data.name) {
        $scope.users.splice(i, 1);
        break;
      }
    }
  });

  // Private helpers
  // ===============

  var changeName = function (oldName, newName) {
    // rename user in list of users
    var i;
    for (i = 0; i < $scope.users.length; i++) {
      if ($scope.users[i] === oldName) {
        $scope.users[i] = newName;
      }
    }

    $scope.messages.push({
      user: 'chatroom',
      text: 'User ' + oldName + ' is now known as ' + newName + '.'
    });
  }

  // Methods published to the scope
  // ==============================

  $scope.changeName = function () {
    socket.emit('change:name', {
      name: $scope.newName
    }, function (result) {
      if (!result) {
        alert('There was an error changing your name');
      } else {

        changeName($scope.name, $scope.newName);

        $scope.name = $scope.newName;
        $scope.newName = '';
      }
    });
  };

  $scope.messages = [];

  $scope.sendMessage = function () {
    socket.emit('send:message', {
      message: $scope.message
    });

    // add the message to our model locally
    $scope.messages.push({
      user: $scope.name,
      text: $scope.message
    });

    // clear message box
    $scope.message = '';
  };
}

This application will only feature one view, so we can remove the routing from public/js/app.js and simplify it to:

// Declare app level module which depends on filters, and services
var app = angular.module('myApp', ['myApp.filters', 'myApp.directives']);

Writing the Server

Open routes/socket.js. We need to define an object for maintaining the state of the server, so that user names are unique.

// Keep track of which names are used so that there are no duplicates
var userNames = (function () {
  var names = {};

  var claim = function (name) {
    if (!name || names[name]) {
      return false;
    } else {
      names[name] = true;
      return true;
    }
  };

  // find the lowest unused "guest" name and claim it
  var getGuestName = function () {
    var name,
      nextUserId = 1;

    do {
      name = 'Guest ' + nextUserId;
      nextUserId += 1;
    } while (!claim(name));

    return name;
  };

  // serialize claimed names as an array
  var get = function () {
    var res = [];
    for (user in names) {
      res.push(user);
    }

    return res;
  };

  var free = function (name) {
    if (names[name]) {
      delete names[name];
    }
  };

  return {
    claim: claim,
    free: free,
    get: get,
    getGuestName: getGuestName
  };
}());

This basically defines a set of names, but with APIs that make more sense for the domain of a chat server. Let's hook this up to the server's socket to respond to the calls that our client makes:

// export function for listening to the socket
module.exports = function (socket) {
  var name = userNames.getGuestName();

  // send the new user their name and a list of users
  socket.emit('init', {
    name: name,
    users: userNames.get()
  });

  // notify other clients that a new user has joined
  socket.broadcast.emit('user:join', {
    name: name
  });

  // broadcast a user's message to other users
  socket.on('send:message', function (data) {
    socket.broadcast.emit('send:message', {
      user: name,
      text: data.message
    });
  });

  // validate a user's name change, and broadcast it on success
  socket.on('change:name', function (data, fn) {
    if (userNames.claim(data.name)) {
      var oldName = name;
      userNames.free(oldName);

      name = data.name;

      socket.broadcast.emit('change:name', {
        oldName: oldName,
        newName: name
      });

      fn(true);
    } else {
      fn(false);
    }
  });

  // clean up when a user leaves, and broadcast it to other users
  socket.on('disconnect', function () {
    socket.broadcast.emit('user:left', {
      name: name
    });
    userNames.free(name);
  });
};

And with that, the application should be complete. Try it out by running node app.js. The application should update in real-time, thanks to Socket.IO.

Conclusion

There's a lot more you could add to this instant messaging app. For instance, you can submit empty messages. You could use ng-valid to prevent this on the client side, and a check on the server. Maybe the server could keep a recent history of messages for the benefit of new users joining the app.

Writing AngularJS apps that make use of other libraries is easy once you understand how to wrap them in a service and notify Angular that a model has changed. Next I plan to cover using AngularJS with D3.js, the popular visualization library.

Comments? Tweet or email me. Corrections? Send me a pull request on Github.