diff --git a/packages/enhanced/test/configCases/container/expose-edge-cases/index.js b/packages/enhanced/test/configCases/container/expose-edge-cases/index.js new file mode 100644 index 00000000000..411e428e725 --- /dev/null +++ b/packages/enhanced/test/configCases/container/expose-edge-cases/index.js @@ -0,0 +1,6 @@ +it('should be able to handle spaces in path to exposes', async () => { + const { default: test1 } = await import('./test 1'); + const { default: test2 } = await import('./path with spaces/test-2'); + expect(test1()).toBe('test 1'); + expect(test2()).toBe('test 2'); +}); diff --git a/packages/enhanced/test/configCases/container/expose-edge-cases/path with spaces/test-2.js b/packages/enhanced/test/configCases/container/expose-edge-cases/path with spaces/test-2.js new file mode 100644 index 00000000000..be5ea780f24 --- /dev/null +++ b/packages/enhanced/test/configCases/container/expose-edge-cases/path with spaces/test-2.js @@ -0,0 +1,3 @@ +export default function test() { + return 'test 2'; +} diff --git a/packages/enhanced/test/configCases/container/expose-edge-cases/test 1.js b/packages/enhanced/test/configCases/container/expose-edge-cases/test 1.js new file mode 100644 index 00000000000..d16a5fa9d1a --- /dev/null +++ b/packages/enhanced/test/configCases/container/expose-edge-cases/test 1.js @@ -0,0 +1,3 @@ +export default function test() { + return 'test 1'; +} diff --git a/packages/enhanced/test/configCases/container/expose-edge-cases/webpack.config.js b/packages/enhanced/test/configCases/container/expose-edge-cases/webpack.config.js new file mode 100644 index 00000000000..3f9c1ff64e5 --- /dev/null +++ b/packages/enhanced/test/configCases/container/expose-edge-cases/webpack.config.js @@ -0,0 +1,20 @@ +const { ModuleFederationPlugin } = require('../../../../dist/src'); + +module.exports = { + mode: 'development', + devtool: false, + output: { + publicPath: 'http://localhost:3000/', + }, + plugins: [ + new ModuleFederationPlugin({ + name: 'remote', + filename: 'remoteEntry.js', + manifest: true, + exposes: { + './test-1': './test 1.js', + './test-2': './path with spaces/test-2.js', + }, + }), + ], +}; diff --git a/packages/enhanced/test/unit/sharing/resolveMatchedConfigs.test.ts b/packages/enhanced/test/unit/sharing/resolveMatchedConfigs.test.ts index 88d1b618622..3bccc8b8563 100644 --- a/packages/enhanced/test/unit/sharing/resolveMatchedConfigs.test.ts +++ b/packages/enhanced/test/unit/sharing/resolveMatchedConfigs.test.ts @@ -3,6 +3,9 @@ * Testing all resolution paths: relative, absolute, prefix, and regular module requests */ +import ModuleNotFoundError from 'webpack/lib/ModuleNotFoundError'; +import LazySet from 'webpack/lib/util/LazySet'; + import { resolveMatchedConfigs } from '../../../src/lib/sharing/resolveMatchedConfigs'; import type { ConsumeOptions } from '../../../src/declarations/plugins/sharing/ConsumeSharedModule'; @@ -10,47 +13,13 @@ jest.mock('@module-federation/sdk/normalize-webpack-path', () => ({ normalizeWebpackPath: jest.fn((path) => path), })); -// Mock webpack classes -jest.mock( - 'webpack/lib/ModuleNotFoundError', - () => - jest.fn().mockImplementation((module, err, details) => { - return { module, err, details }; - }), - { - virtual: true, - }, -); -jest.mock( - 'webpack/lib/util/LazySet', - () => - jest.fn().mockImplementation(() => ({ - add: jest.fn(), - addAll: jest.fn(), - })), - { virtual: true }, -); - describe('resolveMatchedConfigs', () => { let mockCompilation: any; let mockResolver: any; - let mockResolveContext: any; - let MockModuleNotFoundError: any; - let MockLazySet: any; beforeEach(() => { jest.clearAllMocks(); - // Get the mocked classes - MockModuleNotFoundError = require('webpack/lib/ModuleNotFoundError'); - MockLazySet = require('webpack/lib/util/LazySet'); - - mockResolveContext = { - fileDependencies: { add: jest.fn(), addAll: jest.fn() }, - contextDependencies: { add: jest.fn(), addAll: jest.fn() }, - missingDependencies: { add: jest.fn(), addAll: jest.fn() }, - }; - mockResolver = { resolve: jest.fn(), }; @@ -67,9 +36,6 @@ describe('resolveMatchedConfigs', () => { fileDependencies: { addAll: jest.fn() }, missingDependencies: { addAll: jest.fn() }, }; - - // Setup LazySet mock instances - MockLazySet.mockImplementation(() => mockResolveContext.fileDependencies); }); describe('relative path resolution', () => { @@ -138,14 +104,15 @@ describe('resolveMatchedConfigs', () => { expect(result.unresolved.size).toBe(0); expect(result.prefixed.size).toBe(0); expect(mockCompilation.errors).toHaveLength(1); - expect(MockModuleNotFoundError).toHaveBeenCalledWith(null, resolveError, { + const error = mockCompilation.errors[0] as InstanceType< + typeof ModuleNotFoundError + >; + expect(error).toBeInstanceOf(ModuleNotFoundError); + expect(error.module).toBeNull(); + expect(error.error).toBe(resolveError); + expect(error.loc).toEqual({ name: 'shared module ./missing-module', }); - expect(mockCompilation.errors[0]).toEqual({ - module: null, - err: resolveError, - details: { name: 'shared module ./missing-module' }, - }); }); it('should handle resolver returning false', async () => { @@ -163,17 +130,15 @@ describe('resolveMatchedConfigs', () => { expect(result.resolved.size).toBe(0); expect(mockCompilation.errors).toHaveLength(1); - expect(MockModuleNotFoundError).toHaveBeenCalledWith( - null, - expect.any(Error), - { name: 'shared module ./invalid-module' }, - ); - expect(mockCompilation.errors[0]).toEqual({ - module: null, - err: expect.objectContaining({ - message: "Can't resolve ./invalid-module", - }), - details: { name: 'shared module ./invalid-module' }, + const error = mockCompilation.errors[0] as InstanceType< + typeof ModuleNotFoundError + >; + expect(error).toBeInstanceOf(ModuleNotFoundError); + expect(error.module).toBeNull(); + expect(error.error).toBeInstanceOf(Error); + expect(error.error.message).toContain("Can't resolve ./invalid-module"); + expect(error.loc).toEqual({ + name: 'shared module ./invalid-module', }); }); @@ -459,12 +424,6 @@ describe('resolveMatchedConfigs', () => { ['./relative', { shareScope: 'default' }], ]; - const resolveContext = { - fileDependencies: { add: jest.fn(), addAll: jest.fn() }, - contextDependencies: { add: jest.fn(), addAll: jest.fn() }, - missingDependencies: { add: jest.fn(), addAll: jest.fn() }, - }; - mockResolver.resolve.mockImplementation( (context, basePath, request, rc, callback) => { // Simulate adding dependencies during resolution @@ -475,22 +434,29 @@ describe('resolveMatchedConfigs', () => { }, ); - // Update LazySet mock to return the actual resolve context - MockLazySet.mockReturnValueOnce(resolveContext.fileDependencies) - .mockReturnValueOnce(resolveContext.contextDependencies) - .mockReturnValueOnce(resolveContext.missingDependencies); - await resolveMatchedConfigs(mockCompilation, configs); - expect(mockCompilation.contextDependencies.addAll).toHaveBeenCalledWith( - resolveContext.contextDependencies, - ); - expect(mockCompilation.fileDependencies.addAll).toHaveBeenCalledWith( - resolveContext.fileDependencies, + expect(mockCompilation.contextDependencies.addAll).toHaveBeenCalledTimes( + 1, ); - expect(mockCompilation.missingDependencies.addAll).toHaveBeenCalledWith( - resolveContext.missingDependencies, + expect(mockCompilation.fileDependencies.addAll).toHaveBeenCalledTimes(1); + expect(mockCompilation.missingDependencies.addAll).toHaveBeenCalledTimes( + 1, ); + + const [contextDeps] = + mockCompilation.contextDependencies.addAll.mock.calls[0]; + const [fileDeps] = mockCompilation.fileDependencies.addAll.mock.calls[0]; + const [missingDeps] = + mockCompilation.missingDependencies.addAll.mock.calls[0]; + + expect(contextDeps).toBeInstanceOf(LazySet); + expect(fileDeps).toBeInstanceOf(LazySet); + expect(missingDeps).toBeInstanceOf(LazySet); + + expect(contextDeps.has('/some/context')).toBe(true); + expect(fileDeps.has('/some/file.js')).toBe(true); + expect(missingDeps.has('/missing/file')).toBe(true); }); }); diff --git a/packages/manifest/__tests__/ModuleHandler.spec.ts b/packages/manifest/__tests__/ModuleHandler.spec.ts new file mode 100644 index 00000000000..2a24c96d141 --- /dev/null +++ b/packages/manifest/__tests__/ModuleHandler.spec.ts @@ -0,0 +1,179 @@ +import type { StatsModule } from 'webpack'; + +jest.mock( + '@module-federation/sdk', + () => ({ + composeKeyWithSeparator: (...parts: string[]) => parts.join(':'), + moduleFederationPlugin: {}, + createLogger: () => ({ + debug: () => undefined, + error: () => undefined, + info: () => undefined, + warn: () => undefined, + }), + }), + { virtual: true }, +); + +jest.mock( + '@module-federation/dts-plugin/core', + () => ({ + isTSProject: () => false, + retrieveTypesAssetsInfo: () => ({}) as const, + }), + { virtual: true }, +); + +jest.mock( + '@module-federation/managers', + () => ({ + ContainerManager: class { + options?: { name?: string; exposes?: unknown }; + + init(options: { name?: string; exposes?: unknown }) { + this.options = options; + } + + get enable() { + const { name, exposes } = this.options || {}; + + if (!name || !exposes) { + return false; + } + + if (Array.isArray(exposes)) { + return exposes.length > 0; + } + + return Object.keys(exposes as Record).length > 0; + } + + get containerPluginExposesOptions() { + const { exposes } = this.options || {}; + + if (!exposes || Array.isArray(exposes)) { + return {}; + } + + return Object.entries(exposes as Record).reduce( + (acc, [exposeKey, exposeValue]) => { + if (typeof exposeValue === 'string') { + acc[exposeKey] = { import: [exposeValue] }; + } else if (Array.isArray(exposeValue)) { + acc[exposeKey] = { import: exposeValue as string[] }; + } else if ( + exposeValue && + typeof exposeValue === 'object' && + 'import' in exposeValue + ) { + const exposeImport = ( + exposeValue as { import: string | string[] } + ).import; + acc[exposeKey] = { + import: Array.isArray(exposeImport) + ? exposeImport + : [exposeImport], + }; + } + + return acc; + }, + {} as Record, + ); + } + }, + RemoteManager: class { + statsRemoteWithEmptyUsedIn: unknown[] = []; + init() {} + }, + SharedManager: class { + normalizedOptions: Record = {}; + init() {} + }, + }), + { virtual: true }, +); + +import type { moduleFederationPlugin } from '@module-federation/sdk'; +// eslint-disable-next-line import/first +import { ModuleHandler } from '../src/ModuleHandler'; + +describe('ModuleHandler', () => { + it('initializes exposes from plugin options when import paths contain spaces', () => { + const options = { + name: 'test-app', + exposes: { + './Button': './src/path with spaces/Button.tsx', + }, + } as const; + + const moduleHandler = new ModuleHandler(options, [], { + bundler: 'webpack', + }); + + const { exposesMap } = moduleHandler.collect(); + + const expose = exposesMap['./src/path with spaces/Button']; + + expect(expose).toBeDefined(); + expect(expose?.path).toBe('./Button'); + expect(expose?.file).toBe('src/path with spaces/Button.tsx'); + }); + + it('parses container exposes when identifiers contain spaces', () => { + const options = { + name: 'test-app', + } as const; + + const modules: StatsModule[] = [ + { + identifier: + 'container entry (default) [["./Button",{"import":["./src/path with spaces/Button.tsx"],"name":"__federation_expose_Button"}]]', + } as StatsModule, + ]; + + const moduleHandler = new ModuleHandler(options, modules, { + bundler: 'webpack', + }); + + const { exposesMap } = moduleHandler.collect(); + + const expose = exposesMap['./src/path with spaces/Button']; + + expect(expose).toBeDefined(); + expect(expose?.path).toBe('./Button'); + expect(expose?.file).toBe('src/path with spaces/Button.tsx'); + }); + + it('falls back to normalized exposes when identifier parsing fails', () => { + const options = { + exposes: { + './Button': './src/Button.tsx', + './Card': { import: ['./src/Card.tsx'], name: 'Card' }, + './Invalid': { import: [] }, + './Empty': '', + }, + } as const; + + const modules: StatsModule[] = [ + { + identifier: 'container entry (default)', + } as StatsModule, + ]; + + const moduleHandler = new ModuleHandler( + options as unknown as moduleFederationPlugin.ModuleFederationPluginOptions, + modules, + { + bundler: 'webpack', + }, + ); + + const { exposesMap } = moduleHandler.collect(); + + expect(exposesMap['./src/Button']).toBeDefined(); + expect(exposesMap['./src/Card']).toBeDefined(); + expect(exposesMap['./src/Button']?.path).toBe('./Button'); + expect(exposesMap['./src/Card']?.path).toBe('./Card'); + }); +}); diff --git a/packages/manifest/src/ModuleHandler.ts b/packages/manifest/src/ModuleHandler.ts index 660b49e7659..1c2be85110c 100644 --- a/packages/manifest/src/ModuleHandler.ts +++ b/packages/manifest/src/ModuleHandler.ts @@ -8,13 +8,132 @@ import { } from '@module-federation/sdk'; import type { StatsModule } from 'webpack'; import path from 'path'; -import { RemoteManager, SharedManager } from '@module-federation/managers'; +import { + ContainerManager, + RemoteManager, + SharedManager, +} from '@module-federation/managers'; import { getFileNameWithOutExt } from './utils'; type ShareMap = { [sharedKey: string]: StatsShared }; type ExposeMap = { [exposeImportValue: string]: StatsExpose }; type RemotesConsumerMap = { [remoteKey: string]: StatsRemote }; +type ContainerExposeEntry = [ + exposeKey: string, + { import: string[]; name?: string }, +]; + +const isNonEmptyString = (value: unknown): value is string => { + return typeof value === 'string' && value.trim().length > 0; +}; + +const normalizeExposeValue = ( + exposeValue: unknown, +): { import: string[]; name?: string } | undefined => { + if (!exposeValue) { + return undefined; + } + + const toImportArray = (value: unknown): string[] | undefined => { + if (isNonEmptyString(value)) { + return [value]; + } + + if (Array.isArray(value)) { + const normalized = value.filter(isNonEmptyString); + + return normalized.length ? normalized : undefined; + } + + return undefined; + }; + + if (typeof exposeValue === 'object') { + if ('import' in exposeValue) { + const { import: rawImport, name } = exposeValue as { + import: unknown; + name?: string; + }; + const normalizedImport = toImportArray(rawImport); + + if (!normalizedImport?.length) { + return undefined; + } + + return { + import: normalizedImport, + ...(isNonEmptyString(name) ? { name } : {}), + }; + } + + return undefined; + } + + const normalizedImport = toImportArray(exposeValue); + + if (!normalizedImport?.length) { + return undefined; + } + + return { import: normalizedImport }; +}; + +const parseContainerExposeEntries = ( + identifier: string, +): ContainerExposeEntry[] | undefined => { + const startIndex = identifier.indexOf('['); + + if (startIndex < 0) { + return undefined; + } + + let depth = 0; + let inString = false; + let isEscaped = false; + + for (let cursor = startIndex; cursor < identifier.length; cursor++) { + const char = identifier[cursor]; + + if (isEscaped) { + isEscaped = false; + continue; + } + + if (char === '\\') { + isEscaped = true; + continue; + } + + if (char === '"') { + inString = !inString; + continue; + } + + if (inString) { + continue; + } + + if (char === '[') { + depth++; + } else if (char === ']') { + depth--; + + if (depth === 0) { + const serialized = identifier.slice(startIndex, cursor + 1); + + try { + return JSON.parse(serialized) as ContainerExposeEntry[]; + } catch { + return undefined; + } + } + } + } + + return undefined; +}; + export const getExposeName = (exposeKey: string) => { return exposeKey.replace('./', ''); }; @@ -53,6 +172,7 @@ class ModuleHandler { private _options: moduleFederationPlugin.ModuleFederationPluginOptions; private _bundler: 'webpack' | 'rspack' = 'webpack'; private _modules: StatsModule[]; + private _containerManager: ContainerManager; private _remoteManager: RemoteManager = new RemoteManager(); private _sharedManager: SharedManager = new SharedManager(); @@ -65,6 +185,8 @@ class ModuleHandler { this._modules = modules; this._bundler = bundler; + this._containerManager = new ContainerManager(); + this._containerManager.init(options); this._remoteManager = new RemoteManager(); this._remoteManager.init(options); this._sharedManager = new SharedManager(); @@ -290,9 +412,15 @@ class ModuleHandler { return; } // identifier: container entry (default) [[".",{"import":["./src/routes/page.tsx"],"name":"__federation_expose_default_export"}]]' - const data = identifier.split(' '); + const entries = + parseContainerExposeEntries(identifier) ?? + this._getContainerExposeEntriesFromOptions(); + + if (!entries) { + return; + } - JSON.parse(data[3]).forEach(([prefixedName, file]) => { + entries.forEach(([prefixedName, file]) => { // TODO: support multiple import exposesMap[getFileNameWithOutExt(file.import[0])] = getExposeItem({ exposeKey: prefixedName, @@ -302,6 +430,82 @@ class ModuleHandler { }); } + private _getContainerExposeEntriesFromOptions(): + | ContainerExposeEntry[] + | undefined { + const exposes = this._containerManager.containerPluginExposesOptions; + + const normalizedEntries = Object.entries(exposes).reduce< + ContainerExposeEntry[] + >((acc, [exposeKey, exposeOptions]) => { + const normalizedExpose = normalizeExposeValue(exposeOptions); + + if (!normalizedExpose?.import.length) { + return acc; + } + + acc.push([exposeKey, normalizedExpose]); + + return acc; + }, []); + + if (normalizedEntries.length) { + return normalizedEntries; + } + + const rawExposes = this._options.exposes; + + if (!rawExposes || Array.isArray(rawExposes)) { + return undefined; + } + + const normalizedFromOptions = Object.entries(rawExposes).reduce< + ContainerExposeEntry[] + >((acc, [exposeKey, exposeOptions]) => { + const normalizedExpose = normalizeExposeValue(exposeOptions); + + if (!normalizedExpose?.import.length) { + return acc; + } + + acc.push([exposeKey, normalizedExpose]); + + return acc; + }, []); + + return normalizedFromOptions.length ? normalizedFromOptions : undefined; + } + + private _initializeExposesFromOptions(exposesMap: ExposeMap) { + if (!this._options.name || !this._containerManager.enable) { + return; + } + + const exposes = this._containerManager.containerPluginExposesOptions; + + Object.entries(exposes).forEach(([exposeKey, exposeOptions]) => { + if (!exposeOptions.import?.length) { + return; + } + + const [exposeImport] = exposeOptions.import; + + if (!exposeImport) { + return; + } + + const exposeMapKey = getFileNameWithOutExt(exposeImport); + + if (!exposesMap[exposeMapKey]) { + exposesMap[exposeMapKey] = getExposeItem({ + exposeKey, + name: this._options.name!, + file: exposeOptions, + }); + } + }); + } + collect() { const remotes: StatsRemote[] = []; const remotesConsumerMap: { [remoteKey: string]: StatsRemote } = {}; @@ -309,6 +513,8 @@ class ModuleHandler { const exposesMap: { [exposeImportValue: string]: StatsExpose } = {}; const sharedMap: { [sharedKey: string]: StatsShared } = {}; + this._initializeExposesFromOptions(exposesMap); + const isSharedModule = (moduleType?: string) => { return Boolean( moduleType && @@ -316,12 +522,10 @@ class ModuleHandler { ); }; const isContainerModule = (identifier: string) => { - const data = identifier.split(' '); - return Boolean(data[0] === 'container' && data[1] === 'entry'); + return identifier.startsWith('container entry'); }; const isRemoteModule = (identifier: string) => { - const data = identifier.split(' '); - return data[0] === 'remote'; + return identifier.startsWith('remote '); }; // handle remote/expose @@ -337,7 +541,10 @@ class ModuleHandler { if (isRemoteModule(identifier)) { this._handleRemoteModule(mod, remotes, remotesConsumerMap); - } else if (isContainerModule(identifier)) { + } else if ( + !this._containerManager.enable && + isContainerModule(identifier) + ) { this._handleContainerModule(mod, exposesMap); } });