Saturday, June 23, 2018

Angular Server Side Rendering on Azure


This post is a transliteration of my journey to implement server side rendering for Angular on Azure. All the text that is highlighted in yellow is specific to my project, so you will need to modify it for your setup.

ng generate universal

To begin run “ng generate universal” this will create a few files and modify angular.json

Create an HttpHandler

Create an HttpHandler which will run node on the server and generate the server-side rendered output:
using System.Diagnostics;
using System.IO;
using System.Web;

namespace AngularSSR {
    public class AngularSSRHandler: IHttpHandler {
        public void ProcessRequest(HttpContext context) {
            var request = context.Request;
            var response = context.Response;
            var basepath = context.Server.MapPath("~");
            var urlPathRaw = request.Url.PathAndQuery;
            const string prefix = "/v2/prerender/";
            const string staticPrefix = "/prerender/v2/ang6";
            if (urlPathRaw.StartsWith(staticPrefix)) {
                return;
            }
            if (!urlPathRaw.StartsWith(prefix)) {
                return;
            }

            var urlPath = urlPathRaw.Substring(prefix.Length);
            var universalPath = Path.Combine(basepath, "ang6/universal.js");
            var prerenderPath = Path.Combine(basepath, "prerender.html");
            var indexPath = Path.Combine(basepath, "ang6/index.html");
            var arguments = $"{universalPath} /{urlPath}";
            var psi = new ProcessStartInfo("node.exe", arguments) {
                UseShellExecute = false,
                RedirectStandardError = true,
                RedirectStandardOutput = true,
                WorkingDirectory = basepath,
                CreateNoWindow = false,
            };
            var process = Process.Start(psi);
            if (process == null) return;
            process.BeginOutputReadLine();
            process.BeginErrorReadLine();
            var sw = new StringWriter();
            var swErr = new StringWriter();
            process.OutputDataReceived += (s, e) => { sw.Write(e.Data); };
            process.ErrorDataReceived += (s, e) => { swErr.Write(e.Data); };
            process.WaitForExit();
            var ok = process.ExitCode == 0;
            var output = sw.ToString();
            var outputError = swErr.ToString();
            if (!string.IsNullOrEmpty(outputError)) {
                response.Write("<!-- prerender error <pre>\n");
                response.Write(outputError);
                response.Write("\n</pre> -->");
            }
            if (!string.IsNullOrEmpty(output)) {
                response.Write("<!-- prerender output <pre>\n");
                response.Write(output);
                response.Write("\n</pre> -->");
            }

            if (ok) {
                var prerendered = File.ReadAllText(prerenderPath);
                response.Write(prerendered);
            }
            else {
                var index = File.ReadAllText(indexPath);
                response.Write(index);
            }
            response.End();
        }

        public bool IsReusable => false;
    }
}

Specify Node Version in Azure Webapp

Add an application setting called WEBSITE_NODE_DEFAULT_VERSION with a value of 8.9.4

Modify web.config

Add the handler to web.config.
  <system.webServer>
    <handlers>
      <add  verb="*" path="v2/prerender/*" name="AngularSSRHandler" type="AngularSSR.AngularSSRHandler, AngularSSR"/>
    </handlers>
  </system.webServer>

Redirect all requests to the handler first.
  <system.webServer>
    <rewrite>
      <rules>
        <rule name="AngularJS Routes" stopProcessing="true">
          <match url="(.*)" />
          <conditions logicalGrouping="MatchAll">
            <add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
          </conditions>
          <action type="Rewrite" url="prerender/{R:1}" />
        </rule>
      </rules>
    </rewrite>
  </system.webServer>

Modify angular.json

In angular.json add a “configurations” key under the server architecture of your project:
  "projects": {
    "ang6": {
        "server": {
          "configurations": {
            "production": {
              "fileReplacements": [
                {
                  "replace": "src/environments/environment.ts",
                  "with": "src/environments/environment.prod.ts"
                }
              ],
              "optimization": true,
              "outputHashing": "none",
              "sourceMap": false,
              "namedChunks": false,
              "extractLicenses": true,
              "vendorChunk": false
            }
          }
        }
      }
    },
  },

Refactor Animations

