Issue
We are using Module Federation for Micro Frontends in Angular. We are also loading config with APP_INITIALIZER similar to the following two blog posts...
- https://www.prestonlamb.com/blog/loading-app-config-in-app-initializer
- https://timdeschryver.dev/blog/angular-build-once-deploy-to-multiple-environments
We do the above because we implement the "build once deploy to multiple environments" pipeline approach and need an external file Kubernetes can alter to handle our config. All of the above works great in a standalone Angular app. None of it works when that same app is launched as a remote via Module Federation.
The core of the above functionality is handled via a provider in AppModule for APP_INITIALIZER...
providers: [
{
provide: APP_INITIALIZER,
useFactory: initConfig,
deps: [AppConfigService],
multi: true,
},
]
as well as a manual fetch of the file in main.ts (so we can use config inside AppModule itself)...
fetch('/assets/config.json')
.then((response) => response.json())
.then((config) => {
if (environment.production) {
enableProdMode()
}
platformBrowserDynamic([{ provide: APP_CONFIG, useValue: config }])
.bootstrapModule(AppModule)
.catch((err) => console.error(err))
})
According to Angular documentation, the function specified in "useFactory" for APP_INITIALIZER will be launched during app bootstrap - "The function is executed during the application bootstrap process". In the above case "initConfig" is the function. THIS DOES NOT HAPPEN WHEN LAUNCHED AS REMOTE. Also, the above code in main.ts DOES NOT EXECUTE WHEN LAUNCHED AS REMOTE.
Bottom line - we cannot get our config to work in our remote MFE app. There are some significant differences in how an Angular app is launched standalone vs as a remote via Module Federation. Can someone please explicitly detail what these differences are or direct me to a link describing the differences? We have very important functionality in the normal angular bootstrap process that we also need when launched as a remote (namely config), but we do not know how to execute this functionality as a remote in a Micro Frontend.
Solution
We've settled on the following as a decent way to handle configuration for remotes in a Microfrontend architecture while using files for our configs. Using config files that are outside of the angular build (assets folder in our case) is mandatory for docker/kubernetes build-once, deploy to different environments workflows.
We searched high and low and could not find any tutorials on all of these details put together at once. I will try to cover all the relevant elements. I'm sorry I don't have a repo because this is all in a proprietary app at the moment. If I get time, I will put together a sample repo. The following is for Angular 14, using modules and Module Federation. The changes for Angular 17 aren't that difficult, and I can reply with those if anyone wants them.
Every Microfrontend remote has its own url. At the root of that url sits the remoteEntry.js file for that app, AND it's config in /assets/config/config.json. <- This is our convention.
The major problem is how to give each remote it's config file and guarantee it's available when the remote asks for it. The following makes that downright difficult:
- The remotes don't even know where they are hosted - all http calls are inside the context of the shell. Any relative pathing in remote will not work.
- Loading config from a file is an async operation
- Module Federation in Angular is a bit of a black box
To solve the above, we decided to have the shell load each remote's config, and have it available before the remote loads - this magic is done in the shell routes as you'll see below.
In a common library somewhere, we have a ConfigService used by both the host/shell and the remotes to simply load config files. It's a singleton that persists the host/shell config in this.hostConfig and all remote configs in a Map<string, any> called this.remoteConfigs.
in config.service.ts
// outside ConfigService class
export async function loadConfigForRemote(appName: string, configPath: string): Promise<any> {
return await ConfigService.getInstance().loadRemoteConfig(appName, configPath);
}
export async function loadHostConfig(configPath: string): Promise<void> {
return await ConfigService.getInstance().initialize(configPath);
}
// inside ConfigService class
public async initialize(configPath: string): Promise<void> {
await this.loadHostConfig(configPath);
}
private async loadHostConfig(configPath: string): Promise<any> {
const config = await this.loadConfig('', configPath);
this.hostConfig = config;
}
public async loadRemoteConfig(remoteName: string, configPath: string): Promise<any> {
const config = await this.loadConfig(remoteName, configPath);
this.remoteConfigs.set(remoteName, config);
}
private async loadConfig(appName: string, configPath: string): Promise<any> {
// the host/shell config file has a property named remotes
// that contains an object - keys are the names of all possible
// remotes - values are the urls where those remotes are located
const appUrl = appName ? this.hostConfig.remotes[appName] : '';
const configUrl = `${appUrl}${configPath}`;
const response = await fetch(configUrl);
if (response.ok) {
const config = await response.json();
return config;
}
else {
throw new Error(`Unable to load config file for application ${appName}: ${configUrl}`);
}
}
Host/shell of the Microfrontend
in bootstrap.ts
// this ensures that our ConfigService singleton has the shell
// config loaded and ready to go before anything else happens
loadHostConfig(environment.configUrl) // this can be a relative path
.then(async () => {
if (environment.production) {
enableProdMode();
}
await platformBrowserDynamic([])
.bootstrapModule(AppModule);
await defineCustomElements(window);
})
.catch((ex) => console.error(ex));
in main.ts
// this little dance is needed to fix an issue with Module Federation
import('./bootstrap').catch((error) => console.error(error));
in app.module.ts
@NgModule({
bootstrap: [AppComponent],
imports: [APP_MODULES],
providers: [
{
// we did this because we are moving towards a vanilla-js
// version of ConfigService that can be used by React and
// Angular, and this is an easy singleton.
provide: ConfigService, useFactory: () => ConfigService.getInstance()
},
{
// I think this is redundant, we've already ensured
// shell config is loaded in bootstrap.ts (remove and test)
provide: APP_INITIALIZER,
useFactory: (configService: ConfigService) => {
return () => configService.initialize(configPath);
},
deps: [ConfigService],
multi: true
},
{
// When anyone injects AppSettings, they'll get the shell config
provide: AppSettings,
useFactory: (configService: ConfigService) => {
return configService.getHostConfig();
},
deps: [ConfigService]
},
],
declarations: [AppComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppModule {
}
in app-routing.module.ts
const routes: Routes = [
// ... all other routes deleted from example ...
// ***THIS IS THE MAGIC*** - we could not find an example of this anywhere
{
path: 'some-remote-app',
loadChildren: () =>
// load remote config here - GUARANTEES it will be available in common
// ConfigService before remote module is loaded
loadConfigForRemote('some-remote-app', '/assets/config/config.json')
.then(() => {
const remoteModule =
loadRemoteModule({
type: 'manifest',
remoteName: 'someRemoteAppMFE',
exposedModule: './someRemoteApp'
});
return remoteModule;
})
.then(m => m.SomeModuleInRemoteApp)
}
]
},
},
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule {}
Next... the remote app. We configured ours to be able to run as a remote in a Microfrontend or as a standalone app. In our case AppModule is ONLY used when the app is launched standalone, and it is very lean and simple. We have a second, child module (SomeModuleInRemoteApp) that contains EVERYTHING needed for the Microfrontend remote.
in some-module-in-remote-app.module.ts
@NgModule({
declarations: [
TestComponent
],
imports: [
CommonModule,
SomeRemoteAppRoutingModule,
],
providers: [
{
provide: ConfigService, useFactory: () => ConfigService.getInstance()
},
{
// The config/settings for this remote, custom to this remote. With
// this, whenever anything in this remote injects "MyAppSettings",
// it will get our remote's config
provide: MyAppSettings,
useFactory: (configService: ConfigService) => configService.getRemoteConfig('some-remote-app'),
deps: [ ConfigService ]
}
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class SomeModuleInRemoteApp
{}
I need to convert this into a blog post and a repo. In the meantime, the major elements of the solution should all be above.
Answered By - Tim Hardy
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.