dkhrunov / ngx-mfe

Angular library for working with micro-frontends in Webpack 5 and plugin ModuleFederation
https://www.npmjs.com/package/ngx-mfe
MIT License
22 stars 4 forks source link
angular mfe microfrontend module-federation ngx typescript

Angular micro-frontend library - ngx-mfe

A library for working with MFE in Angular in a plugin-based approach and with Angular routing.

If you have production build issues check this issue. This issue has been fixed in version 2.0.0.

Have problems with updates? Check out the migration guides.

Contents

Version Compliance

ngx-mfe v1.0.0 v1.0.5 v2.0.0 v3.0.0
Angular v12.0.0 v13.0.0 v13.0.0 v14.0.0
@angular-architects/module-federation v12.0.0 v14.0.0 v14.0.0 v14.3.0

Since v15.0.0 version of ngx-mfe library is compatible with Angular version

Motivation

When Webpack 5 came along and the Module Federation plugin, it became possible to separately compile and deploy code for front-end applications, thereby breaking up a monolithic front-end application into separate and independent MicroFrontEnd (MFE) applications.

The ngx-mfe is an extension of the functionality of the @angular-architects/module-federation. Using @angular-architects/module-federation you could only upload one micro-frontend per page (in the Routing), this limitation was the main reason for the creation of this library.

The key feature of the ngx-mfe library is ability to work with micro-frontends directly in the HTML template using a plugin-based approach. You can load more than one micro-frontend per page.

You can use both ngx-mfe and @angular-architects/module-federation libs together in the same project.

Features

🔥 Load multiple micro-frontend directly from an HTML template with the ability to display a loader component during loading and a fallback component when an error occurs during loading and/or rendering of the mfe component.

🔥 Easy to use, just declare structural directive *mfeOutlet in your template.

🔥 Supports Angular Standalone Components.

🔥 More convenient way to load MFE via Angular Routing.

🔥 It's easy to set up different remoteEntryUrl MFEs for different builds (dev/prod/etc).

Examples

Conventions

  1. To display a standalone MFE component, you only need to the component file itself.

    A standalone component is a component that does not have any dependencies provided or imported in the module where that component is declared.

    Since Angular v14 standalone component it is component that marked with standalone: true in @Component({...}) decorator.

    When you display a standalone MFE component through [mfeOutlet] directive you must omit [mfeOutletModule] input.

    // Standalone Component - standalone.component.ts
    import { Component } from '@angular/core';
    import { CommonModule } from '@angular/common';
    
    @Component({
      selector: 'app-standalone',
      standalone: true,
      imports: [CommonModule],
      template: ` <p>Standalone component works!</p> `,
      styles: [],
    })
    export class StandaloneComponent {}
    // dashboard-mfe webpack.config
    {
      new ModuleFederationPlugin({
        name: 'dashboard-mfe',
        filename: 'remoteEntry.js',
        exposes: {
          StandaloneComponent: 'apps/dashboard-mfe/src/app/standalone.component.ts',
        },
        [...]
      });
    }
    <!-- shell-app -->
    <ng-template
      mfeOutlet="dashboard-mfe"
      mfeOutletComponent="StandaloneComponent"
    >
    </ng-template>
  2. To display an MFE component with dependencies in the module where the component was declared, you must expose both the component file and the module file from ModuleFederationPlugin.

    This approach is widely used and recommended.

    When you display this type of MFE component with the [mfeOutlet] directive, you must declare an input [mfeOutletModule] with the value of the exposed module name.

  3. The file key of an exposed Module or Component (declared in the ModuleFederationPlugin in the 'expose' property) must match the class name of that file.

    For the plugin-based approach, when loads MFE using [mfeOutlet] directive you must declare Component in the exposed Module and the Component name must match the file key of an exposed Component class.

    // webpack.config
    {
      new ModuleFederationPlugin({
        name: 'dashboard-mfe',
        filename: 'remoteEntry.js',
        exposes: {
          // EntryModule is the key of the entry.module.ts file and corresponds to the exported EntryModule class from this file.
          EntryModule: 'apps/dashboard-mfe/src/app/remote-entry/entry.module.ts',
          // the EntryComponent is key of file entry.module.ts, and match to exported EntryComponent class from that file.
          EntryComponent: 'apps/dashboard-mfe/src/app/remote-entry/entry.component.ts',
        },
        [...]
      });
    }

    If the name of Module doesn't match, you can specify a custom name for this Module in the @Input() property mfeOutletOptions = { componentName: 'CustomName' } of [mfeOutlet] directive, and pass { moduleName: 'CustomName' } options to the loadMfe() function;

    If the name of Component doesn't match, you can specify a custom name for this Component in the @Input() property mfeOutletOptions = { componentName: 'CustomName' } of [mfeOutlet] directive, and pass { moduleName: 'CustomName' } options to the loadMfe() function;

  4. You must follow the rule that only one Component must be declared for an exposed Module. This is known as SCAM (Single Component Angular Module) pattern.

Configuring

Add the ngx-mfe library to a shared property in the ModuleFederationPlugin inside webpack.config.js file for each application in your workspace.

