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
:
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:
In one of the replies, he said:
It is an interesting idea to update the import maps using Object Proxy.