diff --git a/.changeset/lucky-fishes-smell.md b/.changeset/lucky-fishes-smell.md new file mode 100644 index 00000000000..e8d6d61a0f2 --- /dev/null +++ b/.changeset/lucky-fishes-smell.md @@ -0,0 +1,7 @@ +--- +'@module-federation/enhanced': patch +'@module-federation/rsbuild-plugin': patch +'@module-federation/runtime-tools': patch +--- + +Update enhanced and tooling integrations to consume the new optional runtime unload entrypoints. diff --git a/.changeset/soft-carpets-develop.md b/.changeset/soft-carpets-develop.md new file mode 100644 index 00000000000..5cec098a784 --- /dev/null +++ b/.changeset/soft-carpets-develop.md @@ -0,0 +1,15 @@ +--- +'@module-federation/runtime': minor +'@module-federation/runtime-core': minor +'@module-federation/webpack-bundler-runtime': minor +--- + +feat(runtime): move remote unload APIs to optional `@module-federation/runtime/unload` entry + +- Removes `unloadRemote` from baseline `ModuleFederation` and `@module-federation/runtime` root exports to reduce default payload. +- Adds optional `@module-federation/runtime/unload` entrypoint with: + - `unloadRemote(nameOrAlias)` for the active runtime instance. + - `unloadRemoteFromInstance(instance, nameOrAlias)` for explicit instance control. +- Keeps deterministic unload behavior when using the optional entrypoint, including: + - runtime bookkeeping cleanup (`moduleCache`, manifest/snapshot markers, preloaded map entries, id-to-remote mappings), + - webpack bundler module cache cleanup (`__webpack_require__.c`/`m`) and remote load marker reset. diff --git a/.github/workflows/devtools.yml b/.github/workflows/devtools.yml index cb28cff7ff2..0f5cac56f41 100644 --- a/.github/workflows/devtools.yml +++ b/.github/workflows/devtools.yml @@ -42,6 +42,9 @@ jobs: cache: 'pnpm' cache-dependency-path: '**/pnpm-lock.yaml' + - name: Remove cached node_modules + run: rm -rf node_modules .nx + - name: Set Playwright cache status run: | if [ -d "$HOME/.cache/ms-playwright" ] || [ -d "$HOME/.cache/Cypress" ]; then @@ -54,13 +57,15 @@ jobs: uses: nrwl/nx-set-shas@v4 - name: Install Dependencies - run: pnpm install --frozen-lockfile && find . -maxdepth 6 -type d \( -name ".cache" -o -name ".modern-js" \) -exec rm -rf {} + + run: pnpm install --frozen-lockfile - name: Install Cypress run: npx cypress install - name: Run Affected Build - run: npx nx run-many --targets=build --projects=tag:type:pkg + run: | + npx nx run-many --targets=build --projects=tag:type:pkg --parallel=4 --skip-nx-cache + npx nx run-many --targets=build --projects=tag:type:pkg --parallel=4 - name: Configuration xvfb shell: bash diff --git a/apps/website-new/docs/en/guide/runtime/runtime-api.mdx b/apps/website-new/docs/en/guide/runtime/runtime-api.mdx index 1d94de8cc9c..9ac85348842 100644 --- a/apps/website-new/docs/en/guide/runtime/runtime-api.mdx +++ b/apps/website-new/docs/en/guide/runtime/runtime-api.mdx @@ -671,6 +671,47 @@ loadShare('react', { +## unloadRemote + +- Type: `unloadRemote(nameOrAlias: string): boolean` +- Removes a registered remote and clears runtime state for that remote in the current host instance. +- Returns `true` when the remote exists and is removed, otherwise `false`. + +Use this API when you need deterministic teardown before switching remote entries, re-registering remotes, or cleaning up host state in long-lived sessions. + + + + ```tsx + import { unloadRemote } from '@module-federation/enhanced/runtime'; + + // Remove by remote name + unloadRemote('remote'); + + // Remove by alias + unloadRemote('app1'); + ``` + + + ```ts + import { createInstance } from '@module-federation/enhanced/runtime'; + + const mf = createInstance({ + name: 'mf_host', + remotes: [ + { + name: 'remote', + alias: 'app1', + entry: 'http://localhost:2001/mf-manifest.json', + }, + ], + }); + + mf.unloadRemote('remote'); + mf.unloadRemote('app1'); + ``` + + + ## preloadRemote diff --git a/apps/website-new/docs/zh/guide/runtime/runtime-api.mdx b/apps/website-new/docs/zh/guide/runtime/runtime-api.mdx index d9f71bd22d3..23da957e31a 100644 --- a/apps/website-new/docs/zh/guide/runtime/runtime-api.mdx +++ b/apps/website-new/docs/zh/guide/runtime/runtime-api.mdx @@ -672,6 +672,47 @@ loadShare('react', { +## unloadRemote + +- Type: `unloadRemote(nameOrAlias: string): boolean` +- 从当前 host 实例中移除已注册的 remote,并清理该 remote 对应的运行时状态。 +- 当 remote 存在并移除成功时返回 `true`,否则返回 `false`。 + +当你需要在切换 remote 地址、重新注册 remote,或在长生命周期应用中做确定性清理时,可以使用该 API。 + + + + ```tsx + import { unloadRemote } from '@module-federation/enhanced/runtime'; + + // 按 remote name 移除 + unloadRemote('remote'); + + // 按 alias 移除 + unloadRemote('app1'); + ``` + + + ```ts + import { createInstance } from '@module-federation/enhanced/runtime'; + + const mf = createInstance({ + name: 'mf_host', + remotes: [ + { + name: 'remote', + alias: 'app1', + entry: 'http://localhost:2001/mf-manifest.json', + }, + ], + }); + + mf.unloadRemote('remote'); + mf.unloadRemote('app1'); + ``` + + + ## preloadRemote diff --git a/packages/enhanced/src/rspack.ts b/packages/enhanced/src/rspack.ts index e6154c3ddf2..0501205e8e1 100644 --- a/packages/enhanced/src/rspack.ts +++ b/packages/enhanced/src/rspack.ts @@ -4,4 +4,5 @@ export { TreeShakingSharedPlugin, PLUGIN_NAME, } from '@module-federation/rspack/plugin'; +export { parseOptions } from './lib/container/options'; export { createModuleFederationConfig } from '@module-federation/sdk'; diff --git a/packages/rsbuild-plugin/src/cli/index.ts b/packages/rsbuild-plugin/src/cli/index.ts index 5b6184fa2da..b7c9717820e 100644 --- a/packages/rsbuild-plugin/src/cli/index.ts +++ b/packages/rsbuild-plugin/src/cli/index.ts @@ -1,8 +1,8 @@ -import { parseOptions } from '@module-federation/enhanced'; import { ModuleFederationPlugin, TreeShakingSharedPlugin, PLUGIN_NAME, + parseOptions, } from '@module-federation/enhanced/rspack'; import { isRequiredVersion, getManifestFileName } from '@module-federation/sdk'; import pkgJson from '../../package.json'; diff --git a/packages/runtime-core/src/core.ts b/packages/runtime-core/src/core.ts index 411dbefce01..7877ba8ef25 100644 --- a/packages/runtime-core/src/core.ts +++ b/packages/runtime-core/src/core.ts @@ -353,6 +353,10 @@ export class ModuleFederation { return this.remoteHandler.registerRemotes(remotes, options); } + unloadRemote(nameOrAlias: string): boolean { + return this.remoteHandler.unloadRemote(nameOrAlias); + } + registerShared(shared: UserOptions['shared']) { this.sharedHandler.registerShared(this.options, { ...this.options, diff --git a/packages/runtime-core/src/remote/index.ts b/packages/runtime-core/src/remote/index.ts index bb101f25450..5b9b08d2f73 100644 --- a/packages/runtime-core/src/remote/index.ts +++ b/packages/runtime-core/src/remote/index.ts @@ -72,6 +72,16 @@ export class RemoteHandler { remote: Remote; origin: ModuleFederation; }>('registerRemote'), + afterRemoveRemote: new SyncHook< + [ + { + remote: Remote; + origin: ModuleFederation; + loaded: boolean; + }, + ], + void + >('afterRemoveRemote'), beforeRequest: new AsyncWaterfallHook<{ id: string; options: Options; @@ -194,6 +204,17 @@ export class RemoteHandler { } } + unloadRemote(nameOrAlias: string): boolean { + const remote = this.host.options.remotes.find( + (item) => item.name === nameOrAlias || item.alias === nameOrAlias, + ); + if (!remote) { + return false; + } + this.removeRemote(remote); + return true; + } + // eslint-disable-next-line max-lines-per-function // eslint-disable-next-line @typescript-eslint/member-ordering async loadRemote( @@ -472,8 +493,16 @@ export class RemoteHandler { host.options.remotes.splice(remoteIndex, 1); } const loadedModule = host.moduleCache.get(remote.name); + const remoteInfo = loadedModule + ? loadedModule.remoteInfo + : getRemoteInfo(remote); + + if (remoteInfo.entry) { + host.snapshotHandler.manifestCache.delete(remoteInfo.entry); + } + + // Only clean global/share state when this host actually loaded the remote. if (loadedModule) { - const remoteInfo = loadedModule.remoteInfo; const key = remoteInfo.entryGlobalName as keyof typeof CurrentGlobal; if (CurrentGlobal[key]) { @@ -486,16 +515,12 @@ export class RemoteHandler { CurrentGlobal[key] = undefined; } } - const remoteEntryUniqueKey = getRemoteEntryUniqueKey( - loadedModule.remoteInfo, - ); + const remoteEntryUniqueKey = getRemoteEntryUniqueKey(remoteInfo); if (globalLoading[remoteEntryUniqueKey]) { delete globalLoading[remoteEntryUniqueKey]; } - host.snapshotHandler.manifestCache.delete(remoteInfo.entry); - // delete unloaded shared and instance let remoteInsId = remoteInfo.buildVersion ? composeKeyWithSeparator(remoteInfo.name, remoteInfo.buildVersion) @@ -574,27 +599,51 @@ export class RemoteHandler { ); CurrentGlobal.__FEDERATION__.__INSTANCES__.splice(remoteInsIndex, 1); } + } - const { hostGlobalSnapshot } = getGlobalRemoteInfo(remote, host); - if (hostGlobalSnapshot) { - const remoteKey = - hostGlobalSnapshot && - 'remotesInfo' in hostGlobalSnapshot && - hostGlobalSnapshot.remotesInfo && - getInfoWithoutType(hostGlobalSnapshot.remotesInfo, remote.name).key; - if (remoteKey) { - delete hostGlobalSnapshot.remotesInfo[remoteKey]; - if ( - //eslint-disable-next-line no-extra-boolean-cast - Boolean(Global.__FEDERATION__.__MANIFEST_LOADING__[remoteKey]) - ) { - delete Global.__FEDERATION__.__MANIFEST_LOADING__[remoteKey]; - } - } + host.moduleCache.delete(remote.name); + Object.keys(this.idToRemoteMap).forEach((id) => { + if (this.idToRemoteMap[id]?.name === remote.name) { + delete this.idToRemoteMap[id]; + } + }); + + const remotePrefixes = [remote.name, remote.alias].filter( + Boolean, + ) as string[]; + const preloadedMap = Global.__FEDERATION__.__PRELOADED_MAP__; + Array.from(preloadedMap.keys()).forEach((key) => { + if ( + remotePrefixes.some( + (prefix) => key === prefix || key.startsWith(`${prefix}/`), + ) + ) { + preloadedMap.delete(key); } + }); - host.moduleCache.delete(remote.name); + const { hostGlobalSnapshot } = getGlobalRemoteInfo(remote, host); + if (hostGlobalSnapshot) { + const remoteKey = + hostGlobalSnapshot && + 'remotesInfo' in hostGlobalSnapshot && + hostGlobalSnapshot.remotesInfo && + getInfoWithoutType(hostGlobalSnapshot.remotesInfo, remote.name).key; + if (remoteKey) { + delete hostGlobalSnapshot.remotesInfo[remoteKey]; + if ( + //eslint-disable-next-line no-extra-boolean-cast + Boolean(Global.__FEDERATION__.__MANIFEST_LOADING__[remoteKey]) + ) { + delete Global.__FEDERATION__.__MANIFEST_LOADING__[remoteKey]; + } + } } + this.hooks.lifecycle.afterRemoveRemote.emit({ + remote, + origin: host, + loaded: Boolean(loadedModule), + }); } catch (err) { logger.log('removeRemote fail: ', err); } diff --git a/packages/runtime-tools/src/webpack-bundler-runtime.ts b/packages/runtime-tools/src/webpack-bundler-runtime.ts index bf870059e25..b0edfc3de56 100644 --- a/packages/runtime-tools/src/webpack-bundler-runtime.ts +++ b/packages/runtime-tools/src/webpack-bundler-runtime.ts @@ -1,8 +1,2 @@ -import webpackBundlerRuntime from '@module-federation/webpack-bundler-runtime'; - -const normalizedWebpackBundlerRuntime = - // Support both CJS module.exports payload and transpiled default payload. - (webpackBundlerRuntime as { default?: unknown }).default ?? - webpackBundlerRuntime; - -export default normalizedWebpackBundlerRuntime; +export { default } from '@module-federation/webpack-bundler-runtime'; +export * from '@module-federation/webpack-bundler-runtime'; diff --git a/packages/runtime/__tests__/unload.spec.ts b/packages/runtime/__tests__/unload.spec.ts new file mode 100644 index 00000000000..09d61b81ce0 --- /dev/null +++ b/packages/runtime/__tests__/unload.spec.ts @@ -0,0 +1,209 @@ +import { assert, describe, expect, it, vi } from 'vitest'; +import { + CurrentGlobal, + Global, + ModuleFederation, +} from '@module-federation/runtime-core'; +import { init } from '../src'; +import { unloadRemote, unloadRemoteFromInstance } from '../src/unload'; + +describe('unload api', () => { + it('unloads loaded remote by name and allows re-registration', async () => { + const FM = new ModuleFederation({ + name: '@federation/runtime-unload-name', + version: '1.0.1', + remotes: [ + { + name: '@register-remotes/app2', + alias: 'app2', + entry: + 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', + }, + ], + }); + const app1Module = await FM.loadRemote string>>( + '@register-remotes/app2/say', + ); + assert(app1Module); + expect(await app1Module()).toBe('hello app2'); + + expect(unloadRemoteFromInstance(FM, '@register-remotes/app2')).toBe(true); + expect( + FM.options.remotes.find((item) => item.name === '@register-remotes/app2'), + ).toBeUndefined(); + + await expect( + FM.loadRemote string>>('@register-remotes/app2/say'), + ).rejects.toThrow(); + + FM.registerRemotes([ + { + name: '@register-remotes/app2', + alias: 'app2', + entry: + 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', + }, + ]); + const app1ModuleReloaded = await FM.loadRemote string>>( + '@register-remotes/app2/say', + ); + assert(app1ModuleReloaded); + expect(await app1ModuleReloaded()).toBe('hello app2'); + }); + + it('unloads remote by alias', async () => { + const FM = new ModuleFederation({ + name: '@federation/runtime-unload-alias', + version: '1.0.1', + remotes: [ + { + name: '@register-remotes/app2', + alias: 'app2', + entry: + 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', + }, + ], + }); + const app1Module = await FM.loadRemote string>>('app2/say'); + assert(app1Module); + expect(await app1Module()).toBe('hello app2'); + + expect(unloadRemoteFromInstance(FM, 'app2')).toBe(true); + await expect( + FM.loadRemote string>>('app2/say'), + ).rejects.toThrow(); + }); + + it('returns false for missing remote unload', () => { + const FM = new ModuleFederation({ + name: '@federation/runtime-unload-miss', + version: '1.0.1', + remotes: [], + }); + expect(unloadRemoteFromInstance(FM, 'missing')).toBe(false); + }); + + it('does not clear global container when unloading remote not loaded by current host', () => { + const entryGlobalName = '__TEST_RUNTIME_UNLOAD_NOT_LOADED__'; + try { + (CurrentGlobal as any)[entryGlobalName] = { loadedBy: 'other-host' }; + + const FM = new ModuleFederation({ + name: '@federation/runtime-unload-not-loaded', + version: '1.0.1', + remotes: [ + { + name: '@register-remotes/app2', + alias: 'app2', + entry: + 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', + entryGlobalName, + }, + ], + }); + + expect(unloadRemoteFromInstance(FM, '@register-remotes/app2')).toBe(true); + expect((CurrentGlobal as any)[entryGlobalName]).toEqual({ + loadedBy: 'other-host', + }); + } finally { + delete (CurrentGlobal as any)[entryGlobalName]; + } + }); + + it('clears idToRemoteMap and preloaded entries for unloaded remote', async () => { + const FM = new ModuleFederation({ + name: '@federation/runtime-unload-id-map', + version: '1.0.1', + remotes: [ + { + name: '@register-remotes/app2', + alias: 'app2', + entry: + 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', + }, + ], + }); + await FM.loadRemote string>>('@register-remotes/app2/say'); + await FM.loadRemote string>>('app2/say'); + Global.__FEDERATION__.__PRELOADED_MAP__.set('app2/say', true); + Global.__FEDERATION__.__PRELOADED_MAP__.set( + '@register-remotes/app2/say', + true, + ); + + expect( + Object.values((FM as any).remoteHandler.idToRemoteMap).some( + (item: any) => item.name === '@register-remotes/app2', + ), + ).toBe(true); + expect(unloadRemoteFromInstance(FM, '@register-remotes/app2')).toBe(true); + + expect( + Object.values((FM as any).remoteHandler.idToRemoteMap).some( + (item: any) => item.name === '@register-remotes/app2', + ), + ).toBe(false); + expect(Global.__FEDERATION__.__PRELOADED_MAP__.get('app2/say')).toBe( + undefined, + ); + expect( + Global.__FEDERATION__.__PRELOADED_MAP__.get('@register-remotes/app2/say'), + ).toBe(undefined); + }); + + it('emits afterRemoveRemote hook on unload', () => { + const afterRemoveRemote = vi.fn(); + const FM = new ModuleFederation({ + name: '@federation/runtime-unload-bundler-cache', + version: '1.0.1', + remotes: [ + { + name: '@register-remotes/app2', + alias: 'app2', + entry: + 'http://localhost:1111/resources/register-remotes/app2/federation-remote-entry.js', + }, + ], + }); + FM.registerPlugins([ + { + name: 'after-remove-remote-test', + afterRemoveRemote, + }, + ]); + + expect(unloadRemoteFromInstance(FM, '@register-remotes/app2')).toBe(true); + expect(afterRemoveRemote).toHaveBeenCalledTimes(1); + expect(afterRemoveRemote).toHaveBeenCalledWith({ + remote: expect.objectContaining({ + name: '@register-remotes/app2', + alias: 'app2', + }), + origin: FM, + loaded: false, + }); + }); + + it('exports top-level unloadRemote wrapper and delegates to instance', async () => { + const FM = init({ + name: '@federation/unload-wrapper', + remotes: [ + { + name: '@register-remotes/app1', + alias: 'app1', + entry: + 'http://localhost:1111/resources/register-remotes/app1/federation-remote-entry.js', + }, + ], + }); + await FM.loadRemote('@register-remotes/app1/say'); + expect(unloadRemote('@register-remotes/app1')).toBe(true); + }); + + it('throws for top-level unloadRemote wrapper when instance is missing', async () => { + vi.resetModules(); + const runtimeUnload = await import('../src/unload'); + expect(() => runtimeUnload.unloadRemote('missing')).toThrow(); + }); +}); diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 3c4f1f33236..9c668e75502 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -63,6 +63,16 @@ "default": "./dist/core.cjs" } }, + "./unload": { + "import": { + "types": "./dist/unload.d.ts", + "default": "./dist/unload.esm.js" + }, + "require": { + "types": "./dist/unload.d.ts", + "default": "./dist/unload.cjs.cjs" + } + }, "./*": "./*" }, "typesVersions": { @@ -78,6 +88,9 @@ ], "core": [ "./dist/core.d.ts" + ], + "unload": [ + "./dist/unload.d.ts" ] } }, diff --git a/packages/runtime/project.json b/packages/runtime/project.json index 9fe80724f83..d665d590e99 100644 --- a/packages/runtime/project.json +++ b/packages/runtime/project.json @@ -6,17 +6,26 @@ "tags": ["type:pkg"], "targets": { "build": { - "executor": "nx:run-commands", + "executor": "@nx/rollup:rollup", "outputs": ["{workspaceRoot}/packages/runtime/dist"], "options": { "parallel": false, - "cwd": "packages/runtime", - "commands": [ - { - "command": "tsdown --config tsdown.config.ts", - "forwardAllArgs": false - } - ] + "outputPath": "packages/runtime/dist", + "main": "packages/runtime/src/index.ts", + "additionalEntryPoints": [ + "packages/runtime/src/types.ts", + "packages/runtime/src/helpers.ts", + "packages/runtime/src/unload.ts" + ], + "tsConfig": "packages/runtime/tsconfig.lib.json", + "assets": [], + "external": ["@module-federation/*"], + "project": "packages/runtime/package.json", + "compiler": "tsc", + "rollupConfig": "packages/runtime/rollup.config.cjs", + "format": ["cjs", "esm"], + "generatePackageJson": false, + "useLegacyTypescriptPlugin": false }, "dependsOn": [ { @@ -39,10 +48,9 @@ "executor": "nx:run-commands", "options": { "parallel": false, - "cwd": "packages/runtime", "commands": [ { - "command": "RUNTIME_TSDOWN_MODE=debug tsdown --config tsdown.config.ts", + "command": "FEDERATION_DEBUG=true nx run runtime:build", "forwardAllArgs": false } ] diff --git a/packages/runtime/rollup.config.cjs b/packages/runtime/rollup.config.cjs new file mode 100644 index 00000000000..75be5dad74e --- /dev/null +++ b/packages/runtime/rollup.config.cjs @@ -0,0 +1,95 @@ +const replace = require('@rollup/plugin-replace'); +const copy = require('rollup-plugin-copy'); + +const FEDERATION_DEBUG = process.env.FEDERATION_DEBUG || ''; + +const adjustSourceMapPath = (relativePath) => { + const normalized = relativePath.replace(/\\/g, '/'); + if (normalized.startsWith('../../src/')) { + return normalized.replace('../../src/', '../src/'); + } + return normalized; +}; + +module.exports = (rollupConfig, projectOptions) => { + rollupConfig.treeshake = { preset: 'recommended' }; + + rollupConfig.input = { + index: 'packages/runtime/src/index.ts', + types: 'packages/runtime/src/types.ts', + helpers: 'packages/runtime/src/helpers.ts', + core: 'packages/runtime/src/core.ts', + unload: 'packages/runtime/src/unload.ts', + }; + + const pkg = require('./package.json'); + + if (rollupConfig.output.format === 'esm' && FEDERATION_DEBUG) { + rollupConfig.output.format = 'iife'; + rollupConfig.output.inlineDynamicImports = true; + delete rollupConfig.external; + delete rollupConfig.input.type; + delete rollupConfig.input.helpers; + } + + if (Array.isArray(rollupConfig.output)) { + rollupConfig.output = rollupConfig.output.map((c) => ({ + ...c, + sourcemap: true, + manualChunks: (id) => { + if (id.includes('@swc/helpers')) { + return 'polyfills'; + } + }, + hoistTransitiveImports: false, + sourcemapPathTransform: adjustSourceMapPath, + entryFileNames: + c.format === 'cjs' + ? c.entryFileNames.replace(/\.js$/, '.cjs') + : c.entryFileNames, + chunkFileNames: + c.format === 'cjs' + ? c.chunkFileNames.replace(/\.js$/, '.cjs') + : c.chunkFileNames, + ...(c.format === 'cjs' ? { externalLiveBindings: false } : {}), + })); + } else { + rollupConfig.output = { + ...rollupConfig.output, + sourcemap: true, + manualChunks: (id) => { + if (id.includes('@swc/helpers')) { + return 'polyfills'; + } + }, + hoistTransitiveImports: false, + sourcemapPathTransform: adjustSourceMapPath, + entryFileNames: + rollupConfig.output.format === 'cjs' + ? rollupConfig.output.entryFileNames.replace(/\.js$/, '.cjs') + : rollupConfig.output.entryFileNames, + chunkFileNames: + rollupConfig.output.format === 'cjs' + ? rollupConfig.output.chunkFileNames.replace(/\.js$/, '.cjs') + : rollupConfig.output.chunkFileNames, + ...(rollupConfig.output.format === 'cjs' + ? { externalLiveBindings: false } + : {}), + }; + } + + rollupConfig.plugins.push( + replace({ + preventAssignment: true, + __VERSION__: JSON.stringify(pkg.version), + FEDERATION_DEBUG: JSON.stringify(FEDERATION_DEBUG), + }), + copy({ + targets: [ + { src: 'packages/runtime/LICENSE', dest: 'packages/runtime/dist' }, + ], + }), + ); + + return rollupConfig; +}; diff --git a/packages/runtime/src/unload.ts b/packages/runtime/src/unload.ts new file mode 100644 index 00000000000..e7de8817951 --- /dev/null +++ b/packages/runtime/src/unload.ts @@ -0,0 +1,20 @@ +import { + getShortErrorMsg, + RUNTIME_009, + runtimeDescMap, +} from '@module-federation/error-codes'; +import { assert, type ModuleFederation } from '@module-federation/runtime-core'; +import { getInstance } from './index'; + +export function unloadRemoteFromInstance( + instance: ModuleFederation, + nameOrAlias: string, +): boolean { + return instance.unloadRemote(nameOrAlias); +} + +export function unloadRemote(nameOrAlias: string): boolean { + const instance = getInstance(); + assert(instance, getShortErrorMsg(RUNTIME_009, runtimeDescMap)); + return unloadRemoteFromInstance(instance, nameOrAlias); +} diff --git a/packages/webpack-bundler-runtime/README.md b/packages/webpack-bundler-runtime/README.md index b82a0435544..02222a6beb3 100644 --- a/packages/webpack-bundler-runtime/README.md +++ b/packages/webpack-bundler-runtime/README.md @@ -19,3 +19,21 @@ __webpack_require__.federation = federation; __webpack_require__.f.remotes = __webpack_require__.federation.remotes(options); __webpack_require__.f.consumes = __webpack_require__.federation.remotes(options); ``` + +## Runtime plugins + +Webpack-specific behaviors (like clearing webpack's module cache on unload) +are exposed as runtime plugins. The bundler runtime automatically registers +the unload plugin during init, but you can also register it manually: + +```ts +import { unloadRemotePlugin } from '@module-federation/webpack-bundler-runtime'; + +const instance = federation.runtime.init({ + name: 'host', + remotes: [], +}); + +instance.registerPlugins([unloadRemotePlugin()]); +instance.unloadRemote('remote/subpath'); +``` diff --git a/packages/webpack-bundler-runtime/__tests__/init.spec.ts b/packages/webpack-bundler-runtime/__tests__/init.spec.ts new file mode 100644 index 00000000000..c32f78a4e67 --- /dev/null +++ b/packages/webpack-bundler-runtime/__tests__/init.spec.ts @@ -0,0 +1,121 @@ +jest.mock('@module-federation/runtime', () => ({ + init: jest.fn(), +})); + +import { init as runtimeInit } from '@module-federation/runtime'; +import { init } from '../src/init'; + +describe('init', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('attaches webpackRequire to runtime instance using internal symbol', () => { + const instance = {}; + (runtimeInit as jest.Mock).mockReturnValue(instance); + + const webpackRequire: any = { + federation: { + initOptions: { + name: 'host', + remotes: [], + }, + runtime: { + init: runtimeInit, + }, + sharedFallback: undefined, + bundlerRuntime: { + getSharedFallbackGetter: jest.fn(), + }, + libraryType: 'var', + }, + }; + + const result = init({ webpackRequire }); + + expect(result).toBe(instance); + expect( + (instance as Record)[Symbol.for('mf_webpack_require')], + ).toBe(webpackRequire); + }); + + test('preserves init behavior while appending runtime plugins', () => { + const instance = { foo: 'bar' }; + (runtimeInit as jest.Mock).mockReturnValue(instance); + + const existingPlugin = { + name: 'existing-plugin', + beforeInit: jest.fn((args) => args), + }; + + const webpackRequire: any = { + federation: { + initOptions: { + name: 'host', + remotes: [], + plugins: [existingPlugin], + }, + runtime: { + init: runtimeInit, + }, + sharedFallback: undefined, + bundlerRuntime: { + getSharedFallbackGetter: jest.fn(), + }, + libraryType: 'var', + }, + }; + + const result = init({ webpackRequire }); + + expect(result).toBe(instance); + expect(runtimeInit).toHaveBeenCalledTimes(1); + const calledOptions = (runtimeInit as jest.Mock).mock.calls[0][0]; + expect(calledOptions.plugins[0]).toBe(existingPlugin); + expect(calledOptions.plugins).toHaveLength(3); + expect(typeof calledOptions.plugins[1].beforeInit).toBe('function'); + expect(calledOptions.plugins[2].name).toBe('unload-remote-plugin'); + }); + + test('does not append unload-remote plugin when already provided', () => { + const instance = { foo: 'bar' }; + (runtimeInit as jest.Mock).mockReturnValue(instance); + + const existingPlugin = { + name: 'existing-plugin', + beforeInit: jest.fn((args) => args), + }; + const unloadRemotePlugin = { + name: 'unload-remote-plugin', + afterRemoveRemote: jest.fn(), + }; + + const webpackRequire: any = { + federation: { + initOptions: { + name: 'host', + remotes: [], + plugins: [existingPlugin, unloadRemotePlugin], + }, + runtime: { + init: runtimeInit, + }, + sharedFallback: undefined, + bundlerRuntime: { + getSharedFallbackGetter: jest.fn(), + }, + libraryType: 'var', + }, + }; + + const result = init({ webpackRequire }); + + expect(result).toBe(instance); + expect(runtimeInit).toHaveBeenCalledTimes(1); + const calledOptions = (runtimeInit as jest.Mock).mock.calls[0][0]; + expect(calledOptions.plugins).toHaveLength(3); + expect(calledOptions.plugins[0]).toBe(existingPlugin); + expect(calledOptions.plugins[1]).toBe(unloadRemotePlugin); + expect(typeof calledOptions.plugins[2].beforeInit).toBe('function'); + }); +}); diff --git a/packages/webpack-bundler-runtime/__tests__/unload-remote-plugin.spec.ts b/packages/webpack-bundler-runtime/__tests__/unload-remote-plugin.spec.ts new file mode 100644 index 00000000000..5ed1792d2d5 --- /dev/null +++ b/packages/webpack-bundler-runtime/__tests__/unload-remote-plugin.spec.ts @@ -0,0 +1,117 @@ +import { ModuleFederation } from '@module-federation/runtime-core'; +import { unloadRemotePlugin } from '../src/unload-remote-plugin'; + +describe('unloadRemotePlugin', () => { + test('skips webpack cache purge when remote was never loaded by host', () => { + const targetMapping: any = ['default', './say', 'external-target']; + targetMapping.p = Promise.resolve(1); + const untouchedMapping: any = ['default', './say', 'external-untouched']; + untouchedMapping.p = Promise.resolve(2); + + const webpackRequire: any = { + c: { + target: { id: 'target' }, + untouched: { id: 'untouched' }, + }, + m: { + target: () => null, + untouched: () => null, + }, + federation: { + bundlerRuntimeOptions: { + remotes: { + idToRemoteMap: { + target: [ + { externalType: 'script', name: '@register-remotes/app2' }, + ], + untouched: [{ externalType: 'script', name: 'other-remote' }], + }, + idToExternalAndNameMapping: { + target: targetMapping, + untouched: untouchedMapping, + }, + }, + }, + }, + }; + const origin: any = {}; + origin[Symbol.for('mf_webpack_require')] = webpackRequire; + + const plugin = unloadRemotePlugin(); + expect(plugin.afterRemoveRemote).toBeDefined(); + plugin.afterRemoveRemote?.({ + remote: { + name: '@register-remotes/app2', + alias: 'app2', + } as any, + origin, + loaded: false, + }); + + expect(webpackRequire.c.target).toBeDefined(); + expect(webpackRequire.m.target).toBeDefined(); + expect(targetMapping.p).toBeDefined(); + expect(webpackRequire.c.untouched).toBeDefined(); + expect(webpackRequire.m.untouched).toBeDefined(); + expect(untouchedMapping.p).toBeDefined(); + }); + + test('clears webpack module cache when loaded is true', () => { + const FM = new ModuleFederation({ + name: '@federation/runtime-unload-bundler-cache-loaded', + version: '1.0.1', + remotes: [], + }); + + const targetMapping: any = ['default', './say', 'external-target']; + targetMapping.p = Promise.resolve(1); + const untouchedMapping: any = ['default', './say', 'external-untouched']; + untouchedMapping.p = Promise.resolve(2); + + const webpackRequire: any = { + c: { + target: { id: 'target' }, + untouched: { id: 'untouched' }, + }, + m: { + target: () => null, + untouched: () => null, + }, + federation: { + bundlerRuntimeOptions: { + remotes: { + idToRemoteMap: { + target: [ + { externalType: 'script', name: '@register-remotes/app2' }, + ], + untouched: [{ externalType: 'script', name: 'other-remote' }], + }, + idToExternalAndNameMapping: { + target: targetMapping, + untouched: untouchedMapping, + }, + }, + }, + }, + }; + (FM as any)[Symbol.for('mf_webpack_require')] = webpackRequire; + + const plugin = unloadRemotePlugin(); + expect(plugin.afterRemoveRemote).toBeDefined(); + plugin.afterRemoveRemote?.({ + remote: { + name: '@register-remotes/app2', + alias: 'app2', + } as any, + origin: FM, + loaded: true, + }); + + expect(webpackRequire.c.target).toBeUndefined(); + expect(webpackRequire.m.target).toBeUndefined(); + expect(targetMapping.p).toBeUndefined(); + expect(webpackRequire.c.untouched).toBeDefined(); + expect(webpackRequire.m.untouched).toBeDefined(); + expect(untouchedMapping.p).toBeDefined(); + }); +}); diff --git a/packages/webpack-bundler-runtime/src/index.ts b/packages/webpack-bundler-runtime/src/index.ts index dc9b130f0eb..7157496089e 100644 --- a/packages/webpack-bundler-runtime/src/index.ts +++ b/packages/webpack-bundler-runtime/src/index.ts @@ -8,8 +8,10 @@ import { attachShareScopeMap } from './attachShareScopeMap'; import { initContainerEntry } from './initContainerEntry'; import { init } from './init'; import { getSharedFallbackGetter } from './getSharedFallbackGetter'; +import { unloadRemotePlugin } from './unload-remote-plugin'; export * from './types'; +export { unloadRemotePlugin }; const federation: Federation = { runtime, diff --git a/packages/webpack-bundler-runtime/src/init.ts b/packages/webpack-bundler-runtime/src/init.ts index fc2e8260a5a..1c3a34f07a3 100644 --- a/packages/webpack-bundler-runtime/src/init.ts +++ b/packages/webpack-bundler-runtime/src/init.ts @@ -3,8 +3,11 @@ import { getRemoteEntry, type ModuleFederationRuntimePlugin, } from '@module-federation/runtime'; -import { ShareArgs } from '@module-federation/runtime/types'; +import type { ShareArgs } from '@module-federation/runtime/types'; import helpers from '@module-federation/runtime/helpers'; +import { unloadRemotePlugin } from './unload-remote-plugin'; + +const WEBPACK_REQUIRE_SYMBOL = Symbol.for('mf_webpack_require'); export function init({ webpackRequire }: { webpackRequire: WebpackRequire }) { const { initOptions, runtime, sharedFallback, bundlerRuntime, libraryType } = @@ -125,6 +128,16 @@ export function init({ webpackRequire }: { webpackRequire: WebpackRequire }) { }; initOptions.plugins ||= []; - initOptions.plugins.push(treeShakingSharePlugin()); - return runtime!.init(initOptions); + const hasPlugin = (name: string) => + initOptions.plugins?.some((plugin) => plugin?.name === name); + if (!hasPlugin('tree-shake-plugin')) { + initOptions.plugins.push(treeShakingSharePlugin()); + } + if (!hasPlugin('unload-remote-plugin')) { + initOptions.plugins.push(unloadRemotePlugin()); + } + const instance = runtime!.init(initOptions); + (instance as unknown as Record)[WEBPACK_REQUIRE_SYMBOL] = + webpackRequire; + return instance; } diff --git a/packages/webpack-bundler-runtime/src/unload-remote-plugin.ts b/packages/webpack-bundler-runtime/src/unload-remote-plugin.ts new file mode 100644 index 00000000000..1a8ccb22918 --- /dev/null +++ b/packages/webpack-bundler-runtime/src/unload-remote-plugin.ts @@ -0,0 +1,96 @@ +import type { + ModuleFederation, + ModuleFederationRuntimePlugin, +} from '@module-federation/runtime'; +import type { Remote } from '@module-federation/runtime/types'; +import { decodeName, ENCODE_NAME_PREFIX } from '@module-federation/sdk'; + +type WebpackRequire = { + c?: Record; + m?: Record; + federation?: { + bundlerRuntimeOptions?: { + remotes?: { + idToRemoteMap?: Record>; + idToExternalAndNameMapping?: Record; + }; + }; + }; +}; + +const WEBPACK_REQUIRE_SYMBOL = Symbol.for('mf_webpack_require'); + +const clearBundlerRemoteModuleCache = ( + origin: ModuleFederation, + remote: Remote, +) => { + const webpackRequire = ( + origin as ModuleFederation & { + [key: symbol]: unknown; + } + )[WEBPACK_REQUIRE_SYMBOL] as WebpackRequire | undefined; + const remotesOptions = + webpackRequire?.federation?.bundlerRuntimeOptions?.remotes; + if (!remotesOptions) { + return; + } + + const { idToRemoteMap = {}, idToExternalAndNameMapping = {} } = + remotesOptions; + const candidates = new Set( + [remote.name, remote.alias].filter(Boolean) as string[], + ); + if (!candidates.size) { + return; + } + + const normalizeName = (value: string): string => { + try { + return decodeName(value, ENCODE_NAME_PREFIX); + } catch { + return value; + } + }; + + Object.entries(idToRemoteMap).forEach(([moduleId, remoteInfos]) => { + if (!Array.isArray(remoteInfos)) { + return; + } + + const matched = remoteInfos.some((remoteInfo) => { + if (!remoteInfo?.name) { + return false; + } + const remoteName = remoteInfo.name; + return ( + candidates.has(remoteName) || candidates.has(normalizeName(remoteName)) + ); + }); + if (!matched) { + return; + } + + if (webpackRequire?.c) { + delete webpackRequire.c[moduleId]; + } + if (webpackRequire?.m) { + delete webpackRequire.m[moduleId]; + } + const mappingItem = idToExternalAndNameMapping[moduleId]; + if (mappingItem && typeof mappingItem === 'object' && 'p' in mappingItem) { + delete mappingItem.p; + } + }); +}; + +export function unloadRemotePlugin(): ModuleFederationRuntimePlugin { + return { + name: 'unload-remote-plugin', + afterRemoveRemote({ remote, origin, loaded }) { + if (!loaded) { + return; + } + clearBundlerRemoteModuleCache(origin, remote); + }, + }; +}