module.exports = {
  [...]
  plugins: [
    [...]
    new ModuleFederationPlugin({
      remotes: {},
      shared: share({
        [...]
        "ngx-mfe": {
          singleton: true,
          strictVersion: true,
          requiredVersion: 'auto',
          includeSecondaries: true
        },
        ...sharedMappings.getDescriptors(),
      }),
      library: {
        type: 'module'
      },
    }),
    [...]
  ],
  [...]
};

To configure this library, you must import MfeModule.forRoot(options: NgxMfeOptions) into the root module of the Host app(s) and the root module of the Remote apps in order for Remote to work correctly when running as a standalone application:

For feature modules just import MfeModule without options, where, you may need the functionality of the library, for example, the MfeOutlet directive.

For core / app module:

@NgModule({
  imports: [
    MfeModule.forRoot({
      mfeConfig: {
        "dashboard-mfe": "http://localhost:4201/remoteEntry.js",
        "loaders-mfe": "http://localhost:4202/remoteEntry.js",
        "fallbacks-mfe": "http://localhost:4203/remoteEntry.js"
      },
      preload: ['loaders-mfe', 'fallbacks-mfe'],
      loader: {
        app: 'loaders',
        module: 'SpinnerModule',
        component: 'SpinnerComponent',
      },
      loaderDelay: 500,
      fallback: {
        app: 'fallbacks',
        module: 'MfeFallbackModule',
        component: 'MfeFallbackComponent',
      },
    }),
  ],
})
export class AppModule {}

For feature module:

@NgModule({
  imports: [
    MfeModule,
  ],
})
export class Feature1Module {}

List of all available options:

Next options are only works in plugin-based approach with MfeOutletDirective:

You can get all configured options by injecting NGX_MFE_OPTIONS by DI:

class AppComponent {
  constructor(@Inject(NGX_MFE_OPTIONS) public options: NgxMfeOptions) {}
}

Display MFE in HTML template / plugin-based approach

This approach allows us to load micro-frontends directly from HTML.

The advantages of this approach are that we can display several MFEs at once on the same page, even display several of the same MFEs.

More about plugin-based approach here.

Full code of this example can be found at https://github.com/dkhrunov/ngx-mfe-test.

Example app:

image

An example webpack.config.js that exposes the "MfeTestComponent" (brown border in the screenshot above):

// webpack.config.js
return {
    [...]
    resolve: {
        alias: sharedMappings.getAliases(),
    },
    plugins: [
        new ModuleFederationPlugin({
            name: 'test',
            exposes: {
        MfeTestModule: 'apps/test/src/app/mfe-test/mfe-test.module.ts',
        MfeTestComponent: 'apps/test/src/app/mfe-test/mfe-test.component.ts',
            },
            filename: 'remoteEntry',
            shared: share({ ... }),
        }),
        sharedMappings.getPlugin(),
    ],
};
  1. Just display the component "MfeTestComponent" inside other MFE component "Form" from "address-form" app:

    One variant:

    <ng-template
      mfeOutlet="test"
      mfeOutletModule="MfeTestModule"
      mfeOutletComponent="MfeTestComponent"
    >
    </ng-template>

    Other variant:

    <ng-container
      *mfeOutlet="
        'test';
        module: 'MfeTestModule';
        component: 'MfeTestComponent'
      "
    >
    </ng-container>

    These two examples are equal and display the MFE "MfeTestComponent".

  1. You can pass/bind @Input and @Output props to MFE component:

    <!-- form.component.html file -->
    <ng-container
      *mfeOutlet="
        'test';
        module: 'MfeTestModule';
        component: 'MfeTestComponent';
        inputs: { text: text$ | async };
        outputs: { click: onClick };
      "
    ></ng-container>
    // form.component.ts file
    @Component({
      selector: 'app-form',
      templateUrl: './form.component.html',
      styleUrls: ['./form.component.scss'],
      changeDetection: ChangeDetectionStrategy.OnPush,
    })
    export class FormComponent {
      [...]
      // timer emits after 1 second, then every 2 seconds
      public readonly text$: Observable<number> = timer(1000, 2000);
    
      // on click log to console event
      public onClick(event: MouseEvent): void {
        console.log('clicked', event);
      }
      [...]
    }

    If you try to bind a @Output() property that is not in the component, then an error will fall into the console: "Output someOutput is not output of SomeComponent."

    If you try to pass a non-function, then an error will fall into the console: "Output someOutput must be a function."

  2. To override the default loader delay, configured in MfeModule.forRoot({ ... }), provide custom number in ms to property loaderDelay:

    <ng-container
      *mfeOutlet="
        'test';
        module: 'MfeTestModule';
        component: 'MfeTestComponent';
        loaderDelay: 1000
      "
    ></ng-container>
  3. To override the default loader and fallback MFE components, configured in MfeModule.forRoot({ ... }), specify content with TemplateRef, pass it to the appropriate properties loader and fallback:

    <ng-container
      *mfeOutlet="
        'test';
        module: 'MfeTestModule';
        component: 'MfeTestComponent';
        loader: loaderTpl;
        fallback: fallbackTpl
      "
    ></ng-container>
    
    <ng-template #loaderTpl>
      <div>loading...</div>
    </ng-template>
    
    <ng-template #fallbackTpl>
      <div>Ooops! Something went wrong</div>
    </ng-template>
    <!-- TemplateRef that render loader as MFE component -->
    <ng-template
      mfeOutlet="test"
      mfeOutletModule="MfeTestModule"
      mfeOutletComponent="MfeTestComponent"
      [mfeOutletLoader]="loaderMfeTpl"
    ></ng-template>
    
    <ng-template #loaderMfeTpl>
      <ng-template
        mfeOutlet="loaders-mfe"
        mfeOutletModule="SpinnerModule"
        mfeOutletComponent="SpinnerComponent"
        [mfeOutletLoader]="undefined"
        [mfeOutletLoaderDelay]="0"
      >
      </ng-template>
    </ng-template>
  4. You can also provide a custom injector for a component like this:

    <ng-template
      mfeOutlet="test"
      mfeOutletModule="MfeTestModule"
      mfeOutletComponent="MfeTestComponent"
      [mfeOutletInjector]="customInjector"
    ></ng-template>