First, remove the import of BrowserAnimationsModule from your app.module.ts, then create a module called app.browser.module.ts which will be your new main module, and in it import BrowserAnimationsModule
import { NgModule } from '@angular/core';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';

import { AppModule } from './app.module';
import { AppComponent } from './app.component';

@NgModule({
  imports: [
    BrowserAnimationsModule,
    AppModule,
  ],
  bootstrap: [AppComponent],
})
export class AppBrowserModule {}

Also update your main.ts with the new module
import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

import { AppBrowserModule } from './app/app.browser.module';
import { environment } from './environments/environment';
import 'hammerjs';

if (environment.production) {
  enableProdMode();
}

document.addEventListener('DOMContentLoaded', () => {
  platformBrowserDynamic().bootstrapModule(AppBrowserModule)
  .catch(err => console.log(err));
});

Then update your app.server.module.ts (which was generated by “ng generate universal”) to include the NoopAnimationsModule
import { NgModule } from '@angular/core';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ServerModule } from '@angular/platform-server';

import { AppModule } from './app.module';
import { AppComponent } from './app.component';

@NgModule({
  imports: [
    AppModule,
    NoopAnimationsModule,
    ServerModule,
  ],
  bootstrap: [AppComponent],
})
export class AppServerModule {}

Angular Entry Point for SSR

Create a server entry point for angular called universal.js:
import 'zone.js/dist/zone-node';
import 'reflect-metadata';
import { createWindow } from 'domino';
import { renderModuleFactory } from '@angular/platform-server';
import { writeFileSync, readFileSync } from 'fs';

const fakeStorage = {
  length: 0,
  clear: () => { },
  getItem: (_key) => null,
  key: (_index) => null,
  removeItem: (_key) => { },
  setItem: (_key, _data) => { }
};

var url = process.argv[2] || '/';

import { AppServerModuleNgFactory } from './dist/ang6-server/main';

const indexHtml = readFileSync('./ang6/index.html', 'utf-8').toString();
var win = createWindow(indexHtml);
global['window'] = win;
global['document'] = win.document;
global['navigator'] = win.navigator;
global['localStorage'] = fakeStorage;
global['DOMTokenList'] = win.DOMTokenList;
global['Node'] = win.Node;
global['Text'] = win.Text;
global['HTMLElement'] = win.HTMLElement;
global['MutationObserver'] = getMockMutationObserver();

function getMockMutationObserver() {
  return class {
    observe(node, options) {
    }
    disconnect() {
    }
    takeRecords() {
      return [];
    }
  };
}
Object.defineProperty(win.document.body.style, 'transform', {
  value: () => {
    return {
      enumerable: true,
      configurable: true
    };
  },
});
Object.defineProperty(win.document.body.style, 'box-shadow', {
  value: () => {
    return {
      enumerable: true,
      configurable: true
    };
  },
});
global['getComputedStyle'] = win.getComputedStyle;
import 'hammerjs';

renderModuleFactory(AppServerModuleNgFactory, {
  document: indexHtml + '\n\n',
  url
})
  .then(html => {
    console.log('Pre-rendering successful, saving prerender.html');
    writeFileSync('./prerender.html', html);
  })
  .catch(error => {
    console.error('Error occurred:', error);
  });

Webpack SSR Configuration

Create a webpack.config.js for the ssr. Note that we are using 'development' mode because using 'none' or 'production' doesn't work due to some kind of bug in webpack (https://github.com/angular/angular-cli/issues/10787)
var path = require('path');
var UglifyJsPlugin = require('uglifyjs-webpack-plugin');

module.exports = {
  mode: 'development',
  entry: {
    universal: './universal.js'
  },
  target: 'node',
  plugins: [
    new UglifyJsPlugin({
      sourceMap: true,
      uglifyOptions: {
        output: {
          beautify: false,
        },
        compress: true,
        mangle: true,
      },
    })
  ],
  output: {
    filename: 'universal.js',
    path: path.resolve(__dirname, 'dist/ang6')
  }
};

Modify package.json

Add a build:ssr script and a webpack script to package.json
  "scripts": {
    "build:ssr": "ng build --i18n-locale=en-UK --prod --base-href /v2 --deploy-url /v2/ang6/ && ng run ang6:server:production && npm run webpack",
    "webpack": "webpack"
  },