Search code examples
angulardockerkubernetesbase-path

Angular 2+ - Dynamically change base path of app


Intro

I know this question has been asked in many different forms. But I have already read every post and article I could find on that matter and could not solve my problem with the prerequisites we face. Therefore I would like to describe our problem first and give our motivation, and thereafter describe the different methods we already tried or am using, even though they are unsatisfactory.

Problem/Motiviation

Our setup could be described as somewhat complex. Our Angular application is deployed on several Kubernetes clusters and served from different paths.

The clusters themselves are behind proxies and Kubernetes itself adds a proxy named Ingress itself. Thus a common setup for us may look like the following:

Local

http://localhost:4200/

Local Cluster

https://kubernetes.local/angular-app

Development

https://ourcompany.com/proxy-dev/kubernetes/angular-app

Staging

https://ourcompany.com/proxy-staging/kubernetes/angular-app

Customer

https://kubernetes.customer.com/angular-app

The application is always served with a different base-path in mind. As this is Kubernetes, our app is deployed with Docker-containers.

Classically, one would use ng build to transpile the Angular app and then use e.g. a Dockerfile like the following:

FROM nginx:1.17.10-alpine

EXPOSE 80

COPY conf/nginx.conf /etc/nginx/conf.d/default.conf
RUN rm -rf /usr/share/nginx/html/*
COPY dist/ /usr/share/nginx/html/

To tell the CLI how to set the base-href and where assets are served from one would use --base-href and --deploy-url options of the Angular CLI.

This unfortunately means, that we need to build a Docker-container for every environment, in which we want to deploy the application.

Furthermore, we have to take care in this prebuilding process to set other environment variables e.g. inside the environments[.prod].ts for every deployment environment.

Current Solution

Our current solution comprises of building the app therefore during the deployment. That works by using a different Docker-container used as a so called initContainer which runs before the nginx-container is started.

The Dockerfile for this one looks as follows:

FROM node:13.10.1-alpine

# set working directory
WORKDIR /app

# add `/app/node_modules/.bin` to $PATH
ENV PATH /app/node_modules/.bin:$PATH

# install and cache app dependencies
COPY package*.json ./

RUN npm install

COPY / /app/

ENV PROTOCOL http
ENV HOST localhost
ENV CONTEXT_PATH /
ENV PORT 8080
ENV ENDPOINT localhost/endpoint
VOLUME /app/dist

# prebuild ES5 modules
RUN ng build --prod --output-path=build
CMD \
  sed -i -E "s@(endpoint: ['|\"])[^'\"]+@\1${ENDPOINT}@g" src/environments/environment.prod.ts \
  && \
  ng build \
  --prod \
  --base-href=${PROTOCOL}://${HOST}:${PORT}${CONTEXT_PATH} \
  --deploy-url=${PROTOCOL}://${HOST}:${PORT}${CONTEXT_PATH} \
  --output-path=build \
  && \
  rm -rf /app/dist/* && \
  mv -v build/* /app/dist/

Incorporating this container into a Kubernetes deployment as an initContainer allows the app to be built with the correct base-href, deployment-url and that specific variables are replaced depending on the environment this app is deployed in.

This approach works perfectly except for a few problems it creates:

  1. One deployment takes several minutes
    The initContainer needs to run every time the app is redeployed. This always runs the ng build command of course. To prevent it to build the ES-modules every time the container already runs the ng build command in the prior RUN directive to cache these.
    Nevertheless, the building for differential loading and the Terser etc. are running again which takes several minutes until completion.
    When there is a horizontal pod autoscaling then it would take forever for the additional pods to be available.
  2. The container contains the code. This is more a policy but it is discouraged in our company to deliver the code with a deployment. At least not in an non-obfuscated form.

Because of those two issues we decided to move the logic away from Kubernetes/Docker directly into the app itself.

Planned Solution

After some research we stumbled on the APP_BASE_HREF InjectionToken. Thus we tried to follow different guides on the web to dynamically set this depending on the environment the app is deployed in. What was concretely first done is this:

  1. add a file named config.json in /src/assets/config.json with the base-path in the content (and other variables)
{
   "basePath": "/angular-app",
   "endpoint": "https://kubernetes.local/endpoint"
}
  1. We added code to the main.ts file that probes the current window.location.pathname recursively through the parent history to find the config.json.
export type AppConfig = {
  basePath: string;
  endpoint: string;
};

export const APP_CONFIG = new InjectionToken<AppConfig>('APP_CONFIG');

export async function fetchConfig(): Promise<AppConfig> {
  const pathName = window.location.pathname.split('/');

  for (const index of pathName.slice().reverse().keys()) {

    const path = pathName.slice(0, pathName.length - index).join('/');
    const url = stripSlashes(`${window.location.origin}/${path}/assets/config.json`);
    const promise = fetch(url);
    const [response, error] = await handle(promise) as [Response, any];

    if (!error && response.ok && response.headers.get('content-type') === 'application/json') {
      return await response.json();
    }
  }
  return null;
}

fetchConfig().then((config: AppConfig) => {
  platformBrowserDynamic([{ provide: APP_CONFIG, useValue: config }])
    .bootstrapModule(AppModule)
    .catch(err => console.error('An unexpected error occured: ', err));
});
  1. Inside the app.module.ts the APP_BASE_HREF is initialized with the APP_CONFIG
@NgModule({
  providers: [
  {
    provide: APP_BASE_HREF,
    useFactory: (config: AppConfig) => {
      return config.basePath;
    },
    deps: [APP_CONFIG]
  }
  ]
})
export class AppModule { }

Important: we used this approach instead of using the APP_INITIALIZER because when we tried it the provider for the APP_BASE_HREF was always run before the provider for the APP_INITIALIZER, thus the APP_BASE_HREF was always undefined.

Unfortunately, this only worked locally and does not work while the app is proxied. The issue we have observed here is, that when the app is initially served by the webserver without specifying the base-href and the deploy-url, the app tries to load everything from '/' (root) obviously.

But this also means it tries to fetch the angular scripts aka main.js, vendor.js, runtime.js and all the other assets from there and so none of our code is actually run.

To fix this we adapted the code slightly.

Instead of letting angular probe the server for the config.json we placed the code directly inside the index.html and inlined it.
Like this we could find the base-path and replace all the links in the html with the prefixed one, to load at least the scripts and other assets. This looks as follows:

<body>
  <app-root></app-root>
  <script>
    function addScriptTag(d, src) {
      const script = d.createElement('script');
      script.type = 'text/javascript';
      script.onload = function(){
        // remote script has loaded
      };
      script.src = src;
      d.getElementsByTagName('body')[0].appendChild(script);
    }

    const pathName = window.location.pathname.split('/');

    const promises = [];

    for (const index of pathName.slice().reverse().keys()) {

      const path = pathName.slice(0, pathName.length - index).join('/');
      const url = `${window.location.origin}/${path}/assets/config.json`;
      const stripped = url.replace(/([^:]\/)\/+/gi, '$1')
      promises.push(fetch(stripped));
    }

    Promise.all(promises).then(result => {
      const response = result.find(response => response.ok && response.headers.get('content-type').includes('application/json'));
      if (response) {
        response.json().then(json => {
          document.querySelector('base').setAttribute('href', json.basePath);
          for (const node of document.querySelectorAll('script[src]')) {
            addScriptTag(document, `${json.basePath}/${node.getAttribute('src')}`);
            node.remove();
            window['app-config'] = json;
          }
        });
      }
    });
  </script>
</body>

Furthermore we had to adapt the code inside the APP_BASE_HREF provider as follows:

useFactory: () => {
  const config: AppConfig = (window as {[key: string]: any})['app-config'];
  return config.basePath;
},
deps: []

Now what happens is, that it loads the page, replaces the source urls with the prefixed ones, loads the scripts, loads the app and sets the APP_BASE_HREF.

Routing seems to work but all the other logic like loading language-files, markdown-files and other assets doesn't work anymore.

I think the --base-href option actually sets the APP_BASE_HREF but what the --deploy-url option does I could not find out.
Most of the articles and posts specify that it is enough to specify the base-href and the assets would work as well, but that does not seem to be the case.

Question

Considering all that, my question then would be how to design an Angular app to definitely be able to set its base-href and deploy-url, so that all angular features like routing, translate, import() etc. are working as if I had set those via the Angular CLI?

I am not sure if I gave enough Information to fully comprehend what our problem is and what we expect, but if not I will provide it if possible.


Solution

  • It took some time but we finally managed to find an easy enough solution to cover all the points we made in the question.

    The solution is quite close to the original approach of just building the Angular app offline and copying the content inside an nginx container, mixed with the build process of previously used in the init container.

    Our solution then looks as follows. We first build the application offline and inject some recognisable string where the base-href and the deploy-url should be later.

    ng build --prod --base-href=http://recognisable-host-name/ --deploy-url=http://recognisable-host-name/
    

    The resulting runtime*.js and the index.html file will contain these inside the code and as the base for assets to load.

    Then in the second phase the Dockerfile for a standard Angular application is adapted from

    FROM nginx:1.17.10-alpine
    
    EXPOSE 80
    
    COPY conf/nginx.conf /etc/nginx/conf.d/default.conf
    RUN rm -rf /usr/share/nginx/html/*
    COPY dist/ /usr/share/nginx/html/
    

    to the new version like this

    FROM nginx:1.17.10-alpine
    
    EXPOSE 80
    
    COPY conf/nginx.conf /etc/nginx/conf.d/default.conf
    RUN rm -rf /usr/share/nginx/html/*
    COPY dist/ /usr/share/nginx/html/
    
    ENV CONTEXT_PATH http://localhost:80
    ENV ENDPOINT http://localhost/endpoint
    ENV VARIABLE foobar
    
    CMD \
      mainFiles=$(ls /usr/share/nginx/html/main*.js) \
      && \
      for f in ${mainFiles}; do \
        envsubst '${ENDPOINT},${VARIABLE}' < "$f" > "${f}.tmp" && mv "${f}.tmp" "$f"; \
      done \
      && \
      runtimeFiles=$(grep -lr "recognisable-host-name" /usr/share/nginx/html) \
      && \
      for f in ${runtimeFiles}; do sed -i -E "s@http://recognisable-host-name/?@${CONTEXT_PATH}@g" "$f"; done \
      && \
      nginx -g 'daemon off;'
    

    Here first we pass the substitutions we would like to make. First the CONTEXT_PATH is the full base path that will replace the http://recognisable-host-name string we injected before, by first finding all the files containing the string and then replacing the it with the sed command.

    The other variables that may be environment specific can also be replaced by utilising envsubst and replacing all mentions of those variables from the main*.js files. Therefore the environments.prod.ts is prepared as follows:

    export const environment = {
        "endpoint": "${ENDPOINT}",
        "variable": "${VARIABLE}"
    }
    

    This takes care of all the main points I raced, namely:

    1. do not share the original code
    2. make a deployment fast
    3. container can be served behind any proxy (e.g. Nginx-Ingress/Kubernetes)
    4. environment variables can be injected

    I hope it helps somebody else, especially because as I was researching this topic I found dozens of articles but none met all the criteria or necessitated lots of custom code that is complex or inefficient or hard to maintain.

    Edit:

    If somebody is interested, I set up a demo project where this solution can be tested:
    https://gitlab.com/satanik-angular/base-path-problem