fix(module-federation): enhance remote entry handling with query parameters in paths (#30615)

<!-- Please make sure you have read the submission guidelines before
posting an PR -->
<!--
https://github.com/nrwl/nx/blob/master/CONTRIBUTING.md#-submitting-a-pr
-->

<!-- Please make sure that your commit message follows our format -->
<!-- Example: `fix(nx): must begin with lowercase` -->

<!-- If this is a particularly complex change or feature addition, you
can request a dedicated Nx release for this pull request branch. Mention
someone from the Nx team or the `@nrwl/nx-pipelines-reviewers` and they
will confirm if the PR warrants its own release for testing purposes,
and generate it for you if appropriate. -->

## Current Behavior
<!-- This is the behavior we have today -->
In Module Federation apps, when remotes are defined using URLs that
include query string or hash fragments (e.g. for cache busting), those
params are not preserved after the application is built.


## Expected Behavior
<!-- This is the behavior we should expect with the changes in this PR
-->
This PR ensures that query strings and hash fragments are preserved when
resolving or generating remote URLs.

## Related Issue(s)
<!-- Please link the issue being fixed so it gets closed when this is
merged. -->

Fixes #30602
This commit is contained in:
Nicholas Cunningham 2025-05-26 09:25:32 -06:00 committed by GitHub
parent 752d418f78
commit dfc8162db7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 173 additions and 41 deletions

View File

@ -5,6 +5,7 @@ import {
killPorts, killPorts,
killProcessAndPorts, killProcessAndPorts,
newProject, newProject,
readJson,
runCLIAsync, runCLIAsync,
runCommandUntil, runCommandUntil,
runE2ETests, runE2ETests,
@ -227,5 +228,63 @@ describe('React Rspack Module Federation', () => {
remotePort remotePort
); );
}, 500_000); }, 500_000);
it('should preserve remotes with query params in the path', async () => {
const shell = uniq('shell');
const remote1 = uniq('remote1');
runCLI(
`generate @nx/react:host apps/${shell} --name=${shell} --remotes=${remote1} --bundler=rspack --e2eTestRunner=none --style=css --no-interactive --skipFormat`
);
// Update the remote entry to include query params
updateFile(`apps/${shell}/module-federation.config.ts`, (content) =>
content.replace(
`"${remote1}"`,
`['${remote1}', 'http://localhost:4201/remoteEntry.js?param=value']`
)
);
runCLI(`run ${shell}:build:production`);
// Check the artifact in dist for the remote
const manifestJson = readJson(`dist/apps/${shell}/mf-manifest.json`);
const remoteEntry = manifestJson.remotes[0]; // There should be only one remote
expect(remoteEntry).toBeDefined();
expect(remoteEntry.entry).toContain(
'http://localhost:4201/remoteEntry.js?param=value'
);
expect(manifestJson.remotes).toMatchInlineSnapshot(`
[
{
"alias": "${remote1}",
"entry": "http://localhost:4201/remoteEntry.js?param=value",
"federationContainerName": "${remote1}",
"moduleName": "Module",
},
]
`);
// Update the remote entry to include new query params without remoteEntry.js
updateFile(`apps/${shell}/module-federation.config.ts`, (content) =>
content.replace(
'http://localhost:4201/remoteEntry.js?param=value',
'http://localhost:4201?param=newValue'
)
);
runCLI(`run ${shell}:build:production`);
// Check the artifact in dist for the remote
const manifestJsonUpdated = readJson(
`dist/apps/${shell}/mf-manifest.json`
);
const remoteEntryUpdated = manifestJsonUpdated.remotes[0]; // There should be only one remote
expect(remoteEntryUpdated).toBeDefined();
expect(remoteEntryUpdated.entry).toContain(
'http://localhost:4201/remoteEntry.js?param=newValue'
);
});
}); });
}); });

View File

