ng-content

ng-content

Errores con Module Federation y Angular

Photo by Frida Lannerström on Unsplash

Errores con Module Federation y Angular

Errores típicos que surgen al utilizar Module Federation junto con Angular y las estrategias para evitarlas.

Dany Paredes's photo
Dany Paredes
·Jul 26, 2022·

12 min read

Subscribe to our newsletter and never miss any upcoming articles

Play this article

Table of contents

En este artículo, vamos a desmontar el ejemplo de Module Federation! Sin embargo, no debes preocuparte: es por una muy buena razón. El objetivo es mostrar los problemas típicos que surgen al utilizar Module Federation junto con Angular y este artículo presenta algunas estrategias para evitar estos problemas.

Mientras que Module Federation es realmente una solución directa y bien pensada, usar Micro Frontends significa en general hacer dependencias en tiempo de ejecución a partir de dependencias en tiempo de compilación. Como resultado, el compilador no puede protegernos tan bien como estamos acostumbrado.

Codigo fuente

Traducción en español del artículo original de Manfred Steyer Pitfalls with Module Federation and Angular actualizado el 23.07.2021

"No required version specified" y los puntos de entrada secundarios

Para el primer problema del que quiero hablar, echemos un vistazo al webpack.config.js de nuestro shell. Además, vamos a simplificar share de la siguiente manera

shared: {
   "@angular/core": { singleton: true, strictVersion: true },
   "@angular/common": { singleton: true, strictVersion: true },
   "@angular/router": { singleton: true, strictVersion: true },
   "@angular/common/http": { singleton: true, strictVersion: true }, 
 },

Como ves, ya no especificamos un requiredVersiony normalmente esto no es necesario porque module federation de webpack es muy inteligente a la hora de averiguar qué versión utilizas.

Sin embargo, ahora, al compilar el shell (ng build shell), obtenemos el siguiente error:

shared module @angular/common – Warning: No required version specified and unable to automatically determine one. Unable to find required version for "@angular/common" in description file (C:\Users\Manfred\Documents\artikel\ModuleFederation-Pitfalls\example\node_modules\@angular\common\package.json). It need to be in dependencies, devDependencies or peerDependencies

La razón de esto es el punto de entrada secundario @angular/common/http que es como un paquete npm dentro de un paquete npm. Técnicamente, es sólo otro archivo expuesto por el paquete npm @angular/common.

Como es lógico, @angular/common/http utiliza @angular/common y webpack lo reconoce. Por esta razón, webpack quiere averiguar qué versión de @angular/common se utiliza. Para ello, busca en el package.json del paquete npm @angular/common/package.json y explora las dependencias allí. Sin embargo, @angular/common no es una dependencia de @angular/common y por lo tanto, la versión no se puede encontrar.

Tendrás el mismo problema con otros paquetes que utilizan puntos de entrada secundarios, por ejemplo @angular/material.

Para evitar esta situación, puedes asignar versiones a todas las bibliotecas compartidas a mano:

shared: {
   "@angular/core": { singleton: true, strictVersion: true, requiredVersion: '12.0.0' },
   "@angular/common": { singleton: true, strictVersion: true, requiredVersion: '12.0.0' },
   "@angular/router": { singleton: true, strictVersion: true, requiredVersion: '12.0.0' },
   "@angular/common/http": { singleton: true, strictVersion: true, requiredVersion: '12.0.0' }, 
 },

Obviamente, esto es engorroso y por eso se nos ocurrió otra solución. Desde la versión 12.3, @angular-architects/module-federation viene con una función de ayuda llamada shared. Si tu webpack.config.js fue generado con esta versión o una más reciente, ya utiliza esta función.

[...]

 const mf = require("@angular-architects/module-federation/webpack");
 [...]
 const share = mf.share;

 [...]

 shared: share({
   "@angular/core": { singleton: true, strictVersion: true, requiredVersion: 'auto' },
   "@angular/common": { singleton: true, strictVersion: true, requiredVersion: 'auto' },
   "@angular/router": { singleton: true, strictVersion: true, requiredVersion: 'auto' },
   "@angular/common/http": { singleton: true, strictVersion: true, requiredVersion: 'auto' }, 
   "@angular/material/snack-bar": { singleton: true, strictVersion: true, requiredVersion:'auto' }, 

 })

