External Configurations in Angular

Post Editor

In this article, we will learn about external configurations in Angular.

18 min read
post

External Configurations in Angular

In this article, we will learn about external configurations in Angular.

post
post
18 min read

Environment variables pollute the compiled source code, which does not allow for multiple server deployments. External configuration, allows multiple custom configuration for the same source code.

Consider the following server layout:

Content imageContent image


The classical way to add configurations from an environment file. So if we have two environments, we will need to make two builds. Having an external configuration allows us to make one build, and rely on the host server to feed the missing configuration file, reducing time and resources needed.

The environment then only needs to decide the location of the configuration file.

Furthermore, on the host, we can serve different configuration files based on a route, and deploy once. This is most common in multilingual or multinational sites, where for example, the app prefixed with /en-us/ would be slightly different from that of /en-au/.

Taking it even further, we can deploy on multiple servers, excluding the configuration file, and let every remote server handle its own configuration file. A bit extreme, it is an option nevertheless. This is common in deploying custom software per client.

The downside is as you can guess, manual maintenance is needed.

Here in this article, we will investigate a couple of ways to load external configuration, the pitfalls, and possible solutions.

Part I

Via Http and APP_INITIALIZER token
Link to this section

Let's begin by creating the config json file with some keys:

The full project is on StackBlitz
<>Copy
{ "API": { "apiRoot": "<http://localhost:8888/.netlify/functions>" }, "MyKey": "MyValue" }

The end result is to be able to get the configuration as a property of a service, or as a static member, for example:

