angular-architects / module-federation-plugin

MIT License
724 stars 196 forks source link

Bidirectional communication with shell owned variables #121

Open windbeneathyourwings opened 2 years ago

windbeneathyourwings commented 2 years ago

In the application I’m building users can define contexts. Those contexts are basically variables that can change over time. For example, a context can resolve a url change to an entity via http fetch. When that context changes any components that rely on that entity know to update. The real world example would be a master and detail page where the master and detail are part of a single encapsulating component. So when someone clicks the master item to reveal detail info the component isn’t destroyed. Instead the existing component needs to know how to handle updates to its inputs. Is there a way to achieve this type of scenario with module federation. Where the master and detail could be separate applications. Yet when context controlled by the shell federated modules can receive and update themselves approprately.

shuppert commented 2 years ago

@windbeneathyourwings I accomplished something similar with a shared service in a library. The service uses a BehaviorSubject and exposes "get" and "set" methods. The set method fires next on the BehaviourSubject. The get method exposes the BehaviorSubject as an observable. The shared service is configured as singleton shared resource in the webpack.config files of both applications.

windbeneathyourwings commented 2 years ago

@shuppert That sounds like a good approach. However, I wonder if there is a way to pass variables between applications. For example, passing a promise instead of an observable to integrate with a react, vue, or plain JavaScript app. Instead of requiring those other projects to include the dependency for the contextual system. The shell could be the only thing that knows about the actual context system but other applications could optionaly subscribe to specific context updates like user info.

ng-druid commented 2 years ago

I'm currently trying the approach you have recommended and am not having much success at the moment. I have a repo that contains a collection of Angular 13 libs. One of those libs has a service that is a PluginManager. Even though the module containing the plugin manager is defined as a singleton and uses the same version in the micro frontend a new instance of the PluginManager service is always being passed into the federated module being loaded dynamically.

Shell webpack config:

const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
const mf = require("@angular-architects/module-federation/webpack");
const path = require("path");
const share = mf.share;

const sharedMappings = new mf.SharedMappings();
sharedMappings.register(
  path.join(__dirname, '../../tsconfig.json'),
  [ /*'@ng-druid/utils', '@ng-druid/attributes', '@ng-druid/plugin', '@ng-druid/material', '@ng-druid/content'*/ ]);

module.exports = {
  output: {
    uniqueName: "ipe",
    publicPath: "auto",
  },
  optimization: {
    runtimeChunk: false
  },   
  resolve: {
    alias: {
      ...sharedMappings.getAliases(),
    }
  },
  experiments: {
    outputModule: true
  },
  plugins: [
    new ModuleFederationPlugin({
        library: { type: "module" },

        // For remotes (please adjust)
        // name: "ipe",
        // filename: "remoteEntry.js",
        // exposes: {
        //     './Component': './projects/ipe/src/app/app.component.ts',
        // },        

        // For hosts (please adjust)
        /*remotes: {
          "fedMicroNg": "http://localhost:3000/remoteEntry.js"
        },*/

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

          "@ng-druid/utils": { singleton: true, strictVersion: true, requiredVersion: '0.0.7' }, 
          "@ng-druid/attributes": { singleton: true, strictVersion: true, requiredVersion: '0.0.7' }, 
          "@ng-druid/material": { singleton: true, strictVersion: true, requiredVersion: '0.0.7' }, 
          "@ng-druid/plugin": { singleton: true, strictVersion: true, requiredVersion: '0.0.7' },
          "@ng-druid/content": { singleton: true, strictVersion: true, requiredVersion: '0.0.7' },

          ...sharedMappings.getDescriptors()
        })

    }),
    sharedMappings.getPlugin()
  ],
};

Micro front-end webpack config.

const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
const mf = require("@angular-architects/module-federation/webpack");
const path = require("path");
const share = mf.share;

const sharedMappings = new mf.SharedMappings();
sharedMappings.register(
  path.join(__dirname, '../../tsconfig.json'),
  []);

module.exports = {
  output: {
    uniqueName: "mfe1",
    publicPath: "auto"
  },
  optimization: {
    runtimeChunk: false
  },
  resolve: {
    alias: {
      ...sharedMappings.getAliases(),
    }
  },
  experiments: {
    outputModule: true
  },
  plugins: [
    new ModuleFederationPlugin({

      library: { type: "module" },

      name: "mfe1",
      filename: "remoteEntry.js",
      exposes: {
        './DownloadModule': './projects/mfe1/src/app/download.module.ts',
        './Download': './projects/mfe1/src/app/download.component.ts',
        './Upload': './projects/mfe1/src/app/upload.component.ts'
      },

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

        "@ng-druid/utils": { singleton: true, strictVersion: true, requiredVersion: 'auto', eager: false },
        "@ng-druid/attributes": { singleton: true, strictVersion: true, requiredVersion: 'auto', eager: false },
        "@ng-druid/plugin": { singleton: true, strictVersion: true, requiredVersion: 'auto', eager: false },
        "@ng-druid/material": { singleton: true, strictVersion: true, requiredVersion: 'auto', eager: false },
        "@ng-druid/content": { singleton: true, strictVersion: true, requiredVersion: 'auto', eager: false },

        ...sharedMappings.getDescriptors()
      })

    }),
    sharedMappings.getPlugin()
  ],
};

