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:
parent
752d418f78
commit
dfc8162db7
@ -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'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user