Realtime feed with Sails, Angular and SocketIO

One of my private long-term projects has a simple but dedicated goal: a lot of its components should be realtime, hence data should update automatically and without any action required (like refreshing the page). Gladly, this is not too hard to implement using NodeJS.

Requirements

TL;DR: source code at GitHub.

I won't cover standard things like "you need to have node installed", since this post should cover how to implement a realtime feed, not how to setup your environment.

My feed is implemented using the following technologies:

  • SailsJS - the server-side framework
  • AngularJS - the client-side framework (in version 1.x!)
  • socket.io - the socket framework (which is bundled with SailsJS!)

After creating a new project using sails new realtimefeed (see here if you're new to Sails) your directory should look like

api/  
assets/  
config/  
node_modules/  
tasks/  
views/  
.editorconfig
.gitignore
.sailsrc
app.js  
Gruntfile.js  
package.json  
README.md  

Pretty standard so far. At this point you can implement Livereload for SailsJS if you want to.

Dependencies

For the sake of simplicity we're going to install all dependencies using CDNs (otherwise you should use Bower and follow this guide). Open the views/layout.ejs and add the following to your head:

<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">  
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.6.2/css/font-awesome.min.css">  

And the following lines right before </body> (and before the <!--SCRIPTS-->):

<script src="https://code.jquery.com/jquery-2.2.3.min.js"></script>  
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.5.5/angular.min.js"></script>  
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.5.5/angular-resource.js"></script>  
<script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.5.5/angular-animate.min.js"></script>  
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.13.0/moment.min.js"></script>  
<script src="https://cdnjs.cloudflare.com/ajax/libs/angular-moment/0.10.3/angular-moment.min.js"></script>  

To quickly explain these dependencies:

  • bootstrap.min.css: the Bootstrap CSS framework, so your feed doesn't look like shit.
  • font-awesome.min.css: FontAwesome for icons.
  • jquery-2.3.3.min.js: jQuery.
  • angular.min.js: Angular, our client-side framework.
  • angular-resource.js: Angular plugin to handle RESTful sources.
  • angular-animate.min.js: Angular plugin for pretty animations.
  • moment.min.js and angular-moment.min.js: Moment.js lib to display dates.

Routing

sails lift at this point would still lead to the "A brand new app.", the default page of Sails. Again, to keep it simple, just create a view file for our feed (views/feed.ejs) and adjust the config/routes.js by simple switching the default view (around line ~36) from view: 'homepage' to view: 'feed'. Fill the feed.ejs file with some dummy content, run sails lift again and you should be able to see your dummy content and all dependencies should also be loaded.

How does it work

Okay, before going any further, let's talk about how does this thing actually work. It's simple: by entering the feed we subscribe to a room where all new feed entries are pushed to. On client-side we just listen to events on this room and handle them properly. That's all, it's that easy.

Server-side implementation

Let's start with a controller for the feed and a method for subscribing to our feed room:

// api/controllers/FeedController.js

module.exports = {  
  subscribe: function(req, res) {
    if( ! req.isSocket) {
      return res.badRequest();
    }

    sails.sockets.join(req.socket, 'feed');

    return res.ok();
  }
};

Sails resolves the route to this method automatically; by simply calling /feed/subscribe we should now get a 400 (Bad Request) error - which is fine, since this route needs to be called through a socket request (more on that later).

The next thing we need is a model for our feed entries. We'll keep this as simple as possible:

// api/models/Feed.js

module.exports = {  
  attributes: {
    icon: {
      type: 'string'
    },
    title: {
      type: 'text'
    },
    description: {
      type: 'text'
    }
  }
};

Since we now have a controller and a model (with the same name), we can make use of Sails blueprint API. Therefore calling <your-url>/feed/ will return all feed entries, and <your-url>/feed/create?<params> will create new entries. This feature comes in pretty handy for this guide!

Our server implementation is done - for now. We'll come back later for broadcasting events, but let's take a look at the client-side implementation.

Client-side implementation

Angular makes everything here pretty easy. We'll place all our client-side logic into one single file - don't worry, this file still won't be big at all. Simply create a assets/js/app.js file - it will be included by Sails automatically.

First of all we need to configure the angular app object:

//assets/js/app.js

var feedApp = angular.module('feedApp', ['ngResource', 'angularMoment', 'ngAnimate']);  

At this point we need one additional modification within our views/layout.ejs: change the <html> tag to <html ng-app="feedApp"> to tell Angular to auto-bootstrap our application.

The next thing we need is a controller for our feed:

//assets/js/app.js

var feedApp = angular.module('feedApp', ['ngResource', 'angularMoment', 'ngAnimate']);

feedApp.controller('FeedCtrl', ['$scope', '$resource', '$timeout', function($scope, $resource, $timeout) {  
  $scope.feedEntries = $resource('/feed').query();

  io.socket.get('/feed/subscribe', function(data, jwr) {
    io.socket.on('new_entry', function(entry) {
      $timeout(function() {
        $scope.feedEntries.unshift(entry);
      });
    });
  });
}]);

Two major things are happening here: $scope.feedEntries is assigned with all available feed entries (loaded by $resource). The second one thing is important for our realtime functionality: we subscribe to our feed room (this time the request works fine, since it's sent via sockets) and listen for a new_entry event. Everytime the new_entry event is triggered the newly inserted entry is pushed (or "unshifted") to the existing feed entries. The reason why the unshift is within Angulars $timeout function is to properly update feed entries on updates (otherwise it won't look up for changes after the first iteration).

Time to show our entries: let's modify our views/feed.ejs:

//views/feed.ejs
<div class="container">  
  <div class="page-header" style="padding: 0; margin: 0;">
    <h1 style="margin-top: 0;">
      Feed
      <small>In case you missed something</small>
    </h1>
  </div>

  <section ng-controller="FeedCtrl">
    <div ng-repeat="entry in feedEntries | orderBy:'+':true" class="feed-entry">
      <span class="pull-right text-muted" am-time-ago="entry.createdAt"></span>
      <i class="fa fa-{{entry.icon}} text-muted"></i>
      {{entry.title}}
      <small class="meta text-muted">
        {{entry.description}}
      </small>
    </div>
  </section>
</div>  

The interesting part here begins at <section ng-controller="FeedCtrl">, where we tell angular which controller to apply. The ng-repeat directive takes care of showing the data (where the orderBy:'+':true is just to reverse data, since we want new entries at the top), everything else is just markup.

If you enter your app now, your feed is probably empty. You can easily create some test entries with the blueprint API by just calling <your-url>/feed/create?icon=code&title=Hello%20World&description=This%20is%20the%20first%20test..

One last thing for now is a bit of styling, since I don't like ugly apps:

//assets/styles/importer.less

.feed-entry {
 border-bottom: 1px solid #eee;
 padding-bottom: 12px;
 padding-top: 12px;

 .fa {
   margin-right: 12px;
 }

 .meta {
   display: block;
   margin-left: 32px;
   max-width: 80%;
 }
}

// Will be used for animation!
.animation {
  -webkit-transition: 1s;
}
.animation.ng-enter {
  opacity: 0;
}
.animation.ng-leave {
  opacity: 1;
}
.animation.ng-enter.ng-enter-active {
  opacity: 1;
}
.animation.ng-leave.ng-leave-active {
  opacity: 0;
}

Note: I'm usually not using LESS (but SASS). But since the standard CSS precompiler of Sails is LESS and this is just a demo it should be fine.

By now our app should look like this:

Looks good so far - but nothing happens without refreshing. Let's change this.

Back to the server-side

There's one thing we need to do to make our app realtime: broadcast newly created feed entries. We can make use of Sails afterCreate method within models here:

//app/models/Feed.js

module.exports = {  
  attributes: {
    icon: {
      type: 'string'
    },
    title: {
      type: 'text'
    },
    description: {
      type: 'text'
    }
  },

  afterCreate: function(entry, cb) {
    sails.sockets.broadcast('feed', 'new_entry', entry);
    cb();
  }
};

Now everytime a feed entry is created, its contents are broadcasted to all room subscribers.

Done.

Everything should work fine now.

You can find the source code for this demo at GitHub.

F.A.Q.

Since this was just a demo and the feed I've shown at the beginning is a bit different, there may be some questions about it.

Why not just subscribe to the model itself?

Sails allows to subscribe to models directly, which would have the same affect here. Since I've taken most of this code from my current project it's easy to explain why we didn't subscribe to the model directly: user management.

I've got multiple users which subscribe to different feed rooms (e.g. feed_user_12). Since not all entries should be broadcasted to every user, having rooms for each connected user comes in very handy. Since we don't have an user system implemented here, we're just using one room.

Different feed entry types

The feed model from my project looks a bit different:

module.exports = {  
  attributes: {
    author: {
      model: 'user'
    },
    type: {
      type: 'string',
      required: true,
      defaultsTo: 'unknown'
    },
    action: {
      type: 'string',
      required: true,
      defaultsTo: 'unknown'
    },
    metadata: {
      type: 'json'
    }
  }
};

type could also be named "category", and action is what exactly happened. metadata contains all data I need to display the entry. An example entry would be:

{
  type: 'team',
  action: 'user_joined',
  metadata: {
    username: 'John Fly',
    age: 35,
    role: 'ui designer',
    location: 'Graz'
  },
  date: new Date()
  }

Angular makes it really easy to display different views, based on it's type:

<!-- The feed itself -->  
<section ng-controller="DashboardCtrl" id="dashboard-feed">  
  <div ng-repeat="feed in feedEntries" class="animation">
    <div ng-if="feed.type == 'team'">
      <ng-include src="'templates/dashboard/feed/type-team.html'"></ng-include>
    </div>
    <div ng-if="feed.type == 'misc'">
      <ng-include src="'templates/dashboard/feed/type-misc.html'"></ng-include>
    </div>
    <!-- truncated... -->

<!-- Single feed entry markup for the "team" type -->  
<div class="feed-entry">  
  <div ng-if="feed.action == 'user_joined'">
    <span class="pull-right text-muted" am-time-ago="feed.date"></span>
    <i class="fa fa-user text-muted"></i>
    <a href="#">{{feed.metadata.username}}</a> joined the team.
    <small class="meta text-muted">{{feed.metadata.age}}-year old {{feed.metadata.role}} from {{feed.metadata.location}}</small>
  </div>
</div>  

I've got a lot more types and actions, but the technical principle remains the same.

The downside here is how the meta data is stored; it's inconsistent. If the user gets deleted the link to it would be dead. Explaining why this is still a good choice would blow up this post way too much - but, to keep it short, I'm okay with these inconsistencies.

The "currently online" panel

One additional thing happening in the animation at the beginning is that someone's coming online (let's be honest: it's just me with an incognito chrome window). I've explained this panel and how it works here.

What is this project (from the animation) about?

It's my long-term project, a project management application. I'll talk more about it somewhen in the future (mainly when there's more to show then a few components).