diff --git a/src/index.ts b/src/index.ts index 86ad878..1738f18 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import defu from 'defu'; import { Plugin } from 'vite'; import addEntry from './plugins/pluginAddEntry'; +import { checkAliasConflicts } from './plugins/pluginCheckAliasConflicts'; import { PluginDevProxyModuleTopLevelAwait } from './plugins/pluginDevProxyModuleTopLevelAwait'; import pluginManifest from './plugins/pluginMFManifest'; import pluginModuleParseEnd from './plugins/pluginModuleParseEnd'; @@ -41,6 +42,7 @@ function federation(mfUserOptions: ModuleFederationOptions): Plugin[] { }, }, aliasToArrayPlugin, + checkAliasConflicts({ shared }), normalizeOptimizeDepsPlugin, ...addEntry({ entryName: 'remoteEntry', diff --git a/src/plugins/__tests__/pluginCheckAliasConflicts.test.ts b/src/plugins/__tests__/pluginCheckAliasConflicts.test.ts new file mode 100644 index 0000000..1a028be --- /dev/null +++ b/src/plugins/__tests__/pluginCheckAliasConflicts.test.ts @@ -0,0 +1,226 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { checkAliasConflicts } from '../pluginCheckAliasConflicts'; + +describe('pluginCheckAliasConflicts', () => { + let consoleWarnSpy: any; + let mockLogger: any; + + beforeEach(() => { + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mockLogger = { + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + }; + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + }); + + it('should warn when alias conflicts with shared module', () => { + const plugin = checkAliasConflicts({ + shared: { + vue: { + name: 'vue', + version: '3.2.45', + scope: 'default', + from: 'host', + shareConfig: { + requiredVersion: '^3.2.45', + } as any, + }, + pinia: { + name: 'pinia', + version: '2.0.28', + scope: 'default', + from: 'host', + shareConfig: { + requiredVersion: '^2.0.28', + } as any, + }, + }, + }); + + const mockConfig = { + logger: mockLogger, + resolve: { + alias: [ + { + find: 'vue', + replacement: '/path/to/project/node_modules/vue/dist/vue.runtime.esm-bundler.js', + }, + { + find: 'pinia', + replacement: '/path/to/project/node_modules/pinia/dist/pinia.mjs', + }, + { + find: 'shared', + replacement: '/path/to/project/shared/shared', + }, + ], + }, + }; + + plugin.configResolved!(mockConfig as any); + + expect(mockLogger.warn).toHaveBeenCalledTimes(4); + expect(mockLogger.warn).toHaveBeenCalledWith( + '\n[Module Federation] Detected alias conflicts with shared modules:' + ); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Shared module "vue" is aliased by "vue"') + ); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Shared module "pinia" is aliased by "pinia"') + ); + }); + + it('should not warn when no alias conflicts exist', () => { + const plugin = checkAliasConflicts({ + shared: { + vue: { + name: 'vue', + version: '3.2.45', + scope: 'default', + from: 'host', + shareConfig: { + requiredVersion: '^3.2.45', + } as any, + }, + }, + }); + + const mockConfig = { + logger: mockLogger, + resolve: { + alias: [ + { + find: 'shared', + replacement: '/path/to/project/shared/shared', + }, + ], + }, + }; + + plugin.configResolved!(mockConfig as any); + + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + + it('should not warn when shared is empty', () => { + const plugin = checkAliasConflicts({ + shared: {}, + }); + + const mockConfig = { + logger: mockLogger, + resolve: { + alias: [ + { + find: 'vue', + replacement: '/path/to/project/node_modules/vue/dist/vue.runtime.esm-bundler.js', + }, + ], + }, + }; + + plugin.configResolved!(mockConfig as any); + + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + + it('should handle regex alias patterns', () => { + const plugin = checkAliasConflicts({ + shared: { + react: { + name: 'react', + version: '18.0.0', + scope: 'default', + from: 'host', + shareConfig: { + requiredVersion: '^18.0.0', + } as any, + }, + }, + }); + + const mockConfig = { + logger: mockLogger, + resolve: { + alias: [ + { + find: /^react$/, + replacement: '/path/to/project/node_modules/react/index.js', + }, + ], + }, + }; + + plugin.configResolved!(mockConfig as any); + + expect(mockLogger.warn).toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Shared module "react" is aliased') + ); + }); + + it('should handle shared modules with trailing slash', () => { + const plugin = checkAliasConflicts({ + shared: { + 'lodash/': { + name: 'lodash/', + version: '4.17.21', + scope: 'default', + from: 'host', + shareConfig: { + requiredVersion: '^4.17.21', + } as any, + }, + }, + }); + + const mockConfig = { + logger: mockLogger, + resolve: { + alias: [ + { + find: 'lodash', + replacement: '/path/to/project/node_modules/lodash', + }, + ], + }, + }; + + plugin.configResolved!(mockConfig as any); + + expect(mockLogger.warn).toHaveBeenCalled(); + }); + + it('should work with undefined alias', () => { + const plugin = checkAliasConflicts({ + shared: { + vue: { + name: 'vue', + version: '3.2.45', + scope: 'default', + from: 'host', + shareConfig: { + requiredVersion: '^3.2.45', + } as any, + }, + }, + }); + + const mockConfig = { + logger: mockLogger, + resolve: { + alias: undefined, + }, + }; + + plugin.configResolved!(mockConfig as any); + + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/pluginCheckAliasConflicts.ts b/src/plugins/pluginCheckAliasConflicts.ts new file mode 100644 index 0000000..5c0d4a0 --- /dev/null +++ b/src/plugins/pluginCheckAliasConflicts.ts @@ -0,0 +1,59 @@ +import type { Alias, Plugin } from 'vite'; +import { NormalizedShared } from '../utils/normalizeModuleFederationOptions'; + +/** + * Check if user-defined alias conflicts with shared modules + * This should run after aliasToArrayPlugin to ensure alias is an array + */ +export function checkAliasConflicts(options: { shared?: NormalizedShared }): Plugin { + const { shared = {} } = options; + const sharedKeys = Object.keys(shared); + + return { + name: 'check-alias-conflicts', + configResolved(config: any) { + if (sharedKeys.length === 0) return; + + const userAliases: Alias[] = config.resolve?.alias || []; + const conflicts: Array<{ sharedModule: string; alias: string; target: string }> = []; + + for (const sharedKey of sharedKeys) { + for (const aliasEntry of userAliases) { + const findPattern = aliasEntry.find; + const replacement = aliasEntry.replacement; + + // Skip if replacement is not a string (e.g., customResolver) + if (typeof replacement !== 'string') continue; + + // Check if alias pattern matches the shared module + let isMatch = false; + if (typeof findPattern === 'string') { + isMatch = findPattern === sharedKey || sharedKey.startsWith(findPattern + '/'); + } else if (findPattern instanceof RegExp) { + isMatch = findPattern.test(sharedKey); + } + + if (isMatch) { + conflicts.push({ + sharedModule: sharedKey, + alias: String(findPattern), + target: replacement, + }); + } + } + } + + if (conflicts.length > 0) { + config.logger.warn('\n[Module Federation] Detected alias conflicts with shared modules:'); + conflicts.forEach(({ sharedModule, alias, target }) => { + config.logger.warn( + ` - Shared module "${sharedModule}" is aliased by "${alias}" to "${target}"` + ); + }); + config.logger.warn( + " This may cause runtime errors as the shared module will bypass Module Federation's sharing mechanism." + ); + } + }, + }; +}