diff --git a/packages/mf/README.md b/packages/mf/README.md index 3358ef12..7980ca0e 100644 --- a/packages/mf/README.md +++ b/packages/mf/README.md @@ -15,7 +15,7 @@ Big thanks to the following people who helped to make this possible: ## Motivation 💥 -Module Federation allows to load separately compiled and deployed code (like micro frontends or plugins) into an application. This plugin makes Module Federation work together with Angular and the CLI. +Module Federation allows loading separately compiled and deployed code (like micro frontends or plugins) into an application. This plugin makes Module Federation work together with Angular and the CLI. ## Features 🔥 @@ -27,13 +27,20 @@ Module Federation allows to load separately compiled and deployed code (like mic The module federation config is a **partial** webpack configuration. It only contains stuff to control module federation. The rest is generated by the CLI as usual. +Since Version 1.2, we also provide some advanced features like: + +✅ Dynamic Module Federation support + +✅ Sharing Libs of a Monorepo + + ## Usage 🛠️ 1. ``ng add @angular-architects/module-federation`` 2. Adjust the generated ``webpack.config.js`` file 3. Repeat this for further projects in your workspace (if needed) -## Opting in into webpack 5 with CLI 11 🧐 +## Opt-in into webpack 5 with CLI 11 🧐 - You need to use **yarn** b/c it allows to override dependencies - Existing Projects: ``ng config -g cli.packageManager yarn`` @@ -49,7 +56,19 @@ The module federation config is a **partial** webpack configuration. It only con - Run **yarn** to install all packages -Please that the CLI's webpack 5 support is current experimental. +Please that the CLI's webpack 5 support is experimental in CLI 11. Here, you find a list with [unresolved issues](https://github.com/angular/angular-cli/pull/18873) in the current version. + +## Getting Started 🧪 + +Please find here a [tutorial](https://github.com/angular-architects/module-federation-plugin/blob/main/packages/mf/tutorial/tutorial.md) that shows how to use this plugin. + +![Microfrontend Loaded into Shell](https://github.com/angular-architects/module-federation-plugin/raw/main/packages/mf/tutorial/result.png) + +[>> Start Tutorial](https://github.com/angular-architects/module-federation-plugin/blob/main/packages/mf/tutorial/tutorial.md) + +## Documentation 📰 + +Please have a look at this [article series about Module Federation](https://www.angulararchitects.io/aktuelles/the-microfrontend-revolution-part-2-module-federation-with-angular/). ## Example 📽️ @@ -61,18 +80,146 @@ This [example](https://github.com/manfredsteyer/module-federation-plugin-example Please have a look into the example's **readme**. It points you to the important aspects of using Module Federation. -## Tutorial 🧪 +## Advanced Features -Please find here a [tutorial](https://github.com/angular-architects/module-federation-plugin/blob/main/packages/mf/tutorial/tutorial.md) that shows step by step how to introduce Module Federation into the above mentioned example. +While the above-mentioned tutorial and blog articles guide you through using Module Federation, this section draws your attention to some advanced aspects of this plugin and Module Federation in general. -![Microfrontend Loaded into Shell](https://github.com/angular-architects/module-federation-plugin/raw/main/packages/mf/tutorial/result.png) +### Dynamic Module Federation -[>> Start Tutorial](https://github.com/angular-architects/module-federation-plugin/blob/main/packages/mf/tutorial/tutorial.md) +Since version 1.2, we provide helper functions making dynamic module federation really easy. Just use our ``loadRemoteModule`` function instead of a dynamic ``include``, e. g. together with lazy routes: + +```typescript +import { loadRemoteModule } from '@angular-architects/module-federation'; + +[...] +const routes: Routes = [ + [...] + { + path: 'flights', + loadChildren: () => + loadRemoteModule({ + remoteEntry: 'http://localhost:3000/remoteEntry.js', + remoteName: 'mfe1', + exposedModule: './Module' + }) + .then(m => m.FlightsModule) + }, + [...] +] +``` + +If somehow possible, load the ``remoteEntry`` upfront. This allows Module Federation to take the remote's metadata in consideration when negotiating the versions of the shared libraries. + +For this, you could call ``loadRemoteEntry`` BEFORE bootstrapping Angular: + +```typescript +// main.ts +import { loadRemoteEntry } from '@angular-architects/module-federation'; + +Promise.all([ + loadRemoteEntry('http://localhost:3000/remoteEntry.js', 'mfe1') +]) +.catch(err => console.error('Error loading remote entries', err)) +.then(() => import('./bootstrap')) +.catch(err => console.error(err)); +``` + +The ``bootstrap.ts`` file contains the source code normally found in ``main.ts`` and hence, it calls ``platform.bootstrapModule(AppModule)``. You really need this combination of an upfront file calling loadRemoteEntry and a dynamic import loading another file bootstrapping Angular because Angular itself is already a shared library respected during the version negotiation. + +Then, when loading the remote Module, just skip the ``remoteEntry`` property: + +```typescript +import { loadRemoteModule } from '@angular-architects/module-federation'; + +[...] +const routes: Routes = [ + [...] + { + path: 'flights', + loadChildren: () => + loadRemoteModule({ + // Skipped - already loaded upfront: + // remoteEntry: 'http://localhost:3000/remoteEntry.js', + remoteName: 'mfe1', + exposedModule: './Module' + }) + .then(m => m.FlightsModule) + }, + [...] +] +``` + +### Sharing Libs of a Monorepo + +Let's assume, you have an Angular CLI Monorepo or an Nx Monorepo using path mappings in ``tsconfig.json`` for providing libraries: + +```json +"shared-lib": [ + "projects/shared-lib/src/public-api.ts", +], +``` + +You can now share such a library across all your micro frontends (apps) in your mono repo. This means, this library will be only loaded once. + +To accomplish this, just register this lib name with the ``SharedMappings`` instance in your webpack config: + +```javascript +const mf = require("@angular-architects/module-federation/webpack"); +const path = require("path"); + +[...] + +const sharedMappings = new mf.SharedMappings(); +sharedMappings.register( + path.join(__dirname, '../../tsconfig.json'), + ['auth-lib'] +); +``` + +Beginning with version 1.2, the boilerplate for using ``SharedMappings`` is generated for you. You only need to add your lib's name here. + +This generated code includes providing the metadata for these libraries for the ``ModuleFederationPlugin`` and adding a plugin making sure that even source code generated by the Angular Compiler uses the shared version of the library. + +```javascript +plugins: [ + new ModuleFederationPlugin({ + [...] + shared: { + [...] + ...sharedMappings.getDescriptors() + } + }), + sharedMappings.getPlugin(), +], +``` + +### Pitfalls when sharing libraries of a Monorepo + +#### Bug with styleUrls + +Currently, there is, unfortunately, a bug in the experimental CLI/webpack5 integration causing issues when using shared libraries together with components pointing to ``styleUrls``. For the time being, you can work around this issue by removing all ``styleUrls`` in your applications and libraries. + +#### Sharing a library that is not even used + +If you shared a local library that is not even used, you get the following error: + +``` +./projects/shared-lib/src/public-api.ts - Error: Module build failed (from ./node_modules/@ngtools/webpack/src/index.js): +Error: C:\Users\Manfred\Documents\projekte\mf-plugin\example\projects\shared-lib\src\public-api.ts is missing from the TypeScript compilation. Please make sure it is in your tsconfig via the 'files' or 'include' property. + at AngularCompilerPlugin.getCompiledFile (C:\Users\Manfred\Documents\projekte\mf-plugin\example\node_modules\@ngtools\webpack\src\angular_compiler_plugin.js:957:23) + at C:\Users\Manfred\Documents\projekte\mf-plugin\example\node_modules\@ngtools\webpack\src\loader.js:43:31 +``` + +#### Not exported Components +If you use a shared component without exporting it via your library's barrel (``index.ts`` or ``public-api.ts``), you get the following error at runtime: -## More Details on Module Federation 📰 +``` +core.js:4610 ERROR Error: Uncaught (in promise): TypeError: Cannot read property 'ɵcmp' of undefined +TypeError: Cannot read property 'ɵcmp' of undefined + at getComponentDef (core.js:1821) +``` -Have a look at this [article series about Module Federation](https://www.angulararchitects.io/aktuelles/the-microfrontend-revolution-part-2-module-federation-with-angular/). ## Angular Trainings, Workshops, and Consulting 👨‍🏫 diff --git a/packages/mf/package.json b/packages/mf/package.json index d508dc82..31ba92b3 100644 --- a/packages/mf/package.json +++ b/packages/mf/package.json @@ -1,6 +1,6 @@ { "name": "@angular-architects/module-federation", - "version": "1.2.0-rc.9", + "version": "1.2.0", "license": "MIT", "repository": { "type": "GitHub", diff --git a/packages/mf/src/schematics/mf/schematic.ts b/packages/mf/src/schematics/mf/schematic.ts index 1e746833..231e2738 100644 --- a/packages/mf/src/schematics/mf/schematic.ts +++ b/packages/mf/src/schematics/mf/schematic.ts @@ -61,7 +61,7 @@ function makeMainAsync(main: string): Rule { const mainContent = tree.read(main); tree.create(bootstrapName, mainContent); - tree.overwrite(main, "import('./bootstrap');\n"); + tree.overwrite(main, "import('./bootstrap')\n\t.catch(err => console.error(err));\n"); } } diff --git a/packages/mf/tutorial/tutorial.md b/packages/mf/tutorial/tutorial.md index 78f72b92..dd8b2b8d 100644 --- a/packages/mf/tutorial/tutorial.md +++ b/packages/mf/tutorial/tutorial.md @@ -66,6 +66,8 @@ Now, let's activate and configure module federation: ```javascript const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin"); + [...] + module.exports = { output: { uniqueName: "mfe1" @@ -87,20 +89,24 @@ Now, let's activate and configure module federation: shared: { "@angular/core": { singleton: true, strictVersion: true }, "@angular/common": { singleton: true, strictVersion: true }, - "@angular/router": { singleton: true, strictVersion: true } + "@angular/router": { singleton: true, strictVersion: true }, + [...] } - }) + }), + [...] ], }; ``` This exposes the ``FlightsModule`` under the Name ``./Module.``. Hence, the shell can use this path to load it. -3. Switch into the ``shell`` project and open the file ``projects\shell\webpack.config.js``. Adjust it as follows: +1. Switch into the ``shell`` project and open the file ``projects\shell\webpack.config.js``. Adjust it as follows: ```javascript const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin"); + [...] + module.exports = { output: { uniqueName: "shell" @@ -117,16 +123,18 @@ Now, let's activate and configure module federation: shared: { "@angular/core": { singleton: true, strictVersion: true }, "@angular/common": { singleton: true, strictVersion: true }, - "@angular/router": { singleton: true, strictVersion: true } + "@angular/router": { singleton: true, strictVersion: true }, + [...] } - }) + }), + [...] ], }; ``` This references the separately compiled and deployed ``mfe1`` project. There are some alternatives to configure its URL (see links at the end). -4. Open the ``shell``'s router config (``projects\shell\src\app\app.routes.ts``) and add a route loading the microfrontend: +2. Open the ``shell``'s router config (``projects\shell\src\app\app.routes.ts``) and add a route loading the microfrontend: ```javascript { @@ -137,7 +145,7 @@ Now, let's activate and configure module federation: Please note that the imported URL consists of the names defined in the configuration files above. -5. As the Url ``mfe1/Module`` does not exist at compile time, ease the TypeScript compiler by adding the following line to the file ``projects\shell\src\decl.d.ts``: +3. As the Url ``mfe1/Module`` does not exist at compile time, ease the TypeScript compiler by adding the following line to the file ``projects\shell\src\decl.d.ts``: ```javascript declare module 'mfe1/Module'; @@ -167,6 +175,188 @@ Now, let's try it out! Congratulations! You've implemented your first Module Federation project with Angular! +## Part 4: Switch to Dynamic Federation + +Now, let's remove the need for registering the micro frontends upfront with with shell. + +1. Switch to your ``shell`` application and open the file ``webpack.config.js``. Here, remove the registered remotes: + + ```javascript + remotes: { + // Remove this line or comment it out: + // "mfe1": "mfe1@http://localhost:3000/remoteEntry.js", + }, + ``` + +1. Open the file ``app.routes.ts`` and use the function ``loadRemoteModule`` instead of the dynamic ``import`` statement: + + ```typescript + import { loadRemoteModule } from '@angular-architects/module-federation'; + + [...] + const routes: Routes = [ + [...] + { + path: 'flights', + loadChildren: () => + loadRemoteModule({ + remoteEntry: 'http://localhost:3000/remoteEntry.js', + remoteName: 'mfe1', + exposedModule: './Module' + }) + .then(m => m.FlightsModule) + }, + [...] + ] + ``` + +2. Restart both, the ``shell`` and the micro frontend (``mfe1``). + +3. The shell should still be able to load the micro frontend. However, now it's loaded dynamically. + +This was quite easy, wasn't it? However, we can improve this solution a bit. Ideally, we load the remote entry upfront before Angular bootstraps. In this early phase, Module Federation tries to determine the highest compatible versions of all dependencies. Let's assume, the shell provides version 1.0.0 of a dependency (specifying ^1.0.0 in its ``package.json``) and the micro frontend uses version 1.1.0 (specifying ^1.1.0 in its ``package.json``). In this case, they would go with version 1.1.0. However, this is only possible if the remote's entry is loaded upfront. + +1. Switch to the ``shell`` project and open the file ``main.ts``. Adjust it as follows: + + ```typescript + import { loadRemoteEntry } from '@angular-architects/module-federation'; + + Promise.all([ + loadRemoteEntry('http://localhost:3000/remoteEntry.js', 'mfe1') + ]) + .catch(err => console.error('Error loading remote entries', err)) + .then(() => import('./bootstrap')) + .catch(err => console.error(err)); + ``` + +1. Open the file ``app.routes.ts`` and comment out (or remove) the property ``remoteEntry``: + + ```typescript + import { loadRemoteModule } from '@angular-architects/module-federation'; + + [...] + const routes: Routes = [ + [...] + { + path: 'flights', + loadChildren: () => + loadRemoteModule({ + // remoteEntry: 'http://localhost:3000/remoteEntry.js', + remoteName: 'mfe1', + exposedModule: './Module' + }) + .then(m => m.FlightsModule) + }, + [...] + ] + ``` + +2. Restart both, the ``shell`` and the micro frontend (``mfe1``). + +3. The shell should still be able to load the micro frontend. + +## Step 5: Share a Library of Your Monorepo + +1. Add a library to your monorepo: + + ``` + ng g lib auth-lib + ``` + +2. In your ``tsconfig.json`` in the project's root, adjust the path mapping for ``auth-lib`` so that it points to the libs entry point: + + ```json + "auth-lib": [ + "projects/auth-lib/src/public-api.ts" + ] + ``` + +3. As most IDEs only read global configuration files like the ``tsconfig.json`` once, restart your IDE (Alternatively, your IDE might also provide an option for reloading these settings). + +4. Open the ``shell``'s ``webpack.config.js`` and register the created ``auth-lib`` with the ``sharedMappings``: + + ```typescript + const sharedMappings = new mf.SharedMappings(); + sharedMappings.register( + path.join(__dirname, '../../tsconfig.json'), + ['auth-lib'] // <-- Add this entry! + ); + ``` + +5. Also open the micro frontends (``mfe1``) ``webpack.config.js`` and do the same. + +6. Switch to your ``auth-lib`` project and open the file ``auth-lib.service.ts``. Adjust it as follows: + + ```typescript + @Injectable({ + providedIn: 'root' + }) + export class AuthLibService { + + private userName: string; + + public get user(): string { + return this.userName; + } + + constructor() { } + + public login(userName: string, password: string): void { + // Authentication for **honest** users TM. (c) Manfred Steyer + this.userName = userName; + } + + } + ``` + +7. Switch to your ``shell`` project and open its ``app.component.ts``. Use the shared ``AuthLibService`` to login a user: + + ```typescript + 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); + } + + } + ``` + +8. Switch to your ``mfe1`` project and open its ``flights-search.component.ts``. Use the shared service to retrieve the current user's name: + + ```typescript + export class FlightsSearchComponent { + + [...] + + user = this.service.user; + + constructor(private service: AuthLibService, [...]) { } + + [...] + } + ``` + +9. Open this component's template(``flights-search.component.html``) and data bind the property ``user``: + + ```html +
+
{{user}}
+ [...] +
+ ``` + +10. Restart both, the ``shell`` and the micro frontend (``mfe1``). + +11. In the shell, navigate to the micro frontend. If it shows the same user name, the library is shared. + + ## More Details on Module Federation Have a look at this [article series about Module Federation](https://www.angulararchitects.io/aktuelles/the-microfrontend-revolution-part-2-module-federation-with-angular/)