Sekrab Garage

UTILIZING ANGULAR TOKENS

Loading external configurations inline and in SSR in Angular

AngularDesign March 25, 22

In the previous article I made use of the APP_INITLIZER token to load external configurations via HTTP. Today I am going to bring the configuration closer, ditching the HTTP request. But how do we inject json into HTML, in an Angular application?

The implementation needs to meet two targets:

  • The configuration cannot be included in the compiled source, thus it cannot be imported directly, or indirectly in typescript.

    This rules out the local import:
    import * as WebConfig from '/localdata/config.json';
    Or the module script
    <script type="module" src="/localdata/config.js">
    Or dynamic module loading
import('./localdata/config.js')  
.then((config) => {
  // do something with config
});
  • We want to maintain typing, so config cannot be used before it is casted.

Since JSON cannot be injected in HTML due to security precautions, let me create the configuration script:

// configs/config.js file, named it "WebConfig" to avoid confusion
const WebConfig = {
  isServed: true,
  API: {
    apiRoot: 'url/server/app',
  },
  MyKey: 'MyValue',
};

Injecting a script

The only location to import a JavaScript config without including it in the build, is directly in HTML header. It's the only place that does not get checked at design time, and throws a silent 404 at runtime.

This is how it's done.

<script src="localdata/config.js"></script>

To make this path work, an adjustment in angular.json assets is needed:

I make it a habit to name things differently just to remember that the rule exists.
{ //... angular.json
"assets": [
  {
    "glob": "*",
    "input": "configs",
    "output": "/localdata"
  }

Implementing APP_INITIALIZER

Let's build an APP_INITIALIZER with minimum response: void. Here is the ConfigService

// declare WebConfig
declare const WebConfig: any;

export const configFactory = (config: ConfigService): (() => void) => {
    return () => config.loadAppConfig();
};

@Injectable({
  providedIn: 'root',
})
export class ConfigService {
  constructor() {}

 // set a static member for easier handling
 private static _config: IConfig;

 static get Config(): IConfig {
    return this._config || Config;
  }

  private _createConfig(config: any): IConfig {
    // cast all keys as are, extend local Config
    const _config = { ...Config, ...(<IConfig>config) };
    // set static member
    ConfigService._config = _config;
    return _config;
  }

  loadAppConfig(): void {
    // here is the JavaScript variable... is it ready?
    if (WebConfig?.isServed) {
      this._createConfig(WebConfig);
    } else {
      // not loaded? fall back
      console.log('error');
      this._createConfig(Config);
    }
  }
}

Issues:

First issue to fix is the type of WebConfig, declare a const in the same service file:
declare const WebConfig: any;

The other issue is the extreme case of slow configuration. If the script has a defer property it should not be blocking, and if it is from localdata served from same server, it should be fast enough. On StackBlitz however, it is too slow. I am not going down that track though, because if we had to take care of "waiting for remote config to load locally", then we are better off with the HTTP method.

To tighten the loose ends though, the extreme case is produced locally with the following:

  • Load the config from a remote server
  • add async attribute
  • and probably, place the script before end of body

<script src="https://saphire.sekrab.com/localdata/config.js" async></script>

Running... The WebConfig has no value initially, so it throws an "undefined" error. To fix that, a patch in index.html or in any JavaScript added to code.

<script>
  window.WebConfig = {
    isServed: false,
  };
</script>

Implementing APP_BOOTSTRAP_LISTENER

The main problem with this listener is that it gets fired after any router resolve, it's too late for configurations so we are not going in that direction.

Implementing PLATFORM_INITIALIZER

Since the return of the token is not important, we might be able to load it earlier, in Platform Initializer. Though you must be careful, use defer and stay local. (PS. cannot use this method on StackBlitz.)

export const platformFactory = (): (() => void)  => {
    ConfigService.loadAppConfig(); // static element
    return () => null;
};

In main.ts

platformBrowserDynamic([
    {
          provide: PLATFORM_INITIALIZER,
          useFactory: platformFactory,
          multi: true,
     }
 ]).bootstrapModule(AppBrowserModule)

This token does not use dependencies, so the ConfigService ends up being a group of static elements, so no need to provide it anywhere. Let me rewrite and test.

// notice it no longer needs to be injected
export class ConfigService {
  private static _config: IConfig;
  
  static get Config(): IConfig {
    return this._config || Config;
  }
  
  private static _createConfig(config: any): IConfig {
    // cast all keys as are
    const _config = { ...Config, ...(<IConfig>config) };
    // set static member
    ConfigService._config = _config;
    return _config;
  }
  
  static loadAppConfig(): void {
    if (WebConfig?.isServed) {
      this._createConfig(WebConfig);
    } else {
     // error
     this._createConfig(Config);
    }
  }
}

Let us also just make it local:

<script src="localdata/config.js" defer></script>

Using it is as simple as referencing the static element anywhere.
ConfigService.Config.isServed

The router resolve also withstood the test, since defer attribute loads the JavaScript after parsing, but before DOMContentLoaded. On client side, it all works. Now on to SSR.

SSR

If we use APP_INITIALIZER (with static methods), the token is still provided in AppModule, which is shared for both platforms. If we use PLATFORM_INITIALIZER, it has been injected in platformBrowserDynamic which only runs browser platform. For SSR, needs to be injected in server platform.

In server.ts, bootstrapping AppServerModule occurs as an option for ngExpressEngine, which takes another option: providers array, and that is where the token is provided:

// in server.ts, or where you create the ngExpressEngine
export const AppEngine = ngExpressEngine({
    bootstrap: AppServerModule,
    // pass provider here
    providers:[
        {
            provide: PLATFORM_INITIALIZER,
            useFactory: platformFactory,
            multi: true,
        }
    ]
});

That is not enough. Now the WebConfig on the server side is undefined.

In server output folder after build, where the express app is defined, the WebConfig variable must be set in global context. In NodeJs (are we not all using it?) it is as simple as global.WebConfig

global.WebConfig = require('./localdata/config.js');

The localdata in this case is a server folder, that contains the server config.js file.

But wait the config.js file must have an exports statement for that line to work. Also, it cannot have the exports statement to run in browser after hydration!

Solution? check for a property that is null on one platform, and not null on the other. The easiest property is window. (you can create one, but it takes 5 times more lines of code to cater for it).

First, in your express server file, set global.window = undefined.

Then, in the host config file (server/localdata/config.js):

// in config.js add the following lines 
if (!window) {
    module.exports = WebConfig;
}

There. Now the config file shall work on both browser and server platforms.

Challenges

  • It must be in HTML, thus, if you choose to differentiate config.js from config.prod.js you will end up with index.dev.html and index.html for production.
  • It cannot be a json file, but a JS with a const.
  • It must be local, remote is too slow and does not work on sever side.
  • To serve SSR, you need the extra baggage
StackBlitz project does not run Angular universal, nevertheless, the source code includes SSR

A step back, to HTTP

I like the HTTP method! There is one trick though that we can utilize in SSR, to provide the JSON in the HTML Engine render options, for SSR only. What does that entail? What do we need to fix? Let me tell you about it next week. Along with how to separate your server code completely from Angular for better control.