Ensuring Initializing and Server Side Configuration with Angular 4+

Ensuring Initializing and Server Side Configuration with Angular 4+

Introduction

During the build of the Blockchain Proof of Concept Framework we determined that for greatest configurability and simplistic deployment we needed to have some configuration settings provided by a simple REST service. The website itself for the client is an Angular 4 application and can be served using a static web server such as nginx or even an Express site. We ended up creating two approaches and I'm sharing a simple PoC here and on GitHub.

Within Angular there is the environment support, which provies basic runtime configuration for the Angular app in static json files. But, we didn't want to use this as it complicates build and deployment especially if this is to be used by folks looking for simplicity and not wanting to dive into Angular specific code.

Angular Startup

For this approach we took advantage of AppSettings in Azure WebApps - which show up as environment variables with an APPSETTING_ prefix. The server has the responsibility to emit a JSON payload that the Angular client can use to prefix any further API calls. Courtesy of this StackOverflow post: How to call an rest api while bootstrapping angular 2 app.

Angular provider

The key settings in app.module.ts is the experimental APP_INITIALIZER shown below and the startup service factory.

export function startupServiceFactory(startupService: StartupService): Function {
  return () => startupService.load();
}

...

  providers: [
    HttpModule,
    StartupService,
    Title,
    {
      provide: APP_INITIALIZER,
      useFactory: startupServiceFactory,
      deps: [StartupService],
      multi: true
    }

Startup Service

The startup service also uses load to ensure that any state is initialized at startup:

export class StartupService {

    private _startupData: any;
    // data;

    constructor(private http: Http) { }

    // This is the method you want to call at bootstrap
    // Important: It should return a Promise
    load(): Promise<any> {

        this._startupData = null;

        return this.http
            .get('/config')
            .map((res: Response) => res.json())
            .toPromise()
            .then((data: any) => this._startupData = data)
            .catch((err: any) => Promise.resolve());
    }

    get startupData(): any {
        return this._startupData;
    }

    get gatewayApiHost(): string {
        let rv = this.startupData["APPSETTING_URL"]
            || environment.gatewayApiHost || 'http://localhost:3030';
        console.log('gatewayHost: %s', rv);
        return rv;
    }

    get anotherWay(): string {
        return this.startupData["APPSETTING_URL"];
    }

App Components

Any application component that requires the startup data needs to inject it within the constructor and then within ngInit read the properties as follows:

export class AppComponent implements OnInit {

    constructor(private startup: StartupService, private router: Router, private titleService: Title) { }

    title = "My site";
    //startupData: string;

    ngOnInit() {

        // If there is no startup data received (maybe an error!)
        // navigate to error route
        if (!this.startup.startupData) {
            console.log('startup data does not exists');
            this.router.navigate(['error'], { replaceUrl: true });
        }
        else {
            console.log('startup data exists.');
        }

        this.setTitle("Your Site..");
        //this.startupData = JSON.stringify(this.startup.startupData);
    
    }

nginx approach

In this example, the server is nginx and a shell script is used to start nginx, but also create a static config.json file that is read by the service in the Angular app. Courtesy of Jurgen Van de Moere

Source Code Example

The source for the example Angular app and a Dockerfile, along with the startup script is here.

The startup script is below.

#!/usr/bin/env sh

echo "
{
  \"GatewayApiUrl\" : \"${GATEWAY_API_URL}\",
  \"ActiveDirectoryTenant\" : \"${ACTIVE_DIRECTORY_TENANT}\",
  \"ActiveDirectoryClientId\" : \"${ACTIVE_DIRECTORY_CLIENTID}\"
}
" > /usr/share/nginx/html/config.json

nginx -g 'daemon off;'

static "bare node js" approach

This is a non-dependancy static server that offers no caching, etc. it provides several mime types, in addition a single /config endpoint that will run a filter on the environment variables serverside. The intent is that settings prefixed with APPSETTING_ are bundled into a flat json object. This allows use in Azure WebApps and app settings which are configurage at deployment and at anypoint after deployment. The core of this was taken from this gist.

Source Code Example

The source for the example Angular app and the static web server are here.

The full source to the pure Node JS static web server is below:

'use strict';
/* This is a non-dependancy static server that offers no caching, etc.
 * it provides several mime types, in addition a single /config endpoint
 * that will run a filter on the environment variables serverside.
 * The intent is that settings prefixed with APPSETTING_ are bundled into a flat
 * json object. This allows use in Azure WebApps and app settings which are 
 * configurage at deployment and at anypoint after deployment.
 * The core of this was taken from this gist: //https://gist.github.com/amejiarosario/53afae82e18db30dadc9bc39035778e5
 *
 * 
 * Shawn Cicoria May 23, 2017
 */

const http = require('http');
const url = require('url');
const fs = require('fs');
const path = require('path');

const util = require('util');

const port = process.env.port || 3000;

http.createServer(function (req, res) {
  console.log(`${req.method} ${req.url}`);

  // parse URL
  const parsedUrl = url.parse(req.url, true);

  // extract URL path
  let pathname = `.${parsedUrl.pathname}`;
  console.log(pathname)

  if (pathname == './') {
    pathname = 'index.html';
  }

  if (req.method === 'GET' && parsedUrl.path === '/config') {
    //run the config API
    configApi(res);
  }
  else {
    // based on the URL path, extract the file extention. e.g. .js, .doc, ...
    const ext = path.parse(pathname).ext;


    // maps file extention to MIME typere
    const map = {
      '.ico': 'image/x-icon',
      '.html': 'text/html',
      '.js': 'text/javascript',
      '.json': 'application/json',
      '.css': 'text/css',
      '.png': 'image/png',
      '.jpg': 'image/jpeg',
      '.wav': 'audio/wav',
      '.mp3': 'audio/mpeg',
      '.svg': 'image/svg+xml',
      '.pdf': 'application/pdf',
      '.doc': 'application/msword'
    };

    fs.exists(pathname, function (exist) {
      if (!exist) {
        // if the file is not found, return 404
        res.statusCode = 404;
        res.end(`File ${pathname} not found!`);
        return;
      }

      // if is a directory search for index file matching the extention
      if (fs.statSync(pathname).isDirectory()) pathname += '/index' + ext;

      // read file from file system
      fs.readFile(pathname, function (err, data) {
        if (err) {
          res.statusCode = 500;
          res.end(`Error getting the file: ${err}.`);
        } else {
          // if the file is found, set Content-type and send data
          res.setHeader('Content-type', map[ext] || 'text/plain');
          res.end(data);
        }
      });
    });
  }

}).listen(parseInt(port));

console.log(`Server listening on port ${port}`);

const appSettings = {};
Object.keys(process.env).map(function (key, index, array) {
  if (key.startsWith('APPSETTING_'))
    appSettings[key] = process.env[key];
});

function configApi(res) {
  // if the file is found, set Content-type and send data
  res.setHeader('Content-type', 'application/json');
  return res.end(JSON.stringify(appSettings));
}