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
// you might as well consider having a namespace
const WebConfig = {
isServed: true,
API: {
apiRoot: 'url/server/app',
},
MyKey: 'MyValue',
};
anchorInjecting 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"
}
anchorImplementing 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>
anchorImplementing 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.
anchorImplementing 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.
anchorSSR
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.
anchorChallenges
- It must be in HTML, thus, if you choose to differentiate
config.js
fromconfig.prod.js
you will end up withindex.dev.html
andindex.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
anchorA 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.