Load a Federated Module with an Angular Directive

Load a Federated Module with an Angular Directive

We have tried lazy loading modules and components in Angular. But what about loading a federated module instead? In this article, we’re going to create a directive which can fetch and render a remote module exposed from a different application into the so called “host” app.

What is a federated module?

In the past, tools like Webpack assume that the entire program code is available when compiling. And we can split a module like this and lazy load it, but still, that is known from the angular compiler while building the code. But what we want to do is load program parts which are not known at compile time. Webpack 5 finally enabled us to do such a thing. A “federated module” in the microfrontend world is an Angular module, which can be federated by a remote app and imported into the host app, but the host application doesn’t know anything about it in the compile time. So this means faster builds, small bundle applications and of course, faster times painting of the script in the browser.

In my last article of the microfrontend series, we were able to load a remote exposed module from a remote app into the host application using angular routing and module federation plugin.

loadRemoteModule function. What is it?

We are making use of @angular-architects/module-federation package which exposed an important function which loads the remote module.

import { loadRemoteModule } from '@angular-architects/module-federation';

loadRemoteModule function requires 3 parameters:

  1. remoteEntry → basically, it's the port where the remote app is being served. If you check my previous article you can see that remote-app-1 is being served on port 4201 and exposing a remoteEntry called remoteAppEntry.js.

  2. remoteName → is the name of the generated file’s entry point to your remote module.

  3. exposedModule → the named paths to modules, components, etc. that you want to make accessible to the shell to pull in.

Creating the directive

import { loadRemoteModule } from '@angular-architects/module-federation';

import {
    Compiler,
    Directive,
    EventEmitter,
    Injector,
    Input,
    NgModuleFactory,
    NgModuleRef,
    OnInit,
    Output,
    Type,
    ViewContainerRef
} from '@angular/core';

interface ICacheItem {
    component: Type<unknown>;
    ngModuleRef: NgModuleRef<unknown>;
}

@Directive({
    selector: '[loadRemoteModule]'
})
export class LoadRemoteModuleDirective implements OnInit {
    static cache: Map<string, ICacheItem> = new Map<string, ICacheItem>();

    @Input() remoteEntry: string;
    @Input() remoteName: string;
    @Input() exposedModule: string;
    @Input() data: unknown;
    @Output() loaded: EventEmitter<void> = new EventEmitter<void>();

    constructor(
        private readonly viewContainerRef: ViewContainerRef,
        private readonly injector: Injector,
        private compiler: Compiler
    ) {}

    async ngOnInit() {
        await this.getComponent().then(({ component, ngModuleRef }) => {
            const componentRef = this.viewContainerRef.createComponent(component, {
                injector: this.injector,
                ngModuleRef
            });

            componentRef.instance['data'] = this.data;
            this.loaded.emit();
            componentRef.changeDetectorRef.markForCheck();
        });
    }

    private getComponent(): Promise<ICacheItem> {
        const key = this.getKey();

        if (LoadRemoteModuleDirective.cache.has(key)) {
            return Promise.resolve(LoadRemoteModuleDirective.cache.get(key));
        }

        return this.loadComponent();
    }

    private loadComponent(): Promise<ICacheItem> {
        return loadRemoteModule({
            remoteEntry: this.remoteEntry,
            remoteName: this.remoteName,
            exposedModule: this.exposedModule
        })
            .then((m) => {
                const keys = Object.keys(m);
                const moduleKey = keys.find((key) => key.toLowerCase().includes('module'));

                return this.createModule(m[moduleKey]).then((ngModuleRef) => {
                    return {
                        component: m[moduleKey].component,
                        ngModuleRef
                    }
                });
            })
            .then((result) => {
                LoadRemoteModuleDirective.cache.set(this.getKey(), result);
                return result;
            });
    }

    private getKey() {
        return `${this.remoteEntry}${this.remoteName}${this.exposedModule}`;
    }

    private createModule(moduleOrFactory: any | NgModuleFactory<any>): Promise<NgModuleRef<any>> {
        if (moduleOrFactory instanceof NgModuleFactory) {
            return Promise.resolve(moduleOrFactory.create(this.injector));
        }

        return this.compiler.compileModuleAsync(moduleOrFactory).then((factory) => {
            return factory.create(this.injector);
        });
    }
}

Implementing the directive

  1. remote-entry.module.ts should look something like the below. It's a Module Per Component Approach, so it declares one component and exports it as well. The static component is very important because that's how it read the component factory in the directive. It's the key where we find the component from the module factory.
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';

import { RemoteEntryComponent } from './entry.component';

@NgModule({
  declarations: [RemoteEntryComponent],
  imports: [CommonModule],
  exports:[RemoteEntryComponent],
})
export class RemoteEntryModule {
    public static component = RemoteEntryComponent;
}

2. In your remote app webpack config you should expose the module like below:

exposes: {
   './Module': 'apps/remote-app-1/src/app/remote-entry/entry.module.ts',
},

3. In your host application make sure to import the exposed remote module like below:

<ng-container
    loadRemoteModule
    remoteEntry="http://localhost:4201/remoteAppEntry.js"
    remoteName="remoteApp"
    exposedModule="./Module"
    (loaded)="onLoad()"
></ng-container>

4. The loaded() is an emmiter that can be used to show any loading state you want while the federated module is being fetched and prepared from the directive.

5. Make sure to run both apps. You can run them separately or use the below command to run all apps in parallel. This is useful when you don’t have many applications in your monorepo. Keep in mind that if you make changes to the webpack.config.js file, you need to restart the respective application, since it's not part of the hot reloading strategy.

nx run-many --parallel --target=serve --all

That’s it

As usual, thank you for reading. I appreciate the time you take to read my content and stories. I hope you can find this post useful.
Stay tuned and happy coding!