@ -5,6 +5,7 @@ import {
killPorts, killPorts,
killProcessAndPorts, killProcessAndPorts,
newProject, newProject,
readJson,
runCLIAsync, runCLIAsync,
runCommandUntil, runCommandUntil,
runE2ETests, runE2ETests,
@ -187,6 +188,65 @@ describe('React Module Federation', () => {
} }
}, 500_000); }, 500_000);
it('should preserve remotes with query params in the path', async () => {
const shell = uniq('shell');
const remote1 = uniq('remote1');
runCLI(
`generate @nx/react:host apps/${shell} --name=${shell} --remotes=${remote1} --bundler=webpack --e2eTestRunner=none --style=css --no-interactive --skipFormat`
);
// Update the remote entry to include query params at the end with remoteEntry in path
updateFile(`apps/${shell}/webpack.config.prod.ts`, (content) =>
content.replace(
`'http://localhost:4201/'`,
`'http://localhost:4201/remoteEntry.js?param=value'`
)
);
runCLI(`run ${shell}:build:production`);
// Check the artifact in dist for the remote
const manifestJson = readJson(`dist/apps/${shell}/mf-manifest.json`);
const remoteEntry = manifestJson.remotes[0];
expect(remoteEntry).toBeDefined();
expect(remoteEntry.entry).toContain(
'http://localhost:4201/remoteEntry.js?param=value'
);
expect(manifestJson.remotes).toMatchInlineSnapshot(`
[
{
"alias": "${remote1}",
"entry": "http://localhost:4201/remoteEntry.js?param=value",
"federationContainerName": "${remote1}",
"moduleName": "Module",
},
]
`);
// Update the remote entry to include query params at the end without remoteEntry in path
updateFile(`apps/${shell}/webpack.config.prod.ts`, (content) =>
content.replace(
`'http://localhost:4201/remoteEntry.js?param=value'`,
`'http://localhost:4201?param=newValue'`
)
);
runCLI(`run ${shell}:build:production`);
// Check the artifact in dist for the remote
const manifestJsonUpdated = readJson(
`dist/apps/${shell}/mf-manifest.json`
);
const remoteEntryUpdated = manifestJsonUpdated.remotes[0]; // There should be only one remote
expect(remoteEntryUpdated).toBeDefined();
expect(remoteEntryUpdated.entry).toContain(
'http://localhost:4201/remoteEntry.js?param=newValue'
);
});
describe('ssr', () => { describe('ssr', () => {
it('should generate host and remote apps with ssr', async () => { it('should generate host and remote apps with ssr', async () => {
const shell = uniq('shell'); const shell = uniq('shell');

View File

@ -1,3 +1,5 @@
import { extname } from 'node:path';
export type ResolveRemoteUrlFunction = ( export type ResolveRemoteUrlFunction = (
remoteName: string remoteName: string
) => string | Promise<string>; ) => string | Promise<string>;
@ -131,13 +133,19 @@ async function loadRemoteContainer(remoteName: string) {
? remoteUrlDefinitions[remoteName] ? remoteUrlDefinitions[remoteName]
: await resolveRemoteUrl(remoteName); : await resolveRemoteUrl(remoteName);
let containerUrl = remoteUrl; const url = new URL(remoteUrl);
if (!remoteUrl.endsWith('.mjs') && !remoteUrl.endsWith('.js')) { const ext = extname(url.pathname);
containerUrl = `${remoteUrl}${
remoteUrl.endsWith('/') ? '' : '/' const needsRemoteEntry = !['.js', '.mjs', '.json'].includes(ext);
}remoteEntry.mjs`;
if (needsRemoteEntry) {
url.pathname = url.pathname.endsWith('/')
? `${url.pathname}remoteEntry.mjs`
: `${url.pathname}/remoteEntry.mjs`;
} }
const containerUrl = url.href;
const container = await loadModule(containerUrl); const container = await loadModule(containerUrl);
await container.init(__webpack_share_scopes__.default); await container.init(__webpack_share_scopes__.default);

View File

@ -45,33 +45,27 @@ function handleArrayRemote(
remoteEntryExt: 'js' | 'mjs', remoteEntryExt: 'js' | 'mjs',
isRemoteGlobal: boolean isRemoteGlobal: boolean
): string { ): string {
let [nxRemoteProjectName, remoteLocation] = remote; const [nxRemoteProjectName, remoteLocation] = remote;
const mfRemoteName = normalizeRemoteName(nxRemoteProjectName); const mfRemoteName = normalizeRemoteName(nxRemoteProjectName);
const remoteLocationExt = extname(remoteLocation);
// If remote location already has .js or .mjs extension // Remote string starts like "promise new Promise(...)" return as-is
if (['.js', '.mjs', '.json'].includes(remoteLocationExt)) { if (remoteLocation.startsWith('promise new Promise')) {
if (isRemoteGlobal && !remoteLocation.startsWith(`${mfRemoteName}@`)) {
return `${mfRemoteName}@${remoteLocation}`;
}
return remoteLocation; return remoteLocation;
} }
const baseRemote = remoteLocation.endsWith('/') const resolvedUrl = new URL(remoteLocation);
? remoteLocation.slice(0, -1) const ext = extname(resolvedUrl.pathname);
: remoteLocation; const needsRemoteEntry = !['.js', '.mjs', '.json'].includes(ext);
const globalPrefix = isRemoteGlobal if (needsRemoteEntry) {
? `${normalizeRemoteName(nxRemoteProjectName)}@` resolvedUrl.pathname = resolvedUrl.pathname.endsWith('/')
: ''; ? `${resolvedUrl.pathname}remoteEntry.${remoteEntryExt}`
: `${resolvedUrl.pathname}/remoteEntry.${remoteEntryExt}`;
// if the remote is defined with anything other than http then we assume it's a promise based remote
// In that case we should use what the user provides as the remote location
if (!remoteLocation.startsWith('promise new Promise')) {
return `${globalPrefix}${baseRemote}/remoteEntry.${remoteEntryExt}`;
} else {
return remoteLocation;
} }
const finalRemoteUrl = resolvedUrl.href;
return isRemoteGlobal ? `${mfRemoteName}@${finalRemoteUrl}` : finalRemoteUrl;
} }
// Helper function to deal with remotes that are strings // Helper function to deal with remotes that are strings
@ -106,16 +100,20 @@ export function mapRemotesForSSR(
if (Array.isArray(remote)) { if (Array.isArray(remote)) {
let [nxRemoteProjectName, remoteLocation] = remote; let [nxRemoteProjectName, remoteLocation] = remote;
const mfRemoteName = normalizeRemoteName(nxRemoteProjectName); const mfRemoteName = normalizeRemoteName(nxRemoteProjectName);
const remoteLocationExt = extname(remoteLocation);
mappedRemotes[mfRemoteName] = `${mfRemoteName}@${ const resolvedUrl = new URL(remoteLocation);
['.js', '.mjs', '.json'].includes(remoteLocationExt) const remoteLocationExt = extname(resolvedUrl.pathname);
? remoteLocation const needsRemoteEntry = !['.js', '.mjs', '.json'].includes(
: `${ remoteLocationExt
remoteLocation.endsWith('/') );
? remoteLocation.slice(0, -1)
: remoteLocation if (needsRemoteEntry) {
}/remoteEntry.${remoteEntryExt}` resolvedUrl.pathname = resolvedUrl.pathname.endsWith('/')
}`; ? `${resolvedUrl.pathname}remoteEntry.${remoteEntryExt}`
: `${resolvedUrl.pathname}/remoteEntry.${remoteEntryExt}`;
}
const finalRemoteUrl = resolvedUrl.href;
mappedRemotes[mfRemoteName] = `${mfRemoteName}@${finalRemoteUrl}`;
} else if (typeof remote === 'string') { } else if (typeof remote === 'string') {
const mfRemoteName = normalizeRemoteName(remote); const mfRemoteName = normalizeRemoteName(remote);
mappedRemotes[mfRemoteName] = `${mfRemoteName}@${determineRemoteUrl( mappedRemotes[mfRemoteName] = `${mfRemoteName}@${determineRemoteUrl(

View File

@ -6,7 +6,6 @@ import {
NxModuleFederationConfigOverride, NxModuleFederationConfigOverride,
} from '../../utils'; } from '../../utils';
import { getModuleFederationConfig } from './utils'; import { getModuleFederationConfig } from './utils';
import { type ExecutorContext } from '@nx/devkit';
const isVarOrWindow = (libType?: string) => const isVarOrWindow = (libType?: string) =>
libType === 'var' || libType === 'window'; libType === 'var' || libType === 'window';

View File

@ -1,3 +1,5 @@
import { extname } from 'node:path';
export type ResolveRemoteUrlFunction = ( export type ResolveRemoteUrlFunction = (
remoteName: string remoteName: string
) => string | Promise<string>; ) => string | Promise<string>;
@ -158,13 +160,19 @@ async function loadRemoteContainer(remoteName: string) {
? remoteUrlDefinitions[remoteName] ? remoteUrlDefinitions[remoteName]
: await resolveRemoteUrl(remoteName); : await resolveRemoteUrl(remoteName);
let containerUrl = remoteUrl; const url = new URL(remoteUrl);
if (!remoteUrl.endsWith('.mjs') && !remoteUrl.endsWith('.js')) { const ext = extname(url.pathname);
containerUrl = `${remoteUrl}${
remoteUrl.endsWith('/') ? '' : '/' const needsRemoteEntry = !['.js', '.mjs', '.json'].includes(ext);
}remoteEntry.js`;
if (needsRemoteEntry) {
url.pathname = url.pathname.endsWith('/')
? `${url.pathname}remoteEntry.mjs`
: `${url.pathname}/remoteEntry.mjs`;
} }
const containerUrl = url.href;
const container = await fetchRemoteModule(containerUrl, remoteName); const container = await fetchRemoteModule(containerUrl, remoteName);
await container.init(__webpack_share_scopes__.default); await container.init(__webpack_share_scopes__.default);