DEV Community

Cover image for How to Use Module Federation with Re.Pack 3
Callstack Engineers
Callstack Engineers

Posted on • Edited on

How to Use Module Federation with Re.Pack 3

by Jakub Binda & Andrew Andilevko

Introduction

Along with the release of Re.Pack v.3 there comes a huge update for the library - stable support for Module Federation.

This feature can be a powerful ally for development departments, especially when it comes to building complex apps with Micro-Frontend architecture, requiring multiple teams to deliver the whole thing.

In this article, you’ll find out

  • what’s the idea behind Module Federation,
  • and how to use Module Federation with Re.Pack 3.

The idea behind Module Federation

Module Federation was first introduced in Webpack 5. It’s a functionality that allows for code-splitting and sharing the split code parts (chunks) between loosely coupled applications. It also helps distributed teams to ship large applications faster. And, along with its latest update, Re.Pack 3 supports this functionality out-of-the-box.

Module Federation is one of the approaches to creating Micro-frontends architecture for your application which makes the functionality a great ally in, for example, super app development or creating features on demand.

Micro-frontends

Going briefly through the Micro-Frontends working scheme, there are:

  • the host app that runs firstly on the device,
  • and Micro-frontend (MFE) apps that are used by the host app.

That means each container (MFE) that is used by the host application could be deployed and maintained independently. The host apps are able to reflect changes in Micro-frontend immediately with no need for re-deploy until the changes are connected to the JS only.

Accessing remote JS code exposed by MFE is the key feature of Module Federation and the process is called Runtime Deployment.

Image description
Scheme 1. Module Federation in the app

How to use Module Federation in Re.Pack
If you are familiar with the Webpack config for Module Federation on the web environment, you will notice a similarity in the configuration using Re.Pack. The major difference is to take into account mobile platform / React Native specifics.

Let’s assume that we have the host application, two container applications, and one module used in the first container application. Each of the apps could be deployed independently, but all of them are bound using Webpack config.

The most interesting properties for us from this config are plugins. It is an array of all the required Webpack plugins. As you can see from the code snippet below, Re.Pack exports a ModuleFederationPlugin plugin that enables Module Federation functionality:

new Repack.plugins.ModuleFederationPlugin({
        name: 'host',
        shared: {
          react: {
            ...Repack.Federated.SHARED_REACT,
            requiredVersion: '17.0.2',
          },
          'react-native': {
            ...Repack.Federated.SHARED_REACT_NATIVE,
            requiredVersion: '0.68.2',
          },
        },
      })
Enter fullscreen mode Exit fullscreen mode

Host app Webpack config Module Federation plugin.js hosted with ❤ by GitHub

Code snippet 1. Host app Webpack config Module Federation plugin

new Repack.plugins.ModuleFederationPlugin({
        name: 'app1',
        exposes: {
          './App': './src/App.tsx',
          './Text': './src/Text.tsx',
          './foo': './src/foo.ts',
        },
        shared: {
          react: {
            ...Repack.Federated.SHARED_REACT,
            eager: STANDALONE,
          },
          'react-native': {
            ...Repack.Federated.SHARED_REACT_NATIVE,
            eager: STANDALONE,
            requiredVersion: '0.68.2',
          },
        },
        remotes: {
          module1: 'module1@dynamic',
        },
      })
Enter fullscreen mode Exit fullscreen mode

Remote Container 1 Webpack config Module Federation plugin.js hosted with ❤ by GitHub

Code snippet 2. Remote Container 1 Webpack config Module Federation plugin

new Repack.plugins.ModuleFederationPlugin({
        name: 'module1',
        exposes: {
          './Root': './src/Root.tsx',
          './baz': './src/baz.ts',
        },
        shared: {
          react: {
            ...Repack.Federated.SHARED_REACT,
            eager: STANDALONE,
          },
          'react-native': {
            ...Repack.Federated.SHARED_REACT_NATIVE,
            eager: STANDALONE,
            requiredVersion: '0.68.2',
          },
        },
        remotes: {
          app1: 'app1@dynamic',
        },
      })
Enter fullscreen mode Exit fullscreen mode

