Server side vanilla Angular rendering under Node

Loading and running simple Angular app under Node.

Angular is great on the client, but a lot of people complain that web crawlers do not see the dynamic application. This forces developers to either pre-render a view of the website using PhantomJs / CasperJs, or create isomorphic JavaScript libraries that can render client application and an equivalent server-side application. These solutions seem like huge overkill to me. What if we could render the same DOM server side using Node?

Previously I tried loading AngularJs library from Node, but it required small fixes to content security policy variables (see Unit testing Angular load using Node). Recently, the Angular library has been fixed, and v1.2.25 loads under Node using synthetic browser emulation just fine. Here is what you can do with plain vanilla angular from Node.

Basic setup

To load and run Angular we need valid window and document objects that simulate the actual browser. I am using benv that wraps around jsdom to create these objects.

basic-setup.js
1
2
3
4
5
6
7
8
9
10
11
var benv = require('benv');
benv.setup(function () {
console.assert(window, 'have window object');
console.assert(document, 'have document object');
console.log('created window and document');
console.log(window.navigator.userAgent);
});
// output
$ node basic-setup.js
created window and document
Node.js (darwin; U; rv:v0.10.28)

Loading Angular

benv allows loading scripts in the window context, but returns a valid reference. In the example below I will load zepto before loading AngularJs script. Loading Zepto is not strictly necessary, but will make DOM manipulations simpler compared to jQuery-lite included with AngularJs.

load-angular.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var benv = require('benv');
benv.setup(function () {
benv.expose({
$: benv.require('./bower_components/zepto/zepto.js', 'Zepto'),
angular: benv.require('./bower_components/angular/angular.js', 'angular')
});
console.log('loaded angular', angular.version);
});
// output
$ node load-angular.js
loaded angular { full: '1.2.25',
major: 1,
minor: 2,
dot: 25,
codeName: 'hypnotic-gesticulation' }

Both Zepto and Angular scripts are run of the mill scripts installed using bower

bower install zepto angular

Bind model

Let us take a simplest Angular feature - model binding and see if it works. We will setup window and document, then will load basic HTML into the document. Then we will load AngularJs library and will bootstrap the document.

bind-value.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var benv = require('benv');
var beautify = require('js-beautify').html; // pretty HTML printing
benv.setup(function () {
benv.expose({
$: benv.require('./bower_components/zepto/zepto.js', 'Zepto'),
angular: benv.require('./bower_components/angular/angular.js', 'angular')
});
// basic html with Angular template
$('body').html('<div ng-controller="greetingController"><h1>{{ greeting }}</h1></div>')
// Angular module that sets value
angular.module('bindValue', [])
.controller('greetingController', function ($scope) {
$scope.greeting = 'Hi Node!';
});
// start angular digest cycle
angular.bootstrap(document, ['bindValue']);
console.log(beautify(document.documentElement.outerHTML));
});

Notice that there is no distinction between the Node script and the "browser" environment - we can manipulate angular modules, controllers, etc. directly. This is very different from running scripts via PhantomJs (where we need to use evaluate method to execute script in the browser context).

The script produces valid DOM with bound data values

$ node bind-value.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<html>
<head>
<style type="text/css">
@charset "UTF-8";
[ng\:cloak],
[ng-cloak],
[data-ng-cloak],
[x-ng-cloak],
.ng-cloak,
.x-ng-cloak,
.ng-hide {
display: none !important;
}
ng\:form {
display: block;
}
.ng-animate-block-transitions {
transition: 0s all!important;
-webkit-transition: 0s all!important;
}
.ng-hide-add-active,
.ng-hide-remove {
display: block!important;
}
</style>
</head>
<body style="">
<div ng-controller="greetingController" class="ng-scope">
<h1 class="ng-binding">Hi Node!</h1>
</div>
</body>
</html>

Notice that it even adds AngularJs style sheet, and most importantly sets the correct contents to h1 node. This page can now be parsed by any web crawler without running JavaScript.

Using $timeout

Let us do something a little more complicated: let us update the model after an interval to see if it works.

timeout-example.js
1
2
3
4
5
6
7
8
9
benv.setup(function () {
// same setup as above
$('body').html('<div ng-controller="greetingController"><h1>{{ greeting }}</h1></div>');
require('./timeout-app.js'); // 1
// need to wait until timeout runs
setTimeout(function dumpPage() {
console.log(beautify(document.documentElement.outerHTML));
}, 1500);
});

Notice that instead of writing angular script directly inside timeout-example.js we moved it into separate file timeout-app.js

timeout-app.js
1
2
3
4
5
6
7
angular.module('bindValue', [])
.controller('greetingController', function ($scope, $timeout) {
$timeout(function setGreeting() {
$scope.greeting = 'Hi Node after 1 second!';
}, 1000);
});
angular.bootstrap(document, ['bindValue']);

Running timeout-example.js from node generates after 1.5 seconds

$ node timeout-example.js
...
<body style="">
    <div ng-controller="greetingController" class="ng-scope">
        <h1 class="ng-binding">Hi Node after 1 second!</h1>
    </div>
</body>

So $timeout service works

Application organization

In the above timeout example we loaded the AngularJs script from separate file. We can move the HTML text into separate file index.html and load timeout-app.js from there too!

index.html
1
2
3
4
5
6
7
8
9
10
<!DOCTYPE html>
<html lang="en">
<head>
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.2.5/angular.min.js"></script>
</head>
<body>
<h1 ng-controller="helloController">Hello {{ title }}</h1>
<script src="app.js"></script>
</body>
</html>

The app.js file

1
2
3
4
5
6
7
angular.module('myApp', [])
.controller('helloController', ['$scope', '$timeout', function ($scope, $timeout) {
$timeout(function setTitle() {
$scope.title = 'Node!';
}, 1000);
}]);
angular.bootstrap(document, ['myApp']);

You can open index.html in your browser and see AngularApp working just fine.

Here is the Node rendering script

load-test.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var benv = require('benv');
var beautify = require('js-beautify').html;
var read = require('fs').readFileSync;
var indexHtml = read('./index.html', 'utf8');
benv.setup(function () {
benv.expose({
$: benv.require('./bower_components/zepto/zepto.js', 'Zepto'),
angular: benv.require('./bower_components/angular/angular.js', 'angular')
});
$('html').html(indexHtml);
require('./app.js');
setTimeout(function grabRenderedHtml() {
console.log(beautify(document.documentElement.outerHTML));
}, 1500);
});

We need to load app.js because synthetic browser environment does NOT load and run scripts. Thus all <script> tags in the index.html are ignored. Still we generate valid DOM when we run

$ node load-test.js
1
2
3
4
5
<html>
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.2.5/angular.min.js"></script>
<h1 ng-controller="helloController" class="ng-scope ng-binding">Hello Node!</h1>
<script src="app.js"></script>
</html>

Conclusion

This was simple experiment in loading and running Angular application completely inside synthetic browser environment. It proves that Node JavaScript environment is good enough for Angular to run its update cycle, perform dependency injection, run services and update DOM.