AngularJS: Replace $http to Run Requests via WebSocket
A little while back while working with a client I noticed that REST-like data exchanges running over WebSockets are generally faster than the same exchanges using HTTP/S, especially when considering large numbers of parallel requests, and even with Keep-Alive on. This is more noticeable in Firefox, but even in Chromium there is arguably enough of a difference to care about if you are interested in the future of building highly responsive single page web applications.
A Simple Demonstration Application
When some free time arose, I built a small and crude test application using AngularJS, Express, and Socket.IO to verify that this speed difference was something interesting enough to follow up on. You can try it out easily enough:
npm install imperfect-rest-comparison node imperfect-rest-comparison
Then open up a WebSocket-capable browser and navigate to:
http://localhost:10080
The application allows you to send concurrent and consecutive requests over HTTP and WebSocket, and displays the elapsed round trip timing. You can see the speed difference for yourself.
A Replacement Service For $http Called httpOverWebSocket
One of the possible next steps in this exploratory process was to test in real AngularJS applications, and since this seemed more interesting than tracking down exactly where the extra time is being spent in an HTTP request, that is what I moved on to. One way to achieve this end is to create a service that has the same API as $http, so that an existing application can be easily changed to run at least some of its HTTP requests via a WebSocket connection. It is also possible to replace $httpBackend in the same way, but I did not take that route, although it is arguably much easier.
Unfortunately adding WebSocket support to the server component of an application isn't so straightforward as just replacing a service definition. That said, it really isn't hard at all for small-scale applications with a Node.js backend. So for now I restricted myself to supporting Node.js servers using any of the various WebSocket implementations in that ecosystem. With this initial constraint I put together httpOverWebSocket, a project that provides:
- A configurable httpOverWebSocket service to replace $http.
- Mocks for unit testing that pass requests through to the standard mock $httpBackend.
- An Express / Engine.IO demonstration application.
- And by way of complete overkill, a Vagrant and Chef configuration to launch a server with the application running as a service behind a proxy.
npm install angularjs-websocket-transport node node_modules/angularjs-websocket-transport/src-example/server/expressApp.jsAs before, you can find the demonstration application at:
http://localhost:10080
Setup for httpOverWebSocket: Server Additions
So enough with the examples. How can you use httpOverWebSocket in your Express / AngularJS web application? Let us start with the server side, where you will have to install the Primus abstraction layer and one of the supported WebSocket implementations such as Engine.IO.
npm install -g primus engine.io
Primus provides a way to easily swap out the better known Node.js WebSocket implementations - which is a good thing, as the status and future support of some of those projects is somewhat up in the air. Per the Primus documentation, the server code generates the client library on the fly, a task that you can either perform on launch or include in your build and deployment process. Your Express launch script will have to set up and configure Primus to use the chosen WebSocket implementation. Again, see the Primus documentation for options and the full API. As a trivial example:
var app = express(); var server = http.createServer(app).listen(10080); // Use Engine.IO as the underlying implementation. var primus = new Primus(server, { transformer: 'engine.io' }); // Write out the client library. In a real setup we would create this at deploy // time through a separate script, or have a utility to recreate it and check it // in to make sure it was under version control, etc. // // But this is good enough for an example. primus.save(path.join(__dirname, '../client/js/lib/primus.js'));
You must provide a listener for WebSocket messages arriving from a Primus client and ensure that they return the same response as for the equivalent HTTP request. There are any number of ways to go about this, and the example code below is the outline of this process only.
// Set up a listener. primus.on('connection', function (spark) { // These are magic strings used by httpOverWebSocket. var ID = '_howsId'; var STATUS = '_howsStatus'; // Set up responses to httpOverWebSocket requests. spark.on('data', function (data) { // Check to make sure that this is related to httpOverWebSocket. If not, // do nothing. if (typeof data !== 'object' || !data[ID]) { return; } // Generate a response here. The response must be an object that at // minimum has an ID property that matches that of the request. It should // also contain a STATUS property that provides an HTTP status code. var responseData = { stuff: { // ... } } responseData[ID] = data[ID]; responseData[STATUS] = 200; // Then send the response. spark.write(responseData); }); });
Copy the file /src/httpOverWebSocket.js in the httpOverWebSocket project and include it in the deployment along with the Primus generated client Javascript file. Make sure that both scripts are loaded prior to the AngularJS application definition. E.g. a development environment without minification might look as follows:
<script type="text/javascript" src="js/primus.js"></script> <script type="text/javascript" src="js/jquery.2.0.3.js"></script> <script type="text/javascript" src="js/angular.1.2.0.js"></script> <script type="text/javascript" src="js/angular-route.1.2.0.js"></script> <script type="text/javascript" src="js/httpOverWebSocket.js"></script> <script type="text/javascript" src="js/app.js"></script>
Setup for httpOverWebSocket: Client Additions
Now on to the client side changes, which are straightforward, simple, and essentially the same for any application. Given the AngularJS dependency injection apparatus there several approaches by which you can substitute httpOverWebSocket for $http either globally or in a more limited way. For example to replace $http on a service by service basis:
var Provider = angular.httpOverWebSocket.Provider; var transportProvider = angular.httpOverWebSocket.TransportProvider; var myModule = angular.module('myModule', ['ngRoute']); myModule.provider('httpOverWebSocket', Provider); myModule.provider('httpOverWebSocketTransport', TransportProvider); myModule.config([ 'httpOverWebSocketProvider', 'httpOverWebSocketTransportProvider', function (httpOverWebSocketProvider, httpOverWebSocketTransportProvider) { httpOverWebSocketTransportProvider.configure({ transport: 'primus', options: { // Request timeout in milliseconds. Not the same as the various timeouts // associated with Primus: this is how long to wait for a response to a // specific request before rejecting the associated promise. timeout: 10000, // Delay in milliseconds between timeout checks. timeoutCheckInterval: 100, // Already connected primus instance. instance: new Primus('/', { // Default options for the Primus client. }) } }); httpOverWebSocketProvider.configure({ // Don't exclude any URLs. exclude: [], // Requests with URLs that match this regular expression are sent via // WebSocket. include: [/^/restOverWebSocket/] }); } ]); // Ensure that one specific service uses httpOverWebSocket in place // of $http. function myService($http) { // ... }; myModule.service('myService', [ 'httpOverWebSocket', myService ]);
Unit Tests With httpOverWebSocket
A service to replace $http isn't much use without support for unit testing, and so mocks are provided with httpOverWebSocket. When using the mock transport layer all requests are passed through to $httpBackend, so you can write unit tests in exactly the same way as you would if using the $http service.
When setting up your unit test environment you must include /src/httpOverWebSocketMocks.js from the httpOverWebSocket project. Thus the files property of your Karma configuration might look something like this, for example:
// A list of files / patterns to load in the browser. files: [ 'src-example/client/js/lib/angular.1.2.0.min.js', 'test/lib/angular-mocks.1.2.0.js', 'src/httpOverWebSocket.js', 'src/httpOverWebSocketMocks.js', 'test/**/*.test.js' ],
Here is an example Jasmine unit test:
describe('An Example', function () { 'use strict'; var $httpBackend, httpOverWebSocket; var Provider = angular.httpOverWebSocket.Provider; // The mock transport layer that redirects all requests to $httpBackend. var MockTransportProvider = angular.httpOverWebSocket.MockTransportProvider; beforeEach(function () { // Set up a module and load it. var test = angular.module('test', []); test.provider('httpOverWebSocket', Provider); test.provider('httpOverWebSocketTransport', MockTransportProvider); test.config([ 'httpOverWebSocketProvider', 'httpOverWebSocketTransportProvider', function (httpOverWebSocketProvider, httpOverWebSocketTransportProvider) { // This is a dummy function; it does nothing. httpOverWebSocketTransportProvider.configure({}); // All requests wind up passing through $httpBackend regardless of these // settings, either via $http (if excluded or not included) or via the // MockTransport service (if included and not excluded). httpOverWebSocketProvider.configure({ exclude: [], include: [/^/overWebSocket/] }); } ]); module('test'); inject(function($injector) { $httpBackend = $injector.get('$httpBackend'); httpOverWebSocket = $injector.get('httpOverWebSocket'); }); }); afterEach(function() { $httpBackend.verifyNoOutstandingExpectation(); $httpBackend.verifyNoOutstandingRequest(); }); it('GET request', function () { var url = '/overWebSocket'; $httpBackend.expectGET(url); $httpBackend.whenGET(url).respond(200, { stuff: 'nonsense' }); var resolved = jasmine.createSpy(); var rejected = jasmine.createSpy(); httpOverWebSocket({ method: method, url: url }).then(resolved, rejected); $httpBackend.flush(); expect(rejected).not.toHaveBeenCalled(); expect(resolved).toHaveBeenCalled(); expect(resolved.mostRecentCall.args[0].data).toEqual(response); }); });