Module app Webpack config Module Federation plugin.js hosted with ❤ by GitHub

Code snippet 3. Module app Webpack config Module Federation plugin

Going through the properties in the Module Federation plugin config, we can find the same ones in all of the apps. The name property is required, so we have to provide a unique name for each of our apps. Then, we will use it across the app for importing federated modules. The next common property is called shared. This array contains libraries and provides Webpack information regarding the libraries that should be shared in other applications. Also, they should have only one instance across the apps. Re.Pack provides default settings for React and React Native libraries in shared property.

So, until there are no more other libraries that should be shared, this property could be skipped. Dependency defined inside shared property might be also set as eager, which doesn’t put the modules in an async chunk, but provides them synchronously. That’s why it is combined with STANDALONE env which should be true when a remote app is run as its standalone version. Otherwise shared modules won’t be available in the initial bundle and the app is likely to crash.

As you can see on the code snippets above, all the plugins’ configs, except the host app, have exposes property. This is a list of modules that the application will export as remote to another application. The host app can only import remote modules and cannot have pre-defined ‘exposes’ or ‘remotes’ properties in Webpack config. However, MFEs can do both, expose and import remote modules at the same time.

While ‘exposes’ and ‘remotes’ properties are sufficient to support Module Federation in web apps, it’s not enough in a mobile environment. For Re.Pack, we have to add one more thing to handle fetched scripts from remote - a resolver. That’s why the ScriptManager was introduced in Re.Pack 3.

This is a manager that eases the resolution, downloading, and executing additional code from:

  • arbitrary JavaScript scripts
  • Webpack chunks
  • Webpack bundles
  • Webpack MF containers

To make ScriptManager resolve our remote scripts properly, we need to pass URLs to remote locations that those scripts will be available to fetch. They should be passed in proper object shape accepted by the createURLResolver method from the Federated module. In a very basic example, URLs might be hardcoded and the implementation is presented in the snippet below:

const resolveURL = Federated.createURLResolver({
  containers: {
    app1: 'http://localhost:9000/[name][ext]',
    app2: 'http://localhost:9001/[name][ext]',
    module1: 'http://localhost:9002/[name][ext]',
  },
});

ScriptManager.shared.addResolver(async (scriptId, caller) => {
  let url;
  if (caller === 'main') {
    url = Script.getDevServerURL(scriptId);
  } else {
    url = resolveURL(scriptId, caller);
  }

  if (!url) {
    return undefined;
  }

  return {
    url,
    cache: false,
    query: {
      platform: Platform.OS,
    },
  };
});
Enter fullscreen mode Exit fullscreen mode

ScriptManager usage inside host application.ts hosted with ❤ by GitHub

Code snippet 4. ScriptManager usage inside the host application

However, there is an option to provide remote containers URLs dynamically. That means removing hardcoded URLs and fetching them from external service right in addResolver. Implementing the external service is optional but might become very useful on a larger scale. It allows to manage remote containers' URLs from outside of the host app and to switch them without the need for the host app re-deployement. Furthermore, it might help to handle fetching theproper version of MFEs inside the host app when a version management system is required.

ScriptManager.shared.addResolver(async (scriptId, caller) => {
  let url;
    const containers = await fetchURLs();
    const resolveURL = Federated.createURLResolver({
      containers,
    });

  if (caller === 'main') {
    url = Script.getDevServerURL(scriptId);
  } else {
    url = resolveURL(scriptId, caller);
  }

  if (!url) {
    return undefined;
  }

  return {
    url,
    cache: false,
    query: {
      platform: Platform.OS,
    },
  };
});
Enter fullscreen mode Exit fullscreen mode

ScriptManager usage with dynamic URLs.js hosted with ❤ by GitHub

Code snippet 4.1. ScriptManager usage with dynamic URLs

After setting up the ScriptManager, we can use Module Federation functionality in the code. Here is the App.tsx file from the host application:

// eslint-disable-next-line import/no-extraneous-dependencies
import { Federated } from '@callstack/repack/client';
import React from 'react';
import { Text, SafeAreaView } from 'react-native';

