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; + } +}