DEV Community

Cover image for Creating Envless Angular-application
Maksim Dolgih
Maksim Dolgih

Posted on

Creating Envless Angular-application

Ways to move from hard-coded code for each environment to a universal build that can be used anywhere

Introduction

As you all know, Angular has its own tools for building an application for different environments

Configuring application environments

This is accomplished by creating and using the environment.<env>.ts file for the appropriate environment in the build. These allow you to switch between settings for:

  • Development (environment.ts)

  • Testing (environment.test.ts)

  • Production (environment.prod.ts)

The main tasks of environment.ts files are:

  • API settings. Each file can contain different URLs for API servers depending on the environment.

  • Optimization. The production file disables debugging features and enables optimization to improve performance.

  • Environment variables. Easily manage environment variables such as API keys and flags to activate or deactivate functions.

And everything seems to be fine — a different file for each environment

Problem

Imagine, as the number of environments grows, you need to:

  • Create a separate environment.<env>.ts file each time.

  • Create a separate build-configuration and specify fileReplacements

  • Add this build-configuration to serve-build

  • Add the command "my-app.build.<env>": "ng build — configuration <env>” to package.json

So every time

another one meme

I’m not speaking about e2e-tests in pre-prod-environment or dedicated builds for feature-branch.

And for what? — To use a template command in CI

- run: npm run my-app:build:${{ SOME_VAR.ENV }}
Enter fullscreen mode Exit fullscreen mode

The notional pipelines of such an application can be depicted as follows

Typical pipeline build and deploy workflow

And you can say — “this is basic scaling and CI independence from env. If we want to use TEST1, just pass ENV=TEST1”.

No, my young architect, this is wrong. Why? — Because your application knows about all the environments in which it is used, and keeping track of every configuration is a problem:

  • Do you want to add 1 new parameter to the config? — you need to update each file, plus you need to know what value is needed for each env.

  • Want to change a parameter for an application in some environment? — Be kind enough to go to the repositories, update the file and run it

There are so many examples of this, and every time there are scalability and support issues

Disadvantages of using environments.ts files

  • Support for all environments.<env>.ts files is required

  • Creating alternative builds for each env in angular.json

  • Creating duplicate docker images with only a few parameters, resulting inefficient registry space utilization

  • Necessity to run a full pipeline for each

  • Tests are performed on an assembly that will not be provided to the user

  • Lack of flexibility to change a parameter for an individual environment

  • Inability to share the application as a HeadLess solution

Task Statement

Main points to achieve:

  • Ensure that the application and docker image are built only 1 time.

  • Our application should be able to be configured for different ENVs, and define only the interface of the variables it needs

  • To separate the development space and work with the application build, we need to keep the source files environment.ts and environment.prod.ts

As a result, we should get the following scheme for using and deploying the application

Build and deploy workflow

Where:

  • Envless Build pipeline is a pipeline that runs only once, builds the production build of the application, and saves it as a docker image in the registry

  • Deploy pipeline is a release pipeline that already knows for which ENV the application should be deployed.

The application configuration is pulled back until the last moment closer to the release part


Solution 1 — Get config from the server

To implement this solution, it is necessary to have an API-endpoint server within which all the necessary config can be obtained

Scheme of work

Deploy workflow with configuration by server

Implementation

  • Let’s declare a configure interface and a token for its provisioning within the application. Also, we’ll create a default value for the initial state
export interface IAppConfig {
  apiHost: string,
  imageHost: string
  titleApp: string
}

export const APP_CONFIG_DEFAULT:IAppConfig = {
  apiHost: 'https://my-backend.com',
  imageHost: 'https://image-service.com',
  titleApp: 'Production Angular EnvLess App'
}

export const APP_CONFIG_TOKEN: InjectionToken<IAppConfig> = new InjectionToken<IAppConfig>(
    'APP_CONFIG_TOKEN'
)
Enter fullscreen mode Exit fullscreen mode
  • Create functions that will request the config and provide it. In case of server error, provide the application with default settings for valid operation
  function loadConfig(): Promise<IAppConfig> {
      // or relative path /app/config
      return fetch('http://localhost:3000/app/config').then(
          (res) => res.json(),
          () => APP_CONFIG_DEFAULT
      )
  }

  export async function bootstrapApplicationWithConfig(
      rootComponent: Type<unknown>,
      appConfig?: ApplicationConfig
  ): Promise<ApplicationRef> {
   return bootstrapApplication(rootComponent, {
       providers: [
           ...appConfig?.providers || [],
           {
               provide: APP_CONFIG_TOKEN,
               useValue: await loadConfig()
           },
       ]
   })
  }