const App1 = React.lazy(() => Federated.importModule('app1', './App'));
const App2 = React.lazy(() => Federated.importModule('app2', './App'));

export default function App() {
  return (
    <SafeAreaView>
      <Text>Host App</Text>
      <React.Suspense fallback={<Text>Loading app1...</Text>}>
        <App1 />
      </React.Suspense>
      <React.Suspense fallback={<Text>Loading app2...</Text>}>
        <App2 />
      </React.Suspense>
    </SafeAreaView>
  );
}

Enter fullscreen mode Exit fullscreen mode

App.tsx code from host application.tsx hosted with ❤ by GitHub

The imports are async, so we should wait till the script will be downloaded, resolved by ScriptManager, and ready to use. So, we added React.lazy and React.Suspense syntax for handling async imports. React.Suspense has the fallback property that allows showing loader component to the users during loading scripts. To dynamically import remote modules in the host app, we also used the utility function importModule from Federated namespace, to provide the correct container and module name.

But, let’s take a look at the previously presented code from code snippet 2. There is remotes property under the Module Federation plugin in Webpack config. It helps to use the same syntax as for static imports inside app1 when importing modules from remote containers. Also, it’s worth mentioning that the component name needs to be prefixed with the remote container name. In this case, it’s module1/Root. According to what was written before - the remotes property can be used only in MFEs but anyway, it should still use React.Suspense when rendering remote containers:

import * as React from 'react';
import { Text } from 'react-native';

// eslint-disable-next-line import/no-unresolved
import Module1 from 'module1/Root';
import { foo } from './foo';

export default function App() {
  const [fooText, setFooText] = React.useState<string>('');

  React.useEffect(() => {
    (async () => {
      try {
        const fooText = await foo();
        setFooText(fooText);
      } catch {
        setFooText('Failed to get foo text');
      }
    })();
  }, []);

  return (
    <>
      <Text>App 1</Text>
      <React.Suspense fallback={<Text>Loading module1...</Text>}>
        <Module1 />
      </React.Suspense>
      <Text>{fooText}</Text>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

App.tsx code from app1 application.tsx hosted with ❤ by GitHub

Code snippet 6. App.tsx code from app1 application

In code snippet 3 there is also a defined remotes property that allows importing modules exposed by app1. This pattern is called bi-directional import. It won’t be possible outside the Module Federation when importing module B in module A and module A in module B at the same time because of circular dependencies. However, Module Federation allows it and it’s totally fine until both modules are imported from different MFEs. The only thing that developers have to keep in mind is that it might be easy to end up with an endless loop e.g. by calling one function by another as presented in the code snippet below:

let counter = 3;

export async function baz() {
  if (counter <= 0) {
    return 'end';
  }

  // eslint-disable-next-line import/no-unresolved
  const { foo } = await import('app1/foo');

  counter--;
  return `baz + ${await foo()}`;
}
Enter fullscreen mode Exit fullscreen mode

baz function inside module1 which uses foo function from app1.tsx hosted with ❤ by GitHub

Code snippet 7. baz function inside module1 which uses foo function from app1

Please notice that the foo function from app1 uses baz function from module1. So as mentioned above, circular dependencies are working perfectly fine:

// eslint-disable-next-line import/no-unresolved
import { baz } from 'module1/baz';

export async function foo() {
  return `foo + ${await baz()}`;
}
Enter fullscreen mode Exit fullscreen mode

foo function from app1.tsx hosted with ❤ by GitHub

Code snippet 8. foo function from app1

On code snippet 7, there is a counter variable that prevents an endless loop caused by calling foo by baz and baz by foo.

Summary

Module Federation is a powerful feature implemented in Webpack 5 and carefully migrated to Re.Pack 3 package.

Thanks to its functionalities, Module Federation can be a great ally for large development teams working on complex products, like super apps. The main benefits of Module Federation are

  • Independent deployment. No need to re-deploy host application on remote containers changes and re-deployment
  • Separating huge development teams. As every container could be deployed independently each team could work on their container
  • Sharing the code between tightly loosely coupled applications

This article was originally published at callstack.com on October 4, 2022.

Top comments (0)