angular-architects / module-federation-plugin

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Use more importmap default behaviours

jogelin opened this issue Β· comments

Hi there,

As usual, it's a great library πŸ‘. I appreciate the fact that the lesser-known importmaps standard is utilized ;)

Goal

I've previously employed importmaps in one of my micro-frontend projects alongside single-spa and the importmap overrides library. This approach significantly enhances the developer experience in complex environments by allowing direct integration of a local server into an integration server (more info in the YouTube video from Joel Denning).

It also proves beneficial for testing, enabling the generation of an affected importmap on PRs and its use to override bundles in an existing environment without the need for cloning or deploying anything.

It also facilitates incremental and canary deployment.

Issue

My intention was to apply the same strategy with native federation due to its utilization. However, I found it unfeasible due to a globalCache that does not account for potential modifications to the importmaps overrides. This capability is a default feature and it is supported by the es-module-shims library.

Propositions

First, let's examine the current behavior:

flowchart TD
    subgraph Remote [On Load Remote Module]
        1[loadRemoteModule on routing or in component]
        2[getRemote Infos From Cache]
        3[importShim from remote url]

        1 --> 2
        2 --> 3
    end

    subgraph Host [On Host Initialization]
        direction TB
        a[initFederation In Host]
        b[Fetch federation.manifest.json]

        h[Load remoteEntry.json of the host]
        i[Generate importmap with key/url]

        y[Combine Host importmap and remotes importmaps]
        z[Write importmaps in DOM]

        a --> b
        a --> A
        b --> B

        subgraph A [hostImportMap]
            direction TB
            h[Load remoteEntry.json]
            i[Generate importmap with key/url]

            h --> i
        end

        subgraph B [remotesImportMap]
            direction TB
            c[Load remoteEntry.json]
            d[Generate importmap with key/url]
            e[Add remote entry infos to globalCache]

            c --> d
            c --> e
        end

        A --> y
        B --> y
        y --> z
    end

    Cache((globalCache))

    e .-> Cache
    2 .-> Cache

1. Allow importmap overrides in the es-module-shims library

 <script type="esms-options">
 {
   "shimMode": true
   "mapOverrides": true
 }
 </script>

2. Import the importmap directly as a file in the index.html instead of runtime code in initFederation

The browser can combine directly multiple importmaps and load them for us. I would suggest that instead of executing runtime code, integrating all importmap already generated at compile time directly in the index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>host</title>
    <base href="/" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link rel="icon" type="image/x-icon" href="favicon.ico" />

    <script type="importmap-shim" src="assets/host-shared.importmap.json"></script>
    <script type="importmap-shim" src="assets/remotes.importmap.json"></script>

  </head>
  <body>
    <nx-nf-root></nx-nf-root>
  </body>
</html>

3. Ensure that when we loadRemoteModule, we obtain the actual version from the importmap

By listening to the DOM mutation like es-module-shim and refreshing the cache

  new MutationObserver((mutations) => {
    for (const { addedNodes, type } of mutations) {
      if (type !== 'childList') continue;
      for (const node of addedNodes) {
        if (node.tagName === 'SCRIPT') {
          if (node.type === 'importmap-shim' || node.type === 'importmap') {
            const remoteNamesToRemote = globalcache.remoteNamesToRemote;
            // TODO: should update the remoteNamesToRemote by changing the base url
          }
        }
      }
    }
  }).observe(document, { childList: true, subtree: true });

The challenge here is that the cache is grouped per remote and only maintains a single base URL, so overriding one exposes would change the URL for others as well.

By directly reading the importmap

The impact on performance is uncertain. Having an API library to manipulate importmaps would be advantageous.

Work Around

I have succeeded in overriding the loadRemoteModule function:

export function getImportMapOverride(importMapKey: string): string | undefined {
  // @ts-ignore
  const imports = window?.importMapOverrides?.getOverrideMap()?.imports;
  return imports && imports[importMapKey];
}

export async function loadRemoteOverrideUtils<T = any>(
  remoteName: string,
  exposedModule: string
): Promise<T> {
  const remoteKey = `${remoteName}/${exposedModule}`;

  const importMapOverrideUrl = getImportMapOverride(remoteKey);

  // If override found for remoteKey, load it separately
  // Else, use the default function
  return importMapOverrideUrl
    ? importShim<T>(importMapOverrideUrl)
    : loadRemoteModule(remoteName, exposedModule);
}

But I don't like the fact that the globalCache of native federation is still invalid.

full code here https://github.com/jogelin/nx-nf/tree/poc-load-remote-overrides

What about directly overriding the federation.manifest.json?

In my opinion, it is the best approach because overriding only one exposes does not make sense. Usually, we want to override an entire remote URL.

By using a custom approach

I implemented an easy way, but custom, but it keeps the globalCache in sync:

If you have that override in your localStorage:
image

Directly in the main.ts you can use:

initFederation('/assets/federation.manifest.json')
  .then(() => initFederationOverrides()) // <-- HERE
  .catch((err) => console.error(err))
  .then((_) => import('./bootstrap'))
  .catch((err) => console.error(err));

utilities functions:

import { processRemoteInfo } from '@angular-architects/native-federation';
import { ImportMap } from './import-map.type';