Como se ve aquí, la función share envuelve el objeto con las bibliotecas compartidas y permite utilizar requiredVersion: 'auto' ademas convierte el valor auto al valor encontrado en el package.json de su shell (o del micro frontend).

Incompatibilidad de versión no evidentes: Problemas con peers dependecies.

¿Alguna vez ha ignorado una advertencia de peers dependecy? Honestamente, creo que todos conocemos esas situaciones. Ignorarlas suele estar bien, ya que sabemos que en tiempo de ejecución todo estará bien. Desafortunadamente, esta situación puede confundir a webpack con Module Federation ya que cuando intenta autodetectar las versiones necesarias de las dependencias de las dependecies.

Para demostrar esta situación, instalemos @angular/material y @angular/cdk en una versión que esté al menos 2 versiones por detrás de nuestra versión de Angular. En este caso, deberíamos obtener un aviso de peers dependecies.

npm i @angular/material@10
npm i @angular/cdk@10

Ahora, cambiemos el FlightModule del Micro Frontend (mfe1) para importar el MatSnackBarModule:

[...]
 import { MatSnackBarModule  } from '@angular/material/snack-bar';
 [...]

 @NgModule({
   imports: [
     [...]
     // Add this line
     MatSnackBarModule,
   ],
   declarations: [
     [...]  
   ]
 })
 export class FlightsModule { }

Para hacer uso del MatSnakBar en el FlightsSearchComponent, debemos inyéctarla en el constructor y llamar a su método open:

[...]
 import { MatSnackBar } from '@angular/material/snack-bar';

 @Component({
   selector: 'app-flights-search',
   templateUrl: './flights-search.component.html'
 })
 export class FlightsSearchComponent {
   constructor(snackBar: MatSnackBar) {
     snackBar.open('Hallo Welt!');
   }
 }

Además, para esta prueba, asegúrate de que el webpack.config.js del proyecto mfe1 no define las versiones de las dependencias compartidas:

shared: {
   "@angular/core": { singleton: true, strictVersion: true },
   "@angular/common": { singleton: true, strictVersion: true },
   "@angular/router": { singleton: true, strictVersion: true },
   "@angular/common/http": { singleton: true, strictVersion: true }, 
 },

No definir estas versiones a mano obliga a Module Federation a intentar detectarlas automáticamente. Sin embargo, el conflicto de dependencias entre peers dependecies hace que Module Federation lo pase mal y por eso saca el siguiente error:

Unsatisfied version 12.0.0 of shared singleton module @angular/core (required ^10.0.0 || ^11.0.0-0) ; Zone: ; Task: Promise.then ; Value: Error: Unsatisfied version 12.0.0 of shared singleton module @angular/core (required ^10.0.0 || ^11.0.0-0)

Mientras que @angular/material y @angular/cdk necesitan oficialmente @angular/core 10, el resto de la aplicación ya utiliza @angular/core 12. Esto muestra que webpack busca en los archivos package.json de todas las dependencias compartidas para determinar las versiones necesarias.

Para resolver esto, puedes establecer las versiones a mano o utilizando la función de ayuda share que utiliza la versión encontrada en el package.json de tu proyecto:

[...]

 const mf = require("@angular-architects/module-federation/webpack");
 [...]
 const share = mf.share;

 [...]

 shared: share({
   "@angular/core": { singleton: true, strictVersion: true, requiredVersion: 'auto' },
   "@angular/common": { singleton: true, strictVersion: true, requiredVersion: 'auto' },
   "@angular/router": { singleton: true, strictVersion: true, requiredVersion: 'auto' },
   "@angular/common/http": { singleton: true, strictVersion: true, requiredVersion: 'auto' }, 
   "@angular/material/snack-bar": { singleton: true, strictVersion: true, requiredVersion:'auto' }, 
 })

Compartiendo Codigo y Datos

En nuestro ejemplo, el shell y el micro frontend mfe1 comparten el auth-lib. Su AuthService almacena el nombre de usuario actual. Por lo tanto, el shell puede establecer el nombre de usuario y el mfe1 cargado de forma asyncrona puede acceder a él:

Si auth-lib fuera un paquete npm tradicional, podríamos simplemente registrarlo como una biblioteca compartida con Module Federation. Sin embargo, en nuestro caso, la auth-lib es sólo una biblioteca en nuestro monorepo. Y las bibliotecas en ese sentido son sólo carpetas con el código fuente.

