Skip to content

Running AngularJS on the server with Node.js and jsdom

Jason Livesay edited this page May 21, 2019 · 24 revisions

Update

This was several years ago. The new way to do this is here: https://angular.io/guide/universal Also see https://prerender.io/


We need to pre-render pages on the server for Google to index. We don't want to have to repeat ourselves on the back end. I found a few examples of server-side rendering for Backbone applications, but none showing how to do it with AngularJS. To make this work I have modified a couple of Node modules, jsdom and xmlhttprequest. They are loaded from local subdirectories (/jsdom-with-xmlhttprequest and /xmlhttprequest).

Newest Note: Last time I tried rethinkdb (after I wrote this initially) the API had changed and actually the package in npm just didn't work. So you probably want to just drop all of the rethinkdb stuff from the example code in this repo -- its not necessary anyway.

(Old Note: You will need to install rethinkdb (http://www.rethinkdb.com/docs/install/) and start the server up (run a command like rethinkdb &) before attempting to run the code in this repo.)

Note 2: There may be a few details or pieces of code that I forgot to mention on this page, but the code in this repo should work ok if you clone it as a starting point.

First problem: jsdom has no XMLHttpRequest

After I eventually got a very basic AngularJS example running on the server, I moved on to routing. The first problem I ran into was that jsdom just stubs out the Window object's XMLHttpRequest, so it doesn't do anything. Solution was pretty simple -- modify a line in lib/jsdom/browser/index.js:

...
XMLHttpRequest: require('../../../../xmlhttprequest').XMLHttpRequest,
...

Helping XMLHttpRequest find stuff

Turns out that as the requests were being made from the Angular app on the server, XMLHttpRequest was able to guess that we were pulling from localhost, but didn't know what port to use. So I just modified the start of the open method in lib/XmlHttpRequest.js:

this.open = function(method, url, async, user, password) {
    url = 'http://localhost:3002/' + url;

Passing paths from Express into the Angular router

When I visit a page on the site, such as /Catalog, I need to render that using Angular JS. So I added a method to my controller (in public/js/script.js) that I can call from global scope:

  $scope.setLocation = function(url) {
    $scope.$location.path(url);
  }

Then, in the Node server code, I use Angular's $apply to run setLocation within the main controller's scope:

app.get "*", (req, res, next) ->
  e = window.document.getElementById 'mainctl'
  if window.angular?
    scope = window.angular.element(e).scope()
    scope.$apply ->
      scope.setLocation req.url
      return undefined
    delay 50, ->
      res.end window.document.innerHTML

Extra /'s

For some reason XMLHttpRequests from the server-side Angular app still weren't all going through. Eventually I realized that some of them looked like '//products.html' instead of '/products.html'. So, as a quick solution, I just added a few extra routes to handle those requests.

ngView contents initially cleared

So when I finally had the routes rendering on the server and I could see that the HTML being served by my Node program was correct, unfortunately the browser was still showing me a blank space where the view corresponding to my route was supposed to be, and then filling it in a few milliseconds later. The culprit turned out to be a single line in Angular's ngView directive. So (in script.js) I made a 'prerendered' directive that is exactly the same as ngView, but has that line commented out:

    //...
    function update() {
        var locals = $route.current && $route.current.locals,
            template = locals && locals.$template;

        if (template) {
        //...
        } else {
          //clearContent();
        }
        //...

An Angular directive for a product list

With those problems solved, the main parts left are: a directive for displaying some information, such as a list of products, and some code to pull that product list from the database. First, a simple AngularJS directive:

viewMod.directive('productList', function($resource) {
    return {
      restrict: 'E',
      replace: true,
      transclude: false,
      template: '<li ng-repeat="product in products">{{product.name}} {{product.price}}</li>',
      link: function(scope, element, attrs) {
        var Product = $resource('/products');
        scope.products = Product.query({type: scope.params.type}, function() { });
      }
    }
  });

That allows me to code my product list view very simply: <product-list/> Not a terribly useful shorthand in this case, but its fun to be able to use a custom element name, and could come in handy for defining custom components.

##Serving up data from RethinkDB

Last major aspect of this is responding to GET requests with product data:

getProducts = (req, res) ->
  if req.query.type is 'undefined'
    db.table('products').run().collect (products) ->
      res.end JSON.stringify(products)
  else
    db.table('products').filter({type: req.query.type}).run().collect (products) ->
      res.end JSON.stringify(products)

app.get '/products', (req, res, next) ->
  getProducts req, res