diff --git a/e2e/react/src/react.mfe.test.ts b/e2e/react/src/react.mfe.test.ts
index 7698b6c1ecb5e..94d6485f4b2aa 100644
--- a/e2e/react/src/react.mfe.test.ts
+++ b/e2e/react/src/react.mfe.test.ts
@@ -22,12 +22,16 @@ describe('React MFE', () => {
const remote3 = uniq('remote3');
runCLI(
- `generate @nrwl/react:mfe-host ${shell} --style=css --remotes=${remote1},${remote2},${remote3} --no-interactive`
+ `generate @nrwl/react:mfe-host ${shell} --style=css --remotes=${remote1},${remote2} --no-interactive`
+ );
+ runCLI(
+ `generate @nrwl/react:mfe-remote ${remote3} --style=css --host=${shell} --no-interactive`
);
checkFilesExist(`apps/${shell}/mfe.config.js`);
checkFilesExist(`apps/${remote1}/mfe.config.js`);
checkFilesExist(`apps/${remote2}/mfe.config.js`);
+ checkFilesExist(`apps/${remote3}/mfe.config.js`);
await expect(runCLIAsync(`test ${shell}`)).resolves.toMatchObject({
combinedOutput: expect.stringContaining('Test Suites: 1 passed, 1 total'),
diff --git a/packages/react/src/generators/application/lib/find-free-port.spec.ts b/packages/react/src/generators/application/lib/find-free-port.spec.ts
new file mode 100644
index 0000000000000..0fd55dd357148
--- /dev/null
+++ b/packages/react/src/generators/application/lib/find-free-port.spec.ts
@@ -0,0 +1,41 @@
+import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing';
+import { addProjectConfiguration, Tree } from '@nrwl/devkit';
+
+import { findFreePort } from './find-free-port';
+
+describe('findFreePort', () => {
+ it('should return the largest port + 1', () => {
+ const tree = createTreeWithEmptyWorkspace();
+ addProject(tree, 'app1', 4200);
+ addProject(tree, 'app2', 4201);
+ addProject(tree, 'no-serve');
+
+ const port = findFreePort(tree);
+
+ expect(port).toEqual(4202);
+ });
+
+ it('should default to port 4200', () => {
+ const tree = createTreeWithEmptyWorkspace();
+ addProject(tree, 'no-serve');
+
+ const port = findFreePort(tree);
+
+ expect(port).toEqual(4200);
+ });
+});
+
+function addProject(tree: Tree, name: string, port?: number) {
+ addProjectConfiguration(tree, name, {
+ name: 'app1',
+ root: '/app1',
+ targets: port
+ ? {
+ serve: {
+ executor: '',
+ options: { port },
+ },
+ }
+ : {},
+ });
+}
diff --git a/packages/react/src/generators/application/lib/find-free-port.ts b/packages/react/src/generators/application/lib/find-free-port.ts
new file mode 100644
index 0000000000000..cfa2cdec81ad3
--- /dev/null
+++ b/packages/react/src/generators/application/lib/find-free-port.ts
@@ -0,0 +1,14 @@
+import { Tree } from 'nx/src/config/tree';
+import { getProjects } from '@nrwl/devkit';
+
+export function findFreePort(host: Tree) {
+ const projects = getProjects(host);
+ let port = -Infinity;
+ for (const [, p] of projects.entries()) {
+ const curr = p.targets?.serve?.options?.port;
+ if (typeof curr === 'number') {
+ port = Math.max(port, curr);
+ }
+ }
+ return port > 0 ? port + 1 : 4200;
+}
diff --git a/packages/react/src/generators/application/lib/normalize-options.ts b/packages/react/src/generators/application/lib/normalize-options.ts
index a28a4e51de841..c7a2bca6b6b15 100644
--- a/packages/react/src/generators/application/lib/normalize-options.ts
+++ b/packages/react/src/generators/application/lib/normalize-options.ts
@@ -1,6 +1,7 @@
import { NormalizedSchema, Schema } from '../schema';
import { assertValidStyle } from '../../../utils/assertion';
-import { names, Tree, normalizePath, getWorkspaceLayout } from '@nrwl/devkit';
+import { getWorkspaceLayout, names, normalizePath, Tree } from '@nrwl/devkit';
+import { findFreePort } from './find-free-port';
export function normalizeOptions(
host: Tree,
@@ -34,6 +35,7 @@ export function normalizeOptions(
options.unitTestRunner = options.unitTestRunner ?? 'jest';
options.e2eTestRunner = options.e2eTestRunner ?? 'cypress';
options.compiler = options.compiler ?? 'babel';
+ options.devServerPort ??= findFreePort(host);
return {
...options,
@@ -45,6 +47,5 @@ export function normalizeOptions(
fileName,
styledModule,
hasStyles: options.style !== 'none',
- devServerPort: options.devServerPort ?? 4200,
};
}
diff --git a/packages/react/src/generators/mfe-host/files/mfe/webpack.config.prod.js__tmpl__ b/packages/react/src/generators/mfe-host/files/mfe/webpack.config.prod.js__tmpl__
index bbf8e1f9d4c69..037ace53e6d96 100644
--- a/packages/react/src/generators/mfe-host/files/mfe/webpack.config.prod.js__tmpl__
+++ b/packages/react/src/generators/mfe-host/files/mfe/webpack.config.prod.js__tmpl__
@@ -1 +1,12 @@
-module.exports = require('./webpack.config');
\ No newline at end of file
+const withModuleFederation = require('@nrwl/react/module-federation');
+const mfeConfig = require('./mfe.config');
+
+module.exports = withModuleFederation({
+ ...mfeConfig,
+ // Override remote location for production build.
+ // Each entry is a pair of an unique name and the URL where it is deployed.
+ // remotes: [
+ // ['app1', 'http://app1.example.com/'],
+ // ['app2', 'http://app2.example.com/'],
+ // ],
+});
diff --git a/packages/react/src/generators/mfe-host/lib/update-host-with-remote.ts b/packages/react/src/generators/mfe-host/lib/update-host-with-remote.ts
index 3832261ec358a..8839ad75cb43b 100644
--- a/packages/react/src/generators/mfe-host/lib/update-host-with-remote.ts
+++ b/packages/react/src/generators/mfe-host/lib/update-host-with-remote.ts
@@ -1,8 +1,97 @@
-import { Tree } from '@nrwl/devkit';
-import { Schema } from '../schema';
+import {
+ applyChangesToString,
+ joinPathFragments,
+ logger,
+ names,
+ readProjectConfiguration,
+ Tree,
+} from '@nrwl/devkit';
+import {
+ addRemoteDefinition,
+ addRemoteRoute,
+ addRemoteToMfeConfig,
+} from '../../../mfe/mfe-ast-utils';
+import * as ts from 'typescript';
-export function updateHostWithRemote(host: Tree, options: Schema) {
- // find the host project path
- // Update remotes inside ${host_path}/src/remotes.d.ts
- // Update remotes inside ${host_path}/mfe.config.js
+export function updateHostWithRemote(
+ host: Tree,
+ hostName: string,
+ remoteName: string
+) {
+ const hostConfig = readProjectConfiguration(host, hostName);
+ const mfeConfigPath = joinPathFragments(hostConfig.root, 'mfe.config.js');
+ const remoteDefsPath = joinPathFragments(
+ hostConfig.sourceRoot,
+ 'remotes.d.ts'
+ );
+ const appComponentPath = findAppComponentPath(host, hostConfig.sourceRoot);
+
+ if (host.exists(mfeConfigPath)) {
+ // find the host project path
+ // Update remotes inside ${host_path}/src/remotes.d.ts
+ let sourceCode = host.read(mfeConfigPath).toString();
+ const source = ts.createSourceFile(
+ mfeConfigPath,
+ sourceCode,
+ ts.ScriptTarget.Latest,
+ true
+ );
+ host.write(
+ mfeConfigPath,
+ applyChangesToString(sourceCode, addRemoteToMfeConfig(source, remoteName))
+ );
+ } else {
+ // TODO(jack): Point to the nx.dev guide when ready.
+ logger.warn(
+ `Could not find MFE configuration at ${mfeConfigPath}. Did you generate this project with "@nrwl/react:mfe-host"?`
+ );
+ }
+
+ if (host.exists(remoteDefsPath)) {
+ let sourceCode = host.read(remoteDefsPath).toString();
+ const source = ts.createSourceFile(
+ mfeConfigPath,
+ sourceCode,
+ ts.ScriptTarget.Latest,
+ true
+ );
+ host.write(
+ remoteDefsPath,
+ applyChangesToString(sourceCode, addRemoteDefinition(source, remoteName))
+ );
+ } else {
+ logger.warn(
+ `Could not find remote definitions at ${remoteDefsPath}. Did you generate this project with "@nrwl/react:mfe-host"?`
+ );
+ }
+
+ if (host.exists(appComponentPath)) {
+ let sourceCode = host.read(appComponentPath).toString();
+ const source = ts.createSourceFile(
+ mfeConfigPath,
+ sourceCode,
+ ts.ScriptTarget.Latest,
+ true
+ );
+ host.write(
+ appComponentPath,
+ applyChangesToString(
+ sourceCode,
+ addRemoteRoute(source, names(remoteName))
+ )
+ );
+ } else {
+ logger.warn(
+ `Could not find app component at ${appComponentPath}. Did you generate this project with "@nrwl/react:mfe-host"?`
+ );
+ }
+}
+
+function findAppComponentPath(host: Tree, sourceRoot: string) {
+ const locations = ['app/app.tsx', 'app/App.tsx', 'app.tsx', 'App.tsx'];
+ for (const loc of locations) {
+ if (host.exists(joinPathFragments(sourceRoot, loc))) {
+ return joinPathFragments(sourceRoot, loc);
+ }
+ }
}
diff --git a/packages/react/src/generators/mfe-host/mfe-host.ts b/packages/react/src/generators/mfe-host/mfe-host.ts
index 46306cf7ee963..b904362c8086d 100644
--- a/packages/react/src/generators/mfe-host/mfe-host.ts
+++ b/packages/react/src/generators/mfe-host/mfe-host.ts
@@ -42,3 +42,5 @@ export async function mfeHostGenerator(host: Tree, schema: Schema) {
return initTask;
}
+
+export default mfeHostGenerator;
diff --git a/packages/react/src/generators/mfe-remote/mfe-remote.ts b/packages/react/src/generators/mfe-remote/mfe-remote.ts
index 08e3bddb94bbf..8606049eabf78 100644
--- a/packages/react/src/generators/mfe-remote/mfe-remote.ts
+++ b/packages/react/src/generators/mfe-remote/mfe-remote.ts
@@ -28,6 +28,10 @@ export async function mfeRemoteGenerator(host: Tree, schema: Schema) {
const options = normalizeOptions(host, schema);
const initApp = await applicationGenerator(host, options);
+ if (schema.host) {
+ updateHostWithRemote(host, schema.host, options.name);
+ }
+
// Module federation requires bootstrap code to be dynamically imported.
// Renaming original entry file so we can use `import(./bootstrap)` in
// new entry file.
@@ -38,12 +42,6 @@ export async function mfeRemoteGenerator(host: Tree, schema: Schema) {
addMfeFiles(host, options);
updateMfeProject(host, options);
- if (schema.host) {
- updateHostWithRemote(host, options);
- } else {
- // Log that no host has been passed in so we will use the default project as the host (Only if through CLI)
- // Since Remotes can be generated from the Host Generator we should probably have some identifier to use
- }
if (!options.skipFormat) {
await formatFiles(host);
@@ -51,3 +49,5 @@ export async function mfeRemoteGenerator(host: Tree, schema: Schema) {
return runTasksInSerial(initApp);
}
+
+export default mfeRemoteGenerator;
diff --git a/packages/react/src/generators/mfe-remote/schema.d.ts b/packages/react/src/generators/mfe-remote/schema.d.ts
index 557016de7ebee..e4f88c7e10541 100644
--- a/packages/react/src/generators/mfe-remote/schema.d.ts
+++ b/packages/react/src/generators/mfe-remote/schema.d.ts
@@ -9,10 +9,6 @@ export interface Schema {
directory?: string;
tags?: string;
unitTestRunner: 'jest' | 'none';
- /**
- * @deprecated
- */
- babelJest?: boolean;
e2eTestRunner: 'cypress' | 'none';
linter: Linter;
pascalCaseFiles?: boolean;
diff --git a/packages/react/src/mfe/mfe-ast-utils.spec.ts b/packages/react/src/mfe/mfe-ast-utils.spec.ts
new file mode 100644
index 0000000000000..af204ae946ccd
--- /dev/null
+++ b/packages/react/src/mfe/mfe-ast-utils.spec.ts
@@ -0,0 +1,180 @@
+import * as ts from 'typescript';
+import { applyChangesToString, stripIndents } from '@nrwl/devkit';
+import { addRemoteDefinition, addRemoteToMfeConfig } from './mfe-ast-utils';
+import { addRemoteRoute } from '@nrwl/react/src/mfe/mfe-ast-utils';
+
+describe('addRemoteToMfeConfig', () => {
+ it('should add to existing remotes array', async () => {
+ const sourceCode = stripIndents`
+ module.exports = {
+ name: 'shell',
+ remotes: [
+ 'app1',
+ ['app2','//example.com']
+ ]
+ };
+ `;
+
+ const source = ts.createSourceFile(
+ '/mfe.config.js',
+ sourceCode,
+ ts.ScriptTarget.Latest,
+ true
+ );
+
+ const result = applyChangesToString(
+ sourceCode,
+ addRemoteToMfeConfig(source, 'new-app')
+ );
+
+ expect(result).toEqual(stripIndents`
+ module.exports = {
+ name: 'shell',
+ remotes: [
+ 'app1',
+ ['app2','//example.com'],
+ 'new-app',
+ ]
+ };
+ `);
+ });
+
+ it('should create remotes array if none exist', async () => {
+ const sourceCode = stripIndents`
+ module.exports = {
+ name: 'shell',
+ };
+ `;
+
+ const source = ts.createSourceFile(
+ '/mfe.config.js',
+ sourceCode,
+ ts.ScriptTarget.Latest,
+ true
+ );
+
+ const result = applyChangesToString(
+ sourceCode,
+ addRemoteToMfeConfig(source, 'new-app')
+ );
+
+ expect(result).toEqual(stripIndents`
+ module.exports = {
+ name: 'shell',
+ remotes: ['new-app']
+ };
+ `);
+ });
+
+ it.each`
+ sourceCode
+ ${"console.log('???');"}
+ ${'module.exports = { remotes: {} }'}
+ ${"module.exports = '???';"}
+ `('should skip updates if format not as expected', async ({ sourceCode }) => {
+ const source = ts.createSourceFile(
+ '/mfe.config.js',
+ sourceCode,
+ ts.ScriptTarget.Latest,
+ true
+ );
+
+ const result = applyChangesToString(
+ sourceCode,
+ addRemoteToMfeConfig(source, 'new-app')
+ );
+
+ expect(result).toEqual(sourceCode);
+ });
+});
+
+describe('addRemoteDefinition', () => {
+ it('should add to existing remotes array', async () => {
+ const sourceCode = stripIndents`
+ declare module 'app1/Module';
+ `;
+
+ const source = ts.createSourceFile(
+ '/remotes.d.ts',
+ sourceCode,
+ ts.ScriptTarget.Latest,
+ true
+ );
+
+ const result = applyChangesToString(
+ sourceCode,
+ addRemoteDefinition(source, 'app2')
+ );
+
+ expect(result).toEqual(stripIndents`
+ declare module 'app1/Module';
+ declare module 'app2/Module';
+ `);
+ });
+});
+
+describe('addRemoteRoute', () => {
+ it('should add remote route to host app', async () => {
+ const sourceCode = stripIndents`
+ import * as React from 'react';
+ import { Link, Route, Switch } from 'react-router-dom';
+
+ const App1 = React.lazy(() => import('app1/Module'));
+
+ export function App() {
+ return (
+
+
+
+ } />
+
+
+ );
+ }
+
+ export default App;
+ `;
+
+ const source = ts.createSourceFile(
+ '/apps.tsx',
+ sourceCode,
+ ts.ScriptTarget.Latest,
+ true
+ );
+
+ const result = applyChangesToString(
+ sourceCode,
+ addRemoteRoute(source, { fileName: 'app2', className: 'App2' })
+ );
+
+ expect(result).toEqual(
+ stripIndents`
+ import * as React from 'react';
+ import { Link, Route, Switch } from 'react-router-dom';
+
+ const App2 = React.lazy(() => import('app2/Module'));
+
+ const App1 = React.lazy(() => import('app1/Module'));
+
+ export function App() {
+ return (
+
+
+
+ } />
+ } />
+
+
+ );
+ }
+
+ export default App;
+ `
+ );
+ });
+});
diff --git a/packages/react/src/mfe/mfe-ast-utils.ts b/packages/react/src/mfe/mfe-ast-utils.ts
new file mode 100644
index 0000000000000..b8b78fc3182a3
--- /dev/null
+++ b/packages/react/src/mfe/mfe-ast-utils.ts
@@ -0,0 +1,134 @@
+import * as ts from 'typescript';
+import { ChangeType, StringChange } from '@nrwl/devkit';
+import { findNodes } from '@nrwl/workspace/src/utilities/typescript/find-nodes';
+import {
+ addImport,
+ findClosestOpening,
+ findElements,
+} from '../utils/ast-utils';
+
+export function addRemoteToMfeConfig(
+ source: ts.SourceFile,
+ app: string
+): StringChange[] {
+ const assignments = findNodes(
+ source,
+ ts.SyntaxKind.PropertyAssignment
+ ) as ts.PropertyAssignment[];
+
+ const remotesAssignment = assignments.find(
+ (s) => s.name.getText() === 'remotes'
+ );
+
+ if (remotesAssignment) {
+ const arrayExpression =
+ remotesAssignment.initializer as ts.ArrayLiteralExpression;
+
+ return arrayExpression.elements
+ ? [
+ {
+ type: ChangeType.Insert,
+ index:
+ arrayExpression.elements[arrayExpression.elements.length - 1].end,
+ text: `,`,
+ },
+ {
+ type: ChangeType.Insert,
+ index: remotesAssignment.end - 1,
+ text: `'${app}',\n`,
+ },
+ ]
+ : [];
+ }
+
+ const binaryExpressions = findNodes(
+ source,
+ ts.SyntaxKind.BinaryExpression
+ ) as ts.BinaryExpression[];
+ const exportExpression = binaryExpressions.find((b) => {
+ if (b.left.kind === ts.SyntaxKind.PropertyAccessExpression) {
+ const pae = b.left as ts.PropertyAccessExpression;
+ return (
+ pae.expression.getText() === 'module' &&
+ pae.name.getText() === 'exports'
+ );
+ }
+ });
+
+ if (exportExpression?.right.kind === ts.SyntaxKind.ObjectLiteralExpression) {
+ const ole = exportExpression.right as ts.ObjectLiteralExpression;
+ return [
+ {
+ type: ChangeType.Insert,
+ index: ole.end - 1,
+ text: `remotes: ['${app}']\n`,
+ },
+ ];
+ }
+
+ return [];
+}
+
+export function addRemoteDefinition(
+ source: ts.SourceFile,
+ app: string
+): StringChange[] {
+ return [
+ {
+ type: ChangeType.Insert,
+ index: source.end,
+ text: `\ndeclare module '${app}/Module';`,
+ },
+ ];
+}
+
+export function addRemoteRoute(
+ source: ts.SourceFile,
+ names: {
+ fileName: string;
+ className: string;
+ }
+): StringChange[] {
+ const routes = findElements(source, 'Route');
+ const links = findElements(source, 'Link');
+
+ if (routes.length === 0) {
+ return [];
+ } else {
+ const changes: StringChange[] = [];
+ const firstRoute = routes[0];
+ const firstLink = links[0];
+
+ changes.push(
+ ...addImport(
+ source,
+ `const ${names.className} = React.lazy(() => import('${names.fileName}/Module'));`
+ )
+ );
+
+ changes.push({
+ type: ChangeType.Insert,
+ index: firstRoute.end,
+ text: `\n <${names.className} />} />`,
+ });
+
+ if (firstLink) {
+ const parentLi = findClosestOpening('li', firstLink);
+ if (parentLi) {
+ changes.push({
+ type: ChangeType.Insert,
+ index: parentLi.end,
+ text: `\n${names.className}`,
+ });
+ } else {
+ changes.push({
+ type: ChangeType.Insert,
+ index: firstLink.parent.end,
+ text: `\n${names.className}`,
+ });
+ }
+ }
+
+ return changes;
+ }
+}