Para hacer que esta carpeta parezca un paquete npm, hay un mapeo de ruta para ella en el tsconfig.json:

"paths": {
   "auth-lib": [
     "projects/auth-lib/src/public-api.ts"
   ]
 }

Tenga en cuenta que estamos apuntando directamente a la carpeta src del auth-lib. Nx hace esto por defecto.Si usamos el CLI tradicional, tendremos que ajustar esto a mano.

Afortunadamente, Module Federation nos tiene cubiertos con tales escenarios. Para hacer la configuración de estos casos un poco más fácil, así como para evitar problemas con el compilador de Angular, @angular-architects/module-federation proporciona una propiedad de configuración llamada sharedMappings:

module.exports = withModuleFederationPlugin({

     // Shared packages:
     shared: [...],

     // Explicitly share mono-repo libs:
     sharedMappings: ['auth-lib'],

 });

Importante: Desde la versión 14.3, la opcion withModuleFederationPlugin comparte automáticamente todas las rutas mapeadas si no se utiliza la propiedad sharedMappings. Por lo tanto, el problema descrito aquí, no ocurrirá.

Obviamente, si usted no opta por compartir la biblioteca en uno de los proyectos, estos proyectos obtendrán su propia copia de la auth-lib y por lo tanto compartir el nombre de usuario ya no es posible.

Para crear ese escenario, vamos a añadir otra biblioteca a nuestro monorepo:

ng g lib other-lib

Además, asegúrese de que tenemos una asignación de ruta para que apunta a su código fuente:

"paths": {
   "other-lib": [
     "projects/other-lib/src/public-api.ts"
   ],
 }

Supongamos que también queremos almacenar el nombre de usuario actual en esta biblioteca:

import { Injectable } from '@angular/core';

 @Injectable({
   providedIn: 'root'
 })
 export class OtherLibService {

   // Add this:
   userName: string;

   constructor() { }

 }

Y asumamos también, que el AuthLibService delega en esta propiedad:

 import { Injectable } from '@angular/core';
 import { OtherLibService } from 'other-lib';

 @Injectable({
   providedIn: 'root'
 })
 export class AuthLibService {

   private userName: string;

   public get user(): string {
     return this.userName;
   }

   public get otherUser(): string {
     // DELEGATION!
     return this.otherService.userName;
   }

   constructor(private otherService: OtherLibService) { }

   public login(userName: string, password: string): void {
     // Authentication for **honest** users TM. (c) Manfred Steyer
     this.userName = userName;

     // DELEGATION!
     this.otherService.userName = userName;
   }

 }

El AppComponent del shell sólo llama al método de inicio de sesión:

 import { Component } from '@angular/core';
 import { AuthLibService } from 'auth-lib';

 @Component({
   selector: 'app-root',
   templateUrl: './app.component.html'
 })
 export class AppComponent {
   title = 'shell';

   constructor(
     private service: AuthLibService
     ) {

     this.service.login('Max', null);
   }

 }

Sin embargo, ahora el Micro Frontend tiene tres formas de obtener el nombre de usuario definido:

 import { HttpClient } from '@angular/common/http';
 import {Component} from '@angular/core';
 import { AuthLibService } from 'auth-lib';
 import { OtherLibService } from 'other-lib';

 @Component({
   selector: 'app-flights-search',
   templateUrl: './flights-search.component.html'
 })
 export class FlightsSearchComponent {
   constructor(
     authService: AuthLibService,
     otherService: OtherLibService) {

     // Three options for getting the user name:
     console.log('user from authService', authService.user);
     console.log('otherUser from authService', authService.otherUser);
     console.log('otherUser from otherService', otherService.userName);

   }
 }

A primera vista, estas tres opciones deberían dar el mismo valor. Sin embargo, si sólo compartimos auth-lib pero no other-lib, obtenemos el siguiente resultado:

Como other-lib no se comparte, tanto auth-lib como el micro frontend tienen su propia versión. Por lo tanto, tenemos dos instancias de ella en el lugar. Mientras que la primera conoce el nombre de usuario, la segunda no.