<>Copy
constructor(private configService: ConfigService) { } ngOnInit(): void { const myValue = this.configService.Config.MyKey; // or const myStaticValue = ConfigService.Config.MyKey; }

APP_INITIALZER token
Link to this section

Using the APP_INITIALIZER token in AppModule, we can use HttpClient to request the json file. So we start in AppModule: (refer to Angular Initialization Tokens article)

<>Copy
@NgModule({ imports: [BrowserModule, HttpClientModule, CommonModule], declarations: [AppComponent, HelloComponent], bootstrap: [AppComponent], providers: [ { // TODO: create ConfigService and configFactory provide: APP_INITIALIZER, useFactory: configFactory, multi: true, deps: [ConfigService] }, ], }) export class AppModule {}

In a service file for ConfigService, let’s add the http call to get the configuration file, and find out what we can do with the result.

<>Copy
export const configFactory = (config: ConfigService): (() => Observable<boolean>) => { return () => config.loadAppConfig(); }; @Injectable({ providedIn: 'root', }) export class ConfigService { constructor(private http: HttpClient) { } // return observable, right now just http.get loadAppConfig(): Observable<boolean> { return this.http.get(environment.configUrl).pipe( map((response) => { // do something to reflect into local model this.CreateConfig(response); return true; }), catchError((error) => { // if in error, set default fall back from environment this.CreateConfig(defaultConfig); return of(false); }) ); } }

The evironment.configUrl in development would be the local file relative URL. Later will elaborate more on the strategy of how to handle the config file and its location.

The IConfig model looks like this:

<>Copy
export interface IConfig { API: { apiRoot: string; }; MyKey: string; }

The private method to cast configuration, should also return default configuration in case of failure. The extra configuration though does not have to match IConfig.

The default fallback config looks as follows (in app/config.ts file):

<>Copy
import { environment } from '../environments/dev.env'; export const Config = { API: { apiRoot: environment.apiRoot, }, MyKey: 'default value', ExtraKeys: 'wont harm', };

Back to the service, the CreateConfig should only try to cast, then set to a public property. This, later, is going to fail. But let's go on.

<>Copy
export class ConfigService { constructor(private http: HttpClient) {} private _createConfig(config: any): IConfig { // cast all keys as are const _config = { ...(<IConfig>config) }; return _config; } // public property public Config: IConfig; loadAppConfig(): Observable<boolean> { return this.http.get(environment.configUrl).pipe( map((response) => { // set to public property this.Config = this._createConfig(response); return true; }), catchError((error) => { // if in error, return fall back from Config this.Config = Config; return of(false); }) ); } }

The curious case of Router Initialization
Link to this section

The Router Module uses APP_INITIALIZER as referenced in master branch of Angular 13, and initialization functions are run in parallel according to source code. Without digging deeper into navigation options, it is already an open wound that needs to be patched. The sequence of events cannot be guaranteed in a module that uses both configuration and Route modules. One is going to happen before the other.

Route guards and resolves are examples of routing happening sooner than initialization response. The extreme case I reached after multiple trials:

  • The external configuration is remote, thus a bit slower than local
  • Routing option InitialNavigation is set to enabledBlocking, according to Angular docs, this is required for SSR.

A word of caution, leaving the InitialNavigation set to its default enabledNonBlocking will produce unexpected results in the resolve service. Filtering out unready configuration to avoid "fallback" values, the benefit of "non blocking" is nullified. Read the code comments as you go along.

Let's create an app routing module and add a router resolve with these extreme conditions.

<>Copy
// the routing module const routes: Routes = [ { path: 'project', component: ProjectComponent, resolve: { // add a project resolve ready: ProjectResolve, }, }, // ... ]; @NgModule({ imports: [ RouterModule.forRoot(routes, { // enabledBlocking for SSR, but also enabledNonBlocking is not as good as it sounds in this setup initialNavigation: 'enabledBlocking', }), ], exports: [RouterModule], }) export class AppRoutingModule {}

Import the AppRoutingModule into root AppModule, add a project component, and let's create the project resolver that returns an Observable of Boolean.

<>Copy
@Injectable({ providedIn: 'root' }) export class ProjectResolve implements Resolve<boolean> { // inject the service constructor(private configService: ConfigService) {} resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> { // log the value of the configuration here // if this is too soon, the result is undefined console.log('on resolve', this.configService.Config); return of(true); } }

We can run above code on Stackblitz and go to route /project, Console will log "undefined." Which means, the initial Route Resolver was faster than fetching config via Http. The solution to that, if we see it backwards, should be like this:

“wait till this.configService.Config is ready”

That translates to RxJS observable. So let me head to ConfigService and create an observable of an internal subject.

<>Copy
// config service export class ConfigService { constructor(private http: HttpClient) {} // keep track of config, initialize with fall back Config private config = new BehaviorSubject<IConfig>(Config as IConfig); config$: Observable<IConfig> = this.config.asObservable(); private _createConfig(config: any): IConfig { // cast all keys as are const _config = { ...(<IConfig>config) }; return _config; } loadAppConfig(): Observable<boolean> { return this.http.get(environment.configUrl).pipe( map((response) => { const config = this._createConfig(response); // here next this.config.next(config); return true; }), catchError((error) => { // if in error, return fall back from Config this.config.next(Config); console.log(error); return of(false); }) ); } }

In the resolve service, watching updates is not good enough, we need to signal end of stream, to return and move on. RxJS take(1) is usually recommended, but before we take 1, we need to filter out configuration that is not ready yet, otherwise, that "1" would be the fallback one. This is why enabledNonBlocking is useless in this setup, because the resolve service is blocking anyway.

Below are three attempts for the resolver, the initial attempt is to take(1), the second attempt is to filter before taking, and the last is a combination of both with first RxJS operator.

<>Copy
// in resolver, need to take 1 and return // This is the first attempt return this.configService.config$.pipe( take(1), map(n => { if (n.MyKey === 'default') { // the first one will actually be the fallback return false; } return true; })); // attempt two: filter before you take return this.configService.config$.pipe( filter(n => n['somevalue to distinguish remote config']) take(1), map(n => { if (n.MyKey === 'default') { return false; } // it will be true for sure return true; })); // last attempt, two in one: return this.configService.config$.pipe( first(n => n['somevalue to distinguish remote config'] map(n => { // it will be true for sure return true; }));

isServed is my new configuration property to "distinguish remote configuration" from fallback one. It's just a Boolean set to true in remote config.

<>Copy
// config json { "isServed": true, "API": { "apiRoot": "<http://localhost:8888/server/app>" }, "MyKey": "MyValue" }

Add it to the IConfig model, and to the default Config.

<>Copy
// config model: export interface IConfig { isServed: boolean; API: { apiRoot: string; }; MyKey: string; } // the default Config with isServed: false export const Config = { isServed: false, API: { apiRoot: environment.apiRoot, }, MyKey: 'default value', ExtraKeys: 'wont harm', };

The project resolve service is ready

<>Copy
@Injectable({ providedIn: 'root' }) export class ProjectResolve implements Resolve<boolean> { constructor(private configService: ConfigService) {} resolve( route: ActivatedRouteSnapshot, state: RouterStateSnapshot ): Observable<boolean> { // watch it until it's served return this.configService.config$.pipe( first((n) => n.isServed), map((n) => true) ); } }

The observable in the current setup shall produce two values, the first is isServed set to false. To read the configuration in a component:

<>Copy
@Component({ template: `Project page with resolve <p> {{ config$ | async | json}} </p>` }) export class ProjectComponent implements OnInit { config$: Observable<IConfig>; constructor(private configService: ConfigService) { } ngOnInit(): void { this.config$ = this.configService.config$; } }

A final touch to garnish, for off the track usage, we add a static getter, that returns the value of the configuration:

<>Copy
// config service // make a static member private static _config: IConfig; // and a static getter with fallback static get Config(): IConfig { return this._config || Config; } private _createConfig(config: any): IConfig { const _config = { ...(<IConfig>config) }; // set static member ConfigService._config = _config; return _config; } // ... // This can be used directly, for example in a template // {{ ConfigService.Config.isServed }}

Pitfalls
Link to this section

  1. If the remote configuration does not have all keys expected, they will be overwritten to "null". To overcome, extend the configuration, via shallow cloning.
<>Copy
private _createConfig(config: any): IConfig { // shallow extension of fallback const _config = {...Config, ...(config) }; ConfigService._config = _config; return _config; }

2. The default Config may be mistaken for ConfigService.Config, if ever used, the default fallback value is in place. To fix that, a separation between the general Config, and remote Config fallback may be needed, or a little bit of attention. You can also make it a habit to use ConfigService, or make the default Config a private element. Treat it per project needs.

3. If the config file needed in Route Resolve or Guard fails to be served, we're blocked. Placing the config file on the same server, or a combination of RxJS operators, are possible solutions. A second property that identifies failure in Config is also a solution. We will use this method later.

4. The URL of the config file, cannot be part of the configuration keys!

5. Remember to filter out the URL of your config file in your HTTP interceptor, if you prefix URLs with a value fed by configuration.

Where to place the config file
Link to this section

The benefit aspired for is to have production-specific configurations for every deployed version, ready to be adjusted for whatever prompt reason. As much as you would like to believe that touching production is taboo, there shall be times when the kitchen is on fire.

The question is, where to place configuration during development.

  1. Remote server. Can be an inhouse local server, or a staging server.
  2. Mock server, a nodejs local server that you run before starting Angular.
  3. On a root folder, e.g. "configs", served via angular.json assets This will also copy the file into production, and used with the same URL, but if that is not your intention, remove this entry from production assets in angular.json.
<>Copy
// add this to assets in angular.json "assets": [ { "glob": "*.json", "input": "configs", "output": "/localdata" } ] // now, every ./configs/*.json will be accessed as /localdata/*.json

Wherever you decide to place your configuration, remember to update respective environments.

Part II

External configurations in Angular Universal
Link to this section

Let’s test some setups for SSR, to see what other things we need to fix.

External Remote Configuration
Link to this section

Expanding on StackBlitz Token Test Project, where the URL of the configuration had to be set to remote HTTP, building locally and testing for server, produced identical results. The project resolve worked as expected. The only issue was: failure of remote URL meant blocking of app. This is one of the pitfalls of having a remote config. One way to fix that, is as follows:

Slight fix to configuration
Link to this section

We want to distinguish served configurations, but we do not want to block the UI in case of failure. The project resolve for example, should decide what to do with error:

<>Copy
return this.configService.config$.pipe( first((n) => n.isServed), map((n) => { // if served with error, reroute or notify user, but do not block user console.log(n.withError); // let's introduce this property return true; }) );

In ConfigService I will stop making a distinction between success and failure, they both are served. Then by introducing withError property, we will set it to true when failing.

<>Copy
// after defining withError property in IConfig... private _createConfig(config: any, withError: boolean): void { // cast all keys as are const _config = { ...Config, ...(<IConfig>config) }; // is served, always _config.isServed = true; // with error _config.withError = withError; // set static member ConfigService._config = _config; // next, always next the subject this.config.next(config); } loadAppConfig(): Observable<boolean> { return this.http.get(environment.configUrl).pipe( map((response) => { // create with no errors this._createConfig(response, false); return true; }), catchError((error) => { // if in error, return fall back from environment // and create with errors this._createConfig(Config, true); return of(false); }) ); }

This works as expected, however, if the HTTP request fails on server, Angular will attempt to reconnect after rehydration, on client.

External Local Configuration
Link to this section

Moving the files to localdata folder using angular.json assets:

<>Copy
"assets": [ { "glob": "*.json", "input": "configs", "output": "/localdata" } ]

The config URL now looks like this localdata/config.json. It is relative.

According to Angular Docs:

“If you are using one of the @nguniversal/*-engine packages (such as @nguniversal/express-engine), this is taken care of for you automatically. You don't need to do anything to make relative URLs work on the server.”

Well, I get:

GET localdata/config.prod.json NetworkError

I guess what they mean is that, if you go their way of rendering, you are covered. That is, if you use this:

<>Copy
server.get('*', (req, res) => { res.render(indexHtml, { req, providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }] }); });

But I don't. And I will tell you why, and how. Then I will go through the solution for the relative URLs.

Isolating the server
Link to this section

If we follow the documentation Server-side rendering (SSR) with Angular Universal it walks you through building the server in src folder, and generating the server in a build process. I find that too obtrusive. Coming from old school, I cannot sleep well while my server is in my development source code. If something on the server goes wrong, I have to build and test? Every time? Not cool.

One good scenario, is serving a multilingual Angular app, using the same build.

Let's first reduce the size of the server.ts suggested by Angular Docs, to have only the ngExpressEngine, export it, and create a separate express app.

<>Copy
// server.ts // export the ngExpressEngine export const AppEngine = ngExpressEngine({ bootstrap: AppServerModule });

Building the ssr uses the following angular.json settings

<>Copy
// ... angular.json "architect": { // ... "server": { "builder": "@angular-devkit/build-angular:server", "options": { // choose the output path where the main.js will end up "outputPath": "./host/server", "main": "server.ts", "tsConfig": "tsconfig.server.json" }, "configurations": { "production": { // don't delete because there will be other files "deleteOutputPath": false // ... } } } }

The main.js generated will end up in outputPath, let's create a server there, and use the exported AppEngine.

<>Copy
// host/server.js const express = require('express'); // express app const app = express(); // setup express require('./server/express')(app); // setup routes require('./server/routes')(app); // other stuff is up to you // listen const port = process.env.PORT || 1212; app.listen(port, function (err) { console.log('started to listen to port: ' + port); if (err) { console.log(err); return; } });

The express module is basic, you can have a look at it on StackBlitz. The routes.js is where the cooking happens:

Note: I cannot test on StackBlitz, you may want to use __dirname to get accurate paths

<>Copy
const express = require('express'); // ngExpressEngine from compiled main.js const ssr = require('./main'); // setup the routes module.exports = function (app) { // set engine, we called it AppEngine in server.ts app.engine('html', ssr.AppEngine); // set view engine app.set('view engine', 'html'); // set views directory, the clientside build output app.set('views', '../client'); // expose the configs path as localdata (or whatever you choose to name it) app.use('/localdata', express.static('../localdata', { fallthrough: false })); // expose client folder app.use(express.static('../client')); // now THIS app.get('/*', (req, res) => { // point to your index.html res.render(`../client/index.html`, { req, // pass request res, // pass response // here, we can provide things for ssr }); }); };

In res.render, I passed back response and request just in case I want to use them in Angular. (It's rare, but it happens). So that's the why, and how.

Provide absolute URLs for local requests
Link to this section

A local request is like our localdata/config.prod.json. To fix it, it must be prepended by the server URL. Our final result in ConfigService should look like this:

<>Copy
loadAppConfig(): Observable<boolean> { // fix url first if its on server let url = environment.configUrl; if (serverUrlExsits) { url = serverUrl + url; } return this.http.get(url).pipe( // ... etc ); }

The URL on the server is constructed using the REQUEST injection token, as documented on NPM packages.

<>Copy
// change ConfigService // for this line to work, install @types/express import { Request } from 'express'; import { REQUEST } from '@nguniversal/express-engine/tokens'; @Injectable() export class ConfigService { // make it Optional to work on browser platform as well constructor(@Optional() @Inject(REQUEST) private request: Request) {} loadAppConfig(): Observable<boolean> { // fix url first if its on server let url= environment.configUrl; if (this.request) { // on ssr get a full url of current server url = `${this.request.protocol}://${this.request.get('host')}/${url}`; } // ... etc } }

Since we already provided req in the res.render call, this is sufficient. But it looks ugly. We can create an HTTP interceptor for localdata to make use of any other localdata. But first:

The curious case of reverse proxy
Link to this section

Without digressing beyond the scope of this article, reverse proxy and load balancing on production servers usually proxy https into http, and real.host.com into localhost. The latter, we fixed by using req.get('host') which accesses the header. And to fix the protocol, we access another header value: x-forwarded-proto.

Here is an azure website example I set up, notice how the values in the header, are different than plain ones, because of cloud hosting setup:

aumet.azurewebsites.net/webinfo

<>Copy
{ "request": { "headers": { "host": "aumet.azurewebsites.net", "disguised-host": "aumet.azurewebsites.net", "x-original-url": "/webinfo", "x-forwarded-for": "client-ip-address-here", "x-forwarded-proto": "https" }, // on other servers this could be localhost "hostname": "aumet.azurewebsites.net", "path": "/webinfo", // don't read this value "protocol": "http", } }

But before I add that to my Angular App, back to being obsessive about separation of concerns, this is not an Angular issue, thus it shall not belong to the app. I would rather set up the right URL, and provide it. Like this:

<>Copy
// in host/server/routes.js // change the final get app.get('/*', (req, res) => { // fix and provide actual url let proto = req.protocol; if (req.headers && req.headers['x-forwarded-proto']) { // use this instead proto = req.headers['x-forwarded-proto'].toString(); } // also, always use req.get('host') const url= `${proto}://${req.get('host')}`; res.render(`../client/index.html`, { req, res, // here, provide it providers: [ { provide: 'serverUrl', useValue: url, }, ], }); });

Back to our Angular App, let's create a proper HTTP interceptor, to intercept localdata calls:

<>Copy
// Angular interceptor @Injectable() export class LocalInterceptor implements HttpInterceptor { constructor( // inject our serverUrl @Optional() @Inject('serverUrl') private serverUrl: string ) {} intercept(req: HttpRequest<any>,next: HttpHandler): Observable<HttpEvent<any>> { // if request does not have 'localdata' ignore if (req.url.indexOf('localdata') < 0) { return next.handle(req); } let url= req.url; if (this.serverUrl) { // use the serverUrl if it exists url= `${this.serverUrl}/${req.url}`; } const adjustedReq = req.clone({ url: url}); return next.handle(adjustedReq); } }

Provide the HttpInterceptor in AppModule

<>Copy
// app.module.ts providers: [ { provide: APP_INITIALIZER, useFactory: configFactory, multi: true, deps: [ConfigService], }, // provide http interceptor here { provide: HTTP_INTERCEPTORS, useClass: LocalInterceptor, multi: true, }, ],

And clean up ConfigService from any reference to our server. Building, testing, works.

We can change the server config.prod.json without restarting the server, nor worry about polluting other environments, and servers. Now I can sleep better.

Providing the config on server
Link to this section

Now that we have a separate server, and the configuration file is not remote, why not provide the config and inject it in the ConfigService?

<>Copy
// host/server/routes.js // require the json file sitting in localdata const localConfig = require('../localdata/config.prod.json'); // setup the routes module.exports = function (app) { // ... res.render(`../client/index.html`, { req, res, // also provide the localConfig providers: [ { provide: 'localConfig', useValue: localConfig } // though don't lose the serverUrl, it's quite handy ] }); }); };

In ConfigService

<>Copy
constructor( private http: HttpClient, // optional injector for localConfig @Optional() @Inject('localConfig') private localConfig: IConfig ) {} loadAppConfig(): Observable<boolean> { // if on server, grab config without HTTP call if (this.localConfig) { this._createConfig(this.localConfig, true); return of(true); } return this.http.get(environment.configUrl).pipe( // ... ); }

This is the fastest and least error prone method for the server to get configuration. But it might be an overkill for some. May the force be with you.

Part III

Previously we 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
<>Copy
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:

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

Injecting a script
Link to this section

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 output differently just to remember that the rule exists.

<>Copy
{ //... angular.json "assets": [ { "glob": "*", "input": "configs", "output": "/localdata" }

Implementing APP_INITIALIZER
Link to this section

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

<>Copy
// 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:
Link to this section

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 the 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 conditions:

  • 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.

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

Implementing PLATFORM_INITIALIZER
Link to this section

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.)

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

In main.ts

<>Copy
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.

<>Copy
// 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 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 browser platform, it all works. Now on to server platform.

Server Platform
Link to this section

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, it 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:

<>Copy
// 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 the 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:

<>Copy
// 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
Link to this section

  • 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 server platform.
  • To serve SSR, you need the extra baggage

Thank you for reading this far, let me know in the comments below if we stepped on a rotten tomato.

Share

About the author

default

Been in web development business for 20 years, long enough to be lost in titles

author_image

About the author

Amal Ayyash

Been in web development business for 20 years, long enough to be lost in titles

About the author

default

Been in web development business for 20 years, long enough to be lost in titles

Looking for a JS job?
Job logo
Senior Frontend Software Engineer (Angular)

Argument

Ukraine
Remote
$54k - $72k
Job logo
Front-End Web Software Engineer (Angular12 + ASP.NET)

MWS Technology

Ukraine
Remote
$36k - $60k
Job logo
Angular Software Developer

Salamander Technologies

America
Remote
$80k - $95k
Job logo
Senior Front End Developer - Angular

triValence

United States
Remote
$125k - $160k
More jobs

Featured articles