Module Federation Series Part 1: A Little in-depth

Backgorund image by Andy Sanchez: https://unsplash.com/photos/SE0-8EKQRck
Backgorund image by Andy Sanchez: https://unsplash.com/photos/SE0-8EKQRck

When you research approaches for building micro frontends, you would constantly see a module federation which is a new game-changer plugin. The problem is that devs (like me) may find it hard to understand the philosophy of module federation. For example: how things like sharing dependencies happen behind the scenes. It took me days/weeks in order to better figure it out. Since module federation is relatively new, most blogs cover only the essential parts, unfortunately.

Building micro frontends with module federation is a big topic, so I decided to split my post mainly into 3 separate blogs:

✅ Module Federation Series Part 1: A little deep dive

⏳ Module Federation Series Part 2: Things you should know when building micro frontends with Angular + Module Federation

⏳ Module Federation Series Part 3: Example Angular app with Module Federation + Router + NgRx + Angular Material + Communication

About me: I am a junior front-end developer.

☕ In this blog, I would try to share some knowledge with you about things I learned while reverse engineering module federation.

You can check out the Youtube Clone using ModuleFederation which uses some of the below techniques:

https://github.com/vugar005/youtube-webapp-turborepo

What is Module Federation?

As per Zack Jakson who is the author of this amazing plugin.

Module federation allows a JavaScript application to dynamically load code from another application. If an application consuming a federated module does not have a dependency needed by the federated code — Webpack will download the missing dependency from that federated build origin.

The world BEFORE module federation:

Before module federation, in order to implement micro frontends, developers were mostly using 2 approaches. The first approach, bundle the whole app as a web component(like angular elements). The main disadvantage of using web components without module federation is our bundle would be very big since we could not share common dependencies 😕. Even if our application consisted of the same version of framework/library like Angular v13, we would still load each app’s big chunk separately. Another approach was bundling our app with libraries such as ng-packagr. In this case, the bundle was minimalistic but it was relying on peer dependencies on parent like angular/core, and angular/material which means our whole app should be Evergreen and consist of the same version of dependencies😕. If we use Angular v12 other apps should also have used v12. Module federation solves those issues by sharing common dependencies and it works with multiple versions or types of frameworks/libraries.😎

Some Basic Definitions🛠:

This blog is based on micro frontend example by Manfred Steyer in REPO.

Assume we have a shell(host) application that loads other micro frontends(remotes). In Module federation shell(host) is called ContainerReferencePlugin while separately loaded apps are called ContainerPlugin.

Our s_h_ell app uses Angular v13🅰, mfe1(remote) uses Angular v13 and mfe2 and mfe3(remotes) use Angular v12. Mfe4(remote) uses reactjs.

As per mentioned REPO, the basic configuration of the remote app(mfe1) would be like below.

// Remote mfe1 webpack config
new ModuleFederationPlugin({
  name: 'mfe1',
  library: { type: 'module' },
  filename: 'remoteEntry.js',
  exposes: { './web-components': './src/bootstrap.ts' },
  shared: [],
});

Name: here we provide the name of our remote so it can be accessed with this name from the shell.

Library: here we specify the type of library. Starting from Angular v13 our app is bundled with ESM module so we specify type: module while below Angular v13 and other libraries like ReactJs or VueJs are bundled as plain js so we should specify them with type: var. The difference is we can load ESM module directly via import statement while to import plain js bundles we should generate <script> tags.

Exposes: here we expose our components. Usually only 1 file like bootstrap.ts. The module federation will generate the file(remoteEntry.js) which consists of instructions from exposes and shared config.

Remotes: Shell config on the other hand has references to remote micro frontends which expose their files via exposes. By the way, you can even load micro frontends dynamically, so even the module federation shell app has no idea it has remotes. For more details LINK.

// Shell webpack config
plugins: [
        new ModuleFederationPlugin({

            library: { type: "module" },

            remotes: {
                // Angular 13: loaded as EcmaScript module
                // (no remoteName needed like mfe1@... )
                // 'mfe1': "http://localhost:4201/remoteEntry.js",

                // Angular 12: loaded as script file
                // 'mfe2': "script mfe2@http://localhost:4202/remoteEntry.js",
                // 'mfe3': "script mfe3@http://localhost:4203/remoteEntry.js",

                // React: loaded as script file
                // 'mfe4': "script mfe4@http://localhost:4204/remoteEntry.js",
              },
            shared: ["@angular/core", "@angular/common", "@angular/router"]
        })
      ],

Shared config and remoteEntry.js:

This is the config you would most modify while building apps. Hence, this blog is mostly about this config. To better understand this section, I configured namedChunks in angular.json/webpack so we can clearly see the names of loaded chunks.

namedChunks: true

Shared config scenario 1: Empty

Assume our shell’s and remote (mfe1) app’s shared config is empty like below:

shared: [];

This is one of the most retarded scenarios😅 since we don’t use the power of the module federation.

Bundle overview:

In this case, we simply bundle all our app dependencies into a single file. Hence when we navigate to MFE1 from shell we can see our full mfe1 bundle(bootstap.js 2.0 mb unoptimized mode) which we exposed.

Shared config scenario 1-inspect
Shared config scenario 1-inspect

remoteEntry.js content:

Now let’s look at part of generated remoteEntry.js., it does not share anything at all. Check LINK to a gist file.

Shared config scenario 1-build
Shared config scenario 1-build

