diff --git a/.changeset/bundler-runtime-compose.md b/.changeset/bundler-runtime-compose.md new file mode 100644 index 00000000000..5000459c095 --- /dev/null +++ b/.changeset/bundler-runtime-compose.md @@ -0,0 +1,5 @@ +--- +'@module-federation/webpack-bundler-runtime': minor +--- + +Add `createFederation` at `./compose` and four adapters at `./adapters/remotes`, `./adapters/consumes`, `./adapters/share-scope`, and `./adapters/container`, so a generated bootstrap can import only the bundler runtime parts a build uses. The root export is rebuilt from the same adapters and keeps its keys and `federation.runtime`. diff --git a/packages/webpack-bundler-runtime/__tests__/compose.spec.ts b/packages/webpack-bundler-runtime/__tests__/compose.spec.ts new file mode 100644 index 00000000000..f66969b5aaa --- /dev/null +++ b/packages/webpack-bundler-runtime/__tests__/compose.spec.ts @@ -0,0 +1,289 @@ +import { remote } from '@module-federation/runtime-core/remote'; +import { shared } from '@module-federation/runtime-core/shared'; +import type { + Capabilities, + Platform, +} from '@module-federation/runtime-core/kernel'; +import { createFederation } from '../src/compose'; +import { remotes } from '../src/adapters/remotes'; +import { consumes } from '../src/adapters/consumes'; +import { shareScope } from '../src/adapters/share-scope'; +import { container } from '../src/adapters/container'; +import type { Adapter } from '../src/types'; + +let seq = 0; + +const containerEntry = { + init: () => undefined, + get: () => () => ({ answer: 42 }), +}; + +const fakePlatform: Platform = { + isBrowser: () => true, + loadScript: () => Promise.resolve(), + loadEntry: () => Promise.resolve(containerEntry as any), +}; + +function boot( + capabilities: Capabilities, + adapters: Adapter[], + initOptions: Record = {}, +) { + const name = `host${++seq}`; + const federation = createFederation({ + buildId: `${name}:1.0.0`, + capabilities, + adapters, + }); + const webpackRequire: any = (id: string) => { + const module = { exports: {} }; + webpackRequire.m[id](module); + return module.exports; + }; + const initPromises = {}; + const initTokens = {}; + Object.assign(webpackRequire, { + m: {}, + c: {}, + p: '', + S: {}, + I: (shareScopeName: string, initScope?: unknown[]) => + federation.bundlerRuntime.I!({ + shareScopeName, + webpackRequire, + initPromises, + initTokens, + initScope: initScope as any, + }), + o: (obj: object, key: string | number) => + Object.prototype.hasOwnProperty.call(obj, key), + federation, + }); + federation.initOptions = { name, remotes: [], ...initOptions } as any; + federation.instance = federation.bundlerRuntime.init({ webpackRequire }); + return { federation, webpackRequire, name }; +} + +async function loadRemoteModule(capabilities: Capabilities) { + const { webpackRequire } = boot(capabilities, [remotes, shareScope], { + remotes: [{ name: 'shop', entry: 'http://localhost/shop.js' }], + }); + webpackRequire.m.external = (module: any) => { + module.exports = {}; + }; + const promises: Promise[] = []; + webpackRequire.federation.bundlerRuntime.remotes({ + chunkId: 'main', + promises, + webpackRequire, + chunkMapping: { main: ['button'] }, + idToExternalAndNameMapping: { button: ['default', './Button', 'external'] }, + idToRemoteMap: { button: [{ externalType: 'script', name: 'shop' }] }, + }); + await Promise.all(promises); + return () => webpackRequire('button'); +} + +async function loadSharedModule(capabilities: Capabilities) { + const { webpackRequire } = boot(capabilities, [consumes, shareScope], { + shared: { + react: { + version: '18.0.0', + scope: ['default'], + get: () => () => ({ react: '18.0.0' }), + shareConfig: { singleton: false, requiredVersion: '^18.0.0' }, + }, + }, + }); + const promises: Promise[] = []; + webpackRequire.federation.bundlerRuntime.consumes({ + chunkId: 'main', + promises, + webpackRequire, + chunkMapping: { main: ['react-consume'] }, + installedModules: {}, + moduleToHandlerMapping: { + 'react-consume': { + shareKey: 'react', + getter: () => Promise.resolve(() => ({ react: 'fallback' })), + shareInfo: { + shareConfig: { singleton: false, requiredVersion: '^18.0.0' }, + scope: ['default'], + }, + }, + }, + }); + await Promise.allSettled(promises); + return () => webpackRequire('react-consume'); +} + +describe('createFederation', () => { + test.each([ + ['remotes', remotes, ['remotes']], + ['consumes', consumes, ['consumes', 'getSharedFallbackGetter']], + ['share-scope', shareScope, ['I', 'installInitialConsumes']], + ['container', container, ['initContainerEntry']], + ])('%s exposes exactly its bundlerRuntime keys', (_, adapter, keys) => { + const { bundlerRuntime } = createFederation({ + buildId: 'host:1.0.0', + capabilities: {}, + adapters: [adapter], + }); + expect(Object.keys(bundlerRuntime).sort()).toEqual( + ['S', 'init', ...keys].sort(), + ); + }); + + test('without adapters exposes only the composer keys', () => { + const federation = createFederation({ + buildId: 'host:1.0.0', + capabilities: {}, + adapters: [], + }); + expect(Object.keys(federation.bundlerRuntime).sort()).toEqual([ + 'S', + 'init', + ]); + expect(Object.keys(federation.runtime)).toEqual(['init']); + }); + + test('init passes buildId as the default id', () => { + const { federation, name } = boot({}, []); + expect(federation.instance!.options.id).toBe(`${name}:1.0.0`); + }); + + test('init keeps an explicit id', () => { + const { federation } = boot({}, [], { id: 'explicit' }); + expect(federation.instance!.options.id).toBe('explicit'); + }); + + test('init runs each adapter beforeInit before the instance is created', () => { + const seen: string[] = []; + const probe: Adapter = { + bundlerRuntime: {}, + beforeInit: (webpackRequire, initOptions) => { + seen.push(initOptions.name); + expect(webpackRequire.federation.instance).toBeUndefined(); + }, + }; + const { name } = boot({}, [probe]); + expect(seen).toEqual([name]); + }); + + test('consumes beforeInit registers the tree-shaking share plugin', () => { + const { federation } = boot({ shared }, [consumes, shareScope]); + expect( + federation.instance!.options.plugins.map((plugin) => plugin.name), + ).toContain('tree-shake-plugin'); + }); + + test('without consumes no tree-shaking share plugin is registered', () => { + const { federation } = boot({ shared }, [shareScope]); + expect( + federation.instance!.options.plugins.map((plugin) => plugin.name), + ).not.toContain('tree-shake-plugin'); + }); + + test('rspack 1.x initializes through runtime.init on the copied keys', () => { + const name = `host${++seq}`; + const composed = createFederation({ + buildId: `${name}:1.0.0`, + capabilities: { platform: fakePlatform }, + adapters: [shareScope], + }); + // @rspack/core 1.x moduleFederationDefaultRuntime: copy the keys, then call runtime.init. + const federation: any = {}; + for (const key in composed) { + federation[key] = (composed as any)[key]; + } + federation.initOptions = { name, remotes: [] }; + federation.instance = federation.runtime.init(federation.initOptions); + expect(federation.instance.options.id).toBe(`${name}:1.0.0`); + }); +}); + +describe('adapters against enabled and disabled handlers', () => { + test('remotes loads a remote module with the remote capability', async () => { + const load = await loadRemoteModule({ remote, platform: fakePlatform }); + expect(load()).toEqual({ answer: 42 }); + }); + + test('remotes fails with the disabled remote error without it', async () => { + const load = await loadRemoteModule({}); + expect(load).toThrow(/Remote loading is disabled/); + }); + + test('consumes resolves a shared module with the shared capability', async () => { + const load = await loadSharedModule({ shared }); + expect(load()).toEqual({ react: '18.0.0' }); + }); + + test('consumes fails with the disabled shared error without it', async () => { + const load = await loadSharedModule({}); + expect(load).toThrow(/Shared dependency loading is disabled/); + }); + + test('share-scope initializes and exposes the share scope with the shared capability', async () => { + const { webpackRequire } = boot({ shared }, [shareScope], { + shared: { + lodash: { + version: '4.0.0', + get: () => () => ({}), + scope: ['default'], + shareConfig: { singleton: false, requiredVersion: '^4.0.0' }, + }, + }, + }); + await webpackRequire.I('default'); + expect(Object.keys(webpackRequire.S.default)).toEqual(['lodash']); + }); + + test('share-scope initializes an empty scope without the shared capability', async () => { + const { webpackRequire } = boot({}, [shareScope]); + await expect(webpackRequire.I('default')).resolves.toBe(true); + expect(webpackRequire.S).toEqual({}); + }); + + test('share-scope installInitialConsumes fails with the disabled shared error without it', () => { + const { webpackRequire } = boot({}, [shareScope]); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + webpackRequire.m.eager = () => undefined; + expect(() => + webpackRequire.federation.bundlerRuntime.installInitialConsumes({ + webpackRequire, + installedModules: {}, + initialConsumes: ['eager'], + moduleToHandlerMapping: { + eager: { + shareKey: 'react', + getter: () => Promise.resolve(() => ({})), + shareInfo: { shareConfig: {}, scope: ['default'] }, + }, + }, + }), + ).not.toThrow(); + expect(() => webpackRequire('eager')).toThrow( + /Shared dependency loading is disabled/, + ); + }); + + test.each([ + ['with the shared capability', { shared }], + ['without the shared capability', {}], + ])( + 'container adopts the host share scope %s', + async (_, capabilities: Capabilities) => { + const { webpackRequire, federation } = boot(capabilities, [ + container, + shareScope, + ]); + const hostScope = {}; + await federation.bundlerRuntime.initContainerEntry({ + webpackRequire, + shareScope: hostScope, + shareScopeKey: 'default', + }); + expect(webpackRequire.S.default).toBe(hostScope); + }, + ); +}); diff --git a/packages/webpack-bundler-runtime/__tests__/consumer-types.spec.ts b/packages/webpack-bundler-runtime/__tests__/consumer-types.spec.ts new file mode 100644 index 00000000000..999d1502bd6 --- /dev/null +++ b/packages/webpack-bundler-runtime/__tests__/consumer-types.spec.ts @@ -0,0 +1,21 @@ +import { join } from 'path'; +import ts from 'typescript'; + +test('published declarations assign across runtime-core types', () => { + const program = ts.createProgram( + [join(__dirname, 'fixtures/consumer-types/index.ts')], + { + strict: true, + noEmit: true, + skipLibCheck: true, + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + target: ts.ScriptTarget.ES2020, + types: [], + }, + ); + const errors = ts + .getPreEmitDiagnostics(program) + .map((d) => ts.flattenDiagnosticMessageText(d.messageText, '\n')); + expect(errors).toEqual([]); +}); diff --git a/packages/webpack-bundler-runtime/__tests__/fixtures/consumer-types/index.ts b/packages/webpack-bundler-runtime/__tests__/fixtures/consumer-types/index.ts new file mode 100644 index 00000000000..94bd1b1d562 --- /dev/null +++ b/packages/webpack-bundler-runtime/__tests__/fixtures/consumer-types/index.ts @@ -0,0 +1,17 @@ +import type { + ModuleFederationRuntimePlugin, + UserOptions, +} from '@module-federation/runtime-core'; +import { initOptions } from '@module-federation/webpack-bundler-runtime'; +import { createFederation } from '@module-federation/webpack-bundler-runtime/compose'; +import { consumes } from '@module-federation/webpack-bundler-runtime/adapters/consumes'; + +declare const plugin: ModuleFederationRuntimePlugin; + +export const options: UserOptions = initOptions!; +initOptions!.plugins!.push(plugin); +export const federation = createFederation({ + buildId: 'host:1.0.0', + capabilities: {}, + adapters: [consumes], +}); diff --git a/packages/webpack-bundler-runtime/__tests__/import-boundary.spec.ts b/packages/webpack-bundler-runtime/__tests__/import-boundary.spec.ts new file mode 100644 index 00000000000..258b89b9831 --- /dev/null +++ b/packages/webpack-bundler-runtime/__tests__/import-boundary.spec.ts @@ -0,0 +1,34 @@ +import { readdirSync, readFileSync } from 'fs'; +import { join, relative } from 'path'; + +const srcDir = join(__dirname, '../src'); +const target = `['"]@module-federation/(?:runtime|runtime-core|runtime/helpers)['"]`; +const forbidden = new RegExp( + [ + `^\\s*(?:import|export)(?!\\s+type\\b)[^;]*?\\bfrom\\s*${target}`, + `^\\s*import\\s*${target}`, + `\\b(?:require|import)\\(\\s*${target}\\s*\\)`, + ].join('|'), + 'gm', +); + +function sourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => + entry.isDirectory() + ? sourceFiles(join(dir, entry.name)) + : entry.name.endsWith('.ts') + ? [join(dir, entry.name)] + : [], + ); +} + +test('only the legacy root imports the runtime or runtime-core root or runtime/helpers', () => { + const offenders = sourceFiles(srcDir) + .filter((file) => relative(srcDir, file) !== 'index.ts') + .flatMap((file) => + (readFileSync(file, 'utf8').match(forbidden) ?? []).map( + (line) => `${relative(srcDir, file)}: ${line}`, + ), + ); + expect(offenders).toEqual([]); +}); diff --git a/packages/webpack-bundler-runtime/__tests__/legacy-root.spec.ts b/packages/webpack-bundler-runtime/__tests__/legacy-root.spec.ts new file mode 100644 index 00000000000..14c5778fbee --- /dev/null +++ b/packages/webpack-bundler-runtime/__tests__/legacy-root.spec.ts @@ -0,0 +1,48 @@ +import * as legacyRoot from '../src/index'; + +describe('legacy root keeps the keys rspack copies onto federation', () => { + test('keeps the default export keys', () => { + expect(Object.keys(legacyRoot.default)).toEqual([ + 'runtime', + 'instance', + 'initOptions', + 'bundlerRuntime', + 'attachShareScopeMap', + 'bundlerRuntimeOptions', + ]); + }); + + test('exposes every adapter without capability defines', () => { + expect(Object.keys(legacyRoot.default.bundlerRuntime!).sort()).toEqual( + [ + 'I', + 'S', + 'consumes', + 'getSharedFallbackGetter', + 'init', + 'initContainerEntry', + 'installInitialConsumes', + 'remotes', + ].sort(), + ); + }); + + test('keeps the named exports', () => { + expect(Object.keys(legacyRoot).sort()).toEqual( + [ + 'attachShareScopeMap', + 'bundlerRuntime', + 'bundlerRuntimeOptions', + 'default', + 'initOptions', + 'instance', + 'runtime', + ].sort(), + ); + }); + + test('keeps federation.runtime as the runtime namespace', () => { + expect(typeof legacyRoot.default.runtime!.init).toBe('function'); + expect(legacyRoot.runtime).toBe(legacyRoot.default.runtime); + }); +}); diff --git a/packages/webpack-bundler-runtime/package.json b/packages/webpack-bundler-runtime/package.json index 0ddbdd10c0a..c1f994e4d33 100644 --- a/packages/webpack-bundler-runtime/package.json +++ b/packages/webpack-bundler-runtime/package.json @@ -28,6 +28,7 @@ "dependencies": { "@module-federation/error-codes": "workspace:*", "@module-federation/runtime": "workspace:*", + "@module-federation/runtime-core": "workspace:*", "@module-federation/sdk": "workspace:*" }, "exports": { @@ -41,6 +42,56 @@ "default": "./dist/index.cjs" } }, + "./compose": { + "import": { + "types": "./dist/compose.d.ts", + "default": "./dist/compose.js" + }, + "require": { + "types": "./dist/compose.d.ts", + "default": "./dist/compose.cjs" + } + }, + "./adapters/remotes": { + "import": { + "types": "./dist/adapters/remotes.d.ts", + "default": "./dist/adapters/remotes.js" + }, + "require": { + "types": "./dist/adapters/remotes.d.ts", + "default": "./dist/adapters/remotes.cjs" + } + }, + "./adapters/consumes": { + "import": { + "types": "./dist/adapters/consumes.d.ts", + "default": "./dist/adapters/consumes.js" + }, + "require": { + "types": "./dist/adapters/consumes.d.ts", + "default": "./dist/adapters/consumes.cjs" + } + }, + "./adapters/share-scope": { + "import": { + "types": "./dist/adapters/share-scope.d.ts", + "default": "./dist/adapters/share-scope.js" + }, + "require": { + "types": "./dist/adapters/share-scope.d.ts", + "default": "./dist/adapters/share-scope.cjs" + } + }, + "./adapters/container": { + "import": { + "types": "./dist/adapters/container.d.ts", + "default": "./dist/adapters/container.js" + }, + "require": { + "types": "./dist/adapters/container.d.ts", + "default": "./dist/adapters/container.cjs" + } + }, "./constant": { "import": { "types": "./dist/constant.d.ts", @@ -59,6 +110,21 @@ ".": [ "./dist/index.d.ts" ], + "compose": [ + "./dist/compose.d.ts" + ], + "adapters/remotes": [ + "./dist/adapters/remotes.d.ts" + ], + "adapters/consumes": [ + "./dist/adapters/consumes.d.ts" + ], + "adapters/share-scope": [ + "./dist/adapters/share-scope.d.ts" + ], + "adapters/container": [ + "./dist/adapters/container.d.ts" + ], "constant": [ "./dist/constant.d.ts" ], diff --git a/packages/webpack-bundler-runtime/src/adapters/consumes.ts b/packages/webpack-bundler-runtime/src/adapters/consumes.ts new file mode 100644 index 00000000000..22d2b60ffbf --- /dev/null +++ b/packages/webpack-bundler-runtime/src/adapters/consumes.ts @@ -0,0 +1,12 @@ +import { consumes as loadConsumes } from '../consumes'; +import { getSharedFallbackGetter } from '../getSharedFallbackGetter'; +import { createTreeShakingSharePlugin } from '../treeShakingSharePlugin'; +import type { Adapter } from '../types'; + +export const consumes: Adapter = { + bundlerRuntime: { consumes: loadConsumes, getSharedFallbackGetter }, + beforeInit(webpackRequire, initOptions) { + initOptions.plugins ||= []; + initOptions.plugins.push(createTreeShakingSharePlugin(webpackRequire)); + }, +}; diff --git a/packages/webpack-bundler-runtime/src/adapters/container.ts b/packages/webpack-bundler-runtime/src/adapters/container.ts new file mode 100644 index 00000000000..281ea496699 --- /dev/null +++ b/packages/webpack-bundler-runtime/src/adapters/container.ts @@ -0,0 +1,4 @@ +import { initContainerEntry } from '../initContainerEntry'; +import type { Adapter } from '../types'; + +export const container: Adapter = { bundlerRuntime: { initContainerEntry } }; diff --git a/packages/webpack-bundler-runtime/src/adapters/remotes.ts b/packages/webpack-bundler-runtime/src/adapters/remotes.ts new file mode 100644 index 00000000000..b76b10f06e0 --- /dev/null +++ b/packages/webpack-bundler-runtime/src/adapters/remotes.ts @@ -0,0 +1,4 @@ +import { remotes as loadRemotes } from '../remotes'; +import type { Adapter } from '../types'; + +export const remotes: Adapter = { bundlerRuntime: { remotes: loadRemotes } }; diff --git a/packages/webpack-bundler-runtime/src/adapters/share-scope.ts b/packages/webpack-bundler-runtime/src/adapters/share-scope.ts new file mode 100644 index 00000000000..ea4cb73cc85 --- /dev/null +++ b/packages/webpack-bundler-runtime/src/adapters/share-scope.ts @@ -0,0 +1,7 @@ +import { initializeSharing } from '../initializeSharing'; +import { installInitialConsumes } from '../installInitialConsumes'; +import type { Adapter } from '../types'; + +export const shareScope: Adapter = { + bundlerRuntime: { I: initializeSharing, installInitialConsumes }, +}; diff --git a/packages/webpack-bundler-runtime/src/compose.ts b/packages/webpack-bundler-runtime/src/compose.ts new file mode 100644 index 00000000000..8ef78bb662b --- /dev/null +++ b/packages/webpack-bundler-runtime/src/compose.ts @@ -0,0 +1,65 @@ +import { init as composeInit } from '@module-federation/runtime/compose'; +import type { + Capabilities, + FederationKernel, + UserOptions, +} from '@module-federation/runtime-core/kernel'; +import { attachShareScopeMap } from './attachShareScopeMap'; +import type { + Adapter, + BundlerRuntime, + Federation, + WebpackRequire, +} from './types'; + +export type ComposedFederation = Omit< + Federation, + 'runtime' | 'instance' | 'bundlerRuntime' +> & { + instance?: FederationKernel; + bundlerRuntime: Partial & { + S: BundlerRuntime['S']; + init(options: { webpackRequire: WebpackRequire }): FederationKernel; + }; + runtime: { + init(options: UserOptions): FederationKernel; + }; +}; + +export function createFederation({ + buildId, + capabilities, + adapters, +}: { + buildId: string; + capabilities: Capabilities; + adapters: Adapter[]; +}): ComposedFederation { + const init = (options: UserOptions) => + composeInit({ ...options, id: options.id || buildId }, capabilities); + const federation: ComposedFederation = { + instance: undefined, + initOptions: undefined, + bundlerRuntime: { + S: {}, + init({ webpackRequire }) { + const { initOptions } = webpackRequire.federation; + if (!initOptions) { + throw new Error('initOptions is required!'); + } + for (const adapter of adapters) { + adapter.beforeInit?.(webpackRequire, initOptions); + } + return init(initOptions); + }, + }, + attachShareScopeMap, + bundlerRuntimeOptions: {}, + // rspack native runtimes before 2.0.0-beta.1 call federation.runtime.init. + runtime: { init }, + }; + for (const adapter of adapters) { + Object.assign(federation.bundlerRuntime, adapter.bundlerRuntime); + } + return federation; +} diff --git a/packages/webpack-bundler-runtime/src/getSharedFallbackGetter.ts b/packages/webpack-bundler-runtime/src/getSharedFallbackGetter.ts index c5f7d364e88..8c8ddc43cf7 100644 --- a/packages/webpack-bundler-runtime/src/getSharedFallbackGetter.ts +++ b/packages/webpack-bundler-runtime/src/getSharedFallbackGetter.ts @@ -1,3 +1,4 @@ +import { getRemoteEntry } from '@module-federation/runtime-core/kernel'; import type { GetSharedFallbackGetterOptions } from './types'; export const getSharedFallbackGetter = ({ @@ -7,8 +8,7 @@ export const getSharedFallbackGetter = ({ webpackRequire, libraryType = 'global', }: GetSharedFallbackGetterOptions) => { - const { runtime, instance, bundlerRuntime, sharedFallback } = - webpackRequire.federation!; + const { bundlerRuntime, sharedFallback } = webpackRequire.federation!; if (!sharedFallback) { return factory; } @@ -26,18 +26,17 @@ export const getSharedFallbackGetter = ({ ); } return () => - runtime! - .getRemoteEntry({ - origin: webpackRequire.federation.instance!, - remoteInfo: { - name: fallbackItem[2], - entry: `${webpackRequire.p}${fallbackItem[0]}`, - type: libraryType, - entryGlobalName: fallbackItem[2], - // current not used - shareScope: 'default', - }, - }) + getRemoteEntry({ + origin: webpackRequire.federation.instance!, + remoteInfo: { + name: fallbackItem[2], + entry: `${webpackRequire.p}${fallbackItem[0]}`, + type: libraryType, + entryGlobalName: fallbackItem[2], + // current not used + shareScope: 'default', + }, + }) // @ts-ignore .then((shareEntry) => { if (!shareEntry) { diff --git a/packages/webpack-bundler-runtime/src/index.ts b/packages/webpack-bundler-runtime/src/index.ts index ff7bf9130dc..984c9cadefd 100644 --- a/packages/webpack-bundler-runtime/src/index.ts +++ b/packages/webpack-bundler-runtime/src/index.ts @@ -1,13 +1,10 @@ import * as runtime from '@module-federation/runtime'; -import { Federation } from './types'; -import { remotes } from './remotes'; -import { consumes } from './consumes'; -import { initializeSharing } from './initializeSharing'; -import { installInitialConsumes } from './installInitialConsumes'; +import type { Adapter, Federation, WebpackRequire } from './types'; import { attachShareScopeMap } from './attachShareScopeMap'; -import { initContainerEntry } from './initContainerEntry'; -import { init } from './init'; -import { getSharedFallbackGetter } from './getSharedFallbackGetter'; +import { remotes } from './adapters/remotes'; +import { consumes } from './adapters/consumes'; +import { shareScope } from './adapters/share-scope'; +import { container } from './adapters/container'; declare const FEDERATION_OPTIMIZE_NO_REMOTE: boolean; declare const FEDERATION_OPTIMIZE_NO_SHARED: boolean; @@ -26,16 +23,27 @@ const USE_SHARED = const USE_EXPOSES = typeof FEDERATION_HAS_EXPOSES === 'boolean' ? FEDERATION_HAS_EXPOSES : true; -const bundlerRuntime = { - remotes: USE_REMOTE ? remotes : undefined, - consumes: USE_SHARED ? consumes : undefined, - I: USE_SHARED ? initializeSharing : undefined, - S: {}, - installInitialConsumes: USE_SHARED ? installInitialConsumes : undefined, - initContainerEntry: USE_EXPOSES ? initContainerEntry : undefined, - init, - getSharedFallbackGetter: USE_SHARED ? getSharedFallbackGetter : undefined, -} as NonNullable; +const adapters: Adapter[] = []; +if (USE_REMOTE) adapters.push(remotes); +if (USE_SHARED) adapters.push(consumes, shareScope); +if (USE_EXPOSES) adapters.push(container); + +const bundlerRuntime = Object.assign( + { + S: {}, + init({ webpackRequire }: { webpackRequire: WebpackRequire }) { + const { initOptions } = webpackRequire.federation; + if (!initOptions) { + throw new Error('initOptions is required!'); + } + for (const adapter of adapters) { + adapter.beforeInit?.(webpackRequire, initOptions); + } + return webpackRequire.federation.runtime!.init(initOptions); + }, + }, + ...adapters.map((adapter) => adapter.bundlerRuntime), +) as NonNullable; const federation: Federation = { runtime, @@ -48,9 +56,10 @@ const federation: Federation = { // Keep CJS interop stable for consumers that iterate required keys directly. export { runtime, attachShareScopeMap }; -export const instance = federation.instance; -export const initOptions = federation.initOptions; +export const instance: Federation['instance'] = federation.instance; +export const initOptions: Federation['initOptions'] = federation.initOptions; export { bundlerRuntime }; -export const bundlerRuntimeOptions = federation.bundlerRuntimeOptions; +export const bundlerRuntimeOptions: Federation['bundlerRuntimeOptions'] = + federation.bundlerRuntimeOptions; export default federation; diff --git a/packages/webpack-bundler-runtime/src/init.ts b/packages/webpack-bundler-runtime/src/init.ts deleted file mode 100644 index d8a7e9e2d7c..00000000000 --- a/packages/webpack-bundler-runtime/src/init.ts +++ /dev/null @@ -1,139 +0,0 @@ -import type { WebpackRequire } from './types'; -import { - getRemoteEntry, - type ModuleFederationRuntimePlugin, -} from '@module-federation/runtime'; -import { ShareArgs } from '@module-federation/runtime/types'; -import helpers from '@module-federation/runtime/helpers'; - -declare const FEDERATION_OPTIMIZE_NO_SHARED: boolean; - -const USE_SHARED = - typeof FEDERATION_OPTIMIZE_NO_SHARED === 'boolean' - ? !FEDERATION_OPTIMIZE_NO_SHARED - : true; - -export function init({ webpackRequire }: { webpackRequire: WebpackRequire }) { - const { initOptions, runtime, sharedFallback, bundlerRuntime, libraryType } = - webpackRequire.federation; - - if (!initOptions) { - throw new Error('initOptions is required!'); - } - - if (USE_SHARED) { - const treeShakingSharePlugin: () => ModuleFederationRuntimePlugin = - function () { - return { - name: 'tree-shake-plugin', - beforeInit(args) { - const { userOptions, origin, options: registeredOptions } = args; - const version = userOptions.version || registeredOptions.version; - if (!sharedFallback) { - return args; - } - - const currentShared = userOptions.shared || {}; - const shared: Array<[pkgName: string, ShareArgs]> = []; - - Object.keys(currentShared).forEach((sharedName) => { - const sharedArgs = Array.isArray(currentShared[sharedName]) - ? currentShared[sharedName] - : [currentShared[sharedName]]; - sharedArgs.forEach((sharedArg) => { - shared.push([sharedName, sharedArg]); - if ('get' in sharedArg) { - sharedArg.treeShaking ||= {}; - sharedArg.treeShaking.get = sharedArg.get; - sharedArg.get = bundlerRuntime!.getSharedFallbackGetter({ - shareKey: sharedName, - factory: sharedArg.get, - webpackRequire, - libraryType, - version: sharedArg.version, - }); - } - }); - }); - - // read snapshot to override re-shake getter - const hostGlobalSnapshot = - helpers.global.getGlobalSnapshotInfoByModuleInfo({ - name: origin.name, - version: version, - }); - if (!hostGlobalSnapshot || !('shared' in hostGlobalSnapshot)) { - return args; - } - - Object.keys(registeredOptions.shared || {}).forEach((pkgName) => { - const sharedInfo = registeredOptions.shared[pkgName]; - sharedInfo.forEach((sharedArg) => { - shared.push([pkgName, sharedArg]); - }); - }); - - const patchShared = (pkgName: string, shared: ShareArgs) => { - const shareSnapshot = hostGlobalSnapshot.shared.find( - (item) => item.sharedName === pkgName, - ); - if (!shareSnapshot) { - return; - } - const { treeShaking } = shared; - if (!treeShaking) { - return; - } - const { - secondarySharedTreeShakingName, - secondarySharedTreeShakingEntry, - treeShakingStatus, - } = shareSnapshot; - if (treeShaking.status === treeShakingStatus) { - return; - } - treeShaking.status = treeShakingStatus; - if ( - secondarySharedTreeShakingEntry && - libraryType && - secondarySharedTreeShakingName - ) { - treeShaking.get = async () => { - const shareEntry = await getRemoteEntry({ - origin, - remoteInfo: { - name: secondarySharedTreeShakingName, - entry: secondarySharedTreeShakingEntry, - type: libraryType, - entryGlobalName: secondarySharedTreeShakingName, - // current not used - shareScope: 'default', - }, - }); - // TODO: add errorLoad hook ? - // @ts-ignore - await shareEntry.init( - origin, - // @ts-ignore - __webpack_require__.federation.bundlerRuntime, - ); - // @ts-ignore - const getter = shareEntry.get(); - return getter; - }; - } - }; - shared.forEach(([pkgName, sharedArg]) => { - patchShared(pkgName, sharedArg); - }); - - return args; - }, - }; - }; - - initOptions.plugins ||= []; - initOptions.plugins.push(treeShakingSharePlugin()); - } - return runtime!.init(initOptions); -} diff --git a/packages/webpack-bundler-runtime/src/remotes.ts b/packages/webpack-bundler-runtime/src/remotes.ts index 8682d25de31..9d78b52c82e 100644 --- a/packages/webpack-bundler-runtime/src/remotes.ts +++ b/packages/webpack-bundler-runtime/src/remotes.ts @@ -2,7 +2,7 @@ import { attachShareScopeMap } from './attachShareScopeMap'; import type { RemoteEntryExports } from './types'; import { RemotesOptions } from './types'; import { FEDERATION_SUPPORTED_TYPES } from './constant'; -import { decodeName, ENCODE_NAME_PREFIX } from '@module-federation/sdk'; +import { decodeName, ENCODE_NAME_PREFIX } from '@module-federation/sdk/core'; import { updateRemoteOptions } from './updateOptions'; export function remotes(options: RemotesOptions) { diff --git a/packages/webpack-bundler-runtime/src/treeShakingSharePlugin.ts b/packages/webpack-bundler-runtime/src/treeShakingSharePlugin.ts new file mode 100644 index 00000000000..25dcb24ec1a --- /dev/null +++ b/packages/webpack-bundler-runtime/src/treeShakingSharePlugin.ts @@ -0,0 +1,121 @@ +import type { + ModuleFederationRuntimePlugin, + ShareArgs, +} from '@module-federation/runtime/types'; +import { + getGlobalSnapshotInfoByModuleInfo, + getRemoteEntry, +} from '@module-federation/runtime-core/kernel'; +import type { WebpackRequire } from './types'; + +export function createTreeShakingSharePlugin( + webpackRequire: WebpackRequire, +): ModuleFederationRuntimePlugin { + const { sharedFallback, bundlerRuntime, libraryType } = + webpackRequire.federation; + return { + name: 'tree-shake-plugin', + beforeInit(args) { + const { userOptions, origin, options: registeredOptions } = args; + const version = userOptions.version || registeredOptions.version; + if (!sharedFallback) { + return args; + } + + const currentShared = userOptions.shared || {}; + const shared: Array<[pkgName: string, ShareArgs]> = []; + + Object.keys(currentShared).forEach((sharedName) => { + const sharedArgs = Array.isArray(currentShared[sharedName]) + ? currentShared[sharedName] + : [currentShared[sharedName]]; + sharedArgs.forEach((sharedArg) => { + shared.push([sharedName, sharedArg]); + if ('get' in sharedArg) { + sharedArg.treeShaking ||= {}; + sharedArg.treeShaking.get = sharedArg.get; + sharedArg.get = bundlerRuntime!.getSharedFallbackGetter({ + shareKey: sharedName, + factory: sharedArg.get, + webpackRequire, + libraryType, + version: sharedArg.version, + }); + } + }); + }); + + // read snapshot to override re-shake getter + const hostGlobalSnapshot = getGlobalSnapshotInfoByModuleInfo({ + name: origin.name, + version: version, + }); + if (!hostGlobalSnapshot || !('shared' in hostGlobalSnapshot)) { + return args; + } + + Object.keys(registeredOptions.shared || {}).forEach((pkgName) => { + const sharedInfo = registeredOptions.shared[pkgName]; + sharedInfo.forEach((sharedArg) => { + shared.push([pkgName, sharedArg]); + }); + }); + + const patchShared = (pkgName: string, shared: ShareArgs) => { + const shareSnapshot = hostGlobalSnapshot.shared.find( + (item) => item.sharedName === pkgName, + ); + if (!shareSnapshot) { + return; + } + const { treeShaking } = shared; + if (!treeShaking) { + return; + } + const { + secondarySharedTreeShakingName, + secondarySharedTreeShakingEntry, + treeShakingStatus, + } = shareSnapshot; + if (treeShaking.status === treeShakingStatus) { + return; + } + treeShaking.status = treeShakingStatus; + if ( + secondarySharedTreeShakingEntry && + libraryType && + secondarySharedTreeShakingName + ) { + treeShaking.get = async () => { + const shareEntry = await getRemoteEntry({ + origin, + remoteInfo: { + name: secondarySharedTreeShakingName, + entry: secondarySharedTreeShakingEntry, + type: libraryType, + entryGlobalName: secondarySharedTreeShakingName, + // current not used + shareScope: 'default', + }, + }); + // TODO: add errorLoad hook ? + // @ts-ignore + await shareEntry.init( + origin, + // @ts-ignore + __webpack_require__.federation.bundlerRuntime, + ); + // @ts-ignore + const getter = shareEntry.get(); + return getter; + }; + } + }; + shared.forEach(([pkgName, sharedArg]) => { + patchShared(pkgName, sharedArg); + }); + + return args; + }, + }; +} diff --git a/packages/webpack-bundler-runtime/src/types.ts b/packages/webpack-bundler-runtime/src/types.ts index e197f605c9c..c8f475c36cb 100644 --- a/packages/webpack-bundler-runtime/src/types.ts +++ b/packages/webpack-bundler-runtime/src/types.ts @@ -1,4 +1,4 @@ -import * as runtime from '@module-federation/runtime'; +import type * as runtime from '@module-federation/runtime'; import type { Remote, RemoteEntryInitOptions, @@ -254,3 +254,13 @@ export interface Federation { // { react: [ [ react/19.0.0/index.js , 19.0.0, react_global_name, ] ] } sharedFallback?: Record>; } + +export type BundlerRuntime = NonNullable; + +export interface Adapter { + bundlerRuntime: Partial; + beforeInit?: ( + webpackRequire: WebpackRequire, + initOptions: InitOptions, + ) => void; +} diff --git a/packages/webpack-bundler-runtime/tsdown.config.ts b/packages/webpack-bundler-runtime/tsdown.config.ts index e4561c5ba35..70cec6ee9ab 100644 --- a/packages/webpack-bundler-runtime/tsdown.config.ts +++ b/packages/webpack-bundler-runtime/tsdown.config.ts @@ -15,6 +15,11 @@ export default defineConfig([ index: 'src/index.ts', constant: 'src/constant.ts', bundler: 'src/bundler.ts', + compose: 'src/compose.ts', + 'adapters/remotes': 'src/adapters/remotes.ts', + 'adapters/consumes': 'src/adapters/consumes.ts', + 'adapters/share-scope': 'src/adapters/share-scope.ts', + 'adapters/container': 'src/adapters/container.ts', }, external: ['@module-federation/*', 'webpack'], noExternal: ['@module-federation/error-codes'], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a9363e8a1c..33c93b132e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4658,6 +4658,9 @@ importers: '@module-federation/runtime': specifier: workspace:* version: link:../runtime + '@module-federation/runtime-core': + specifier: workspace:* + version: link:../runtime-core '@module-federation/sdk': specifier: workspace:* version: link:../sdk