const NATIVE_FEDERATION_LOCAL_STORAGE_PREFIX = 'native-federation-override:';

export function initFederationOverrides(): Promise<ImportMap[]> {
  const overrides = loadNativeFederationOverridesFromStorage();
  const processRemoteInfoPromises = Object.entries(overrides).map(
    ([remoteName, url]) => processRemoteInfo(url, remoteName)
  );

  return Promise.all(processRemoteInfoPromises);
}

function loadNativeFederationOverridesFromStorage(): Record<string, string> {
  return Object.entries(localStorage).reduce((overrides, [key, url]) => {
    return {
      ...overrides,
      ...(key.startsWith(NATIVE_FEDERATION_LOCAL_STORAGE_PREFIX) && {
        [key.replace(NATIVE_FEDERATION_LOCAL_STORAGE_PREFIX, '')]: url,
      }),
    };
  }, {});
}

But why not using an importmap to load the remoteEntry.json files?

The federation.manifest.json would then appear as:

{
    imports: {
        "host": "http://localhost:4200/remoteEntry.json",
	"mfAccount": "http://localhost:4203/remoteEntry.json",
	"mfHome": "http://localhost:4201/remoteEntry.json",
	"mfLogin": "http://localhost:4202/remoteEntry.json"
    }
}

and directly integrate it into the index.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>host</title>
    <base href="/" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link rel="icon" type="image/x-icon" href="favicon.ico" />

    <script type="importmap-shim" src="assets/federation.manifest.json"></script>

  </head>
  <body>
    <nx-nf-root></nx-nf-root>
  </body>
</html>

and in the initFederation, we just need to use:

import('mfAccount').then((remoteEntry) => // same as before, inject exposes to inline importmap)

In this way:
βœ… It is standard
βœ… We use importmap everywhere
βœ… We can use default override behaviour
βœ… It allows to override a full remote AND exposes separately

What do you Think?

Do you want to make a PR?

Yes of course after discussion :)

Thanks for this. What are the use cases for this?

So far, instead of using overrides, I chose between several manifests at runtime. One could even create the manifest in code and pass it to initFederation. Would this also work for your use cases?

(Sorry I am talking/writing too much :D)

Use Cases

1. Local Development

In a large organization, setting up a complex local environment usually involves:

  • A backend like .Net or Java
  • A local database or a connection to an external environment
  • A local queuing system or a connection to an external environment
  • Complex configurations based on the Tenant
  • ...You get my point :)

It can be frustrating to deal with this complexity when you only need to make a small UI modification.

Sometimes, organizations with a shared UI library want to change just a CSS in a product. So, they have many repositories locally, change one thing in one repo, publish, run install in another repo, start backend, frontend, database, and finally, they see the result...if there's a bug, start over... (okay, a monorepo would help but it's not always easy).

With the overrides approach:

  • Serve one micro frontend locally (nothing else).
  • Access a dev environment with everything working.
  • Set the new URL for your remote in your localStorage.
  • And see your local modification directly integrated into a real environment.

This is old but still valid, please watch this video.

2. Testing

Manual

Before merging, it's sometimes nice to see the result of a PR. With this approach, you don't need to:

  • Deploy on a static team environment and having conflict.
  • Create an environment on demand and consume resources.

You can just generate an affected manifest/importmap with the overrides and use it in a common environment. Even in production :)

Automatic

You can also run Cypress/Playwright tests by injecting the generated importmap to test the new bundle in a remote environment without using too many resources.

3. Deployment

When using CI/CD, if all your e2e tests pass, you can directly publish your artifact with the version + the latest manifest/importmap with the same version. In incremental deployment, we take the last importmap and override it with the new version of the micro frontend.

At any time, you can deploy the new manifest/importmap in production or even inject it using a proxy based on the user for example.

Manifest vs importmap

So far, instead of using overrides, I chose between several manifests at runtime. One could even create the manifest in code and pass it to initFederation. Would this also work for your use cases?

Indeed, fully agree.

The critical aspect to grasp about these use cases is the ability to connect your local environment to a distant one simply through the browser, which will load the micro frontend from your local setup.

Managing multiple manifests necessitates implementing a system to toggle between manifests in the distant environment.

Hence, I recommended utilizing the import map system, which browsers support by default, eliminating the need for any custom solutions.

So, my main question remains: Why isn't the federation.manifest.json used as an importmap.json in your index.html?

More details in my article ⏰ It’s time to talk about Import Map, Micro Frontend, and Nx Monorepo

Hi Jogelin,

Thanks for this information.

Let's add some hooks to NF so that you can easily implement this. I guess, this could be a nice companion library, I would link in the documentation.

What do you think?
Do you want to start a discussion with a minimal PR that gives you all the hooks you need?

Best wishes,
Manfred

Hey @manfredsteyer

Yep, I fully agree. I think it is possible to adapt it a bit to support fully the import maps and the overrides

I'll start a PR soon!

There is an interesting tweet from the creator of Module Federation Zack Jackson:

image

In one of the replies, he said:

image

It is an interesting idea to update the import maps using Object Proxy.

For info,

I have 3 PRs related that will allow the native usage of import maps: