Skip to content

Commit 5eef805

Browse files
fix(metro-core): normalize Windows path handling in Metro federation (#4453)
Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Zack Jackson <ScriptedAlchemy@users.noreply.github.com>
1 parent dab4f23 commit 5eef805

22 files changed

Lines changed: 386 additions & 133 deletions

.changeset/fair-eels-grab.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@module-federation/metro': patch
3+
---
4+
5+
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.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
normalizeOutputRelativePath,
4+
toFileSourceUrl,
5+
} from '../../../src/commands/utils/path-utils';
6+
7+
describe('commands path utils', () => {
8+
it('normalizes relative output paths to posix separators', () => {
9+
expect(normalizeOutputRelativePath('shared\\lodash.bundle')).toBe(
10+
'shared/lodash.bundle',
11+
);
12+
});
13+
14+
it('builds file urls from normalized relative paths', () => {
15+
const parsed = new URL(toFileSourceUrl('exposed\\info.bundle'));
16+
expect(parsed.protocol).toBe('file:');
17+
expect(parsed.search).toBe('');
18+
expect(parsed.hash).toBe('');
19+
expect(parsed.pathname.endsWith('/exposed/info.bundle')).toBe(true);
20+
});
21+
22+
it('encodes reserved characters as pathname segments', () => {
23+
const hashParsed = new URL(toFileSourceUrl('shared/#hash.bundle'));
24+
expect(hashParsed.hash).toBe('');
25+
expect(hashParsed.pathname.endsWith('/shared/%23hash.bundle')).toBe(true);
26+
27+
const queryParsed = new URL(toFileSourceUrl('shared/?query.bundle'));
28+
expect(queryParsed.search).toBe('');
29+
expect(queryParsed.pathname.endsWith('/shared/%3Fquery.bundle')).toBe(true);
30+
31+
const percentParsed = new URL(toFileSourceUrl('shared/%25literal.bundle'));
32+
expect(percentParsed.pathname.endsWith('/shared/%2525literal.bundle')).toBe(
33+
true,
34+
);
35+
});
36+
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { vol } from 'memfs';
4+
import { afterEach, describe, expect, it, vi } from 'vitest';
5+
import { createBabelTransformer } from '../../src/plugin/babel-transformer';
6+
import type { ModuleFederationConfigNormalized } from '../../src/types';
7+
8+
function createConfig(): ModuleFederationConfigNormalized {
9+
return {
10+
name: 'test-app',
11+
filename: 'remote.js',
12+
remotes: {},
13+
exposes: {},
14+
shared: {},
15+
shareStrategy: 'loaded-first',
16+
plugins: [],
17+
};
18+
}
19+
20+
describe('createBabelTransformer', () => {
21+
afterEach(() => {
22+
vol.reset();
23+
vi.restoreAllMocks();
24+
});
25+
26+
it('escapes Windows paths for require()', () => {
27+
const realReadFileSync = fs.readFileSync.bind(fs);
28+
vi.spyOn(fs, 'readFileSync').mockImplementation(((filePath, options) => {
29+
const targetPath = filePath.toString();
30+
if (vol.existsSync(targetPath)) {
31+
return vol.readFileSync(targetPath, options as never);
32+
}
33+
return realReadFileSync(filePath, options as never);
34+
}) as typeof fs.readFileSync);
35+
vi.spyOn(fs, 'writeFileSync').mockImplementation(((
36+
filePath,
37+
data,
38+
options,
39+
) => {
40+
const targetPath = filePath.toString();
41+
vol.mkdirSync(path.dirname(targetPath), { recursive: true });
42+
vol.writeFileSync(targetPath, data, options as never);
43+
}) as typeof fs.writeFileSync);
44+
45+
const tmpDirPath = path.join('/virtual', '.mf');
46+
vol.mkdirSync(tmpDirPath, { recursive: true });
47+
const windowsPath =
48+
'C:\\Users\\someone\\project\\node_modules\\metro-babel-transformer\\src\\index.js';
49+
50+
const outputPath = createBabelTransformer({
51+
blacklistedPaths: [],
52+
federationConfig: createConfig(),
53+
originalBabelTransformerPath: windowsPath,
54+
tmpDirPath,
55+
enableInitializeCorePatching: false,
56+
enableRuntimeRequirePatching: false,
57+
});
58+
59+
const output = fs.readFileSync(outputPath, 'utf-8');
60+
expect(output).toContain(`require(${JSON.stringify(windowsPath)})`);
61+
});
62+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { toPosixPath } from '../../src/plugin/helpers';
3+
4+
describe('toPosixPath', () => {
5+
it('converts backslashes to forward slashes', () => {
6+
expect(toPosixPath('C:\\Users\\someone\\project\\src\\index.js')).toBe(
7+
'C:/Users/someone/project/src/index.js',
8+
);
9+
});
10+
11+
it('leaves posix paths unchanged', () => {
12+
expect(toPosixPath('/usr/local/bin')).toBe('/usr/local/bin');
13+
});
14+
});

packages/metro-core/__tests__/plugin/normalize-options.spec.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ vi.mock('node:fs', () => {
77
return { ...memfs, default: memfs };
88
});
99

10+
import { toPosixPath } from '../../src/plugin/helpers';
1011
import { normalizeOptions } from '../../src/plugin/normalize-options';
1112

1213
let projectCount = 0;
@@ -88,8 +89,8 @@ describe('normalizeOptions', () => {
8889
'../../src/modules/metroCorePlugin.ts',
8990
);
9091
expect(normalized.plugins).toEqual([
91-
path.relative(tmpDirPath, metroCorePluginPath),
92-
path.relative(tmpDirPath, runtimePluginPath),
92+
toPosixPath(path.relative(tmpDirPath, metroCorePluginPath)),
93+
toPosixPath(path.relative(tmpDirPath, runtimePluginPath)),
9394
]);
9495
});
9596

@@ -200,9 +201,9 @@ describe('normalizeOptions', () => {
200201
'../../src/modules/metroCorePlugin.ts',
201202
);
202203
expect(normalized.plugins).toEqual([
203-
path.relative(tmpDirPath, metroCorePluginPath),
204-
path.relative(tmpDirPath, runtimePluginPath),
205-
path.relative(tmpDirPath, runtimePluginTwoPath),
204+
toPosixPath(path.relative(tmpDirPath, metroCorePluginPath)),
205+
toPosixPath(path.relative(tmpDirPath, runtimePluginPath)),
206+
toPosixPath(path.relative(tmpDirPath, runtimePluginTwoPath)),
206207
]);
207208
});
208209

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,21 @@
1-
import path from 'node:path';
21
import { describe, expect, it } from 'vitest';
32
import { createRewriteRequest } from '../../src/plugin/rewrite-request';
43

54
describe('createRewriteRequest', () => {
6-
it('rewrites dts asset requests to tmp dir when names are available', () => {
7-
const projectRoot = '/virtual/metro-core';
5+
it('normalizes manifest rewrite paths to posix separators', () => {
86
const rewriteRequest = createRewriteRequest({
97
config: {
10-
projectRoot,
8+
projectRoot: 'C:\\repo\\app',
119
server: {},
1210
} as any,
1311
originalEntryFilename: 'index.js',
14-
remoteEntryFilename: 'remoteEntry.js',
15-
manifestPath: path.join(
16-
projectRoot,
17-
'node_modules',
18-
'.mf-metro',
19-
'mf-manifest.json',
20-
),
21-
tmpDirPath: path.join(projectRoot, 'node_modules', '.mf-metro'),
22-
getDtsAssetNames: () => ({
23-
zipName: '@mf-types.zip',
24-
apiFileName: '@mf-types.d.ts',
25-
}),
12+
remoteEntryFilename: 'mini.bundle',
13+
manifestPath: 'C:\\repo\\app\\node_modules\\.mf\\mf-manifest.json',
14+
tmpDirPath: 'C:\\repo\\app\\node_modules\\.mf',
2615
});
2716

28-
expect(rewriteRequest('/@mf-types.zip')).toBe(
29-
'/node_modules/.mf-metro/@mf-types.zip',
30-
);
31-
expect(rewriteRequest('/@mf-types.d.ts')).toBe(
32-
'/node_modules/.mf-metro/@mf-types.d.ts',
17+
expect(rewriteRequest('/mf-manifest.json')).toBe(
18+
'/[metro-project]/node_modules/.mf/mf-manifest.json',
3319
);
3420
});
3521
});
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { ConfigError } from '../../src/utils/errors';
3+
4+
vi.mock('../../src/utils/metro-compat', () => {
5+
const baseJSBundle = vi.fn(() => ({ mocked: true }));
6+
7+
return {
8+
CountingSet: class CountingSet<T> extends Set<T> {},
9+
baseJSBundle,
10+
bundleToString: vi.fn(() => ({ code: 'serialized-output' })),
11+
};
12+
});
13+
14+
import { getModuleFederationSerializer } from '../../src/plugin/serializer';
15+
import { baseJSBundle } from '../../src/utils/metro-compat';
16+
17+
function createSerializer(exposes: Record<string, string>) {
18+
return getModuleFederationSerializer(
19+
{
20+
name: 'MFExampleMini',
21+
filename: 'mini.bundle',
22+
remotes: {},
23+
exposes,
24+
shared: {},
25+
shareStrategy: 'loaded-first',
26+
plugins: [],
27+
},
28+
true,
29+
);
30+
}
31+
32+
function createSerializerOptions(projectRoot = '/projectRoot') {
33+
return {
34+
runModule: false,
35+
modulesOnly: true,
36+
projectRoot,
37+
} as any;
38+
}
39+
40+
function createGraph() {
41+
return {
42+
dependencies: new Map(),
43+
} as any;
44+
}
45+
46+
describe('getModuleFederationSerializer', () => {
47+
beforeEach(() => {
48+
vi.clearAllMocks();
49+
});
50+
51+
it('matches expose paths when the entry path contains backslashes', async () => {
52+
const serializer = createSerializer({ './info': './src/info.tsx' });
53+
54+
await expect(
55+
serializer(
56+
'/projectRoot/src\\info.tsx',
57+
[],
58+
createGraph(),
59+
createSerializerOptions(),
60+
),
61+
).resolves.toBe('serialized-output');
62+
expect(baseJSBundle).toHaveBeenCalledTimes(1);
63+
});
64+
65+
it('matches expose paths without extension against resolved entry files', async () => {
66+
const serializer = createSerializer({ './info': './src/info' });
67+
68+
await expect(
69+
serializer(
70+
'/projectRoot/src/info.tsx',
71+
[],
72+
createGraph(),
73+
createSerializerOptions(),
74+
),
75+
).resolves.toBe('serialized-output');
76+
expect(baseJSBundle).toHaveBeenCalledTimes(1);
77+
});
78+
79+
it('prefers exact expose path match over extensionless fallback', async () => {
80+
const serializer = createSerializer({
81+
'./js': './src/info.js',
82+
'./tsx': './src/info.tsx',
83+
});
84+
85+
await expect(
86+
serializer(
87+
'/projectRoot/src/info.tsx',
88+
[],
89+
createGraph(),
90+
createSerializerOptions(),
91+
),
92+
).resolves.toBe('serialized-output');
93+
expect(baseJSBundle).toHaveBeenCalledTimes(1);
94+
95+
const preModules = vi.mocked(baseJSBundle).mock.calls[0][1] as any[];
96+
expect(preModules[0].output[0].data.code).toContain('["exposed/tsx"]');
97+
expect(preModules[1].output[0].data.code).toContain('["exposed/tsx"]');
98+
});
99+
100+
it('throws a config error when no expose entry matches', async () => {
101+
const serializer = createSerializer({ './other': './src/other.tsx' });
102+
103+
await expect(
104+
serializer(
105+
'/projectRoot/src/info.tsx',
106+
[],
107+
createGraph(),
108+
createSerializerOptions(),
109+
),
110+
).rejects.toBeInstanceOf(ConfigError);
111+
});
112+
});

packages/metro-core/__tests__/plugin/validate-options.spec.ts

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -116,28 +116,54 @@ describe('validateOptions', () => {
116116
).toThrow('shared');
117117
});
118118

119-
it('accepts dts as a boolean or object', () => {
119+
it('throws for windows-style relative shared module names', () => {
120120
expect(() =>
121121
validateOptions({
122122
...getValidConfig(),
123-
dts: true,
123+
shared: {
124+
...getValidConfig().shared,
125+
'.\\local-shared': {
126+
singleton: false,
127+
eager: false,
128+
version: '1.0.0',
129+
requiredVersion: '1.0.0',
130+
},
131+
},
124132
} as any),
125-
).not.toThrow();
133+
).toThrow('Relative paths are not supported');
134+
});
126135

136+
it('throws for windows-style absolute shared module names', () => {
127137
expect(() =>
128138
validateOptions({
129139
...getValidConfig(),
130-
dts: { generateAPITypes: true },
140+
shared: {
141+
...getValidConfig().shared,
142+
'C:\\project\\shared\\module': {
143+
singleton: false,
144+
eager: false,
145+
version: '1.0.0',
146+
requiredVersion: '1.0.0',
147+
},
148+
},
131149
} as any),
132-
).not.toThrow();
150+
).toThrow('Absolute paths are not supported');
133151
});
134152

135-
it('throws for invalid dts type', () => {
153+
it('throws for UNC absolute shared module names', () => {
136154
expect(() =>
137155
validateOptions({
138156
...getValidConfig(),
139-
dts: 1,
157+
shared: {
158+
...getValidConfig().shared,
159+
'\\\\server\\share\\module': {
160+
singleton: false,
161+
eager: false,
162+
version: '1.0.0',
163+
requiredVersion: '1.0.0',
164+
},
165+
},
140166
} as any),
141-
).toThrow("Option 'dts' must be a boolean or a plain object.");
167+
).toThrow('Absolute paths are not supported');
142168
});
143169
});

0 commit comments

Comments
 (0)