<!-- 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 --> We can serve each application in a module federation setup statically, but only individually. ## Expected Behavior <!-- This is the behavior we should expect with the changes in this PR --> Add a serve static executor for module federation hosts which will also spin up the remotes statically ## Related Issue(s) <!-- Please link the issue being fixed so it gets closed when this is merged. --> Fixes #
1219 lines
38 KiB
TypeScript
1219 lines
38 KiB
TypeScript
import { Tree, stripIndents } from '@nx/devkit';
|
|
import {
|
|
checkFilesExist,
|
|
cleanupProject,
|
|
fileExists,
|
|
killPorts,
|
|
killProcessAndPorts,
|
|
newProject,
|
|
readJson,
|
|
runCLI,
|
|
runCLIAsync,
|
|
runCommandUntil,
|
|
runE2ETests,
|
|
tmpProjPath,
|
|
uniq,
|
|
updateFile,
|
|
updateJson,
|
|
} from '@nx/e2e/utils';
|
|
import { join } from 'path';
|
|
import { createTreeWithEmptyWorkspace } from 'nx/src/devkit-testing-exports';
|
|
|
|
describe('React Rspack Module Federation', () => {
|
|
describe('Default Configuration', () => {
|
|
beforeAll(() => {
|
|
newProject({ packages: ['@nx/react'] });
|
|
});
|
|
|
|
afterAll(() => cleanupProject());
|
|
|
|
it.each`
|
|
js
|
|
${false}
|
|
${true}
|
|
`(
|
|
'should generate host and remote apps with "--js=$js"',
|
|
async ({ js }) => {
|
|
const shell = uniq('shell');
|
|
const remote1 = uniq('remote1');
|
|
const remote2 = uniq('remote2');
|
|
const remote3 = uniq('remote3');
|
|
|
|
runCLI(
|
|
`generate @nx/react:host ${shell} --remotes=${remote1},${remote2},${remote3} --bundler=rspack --e2eTestRunner=cypress --style=css --no-interactive --skipFormat --js=${js}`
|
|
);
|
|
|
|
checkFilesExist(
|
|
`apps/${shell}/module-federation.config.${js ? 'js' : 'ts'}`
|
|
);
|
|
checkFilesExist(
|
|
`apps/${remote1}/module-federation.config.${js ? 'js' : 'ts'}`
|
|
);
|
|
checkFilesExist(
|
|
`apps/${remote2}/module-federation.config.${js ? 'js' : 'ts'}`
|
|
);
|
|
checkFilesExist(
|
|
`apps/${remote3}/module-federation.config.${js ? 'js' : 'ts'}`
|
|
);
|
|
|
|
await expect(runCLIAsync(`test ${shell}`)).resolves.toMatchObject({
|
|
combinedOutput: expect.stringContaining(
|
|
'Test Suites: 1 passed, 1 total'
|
|
),
|
|
});
|
|
|
|
updateFile(
|
|
`apps/${shell}-e2e/src/integration/app.spec.${js ? 'js' : 'ts'}`,
|
|
stripIndents`
|
|
import { getGreeting } from '../support/app.po';
|
|
|
|
describe('shell app', () => {
|
|
it('should display welcome message', () => {
|
|
cy.visit('/')
|
|
getGreeting().contains('Welcome ${shell}');
|
|
});
|
|
|
|
it('should load remote 1', () => {
|
|
cy.visit('/${remote1}')
|
|
getGreeting().contains('Welcome ${remote1}');
|
|
});
|
|
|
|
it('should load remote 2', () => {
|
|
cy.visit('/${remote2}')
|
|
getGreeting().contains('Welcome ${remote2}');
|
|
});
|
|
|
|
it('should load remote 3', () => {
|
|
cy.visit('/${remote3}')
|
|
getGreeting().contains('Welcome ${remote3}');
|
|
});
|
|
});
|
|
`
|
|
);
|
|
|
|
[shell, remote1, remote2, remote3].forEach((app) => {
|
|
['development', 'production'].forEach(async (configuration) => {
|
|
const cliOutput = runCLI(`run ${app}:build:${configuration}`);
|
|
expect(cliOutput).toContain('Successfully ran target');
|
|
});
|
|
});
|
|
|
|
const serveResult = await runCommandUntil(`serve ${shell}`, (output) =>
|
|
output.includes(`http://localhost:${readPort(shell)}`)
|
|
);
|
|
|
|
await killProcessAndPorts(serveResult.pid, readPort(shell));
|
|
|
|
if (runE2ETests()) {
|
|
const e2eResultsSwc = await runCommandUntil(
|
|
`e2e ${shell}-e2e --no-watch --verbose`,
|
|
(output) => output.includes('All specs passed!')
|
|
);
|
|
|
|
await killProcessAndPorts(e2eResultsSwc.pid, readPort(shell));
|
|
|
|
const e2eResultsTsNode = await runCommandUntil(
|
|
`e2e ${shell}-e2e --no-watch --verbose`,
|
|
(output) =>
|
|
output.includes('Successfully ran target e2e for project'),
|
|
{
|
|
env: { NX_PREFER_TS_NODE: 'true' },
|
|
}
|
|
);
|
|
await killProcessAndPorts(e2eResultsTsNode.pid, readPort(shell));
|
|
}
|
|
},
|
|
500_000
|
|
);
|
|
it('should generate host and remote apps and use playwright for e2es', async () => {
|
|
const shell = uniq('shell');
|
|
const remote1 = uniq('remote1');
|
|
const remote2 = uniq('remote2');
|
|
const remote3 = uniq('remote3');
|
|
|
|
runCLI(
|
|
`generate @nx/react:host ${shell} --remotes=${remote1},${remote2},${remote3} --bundler=rspack --e2eTestRunner=playwright --style=css --no-interactive --skipFormat`
|
|
);
|
|
|
|
checkFilesExist(`apps/${shell}/module-federation.config.ts`);
|
|
checkFilesExist(`apps/${remote1}/module-federation.config.ts`);
|
|
checkFilesExist(`apps/${remote2}/module-federation.config.ts`);
|
|
checkFilesExist(`apps/${remote3}/module-federation.config.ts`);
|
|
|
|
await expect(runCLIAsync(`test ${shell}`)).resolves.toMatchObject({
|
|
combinedOutput: expect.stringContaining(
|
|
'Test Suites: 1 passed, 1 total'
|
|
),
|
|
});
|
|
|
|
updateFile(
|
|
`apps/${shell}-e2e/src/example.spec.ts`,
|
|
stripIndents`
|
|
import { test, expect } from '@playwright/test';
|
|
test('should display welcome message', async ({page}) => {
|
|
await page.goto("/");
|
|
expect(await page.locator('h1').innerText()).toContain('Welcome');
|
|
});
|
|
|
|
test('should load remote 1', async ({page}) => {
|
|
await page.goto("/${remote1}");
|
|
expect(await page.locator('h1').innerText()).toContain('${remote1}');
|
|
});
|
|
|
|
test('should load remote 2', async ({page}) => {
|
|
await page.goto("/${remote2}");
|
|
expect(await page.locator('h1').innerText()).toContain('${remote2}');
|
|
});
|
|
|
|
test('should load remote 3', async ({page}) => {
|
|
await page.goto("/${remote3}");
|
|
expect(await page.locator('h1').innerText()).toContain('${remote3}');
|
|
});
|
|
`
|
|
);
|
|
|
|
if (runE2ETests()) {
|
|
const e2eResultsSwc = await runCommandUntil(
|
|
`e2e ${shell}-e2e`,
|
|
(output) => output.includes('Successfully ran target e2e for project')
|
|
);
|
|
|
|
await killProcessAndPorts(e2eResultsSwc.pid, readPort(shell));
|
|
|
|
const e2eResultsTsNode = await runCommandUntil(
|
|
`e2e ${shell}-e2e`,
|
|
(output) =>
|
|
output.includes('Successfully ran target e2e for project'),
|
|
{
|
|
env: { NX_PREFER_TS_NODE: 'true' },
|
|
}
|
|
);
|
|
await killProcessAndPorts(e2eResultsTsNode.pid, readPort(shell));
|
|
}
|
|
}, 500_000);
|
|
|
|
it('should have interop between webpack host and rspack remote', async () => {
|
|
const shell = uniq('shell');
|
|
const remote1 = uniq('remote1');
|
|
const remote2 = uniq('remote2');
|
|
|
|
runCLI(
|
|
`generate @nx/react:host ${shell} --remotes=${remote1} --bundler=webpack --e2eTestRunner=cypress --style=css --no-interactive --skipFormat`
|
|
);
|
|
|
|
runCLI(
|
|
`generate @nx/react:remote ${remote2} --host=${shell} --bundler=rspack --style=css --no-interactive --skipFormat`
|
|
);
|
|
|
|
updateFile(
|
|
`apps/${shell}-e2e/src/integration/app.spec.ts`,
|
|
stripIndents`
|
|
import { getGreeting } from '../support/app.po';
|
|
|
|
describe('shell app', () => {
|
|
it('should display welcome message', () => {
|
|
cy.visit('/')
|
|
getGreeting().contains('Welcome ${shell}');
|
|
});
|
|
|
|
it('should load remote 1', () => {
|
|
cy.visit('/${remote1}')
|
|
getGreeting().contains('Welcome ${remote1}');
|
|
});
|
|
|
|
it('should load remote 2', () => {
|
|
cy.visit('/${remote2}')
|
|
getGreeting().contains('Welcome ${remote2}');
|
|
});
|
|
});
|
|
`
|
|
);
|
|
|
|
[shell, remote1, remote2].forEach((app) => {
|
|
['development', 'production'].forEach(async (configuration) => {
|
|
const cliOutput = runCLI(`run ${app}:build:${configuration}`);
|
|
expect(cliOutput).toContain('Successfully ran target');
|
|
});
|
|
});
|
|
|
|
const serveResult = await runCommandUntil(`serve ${shell}`, (output) =>
|
|
output.includes(`http://localhost:${readPort(shell)}`)
|
|
);
|
|
|
|
await killProcessAndPorts(serveResult.pid, readPort(shell));
|
|
|
|
if (runE2ETests()) {
|
|
const e2eResultsSwc = await runCommandUntil(
|
|
`e2e ${shell}-e2e --no-watch --verbose`,
|
|
(output) => output.includes('All specs passed!')
|
|
);
|
|
|
|
await killProcessAndPorts(e2eResultsSwc.pid, readPort(shell));
|
|
|
|
const e2eResultsTsNode = await runCommandUntil(
|
|
`e2e ${shell}-e2e --no-watch --verbose`,
|
|
(output) =>
|
|
output.includes('Successfully ran target e2e for project'),
|
|
{
|
|
env: { NX_PREFER_TS_NODE: 'true' },
|
|
}
|
|
);
|
|
await killProcessAndPorts(e2eResultsTsNode.pid, readPort(shell));
|
|
}
|
|
}, 500_000);
|
|
|
|
it('should have interop between rspack host and webpack remote', async () => {
|
|
const shell = uniq('shell');
|
|
const remote1 = uniq('remote1');
|
|
const remote2 = uniq('remote2');
|
|
runCLI(
|
|
`generate @nx/react:host ${shell} --remotes=${remote1} --bundler=rspack --e2eTestRunner=cypress --style=css --no-interactive --skipFormat`
|
|
);
|
|
|
|
runCLI(
|
|
`generate @nx/react:remote ${remote2} --host=${shell} --bundler=webpack --style=css --no-interactive --skipFormat`
|
|
);
|
|
|
|
updateFile(
|
|
`apps/${shell}-e2e/src/integration/app.spec.ts`,
|
|
stripIndents`
|
|
import { getGreeting } from '../support/app.po';
|
|
|
|
describe('shell app', () => {
|
|
it('should display welcome message', () => {
|
|
cy.visit('/')
|
|
getGreeting().contains('Welcome ${shell}');
|
|
});
|
|
|
|
it('should load remote 1', () => {
|
|
cy.visit('/${remote1}')
|
|
getGreeting().contains('Welcome ${remote1}');
|
|
});
|
|
|
|
it('should load remote 2', () => {
|
|
cy.visit('/${remote2}')
|
|
getGreeting().contains('Welcome ${remote2}');
|
|
});
|
|
|
|
});
|
|
`
|
|
);
|
|
|
|
if (runE2ETests()) {
|
|
const e2eResultsSwc = await runCommandUntil(
|
|
`e2e ${shell}-e2e --no-watch --verbose`,
|
|
(output) => output.includes('All specs passed!')
|
|
);
|
|
|
|
await killProcessAndPorts(e2eResultsSwc.pid, readPort(shell));
|
|
|
|
const e2eResultsTsNode = await runCommandUntil(
|
|
`e2e ${shell}-e2e --no-watch --verbose`,
|
|
(output) =>
|
|
output.includes('Successfully ran target e2e for project'),
|
|
{
|
|
env: { NX_PREFER_TS_NODE: 'true' },
|
|
}
|
|
);
|
|
await killProcessAndPorts(e2eResultsTsNode.pid, readPort(shell));
|
|
}
|
|
}, 500_000);
|
|
|
|
describe('ssr', () => {
|
|
it('should generate host and remote apps with ssr', async () => {
|
|
const shell = uniq('shell');
|
|
const remote1 = uniq('remote1');
|
|
const remote2 = uniq('remote2');
|
|
const remote3 = uniq('remote3');
|
|
|
|
await runCLIAsync(
|
|
`generate @nx/react:host ${shell} --ssr --remotes=${remote1},${remote2},${remote3} --bundler=rspack --style=css --no-interactive --projectNameAndRootFormat=derived --skipFormat`
|
|
);
|
|
|
|
expect(readPort(shell)).toEqual(4200);
|
|
expect(readPort(remote1)).toEqual(4201);
|
|
expect(readPort(remote2)).toEqual(4202);
|
|
expect(readPort(remote3)).toEqual(4203);
|
|
|
|
[shell, remote1, remote2, remote3].forEach((app) => {
|
|
checkFilesExist(
|
|
`apps/${app}/module-federation.config.ts`,
|
|
`apps/${app}/module-federation.server.config.ts`
|
|
);
|
|
['build', 'server'].forEach((target) => {
|
|
['development', 'production'].forEach(async (configuration) => {
|
|
const cliOutput = runCLI(`run ${app}:${target}:${configuration}`);
|
|
expect(cliOutput).toContain('Successfully ran target');
|
|
|
|
await killPorts(readPort(app));
|
|
});
|
|
});
|
|
});
|
|
}, 500_000);
|
|
|
|
it('should serve remotes as static when running the host by default', async () => {
|
|
const shell = uniq('shell');
|
|
const remote1 = uniq('remote1');
|
|
const remote2 = uniq('remote2');
|
|
const remote3 = uniq('remote3');
|
|
|
|
await runCLIAsync(
|
|
`generate @nx/react:host ${shell} --ssr --remotes=${remote1},${remote2},${remote3} --bundler=rspack --style=css --e2eTestRunner=cypress --no-interactive --projectNameAndRootFormat=derived --skipFormat`
|
|
);
|
|
|
|
const serveResult = await runCommandUntil(`serve ${shell}`, (output) =>
|
|
output.includes(`Nx SSR Static remotes proxies started successfully`)
|
|
);
|
|
|
|
await killProcessAndPorts(serveResult.pid);
|
|
}, 500_000);
|
|
|
|
it('should serve remotes as static and they should be able to be accessed from the host', async () => {
|
|
const shell = uniq('shell');
|
|
const remote1 = uniq('remote1');
|
|
const remote2 = uniq('remote2');
|
|
const remote3 = uniq('remote3');
|
|
|
|
await runCLIAsync(
|
|
`generate @nx/react:host ${shell} --ssr --remotes=${remote1},${remote2},${remote3} --bundler=rspack --style=css --e2eTestRunner=cypress --no-interactive --projectNameAndRootFormat=derived --skipFormat`
|
|
);
|
|
|
|
const capitalize = (s: string) =>
|
|
s.charAt(0).toUpperCase() + s.slice(1);
|
|
|
|
updateFile(`apps/${shell}-e2e/src/e2e/app.cy.ts`, (content) => {
|
|
return `
|
|
describe('${shell}-e2e', () => {
|
|
beforeEach(() => cy.visit('/'));
|
|
|
|
it('should display welcome message', () => {
|
|
expect(cy.get('ul li').should('have.length', 4));
|
|
expect(cy.get('ul li').eq(0).should('have.text', 'Home'));
|
|
expect(cy.get('ul li').eq(1).should('have.text', '${capitalize(
|
|
remote1
|
|
)}'));
|
|
expect(cy.get('ul li').eq(2).should('have.text', '${capitalize(
|
|
remote2
|
|
)}'));
|
|
expect(cy.get('ul li').eq(3).should('have.text', '${capitalize(
|
|
remote3
|
|
)}'));
|
|
});
|
|
});
|
|
`;
|
|
});
|
|
|
|
if (runE2ETests()) {
|
|
const hostE2eResults = await runCommandUntil(
|
|
`e2e ${shell}-e2e --no-watch --verbose`,
|
|
(output) => output.includes('All specs passed!')
|
|
);
|
|
await killProcessAndPorts(hostE2eResults.pid);
|
|
}
|
|
}, 600_000);
|
|
});
|
|
|
|
it('should should support generating host and remote apps with the new name and root format', async () => {
|
|
const shell = uniq('shell');
|
|
const remote = uniq('remote');
|
|
|
|
runCLI(
|
|
`generate @nx/react:host ${shell} --project-name-and-root-format=as-provided --no-interactive --skipFormat`
|
|
);
|
|
runCLI(
|
|
`generate @nx/react:remote ${remote} --host=${shell} --bundler=rspack --project-name-and-root-format=as-provided --no-interactive --skipFormat`
|
|
);
|
|
|
|
const shellPort = readPort(shell);
|
|
const remotePort = readPort(remote);
|
|
|
|
// check files are generated without the layout directory ("apps/") and
|
|
// using the project name as the directory when no directory is provided
|
|
checkFilesExist(`${shell}/module-federation.config.ts`);
|
|
checkFilesExist(`${remote}/module-federation.config.ts`);
|
|
|
|
// check default generated host is built successfully
|
|
const buildOutputSwc = runCLI(`run ${shell}:build:development`);
|
|
expect(buildOutputSwc).toContain('Successfully ran target build');
|
|
|
|
const buildOutputTsNode = runCLI(`run ${shell}:build:development`, {
|
|
env: { NX_PREFER_TS_NODE: 'true' },
|
|
});
|
|
expect(buildOutputTsNode).toContain('Successfully ran target build');
|
|
|
|
// check serves devRemotes ok
|
|
const shellProcessSwc = await runCommandUntil(
|
|
`serve ${shell} --devRemotes=${remote} --verbose`,
|
|
(output) => {
|
|
return output.includes(
|
|
`All remotes started, server ready at http://localhost:${shellPort}`
|
|
);
|
|
}
|
|
);
|
|
await killProcessAndPorts(
|
|
shellProcessSwc.pid,
|
|
shellPort,
|
|
remotePort + 1,
|
|
remotePort
|
|
);
|
|
|
|
const shellProcessTsNode = await runCommandUntil(
|
|
`serve ${shell} --devRemotes=${remote} --verbose`,
|
|
(output) => {
|
|
return output.includes(
|
|
`All remotes started, server ready at http://localhost:${shellPort}`
|
|
);
|
|
},
|
|
{
|
|
env: { NX_PREFER_TS_NODE: 'true' },
|
|
}
|
|
);
|
|
await killProcessAndPorts(
|
|
shellProcessTsNode.pid,
|
|
shellPort,
|
|
remotePort + 1,
|
|
remotePort
|
|
);
|
|
}, 500_000);
|
|
});
|
|
// Federate Module
|
|
describe('Federate Module', () => {
|
|
let proj: string;
|
|
let tree: Tree;
|
|
|
|
beforeAll(() => {
|
|
tree = createTreeWithEmptyWorkspace();
|
|
proj = newProject();
|
|
});
|
|
|
|
afterAll(() => cleanupProject());
|
|
it('should federate a module from a library and update an existing remote', async () => {
|
|
const lib = uniq('lib');
|
|
const remote = uniq('remote');
|
|
const module = uniq('module');
|
|
const host = uniq('host');
|
|
|
|
runCLI(
|
|
`generate @nx/react:host ${host} --remotes=${remote} --bundler=rspack --e2eTestRunner=cypress --no-interactive --projectNameAndRootFormat=as-provided --skipFormat`
|
|
);
|
|
|
|
runCLI(
|
|
`generate @nx/js:lib ${lib} --no-interactive --projectNameAndRootFormat=as-provided --skipFormat`
|
|
);
|
|
|
|
// Federate Module
|
|
runCLI(
|
|
`generate @nx/react:federate-module ${lib}/src/index.ts --name=${module} --remote=${remote} --bundler=rspack --no-interactive --skipFormat`
|
|
);
|
|
|
|
updateFile(
|
|
`${lib}/src/index.ts`,
|
|
`export { default } from './lib/${lib}';`
|
|
);
|
|
updateFile(
|
|
`${lib}/src/lib/${lib}.ts`,
|
|
`export default function lib() { return 'Hello from ${lib}'; };`
|
|
);
|
|
|
|
// Update Host to use the module
|
|
updateFile(
|
|
`${host}/src/app/app.tsx`,
|
|
`
|
|
import * as React from 'react';
|
|
import NxWelcome from './nx-welcome';
|
|
import { Link, Route, Routes } from 'react-router-dom';
|
|
|
|
import myLib from '${remote}/${module}';
|
|
|
|
export function App() {
|
|
return (
|
|
<React.Suspense fallback={null}>
|
|
<div className='remote'>
|
|
My Remote Library: { myLib() }
|
|
</div>
|
|
<ul>
|
|
<li>
|
|
<Link to="/">Home</Link>
|
|
</li>
|
|
</ul>
|
|
<Routes>
|
|
<Route path="/" element={<NxWelcome title="Host" />} />
|
|
</Routes>
|
|
</React.Suspense>
|
|
);
|
|
}
|
|
|
|
export default App;
|
|
`
|
|
);
|
|
|
|
// Update e2e test to check the module
|
|
updateFile(
|
|
`${host}-e2e/src/e2e/app.cy.ts`,
|
|
`
|
|
describe('${host}', () => {
|
|
beforeEach(() => cy.visit('/'));
|
|
|
|
it('should display contain the remote library', () => {
|
|
expect(cy.get('div.remote')).to.exist;
|
|
expect(cy.get('div.remote').contains('My Remote Library: Hello from ${lib}'));
|
|
});
|
|
});
|
|
|
|
`
|
|
);
|
|
|
|
const hostPort = readPort(host);
|
|
const remotePort = readPort(remote);
|
|
|
|
// Build host and remote
|
|
const buildOutput = runCLI(`build ${host}`);
|
|
const remoteOutput = runCLI(`build ${remote}`);
|
|
|
|
expect(buildOutput).toContain('Successfully ran target build');
|
|
expect(remoteOutput).toContain('Successfully ran target build');
|
|
|
|
if (runE2ETests()) {
|
|
const hostE2eResults = await runCommandUntil(
|
|
`e2e ${host}-e2e --no-watch --verbose`,
|
|
(output) => output.includes('All specs passed!')
|
|
);
|
|
await killProcessAndPorts(
|
|
hostE2eResults.pid,
|
|
hostPort,
|
|
hostPort + 1,
|
|
remotePort
|
|
);
|
|
}
|
|
}, 500_000);
|
|
|
|
it('should federate a module from a library and create a remote and serve it recursively', async () => {
|
|
const lib = uniq('lib');
|
|
const remote = uniq('remote');
|
|
const childRemote = uniq('childremote');
|
|
const module = uniq('module');
|
|
const host = uniq('host');
|
|
|
|
runCLI(
|
|
`generate @nx/react:host ${host} --remotes=${remote} --bundler=rspack --e2eTestRunner=cypress --no-interactive --projectNameAndRootFormat=as-provided --skipFormat`
|
|
);
|
|
|
|
runCLI(
|
|
`generate @nx/js:lib ${lib} --no-interactive --projectNameAndRootFormat=as-provided --skipFormat`
|
|
);
|
|
|
|
// Federate Module
|
|
runCLI(
|
|
`generate @nx/react:federate-module ${lib}/src/index.ts --name=${module} --remote=${childRemote} --bundler=rspack --no-interactive --skipFormat`
|
|
);
|
|
|
|
updateFile(
|
|
`${lib}/src/index.ts`,
|
|
`export { default } from './lib/${lib}';`
|
|
);
|
|
updateFile(
|
|
`${lib}/src/lib/${lib}.ts`,
|
|
`export default function lib() { return 'Hello from ${lib}'; };`
|
|
);
|
|
|
|
// Update Host to use the module
|
|
updateFile(
|
|
`${remote}/src/app/app.tsx`,
|
|
`
|
|
import * as React from 'react';
|
|
import NxWelcome from './nx-welcome';
|
|
|
|
import myLib from '${childRemote}/${module}';
|
|
|
|
export function App() {
|
|
return (
|
|
<React.Suspense fallback={null}>
|
|
<div className='remote'>
|
|
My Remote Library: { myLib() }
|
|
</div>
|
|
<NxWelcome title="Host" />
|
|
</React.Suspense>
|
|
);
|
|
}
|
|
|
|
export default App;
|
|
`
|
|
);
|
|
|
|
// Update e2e test to check the module
|
|
updateFile(
|
|
`${host}-e2e/src/e2e/app.cy.ts`,
|
|
`
|
|
describe('${host}', () => {
|
|
beforeEach(() => cy.visit('/${remote}'));
|
|
|
|
it('should display contain the remote library', () => {
|
|
expect(cy.get('div.remote')).to.exist;
|
|
expect(cy.get('div.remote').contains('My Remote Library: Hello from ${lib}'));
|
|
});
|
|
});
|
|
|
|
`
|
|
);
|
|
|
|
const hostPort = readPort(host);
|
|
const remotePort = readPort(remote);
|
|
const childRemotePort = readPort(childRemote);
|
|
|
|
// Build host and remote
|
|
const buildOutput = runCLI(`build ${host}`);
|
|
const remoteOutput = runCLI(`build ${remote}`);
|
|
|
|
expect(buildOutput).toContain('Successfully ran target build');
|
|
expect(remoteOutput).toContain('Successfully ran target build');
|
|
|
|
if (runE2ETests()) {
|
|
const hostE2eResults = await runCommandUntil(
|
|
`e2e ${host}-e2e --no-watch --verbose`,
|
|
(output) => output.includes('All specs passed!')
|
|
);
|
|
await killProcessAndPorts(
|
|
hostE2eResults.pid,
|
|
hostPort,
|
|
hostPort + 1,
|
|
remotePort,
|
|
childRemotePort
|
|
);
|
|
}
|
|
}, 500_000);
|
|
});
|
|
|
|
describe('Independent Deployability', () => {
|
|
let proj: string;
|
|
let tree: Tree;
|
|
|
|
beforeAll(() => {
|
|
process.env.NX_ADD_PLUGINS = 'false';
|
|
tree = createTreeWithEmptyWorkspace();
|
|
proj = newProject();
|
|
});
|
|
|
|
afterAll(() => {
|
|
cleanupProject();
|
|
delete process.env.NX_ADD_PLUGINS;
|
|
});
|
|
|
|
it('should support promised based remotes', async () => {
|
|
const remote = uniq('remote');
|
|
const host = uniq('host');
|
|
|
|
runCLI(
|
|
`generate @nx/react:host ${host} --remotes=${remote} --bundler=rspack --e2eTestRunner=cypress --no-interactive --projectNameAndRootFormat=as-provided --typescriptConfiguration=false --skipFormat`
|
|
);
|
|
|
|
// Update remote to be loaded via script
|
|
updateFile(
|
|
`${remote}/module-federation.config.js`,
|
|
stripIndents`
|
|
module.exports = {
|
|
name: '${remote}',
|
|
library: { type: 'var', name: '${remote}' },
|
|
exposes: {
|
|
'./Module': './src/remote-entry.ts',
|
|
},
|
|
};
|
|
`
|
|
);
|
|
|
|
updateFile(
|
|
`${remote}/webpack.config.prod.js`,
|
|
`module.exports = require('./webpack.config');`
|
|
);
|
|
|
|
// Update host to use promise based remote
|
|
updateFile(
|
|
`${host}/module-federation.config.js`,
|
|
`module.exports = {
|
|
name: '${host}',
|
|
library: { type: 'var', name: '${host}' },
|
|
remotes: [
|
|
[
|
|
'${remote}',
|
|
\`promise new Promise(resolve => {
|
|
const remoteUrl = 'http://localhost:4201/remoteEntry.js';
|
|
const script = document.createElement('script');
|
|
script.src = remoteUrl;
|
|
script.onload = () => {
|
|
const proxy = {
|
|
get: (request) => window.${remote}.get(request),
|
|
init: (arg) => {
|
|
try {
|
|
window.${remote}.init(arg);
|
|
} catch (e) {
|
|
console.log('Remote container already initialized');
|
|
}
|
|
}
|
|
};
|
|
resolve(proxy);
|
|
}
|
|
document.head.appendChild(script);
|
|
})\`,
|
|
],
|
|
],
|
|
};
|
|
`
|
|
);
|
|
|
|
updateFile(
|
|
`${host}/webpack.config.prod.js`,
|
|
`module.exports = require('./webpack.config');`
|
|
);
|
|
|
|
// Update e2e project.json
|
|
updateJson(`${host}-e2e/project.json`, (json) => {
|
|
return {
|
|
...json,
|
|
targets: {
|
|
...json.targets,
|
|
e2e: {
|
|
...json.targets.e2e,
|
|
options: {
|
|
...json.targets.e2e.options,
|
|
devServerTarget: `${host}:serve-static:production`,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
// update e2e
|
|
updateFile(
|
|
`${host}-e2e/src/e2e/app.cy.ts`,
|
|
`
|
|
import { getGreeting } from '../support/app.po';
|
|
|
|
describe('${host}', () => {
|
|
beforeEach(() => cy.visit('/'));
|
|
|
|
it('should display welcome message', () => {
|
|
getGreeting().contains('Welcome ${host}');
|
|
});
|
|
|
|
it('should navigate to /${remote} from /', () => {
|
|
cy.get('a').contains('${remote[0].toUpperCase()}${remote.slice(
|
|
1
|
|
)}').click();
|
|
cy.url().should('include', '/${remote}');
|
|
getGreeting().contains('Welcome ${remote}');
|
|
});
|
|
});
|
|
`
|
|
);
|
|
|
|
const hostPort = readPort(host);
|
|
const remotePort = readPort(remote);
|
|
|
|
// Build host and remote
|
|
const buildOutput = runCLI(`build ${host}`);
|
|
const remoteOutput = runCLI(`build ${remote}`);
|
|
|
|
expect(buildOutput).toContain('Successfully ran target build');
|
|
expect(remoteOutput).toContain('Successfully ran target build');
|
|
|
|
if (runE2ETests()) {
|
|
const remoteProcess = await runCommandUntil(
|
|
`serve-static ${remote} --no-watch --verbose`,
|
|
() => {
|
|
return true;
|
|
}
|
|
);
|
|
const hostE2eResults = await runCommandUntil(
|
|
`e2e ${host}-e2e --no-watch --verbose`,
|
|
(output) => output.includes('All specs passed!')
|
|
);
|
|
await killProcessAndPorts(hostE2eResults.pid, hostPort, hostPort + 1);
|
|
await killProcessAndPorts(remoteProcess.pid, remotePort);
|
|
}
|
|
}, 500_000);
|
|
|
|
it('should support different versions workspace libs for host and remote', async () => {
|
|
const shell = uniq('shell');
|
|
const remote = uniq('remote');
|
|
const lib = uniq('lib');
|
|
|
|
runCLI(
|
|
`generate @nx/react:host ${shell} --remotes=${remote} --bundler=rspack --e2eTestRunner=cypress --no-interactive --projectNameAndRootFormat=as-provided --skipFormat`
|
|
);
|
|
|
|
runCLI(
|
|
`generate @nx/js:lib ${lib} --importPath=@acme/${lib} --publishable=true --no-interactive --projectNameAndRootFormat=as-provided --skipFormat`
|
|
);
|
|
|
|
const shellPort = readPort(shell);
|
|
const remotePort = readPort(remote);
|
|
|
|
updateFile(
|
|
`${lib}/src/lib/${lib}.ts`,
|
|
stripIndents`
|
|
export const version = '0.0.1';
|
|
`
|
|
);
|
|
|
|
updateJson(`${lib}/package.json`, (json) => {
|
|
return {
|
|
...json,
|
|
version: '0.0.1',
|
|
};
|
|
});
|
|
|
|
// Update host to use the lib
|
|
updateFile(
|
|
`${shell}/src/app/app.tsx`,
|
|
`
|
|
import * as React from 'react';
|
|
|
|
import NxWelcome from './nx-welcome';
|
|
import { version } from '@acme/${lib}';
|
|
import { Link, Route, Routes } from 'react-router-dom';
|
|
|
|
const About = React.lazy(() => import('${remote}/Module'));
|
|
|
|
export function App() {
|
|
return (
|
|
<React.Suspense fallback={null}>
|
|
<div className="home">
|
|
Lib version: { version }
|
|
</div>
|
|
<ul>
|
|
<li>
|
|
<Link to="/">Home</Link>
|
|
</li>
|
|
|
|
<li>
|
|
<Link to="/About">About</Link>
|
|
</li>
|
|
</ul>
|
|
<Routes>
|
|
<Route path="/" element={<NxWelcome title="home" />} />
|
|
|
|
<Route path="/About" element={<About />} />
|
|
</Routes>
|
|
</React.Suspense>
|
|
);
|
|
}
|
|
|
|
export default App;`
|
|
);
|
|
|
|
// Update remote to use the lib
|
|
updateFile(
|
|
`${remote}/src/app/app.tsx`,
|
|
`// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
|
|
import styles from './app.module.css';
|
|
import { version } from '@acme/${lib}';
|
|
|
|
import NxWelcome from './nx-welcome';
|
|
|
|
export function App() {
|
|
return (
|
|
|
|
<div className='remote'>
|
|
Lib version: { version }
|
|
<NxWelcome title="${remote}" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;`
|
|
);
|
|
|
|
// update remote e2e test to check the version
|
|
updateFile(
|
|
`${remote}-e2e/src/e2e/app.cy.ts`,
|
|
`describe('${remote}', () => {
|
|
beforeEach(() => cy.visit('/'));
|
|
|
|
it('should check the lib version', () => {
|
|
cy.get('div.remote').contains('Lib version: 0.0.1');
|
|
});
|
|
});
|
|
`
|
|
);
|
|
|
|
// update shell e2e test to check the version
|
|
updateFile(
|
|
`${shell}-e2e/src/e2e/app.cy.ts`,
|
|
`
|
|
describe('${shell}', () => {
|
|
beforeEach(() => cy.visit('/'));
|
|
|
|
it('should check the lib version', () => {
|
|
cy.get('div.home').contains('Lib version: 0.0.1');
|
|
});
|
|
});
|
|
`
|
|
);
|
|
|
|
if (runE2ETests()) {
|
|
// test remote e2e
|
|
const remoteE2eResults = await runCommandUntil(
|
|
`e2e ${remote}-e2e --no-watch --verbose`,
|
|
(output) => output.includes('All specs passed!')
|
|
);
|
|
await killProcessAndPorts(remoteE2eResults.pid, remotePort);
|
|
|
|
// test shell e2e
|
|
// serve remote first
|
|
const remoteProcess = await runCommandUntil(
|
|
`serve ${remote} --no-watch --verbose`,
|
|
(output) => {
|
|
return output.includes(`Loopback: http://localhost:${remotePort}/`);
|
|
}
|
|
);
|
|
await killProcessAndPorts(remoteProcess.pid, remotePort);
|
|
const shellE2eResults = await runCommandUntil(
|
|
`e2e ${shell}-e2e --no-watch --verbose`,
|
|
(output) => output.includes('All specs passed!')
|
|
);
|
|
await killProcessAndPorts(
|
|
shellE2eResults.pid,
|
|
shellPort,
|
|
shellPort + 1,
|
|
remotePort
|
|
);
|
|
}
|
|
}, 500_000);
|
|
|
|
it('should support host and remote with library type var', async () => {
|
|
const shell = uniq('shell');
|
|
const remote = uniq('remote');
|
|
|
|
runCLI(
|
|
`generate @nx/react:host ${shell} --remotes=${remote} --bundler=rspack --e2eTestRunner=cypress --project-name-and-root-format=as-provided --no-interactive --skipFormat`
|
|
);
|
|
|
|
const shellPort = readPort(shell);
|
|
const remotePort = readPort(remote);
|
|
|
|
// update host and remote to use library type var
|
|
updateFile(
|
|
`${shell}/module-federation.config.ts`,
|
|
stripIndents`
|
|
import { ModuleFederationConfig } from '@nx/webpack';
|
|
|
|
const config: ModuleFederationConfig = {
|
|
name: '${shell}',
|
|
library: { type: 'var', name: '${shell}' },
|
|
remotes: ['${remote}'],
|
|
};
|
|
|
|
export default config;
|
|
`
|
|
);
|
|
|
|
updateFile(
|
|
`${shell}/webpack.config.prod.ts`,
|
|
`export { default } from './webpack.config';`
|
|
);
|
|
|
|
updateFile(
|
|
`${remote}/module-federation.config.ts`,
|
|
stripIndents`
|
|
import { ModuleFederationConfig } from '@nx/webpack';
|
|
|
|
const config: ModuleFederationConfig = {
|
|
name: '${remote}',
|
|
library: { type: 'var', name: '${remote}' },
|
|
exposes: {
|
|
'./Module': './src/remote-entry.ts',
|
|
},
|
|
};
|
|
|
|
export default config;
|
|
`
|
|
);
|
|
|
|
updateFile(
|
|
`${remote}/webpack.config.prod.ts`,
|
|
`export { default } from './webpack.config';`
|
|
);
|
|
|
|
// Update host e2e test to check that the remote works with library type var via navigation
|
|
updateFile(
|
|
`${shell}-e2e/src/e2e/app.cy.ts`,
|
|
`
|
|
import { getGreeting } from '../support/app.po';
|
|
|
|
describe('${shell}', () => {
|
|
beforeEach(() => cy.visit('/'));
|
|
|
|
it('should display welcome message', () => {
|
|
getGreeting().contains('Welcome ${shell}');
|
|
|
|
});
|
|
|
|
it('should navigate to /about from /', () => {
|
|
cy.get('a').contains('${remote[0].toUpperCase()}${remote.slice(
|
|
1
|
|
)}').click();
|
|
cy.url().should('include', '/${remote}');
|
|
getGreeting().contains('Welcome ${remote}');
|
|
});
|
|
});
|
|
`
|
|
);
|
|
|
|
// Build host and remote
|
|
const buildOutput = runCLI(`build ${shell}`);
|
|
const remoteOutput = runCLI(`build ${remote}`);
|
|
|
|
expect(buildOutput).toContain('Successfully ran target build');
|
|
expect(remoteOutput).toContain('Successfully ran target build');
|
|
|
|
if (runE2ETests()) {
|
|
const hostE2eResultsSwc = await runCommandUntil(
|
|
`e2e ${shell}-e2e --no-watch --verbose`,
|
|
(output) => output.includes('All specs passed!')
|
|
);
|
|
await killProcessAndPorts(
|
|
hostE2eResultsSwc.pid,
|
|
shellPort,
|
|
shellPort + 1,
|
|
remotePort
|
|
);
|
|
|
|
const remoteE2eResultsSwc = await runCommandUntil(
|
|
`e2e ${remote}-e2e --no-watch --verbose`,
|
|
(output) => output.includes('All specs passed!')
|
|
);
|
|
|
|
await killProcessAndPorts(remoteE2eResultsSwc.pid, remotePort);
|
|
|
|
const hostE2eResultsTsNode = await runCommandUntil(
|
|
`e2e ${shell}-e2e --no-watch --verbose`,
|
|
(output) => output.includes('All specs passed!'),
|
|
{ env: { NX_PREFER_TS_NODE: 'true' } }
|
|
);
|
|
|
|
await killProcessAndPorts(
|
|
hostE2eResultsTsNode.pid,
|
|
shellPort,
|
|
shellPort + 1,
|
|
remotePort
|
|
);
|
|
|
|
const remoteE2eResultsTsNode = await runCommandUntil(
|
|
`e2e ${remote}-e2e --no-watch --verbose`,
|
|
(output) => output.includes('All specs passed!'),
|
|
{ env: { NX_PREFER_TS_NODE: 'true' } }
|
|
);
|
|
|
|
await killProcessAndPorts(remoteE2eResultsTsNode.pid, remotePort);
|
|
}
|
|
}, 500_000);
|
|
});
|
|
|
|
describe('Dynamic Module Federation', () => {
|
|
beforeAll(() => {
|
|
newProject({ packages: ['@nx/react'] });
|
|
});
|
|
|
|
afterAll(() => cleanupProject());
|
|
it('should load remote dynamic module', async () => {
|
|
const shell = uniq('shell');
|
|
const remote = uniq('remote');
|
|
const remotePort = 4205;
|
|
|
|
runCLI(
|
|
`generate @nx/react:host ${shell} --remotes=${remote} --bundler=rspack --e2eTestRunner=cypress --dynamic=true --project-name-and-root-format=as-provided --no-interactive --skipFormat`
|
|
);
|
|
|
|
updateJson(`${remote}/project.json`, (project) => {
|
|
project.targets.serve.options.port = remotePort;
|
|
return project;
|
|
});
|
|
|
|
// Webpack prod config should not exists when loading dynamic modules
|
|
expect(
|
|
fileExists(`${tmpProjPath()}/${shell}/webpack.config.prod.ts`)
|
|
).toBeFalsy();
|
|
expect(
|
|
fileExists(
|
|
`${tmpProjPath()}/${shell}/src/assets/module-federation.manifest.json`
|
|
)
|
|
).toBeTruthy();
|
|
|
|
updateJson(
|
|
`${shell}/src/assets/module-federation.manifest.json`,
|
|
(json) => {
|
|
return {
|
|
[remote]: `http://localhost:${remotePort}`,
|
|
};
|
|
}
|
|
);
|
|
|
|
const manifest = readJson(
|
|
`${shell}/src/assets/module-federation.manifest.json`
|
|
);
|
|
expect(manifest[remote]).toBeDefined();
|
|
expect(manifest[remote]).toEqual('http://localhost:4205');
|
|
|
|
// update e2e
|
|
updateFile(
|
|
`${shell}-e2e/src/e2e/app.cy.ts`,
|
|
`
|
|
import { getGreeting } from '../support/app.po';
|
|
|
|
describe('${shell}', () => {
|
|
beforeEach(() => cy.visit('/'));
|
|
|
|
it('should display welcome message', () => {
|
|
getGreeting().contains('Welcome ${shell}');
|
|
});
|
|
|
|
it('should navigate to /${remote} from /', () => {
|
|
cy.get('a').contains('${remote[0].toUpperCase()}${remote.slice(
|
|
1
|
|
)}').click();
|
|
cy.url().should('include', '/${remote}');
|
|
getGreeting().contains('Welcome ${remote}');
|
|
});
|
|
});
|
|
`
|
|
);
|
|
|
|
// Build host and remote
|
|
const buildOutput = runCLI(`build ${shell}`);
|
|
const remoteOutput = runCLI(`build ${remote}`);
|
|
|
|
expect(buildOutput).toContain('Successfully ran target build');
|
|
expect(remoteOutput).toContain('Successfully ran target build');
|
|
|
|
const shellPort = readPort(shell);
|
|
|
|
if (runE2ETests()) {
|
|
// Serve Remote since it is dynamic and won't be started with the host
|
|
const remoteProcess = await runCommandUntil(
|
|
`serve-static ${remote} --no-watch --verbose`,
|
|
() => {
|
|
return true;
|
|
}
|
|
);
|
|
const hostE2eResultsSwc = await runCommandUntil(
|
|
`e2e ${shell}-e2e --no-watch --verbose`,
|
|
(output) => output.includes('All specs passed!')
|
|
);
|
|
|
|
await killProcessAndPorts(remoteProcess.pid, remotePort);
|
|
await killProcessAndPorts(hostE2eResultsSwc.pid, shellPort);
|
|
}
|
|
}, 500_000);
|
|
});
|
|
});
|
|
|
|
function readPort(appName: string): number {
|
|
let config;
|
|
try {
|
|
config = readJson(join('apps', appName, 'project.json'));
|
|
} catch {
|
|
config = readJson(join(appName, 'project.json'));
|
|
}
|
|
return config.targets.serve.options.port;
|
|
}
|