Display Angular v14 Standalone components

Example app:

image

An example webpack.config.js that exposes the "StandaloneComponent" (green border in the screenshot above):

// webpack.config.js
return {
    [...]
    resolve: {
        alias: sharedMappings.getAliases(),
    },
    plugins: [
        new ModuleFederationPlugin({
            name: 'test',
            exposes: {
        [...]
        StandaloneComponent: 'apps/test/src/app/standalone/standalone.component.ts',
            },
            filename: 'remoteEntry',
            shared: share({ ... }),
        }),
        sharedMappings.getPlugin(),
    ],
};
// standalone.component.ts

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
    selector: 'app-standalone',
    standalone: true,
    imports: [CommonModule],
    template: ` <p>Standalone component works!</p> `,
    styles: [],
})
export class StandaloneComponent {}
<!-- form.component.html of the address-form app -->
[...]
<h3>Angular v14 Standalone component loaded as MFE:</h3>
<ng-template
  mfeOutlet="test"
  mfeOutletComponent="StandaloneComponent"
></ng-template>

Passing Data to the MFE Component via mfeOutlet directive

After using this library for some time, as the author of this library, I came to the conclusion that using @Inputs and @Outputs of an MFE component through the [mfeOutletInputs] [mfeOutletOutputs] properties is not the best practice. Try to make your MFE components as independent as possible from the external environment. But if you still have to pass some values ​​to the component, you can do it in two ways:

  1. As I wrote above through the properties [mfeOutletInputs] [mfeOutletOutputs]

    component.html:

    <ng-template
      mfeOutlet="test"
      mfeOutletModule="MfeTestModule"
      mfeOutletComponent="MfeTestComponent"
      [mfeOutletInputs]="{ text: text$ | async }"
      [mfeOutletOutputs]="{ click: onClick }"
    >
    </ng-template>

    component.ts

    @Component({ ... })
    export class Component {
      public text$ = new BehaviorSubject<string>('Test string');
    
      constructor() { }
    
      public onClick(bool: MouseEvent): void {
        console.log('login', bool);
      }
    }
  2. The second way is to create a new injector and add the necessary data for the MFE component to it. The [mfeOutlet] directive has the [mfeOutletInjector] property through which you can pass the desired injector, when the component is created, the previously passed injector in the [mfeOutletInjector] property will be used instead of the current injector.

    component.html:

    <ng-template
      mfeOutlet="test"
      mfeOutletModule="MfeTestModule"
      mfeOutletComponent="MfeTestComponent"
      [mfeOutletInjector]="testComponentInjector"
    >
    </ng-template>

    component.ts

    @Component({ ... })
    export class Component {
      public readonly testComponentInjector: Injector;
    
      constructor(private readonly _injector: Injector) {
        this.testComponentInjector = Injector.create({
          parent: this._injector,
          providers: [
            {
              provide: TEST_DATA,
              useValue: data,
            },
          ],
        });
      }
    }

Load MFE by Route

To use micro-frontends in Routing, you must import and apply the helper function called loadMfe, like in the example below:

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { loadMfe } from '@dkhrunov/ng-mfe';

const routes: Routes = [
  {
    path: 'dashboard',
    loadChildren: () => loadMfe('dashboard-mfe', 'EntryModule'),
  },
];

@NgModule({
  imports: [RouterModule.forRoot(routes, { initialNavigation: 'enabledBlocking' })],
  exports: [RouterModule],
})
export class AppRoutingModule {}

Changelog

Changes in v2.1.0

Fixed:

Refactored:

Changes in v2.0.0 (Breaking changes)

Why has the API changed? - The problem is that when you use the [mfeOutlet] directive issue, it tries to find the component inside the compiled module by name (as a string), but in runtime the class name will be optimized and replaced with a short character. For example, you have a class TestComponent, it can be changed to the class name a and this causes this error.

General:

LoadMfe function:

MfeOutlet directive:

MfeComponentFactoryResolver:

MfeComponentCache

DynamicComponentBinding


Changes in v1.1.0:


Changes in v1.0.8: