Environment settings in an Angular CI/CD build process


The Problem

When building large-scale Angular applications, most people eventually need to provide their application with environment-specific variables, which doesn’t seem like a very big deal on the surface.  After all, Angular provides us with the “environment.ts” file, right?

It should be as easy as filling in your environment settings and then using the environment variable throughout the site, but this method introduces a level of uncertainty into the build process that may not be acceptable for all applications.

This uncertainty comes primarily from the need to rebuild the application before those new variables are accessible.  For example, when changing from a Dev environment to a Test environment, the build process is at the mercy of hundreds of code packages that may or may not have updated since the last environment promotion.  This can cause time-consuming build errors that aren't even related to the quality of your code.

In some instances, it may be possible to avoid these errors by locking down dependencies with something like “npm shrinkwrap.” However, due to the complexity of the Angular CLI build process, this approach still allows for the potential of different outputs. Even if you manage to get consistent outputs, it’s still time-consuming to constantly rebuild the application in a build pipeline that could potentially get backed up from rapid promotions.

The Solution

Before the application loads, retrieve a CI/CD generated settings file from the server, which allows you to make sure that an unchanged code base can get up to date settings no matter the environment. Here's how our process works:

Create the configuration service

First, we create a service in one of your top-level Angular modules, typically the App Module or, if applicable, the Core Module.  You can also create this service in any non-lazy loaded module that provides services to the application.

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

public settings: AppSettings;
get WebUrl() {
return location.protocol + '//' + window.location.host + '/';
}
constructor() {}
public load() {
return new Promise((resolve, reject) => {
const self = this;
const xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = function () {
if (this.readyState === 4 && this.status === 200) {
const data = JSON.parse(this.responseText);
const jsonConvert = new JsonConvert();
self.settings = jsonConvert.deserializeObject(data.BaseSettings, AppSettings);
resolve(true);
}
if (this.status === 404) {
reject('Server cannot find Appsettings.js file');
}
};
xhttp.open('GET', 'appSettings.json', true);
xhttp.send();
}).catch(error => {
console.error(error);
});
}
}

The “load” function is where we get our settings.  Unfortunately, we can't use Angular’s built in HttpClient because it forms a circular reference with our authentication service, but depending on your project structure, you may be able to simplify this request with built-in Http functionality.

After we receive the data, we use an external library (Json2Typescript) to convert the settings JSON into a typescript class so we can include helper functions if necessary. We then assign this object to the “settings” property on this service to be used elsewhere.

Create the appSettings.json file

This file structure is entirely up to you and your preferred CI/CD tool, but here's an example for reference.

{
"BaseSettings": {
"ApiUrl": "http://localhost:99999/api/"
}
}

Include the appSettings.json file as an asset

Angular needs to be informed specifically about which files are assets so it includes them in the build process. We place our appSettings.json in the root “src” folder of our web application. Because of this, our assets list inside of the angular.json file looks like this:

"assets":[
"src/favicon.ico",
"src/assets",
"src/appSettings.json",
"src/web.config",
]

If you place the settings in a different folder, you'll need to link directly the file, rather than the folder location (e.g., “src/assets/appSettings.json” rather than “src/assets/”).

Tell the app to load settings before launch

The last step on the client side is to make sure the application knows to load the settings before anything else initializes. This is done through Angular’s built-in APP_INITIALIZER function. Below is an example of our core module using the APP_INITIALIZER to load settings.

export function loadConfig(config: AppConfigService) {
return () => config.load();
}

@NgModule({
imports: [
CommonModule,
HttpClientModule
],
declarations: [MainNavComponent],
providers: [
{
provide: APP_INITIALIZER,
useFactory: loadConfig,
deps: [AppConfigService],
multi: true
}
]
})
export class CoreModule { }

Your factory function should return a function that returns a promise. As you can see, we’ve injected our App Config service and returned our “load” function, which will return a promise. Once that is completed the rest of the application will be allowed to load, and the configuration service, with settings, will be available throughout the application.

Configure the build service

At this point, your angular application should be set. The only thing left to do is configure the release service to change the appSettings.json for every environment, and your application should retrieve them properly when it starts. We use Azure DevOps build/release to update the appSettings.json file, but you can use any release-management tool that supports updating json files.

BridgingApps Helps Families in Texas with Transition Tool Agile Feedback from McLeod's Perspective