diff --git a/.changeset/fair-eels-grab.md b/.changeset/fair-eels-grab.md new file mode 100644 index 00000000000..a29bc3bbfaf --- /dev/null +++ b/.changeset/fair-eels-grab.md @@ -0,0 +1,5 @@ +--- +'@module-federation/metro': patch +--- + +fix Metro Windows compatibility by normalizing path handling and source URL generation across absolute and relative entry paths, and tighten expose key resolution to avoid incorrect extension fallback matches. diff --git a/.github/workflows/e2e-metro.yml b/.github/workflows/e2e-metro.yml index 87a40ef5d5f..1e122369f37 100644 --- a/.github/workflows/e2e-metro.yml +++ b/.github/workflows/e2e-metro.yml @@ -155,6 +155,40 @@ jobs: name: maestro-logs-android-${{ env.METRO_APP_NAME }} path: ~/.maestro/tests/ + e2e-metro-windows: + needs: e2e-metro-check-affected + if: needs.e2e-metro-check-affected.outputs.should_run_e2e == 'true' + runs-on: windows-latest + timeout-minutes: 30 + steps: + - name: Check out repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + + - name: Set up Node.js 20 + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'pnpm' + cache-dependency-path: '**/pnpm-lock.yaml' + + - name: Export SKIP_DEVTOOLS_POSTINSTALL + run: echo "SKIP_DEVTOOLS_POSTINSTALL=true" >> "$GITHUB_ENV" + shell: bash + + - name: Install pnpm dependencies + run: pnpm install --frozen-lockfile + + - name: Prepare Metro iOS E2E remotes on Windows + run: pnpm --filter example-host run e2e:prepare:ios + + - name: Prepare Metro Android E2E remotes on Windows + run: pnpm --filter example-host run e2e:prepare:android + e2e-metro-ios: needs: e2e-metro-check-affected if: needs.e2e-metro-check-affected.outputs.should_run_e2e == 'true' diff --git a/apps/metro-example-host/jest.config.js b/apps/metro-example-host/jest.config.js index 8eb675e9bc6..e364b29b01a 100644 --- a/apps/metro-example-host/jest.config.js +++ b/apps/metro-example-host/jest.config.js @@ -1,3 +1,9 @@ module.exports = { preset: 'react-native', + transformIgnorePatterns: [ + 'node_modules[\\\\/](?!.*(react-native|@react-native)[\\\\/])', + ], + transform: { + '^.+\\.(js|jsx|ts|tsx)$': ['babel-jest', {configFile: './babel.config.js'}], + }, }; diff --git a/apps/metro-example-mini/jest.config.js b/apps/metro-example-mini/jest.config.js index 8eb675e9bc6..e364b29b01a 100644 --- a/apps/metro-example-mini/jest.config.js +++ b/apps/metro-example-mini/jest.config.js @@ -1,3 +1,9 @@ module.exports = { preset: 'react-native', + transformIgnorePatterns: [ + 'node_modules[\\\\/](?!.*(react-native|@react-native)[\\\\/])', + ], + transform: { + '^.+\\.(js|jsx|ts|tsx)$': ['babel-jest', {configFile: './babel.config.js'}], + }, }; diff --git a/apps/metro-example-nested-mini/jest.config.js b/apps/metro-example-nested-mini/jest.config.js index 8eb675e9bc6..e364b29b01a 100644 --- a/apps/metro-example-nested-mini/jest.config.js +++ b/apps/metro-example-nested-mini/jest.config.js @@ -1,3 +1,9 @@ module.exports = { preset: 'react-native', + transformIgnorePatterns: [ + 'node_modules[\\\\/](?!.*(react-native|@react-native)[\\\\/])', + ], + transform: { + '^.+\\.(js|jsx|ts|tsx)$': ['babel-jest', {configFile: './babel.config.js'}], + }, }; diff --git a/packages/metro-core/__tests__/commands/utils/path-utils.spec.ts b/packages/metro-core/__tests__/commands/utils/path-utils.spec.ts new file mode 100644 index 00000000000..76bd46ac1d0 --- /dev/null +++ b/packages/metro-core/__tests__/commands/utils/path-utils.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { + normalizeOutputRelativePath, + toFileSourceUrl, +} from '../../../src/commands/utils/path-utils'; + +describe('commands path utils', () => { + it('normalizes relative output paths to posix separators', () => { + expect(normalizeOutputRelativePath('shared\\lodash.bundle')).toBe( + 'shared/lodash.bundle', + ); + }); + + it('builds file urls from normalized relative paths', () => { + const parsed = new URL(toFileSourceUrl('exposed\\info.bundle')); + expect(parsed.protocol).toBe('file:'); + expect(parsed.search).toBe(''); + expect(parsed.hash).toBe(''); + expect(parsed.pathname.endsWith('/exposed/info.bundle')).toBe(true); + }); + + it('encodes reserved characters as pathname segments', () => { + const hashParsed = new URL(toFileSourceUrl('shared/#hash.bundle')); + expect(hashParsed.hash).toBe(''); + expect(hashParsed.pathname.endsWith('/shared/%23hash.bundle')).toBe(true); + + const queryParsed = new URL(toFileSourceUrl('shared/?query.bundle')); + expect(queryParsed.search).toBe(''); + expect(queryParsed.pathname.endsWith('/shared/%3Fquery.bundle')).toBe(true); + + const percentParsed = new URL(toFileSourceUrl('shared/%25literal.bundle')); + expect(percentParsed.pathname.endsWith('/shared/%2525literal.bundle')).toBe( + true, + ); + }); +}); diff --git a/packages/metro-core/__tests__/plugin/babel-transformer.spec.ts b/packages/metro-core/__tests__/plugin/babel-transformer.spec.ts new file mode 100644 index 00000000000..3cb19ef00e8 --- /dev/null +++ b/packages/metro-core/__tests__/plugin/babel-transformer.spec.ts @@ -0,0 +1,62 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { vol } from 'memfs'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createBabelTransformer } from '../../src/plugin/babel-transformer'; +import type { ModuleFederationConfigNormalized } from '../../src/types'; + +function createConfig(): ModuleFederationConfigNormalized { + return { + name: 'test-app', + filename: 'remote.js', + remotes: {}, + exposes: {}, + shared: {}, + shareStrategy: 'loaded-first', + plugins: [], + }; +} + +describe('createBabelTransformer', () => { + afterEach(() => { + vol.reset(); + vi.restoreAllMocks(); + }); + + it('escapes Windows paths for require()', () => { + const realReadFileSync = fs.readFileSync.bind(fs); + vi.spyOn(fs, 'readFileSync').mockImplementation(((filePath, options) => { + const targetPath = filePath.toString(); + if (vol.existsSync(targetPath)) { + return vol.readFileSync(targetPath, options as never); + } + return realReadFileSync(filePath, options as never); + }) as typeof fs.readFileSync); + vi.spyOn(fs, 'writeFileSync').mockImplementation((( + filePath, + data, + options, + ) => { + const targetPath = filePath.toString(); + vol.mkdirSync(path.dirname(targetPath), { recursive: true }); + vol.writeFileSync(targetPath, data, options as never); + }) as typeof fs.writeFileSync); + + const tmpDirPath = path.join('/virtual', '.mf'); + vol.mkdirSync(tmpDirPath, { recursive: true }); + const windowsPath = + 'C:\\Users\\someone\\project\\node_modules\\metro-babel-transformer\\src\\index.js'; + + const outputPath = createBabelTransformer({ + blacklistedPaths: [], + federationConfig: createConfig(), + originalBabelTransformerPath: windowsPath, + tmpDirPath, + enableInitializeCorePatching: false, + enableRuntimeRequirePatching: false, + }); + + const output = fs.readFileSync(outputPath, 'utf-8'); + expect(output).toContain(`require(${JSON.stringify(windowsPath)})`); + }); +}); diff --git a/packages/metro-core/__tests__/plugin/helpers.spec.ts b/packages/metro-core/__tests__/plugin/helpers.spec.ts new file mode 100644 index 00000000000..1902550789d --- /dev/null +++ b/packages/metro-core/__tests__/plugin/helpers.spec.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { toPosixPath } from '../../src/plugin/helpers'; + +describe('toPosixPath', () => { + it('converts backslashes to forward slashes', () => { + expect(toPosixPath('C:\\Users\\someone\\project\\src\\index.js')).toBe( + 'C:/Users/someone/project/src/index.js', + ); + }); + + it('leaves posix paths unchanged', () => { + expect(toPosixPath('/usr/local/bin')).toBe('/usr/local/bin'); + }); +}); diff --git a/packages/metro-core/__tests__/plugin/normalize-options.spec.ts b/packages/metro-core/__tests__/plugin/normalize-options.spec.ts index 2126d7582ac..e8540b1b1c1 100644 --- a/packages/metro-core/__tests__/plugin/normalize-options.spec.ts +++ b/packages/metro-core/__tests__/plugin/normalize-options.spec.ts @@ -7,6 +7,7 @@ vi.mock('node:fs', () => { return { ...memfs, default: memfs }; }); +import { toPosixPath } from '../../src/plugin/helpers'; import { normalizeOptions } from '../../src/plugin/normalize-options'; let projectCount = 0; @@ -69,8 +70,8 @@ describe('normalizeOptions', () => { '../../src/modules/metroCorePlugin.ts', ); expect(normalized.plugins).toEqual([ - path.relative(tmpDirPath, metroCorePluginPath), - path.relative(tmpDirPath, runtimePluginPath), + toPosixPath(path.relative(tmpDirPath, metroCorePluginPath)), + toPosixPath(path.relative(tmpDirPath, runtimePluginPath)), ]); }); @@ -104,9 +105,9 @@ describe('normalizeOptions', () => { '../../src/modules/metroCorePlugin.ts', ); expect(normalized.plugins).toEqual([ - path.relative(tmpDirPath, metroCorePluginPath), - path.relative(tmpDirPath, runtimePluginPath), - path.relative(tmpDirPath, runtimePluginTwoPath), + toPosixPath(path.relative(tmpDirPath, metroCorePluginPath)), + toPosixPath(path.relative(tmpDirPath, runtimePluginPath)), + toPosixPath(path.relative(tmpDirPath, runtimePluginTwoPath)), ]); }); }); diff --git a/packages/metro-core/__tests__/plugin/rewrite-request.spec.ts b/packages/metro-core/__tests__/plugin/rewrite-request.spec.ts new file mode 100644 index 00000000000..804b43ea1b5 --- /dev/null +++ b/packages/metro-core/__tests__/plugin/rewrite-request.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { createRewriteRequest } from '../../src/plugin/rewrite-request'; + +describe('createRewriteRequest', () => { + it('normalizes manifest rewrite paths to posix separators', () => { + const rewriteRequest = createRewriteRequest({ + config: { + projectRoot: 'C:\\repo\\app', + server: {}, + } as any, + originalEntryFilename: 'index.js', + remoteEntryFilename: 'mini.bundle', + manifestPath: 'C:\\repo\\app\\node_modules\\.mf\\mf-manifest.json', + tmpDirPath: 'C:\\repo\\app\\node_modules\\.mf', + }); + + expect(rewriteRequest('/mf-manifest.json')).toBe( + '/[metro-project]/node_modules/.mf/mf-manifest.json', + ); + }); +}); diff --git a/packages/metro-core/__tests__/plugin/serializer.spec.ts b/packages/metro-core/__tests__/plugin/serializer.spec.ts new file mode 100644 index 00000000000..287aa0d1dbf --- /dev/null +++ b/packages/metro-core/__tests__/plugin/serializer.spec.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ConfigError } from '../../src/utils/errors'; + +vi.mock('../../src/utils/metro-compat', () => { + const baseJSBundle = vi.fn(() => ({ mocked: true })); + + return { + CountingSet: class CountingSet extends Set {}, + baseJSBundle, + bundleToString: vi.fn(() => ({ code: 'serialized-output' })), + }; +}); + +import { getModuleFederationSerializer } from '../../src/plugin/serializer'; +import { baseJSBundle } from '../../src/utils/metro-compat'; + +function createSerializer(exposes: Record) { + return getModuleFederationSerializer( + { + name: 'MFExampleMini', + filename: 'mini.bundle', + remotes: {}, + exposes, + shared: {}, + shareStrategy: 'loaded-first', + plugins: [], + }, + true, + ); +} + +function createSerializerOptions(projectRoot = '/projectRoot') { + return { + runModule: false, + modulesOnly: true, + projectRoot, + } as any; +} + +function createGraph() { + return { + dependencies: new Map(), + } as any; +} + +describe('getModuleFederationSerializer', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('matches expose paths when the entry path contains backslashes', async () => { + const serializer = createSerializer({ './info': './src/info.tsx' }); + + await expect( + serializer( + '/projectRoot/src\\info.tsx', + [], + createGraph(), + createSerializerOptions(), + ), + ).resolves.toBe('serialized-output'); + expect(baseJSBundle).toHaveBeenCalledTimes(1); + }); + + it('matches expose paths without extension against resolved entry files', async () => { + const serializer = createSerializer({ './info': './src/info' }); + + await expect( + serializer( + '/projectRoot/src/info.tsx', + [], + createGraph(), + createSerializerOptions(), + ), + ).resolves.toBe('serialized-output'); + expect(baseJSBundle).toHaveBeenCalledTimes(1); + }); + + it('prefers exact expose path match over extensionless fallback', async () => { + const serializer = createSerializer({ + './js': './src/info.js', + './tsx': './src/info.tsx', + }); + + await expect( + serializer( + '/projectRoot/src/info.tsx', + [], + createGraph(), + createSerializerOptions(), + ), + ).resolves.toBe('serialized-output'); + expect(baseJSBundle).toHaveBeenCalledTimes(1); + + const preModules = vi.mocked(baseJSBundle).mock.calls[0][1] as any[]; + expect(preModules[0].output[0].data.code).toContain('["exposed/tsx"]'); + expect(preModules[1].output[0].data.code).toContain('["exposed/tsx"]'); + }); + + it('throws a config error when no expose entry matches', async () => { + const serializer = createSerializer({ './other': './src/other.tsx' }); + + await expect( + serializer( + '/projectRoot/src/info.tsx', + [], + createGraph(), + createSerializerOptions(), + ), + ).rejects.toBeInstanceOf(ConfigError); + }); +}); diff --git a/packages/metro-core/__tests__/plugin/validate-options.spec.ts b/packages/metro-core/__tests__/plugin/validate-options.spec.ts index fc329920b8c..f3bce196a36 100644 --- a/packages/metro-core/__tests__/plugin/validate-options.spec.ts +++ b/packages/metro-core/__tests__/plugin/validate-options.spec.ts @@ -115,4 +115,55 @@ describe('validateOptions', () => { } as any), ).toThrow('shared'); }); + + it('throws for windows-style relative shared module names', () => { + expect(() => + validateOptions({ + ...getValidConfig(), + shared: { + ...getValidConfig().shared, + '.\\local-shared': { + singleton: false, + eager: false, + version: '1.0.0', + requiredVersion: '1.0.0', + }, + }, + } as any), + ).toThrow('Relative paths are not supported'); + }); + + it('throws for windows-style absolute shared module names', () => { + expect(() => + validateOptions({ + ...getValidConfig(), + shared: { + ...getValidConfig().shared, + 'C:\\project\\shared\\module': { + singleton: false, + eager: false, + version: '1.0.0', + requiredVersion: '1.0.0', + }, + }, + } as any), + ).toThrow('Absolute paths are not supported'); + }); + + it('throws for UNC absolute shared module names', () => { + expect(() => + validateOptions({ + ...getValidConfig(), + shared: { + ...getValidConfig().shared, + '\\\\server\\share\\module': { + singleton: false, + eager: false, + version: '1.0.0', + requiredVersion: '1.0.0', + }, + }, + } as any), + ).toThrow('Absolute paths are not supported'); + }); }); diff --git a/packages/metro-core/__tests__/plugin/with-module-federation.spec.ts b/packages/metro-core/__tests__/plugin/with-module-federation.spec.ts index 800f8d683d3..744ba3722f1 100644 --- a/packages/metro-core/__tests__/plugin/with-module-federation.spec.ts +++ b/packages/metro-core/__tests__/plugin/with-module-federation.spec.ts @@ -14,6 +14,7 @@ vi.mock('../../src/plugin/babel-transformer', () => ({ ), })); +import { toPosixPath } from '../../src/plugin/helpers'; import { withModuleFederation } from '../../src/plugin'; let projectCount = 0; @@ -101,7 +102,7 @@ describe('withModuleFederation', () => { const normalized = (global as any).__METRO_FEDERATION_CONFIG; const tmpDirPath = path.join(projectRoot, 'node_modules', '.mf'); expect(normalized.plugins).toContain( - path.relative(tmpDirPath, runtimePluginPath), + toPosixPath(path.relative(tmpDirPath, runtimePluginPath)), ); }); diff --git a/packages/metro-core/src/babel/transformer.js b/packages/metro-core/src/babel/transformer.js index bff4e037199..497370ba9f3 100644 --- a/packages/metro-core/src/babel/transformer.js +++ b/packages/metro-core/src/babel/transformer.js @@ -1,5 +1,5 @@ /* eslint-disable no-undef */ -const babelTransformer = require('__BABEL_TRANSFORMER_PATH__'); +const babelTransformer = require(__BABEL_TRANSFORMER_PATH__); const babelPlugins = __BABEL_PLUGINS__; /* eslint-enable no-undef */ diff --git a/packages/metro-core/src/commands/bundle-host/index.ts b/packages/metro-core/src/commands/bundle-host/index.ts index b9d76dcc690..d9db28d84e2 100644 --- a/packages/metro-core/src/commands/bundle-host/index.ts +++ b/packages/metro-core/src/commands/bundle-host/index.ts @@ -9,6 +9,7 @@ import { createResolver } from '../utils/create-resolver'; import { getCommunityCliPlugin } from '../utils/get-community-plugin'; import loadMetroConfig from '../utils/load-metro-config'; import { saveBundleAndMap } from '../utils/save-bundle-and-map'; +import { toPosixPath } from '../../plugin/helpers'; import type { BundleFederatedHostArgs } from './types'; declare global { @@ -64,9 +65,12 @@ async function bundleFederatedHost( config.server.enhanceMiddleware(server.processRequest, server); const resolver = await createResolver(server, args.platform); // hack: resolve the host entry to register it as a virtual module + const relativeHostEntryPath = toPosixPath( + path.relative(config.projectRoot, hostEntryFilepath), + ); resolver.resolve({ from: config.projectRoot, - to: `./${path.relative(config.projectRoot, hostEntryFilepath)}`, + to: `./${relativeHostEntryPath}`, }); return server.build({ diff --git a/packages/metro-core/src/commands/bundle-remote/index.ts b/packages/metro-core/src/commands/bundle-remote/index.ts index 8a43859324f..084b35d4018 100644 --- a/packages/metro-core/src/commands/bundle-remote/index.ts +++ b/packages/metro-core/src/commands/bundle-remote/index.ts @@ -1,6 +1,5 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import { pathToFileURL } from 'node:url'; import util from 'node:util'; import { mergeConfig } from 'metro'; import type { ModuleFederationConfigNormalized } from '../../types'; @@ -11,7 +10,12 @@ import type { Config } from '../types'; import { createModulePathRemapper } from '../utils/create-module-path-remapper'; import { createResolver } from '../utils/create-resolver'; import loadMetroConfig from '../utils/load-metro-config'; +import { + normalizeOutputRelativePath, + toFileSourceUrl, +} from '../utils/path-utils'; import { saveBundleAndMap } from '../utils/save-bundle-and-map'; +import { toPosixPath } from '../../plugin/helpers'; import type { BundleFederatedRemoteArgs } from './types'; @@ -218,9 +222,12 @@ async function bundleFederatedRemote( }; // hack: resolve the container entry to register it as a virtual module + const relativeContainerEntryPath = toPosixPath( + path.relative(config.projectRoot, containerEntryFilepath), + ); resolver.resolve({ from: config.projectRoot, - to: `./${path.relative(config.projectRoot, containerEntryFilepath)}`, + to: `./${relativeContainerEntryPath}`, }); const exposedModules = Object.entries(federationConfig.exposes) @@ -273,26 +280,26 @@ async function bundleFederatedRemote( moduleOutputDir, moduleBundleName, ); + const relativeModuleBundlePath = normalizeOutputRelativePath( + path.relative(outputDir, moduleBundleFilepath), + ); // Metro requires `sourceURL` to be defined when doing bundle splitting // we use relative path and supply it in fileURL format to avoid issues - const moduleBundleUrl = pathToFileURL( - '/' + path.relative(outputDir, moduleBundleFilepath), - ).href; + const moduleBundleUrl = toFileSourceUrl(relativeModuleBundlePath); const moduleSourceMapName = `${moduleBundleName}.map`; const moduleSourceMapFilepath = path.resolve( moduleOutputDir, moduleSourceMapName, ); // use relative path just like when bundling `index.bundle` - const moduleSourceMapUrl = path.relative( - outputDir, - moduleSourceMapFilepath, + const moduleSourceMapUrl = normalizeOutputRelativePath( + path.relative(outputDir, moduleSourceMapFilepath), ); if (!isContainerModule) { modulePathRemapper.addMapping( moduleInputFilepath, - path.relative(outputDir, moduleBundleFilepath), + relativeModuleBundlePath, ); } diff --git a/packages/metro-core/src/commands/utils/path-utils.ts b/packages/metro-core/src/commands/utils/path-utils.ts new file mode 100644 index 00000000000..eb568b87606 --- /dev/null +++ b/packages/metro-core/src/commands/utils/path-utils.ts @@ -0,0 +1,11 @@ +import { pathToFileURL } from 'node:url'; +import { toPosixPath } from '../../plugin/helpers'; + +export function normalizeOutputRelativePath(relativePath: string) { + return toPosixPath(relativePath); +} + +export function toFileSourceUrl(relativePath: string) { + const normalizedRelativePath = normalizeOutputRelativePath(relativePath); + return pathToFileURL(`/${normalizedRelativePath}`).href; +} diff --git a/packages/metro-core/src/plugin/babel-transformer.ts b/packages/metro-core/src/plugin/babel-transformer.ts index ecd1723b53c..72a86b0e573 100644 --- a/packages/metro-core/src/plugin/babel-transformer.ts +++ b/packages/metro-core/src/plugin/babel-transformer.ts @@ -41,7 +41,10 @@ export function createBabelTransformer({ ].filter(Boolean); const babelTransformer = transformerTemplate - .replaceAll('__BABEL_TRANSFORMER_PATH__', originalBabelTransformerPath) + .replaceAll( + '__BABEL_TRANSFORMER_PATH__', + JSON.stringify(originalBabelTransformerPath), + ) .replaceAll('__BABEL_PLUGINS__', JSON.stringify(plugins)); fs.writeFileSync(outputPath, babelTransformer, 'utf-8'); diff --git a/packages/metro-core/src/plugin/generators.ts b/packages/metro-core/src/plugin/generators.ts index f2e6a6b3b81..fc4c1be69d4 100644 --- a/packages/metro-core/src/plugin/generators.ts +++ b/packages/metro-core/src/plugin/generators.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import type { ModuleFederationConfigNormalized, ShareObject } from '../types'; +import { toPosixPath } from './helpers'; export function getRemoteModule(name: string) { const template = getModuleTemplate('remote-module.js'); @@ -12,9 +13,12 @@ export function getHostEntryModule( paths: { originalEntry: string; tmpDir: string }, ) { const template = getModuleTemplate('host-entry.js'); + const relativeEntryPath = toPosixPath( + path.relative(paths.tmpDir, paths.originalEntry), + ); return template.replaceAll( '__ENTRYPOINT_IMPORT__', - `import './${path.relative(paths.tmpDir, paths.originalEntry)}'`, + `import './${relativeEntryPath}'`, ); } @@ -61,7 +65,9 @@ function generateExposes( ) { const exposesString = Object.keys(exposes).map((key) => { const importPath = path.join(paths.projectDir, exposes[key]); - const relativeImportPath = path.relative(paths.tmpDir, importPath); + const relativeImportPath = toPosixPath( + path.relative(paths.tmpDir, importPath), + ); return `"${key}": async () => import("${relativeImportPath}")`; }); @@ -75,7 +81,8 @@ function generateRuntimePlugins(runtimePlugins: string[]) { runtimePlugins.forEach((plugin, index) => { const pluginName = `plugin${index}`; pluginNames.push(`${pluginName}()`); - pluginImports.push(`import ${pluginName} from "${plugin}";`); + const pluginPath = toPosixPath(plugin); + pluginImports.push(`import ${pluginName} from "${pluginPath}";`); }); const imports = pluginImports.join('\n'); diff --git a/packages/metro-core/src/plugin/helpers.ts b/packages/metro-core/src/plugin/helpers.ts index 46b3fdf405b..e6f80ea312f 100644 --- a/packages/metro-core/src/plugin/helpers.ts +++ b/packages/metro-core/src/plugin/helpers.ts @@ -40,3 +40,7 @@ export function prepareTmpDir(projectRootPath: string) { fs.mkdirSync(tmpDirPath, { recursive: true }); return tmpDirPath; } + +export function toPosixPath(value: string) { + return value.replaceAll('\\', '/'); +} diff --git a/packages/metro-core/src/plugin/normalize-options.ts b/packages/metro-core/src/plugin/normalize-options.ts index d69b47c5bfb..b076aa1fb69 100644 --- a/packages/metro-core/src/plugin/normalize-options.ts +++ b/packages/metro-core/src/plugin/normalize-options.ts @@ -6,6 +6,7 @@ import type { ShareObject, } from '../types'; import { DEFAULT_ENTRY_FILENAME } from './constants'; +import { toPosixPath } from './helpers'; interface ProjectConfig { projectRoot: string; @@ -118,7 +119,7 @@ function getNormalizedPlugins( // make paths relative to the tmp dir return deduplicatedPlugins.map((pluginPath) => - path.relative(tmpDirPath, pluginPath), + toPosixPath(path.relative(tmpDirPath, pluginPath)), ); } diff --git a/packages/metro-core/src/plugin/resolver.ts b/packages/metro-core/src/plugin/resolver.ts index e20abb3c97d..cf03a315332 100644 --- a/packages/metro-core/src/plugin/resolver.ts +++ b/packages/metro-core/src/plugin/resolver.ts @@ -18,7 +18,11 @@ import { getRemoteModule, getRemoteModuleRegistryModule, } from './generators'; -import { isUsingMFBundleCommand, removeExtension } from './helpers'; +import { + isUsingMFBundleCommand, + removeExtension, + toPosixPath, +} from './helpers'; interface CreateResolveRequestOptions { isRemote: boolean; @@ -231,6 +235,6 @@ function getEntryPathRegex(paths: { projectDir: string; }): RegExp { const relativeEntryPath = path.relative(paths.projectDir, paths.entry); - const entryName = removeExtension(relativeEntryPath); + const entryName = toPosixPath(removeExtension(relativeEntryPath)); return new RegExp(`^\\./${entryName}(\\.js)?$`); } diff --git a/packages/metro-core/src/plugin/rewrite-request.ts b/packages/metro-core/src/plugin/rewrite-request.ts index 7da5aec9e78..f365a4db200 100644 --- a/packages/metro-core/src/plugin/rewrite-request.ts +++ b/packages/metro-core/src/plugin/rewrite-request.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import type { ConfigT } from 'metro-config'; import { MANIFEST_FILENAME } from './constants'; -import { removeExtension } from './helpers'; +import { removeExtension, toPosixPath } from './helpers'; type CreateRewriteRequestOptions = { config: ConfigT; @@ -42,7 +42,7 @@ export function createRewriteRequest({ } // rewrite /mf-manifest.json -> /[metro-project]/node_modules/.mf-metro/mf-manifest.json if (pathname.startsWith(`/${MANIFEST_FILENAME}`)) { - const target = manifestPath.replace(root, '[metro-project]'); + const target = toPosixPath(manifestPath.replace(root, '[metro-project]')); return url.replace(MANIFEST_FILENAME, target); } // pass through to original rewriteRequestUrl diff --git a/packages/metro-core/src/plugin/serializer.ts b/packages/metro-core/src/plugin/serializer.ts index 5eed40ba573..12eea62d9ae 100644 --- a/packages/metro-core/src/plugin/serializer.ts +++ b/packages/metro-core/src/plugin/serializer.ts @@ -3,6 +3,7 @@ import type { Module, ReadOnlyGraph, SerializerOptions } from 'metro'; import type { SerializerConfigT } from 'metro-config'; import type { ModuleFederationConfigNormalized, ShareObject } from '../types'; import { ConfigError } from '../utils/errors'; +import { toPosixPath } from './helpers'; import { CountingSet, baseJSBundle, @@ -173,15 +174,32 @@ function getBundlePath( isUsingMFBundleCommand: boolean, ) { const relativeEntryPath = path.relative(projectRoot, entryPoint); + const normalizedRelativeEntryPath = normalizeEntryPath(relativeEntryPath); if (!isUsingMFBundleCommand) { const { dir, name } = path.parse(relativeEntryPath); return path.format({ dir, name, ext: '' }); } // try to match with an exposed module first - const exposedMatchedKey = Object.keys(exposes).find((exposeKey) => - exposes[exposeKey].match(relativeEntryPath), + const exposeEntries = Object.entries(exposes).map( + ([exposeKey, exposePath]) => { + return { + exposeKey, + normalizedExposePath: normalizeEntryPath(exposePath), + }; + }, ); + const exactMatch = exposeEntries.find(({ normalizedExposePath }) => { + return normalizedExposePath === normalizedRelativeEntryPath; + }); + const extensionlessMatch = exposeEntries.find(({ normalizedExposePath }) => { + return ( + removePathExtension(normalizedExposePath) === + removePathExtension(normalizedRelativeEntryPath) + ); + }); + const exposedMatchedKey = + exactMatch?.exposeKey ?? extensionlessMatch?.exposeKey; if (exposedMatchedKey) { // handle as exposed module @@ -202,6 +220,16 @@ function getBundlePath( ); } +function normalizeEntryPath(value: string) { + const normalized = toPosixPath(path.normalize(value)); + return normalized.startsWith('./') ? normalized.slice(2) : normalized; +} + +function removePathExtension(value: string) { + const parsed = path.posix.parse(value); + return path.posix.format({ dir: parsed.dir, name: parsed.name, ext: '' }); +} + function getBundleCode( entryPoint: string, preModules: readonly Module[], diff --git a/packages/metro-core/src/plugin/validate-options.ts b/packages/metro-core/src/plugin/validate-options.ts index f4d124307b3..4192e6cd93d 100644 --- a/packages/metro-core/src/plugin/validate-options.ts +++ b/packages/metro-core/src/plugin/validate-options.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import type { ModuleFederationConfig, ShareObject } from '../types'; import logger from '../logger'; import { ConfigError } from '../utils'; @@ -99,14 +100,14 @@ function validateShared(shared: ModuleFederationConfig['shared']) { const sharedConfig = sharedObject[sharedName] as unknown; // disallow relative paths - if (sharedName.startsWith('./') || sharedName.startsWith('../')) { + if (isRelativePathLike(sharedName)) { throw new ConfigError( 'Relative paths are not supported as shared module names.', ); } // disallow absolute paths - if (sharedName.startsWith('/')) { + if (isAbsolutePathLike(sharedName)) { throw new ConfigError( 'Absolute paths are not supported as shared module names.', ); @@ -259,6 +260,14 @@ function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function isRelativePathLike(value: string): boolean { + return /^\.{1,2}[\\/]/.test(value); +} + +function isAbsolutePathLike(value: string): boolean { + return path.posix.isAbsolute(value) || path.win32.isAbsolute(value); +} + export function validateOptions(options: ModuleFederationConfig) { // warn for known but unsupported options validateUnsupportedTopLevelOptions(options); diff --git a/packages/metro-core/tsconfig.json b/packages/metro-core/tsconfig.json index c2e1f1b1166..a58257592ff 100644 --- a/packages/metro-core/tsconfig.json +++ b/packages/metro-core/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "../../tsconfig.base.json", - "include": ["src"], + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"], "compilerOptions": { "rootDir": "src", "jsx": "react-jsx",