Enter fullscreen mode Exit fullscreen mode
  • Replace the native bootstrapApplication function with a new bootstrapApplicationWithConfig function
  // bootstrapApplication(AppComponent, appConfig)
  bootstrapApplicationWithConfig(AppComponent, appConfig)
    .catch((err) => console.error(err));
Enter fullscreen mode Exit fullscreen mode
  • The application configuration server will be a simple configuration on express
  app.get('/app/config', (req, res) => {
      res.json({
          apiHost: 'https://my-backend.<ENV>.com',
          imageHost: 'https://image-service.<ENV>.com',
          titleApp: '<Env> - Angular EnvLess App'
      });
  });
Enter fullscreen mode Exit fullscreen mode

Run-time verification

Since the application is configured in run-time, it is not a problem to check its operation even in a local environment, knowing the required URL.

At the moment of application startup, we get the config on request without any problems. We can use this config for other application services that use other API URLs for requests

Successful request config.json

In case of a response error from the server, the default value will be used and the application will continue working

Rejected or failed request

Advantages and disadvantages

In addition to fulfilled task conditions, there is other advantages of using this approach:

  • The application is configured by runtime and does not require redeployment

  • The configuration is loaded before the application starts, so you can use parallel requests via APP_INITIALIZER tokens without worrying about the order in which the config is received

  • In e2e-tests, it is easy to intercept the request and give the configuration file for the test

But there is a downside:

  • You need to know the URL to get the config or have the same host for both front- and back-end application

  • The response from the server can be long, which can affect the user’s expectation

  • It is necessary to have a FALLBACK value in case of a request error

  • It is necessary to have a dedicated database with config for each ENV

  • It is necessary to have an API for reading and modifying the config by an administrator with dedicated access rights

  • Due to run-time configuration, there is an increased chance of errors and the config needs to be validated in the front-end application

  • Configuration support is provided by backend developers and DevOps

If we don’t touch the basic build pipeline, is there any possibility to keep the scheme with getting the config, but no longer depend on the server considering its disadvantages? — Yes, you can configure a docker image


Solution 2 — Configuring a docker image

The essence of this solution is simple — instead of requesting a remote server to get the config, there will be a request to the file directory where the Frontend application is located

The configuration file will be created during the source docker image retrieval stage, replacing the default config.json. The default directory in which the front-end application executes requests is assets or public.

The configuration file will be created on the ENV variables that were specified when the deploy pipeline was started. If the ENV variable is not found — the default value will be used

Scheme of work

The solution is elegant but will require skills not only in frontend, but also in DevOps and CI-scripts

You will need to implement this scheme of work to “update” the config.json file

Workflow of “patch image” job

Deploy Pipeline

Deploy workflow with configuration by docker-image

Implementation

As opposed to getting config.json from the server. We need the keys in the config to match the ENV variable name when the config is updated

  • Local assets/config.json
{
  "APP_ENV_API_HOST": "https://local.my-backend.com",
  "APP_ENV_API_IMAGE_HOST": "https://local.image-service.com",
  "APP_ENV_TITLE_APP": "Local - Angular EnvLess App"
}
Enter fullscreen mode Exit fullscreen mode
  • Config request function with updated URL
