diff --git a/.changeset/fix-shared-required-version-protocols.md b/.changeset/fix-shared-required-version-protocols.md new file mode 100644 index 00000000000..5d6f234fbb5 --- /dev/null +++ b/.changeset/fix-shared-required-version-protocols.md @@ -0,0 +1,5 @@ +--- +'@module-federation/enhanced': patch +--- + +Fix shared `requiredVersion` auto-detection for package-manager protocol specifiers (`catalog:`, `workspace:*`, `npm:`, etc.) by falling back to the installed package version as a caret range. diff --git a/packages/enhanced/src/lib/sharing/ConsumeSharedPlugin.ts b/packages/enhanced/src/lib/sharing/ConsumeSharedPlugin.ts index b71e88a1a13..4dae45ed4f2 100644 --- a/packages/enhanced/src/lib/sharing/ConsumeSharedPlugin.ts +++ b/packages/enhanced/src/lib/sharing/ConsumeSharedPlugin.ts @@ -24,6 +24,7 @@ type ConsumeSharedPluginOptions = import { resolveMatchedConfigs } from './resolveMatchedConfigs'; import { getDescriptionFile, + getRawDependencyVersionFromDescriptionFile, getRequiredVersionFromDescriptionFile, } from './utils'; import type { @@ -274,10 +275,20 @@ class ConsumeSharedPlugin { // Package self-referencing return resolve(undefined); } + const rawVersion = getRawDependencyVersionFromDescriptionFile( + data, + packageName, + ); const requiredVersion = getRequiredVersionFromDescriptionFile( data, packageName, ); + // Listed dependency with a non-semver protocol (catalog:, workspace:*, …): + // signal with '' so the installed-package fallback runs below. + // Keep undefined for "not found" / self-ref / absolute-path skip paths. + if (rawVersion !== undefined && !requiredVersion) { + return resolve('' as SemVerRange); + } //TODO: align with webpck semver parser again // @ts-ignore webpack internal semver has some issue, use runtime semver , related issue: https://github.com/webpack/webpack/issues/17756 resolve(requiredVersion); @@ -285,172 +296,219 @@ class ConsumeSharedPlugin { (result) => { if (!result) return false; const { data } = result; - const maybeRequiredVersion = getRequiredVersionFromDescriptionFile( - data, - packageName, - ); + // Match description files that list the package even when the + // dependency string is a protocol specifier (catalog:, workspace:*, …) + // that does not normalize to a usable semver range. return ( data['name'] === packageName || - typeof maybeRequiredVersion === 'string' + getRawDependencyVersionFromDescriptionFile(data, packageName) !== + undefined ); }, ); }), ]).then(([importResolved, requiredVersion]) => { - const currentConfig = { - ...config, - importResolved, - import: importResolved ? config.import : undefined, - requiredVersion, - }; - const consumedModule = new ConsumeSharedModule( - directFallback ? compilation.compiler.context : context, - currentConfig, - ); - - // Check for include version first - if (config.include && typeof config.include.version === 'string') { + const resolveInstalledRequiredVersion = (): Promise< + false | undefined | SemVerRange + > => { + // User-provided requiredVersion (including false) must not be overridden. + if (config.requiredVersion !== undefined) { + return Promise.resolve(requiredVersion); + } + // Semver ranges from package.json are already usable. + if (requiredVersion) { + return Promise.resolve(requiredVersion); + } + // Only fall back for protocol / unusable listed deps (signaled as ''). + // Leave undefined alone (absolute paths, self-ref, missing package.json). + if (requiredVersion !== '') { + return Promise.resolve(undefined); + } if (!importResolved) { - return consumedModule; + return Promise.resolve(undefined); } - - return new Promise((resolveFilter) => { + return new Promise((resolveFallback) => { getDescriptionFile( compilation.inputFileSystem, path.dirname(importResolved as string), ['package.json'], (err, result) => { if (err) { - return resolveFilter(consumedModule); + return resolveFallback(undefined); } const { data } = result || {}; - if (!data || !data['version'] || data['name'] !== request) { - return resolveFilter(consumedModule); - } - - // Only include if version satisfies the include constraint if ( - config.include && - satisfy( - parseRange(config.include.version as string), - data['version'], - ) + !data || + typeof data['version'] !== 'string' || + !data['version'] ) { - // Validate singleton usage with include.version - if ( - config.include && - config.include.version && - config.singleton - ) { - addSingletonFilterWarning( - compilation, - config.shareKey || request, - 'include', - 'version', - config.include.version, - request, // moduleRequest - importResolved, // moduleResource (might be undefined) - ); - } - - return resolveFilter(consumedModule); + return resolveFallback(undefined); } + // Match ProvideSharedPlugin / SharedManager: caret-range from installed version. + resolveFallback(`^${data['version']}` as SemVerRange); + }, + ); + }); + }; - // Check fallback version - if ( - config.include && - typeof config.include.fallbackVersion === 'string' && - config.include.fallbackVersion - ) { + return resolveInstalledRequiredVersion().then((finalRequiredVersion) => { + const currentConfig = { + ...config, + importResolved, + import: importResolved ? config.import : undefined, + requiredVersion: finalRequiredVersion, + }; + const consumedModule = new ConsumeSharedModule( + directFallback ? compilation.compiler.context : context, + currentConfig, + ); + + // Check for include version first + if (config.include && typeof config.include.version === 'string') { + if (!importResolved) { + return consumedModule; + } + + return new Promise((resolveFilter) => { + getDescriptionFile( + compilation.inputFileSystem, + path.dirname(importResolved as string), + ['package.json'], + (err, result) => { + if (err) { + return resolveFilter(consumedModule); + } + const { data } = result || {}; + if (!data || !data['version'] || data['name'] !== request) { + return resolveFilter(consumedModule); + } + + // Only include if version satisfies the include constraint if ( + config.include && satisfy( parseRange(config.include.version as string), - config.include.fallbackVersion, + data['version'], ) ) { + // Validate singleton usage with include.version + if ( + config.include && + config.include.version && + config.singleton + ) { + addSingletonFilterWarning( + compilation, + config.shareKey || request, + 'include', + 'version', + config.include.version, + request, // moduleRequest + importResolved, // moduleResource (might be undefined) + ); + } + return resolveFilter(consumedModule); } + + // Check fallback version + if ( + config.include && + typeof config.include.fallbackVersion === 'string' && + config.include.fallbackVersion + ) { + if ( + satisfy( + parseRange(config.include.version as string), + config.include.fallbackVersion, + ) + ) { + return resolveFilter(consumedModule); + } + return resolveFilter( + undefined as unknown as ConsumeSharedModule, + ); + } + return resolveFilter( undefined as unknown as ConsumeSharedModule, ); - } - - return resolveFilter(undefined as unknown as ConsumeSharedModule); - }, - ); - }); - } - - // Check for exclude version (existing logic) - if (config.exclude && typeof config.exclude.version === 'string') { - if (!importResolved) { - return consumedModule; + }, + ); + }); } - if ( - config.exclude && - typeof config.exclude.fallbackVersion === 'string' && - config.exclude.fallbackVersion - ) { + // Check for exclude version (existing logic) + if (config.exclude && typeof config.exclude.version === 'string') { + if (!importResolved) { + return consumedModule; + } + if ( - satisfy( - parseRange(config.exclude.version), - config.exclude.fallbackVersion, - ) + config.exclude && + typeof config.exclude.fallbackVersion === 'string' && + config.exclude.fallbackVersion ) { - return undefined as unknown as ConsumeSharedModule; + if ( + satisfy( + parseRange(config.exclude.version), + config.exclude.fallbackVersion, + ) + ) { + return undefined as unknown as ConsumeSharedModule; + } + return consumedModule; } - return consumedModule; - } - return new Promise((resolveFilter) => { - getDescriptionFile( - compilation.inputFileSystem, - path.dirname(importResolved as string), - ['package.json'], - (err, result) => { - if (err) { - return resolveFilter(consumedModule); - } - const { data } = result || {}; - if (!data || !data['version'] || data['name'] !== request) { - return resolveFilter(consumedModule); - } + return new Promise((resolveFilter) => { + getDescriptionFile( + compilation.inputFileSystem, + path.dirname(importResolved as string), + ['package.json'], + (err, result) => { + if (err) { + return resolveFilter(consumedModule); + } + const { data } = result || {}; + if (!data || !data['version'] || data['name'] !== request) { + return resolveFilter(consumedModule); + } - if ( - config.exclude && - typeof config.exclude.version === 'string' && - satisfy(parseRange(config.exclude.version), data['version']) - ) { - return resolveFilter( - undefined as unknown as ConsumeSharedModule, - ); - } + if ( + config.exclude && + typeof config.exclude.version === 'string' && + satisfy(parseRange(config.exclude.version), data['version']) + ) { + return resolveFilter( + undefined as unknown as ConsumeSharedModule, + ); + } - // Validate singleton usage with exclude.version - if ( - config.exclude && - config.exclude.version && - config.singleton - ) { - addSingletonFilterWarning( - compilation, - config.shareKey || request, - 'exclude', - 'version', - config.exclude.version, - request, // moduleRequest - importResolved, // moduleResource (might be undefined) - ); - } + // Validate singleton usage with exclude.version + if ( + config.exclude && + config.exclude.version && + config.singleton + ) { + addSingletonFilterWarning( + compilation, + config.shareKey || request, + 'exclude', + 'version', + config.exclude.version, + request, // moduleRequest + importResolved, // moduleResource (might be undefined) + ); + } - return resolveFilter(consumedModule); - }, - ); - }); - } + return resolveFilter(consumedModule); + }, + ); + }); + } - return consumedModule; + return consumedModule; + }); }); } diff --git a/packages/enhanced/src/lib/sharing/utils.ts b/packages/enhanced/src/lib/sharing/utils.ts index 4c81d7b06ce..0976456ccc0 100644 --- a/packages/enhanced/src/lib/sharing/utils.ts +++ b/packages/enhanced/src/lib/sharing/utils.ts @@ -316,6 +316,15 @@ function getGitUrlVersion(gitUrl: string): string { return getVersionFromHash(gitUrl); } +/** + * Package-manager protocol dependency specifiers that are not semver ranges. + * Examples: catalog:, catalog:react, workspace:*, npm:pkg@1.0.0, patch:, file:, link:, portal: + * @see https://pnpm.io/catalogs + * @see https://docs.npmjs.com/cli/v10/configuring-npm/package-json#dependencies + */ +const RE_PACKAGE_MANAGER_PROTOCOL = + /^(catalog|workspace|npm|patch|file|link|portal):/i; + /** * @see https://docs.npmjs.com/cli/v7/configuring-npm/package-json#urls-as-dependencies * @param {string} versionDesc version to be normalized @@ -328,12 +337,58 @@ function normalizeVersion(versionDesc: string): string { return versionDesc; } + // Package manager protocols are not semver and are not git URLs. + // Return empty so callers can fall back to the installed package version. + if (RE_PACKAGE_MANAGER_PROTOCOL.test(versionDesc)) { + return ''; + } + // add handle for URL Dependencies return getGitUrlVersion(versionDesc.toLowerCase()); } export { normalizeVersion }; +/** + * Return the raw dependency version string from a description file, if listed. + * @param {Record} data - package.json data + * @param {string} packageName - dependency name + * @returns {string | undefined} + */ +export function getRawDependencyVersionFromDescriptionFile( + data: Record, + packageName: string, +): string | undefined { + if ( + data['optionalDependencies'] && + typeof data['optionalDependencies'] === 'object' && + packageName in data['optionalDependencies'] + ) { + return data['optionalDependencies'][packageName]; + } + if ( + data['dependencies'] && + typeof data['dependencies'] === 'object' && + packageName in data['dependencies'] + ) { + return data['dependencies'][packageName]; + } + if ( + data['peerDependencies'] && + typeof data['peerDependencies'] === 'object' && + packageName in data['peerDependencies'] + ) { + return data['peerDependencies'][packageName]; + } + if ( + data['devDependencies'] && + typeof data['devDependencies'] === 'object' && + packageName in data['devDependencies'] + ) { + return data['devDependencies'][packageName]; + } +} + /** @typedef {{ data: Record, path: string }} DescriptionFile */ interface DescriptionFile { @@ -427,34 +482,17 @@ export function getRequiredVersionFromDescriptionFile( data: Record, packageName: string, ): string | undefined | void { - if ( - data['optionalDependencies'] && - typeof data['optionalDependencies'] === 'object' && - packageName in data['optionalDependencies'] - ) { - return normalizeVersion(data['optionalDependencies'][packageName]); - } - if ( - data['dependencies'] && - typeof data['dependencies'] === 'object' && - packageName in data['dependencies'] - ) { - return normalizeVersion(data['dependencies'][packageName]); - } - if ( - data['peerDependencies'] && - typeof data['peerDependencies'] === 'object' && - packageName in data['peerDependencies'] - ) { - return normalizeVersion(data['peerDependencies'][packageName]); - } - if ( - data['devDependencies'] && - typeof data['devDependencies'] === 'object' && - packageName in data['devDependencies'] - ) { - return normalizeVersion(data['devDependencies'][packageName]); + const rawVersion = getRawDependencyVersionFromDescriptionFile( + data, + packageName, + ); + if (rawVersion === undefined) { + return; } + const normalized = normalizeVersion(rawVersion); + // Protocol specs (catalog:, workspace:*, …) and other non-semver values + // normalize to ''. Treat that as missing so callers can fall back. + return normalized || undefined; } export function normalizeConsumeShareOptions(consumeOptions: ConsumeOptions) { diff --git a/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.createConsumeSharedModule.test.ts b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.createConsumeSharedModule.test.ts index 987da1e0ad3..20ca2bd9c72 100644 --- a/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.createConsumeSharedModule.test.ts +++ b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.createConsumeSharedModule.test.ts @@ -310,6 +310,110 @@ describe('ConsumeSharedPlugin', () => { expect(result).toBeDefined(); // Should attempt to read package.json for version }); + + describe('protocol specifier requiredVersion fallback', () => { + const installedPath = '/resolved/node_modules/react/index.js'; + + const mockProtocolThenInstalled = ( + protocolSpecifier: string, + installedVersion: string, + ): DescriptionFileResolver => { + return (fs, dir, files, callback, satisfies) => { + // Installed package lookup (fallback / include-exclude) + if (dir.includes('node_modules/react')) { + callback(null, { + data: { name: 'react', version: installedVersion }, + path: '/resolved/node_modules/react/package.json', + }); + return; + } + // Consumer app package.json with protocol dependency + const data = { + name: 'my-app', + dependencies: { react: protocolSpecifier }, + }; + if (satisfies && !satisfies({ data, path: '/test/package.json' })) { + callback(null, undefined, []); + return; + } + callback(null, { + data, + path: '/test/package.json', + }); + }; + }; + + it.each([ + ['catalog:', '19.2.4'], + ['workspace:*', '18.3.1'], + ['npm:react@^19.0.0', '19.0.0'], + ] as const)( + 'resolves %s to ^%s from the installed package', + async (protocolSpecifier, installedVersion) => { + const config = createConsumeConfig({ + requiredVersion: undefined, + packageName: 'react', + import: 'react', + request: 'react', + shareKey: 'react', + }); + + resolveMock.mockImplementation(resolveToPath(installedPath)); + descriptionFileMock.mockImplementation( + mockProtocolThenInstalled(protocolSpecifier, installedVersion), + ); + + const result = await plugin.createConsumeSharedModule( + mockCompilation, + '/test/context', + 'react', + config, + ); + + expect(result).toBeDefined(); + expect(result.options.requiredVersion).toBe(`^${installedVersion}`); + expect(result.options.requiredVersion).not.toBe(protocolSpecifier); + expect(result.options.requiredVersion).not.toBe(''); + }, + ); + + it('keeps existing semver requiredVersion from package.json', async () => { + const config = createConsumeConfig({ + requiredVersion: undefined, + packageName: 'react', + import: 'react', + request: 'react', + shareKey: 'react', + }); + + resolveMock.mockImplementation(resolveToPath(installedPath)); + descriptionFileMock.mockImplementation((fs, dir, files, callback) => { + if (dir.includes('node_modules/react')) { + callback(null, { + data: { name: 'react', version: '19.2.4' }, + path: '/resolved/node_modules/react/package.json', + }); + return; + } + callback(null, { + data: { + name: 'my-app', + dependencies: { react: '^18.2.0' }, + }, + path: '/test/package.json', + }); + }); + + const result = await plugin.createConsumeSharedModule( + mockCompilation, + '/test/context', + 'react', + config, + ); + + expect(result.options.requiredVersion).toBe('^18.2.0'); + }); + }); }); }); }); diff --git a/packages/enhanced/test/unit/sharing/plugin-test-utils.ts b/packages/enhanced/test/unit/sharing/plugin-test-utils.ts index 5a3ab4a4152..84e475e27d8 100644 --- a/packages/enhanced/test/unit/sharing/plugin-test-utils.ts +++ b/packages/enhanced/test/unit/sharing/plugin-test-utils.ts @@ -244,41 +244,63 @@ const mockAddSingletonFilterWarning = rs const mockNormalizeVersion = (versionDesc: string): string => { versionDesc = (versionDesc && versionDesc.trim()) || ''; + // Mirror real normalizeVersion: keep semver-like strings, drop protocol specs. + if (/^([\d^=v<>~]|[*xX]$)/.test(versionDesc)) { + return versionDesc; + } + if (/^(catalog|workspace|npm|patch|file|link|portal):/i.test(versionDesc)) { + return ''; + } return versionDesc; }; -const mockGetRequiredVersionFromDescriptionFile = ( +const mockGetRawDependencyVersionFromDescriptionFile = ( data: Record, packageName: string, -): string | undefined | void => { +): string | undefined => { if ( data['optionalDependencies'] && typeof data['optionalDependencies'] === 'object' && packageName in data['optionalDependencies'] ) { - return mockNormalizeVersion(data['optionalDependencies'][packageName]); + return data['optionalDependencies'][packageName]; } if ( data['dependencies'] && typeof data['dependencies'] === 'object' && packageName in data['dependencies'] ) { - return mockNormalizeVersion(data['dependencies'][packageName]); + return data['dependencies'][packageName]; } if ( data['peerDependencies'] && typeof data['peerDependencies'] === 'object' && packageName in data['peerDependencies'] ) { - return mockNormalizeVersion(data['peerDependencies'][packageName]); + return data['peerDependencies'][packageName]; } if ( data['devDependencies'] && typeof data['devDependencies'] === 'object' && packageName in data['devDependencies'] ) { - return mockNormalizeVersion(data['devDependencies'][packageName]); + return data['devDependencies'][packageName]; + } +}; + +const mockGetRequiredVersionFromDescriptionFile = ( + data: Record, + packageName: string, +): string | undefined | void => { + const rawVersion = mockGetRawDependencyVersionFromDescriptionFile( + data, + packageName, + ); + if (rawVersion === undefined) { + return; } + const normalized = mockNormalizeVersion(rawVersion); + return normalized || undefined; }; const mockNormalizeConsumeShareOptions = (consumeOptions: any) => { @@ -312,6 +334,8 @@ rs.mock('../../../src/lib/sharing/utils.ts', () => ({ testRequestFilters: mockTestRequestFilters, addSingletonFilterWarning: mockAddSingletonFilterWarning, normalizeVersion: mockNormalizeVersion, + getRawDependencyVersionFromDescriptionFile: + mockGetRawDependencyVersionFromDescriptionFile, getRequiredVersionFromDescriptionFile: mockGetRequiredVersionFromDescriptionFile, normalizeConsumeShareOptions: mockNormalizeConsumeShareOptions, @@ -324,6 +348,8 @@ rs.mock('../../../src/lib/sharing/utils', () => ({ testRequestFilters: mockTestRequestFilters, addSingletonFilterWarning: mockAddSingletonFilterWarning, normalizeVersion: mockNormalizeVersion, + getRawDependencyVersionFromDescriptionFile: + mockGetRawDependencyVersionFromDescriptionFile, getRequiredVersionFromDescriptionFile: mockGetRequiredVersionFromDescriptionFile, normalizeConsumeShareOptions: mockNormalizeConsumeShareOptions, diff --git a/packages/enhanced/test/unit/sharing/requiredVersion-protocol.test.ts b/packages/enhanced/test/unit/sharing/requiredVersion-protocol.test.ts new file mode 100644 index 00000000000..92794c20b60 --- /dev/null +++ b/packages/enhanced/test/unit/sharing/requiredVersion-protocol.test.ts @@ -0,0 +1,90 @@ +/* + * @rstest-environment node + */ + +import { + normalizeVersion, + getRequiredVersionFromDescriptionFile, + getRawDependencyVersionFromDescriptionFile, +} from '../../../src/lib/sharing/utils'; + +describe('requiredVersion protocol specifier handling', () => { + describe('normalizeVersion', () => { + it('preserves semver ranges from package.json', () => { + expect(normalizeVersion('^18.2.0')).toBe('^18.2.0'); + expect(normalizeVersion('~1.2.3')).toBe('~1.2.3'); + expect(normalizeVersion('>=1.0.0')).toBe('>=1.0.0'); + expect(normalizeVersion('*')).toBe('*'); + expect(normalizeVersion('1.2.3')).toBe('1.2.3'); + }); + + it('returns empty string for package-manager protocol specifiers', () => { + expect(normalizeVersion('catalog:')).toBe(''); + expect(normalizeVersion('catalog:react')).toBe(''); + expect(normalizeVersion('workspace:*')).toBe(''); + expect(normalizeVersion('workspace:^1.0.0')).toBe(''); + expect(normalizeVersion('npm:react@^18.2.0')).toBe(''); + expect(normalizeVersion('npm:lodash@4')).toBe(''); + expect(normalizeVersion('patch:react@19.0.0#./patches/react.patch')).toBe( + '', + ); + expect(normalizeVersion('file:../local-pkg')).toBe(''); + expect(normalizeVersion('link:../local-pkg')).toBe(''); + expect(normalizeVersion('portal:../local-pkg')).toBe(''); + }); + + it('still extracts versions from git URL dependencies', () => { + // Existing normalizeVersion / getGitUrlVersion behavior for git hashes + const gitHash = normalizeVersion( + 'git+https://github.com/facebook/react.git#v18.2.0', + ); + expect(gitHash).not.toBe(''); + expect(gitHash).not.toBe('catalog:'); + }); + }); + + describe('getRequiredVersionFromDescriptionFile', () => { + it('returns semver ranges unchanged', () => { + expect( + getRequiredVersionFromDescriptionFile( + { dependencies: { react: '^18.2.0' } }, + 'react', + ), + ).toBe('^18.2.0'); + expect( + getRequiredVersionFromDescriptionFile( + { dependencies: { lodash: '*' } }, + 'lodash', + ), + ).toBe('*'); + }); + + it('returns undefined for protocol specifiers (not catalog:/workspace:*)', () => { + expect( + getRequiredVersionFromDescriptionFile( + { dependencies: { react: 'catalog:' } }, + 'react', + ), + ).toBeUndefined(); + expect( + getRequiredVersionFromDescriptionFile( + { dependencies: { react: 'workspace:*' } }, + 'react', + ), + ).toBeUndefined(); + expect( + getRequiredVersionFromDescriptionFile( + { dependencies: { lodash: 'npm:lodash@4.17.21' } }, + 'lodash', + ), + ).toBeUndefined(); + }); + + it('still exposes the raw protocol string via getRawDependencyVersionFromDescriptionFile', () => { + const data = { dependencies: { react: 'catalog:' } }; + expect(getRawDependencyVersionFromDescriptionFile(data, 'react')).toBe( + 'catalog:', + ); + }); + }); +}); diff --git a/packages/enhanced/test/unit/sharing/test-types.ts b/packages/enhanced/test/unit/sharing/test-types.ts index 5fb77dac11c..74196eb66dc 100644 --- a/packages/enhanced/test/unit/sharing/test-types.ts +++ b/packages/enhanced/test/unit/sharing/test-types.ts @@ -33,7 +33,12 @@ export type DescriptionFileResolver = ( callback: ( err: Error | null, result?: { data?: { name?: string; version?: string }; path?: string }, + checkedDescriptionFilePaths?: string[], ) => void, + satisfiesDescriptionFileData?: (data?: { + data?: Record; + path?: string; + }) => boolean, ) => void; export type ConsumeEntry = [