I think it might have something to do with compiling the module dynamically. Once the module is loaded via loadRemoteModule it is compiled into the application.

Resolver that loads remote module and calls dynamic module loader to compile into app.

import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { ContextPlugin, ContextResolver } from 'context';
import { loadRemoteModule } from '@angular-architects/module-federation';
import { ModuleLoaderService } from 'utils';

@Injectable()
export class ModuleResolver implements ContextResolver {

  constructor(
    private moduleLoaderService: ModuleLoaderService
  ) { }

  resolve(ctx: ContextPlugin, data?: any, metadata?: Map<string, any>): Observable<any> {
    console.log('module resolver context', ctx, data, metadata);
    return this.moduleLoaderService.loadModule(
      () => loadRemoteModule({
        type: 'module',
        remoteEntry: data.remoteEntry,
        exposedModule: data.exposedModule
      }).then(m => m.DownloadModule)
    );
    /*return new Observable<undefined>(obs => {
      loadRemoteModule({
        type: 'module',
        remoteEntry: data.remoteEntry,
        exposedModule: data.exposedModule
      }).then(() => {
        console.log('module resolver loaded', ctx.name);
        obs.next();
        obs.complete();
      });
    });*/
  }
}

Dynamic Module loader.

import { Type, Compiler, Injector, Injectable } from '@angular/core';
import { Observable } from 'rxjs';

@Injectable({
  providedIn: 'root'
})

export class ModuleLoaderService {
  constructor(private compiler: Compiler, private injector: Injector) {

  }
  loadModule(module: () => Promise<Type<any>>): Observable<boolean> {
    return new Observable(obs => {
      module().then(m => this.compiler.compileModuleAndAllComponentsAsync(m)).then(mf => {
        const moduleRef = mf.ngModuleFactory.create(this.injector);
        // moduleRef.componentFactoryResolver.resolveComponentFactory(LazyComponent);
        obs.next(true);
        obs.complete();
      });
    });
    /*mport(module)module().then(m => {
      this.compiler.compileModuleAndAllComponentsAsync(m);
      console.log(`module imported: ${module}`);
    });*/
    /*import('./carousel/carousel.module').then(({ CarouselModule }) => {
      const injector = createInjector(CarouselModule, this.injector);
      const carouselModule = injector.get(CarouselModule);
      const componentFactory = carouselModule.resolveCarouselComponentFactory();
    });*/
  }
}

The external remotely loaded DownloadModule.

import { NgModule } from '@angular/core';
import { DownloadComponent } from './download.component';
import { DownloadContentHandler } from './handlers/download-content.handler';
import { ContentPluginManager } from '@ng-druid/content';
import { mfe1DownloadContentPluginFactory } from './app.factories';
import { CommonModule } from '@angular/common';

@NgModule({
  imports: [
    CommonModule,
  ],
  declarations: [
    DownloadComponent
  ],
  providers: [
    DownloadContentHandler
  ],
  exports: [
    DownloadComponent
  ]
})
export class DownloadModule { 
  constructor(
    cpm: ContentPluginManager,
    downloadHandler: DownloadContentHandler
  ) {
    console.log('register mfe1 download content plugin');
    // @todo: lint not picking up register() because in plugin module base class.
    cpm.register(mfe1DownloadContentPluginFactory({ handler: downloadHandler }));
  }
}
ng-druid commented 2 years ago

I eliminated the compiling to be the direct cause by using a component instead of a module. The cpm param is a new instance not the one being used inside the shell.

import { Component, OnInit } from '@angular/core';
import { ContentPluginManager } from '@ng-druid/content';

@Component({
    selector: 'mfe1-download',
    template: `
        <div class="task">
            <!-- <img src="https://d16ooy9q113vw2.cloudfront.net/assets/download.png"> -->
            <img src="https://d8em0358gicmm.cloudfront.net/assets/download.png">
            <p>Download</p>
        </div>
    `
})

export class DownloadComponent implements OnInit {
    constructor(
        private cpm: ContentPluginManager
    ) { 
        console.log('download component constructor');
    }

    ngOnInit() { }
}
ng-druid commented 2 years ago

My error.

I created a separate shell application that imported all the libs instead of using a hybrid repo with both libs and project. Once I did that discovered that I had not migrated all the imports properly. The primary package @ng-druid/ was not apart of all the imports. When using search and replace I had overlooked "" (double quote) imports. Everything still compiled properly because there are mapping that remap those. However, the sharing functionality here didn't work properly. Once I fixed that the shell now shares the plugin manager instance.