Skip to content

Use more importmap default behaviours #489

@jogelin

Description

@jogelin

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
Loading

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 :)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions