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:
- One deployment takes several minutes
The initContainer needs to run every time the app is redeployed. This always runs theng build
command of course. To prevent it to build the ES-modules every time the container already runs theng build
command in the priorRUN
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. - 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:
- 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"
}
- We added code to the
main.ts
file that probes the currentwindow.location.pathname
recursively through the parent history to find theconfig.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));
});
- Inside the
app.module.ts
theAPP_BASE_HREF
is initialized with theAPP_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.