diff --git a/.changeset/short-crabs-join.md b/.changeset/short-crabs-join.md new file mode 100644 index 00000000000..9fd2c886951 --- /dev/null +++ b/.changeset/short-crabs-join.md @@ -0,0 +1,5 @@ +--- +"@module-federation/enhanced": patch +--- + +Default `remoteType` to `script` so UMD library builds generate remote references correctly. diff --git a/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts b/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts index f308f576e25..f7ca212b163 100644 --- a/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts +++ b/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts @@ -22,7 +22,6 @@ import SharePlugin from '../sharing/SharePlugin'; import ContainerPlugin from './ContainerPlugin'; import ContainerReferencePlugin from './ContainerReferencePlugin'; import FederationRuntimePlugin from './runtime/FederationRuntimePlugin'; -import { RemoteEntryPlugin } from '@module-federation/rspack/remote-entry-plugin'; import StartupChunkDependenciesPlugin from '../startup/MfStartupChunkDependenciesPlugin'; import FederationModulesPlugin from './runtime/FederationModulesPlugin'; import { createSchemaValidation } from '../../utils'; @@ -144,6 +143,9 @@ class ModuleFederationPlugin implements WebpackPluginInstance { throw new Error('ModuleFederationPlugin name is required'); } // must before ModuleFederationPlugin + // Use runtime require here to avoid import cycles (and to play nicely with test-time mocking). + const { RemoteEntryPlugin } = + require('@module-federation/rspack/remote-entry-plugin') as typeof import('@module-federation/rspack/remote-entry-plugin'); (new RemoteEntryPlugin(options) as unknown as WebpackPluginInstance).apply( compiler, ); @@ -194,13 +196,55 @@ class ModuleFederationPlugin implements WebpackPluginInstance { new FederationRuntimePlugin(options).apply(compiler); const library = options.library || { type: 'var', name: name }; - const remoteType = - options.remoteType || - (options.library && isValidExternalsType(options.library.type) - ? (options.library.type as moduleFederationPlugin.ExternalsType) - : ('script' as moduleFederationPlugin.ExternalsType)); - const containerRemoteType = - remoteType as moduleFederationPlugin.ExternalsType; + // Default remoteType to 'script' unless the library type itself is a + // well-known externals type that works as a remoteType (e.g. 'var', + // 'module'). Notably, 'umd' is excluded because it isn't a valid + // remoteType for ContainerReferencePlugin. + const validRemoteTypes = new Set([ + 'var', + 'module', + 'assign', + 'this', + 'window', + 'self', + 'global', + 'commonjs', + 'commonjs2', + 'commonjs-module', + 'commonjs-static', + 'amd', + 'amd-require', + 'system', + 'jsonp', + 'import', + 'script', + 'node-commonjs', + 'promise', + ]); + + const isManifestRemote = (value: unknown): boolean => { + // Manifest remote syntax is typically: "name@http(s)://.../mf-manifest.json" + if (typeof value !== 'string') return false; + return /mf-manifest\.json(\?|#|$)/.test(value); + }; + + const remotesContainManifest = (() => { + if (!remotes) return false; + if (Array.isArray(remotes)) return remotes.some(isManifestRemote); + // Object form: { [key]: string | ... }. Only handle string values here. + return Object.values(remotes as Record).some( + isManifestRemote, + ); + })(); + + const remoteType = (options.remoteType ?? + (remotesContainManifest + ? 'script' + : options.library && validRemoteTypes.has(options.library.type) + ? options.library.type + : 'script')) as NonNullable< + moduleFederationPlugin.ModuleFederationPluginOptions['remoteType'] + >; let disableManifest = options.manifest === false; if (useContainerPlugin) { @@ -247,7 +291,7 @@ class ModuleFederationPlugin implements WebpackPluginInstance { : Object.keys(remotes).length > 0) ) { new ContainerReferencePlugin({ - remoteType: containerRemoteType, + remoteType, shareScope, remotes, }).apply(compiler); diff --git a/packages/enhanced/src/lib/sharing/tree-shaking/IndependentSharedPlugin.ts b/packages/enhanced/src/lib/sharing/tree-shaking/IndependentSharedPlugin.ts index a6435c05adc..19ba4c15087 100644 --- a/packages/enhanced/src/lib/sharing/tree-shaking/IndependentSharedPlugin.ts +++ b/packages/enhanced/src/lib/sharing/tree-shaking/IndependentSharedPlugin.ts @@ -241,6 +241,17 @@ export default class IndependentSharedPlugin { const shareRequestsMap: ShareRequestsMap = await this.createIndependentCompiler(parentCompiler); + const normalizeRequest = (resource: string) => { + // The collector records resolved absolute resources. For independent fallback bundles we want + // webpack to resolve the module from the same context as the parent compilation, otherwise + // some setups end up with a runtime "Cannot find module ..." stub instead of bundling. + if (typeof resource !== 'string' || !resource) return resource; + if (!path.isAbsolute(resource)) return resource; + const rel = path.relative(parentCompiler.context, resource); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return resource; + return `./${rel.split(path.sep).join('/')}`; + }; + await Promise.all( sharedOptions.map(async ([shareName, shareConfig]) => { if (!shareConfig.treeShaking) { @@ -258,7 +269,7 @@ export default class IndependentSharedPlugin { currentShare: { shareName, version, - request, + request: normalizeRequest(request), independentShareFileName: sharedConfig?.treeShaking?.filename, }, }); diff --git a/packages/enhanced/test/unit/container/ModuleFederationPlugin.test.ts b/packages/enhanced/test/unit/container/ModuleFederationPlugin.test.ts new file mode 100644 index 00000000000..dbf1a8ca3be --- /dev/null +++ b/packages/enhanced/test/unit/container/ModuleFederationPlugin.test.ts @@ -0,0 +1,252 @@ +import { rs, type Mock } from '@rstest/core'; +import { createMockCompiler, createWebpackMock } from './utils'; + +const webpack = { + ...createWebpackMock(), + DefinePlugin: class DefinePlugin { + apply = rs.fn(); + constructor(_options: Record) {} + }, +}; + +const mocks = rs.hoisted(() => { + const captured = { + containerReferenceOptions: null as null | { remoteType?: string }, + }; + + const mockContainerReferenceApply = rs.fn(); + + return { + captured, + mockBindLoggerToCompiler: rs.fn(), + mockComposeKeyWithSeparator: rs.fn( + (name: string, version: string) => `${name}-${version}`, + ), + mockInfrastructureLogger: { + warn: rs.fn(), + info: rs.fn(), + log: rs.fn(), + }, + mockGetBuildVersion: rs.fn(() => 'build'), + mockContainerManager: rs.fn().mockImplementation(() => ({ + init: rs.fn(), + containerPluginExposesOptions: {}, + })), + mockRemoteEntryPlugin: rs.fn().mockImplementation(() => ({ + apply: rs.fn(), + })), + mockFederationRuntimePlugin: rs.fn().mockImplementation(() => ({ + apply: rs.fn(), + })), + mockFederationModulesPlugin: rs.fn().mockImplementation(() => ({ + apply: rs.fn(), + })), + mockContainerPlugin: rs.fn().mockImplementation(() => ({ + apply: rs.fn(), + })), + mockContainerReferenceApply, + mockContainerReferencePlugin: rs + .fn() + .mockImplementation((options: { remoteType?: string }) => { + captured.containerReferenceOptions = options; + return { + apply: mockContainerReferenceApply, + }; + }), + mockSharePlugin: rs.fn().mockImplementation(() => ({ + apply: rs.fn(), + })), + mockTreeShakingSharedPlugin: rs.fn().mockImplementation(() => ({ + apply: rs.fn(), + })), + mockStartupChunkDependenciesPlugin: rs.fn().mockImplementation(() => ({ + apply: rs.fn(), + })), + mockStatsPlugin: rs.fn().mockImplementation(() => ({ + apply: rs.fn(), + })), + mockDtsPlugin: rs.fn().mockImplementation(() => ({ + apply: rs.fn(), + addRuntimePlugins: rs.fn(), + })), + mockPrefetchPlugin: rs.fn().mockImplementation(() => ({ + apply: rs.fn(), + })), + }; +}); + +rs.mock('webpack', () => webpack); + +rs.mock('@module-federation/sdk/normalize-webpack-path', () => ({ + normalizeWebpackPath: (path: string) => path, +})); + +rs.mock('@module-federation/sdk', () => ({ + bindLoggerToCompiler: mocks.mockBindLoggerToCompiler, + composeKeyWithSeparator: mocks.mockComposeKeyWithSeparator, + infrastructureLogger: mocks.mockInfrastructureLogger, +})); + +rs.mock('@module-federation/managers', () => ({ + ContainerManager: mocks.mockContainerManager, + utils: { + getBuildVersion: mocks.mockGetBuildVersion, + }, +})); + +rs.mock('@module-federation/manifest', () => ({ + StatsPlugin: mocks.mockStatsPlugin, +})); + +rs.mock('@module-federation/dts-plugin', () => ({ + DtsPlugin: mocks.mockDtsPlugin, +})); + +rs.mock('@module-federation/data-prefetch/cli', () => ({ + PrefetchPlugin: mocks.mockPrefetchPlugin, +})); + +rs.mock('@module-federation/rspack/remote-entry-plugin', () => ({ + RemoteEntryPlugin: mocks.mockRemoteEntryPlugin, +})); + +rs.mock('../../../src/utils', () => ({ + createSchemaValidation: () => () => undefined, +})); + +rs.mock('../../../src/lib/container/runtime/FederationRuntimePlugin', () => ({ + __esModule: true, + default: mocks.mockFederationRuntimePlugin, +})); + +rs.mock('../../../src/lib/container/runtime/FederationModulesPlugin', () => ({ + __esModule: true, + default: mocks.mockFederationModulesPlugin, +})); + +rs.mock('../../../src/lib/container/ContainerPlugin', () => ({ + __esModule: true, + default: mocks.mockContainerPlugin, +})); + +rs.mock('../../../src/lib/container/ContainerReferencePlugin', () => ({ + __esModule: true, + default: mocks.mockContainerReferencePlugin, +})); + +rs.mock('../../../src/lib/sharing/SharePlugin', () => ({ + __esModule: true, + default: mocks.mockSharePlugin, +})); + +rs.mock( + '../../../src/lib/sharing/tree-shaking/TreeShakingSharedPlugin', + () => ({ + __esModule: true, + default: mocks.mockTreeShakingSharedPlugin, + }), +); + +rs.mock('../../../src/lib/startup/MfStartupChunkDependenciesPlugin', () => ({ + __esModule: true, + default: mocks.mockStartupChunkDependenciesPlugin, +})); + +const ModuleFederationPlugin = + require('../../../src/lib/container/ModuleFederationPlugin').default; + +const getTap = ( + tapMock: Mock, + name: string, +): ((...args: Args) => unknown) | undefined => { + const entry = tapMock.mock.calls.find((call: unknown[]) => call[0] === name); + return entry ? (entry[1] as (...args: Args) => unknown) : undefined; +}; + +describe('ModuleFederationPlugin remoteType defaults', () => { + let mockCompiler: ReturnType; + + beforeEach(() => { + rs.clearAllMocks(); + mocks.captured.containerReferenceOptions = null; + mockCompiler = createMockCompiler(); + mockCompiler.hooks.afterPlugins = { tap: rs.fn() }; + mockCompiler.options.output = { + ...mockCompiler.options.output, + enabledLibraryTypes: [], + }; + mockCompiler.options.plugins = []; + mockCompiler.webpack = { + ...mockCompiler.webpack, + DefinePlugin: webpack.DefinePlugin, + } as any; + }); + + it('defaults remoteType to script for manifest remotes (umd library)', () => { + const plugin = new ModuleFederationPlugin({ + name: 'host', + library: { type: 'umd', name: 'host' }, + remotes: { + hostapp: 'hostapp@http://localhost:3000/mf-manifest.json', + }, + manifest: false, + dts: false, + }); + + plugin.apply(mockCompiler as any); + + const afterPluginsTap = getTap( + mockCompiler.hooks.afterPlugins.tap as unknown as Mock, + 'ModuleFederationPlugin', + ); + afterPluginsTap?.(); + + expect(mocks.captured.containerReferenceOptions?.remoteType).toBe('script'); + }); + + it('defaults remoteType to library type for non-manifest remotes', () => { + const plugin = new ModuleFederationPlugin({ + name: 'host', + library: { type: 'var', name: 'host' }, + remotes: { + // Non-manifest remote: should not force "script" + remoteA: 'remoteA@http://localhost:3001/remoteEntry.js', + }, + manifest: false, + dts: false, + }); + + plugin.apply(mockCompiler as any); + + const afterPluginsTap = getTap( + mockCompiler.hooks.afterPlugins.tap as unknown as Mock, + 'ModuleFederationPlugin', + ); + afterPluginsTap?.(); + + expect(mocks.captured.containerReferenceOptions?.remoteType).toBe('var'); + }); + + it('respects explicit remoteType', () => { + const plugin = new ModuleFederationPlugin({ + name: 'host', + library: { type: 'umd', name: 'host' }, + remotes: { + hostapp: 'hostapp@http://localhost:3000/mf-manifest.json', + }, + remoteType: 'umd', + manifest: false, + dts: false, + }); + + plugin.apply(mockCompiler as any); + + const afterPluginsTap = getTap( + mockCompiler.hooks.afterPlugins.tap as unknown as Mock, + 'ModuleFederationPlugin', + ); + afterPluginsTap?.(); + + expect(mocks.captured.containerReferenceOptions?.remoteType).toBe('umd'); + }); +});