¿Qué podemos aprender de esto? Bueno, sería una buena idea compartir también las dependencias de nuestras bibliotecas compartidas (¡independientemente de compartir bibliotecas en un monorepo o paquetes npm tradicionales!)

Esto también es válido para los puntos de entrada secundarios a los que pertenecen nuestras bibliotecas compartidas.

Sugerencia: @angular-architects/module-federation viene con la función shareAll para compartir todas las dependencias definidas en el package.json de tu proyecto:

shared: {
   ...shareAll({ 
       singleton: true, 
       strictVersion: true, 
       requiredVersion: 'auto' 
   }),
 }

Esto puede al menos simplificar estos casos para la creación de prototipos. Además, puedes hacer que share y shareAll incluyan todos los puntos de entrada secundarios utilizando la propiedad includeSecondaries:

shared: share({
     "@angular/common": { 
         singleton: true, 
         strictVersion: true,
         requiredVersion: 'auto',
         includeSecondaries: {
             skip: ['@angular/http/testing']
         }
     },
     [...]
 })

NullInjectorError: Service expected in Parent Scope (Root Scope)

La última sección fue un poco difícil. Por lo tanto, vamos a proceder con una más fácil. Tal vez usted ha visto un error como este aquí:

ERROR Error: Uncaught (in promise): NullInjectorError: R3InjectorError(FlightsModule)[HttpClient -> HttpClient -> HttpClient -> HttpClient]: 
   NullInjectorError: No provider for HttpClient!
 NullInjectorError: R3InjectorError(FlightsModule)[HttpClient -> HttpClient -> HttpClient -> HttpClient]: 
   NullInjectorError: No provider for HttpClient!

Parece que, el Micro Frontend mfe1 cargado no puede hacerse con el HttpClient. Puede que incluso funcione cuando se ejecuta mfe1 en modo solo.

La razón de esto es muy probable que no estemos exponiendo todo el Micro Frontend a través de Module Federation sino sólo partes seleccionadas, por ejemplo, algunos features modules con childs routes:

O para decirlo de otra manera NO exponer el AppModule del Micro Frontend. Sin embargo, si esperamos que el AppModule proporcione algunos servicios globales como el HttpClient, también debemos hacerlo en el AppModule del shell:

// Shell's AppModule
 @NgModule({
   imports: [
     [...]
     // Provide global services your micro frontends expect:
     HttpClientModule,
   ],
   [...]
 })
 export class AppModule { }

Varios Root Scopes

En un escenario muy simple usted podría tratar de exponer sólo el AppModule del Micro Frontend.

Como ves aquí, ahora, el AppModule del shell utiliza el AppModule del Micro Frontend. Si usas el router, tendrás algunos problemas iniciales porque necesitas llamar a RouterModule.forRoot para cada AppModule (Root Module) en un lado mientras que sólo se te permite llamarlo una vez en el otro lado.

Pero si sólo compartes componentes o servicios, esto podría funcionar a primera vista. Sin embargo, el problema real aquí es que Angular crea un root scope para cada modulo root. Por lo tanto, ahora tenemos dos root scope. Esto es algo que nadie espera.

Además, esto duplica todos los servicios compartidos registrados para el root scope, por ejemplo con providedIn: 'root'. Por lo tanto, tanto el shell como el Micro Frontend tienen su propia copia de estos servicios y esto es algo que nadie espera.

Una solución simple pero no preferible es poner sus servicios compartidos en el ámbito de la plataforma:

// Don't do this at home!
 @Injectable({
   providedIn: 'platform'
 })
 export class AuthLibService {
 }

Sin embargo, normalmente, este ámbito está destinado a ser utilizado por cosas internas de Angular. Por lo tanto, la única solución limpia aquí es no compartir su AppModule, sino sólo los feature modules con lazy loading. Usando esta práctica, te aseguras (más o menos) que estos módulos de características funcionan igual cuando se cargan en el shell que cuando se usan en modo isolado.

Diferentes versiones de Angular

Otro escollo, menos obvio, con el que te puedes encontrar es este de aquí:

