diff --git a/.changeset/swift-boats-cough.md b/.changeset/swift-boats-cough.md new file mode 100644 index 00000000000..098628d7bb0 --- /dev/null +++ b/.changeset/swift-boats-cough.md @@ -0,0 +1,5 @@ +--- +'@module-federation/runtime': patch +--- + +feat: add registerRemotes api diff --git a/packages/runtime/README.md b/packages/runtime/README.md index 9526b4ec68e..56eb67bf4ad 100644 --- a/packages/runtime/README.md +++ b/packages/runtime/README.md @@ -212,15 +212,15 @@ init({ remotes: [ { name: '@demo/sub1', - entry: 'http://localhost:2001/vmok-manifest.json', + entry: 'http://localhost:2001/mf-manifest.json', }, { name: '@demo/sub2', - entry: 'http://localhost:2001/vmok-manifest.json', + entry: 'http://localhost:2001/mf-manifest.json', }, { name: '@demo/sub3', - entry: 'http://localhost:2001/vmok-manifest.json', + entry: 'http://localhost:2001/mf-manifest.json', }, ], }); @@ -258,6 +258,73 @@ preloadRemote([ ]); ``` +### registerRemotes + +- Type: `registerRemotes(remotes: Remote[], options?: { force?: boolean }): void` +- Used to register remotes after init . + +- Type + +```typescript +function registerRemotes(remotes: Remote[], options?: { force?: boolean }) {} + +type Remote = (RemoteWithEntry | RemoteWithVersion) & RemoteInfoCommon; + +interface RemoteInfoCommon { + alias?: string; + shareScope?: string; + type?: RemoteEntryType; + entryGlobalName?: string; +} + +interface RemoteWithEntry { + name: string; + entry: string; +} + +interface RemoteWithVersion { + name: string; + version: string; +} +``` + +- Details +**info**: Please be careful when setting `force:true` ! + +If set `force: true`, it will merge remote(include loaded remote), and remove loaded remote cache , as well as console.warn to tell this action may have risks. + +* Example + +```ts +import { init, registerRemotes } from '@module-federation/runtime'; + +init({ + name: '@demo/register-new-remotes', + remotes: [ + { + name: '@demo/sub1', + entry: 'http://localhost:2001/mf-manifest.json', + } + ], +}); + +// add new remote @demo/sub2 +registerRemotes([ + { + name: '@demo/sub2', + entry: 'http://localhost:2002/mf-manifest.json', + } +]); + +// override previous remote @demo/sub1 +registerRemotes([ + { + name: '@demo/sub1', + entry: 'http://localhost:2003/mf-manifest.json', + } +]); +``` + ## hooks Lifecycle hooks for FederationHost interaction. diff --git a/packages/runtime/__tests__/register-remotes.spec.ts b/packages/runtime/__tests__/register-remotes.spec.ts new file mode 100644 index 00000000000..25fcde012d3 --- /dev/null +++ b/packages/runtime/__tests__/register-remotes.spec.ts @@ -0,0 +1,108 @@ +import { assert, describe, test, it } from 'vitest'; +import { FederationHost } from '../src/index'; + +describe('FederationHost', () => { + it('register new remotes', async () => { + const FM = new FederationHost({ + name: '@federation/instance', + version: '1.0.1', + remotes: [ + { + name: '@register-remotes/app1', + entry: + 'http://localhost:1111/resources/register-remotes/app1/federation-remote-entry.js', + }, + ], + }); + + const app1Module = await FM.loadRemote string>>( + '@register-remotes/app1/say', + ); + assert(app1Module); + const app1Res = await app1Module(); + expect(app1Res).toBe('hello app1 entry1'); + + // register new remotes + FM.registerRemotes([ + { + name: '@register-remotes/app2', + entry: + 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', + }, + ]); + const app2Module = await FM.loadRemote string>>( + '@register-remotes/app2/say', + ); + assert(app2Module); + const res = await app2Module(); + expect(res).toBe('hello app2'); + }); + + it('will not merge loaded remote by default', async () => { + const FM = new FederationHost({ + name: '@federation/instance', + version: '1.0.1', + remotes: [ + { + name: '@register-remotes/app1', + entry: + 'http://localhost:1111/resources/register-remotes/app1/federation-remote-entry.js', + }, + ], + }); + FM.registerRemotes([ + { + name: '@register-remotes/app1', + // entry is different from the registered remote + entry: + 'http://localhost:1111/resources/register-remotes/app1/federation-remote-entry2.js', + }, + ]); + + const app1Module = await FM.loadRemote string>>( + '@register-remotes/app1/say', + ); + assert(app1Module); + const app1Res = await app1Module(); + expect(app1Res).toBe('hello app1 entry1'); + }); + + it('merge loaded remote by setting "force:true"', async () => { + const FM = new FederationHost({ + name: '@federation/instance', + version: '1.0.1', + remotes: [ + { + name: '@register-remotes/app1', + entry: + 'http://localhost:1111/resources/register-remotes/app1/federation-remote-entry.js', + }, + ], + }); + const app1Module = await FM.loadRemote string>>( + '@register-remotes/app1/say', + ); + assert(app1Module); + const app1Res = await app1Module(); + expect(app1Res).toBe('hello app1 entry1'); + + FM.registerRemotes( + [ + { + name: '@register-remotes/app1', + // entry is different from the registered remote + entry: + 'http://localhost:1111/resources/register-remotes/app1/federation-remote-entry2.js', + }, + ], + { force: true }, + ); + const newApp1Module = await FM.loadRemote string>>( + '@register-remotes/app1/say', + ); + assert(newApp1Module); + const newApp1Res = await newApp1Module(); + // value is different from the registered remote + expect(newApp1Res).toBe('hello app1 entry2'); + }); +}); diff --git a/packages/runtime/__tests__/resources/register-remotes/app1/federation-remote-entry.js b/packages/runtime/__tests__/resources/register-remotes/app1/federation-remote-entry.js new file mode 100644 index 00000000000..ca083a5256c --- /dev/null +++ b/packages/runtime/__tests__/resources/register-remotes/app1/federation-remote-entry.js @@ -0,0 +1,11 @@ +globalThis[`@register-remotes/app1`] = { + get(scope) { + const moduleMap = { + './say'() { + return () => 'hello app1 entry1'; + }, + }; + return moduleMap[scope]; + }, + init() {}, +}; diff --git a/packages/runtime/__tests__/resources/register-remotes/app1/federation-remote-entry2.js b/packages/runtime/__tests__/resources/register-remotes/app1/federation-remote-entry2.js new file mode 100644 index 00000000000..081aa3c23bc --- /dev/null +++ b/packages/runtime/__tests__/resources/register-remotes/app1/federation-remote-entry2.js @@ -0,0 +1,11 @@ +globalThis[`@register-remotes/app1`] = { + get(scope) { + const moduleMap = { + './say'() { + return () => 'hello app1 entry2'; + }, + }; + return moduleMap[scope]; + }, + init() {}, +}; diff --git a/packages/runtime/__tests__/resources/register-remotes/app2/federation-remote-entry.js b/packages/runtime/__tests__/resources/register-remotes/app2/federation-remote-entry.js new file mode 100644 index 00000000000..6014cc9afe8 --- /dev/null +++ b/packages/runtime/__tests__/resources/register-remotes/app2/federation-remote-entry.js @@ -0,0 +1,11 @@ +globalThis[`@register-remotes/app2`] = { + get(scope) { + const moduleMap = { + './say'() { + return () => 'hello app2'; + }, + }; + return moduleMap[scope]; + }, + init() {}, +}; diff --git a/packages/runtime/src/core.md b/packages/runtime/src/core.md index 03c4d151dd8..63efa42eea5 100644 --- a/packages/runtime/src/core.md +++ b/packages/runtime/src/core.md @@ -79,6 +79,12 @@ initializeSharing(shareScopeName?: string): boolean | Promise ``` Initializes sharing sequences for shared scopes. +### `registerRemotes` +```typescript +registerRemotes(remotes: Remote[], options?: { force?: boolean }): void +``` +Register remotes after init. + ## Hooks `FederationHost` offers various lifecycle hooks for interacting at different stages of the module federation process. These hooks include: diff --git a/packages/runtime/src/core.ts b/packages/runtime/src/core.ts index 918ff6b32ea..27022ed4266 100644 --- a/packages/runtime/src/core.ts +++ b/packages/runtime/src/core.ts @@ -40,8 +40,8 @@ import { formatPreloadArgs, preloadAssets } from './utils/preload'; import { generatePreloadAssetsPlugin } from './plugins/generate-preload-assets'; import { snapshotPlugin } from './plugins/snapshot'; import { isBrowserEnv } from './utils/env'; -import { getRemoteInfo } from './utils/load'; -import { Global, Federation } from './global'; +import { getRemoteEntryUniqueKey, getRemoteInfo } from './utils/load'; +import { Global, Federation, globalLoading } from './global'; import { DEFAULT_REMOTE_TYPE, DEFAULT_SCOPE } from './constant'; import { SnapshotHandler } from './plugins/snapshot/SnapshotHandler'; @@ -769,39 +769,7 @@ export class FederationHost { const userRemotes = userOptionsRes.remotes || []; const remotes = userRemotes.reduce((res, remote) => { - if (!res.find((item) => item.name === remote.name)) { - if (remote.alias) { - // Validate if alias equals the prefix of remote.name and remote.alias, if so, throw an error - // As multi-level path references cannot guarantee unique names, alias being a prefix of remote.name is not supported - const findEqual = res.find( - (item) => - remote.alias && - (item.name.startsWith(remote.alias) || - item.alias?.startsWith(remote.alias)), - ); - assert( - !findEqual, - `The alias ${remote.alias} of remote ${ - remote.name - } is not allowed to be the prefix of ${ - findEqual && findEqual.name - } name or alias`, - ); - } - // Set the remote entry to a complete path - if ('entry' in remote) { - if (isBrowserEnv() && !remote.entry.startsWith('http')) { - remote.entry = new URL(remote.entry, window.location.origin).href; - } - } - if (!remote.shareScope) { - remote.shareScope = DEFAULT_SCOPE; - } - if (!remote.type) { - remote.type = DEFAULT_REMOTE_TYPE; - } - res.push(remote); - } + this.registerRemote(remote, res, { force: false }); return res; }, globalOptionsRes.remotes); @@ -905,4 +873,97 @@ export class FederationHost { } }); } + + private removeRemote(remote: Remote): void { + const { name } = remote; + const remoteIndex = this.options.remotes.findIndex( + (item) => item.name === name, + ); + if (remoteIndex !== -1) { + this.options.remotes.splice(remoteIndex, 1); + } + const loadedModule = this.moduleCache.get(remote.name); + if (loadedModule) { + const key = loadedModule.remoteInfo + .entryGlobalName as keyof typeof globalThis; + if (globalThis[key]) { + delete globalThis[key]; + } + const remoteEntryUniqueKey = getRemoteEntryUniqueKey( + loadedModule.remoteInfo, + ); + if (globalLoading[remoteEntryUniqueKey]) { + delete globalLoading[remoteEntryUniqueKey]; + } + this.moduleCache.delete(remote.name); + } + } + + private registerRemote( + remote: Remote, + targetRemotes: Remote[], + options?: { force?: boolean }, + ): void { + const normalizeRemote = () => { + if (remote.alias) { + // Validate if alias equals the prefix of remote.name and remote.alias, if so, throw an error + // As multi-level path references cannot guarantee unique names, alias being a prefix of remote.name is not supported + const findEqual = targetRemotes.find( + (item) => + remote.alias && + (item.name.startsWith(remote.alias) || + item.alias?.startsWith(remote.alias)), + ); + assert( + !findEqual, + `The alias ${remote.alias} of remote ${ + remote.name + } is not allowed to be the prefix of ${ + findEqual && findEqual.name + } name or alias`, + ); + } + // Set the remote entry to a complete path + if ('entry' in remote) { + if (isBrowserEnv() && !remote.entry.startsWith('http')) { + remote.entry = new URL(remote.entry, window.location.origin).href; + } + } + if (!remote.shareScope) { + remote.shareScope = DEFAULT_SCOPE; + } + if (!remote.type) { + remote.type = DEFAULT_REMOTE_TYPE; + } + }; + const registeredRemote = targetRemotes.find( + (item) => item.name === remote.name, + ); + if (!registeredRemote) { + normalizeRemote(); + targetRemotes.push(remote); + } else { + const messages = [ + `The remote "${remote.name}" is already registered.`, + options?.force + ? 'Hope you have known that OVERRIDE it may have some unexpected errors' + : 'If you want to merge the remote, you can set "force: true".', + ]; + if (options?.force) { + // remove registered remote + this.removeRemote(registeredRemote); + normalizeRemote(); + targetRemotes.push(remote); + } + warn(messages.join(' ')); + } + } + + registerRemotes(remotes: Remote[], options?: { force?: boolean }): void { + remotes.forEach((remote) => { + this.registerRemote(remote, this.options.remotes, { + force: options?.force, + }); + }); + } } diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index d00cb1a3510..52b06cb4900 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -68,5 +68,13 @@ export function preloadRemote( return FederationInstance.preloadRemote.apply(FederationInstance, args); } +export function registerRemotes( + ...args: Parameters +): ReturnType { + assert(FederationInstance, 'Please call init first'); + // eslint-disable-next-line prefer-spread + return FederationInstance.registerRemotes.apply(FederationInstance, args); +} + // Inject for debug setGlobalFederationConstructor(FederationHost); diff --git a/packages/runtime/src/utils/load.ts b/packages/runtime/src/utils/load.ts index 8424effbff3..074ad45abb1 100644 --- a/packages/runtime/src/utils/load.ts +++ b/packages/runtime/src/utils/load.ts @@ -104,6 +104,11 @@ export async function loadEntryScript({ }); } +export function getRemoteEntryUniqueKey(remoteInfo: RemoteInfo): string { + const { entry, name } = remoteInfo; + return composeKeyWithSeparator(name, entry); +} + export async function getRemoteEntry({ remoteEntryExports, remoteInfo, @@ -114,7 +119,7 @@ export async function getRemoteEntry({ createScriptHook?: (url: string) => HTMLScriptElement | void; }): Promise { const { entry, name, type, entryGlobalName } = remoteInfo; - const uniqueKey = composeKeyWithSeparator(name, entry); + const uniqueKey = getRemoteEntryUniqueKey(remoteInfo); if (remoteEntryExports) { return remoteEntryExports; }