our story about configurable frontend apps
... how we've done it with a little help of magic from The Fairies
specific environment.ts
ng build
artefact (dist folder)
deployment
running app
runtime
deployment
code/build
single environment.ts
ng build
artefact (dist folder)
deployment
running app
runtime
deployment
code/build
dev
stage
prod
browser url
dev
stage
prod
dev
stage
prod
export const environment = {
'dev.example.com': {
apiPrefix: '/api/v1/',
authServiceLocation: '/auth/'
},
'stage.example.com': {
apiPrefix: '/backend/api/v1/',
authServiceLocation: '/backend/auth/'
},
'example.com': {
apiPrefix: '/api/',
authServiceLocation: 'http://auth.example.com'
}
};
import { environment } from '../../environments/environment';
export function configInitializer(configService) {
return () => {
return new Promise((resolve, reject) => {
const hostname = window.location.hostname;
if (environment.hasOwnProperty(hostname)) {
configService.inject(environment[ hostname ]);
resolve();
return;
}
reject();
return;
});
};
}
ng build
artefact (dist folder)
deployment
running app
runtime
deployment
code/build
dev.json
stage.json
prod.json
browser url
dev
stage
prod
stage
dynamic import
dev
prod
normal app code
*.chunk.js
*.chunk.js
// app/configs/dev_example_com.config.json
{
"apiPrefix": "/api/v1/",
"authServiceLocation": "/auth/"
}
// app/configs/stage_example_com.config.json
{
"apiPrefix": "/api/v2/",
"authServiceLocation": "/auth/"
}
// app/configs/example_com.config.json
{
"apiPrefix": "/api/",
"authServiceLocation": "https://auth.example.com/"
}
import { ConfigService } from '../services/config.service';
export function configInitializer(configService){
return () => {
return new Promise((resolve, reject) => {
const hostname = window.location.hostname.replace(/\./gi, '_');
const importPromise = import(
/* webpackMode: "lazy" */`../../configurations/${ hostname }.config.json`
);
importPromise
.then((configuration) => {
configService.inject(configuration);
resolve();
})
.catch(reject);
});
};
}
preprocess
artefact (dist folder)
running app
runtime
deployment
code/build
index.template.html
via placeholders
index.html
with env vars placeholders
ng build
artefact (docker image)
docker build
ENV_1
ENV_2
placeholders substitution
on docker run
index.html with placeholders replaced
by deployment env specific values
<!-- src/index.template.html --->
<html lang="en">
<head>
<title>MagicDust</title>
<script>
Object.defineProperty(
window,
'MagicDust',
{
value: Object.freeze({
apiPrefix: "<!-- @echo APP_API_PREFIX -->",
authServiceLocation: "<!-- @echo APP_AUTH_SERVICE_LOCATION -->"
})
}
);
</script>
</head>
<body>
<app-root></app-root>
</body>
</html>
// package.json
{
"name": "magic-dust",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "node scripts/generateIndexHtml && ng serve",
"build": "node scripts/generateIndexHtml --prod && ng build",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e"
},
...
// scripts/generateIndexHtml.js
const args = require('yargs').argv;
const preprocess = require('preprocess').preprocessFileSync;
let context;
if (args.prod) { // when app gets build with --prod switch
context = {
APP_API_PREFIX: '${CLIENT_API_PREFIX}',
APP_AUTH_SERVICE_LOCATION: '${APP_AUTH_SERVICE_LOCATION}'
};
} else {
context = { // when app gets build without --prod switch (local dev)
APP_API_PREFIX: '/mocks/api/',
APP_AUTH_SERVICE_LOCATION: 'http://localhost:4210'
};
}
preprocess('src/index.template.html', 'src/index.html', context, { type: 'html' });
# Dockerfile
FROM nginx:1.17.5-alpine
EXPOSE 80
EXPOSE 443
COPY dist /usr/share/nginx/www
WORKDIR /usr/share/nginx/www
# First substitute all env vars references in form of ${ENV_NAME}
CMD envsubst < index.html | sponge index.html && \
# Then run server (nginx)
nginx
<!-- src/index.template.html --->
<html lang="en">
<head>
<title>MagicDust</title>
<script>
Object.defineProperty(
window,
'MagicDust',
{
value: Object.freeze({
apiPrefix: "<!-- @echo APP_API_PREFIX -->",
authServiceLocation: "<!-- @echo APP_AUTH_SERVICE_LOCATION -->"
})
}
);
</script>
</head>
<body>
<app-root></app-root>
</body>
</html>
<!-- src/index.template.html --->
<html lang="en">
<head>
<title>MagicDust</title>
<script>
Object.defineProperty(
window,
'MagicDust',
{
value: Object.freeze({
apiPrefix: "/api/",
authServiceLocation: "/auth/"
})
}
);
</script>
</head>
<body>
<app-root></app-root>
</body>
</html>
<!-- index.html in docker image --->
<html lang="en">
<head>
<title>MagicDust</title>
<script>
Object.defineProperty(
window,
'MagicDust',
{
value: Object.freeze({
apiPrefix: "${APP_API_PREFIX}",
authServiceLocation: "${APP_AUTH_SERVICE_LOCATION}"
})
}
);
</script>
</head>
<body>
<app-root></app-root>
</body>
</html>
ng build
artefact (docker image)
deployment via k8s
running app
runtime
deployment
code/build
env (namespace) specific
config.json file has been mounted and can be loaded
normal app code
artefact (dist folder)
docker build
ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
name: app-client-config-json
namespace: prod
data:
config.json: ewogICJoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgo=
# ^ base64 encoded JSON:
# {
# "apiPrefix": "/api/",
# "authServiceLocation": "http://auth.example.com"
# }
apiVersion: apps/v1
kind: Deployment
metadata:
namespace: prod
name: app-client
labels:
app: app-client
spec:
replicas: 1
template:
metadata:
labels:
deployment: app-client
spec:
containers:
- name: app-client-container
image: app-client:latest
volumeMounts:
- mountPath: /usr/share/nginx/www/config.json
name: app-client-config-volume
ports:
- containerPort: 80
volumes:
- name: app-client-config-volume
configMap:
name: app-client-config-json
import { HttpClient } from '@angular/common/http';
import { throwError } from 'rxjs';
import { tap, catchError } from 'rxjs/operators';
import { ConfigService } from '../services/config.service';
export function configInitializer(httpClient, configService): () => Promise<any> {
return (): Promise<any> => {
return new Promise((resolve, reject): void => {
httpClient.get<any>('config.json').pipe(
tap((config) => {
configService.inject(config)
resolve();
}),
catchError((err: any) => {
reject();
return throwError('magic dust not injected :(');
})
);
});
};
}
running app 1
runtime
deployment
code/build
app 1
ng build
and
docker build
deployable artefact
deploy app
and publish configs
deployable artefact
running app 2
config webservice
app 2
load runtime set
load runtime set
code
configs
code
configs
code
configs
code
configs
if you wish overwrite configs from outside
Auth ConfigSet
Config prop
Config prop
User ConfigSet
Config prop
Config prop
Auth ConfigSet
Config prop
Config prop
Global ConfigSet
User ConfigSet
Config prop
Config prop
Config prop
Config prop
Auth ConfigSet
Config prop
Config prop
Global ConfigSet
Runtime Set A
User ConfigSet
Config prop
Config prop
Config prop
Config prop
Runtime Set A
Runtime Set B
Runtime Set B
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class ConfigService {
private _cfg;
private _runtimeSet: 'RuntimeSet_A';
load() {
// do a HTTP REST call here to load RuntimeSet
}
get(configSetName: string, configProperty: string): any {
return this._cfg[ configSetName ] ?
this._cfg[ configSetName ][ configProperty ] : undefined;
}
}
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { StorageManager } from '../storage-manager.service';
@Controller('api')
export class ApiController {
constructor(private readonly storageManager: StorageManager) {
}
// with a way to upload config sets
@Post('/config-sets')
async addConfigSet(@Body('id') id: string, @Body('data') data) {
return await this.dbService.addOrModifyConfigSet(id, data);
}
// and a way to remove config sets
@Delete('/config-sets/:id')
removeConfigSet(@Param('id') id: string) {
return this.storageManager.removeConfigSet(id);
}
...
@Controller('api')
export class ApiController {
...
// expose and endpoint which will create new RuntimeSets based on an
// array of config-sets id
@Post('/runtime-sets/:id')
async setRuntimeSets(@Param('id') id, @Body() configSetsToUse) {
return await this.storageManager.setRuntimeSetData(id, configSetsToUse);
}
// allow to get RuntimeSet by id
@Get('/runtime-sets/:id')
getRuntimeSetById(@Param('id') id) {
return this.storageManager.getRuntimeSetById(id);
}
}
# Dockerfile
FROM nginx:1.17.5-alpine
EXPOSE 80
EXPOSE 443
COPY dist /usr/share/nginx/www
COPY configurations /configurations
// Publish file under CFG_FILE_TO_USE env
// to webservice under PUBLISH_CFG_TO env
RUN echo "curl --header 'Content-Type: application/json' \
--request POST \
--data '@./$CFG_FILE_TO_USE' $PUBLISH_CFG_TO" | sh
// Run you server
CMD nginx
All Copyrights to The Fairies goes to Disney of course ;)