node_modules_angular_core___ivy_ngcc___fesm2015_core_js.js:6850 ERROR Error: Uncaught (in promise): Error: inject() must be called from an injection context
 Error: inject() must be called from an injection context
     at pr (node_modules_angular_core___ivy_ngcc___fesm2015_core_js.2fc3951af86e4bae0c59.js:1)
     at gr (node_modules_angular_core___ivy_ngcc___fesm2015_core_js.2fc3951af86e4bae0c59.js:1)
     at Object.e.ɵfac [as factory] (node_modules_angular_core___ivy_ngcc___fesm2015_core_js.2fc3951af86e4bae0c59.js:1)
     at R3Injector.hydrate (node_modules_angular_core___ivy_ngcc___fesm2015_core_js.js:11780)
     at R3Injector.get (node_modules_angular_core___ivy_ngcc___fesm2015_core_js.js:11600)
     at node_modules_angular_core___ivy_ngcc___fesm2015_core_js.js:11637
     at Set.forEach (<anonymous>)
     at R3Injector._resolveInjectorDefTypes (node_modules_angular_core___ivy_ngcc___fesm2015_core_js.js:11637)
     at new NgModuleRef$1 (node_modules_angular_core___ivy_ngcc___fesm2015_core_js.js:25462)
     at NgModuleFactory$1.create (node_modules_angular_core___ivy_ngcc___fesm2015_core_js.js:25516)
     at resolvePromise (polyfills.js:10658)
     at resolvePromise (polyfills.js:10610)
     at polyfills.js:10720
     at ZoneDelegate.invokeTask (polyfills.js:10247)
     at Object.onInvokeTask (node_modules_angular_core___ivy_ngcc___fesm2015_core_js.js:28753)
     at ZoneDelegate.invokeTask (polyfills.js:10246)
     at Zone.runTask (polyfills.js:10014)
     at drainMicroTaskQueue (polyfills.js:10427)

Con inject() este debe ser llamado desde un contexto de inyección Angular nos dice que hay varias versiones de Angular cargadas a la vez.

Para provocar este error, ajusta el webpack.config.js de tu shell como sigue:

shared: share({
   "@angular/core": { requiredVersion: 'auto' },
   "@angular/common": { requiredVersion: 'auto' },
   "@angular/router": { requiredVersion: 'auto' },
   "@angular/common/http": { requiredVersion: 'auto' }, 
 })

Tenga en cuenta que estas bibliotecas ya no están configuradas para ser singletons. Por lo tanto, Module Federation permite cargar varias versiones de ellas si no hay una versión compatible más alta.

Además, hay que saber que el package.json del shell apunta a Angular 12.0.0 sin ^ o ~, por lo que necesitamos exactamente esta versión.

Si cargamos un Micro Frontend que utiliza una versión diferente de Angular, Module Federation vuelve a cargar Angular dos veces, una la versión para el shell y otra la versión para el Micro Frontend. Puedes probar esto actualizando el app.routes.ts del shell de la siguiente manera:

{
   path: 'flights',
   loadChildren: () => loadRemoteModule({
       remoteEntry: 'https://brave-plant-03ca65b10.azurestaticapps.net/remoteEntry.js',
       remoteName: 'mfe1',
       exposedModule: './Module'
     })
     .then(m => m.AppModule) 
 },

¿Qué podemos aprender aquí? Bueno, cuando se trata de tu framework principal y con estado - por ejemplo, Angular - es una buena idea definirlo como un singleton. He escrito algunos detalles sobre esto y sobre las opciones para lidiar con los diferencias de versión aquí.

Si realmente, realmente, quieres mezclar y combinar diferentes versiones de Angular, ha hablado sobre esto en articulos anteriores. Sin embargo, ya sabes se dice: Ten cuidado con tus deseos.

circular-blue.png

Extra: Multiple Bundles

Terminemos con algo que parece un problema pero que está totalmente bien. Tal vez ya haya visto que a veces Module Federation genera paquetes duplicados con nombres ligeramente diferentes:

angulararchitects.io/wp-content/webp-expres..

La razón de esta duplicación es que Module Federation genera un paquete por biblioteca compartida por consumidor. El consumidor en este sentido es el proyecto federado (shell o Micro Frontend) o una biblioteca compartida. Esto se hace para tener un paquete de reserva para resolver los conflictos de versión. En general, esto tiene sentido, mientras que en un caso tan específico, no aporta ninguna ventaja.

Sin embargo, si todo está configurado de forma correcta, sólo uno de estos duplicados debería cargarse en tiempo de ejecución. Mientras este sea el caso, no hay que preocuparse por los duplicados.

 
Share this