In other words, the module federation says like: ‘Hmm… Ok’. 😅

Also, we would see this error on the console:

Error: inject() must be called from an injection context

Shared config scenario 1-inject-error
Shared config scenario 1-inject-error

The reason for it is since we don’t share dependencies our (angular/core) will be loaded twice(shell + mfe1).

As JoostK mentioned here:

This error is because having multiple versions of angular/core , as inject depends on the global state for it to work but with multiple copies of @angular/core you'll end up with multiple copies of the global state at runtime, which causes problems like these.

Shared config scenario 2: Auto versioning

Now to solve the issue above, we should load our angular app once. In order to achieve that, we should split dependencies here(angular/core) into separate chunks.

// Shell and mfe1 webpack config
shared: ['@angular/core', '@angular/common', '@angular/router'];

This would split those packages into chunks and will try auto-assign the version numbers.👍👍 See below how:

Bundle overview:

Now we see that, the angular core and other shared dependencies were loaded only once. In addition, our mfe1 bootstrap.js is much smaller now(150kb unoptimized mode).

Shared config scenario 2 inspect
Shared config scenario 2 inspect

remoteEntry.js content:

Now let’s look at part of generated remoteEntry.js. LINK to full gist.

Shared config scenario 2 build
Shared config scenario 2 build

We start to see module federation now auto assigns versions of dependencies and loads them if it is needed.

Shared config scenario 3: No required version specified

Now let’s use HttpClientModule in angular and decide to share it also.

// mfe1 webpack config
shared: ['@angular/core', '@angular/common', '@angular/router', 'angular/common/http'];

When we load the app we would see the below warning:

No required version specified and unable to automatically determine one. Unable to find required version..

Shared config scenario 3 inspect
Shared config scenario 3 inspect

As mentioned previously, if we don’t specify the version manually, module federation would auto-define it’s version from package.json.

According to Manfred amazing book. The reason for this warning above is the secondary entry point. @angular/common/http which is a bit like an npm package within an npm package. Technically, it’s just another file exposed by the npm package @angular/common . Unsurprisingly, @angular/common/http uses @angular/common and webpack recognizes this. For this reason, webpack wants to find out which version of @angular/common is used. For this, it looks into the npm package’s package.json ( @angular/common/package.json ) and browses the dependencies there. However, @angular/common it itself is not a dependency of @angular/common and hence, the version cannot be found.

So basically it is not angular related issue. Module federation is smart to auto-define versions but in some cases, it can’t find versions of secondary entry points.

For more details LINK.

Solution: Define versions manually.

// mfe1 webpack config
shared: {
"@angular/core": {requiredVersion: '13.1.1'},
"@angular/common": {requiredVersion: '13.1.1'},
"@angular/router": {requiredVersion: '13.1.1'},
"@angular/common/http": {requiredVersion: '13.1.1'}
}

Now warnings are gone.

Shared config Scenario 4: singleton and strictVersion

This one pretty simple scenario, if we define our dependencies as singleton. We instruct module federation that one and only one copy of this dependency should be loaded. If it finds out that there are multiple copies later then it will complain.

// mfe2 webpack config
shared: {
  "@angular/core": {requiredVersion: '12.2.15', singleton: true},
  "@angular/common": {requiredVersion: '12.2.15', singleton: true},
  "@angular/router": {requiredVersion: '12.2.15', singleton: true},
  "angular/common/http": {requiredVersion: '12.2.15', singleton: true}
}

Now if we navigate from Shell v13 to MFE2 v12 Angular, we would see this warning.

Shared config scenario 4 inspect
Shared config scenario 4 inspect

In other words, module federation says like: “Come on man, you promised that you will only have the same version angular”😅

In addition: instead of showing a warning, we can make it stricter to show error instead.

// mfe2 webpack config
shared: {
  "@angular/core": {requiredVersion: '12.2.15', singleton: true, strictVersion: true},
  "@angular/common": {requiredVersion: '12.2.15', singleton: true, strictVersion: true},
  "@angular/router": {requiredVersion: '12.2.15', singleton: true, strictVersion: true},
  "angular/common/http": {requiredVersion: '12.2.15', singleton: true, strictVersion: true}
}

Result:

Shared config scenario 4 build
Shared config scenario 4 build

Eager

We can also specify eager flag in the shared config. It is used to instruct module federation whether to load our shared dependencies synchronously or asynchronously. By default, it is false(asynchronous). One thing I should point out is that, if we share our dependencies with eager: true, when we, for example, navigating from shell v13 to remote app(mfe1) the mfe1 dependencies will be still loaded. For more details LINK.

In conclusion,

After playing with shared config in module federation, I realized that it is a safe bet to always specify versions of dependencies manually via requiredVersion. If you find it cumbersome to manually specify versions you can either use angular-architects/module-federation plugin or specify like below:

const deps = require('./package.json').dependencies;

shared: {
 "@angular/core": {requiredVersion: deps["@angular/core"]},
 "@angular/common": {requiredVersion: deps["@angular/common"]},
 "@angular/router": {requiredVersion: deps["@angular/router"]}
}

That's all for this post. Hope you enjoyed it and found it useful.🍍

References:

📗 Book: https://www.angulararchitects.io/en/book/

🗎 Example Code: https://github.com/vugar005/youtube-webapp-turborepo

🗎 Documentation: https://webpack.js.org/concepts/module-federation

Twitter: https://twitter.com/Vugar005

You can also check out my other blogs.