Every project has a story…
...every story has a bit of magic in it…
…where is magic, there are Fairies...
…and where are Fairies, there is...
"MAGIC DUST"
our story about configurable frontend apps
... how we've done it with a little help of magic from The Fairies
by Frontend Wolves: Rafał Brzoska & Łukasz "Max" Kokoszka
PROLOGUE
One day..
fairies had a discussion…
how to help humans developers deliver
configuration magic dust to their apps depending on their different needs and expectations
Hmmm…. We have “environments functionality”
During the discussion Silvermist proposed to go the standard Angular way:
And it’s already there!
specific environment.ts
ng build
artefact (dist folder)
deployment
running app
runtime
deployment
code/build
Is this the only way?
Is it always fine?
They all agreed that this is a well-know standard, which is broadly used….
Lets think this through for a moment…
Mhmmm...
Only Thinkerbell had doubts...
Girls but this is done on the build time
The configuration magic dust gets burned into the output artefact… So you cannot manipulate this easily on any later stage.
Maybe… Oh Maybe we could find a better way!
Thinkerbell!... oh no...
...here we go again!
CHAPTER 1
Direct Dust Injection
But it was not Tinkerbell who opened the pandora box…
It was Fawn...
Maybe we shouldn’t drift so far away from this solution?
Let’s go the same way, but instead of using
multiple environment files,
and making the decision on build time...
...let’s either provide the configuration magic dust for all possible runtime locations in“production” env file
Oh.. Fawn.. Wait wait...
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
By a single config file you mean something like this?
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'
}
};
... with loading via something like this?
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;
});
};
}
...or simply provide multiple files with configuration magic dust
and then just use it
the same way!
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
For multiple files you think about something similar?
// 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/"
}
And loading done ie. via webpack dynamic imports?
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);
});
};
}
Yes, exactly!
They hoped that this will close the topic… as this looks legit ;)
But Tinkerbell was far from being convinced and satisfied…
Girls! Do we really should burn all the configurations magic dust into our artefacts?
This way we deliver a lot of useless magic dust to the users, which is also publicly available!
CHAPTER 2
Dust Injection by Replacement
Imagine a situation, when we still have single deployable artefact, but inject magic dust configuration during deployment not during build!?
Then Iridessa stepped out of the line, probably inspired by the whole discussion and asked:
Ok ! So how do they deploy their frontend apps nowadays?
Again Silvermist - known also as the water fairy - was the first one to say something:
I’ve heard about some kind of dolphin or a fish…
No wait! Was it a whale?
Yhhhhhh…..
Yeah a whale! I think it has a whale in the logo and it is called docker!
It’s just like a bird egg! Everything inside to bring whatever is inside to life!
They contenerize their apps to be a self-sufficient,
encapsulated artefacts.
It is awesome!
Yes! This is some sort of magic as well! - said happily Tinkerbell.
And you can control this container behaviour
via environment variables.
Wow impressive - all the fairies nodded their heads.
So can we or can we not utilise this to support our goal!? - Rosetta asked
What if we could use those environment variables in our app?
For instance by leaving some kind of a magic dust configuration placeholders in index.html for later replacement and usage?
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>
...and then before application build do some magic to support local development (ng serve) and all our target environments (docker)
// 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"
},
...
using a simple node script with tools like:
preprocess* or sed
// 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' });
this way we could prepare our index.html, so it can be used by gettext's envsubst program, to directly inject OS envs as our magic dust configuration during deployment
# 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
so this:
<!-- 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>
and finally after deploy becomes this:
<!-- 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>
after preprocessing before build becomes this...
But ...Wait! I just reminded myself that they use a tool called
Kubernetes (k8s) to build huge ecosystems of those docker images!
CHAPTER 3
Discrete Dust Injection
Oh come on! - Vidia joined the discussion - I see where this is going!
Tinkerbell your curiosity will get us into trouble!
I have an option!
As far as I know k8s is capable of mounting files and "things" as files into containers! (in multiple namespaces)
There are those little things called Secrets and... and... ymhmmmm
ConfigMaps ?
Oh yes! Thank you
So maybe we could join concepts and make k8s load our magic dust configuration as a file depending on deployment namespace!?
This way in each namespace we could have separate configurations and just simply load them on bootstrap of our frontend app instead of this whole magic!
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
When it comes to code, you mean such 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"
# }
and such a Deployment?
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
used with APP_INITIALIZER like this?
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 :(');
})
);
});
};
}
Again: Yes! You've got it!
But can we do this better? Especially when there is this "micro frontends" hype all around?
CHAPTER 4
The Fairies Concept
Girls I've got I splendid concept!
Tinkerbell shouted:
On the Spring Valley and our Queen name!
Let me call it our - Fairies concept !!!
called nest.js and using it build quickly a dedicated webservice to serve the magic dust configurations for us depending on environment and it implicit configuration!
Let's take this, splendid, shiny, superb node js framework for building backend apps - so we won’t need any Java, .net dev to help us ;)
It looks like Angular… So it awesome - if somebody haven’t noticed yet ;)
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 App
User App
As we can have multiple apps (micro frontends setup)
Auth App
Auth ConfigSet
Config prop
Config prop
User App
User ConfigSet
Config prop
Config prop
let’s allow our apps to define their unique - but broadly available - ConfigSets with config properties inside
Auth App
Auth ConfigSet
Config prop
Config prop
Global ConfigSet
User App
User ConfigSet
Config prop
Config prop
Config prop
Config prop
Additionaly let's have a single global ConfigSet
Auth App
Auth ConfigSet
Config prop
Config prop
Global ConfigSet
Runtime Set A
User App
User ConfigSet
Config prop
Config prop
Config prop
Config prop
Runtime Set A
Runtime Set B
Runtime Set B
... then allow each app to build a projection of what's needed as Runtime Sets based on individual ConfigSets
On Angular side we create a simple
service capable of
delivering the configuration when asked
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;
}
}
On the backend (nest.js) create
a simple Controller
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);
}
}
As mentioned we can store our
magic dust configurations in separate files -
as proposed earlier - but outside of our
Angular App but still reachable by docker
/project/
.... typical Angular app structure
- /configurations/
- /dev.config.json
- /stage.config.json
- /prod.config.json
or deliver it via k8s trick
Learn our docker image to publish given env-related configuration to our config webservice while it is brought to life
# 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
Voila! We can request our magic dust configuration - it will be there...
always in latest version, always composed from what is actually needed
And? What do you think?
Do you feel inspired?
THE END
Thank you Fairies!
All Copyrights to The Fairies goes to Disney of course ;)
Configurable Frontend
By Rafał Brzoska
Configurable Frontend
- 1,252