function loadConfig(): Promise<IAppConfig> {
    // relative host
    // public or assets path
    return fetch('/config.json').then(
        (res) => res.json(),
    )
}
Enter fullscreen mode Exit fullscreen mode
  • Example script to create a new config.json and update docker-image
  #!/bin/bash

  set -x
  set -e

  # EXAMPLE - ENVS FOR CONFIG
  APP_ENV_API_HOST="https://<ENV>.my-backend.com"
  APP_ENV_API_IMAGE_HOST="https://<ENV>.image-service.com"

  # SETTINGS
  PORT=4110
  NGINX_PORT=80
  CONTAINER_NAME="angular-envless-container"
  IMAGE="angular-envless"
  NEW_IMAGE="patched-angular-envless"
  CONFIG_NAME="config.json"
  APP_PATH="/usr/share/nginx/html"

  #Step 1
  temp_container_run(){
    docker run -it -d -p $PORT:$NGINX_PORT --name $CONTAINER_NAME $IMAGE
  }

  #Step 2
  temp_container_get_config(){
    docker cp $CONTAINER_NAME:$APP_PATH/$CONFIG_NAME ./$CONFIG_NAME
  }

  #Step 3
  create_config_json(){
    temp_container_get_config

      if [[ ! -f "./$CONFIG_NAME" ]]; then
        echo "Config file not found in the specified directory."
        temp_container_stop
        temp_container_rm
        return 1
      fi

      # Extracting keys and values from JSON
      KEY_VALUES=$(awk -F '[:,]' '/:/{gsub(/"| /,""); print $1 "=\"" $2 "\""}' "./$CONFIG_NAME")

      # Creating a new JSON object
      PROD_CONFIG="{"

      # Passing through keys and values
      for PAIR in $KEY_VALUES; do
        # Separating key and value
        KEY=$(echo $PAIR | cut -d '=' -f 1)
        DEFAULT_VALUE=$(echo $PAIR | cut -d '=' -f 2 | sed 's/,$//') 

        # Check if there is a value in the environment variables
        VALUE=${!KEY}

        # If there is no environment variable, we use the value from the source file
        if [[ -z "$VALUE" ]]; then
          VALUE=$DEFAULT_VALUE
        else
          VALUE="\"$VALUE\""
        fi

        # Add key and value to JSON object
        PROD_CONFIG+="\"$KEY\":$VALUE,"
      done

      # Remove the last comma and close the JSON object
      PROD_CONFIG=${PROD_CONFIG%,}
      PROD_CONFIG+="}"

      # Saving the result to a file
      echo "$PROD_CONFIG" > "./$CONFIG_NAME"

      echo "Config updated successfully and saved to ./$CONFIG_NAME"
  }

  #Step 4
  temp_container_upsert_config(){
    docker cp ./$CONFIG_NAME $CONTAINER_NAME:$APP_PATH/$CONFIG_NAME
  }

  #Step 5
  temp_container_commit(){
    docker commit --pause $CONTAINER_NAME $NEW_IMAGE
  }

  #Step 6.1
  temp_container_stop(){
    docker stop $CONTAINER_NAME
  }

  #Step 6.2
  temp_container_rm(){
    docker rm $CONTAINER_NAME
  }

  main(){
      temp_container_run
      create_config_json
      temp_container_upsert_config
      temp_container_commit
      temp_container_stop
      temp_container_rm
  }

  main

Enter fullscreen mode Exit fullscreen mode

Run-time verification

After running this script, all you need to do is run the docker image in the container and make sure that the environment variables are applied to the config

Successful request config.json

The code works fine, given that the APP_ENV_TITLE_APP variable was not passed when the docker-image was configured

Advantages and disadvantages

Thanks to this approach, the server’s influence on front-application configuration is reduced. In addition to the advantages of configuration through the server, we get:

  • The configuration is available immediately and delays in getting it are minimal

  • No FALLBACK value is required in case of a request error

  • No API or external administration over the config is required.

  • Reliability. The assembly cannot be broken in run-time

  • No need to create a separate database for each environment

But if you look at it from the other side, there are some disadvantages:

  • Increased complexity of the application’s deployment infrastructure

  • Separate Pipeline Deployment is required

  • Need to be supported and validated by DevOps-engineers

  • Need to know the right ENV-variables and set a pre-defined list to configure the application at the time the deployment starts


In conclusion

The solutions I have presented are only suitable if you need flexibility and independent management of your Frontend application. If you realize that your current application does not require flexible configuration, your DevOps engineers effectively manage docker-registry memory or you do not plan to create a headless application — you can use environment.ts files as before.

In some cases, when you don’t know the possible implementations and uses of an application at the start of development, this approach can save a lot of time in the future, and give you full control over build management

Top comments (0)