diff --git a/docs/generated/packages/react.json b/docs/generated/packages/react.json index 2408fc527bc75..1ef50ca916951 100644 --- a/docs/generated/packages/react.json +++ b/docs/generated/packages/react.json @@ -866,7 +866,413 @@ "implementation": "/packages/react/src/generators/hook/hook#hookGenerator.ts", "hidden": false, "path": "/packages/react/src/generators/hook/schema.json" + }, + { + "name": "mfe-host", + "factory": "./src/generators/mfe-host/mfe-host#mfeHostGenerator", + "schema": { + "$schema": "http://json-schema.org/schema", + "$id": "GeneratorReactMFEHost", + "cli": "nx", + "title": "Generate Module Federation Setup for React Host App", + "description": "Create Module Federation configuration files for given React Host Application.", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the host application to generate the Module Federation configuration", + "$default": { "$source": "argv", "index": 0 }, + "x-prompt": "What name would you like to use as the host application?", + "pattern": "^[a-zA-Z].*$" + }, + "directory": { + "description": "The directory of the new application.", + "type": "string", + "alias": "dir" + }, + "style": { + "description": "The file extension to be used for style files.", + "type": "string", + "default": "css", + "alias": "s", + "x-prompt": { + "message": "Which stylesheet format would you like to use?", + "type": "list", + "items": [ + { "value": "css", "label": "CSS" }, + { + "value": "scss", + "label": "SASS(.scss) [ http://sass-lang.com ]" + }, + { + "value": "styl", + "label": "Stylus(.styl) [ http://stylus-lang.com ]" + }, + { + "value": "less", + "label": "LESS [ http://lesscss.org ]" + }, + { + "value": "styled-components", + "label": "styled-components [ https://styled-components.com ]" + }, + { + "value": "@emotion/styled", + "label": "emotion [ https://emotion.sh ]" + }, + { + "value": "styled-jsx", + "label": "styled-jsx [ https://www.npmjs.com/package/styled-jsx ]" + }, + { "value": "none", "label": "None" } + ] + } + }, + "linter": { + "description": "The tool to use for running lint checks.", + "type": "string", + "enum": ["eslint", "tslint"], + "default": "eslint" + }, + "skipFormat": { + "description": "Skip formatting files.", + "type": "boolean", + "default": false + }, + "skipWorkspaceJson": { + "description": "Skip updating workspace.json with default options based on values provided to this app (e.g. babel, style).", + "type": "boolean", + "default": false + }, + "unitTestRunner": { + "type": "string", + "enum": ["jest", "none"], + "description": "Test runner to use for unit tests.", + "default": "jest" + }, + "e2eTestRunner": { + "type": "string", + "enum": ["cypress", "none"], + "description": "Test runner to use for end to end (e2e) tests.", + "default": "cypress" + }, + "tags": { + "type": "string", + "description": "Add tags to the application (used for linting).", + "alias": "t" + }, + "pascalCaseFiles": { + "type": "boolean", + "description": "Use pascal case component file name (e.g. App.tsx).", + "alias": "P", + "default": false + }, + "classComponent": { + "type": "boolean", + "description": "Use class components instead of functional component.", + "alias": "C", + "default": false + }, + "js": { + "type": "boolean", + "description": "Generate JavaScript files rather than TypeScript files.", + "default": false + }, + "globalCss": { + "type": "boolean", + "description": "Default is false. When true, the component is generated with *.css/*.scss instead of *.module.css/*.module.scss", + "default": false + }, + "strict": { + "type": "boolean", + "description": "Creates an application with strict mode and strict type checking", + "default": true + }, + "setParserOptionsProject": { + "type": "boolean", + "description": "Whether or not to configure the ESLint \"parserOptions.project\" option. We do not do this by default for lint performance reasons.", + "default": false + }, + "standaloneConfig": { + "description": "Split the project configuration into /project.json rather than including it inside workspace.json", + "type": "boolean" + }, + "compiler": { + "type": "string", + "description": "The compiler to use", + "enum": ["babel", "swc"], + "default": "babel" + }, + "remotes": { + "type": "array", + "description": "A list of remote application names that the host application should consume.", + "default": [] + }, + "devServerPort": { + "type": "number", + "description": "The port for the dev server of the remote app." + } + }, + "required": ["name"], + "additionalProperties": false, + "presets": [] + }, + "description": "Generate a host react application", + "aliases": "host", + "implementation": "/packages/react/src/generators/mfe-host/mfe-host#mfeHostGenerator.ts", + "hidden": false, + "path": "/packages/react/src/generators/mfe-host/schema.json" + }, + { + "name": "mfe-remote", + "factory": "./src/generators/mfe-remote/mfe-remote#mfeRemoteGenerator", + "schema": { + "$schema": "http://json-schema.org/schema", + "$id": "GeneratorReactMFERemote", + "cli": "nx", + "title": "Generate Module Federation Setup for React Remote App", + "description": "Create Module Federation configuration files for given React Remote Application.", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the remote application to generate the Module Federation configuration", + "$default": { "$source": "argv", "index": 0 }, + "x-prompt": "What name would you like to use as the remote application?", + "pattern": "^[a-zA-Z].*$" + }, + "directory": { + "description": "The directory of the new application.", + "type": "string", + "alias": "dir" + }, + "style": { + "description": "The file extension to be used for style files.", + "type": "string", + "default": "css", + "alias": "s", + "x-prompt": { + "message": "Which stylesheet format would you like to use?", + "type": "list", + "items": [ + { "value": "css", "label": "CSS" }, + { + "value": "scss", + "label": "SASS(.scss) [ http://sass-lang.com ]" + }, + { + "value": "styl", + "label": "Stylus(.styl) [ http://stylus-lang.com ]" + }, + { + "value": "less", + "label": "LESS [ http://lesscss.org ]" + }, + { + "value": "styled-components", + "label": "styled-components [ https://styled-components.com ]" + }, + { + "value": "@emotion/styled", + "label": "emotion [ https://emotion.sh ]" + }, + { + "value": "styled-jsx", + "label": "styled-jsx [ https://www.npmjs.com/package/styled-jsx ]" + }, + { "value": "none", "label": "None" } + ] + } + }, + "linter": { + "description": "The tool to use for running lint checks.", + "type": "string", + "enum": ["eslint", "tslint"], + "default": "eslint" + }, + "routing": { + "type": "boolean", + "description": "Generate application with routes.", + "default": false + }, + "skipFormat": { + "description": "Skip formatting files.", + "type": "boolean", + "default": false + }, + "skipWorkspaceJson": { + "description": "Skip updating workspace.json with default options based on values provided to this app (e.g. babel, style).", + "type": "boolean", + "default": false + }, + "unitTestRunner": { + "type": "string", + "enum": ["jest", "none"], + "description": "Test runner to use for unit tests.", + "default": "jest" + }, + "e2eTestRunner": { + "type": "string", + "enum": ["cypress", "none"], + "description": "Test runner to use for end to end (e2e) tests.", + "default": "cypress" + }, + "tags": { + "type": "string", + "description": "Add tags to the application (used for linting).", + "alias": "t" + }, + "pascalCaseFiles": { + "type": "boolean", + "description": "Use pascal case component file name (e.g. App.tsx).", + "alias": "P", + "default": false + }, + "classComponent": { + "type": "boolean", + "description": "Use class components instead of functional component.", + "alias": "C", + "default": false + }, + "js": { + "type": "boolean", + "description": "Generate JavaScript files rather than TypeScript files.", + "default": false + }, + "globalCss": { + "type": "boolean", + "description": "Default is false. When true, the component is generated with *.css/*.scss instead of *.module.css/*.module.scss.", + "default": false + }, + "strict": { + "type": "boolean", + "description": "Creates an application with strict mode and strict type checking.", + "default": true + }, + "setParserOptionsProject": { + "type": "boolean", + "description": "Whether or not to configure the ESLint \"parserOptions.project\" option. We do not do this by default for lint performance reasons.", + "default": false + }, + "standaloneConfig": { + "description": "Split the project configuration into /project.json rather than including it inside workspace.json", + "type": "boolean" + }, + "compiler": { + "type": "string", + "description": "The compiler to use.", + "enum": ["babel", "swc"], + "default": "babel" + }, + "host": { + "type": "string", + "description": "The host / shell application for this remote." + }, + "devServerPort": { + "type": "number", + "description": "The port for the dev server of the remote app." + } + }, + "required": ["name"], + "additionalProperties": false, + "presets": [] + }, + "description": "Generate a remote react application", + "aliases": "remote", + "implementation": "/packages/react/src/generators/mfe-remote/mfe-remote#mfeRemoteGenerator.ts", + "hidden": false, + "path": "/packages/react/src/generators/mfe-remote/schema.json" } ], - "executors": [] + "executors": [ + { + "name": "mfe-dev-server", + "implementation": "/packages/react/src/executors/mfe-dev-server/mfe-dev-server.impl.ts", + "schema": { + "title": "Web Dev Server", + "description": "Serve a web application.", + "cli": "nx", + "type": "object", + "properties": { + "buildTarget": { + "type": "string", + "description": "Target which builds the application." + }, + "port": { + "type": "number", + "description": "Port to listen on.", + "default": 4200 + }, + "host": { + "type": "string", + "description": "Host to listen on.", + "default": "localhost" + }, + "ssl": { + "type": "boolean", + "description": "Serve using `HTTPS`.", + "default": false + }, + "sslKey": { + "type": "string", + "description": "SSL key to use for serving `HTTPS`." + }, + "sslCert": { + "type": "string", + "description": "SSL certificate to use for serving `HTTPS`." + }, + "watch": { + "type": "boolean", + "description": "Watches for changes and rebuilds application.", + "default": true + }, + "liveReload": { + "type": "boolean", + "description": "Whether to reload the page on change, using live-reload.", + "default": true + }, + "hmr": { + "type": "boolean", + "description": "Enable hot module replacement.", + "default": false + }, + "publicHost": { + "type": "string", + "description": "Public URL where the application will be served." + }, + "open": { + "type": "boolean", + "description": "Open the application in the browser.", + "default": false + }, + "allowedHosts": { + "type": "string", + "description": "This option allows you to whitelist services that are allowed to access the dev server." + }, + "memoryLimit": { + "type": "number", + "description": "Memory limit for type checking service process in `MB`." + }, + "maxWorkers": { + "type": "number", + "description": "Number of workers to use for type checking." + }, + "baseHref": { + "type": "string", + "description": "Base url for the application being built." + }, + "apps": { + "type": "array", + "items": { "type": "string" }, + "description": "List of remote applications to serve in addition to the host application." + } + }, + "presets": [] + }, + "description": "Serve an MFE host or remote application.", + "aliases": [], + "hidden": false, + "path": "/packages/react/src/executors/mfe-dev-server/schema.json" + } + ] } diff --git a/docs/packages.json b/docs/packages.json index 5e7f694e26df2..397039021a807 100644 --- a/docs/packages.json +++ b/docs/packages.json @@ -192,7 +192,7 @@ "name": "react", "path": "generated/packages/react.json", "schemas": { - "executors": [], + "executors": ["mfe-dev-server"], "generators": [ "init", "application", @@ -204,7 +204,9 @@ "component-story", "stories", "component-cypress-spec", - "hook" + "hook", + "mfe-host", + "mfe-remote" ] } }, diff --git a/e2e/react/src/react.mfe.test.ts b/e2e/react/src/react.mfe.test.ts new file mode 100644 index 0000000000000..7698b6c1ecb5e --- /dev/null +++ b/e2e/react/src/react.mfe.test.ts @@ -0,0 +1,95 @@ +import { stripIndents } from '@nrwl/devkit'; +import { + checkFilesExist, + killPorts, + newProject, + readProjectConfig, + runCLI, + runCLIAsync, + uniq, + updateFile, +} from '@nrwl/e2e/utils'; + +describe('React MFE', () => { + let proj: string; + + beforeEach(() => (proj = newProject())); + + it('should generate host and remote apps', async () => { + const shell = uniq('shell'); + const remote1 = uniq('remote1'); + const remote2 = uniq('remote2'); + const remote3 = uniq('remote3'); + + runCLI( + `generate @nrwl/react:mfe-host ${shell} --style=css --remotes=${remote1},${remote2},${remote3} --no-interactive` + ); + + checkFilesExist(`apps/${shell}/mfe.config.js`); + checkFilesExist(`apps/${remote1}/mfe.config.js`); + checkFilesExist(`apps/${remote2}/mfe.config.js`); + + await expect(runCLIAsync(`test ${shell}`)).resolves.toMatchObject({ + combinedOutput: expect.stringContaining('Test Suites: 1 passed, 1 total'), + }); + + updateFile( + `apps/${shell}/webpack.config.js`, + stripIndents` + const withModuleFederation = require('@nrwl/react/module-federation'); + const mfeConfig = require('./mfe.config'); + + module.exports = withModuleFederation({ + ...mfeConfig, + remotes: [ + ['${remote1}', '${remote1}@http://localhost:${readPort( + remote1 + )}/remoteEntry.js'], + ['${remote2}', 'http://localhost:${readPort( + remote2 + )}/remoteEntry.js'], + ['${remote3}', 'http://localhost:${readPort(remote3)}'], + ], + }); + ` + ); + + 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}'); + }); + + it('should load remote 3', () => { + cy.visit('/${remote3}') + getGreeting().contains('Welcome ${remote3}'); + }); + }); + ` + ); + + const e2eResults = runCLI(`e2e ${shell}-e2e --no-watch`); + expect(e2eResults).toContain('All specs passed!'); + expect(await killPorts()).toBeTruthy(); + }, 500_000); + + function readPort(appName: string): number { + const config = readProjectConfig(appName); + return config.targets.serve.options.port; + } +}); diff --git a/packages/react/executors.json b/packages/react/executors.json new file mode 100644 index 0000000000000..a877591633471 --- /dev/null +++ b/packages/react/executors.json @@ -0,0 +1,16 @@ +{ + "builders": { + "mfe-dev-server": { + "implementation": "./src/executors/mfe-dev-server/compat", + "schema": "./src/executors/mfe-dev-server/schema.json", + "description": "Serve an MFE host or remote application." + } + }, + "executors": { + "mfe-dev-server": { + "implementation": "./src/executors/mfe-dev-server/mfe-dev-server.impl", + "schema": "./src/executors/mfe-dev-server/schema.json", + "description": "Serve an MFE host or remote application." + } + } +} diff --git a/packages/react/generators.json b/packages/react/generators.json index 342574c12c63a..c03e879fd618d 100644 --- a/packages/react/generators.json +++ b/packages/react/generators.json @@ -162,6 +162,20 @@ "schema": "./src/generators/hook/schema.json", "description": "Create a hook.", "aliases": "c" + }, + + "mfe-host": { + "factory": "./src/generators/mfe-host/mfe-host#mfeHostGenerator", + "schema": "./src/generators/mfe-host/schema.json", + "description": "Generate a host react application", + "aliases": "host" + }, + + "mfe-remote": { + "factory": "./src/generators/mfe-remote/mfe-remote#mfeRemoteGenerator", + "schema": "./src/generators/mfe-remote/schema.json", + "description": "Generate a remote react application", + "aliases": "remote" } } } diff --git a/packages/react/index.ts b/packages/react/index.ts index 2aec4830f43f9..e9c6a07620353 100644 --- a/packages/react/index.ts +++ b/packages/react/index.ts @@ -16,4 +16,5 @@ export { reduxGenerator } from './src/generators/redux/redux'; export { storiesGenerator } from './src/generators/stories/stories'; export { storybookConfigurationGenerator } from './src/generators/storybook-configuration/configuration'; export { storybookMigration5to6Generator } from './src/generators/storybook-migrate-defaults-5-to-6/migrate-defaults-5-to-6'; +export { mfeHostGenerator } from './src/generators/mfe-host/mfe-host'; export type { SupportedStyles } from './typings/style'; diff --git a/packages/react/module-federation.ts b/packages/react/module-federation.ts new file mode 100644 index 0000000000000..002d0bf100532 --- /dev/null +++ b/packages/react/module-federation.ts @@ -0,0 +1,7 @@ +import { withModuleFederation } from './src/mfe/with-module-federation'; + +export * from './src/mfe/webpack-utils'; +export * from './src/mfe/with-module-federation'; +export { withModuleFederation as default }; + +module.exports = withModuleFederation; diff --git a/packages/react/package.json b/packages/react/package.json index f21dcdfc375b6..d761f5a1b2f6e 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -23,6 +23,7 @@ "url": "https://github.com/nrwl/nx/issues" }, "homepage": "https://nx.dev", + "builders": "./executors.json", "schematics": "./generators.json", "ng-update": { "requirements": {}, diff --git a/packages/react/plugins/webpack.ts b/packages/react/plugins/webpack.ts index c487717f2bb9c..f5dfe8ae29bca 100644 --- a/packages/react/plugins/webpack.ts +++ b/packages/react/plugins/webpack.ts @@ -2,7 +2,7 @@ import type { Configuration } from 'webpack'; import ReactRefreshPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); // Add React-specific configuration -function getWebpackConfig(config: Configuration) { +export function getWebpackConfig(config: Configuration) { config.module.rules.push({ test: /\.svg$/, oneOf: [ diff --git a/packages/react/src/executors/mfe-dev-server/compat.ts b/packages/react/src/executors/mfe-dev-server/compat.ts new file mode 100644 index 0000000000000..c1f871e2220c8 --- /dev/null +++ b/packages/react/src/executors/mfe-dev-server/compat.ts @@ -0,0 +1,5 @@ +import { convertNxExecutor } from '@nrwl/devkit'; + +import mfeDevServer from './mfe-dev-server.impl'; + +export default convertNxExecutor(mfeDevServer); diff --git a/packages/react/src/executors/mfe-dev-server/mfe-dev-server.impl.ts b/packages/react/src/executors/mfe-dev-server/mfe-dev-server.impl.ts new file mode 100644 index 0000000000000..674edc9ed1bb8 --- /dev/null +++ b/packages/react/src/executors/mfe-dev-server/mfe-dev-server.impl.ts @@ -0,0 +1,111 @@ +import { ExecutorContext, runExecutor } from '@nrwl/devkit'; +import devServerExecutor, { + WebDevServerOptions, +} from '@nrwl/web/src/executors/dev-server/dev-server.impl'; +import { join } from 'path'; + +type MFEDevServerOptions = WebDevServerOptions & { + apps?: string[]; +}; + +export default async function* mfeDevServer( + options: MFEDevServerOptions, + context: ExecutorContext +) { + let iter = devServerExecutor(options, context); + const p = context.workspace.projects[context.projectName]; + + const mfeConfigPath = join(context.root, p.root, 'mfe.config.js'); + + let mfeConfig: any; + try { + mfeConfig = require(mfeConfigPath); + } catch { + // TODO(jack): Add a link to guide + throw new Error( + `Could not load ${mfeConfigPath}. Was this project generated with "@nrwl/react:mfe-host"?` + ); + } + + // Remotes can be specified with a custom location + // e.g. + // ``` + // remotes: ['app1', 'http://example.com'] + // ``` + // This shouldn't happen for local dev, but we support it regardless. + let apps = options.apps ?? mfeConfig.remotes ?? []; + apps = apps.map((a) => (Array.isArray(a) ? a[0] : a)); + + for (const app of apps) { + iter = combineAsyncIterators( + iter, + await runExecutor( + { + project: app, + target: 'serve', + configuration: context.configurationName, + }, + {}, + context + ) + ); + } + + return yield* iter; +} + +// TODO(jack): Extract this helper +function getNextAsyncIteratorFactory(options) { + return async (asyncIterator, index) => { + try { + const iterator = await asyncIterator.next(); + + return { index, iterator }; + } catch (err) { + if (options.errorCallback) { + options.errorCallback(err, index); + } + if (options.throwError !== false) { + return Promise.reject(err); + } + + return { index, iterator: { done: true } }; + } + }; +} + +async function* combineAsyncIterators( + ...iterators: { 0: AsyncIterator } & AsyncIterator[] +) { + let [options] = iterators; + if (typeof options.next === 'function') { + options = Object.create(null); + } else { + iterators.shift(); + } + + const getNextAsyncIteratorValue = getNextAsyncIteratorFactory(options); + + try { + const asyncIteratorsValues = new Map( + iterators.map((it, idx) => [idx, getNextAsyncIteratorValue(it, idx)]) + ); + + do { + const { iterator, index } = await Promise.race( + asyncIteratorsValues.values() + ); + if (iterator.done) { + asyncIteratorsValues.delete(index); + } else { + yield iterator.value; + asyncIteratorsValues.set( + index, + getNextAsyncIteratorValue(iterators[index], index) + ); + } + } while (asyncIteratorsValues.size > 0); + } finally { + await Promise.allSettled(iterators.map((it) => it.return())); + } +} diff --git a/packages/react/src/executors/mfe-dev-server/schema.json b/packages/react/src/executors/mfe-dev-server/schema.json new file mode 100644 index 0000000000000..3d6102cc612a4 --- /dev/null +++ b/packages/react/src/executors/mfe-dev-server/schema.json @@ -0,0 +1,82 @@ +{ + "title": "Web Dev Server", + "description": "Serve a web application.", + "cli": "nx", + "type": "object", + "properties": { + "buildTarget": { + "type": "string", + "description": "Target which builds the application." + }, + "port": { + "type": "number", + "description": "Port to listen on.", + "default": 4200 + }, + "host": { + "type": "string", + "description": "Host to listen on.", + "default": "localhost" + }, + "ssl": { + "type": "boolean", + "description": "Serve using `HTTPS`.", + "default": false + }, + "sslKey": { + "type": "string", + "description": "SSL key to use for serving `HTTPS`." + }, + "sslCert": { + "type": "string", + "description": "SSL certificate to use for serving `HTTPS`." + }, + "watch": { + "type": "boolean", + "description": "Watches for changes and rebuilds application.", + "default": true + }, + "liveReload": { + "type": "boolean", + "description": "Whether to reload the page on change, using live-reload.", + "default": true + }, + "hmr": { + "type": "boolean", + "description": "Enable hot module replacement.", + "default": false + }, + "publicHost": { + "type": "string", + "description": "Public URL where the application will be served." + }, + "open": { + "type": "boolean", + "description": "Open the application in the browser.", + "default": false + }, + "allowedHosts": { + "type": "string", + "description": "This option allows you to whitelist services that are allowed to access the dev server." + }, + "memoryLimit": { + "type": "number", + "description": "Memory limit for type checking service process in `MB`." + }, + "maxWorkers": { + "type": "number", + "description": "Number of workers to use for type checking." + }, + "baseHref": { + "type": "string", + "description": "Base url for the application being built." + }, + "apps": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of remote applications to serve in addition to the host application." + } + } +} diff --git a/packages/react/src/generators/application/lib/normalize-options.ts b/packages/react/src/generators/application/lib/normalize-options.ts index aa495a992b268..a28a4e51de841 100644 --- a/packages/react/src/generators/application/lib/normalize-options.ts +++ b/packages/react/src/generators/application/lib/normalize-options.ts @@ -45,5 +45,6 @@ export function normalizeOptions( fileName, styledModule, hasStyles: options.style !== 'none', + devServerPort: options.devServerPort ?? 4200, }; } diff --git a/packages/react/src/generators/application/schema.d.ts b/packages/react/src/generators/application/schema.d.ts index 9046036eadb03..6bdf4678c6ec6 100644 --- a/packages/react/src/generators/application/schema.d.ts +++ b/packages/react/src/generators/application/schema.d.ts @@ -24,6 +24,8 @@ export interface Schema { setParserOptionsProject?: boolean; standaloneConfig?: boolean; compiler?: 'babel' | 'swc'; + remotes?: string[]; + devServerPort?: number; } export interface NormalizedSchema extends Schema { diff --git a/packages/react/src/generators/mfe-host/files/common/src/app/__fileName__.tsx__tmpl__ b/packages/react/src/generators/mfe-host/files/common/src/app/__fileName__.tsx__tmpl__ new file mode 100644 index 0000000000000..4473087795730 --- /dev/null +++ b/packages/react/src/generators/mfe-host/files/common/src/app/__fileName__.tsx__tmpl__ @@ -0,0 +1,29 @@ +import * as React from 'react'; +import NxWelcome from "./nx-welcome"; +<% if (remotes.length > 0) { %> +import { Link, Route, Switch } from 'react-router-dom'; + +<% remotes.forEach(function(r) { %> + const <%= r.className %> = React.lazy(() => import('<%= r.fileName %>/Module')); + <% }); %> +<% } %> +export function App() { + return ( + +
    +
  • Home
  • + <% remotes.forEach(function(r) { %> +
  • <%=r.className%>
  • + <% }); %> +
+ + } /> + <% remotes.forEach(function(r) { %> + <<%= r.className %>/>} /> + <% }); %> + +
+ ); +} + +export default App; diff --git a/packages/react/src/generators/mfe-host/files/mfe/mfe.config.js__tmpl__ b/packages/react/src/generators/mfe-host/files/mfe/mfe.config.js__tmpl__ new file mode 100644 index 0000000000000..8501dc91cc9c9 --- /dev/null +++ b/packages/react/src/generators/mfe-host/files/mfe/mfe.config.js__tmpl__ @@ -0,0 +1,6 @@ +module.exports = { + name: '<%= projectName %>', + remotes: [ + <% remotes.forEach(function(r) {%> "<%= r.fileName %>", <% }); %> + ], +} \ No newline at end of file diff --git a/packages/react/src/generators/mfe-host/files/mfe/src/main.ts__tmpl__ b/packages/react/src/generators/mfe-host/files/mfe/src/main.ts__tmpl__ new file mode 100644 index 0000000000000..137c64f9f4475 --- /dev/null +++ b/packages/react/src/generators/mfe-host/files/mfe/src/main.ts__tmpl__ @@ -0,0 +1 @@ +import('./bootstrap'); \ No newline at end of file diff --git a/packages/react/src/generators/mfe-host/files/mfe/src/remotes.d.ts__tmpl__ b/packages/react/src/generators/mfe-host/files/mfe/src/remotes.d.ts__tmpl__ new file mode 100644 index 0000000000000..1a6f4583c7541 --- /dev/null +++ b/packages/react/src/generators/mfe-host/files/mfe/src/remotes.d.ts__tmpl__ @@ -0,0 +1,4 @@ + // Declare your remote Modules here + // Example declare module 'about/Module'; + + <% remotes.forEach(function(r) { %>declare module '<%= r.fileName %>/Module';<% }); %> \ No newline at end of file diff --git a/packages/react/src/generators/mfe-host/files/mfe/webpack.config.js__tmpl__ b/packages/react/src/generators/mfe-host/files/mfe/webpack.config.js__tmpl__ new file mode 100644 index 0000000000000..1075d9b167418 --- /dev/null +++ b/packages/react/src/generators/mfe-host/files/mfe/webpack.config.js__tmpl__ @@ -0,0 +1,6 @@ +const withModuleFederation = require('@nrwl/react/module-federation'); +const mfeConfig = require('./mfe.config'); + +module.exports = withModuleFederation({ + ...mfeConfig, +}); \ No newline at end of file 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__ new file mode 100644 index 0000000000000..bbf8e1f9d4c69 --- /dev/null +++ b/packages/react/src/generators/mfe-host/files/mfe/webpack.config.prod.js__tmpl__ @@ -0,0 +1 @@ +module.exports = require('./webpack.config'); \ No newline at end of file diff --git a/packages/react/src/generators/mfe-host/lib/add-mfe.ts b/packages/react/src/generators/mfe-host/lib/add-mfe.ts new file mode 100644 index 0000000000000..0d60d51432f4c --- /dev/null +++ b/packages/react/src/generators/mfe-host/lib/add-mfe.ts @@ -0,0 +1,35 @@ +import { NormalizedSchema } from '../schema'; +import { generateFiles, names } from '@nrwl/devkit'; +import { join } from 'path'; + +export function addMFEFiles(host, options: NormalizedSchema) { + const templateVariables = { + ...names(options.name), + ...options, + tmpl: '', + remotes: options.remotes.map((r) => names(r)), + }; + + // Module federation requires bootstrap code to be dynamically imported. + // Renaming original entry file so we can use `import(./bootstrap)` in + // new entry file. + host.rename( + join(options.appProjectRoot, 'src/main.tsx'), + join(options.appProjectRoot, 'src/bootstrap.tsx') + ); + + // New entry file is created here. + generateFiles( + host, + join(__dirname, `../files/mfe`), + options.appProjectRoot, + templateVariables + ); + + generateFiles( + host, + join(__dirname, `../files/common`), + options.appProjectRoot, + templateVariables + ); +} 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 new file mode 100644 index 0000000000000..3832261ec358a --- /dev/null +++ b/packages/react/src/generators/mfe-host/lib/update-host-with-remote.ts @@ -0,0 +1,8 @@ +import { Tree } from '@nrwl/devkit'; +import { Schema } from '../schema'; + +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 +} diff --git a/packages/react/src/generators/mfe-host/lib/update-mfe-e2e-project.ts b/packages/react/src/generators/mfe-host/lib/update-mfe-e2e-project.ts new file mode 100644 index 0000000000000..086d2e2c1b4e5 --- /dev/null +++ b/packages/react/src/generators/mfe-host/lib/update-mfe-e2e-project.ts @@ -0,0 +1,20 @@ +import { Tree } from 'nx/src/shared/tree'; +import { NormalizedSchema } from '../schema'; +import { + readProjectConfiguration, + updateProjectConfiguration, +} from '@nrwl/devkit'; + +export function updateMfeE2eProject(host: Tree, options: NormalizedSchema) { + const e2eName = `${options.name}-e2e`; + try { + let projectConfig = readProjectConfiguration(host, e2eName); + projectConfig.targets.e2e.options = { + ...projectConfig.targets.e2e.options, + baseUrl: 'http://localhost:4200', + }; + updateProjectConfiguration(host, e2eName, projectConfig); + } catch { + // nothing + } +} diff --git a/packages/react/src/generators/mfe-host/lib/update-mfe-project.ts b/packages/react/src/generators/mfe-host/lib/update-mfe-project.ts new file mode 100644 index 0000000000000..a87180086fb4c --- /dev/null +++ b/packages/react/src/generators/mfe-host/lib/update-mfe-project.ts @@ -0,0 +1,18 @@ +import { Tree } from 'nx/src/shared/tree'; +import { NormalizedSchema } from '../schema'; +import { + readProjectConfiguration, + updateProjectConfiguration, +} from '@nrwl/devkit'; + +export function updateMfeProject(host: Tree, options: NormalizedSchema) { + let projectConfig = readProjectConfiguration(host, options.name); + projectConfig.targets.build.options = { + ...projectConfig.targets.build.options, + main: `${options.appProjectRoot}/src/main.ts`, + webpackConfig: `${options.appProjectRoot}/webpack.config.js`, + }; + projectConfig.targets.serve.executor = '@nrwl/react:mfe-dev-server'; + projectConfig.targets.serve.options.port = options.devServerPort; + updateProjectConfiguration(host, options.name, projectConfig); +} diff --git a/packages/react/src/generators/mfe-host/mfe-host.ts b/packages/react/src/generators/mfe-host/mfe-host.ts new file mode 100644 index 0000000000000..46306cf7ee963 --- /dev/null +++ b/packages/react/src/generators/mfe-host/mfe-host.ts @@ -0,0 +1,44 @@ +import { formatFiles, Tree } from '@nrwl/devkit'; +import { Schema } from './schema'; +import applicationGenerator from '../application/application'; +import { normalizeOptions } from '../application/lib/normalize-options'; +import { addMFEFiles } from './lib/add-mfe'; +import { updateMfeProject } from './lib/update-mfe-project'; +import { mfeRemoteGenerator } from '../mfe-remote/mfe-remote'; +import { updateMfeE2eProject } from './lib/update-mfe-e2e-project'; + +export async function mfeHostGenerator(host: Tree, schema: Schema) { + const options = normalizeOptions(host, schema); + + const initTask = await applicationGenerator(host, { + ...options, + // The target use-case for MFE is loading remotes as child routes, thus always enable routing. + routing: true, + }); + + addMFEFiles(host, options); + updateMfeProject(host, options); + updateMfeE2eProject(host, options); + + if (schema.remotes) { + let remotePort = options.devServerPort + 1; + for (const remote of schema.remotes) { + await mfeRemoteGenerator(host, { + name: remote, + style: options.style, + skipFormat: options.skipFormat, + unitTestRunner: options.unitTestRunner, + e2eTestRunner: options.e2eTestRunner, + linter: options.linter, + devServerPort: remotePort, + }); + remotePort++; + } + } + + if (!options.skipFormat) { + await formatFiles(host); + } + + return initTask; +} diff --git a/packages/react/src/generators/mfe-host/schema.d.ts b/packages/react/src/generators/mfe-host/schema.d.ts new file mode 100644 index 0000000000000..d18eecf6e27f4 --- /dev/null +++ b/packages/react/src/generators/mfe-host/schema.d.ts @@ -0,0 +1,28 @@ +import { Linter } from '@nrwl/linter'; +import { SupportedStyles } from '../../../typings'; + +export interface Schema { + name: string; + style: SupportedStyles; + skipFormat: boolean; + directory?: string; + tags?: string; + unitTestRunner: 'jest' | 'none'; + e2eTestRunner: 'cypress' | 'none'; + linter: Linter; + pascalCaseFiles?: boolean; + classComponent?: boolean; + skipWorkspaceJson?: boolean; + js?: boolean; + globalCss?: boolean; + strict?: boolean; + setParserOptionsProject?: boolean; + standaloneConfig?: boolean; + compiler?: 'babel' | 'swc'; + devServerPort?: number; + remotes?: string[]; +} + +export interface NormalizedSchema extends Schema { + appProjectRoot: string; +} diff --git a/packages/react/src/generators/mfe-host/schema.json b/packages/react/src/generators/mfe-host/schema.json new file mode 100644 index 0000000000000..d199c5888a49f --- /dev/null +++ b/packages/react/src/generators/mfe-host/schema.json @@ -0,0 +1,155 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "GeneratorReactMFEHost", + "cli": "nx", + "title": "Generate Module Federation Setup for React Host App", + "description": "Create Module Federation configuration files for given React Host Application.", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the host application to generate the Module Federation configuration", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What name would you like to use as the host application?", + "pattern": "^[a-zA-Z].*$" + }, + "directory": { + "description": "The directory of the new application.", + "type": "string", + "alias": "dir" + }, + "style": { + "description": "The file extension to be used for style files.", + "type": "string", + "default": "css", + "alias": "s", + "x-prompt": { + "message": "Which stylesheet format would you like to use?", + "type": "list", + "items": [ + { + "value": "css", + "label": "CSS" + }, + { + "value": "scss", + "label": "SASS(.scss) [ http://sass-lang.com ]" + }, + { + "value": "styl", + "label": "Stylus(.styl) [ http://stylus-lang.com ]" + }, + { + "value": "less", + "label": "LESS [ http://lesscss.org ]" + }, + { + "value": "styled-components", + "label": "styled-components [ https://styled-components.com ]" + }, + { + "value": "@emotion/styled", + "label": "emotion [ https://emotion.sh ]" + }, + { + "value": "styled-jsx", + "label": "styled-jsx [ https://www.npmjs.com/package/styled-jsx ]" + }, + { + "value": "none", + "label": "None" + } + ] + } + }, + "linter": { + "description": "The tool to use for running lint checks.", + "type": "string", + "enum": ["eslint", "tslint"], + "default": "eslint" + }, + "skipFormat": { + "description": "Skip formatting files.", + "type": "boolean", + "default": false + }, + "skipWorkspaceJson": { + "description": "Skip updating workspace.json with default options based on values provided to this app (e.g. babel, style).", + "type": "boolean", + "default": false + }, + "unitTestRunner": { + "type": "string", + "enum": ["jest", "none"], + "description": "Test runner to use for unit tests.", + "default": "jest" + }, + "e2eTestRunner": { + "type": "string", + "enum": ["cypress", "none"], + "description": "Test runner to use for end to end (e2e) tests.", + "default": "cypress" + }, + "tags": { + "type": "string", + "description": "Add tags to the application (used for linting).", + "alias": "t" + }, + "pascalCaseFiles": { + "type": "boolean", + "description": "Use pascal case component file name (e.g. App.tsx).", + "alias": "P", + "default": false + }, + "classComponent": { + "type": "boolean", + "description": "Use class components instead of functional component.", + "alias": "C", + "default": false + }, + "js": { + "type": "boolean", + "description": "Generate JavaScript files rather than TypeScript files.", + "default": false + }, + "globalCss": { + "type": "boolean", + "description": "Default is false. When true, the component is generated with *.css/*.scss instead of *.module.css/*.module.scss", + "default": false + }, + "strict": { + "type": "boolean", + "description": "Creates an application with strict mode and strict type checking", + "default": true + }, + "setParserOptionsProject": { + "type": "boolean", + "description": "Whether or not to configure the ESLint \"parserOptions.project\" option. We do not do this by default for lint performance reasons.", + "default": false + }, + "standaloneConfig": { + "description": "Split the project configuration into /project.json rather than including it inside workspace.json", + "type": "boolean" + }, + "compiler": { + "type": "string", + "description": "The compiler to use", + "enum": ["babel", "swc"], + "default": "babel" + }, + "remotes": { + "type": "array", + "description": "A list of remote application names that the host application should consume.", + "default": [] + }, + "devServerPort": { + "type": "number", + "description": "The port for the dev server of the remote app." + } + }, + "required": ["name"], + "additionalProperties": false +} diff --git a/packages/react/src/generators/mfe-remote/files/mfe/mfe.config.js__tmpl__ b/packages/react/src/generators/mfe-remote/files/mfe/mfe.config.js__tmpl__ new file mode 100644 index 0000000000000..67bfd8b4ea8e2 --- /dev/null +++ b/packages/react/src/generators/mfe-remote/files/mfe/mfe.config.js__tmpl__ @@ -0,0 +1,6 @@ +module.exports = { + name: '<%= projectName %>', + exposes: { + './Module': './src/remote-entry.ts', + }, +}; diff --git a/packages/react/src/generators/mfe-remote/files/mfe/src/main.ts__tmpl__ b/packages/react/src/generators/mfe-remote/files/mfe/src/main.ts__tmpl__ new file mode 100644 index 0000000000000..b93c7a0268a59 --- /dev/null +++ b/packages/react/src/generators/mfe-remote/files/mfe/src/main.ts__tmpl__ @@ -0,0 +1 @@ +import('./bootstrap'); diff --git a/packages/react/src/generators/mfe-remote/files/mfe/src/remote-entry.ts__tmpl__ b/packages/react/src/generators/mfe-remote/files/mfe/src/remote-entry.ts__tmpl__ new file mode 100644 index 0000000000000..8c1fd1008a0bb --- /dev/null +++ b/packages/react/src/generators/mfe-remote/files/mfe/src/remote-entry.ts__tmpl__ @@ -0,0 +1 @@ +export { default } from './app/app'; diff --git a/packages/react/src/generators/mfe-remote/files/mfe/webpack.config.js__tmpl__ b/packages/react/src/generators/mfe-remote/files/mfe/webpack.config.js__tmpl__ new file mode 100644 index 0000000000000..1075d9b167418 --- /dev/null +++ b/packages/react/src/generators/mfe-remote/files/mfe/webpack.config.js__tmpl__ @@ -0,0 +1,6 @@ +const withModuleFederation = require('@nrwl/react/module-federation'); +const mfeConfig = require('./mfe.config'); + +module.exports = withModuleFederation({ + ...mfeConfig, +}); \ No newline at end of file diff --git a/packages/react/src/generators/mfe-remote/files/mfe/webpack.config.prod.js__tmpl__ b/packages/react/src/generators/mfe-remote/files/mfe/webpack.config.prod.js__tmpl__ new file mode 100644 index 0000000000000..bbf8e1f9d4c69 --- /dev/null +++ b/packages/react/src/generators/mfe-remote/files/mfe/webpack.config.prod.js__tmpl__ @@ -0,0 +1 @@ +module.exports = require('./webpack.config'); \ No newline at end of file diff --git a/packages/react/src/generators/mfe-remote/mfe-remote.ts b/packages/react/src/generators/mfe-remote/mfe-remote.ts new file mode 100644 index 0000000000000..08e3bddb94bbf --- /dev/null +++ b/packages/react/src/generators/mfe-remote/mfe-remote.ts @@ -0,0 +1,53 @@ +import { join } from 'path'; +import { formatFiles, generateFiles, names, Tree } from '@nrwl/devkit'; +import { runTasksInSerial } from '@nrwl/workspace/src/utilities/run-tasks-in-serial'; + +import { Schema } from './schema'; +import { normalizeOptions } from '../application/lib/normalize-options'; +import applicationGenerator from '../application/application'; +import { updateMfeProject } from '../mfe-host/lib/update-mfe-project'; +import { NormalizedSchema } from '../application/schema'; +import { updateHostWithRemote } from '../mfe-host/lib/update-host-with-remote'; + +export function addMfeFiles(host: Tree, options: NormalizedSchema) { + const templateVariables = { + ...names(options.name), + ...options, + tmpl: '', + }; + + generateFiles( + host, + join(__dirname, `./files/mfe`), + options.appProjectRoot, + templateVariables + ); +} + +export async function mfeRemoteGenerator(host: Tree, schema: Schema) { + const options = normalizeOptions(host, schema); + const initApp = await applicationGenerator(host, options); + + // Module federation requires bootstrap code to be dynamically imported. + // Renaming original entry file so we can use `import(./bootstrap)` in + // new entry file. + host.rename( + join(options.appProjectRoot, 'src/main.tsx'), + join(options.appProjectRoot, 'src/bootstrap.tsx') + ); + + 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); + } + + return runTasksInSerial(initApp); +} diff --git a/packages/react/src/generators/mfe-remote/schema.d.ts b/packages/react/src/generators/mfe-remote/schema.d.ts new file mode 100644 index 0000000000000..557016de7ebee --- /dev/null +++ b/packages/react/src/generators/mfe-remote/schema.d.ts @@ -0,0 +1,30 @@ +import { Linter } from '@nrwl/linter'; + +import { SupportedStyles } from '../../../typings'; + +export interface Schema { + name: string; + style: SupportedStyles; + skipFormat: boolean; + directory?: string; + tags?: string; + unitTestRunner: 'jest' | 'none'; + /** + * @deprecated + */ + babelJest?: boolean; + e2eTestRunner: 'cypress' | 'none'; + linter: Linter; + pascalCaseFiles?: boolean; + classComponent?: boolean; + routing?: boolean; + skipWorkspaceJson?: boolean; + js?: boolean; + globalCss?: boolean; + strict?: boolean; + setParserOptionsProject?: boolean; + standaloneConfig?: boolean; + compiler?: 'babel' | 'swc'; + host?: string; + devServerPort?: number; +} diff --git a/packages/react/src/generators/mfe-remote/schema.json b/packages/react/src/generators/mfe-remote/schema.json new file mode 100644 index 0000000000000..bb8a4b1bacc59 --- /dev/null +++ b/packages/react/src/generators/mfe-remote/schema.json @@ -0,0 +1,159 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "GeneratorReactMFERemote", + "cli": "nx", + "title": "Generate Module Federation Setup for React Remote App", + "description": "Create Module Federation configuration files for given React Remote Application.", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the remote application to generate the Module Federation configuration", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What name would you like to use as the remote application?", + "pattern": "^[a-zA-Z].*$" + }, + "directory": { + "description": "The directory of the new application.", + "type": "string", + "alias": "dir" + }, + "style": { + "description": "The file extension to be used for style files.", + "type": "string", + "default": "css", + "alias": "s", + "x-prompt": { + "message": "Which stylesheet format would you like to use?", + "type": "list", + "items": [ + { + "value": "css", + "label": "CSS" + }, + { + "value": "scss", + "label": "SASS(.scss) [ http://sass-lang.com ]" + }, + { + "value": "styl", + "label": "Stylus(.styl) [ http://stylus-lang.com ]" + }, + { + "value": "less", + "label": "LESS [ http://lesscss.org ]" + }, + { + "value": "styled-components", + "label": "styled-components [ https://styled-components.com ]" + }, + { + "value": "@emotion/styled", + "label": "emotion [ https://emotion.sh ]" + }, + { + "value": "styled-jsx", + "label": "styled-jsx [ https://www.npmjs.com/package/styled-jsx ]" + }, + { + "value": "none", + "label": "None" + } + ] + } + }, + "linter": { + "description": "The tool to use for running lint checks.", + "type": "string", + "enum": ["eslint", "tslint"], + "default": "eslint" + }, + "routing": { + "type": "boolean", + "description": "Generate application with routes.", + "default": false + }, + "skipFormat": { + "description": "Skip formatting files.", + "type": "boolean", + "default": false + }, + "skipWorkspaceJson": { + "description": "Skip updating workspace.json with default options based on values provided to this app (e.g. babel, style).", + "type": "boolean", + "default": false + }, + "unitTestRunner": { + "type": "string", + "enum": ["jest", "none"], + "description": "Test runner to use for unit tests.", + "default": "jest" + }, + "e2eTestRunner": { + "type": "string", + "enum": ["cypress", "none"], + "description": "Test runner to use for end to end (e2e) tests.", + "default": "cypress" + }, + "tags": { + "type": "string", + "description": "Add tags to the application (used for linting).", + "alias": "t" + }, + "pascalCaseFiles": { + "type": "boolean", + "description": "Use pascal case component file name (e.g. App.tsx).", + "alias": "P", + "default": false + }, + "classComponent": { + "type": "boolean", + "description": "Use class components instead of functional component.", + "alias": "C", + "default": false + }, + "js": { + "type": "boolean", + "description": "Generate JavaScript files rather than TypeScript files.", + "default": false + }, + "globalCss": { + "type": "boolean", + "description": "Default is false. When true, the component is generated with *.css/*.scss instead of *.module.css/*.module.scss.", + "default": false + }, + "strict": { + "type": "boolean", + "description": "Creates an application with strict mode and strict type checking.", + "default": true + }, + "setParserOptionsProject": { + "type": "boolean", + "description": "Whether or not to configure the ESLint \"parserOptions.project\" option. We do not do this by default for lint performance reasons.", + "default": false + }, + "standaloneConfig": { + "description": "Split the project configuration into /project.json rather than including it inside workspace.json", + "type": "boolean" + }, + "compiler": { + "type": "string", + "description": "The compiler to use.", + "enum": ["babel", "swc"], + "default": "babel" + }, + "host": { + "type": "string", + "description": "The host / shell application for this remote." + }, + "devServerPort": { + "type": "number", + "description": "The port for the dev server of the remote app." + } + }, + "required": ["name"], + "additionalProperties": false +} diff --git a/packages/react/src/mfe/webpack-utils.ts b/packages/react/src/mfe/webpack-utils.ts new file mode 100644 index 0000000000000..83513b6e3b544 --- /dev/null +++ b/packages/react/src/mfe/webpack-utils.ts @@ -0,0 +1,109 @@ +import { existsSync, readFileSync } from 'fs'; +import { NormalModuleReplacementPlugin } from 'webpack'; +import { joinPathFragments, normalizePath, workspaceRoot } from '@nrwl/devkit'; +import { dirname } from 'path'; +import { ParsedCommandLine } from 'typescript'; +import { + getRootTsConfigPath, + readTsConfig, +} from '@nrwl/workspace/src/utilities/typescript'; + +export interface SharedLibraryConfig { + singleton: boolean; + strictVersion: boolean; + requiredVersion: string; + eager: boolean; +} + +export function shareWorkspaceLibraries( + libraries: string[], + tsConfigPath = process.env.NX_TSCONFIG_PATH ?? getRootTsConfigPath() +) { + if (!existsSync(tsConfigPath)) { + throw new Error( + `NX MFE: TsConfig Path for workspace libraries does not exist! (${tsConfigPath})` + ); + } + + const tsConfig: ParsedCommandLine = readTsConfig(tsConfigPath); + const tsconfigPathAliases = tsConfig.options?.paths; + + if (!tsconfigPathAliases) { + return { + getAliases: () => [], + getLibraries: () => {}, + getReplacementPlugin: () => + new NormalModuleReplacementPlugin(/./, () => {}), + }; + } + + const pathMappings: { name: string; path: string }[] = []; + for (const [key, paths] of Object.entries(tsconfigPathAliases)) { + if (libraries && libraries.includes(key)) { + const pathToLib = normalizePath( + joinPathFragments(workspaceRoot, paths[0]) + ); + pathMappings.push({ + name: key, + path: pathToLib, + }); + } + } + + return { + getAliases: () => + pathMappings.reduce( + (aliases, library) => ({ ...aliases, [library.name]: library.path }), + {} + ), + getLibraries: (eager?: boolean): Record => + pathMappings.reduce( + (libraries, library) => ({ + ...libraries, + [library.name]: { requiredVersion: false, eager }, + }), + {} + ), + getReplacementPlugin: () => + new NormalModuleReplacementPlugin(/./, (req) => { + if (!req.request.startsWith('.')) { + return; + } + + const from = req.context; + const to = normalizePath(joinPathFragments(req.context, req.request)); + + for (const library of pathMappings) { + const libFolder = normalizePath(dirname(library.path)); + if (!from.startsWith(libFolder) && to.startsWith(libFolder)) { + req.request = library.name; + } + } + }), + }; +} + +export function sharePackages( + packages: string[] +): Record { + const pkgJsonPath = joinPathFragments(workspaceRoot, 'package.json'); + if (!existsSync(pkgJsonPath)) { + throw new Error( + 'NX MFE: Could not find root package.json to determine dependency versions.' + ); + } + + const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')); + + return packages.reduce( + (shared, pkgName) => ({ + ...shared, + [pkgName]: { + singleton: true, + strictVersion: true, + requiredVersion: pkgJson.dependencies[pkgName], + }, + }), + {} + ); +} diff --git a/packages/react/src/mfe/with-module-federation.ts b/packages/react/src/mfe/with-module-federation.ts new file mode 100644 index 0000000000000..a77b29b15040c --- /dev/null +++ b/packages/react/src/mfe/with-module-federation.ts @@ -0,0 +1,257 @@ +import { + SharedLibraryConfig, + sharePackages, + shareWorkspaceLibraries, +} from './webpack-utils'; +import { + createProjectGraphAsync, + ProjectGraph, + readCachedProjectGraph, + workspaceRoot, + Workspaces, +} from '@nrwl/devkit'; +import { + getRootTsConfigPath, + readTsConfig, +} from '@nrwl/workspace/src/utilities/typescript'; +import { ParsedCommandLine } from 'typescript'; +import { readWorkspaceJson } from 'nx/src/project-graph/file-utils'; +import ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin'); + +export type MFELibrary = { type: string; name: string }; + +export type MFERemotes = string[] | [remoteName: string, remoteUrl: string][]; + +export interface MFEConfig { + name: string; + remotes?: string[]; + library?: MFELibrary; + exposes?: Record; + shared?: ( + libraryName: string, + library: SharedLibraryConfig + ) => undefined | false | SharedLibraryConfig; +} + +function recursivelyResolveWorkspaceDependents( + projectGraph: ProjectGraph, + target: string, + seenTargets: Set = new Set() +) { + if (seenTargets.has(target)) { + return []; + } + let dependencies = [target]; + seenTargets.add(target); + + const workspaceDependencies = ( + projectGraph.dependencies[target] ?? [] + ).filter((dep) => !dep.target.startsWith('npm:')); + if (workspaceDependencies.length > 0) { + for (const dep of workspaceDependencies) { + dependencies = [ + ...dependencies, + ...recursivelyResolveWorkspaceDependents( + projectGraph, + dep.target, + seenTargets + ), + ]; + } + } + + return dependencies; +} + +function mapWorkspaceLibrariesToTsConfigImport(workspaceLibraries: string[]) { + const { projects } = new Workspaces( + workspaceRoot + ).readWorkspaceConfiguration(); + + const tsConfigPath = process.env.NX_TSCONFIG_PATH ?? getRootTsConfigPath(); + const tsConfig: ParsedCommandLine = readTsConfig(tsConfigPath); + + const tsconfigPathAliases: Record = tsConfig.options?.paths; + + if (!tsconfigPathAliases) { + return workspaceLibraries; + } + + const mappedLibraries = []; + for (const lib of workspaceLibraries) { + const sourceRoot = projects[lib].sourceRoot; + let found = false; + + for (const [key, value] of Object.entries(tsconfigPathAliases)) { + if (value.find((p) => p.startsWith(sourceRoot))) { + mappedLibraries.push(key); + found = true; + break; + } + } + + if (!found) { + mappedLibraries.push(lib); + } + } + + return mappedLibraries; +} + +async function getDependentPackagesForProject(name: string) { + let projectGraph: ProjectGraph; + + try { + projectGraph = readCachedProjectGraph(); + } catch (e) { + projectGraph = await createProjectGraphAsync(); + } + + const deps = projectGraph.dependencies[name].reduce( + (dependencies, dependency) => { + const workspaceLibraries = new Set(dependencies.workspaceLibraries); + const npmPackages = new Set(dependencies.npmPackages); + + if (dependency.target.startsWith('npm:')) { + npmPackages.add(dependency.target.replace('npm:', '')); + } else { + workspaceLibraries.add(dependency.target); + } + + return { + workspaceLibraries: [...workspaceLibraries], + npmPackages: [...npmPackages], + }; + }, + { workspaceLibraries: [], npmPackages: [] } + ); + const seenWorkspaceLibraries = new Set(); + deps.workspaceLibraries = deps.workspaceLibraries.reduce( + (workspaceLibraryDeps, workspaceLibrary) => [ + ...workspaceLibraryDeps, + ...recursivelyResolveWorkspaceDependents( + projectGraph, + workspaceLibrary, + seenWorkspaceLibraries + ), + ], + [] + ); + + deps.workspaceLibraries = mapWorkspaceLibrariesToTsConfigImport( + deps.workspaceLibraries + ); + return deps; +} + +function determineRemoteUrl(remote: string) { + const workspace = readWorkspaceJson(); + const serveTarget = workspace.projects[remote]?.targets?.serve; + + if (!serveTarget) { + throw new Error( + `Cannot automatically determine URL of remote (${remote}). Looked for property "host" in the project's "serve" target.\n + You can also use the tuple syntax in your webpack config to configure your remotes. e.g. \`remotes: [['remote1', '//localhost:4201']]\`` + ); + } + + const host = serveTarget.options?.host ?? '//localhost'; + const port = serveTarget.options?.port ?? 4201; + return `${remote}@${ + host.endsWith('/') ? host.slice(0, -1) : host + }:${port}/remoteEntry.js`; +} + +function mapRemotes(remotes: MFERemotes) { + const mappedRemotes = {}; + + for (const remote of remotes) { + if (Array.isArray(remote)) { + let [remoteName, remoteLocation] = remote; + if (!remoteLocation.includes('@')) { + remoteLocation = `${remoteName}@${remoteLocation}`; + } + if (!remoteLocation.match(/remoteEntry\.(js|mjs)$/)) { + remoteLocation = `${ + remoteLocation.endsWith('/') + ? remoteLocation.slice(0, -1) + : remoteLocation + }/remoteEntry.js`; + } + mappedRemotes[remoteName] = remoteLocation; + } else if (typeof remote === 'string') { + mappedRemotes[remote] = determineRemoteUrl(remote); + } + } + + return mappedRemotes; +} + +export async function withModuleFederation(options: MFEConfig) { + const reactWebpackConfig = require('../../plugins/webpack'); + const ws = readWorkspaceJson(); + const project = ws.projects[options.name]; + + if (!project) { + throw Error( + `Cannot find project "${options.name}". Check that the name is correct in mfe.config.js` + ); + } + + const dependencies = await getDependentPackagesForProject(options.name); + const sharedLibraries = shareWorkspaceLibraries( + dependencies.workspaceLibraries + ); + + const npmPackages = sharePackages(dependencies.npmPackages); + + const sharedDependencies = { + ...sharedLibraries.getLibraries(), + ...npmPackages, + }; + + if (options.shared) { + for (const [libraryName, library] of Object.entries(sharedDependencies)) { + const mappedDependency = options.shared(libraryName, library); + if (mappedDependency === false) { + delete sharedDependencies[libraryName]; + continue; + } else if (!mappedDependency) { + continue; + } + + sharedDependencies[libraryName] = mappedDependency; + } + } + + return (config) => { + config = reactWebpackConfig(config); + config.output.uniqueName = options.name; + config.output.publicPath = 'auto'; + + config.optimization = { + runtimeChunk: false, + minimize: false, + }; + + const mappedRemotes = + !options.remotes || options.remotes.length === 0 + ? {} + : mapRemotes(options.remotes); + + config.plugins.push( + new ModuleFederationPlugin({ + name: options.name, + filename: 'remoteEntry.js', + exposes: options.exposes, + remotes: mappedRemotes, + shared: { + ...sharedDependencies, + }, + }), + sharedLibraries.getReplacementPlugin() + ); + + return config; + }; +} diff --git a/packages/web/src/executors/dev-server/dev-server.impl.ts b/packages/web/src/executors/dev-server/dev-server.impl.ts index 3e790cac2ead5..34be65dd04856 100644 --- a/packages/web/src/executors/dev-server/dev-server.impl.ts +++ b/packages/web/src/executors/dev-server/dev-server.impl.ts @@ -76,11 +76,15 @@ export default async function* devServerExecutor( ); if (buildOptions.webpackConfig) { - const customWebpack = resolveCustomWebpackConfig( + let customWebpack = resolveCustomWebpackConfig( buildOptions.webpackConfig, buildOptions.tsConfig ); + if (typeof customWebpack.then === 'function') { + customWebpack = await customWebpack; + } + webpackConfig = customWebpack(webpackConfig, { buildOptions, configuration: serveOptions.buildTarget.split(':')[2], diff --git a/packages/web/src/executors/webpack/webpack.impl.ts b/packages/web/src/executors/webpack/webpack.impl.ts index 7c540a86d1c17..c929cfd925166 100644 --- a/packages/web/src/executors/webpack/webpack.impl.ts +++ b/packages/web/src/executors/webpack/webpack.impl.ts @@ -1,19 +1,17 @@ -import { ExecutorContext, logger } from '@nrwl/devkit'; +import { ExecutorContext, logger, readCachedProjectGraph } from '@nrwl/devkit'; import type { Configuration, Stats } from 'webpack'; import { from, of } from 'rxjs'; import { bufferCount, + mergeMap, mergeScan, switchMap, tap, - mergeMap, } from 'rxjs/operators'; import { eachValueFrom } from 'rxjs-for-await'; import { execSync } from 'child_process'; import { Range, satisfies } from 'semver'; import { basename, join } from 'path'; - -import { readCachedProjectGraph } from '@nrwl/devkit'; import { calculateProjectDependencies, checkDependentProjectsHaveBeenBuilt, @@ -66,10 +64,10 @@ export interface WebWebpackExecutorOptions extends BuildBuilderOptions { postcssConfig?: string; } -function getWebpackConfigs( +async function getWebpackConfigs( options: WebWebpackExecutorOptions, context: ExecutorContext -): Configuration[] { +): Promise { const metadata = context.workspace.projects[context.projectName]; const sourceRoot = metadata.sourceRoot; const projectRoot = metadata.root; @@ -88,6 +86,19 @@ function getWebpackConfigs( scriptTarget ); + let customWebpack = null; + + if (options.webpackConfig) { + customWebpack = resolveCustomWebpackConfig( + options.webpackConfig, + options.tsConfig + ); + + if (typeof customWebpack.then === 'function') { + customWebpack = await customWebpack; + } + } + return [ // ESM build for modern browsers. getWebConfig( @@ -114,11 +125,7 @@ function getWebpackConfigs( ] .filter(Boolean) .map((config) => { - if (options.webpackConfig) { - const customWebpack = resolveCustomWebpackConfig( - options.webpackConfig, - options.tsConfig - ); + if (customWebpack) { return customWebpack(config, { options, configuration: context.configurationName, @@ -198,7 +205,7 @@ export async function* run( deleteOutputDir(context.root, options.outputPath); } - const configs = getWebpackConfigs(options, context); + const configs = await getWebpackConfigs(options, context); return yield* eachValueFrom( from(configs).pipe( mergeMap((config) => (Array.isArray(config) ? from(config) : of(config))),