diff --git a/packages/react/generators.json b/packages/react/generators.json index 2d766fd3b8e85d..9d364fd4f97cd3 100644 --- a/packages/react/generators.json +++ b/packages/react/generators.json @@ -162,6 +162,12 @@ "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" } } } diff --git a/packages/react/index.ts b/packages/react/index.ts index 2aec4830f43f99..e9c6a07620353b 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/plugins/with-module-federation/index.ts b/packages/react/plugins/with-module-federation/index.ts new file mode 100644 index 00000000000000..92f9aa1657a1f1 --- /dev/null +++ b/packages/react/plugins/with-module-federation/index.ts @@ -0,0 +1,139 @@ +import { readCachedProjectGraph } from '@nrwl/workspace/src/core/project-graph'; +import { readWorkspaceJson } from '@nrwl/workspace/src/core/file-utils'; +import { + ProjectGraph, + ProjectGraphDependency, +} from '@nrwl/tao/src/shared/project-graph'; +import { WorkspaceJsonConfiguration } from '@nrwl/tao/src/shared/workspace'; + +const { ModuleFederationPlugin } = require('webpack').container; +const reactWebpackConfig = require('../webpack'); + +export type MFERemotes = string[] | [remoteName: string, remoteUrl: string][]; + +export type MFELibrary = { type: string; name: string }; + +export interface SharedLibraryConfig { + singleton?: boolean; + strictVersion?: boolean; + requiredVersion?: string; + eager?: boolean; +} + +export interface MfeOptions { + name: string; + filename: string; + remotes?: string[]; + library?: MFELibrary; + exposes?: Record; + shared?: ( + library: string, + config: SharedLibraryConfig + ) => undefined | false | SharedLibraryConfig; +} + +function getSharedDependencies(graph: ProjectGraph, options: MfeOptions) { + const dependencies = graph.dependencies[options.name]; + const sharedDependencies = dependencies.reduce((acc, dep) => { + //npm libs + if (graph.externalNodes[dep.target]) { + const externalNode = graph.externalNodes[dep.target]; + acc[externalNode.data.packageName] = { + singleton: true, + requiredVersion: externalNode.data.version, + }; + } + + // workspace libs + if (graph.nodes[dep.target]) { + const node = graph.nodes[dep.target]; + if (node.data.projectType !== 'application') { + acc[node.name] = { + requiredVersion: false, + }; + } + } + return acc; + }, {}); + + if (options.shared) { + for (const [library, config] of Object.entries(sharedDependencies)) { + const shouldKeepDependency = options.shared(library, config); + if (shouldKeepDependency == false) { + delete sharedDependencies[library]; + continue; + } + sharedDependencies[library] = shouldKeepDependency ?? config; + } + } + return sharedDependencies; +} + +function getRemotes(options: MfeOptions, ws: WorkspaceJsonConfiguration) { + return options.remotes.reduce((acc, name) => { + const project = ws.projects[name]; + + if (!project) { + throw Error( + `Cannot find remote project "${options.name}". Check that the name is correct in mfe.config.js` + ); + } + const serveOptions = project?.targets?.serve.options; + if (serveOptions) { + acc[name] = `${name}@//${serveOptions.host ?? 'localhost'}:${ + serveOptions.port ?? 4200 + }/remoteEntry.js`; + } + return acc; + }, {}); +} + +// This is probably something linked to a Schema +function withModuleFederation(options: MfeOptions) { + const ws = readWorkspaceJson(); + const graph = readCachedProjectGraph(); + 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 mfeOptions = { + name: options.name, + filename: 'remoteEntry.js', + exposes: {}, + } as any; + + if (options.library) { + mfeOptions.library = options.library; + } + + mfeOptions.shared = getSharedDependencies(graph, options); + + if (options.remotes) { + mfeOptions.remotes = getRemotes(options, ws); + } + + if (options.exposes) { + mfeOptions.exposes = options.exposes; + } + + return (config, options) => { + config = reactWebpackConfig(config); + config.output.uniqueName = options.name; + config.output.publicPath = 'auto'; + + config.optimization = { + runtimeChunk: false, + minimize: false, + }; + + config.plugins.push(new ModuleFederationPlugin(mfeOptions)); + + return config; + }; +} + +module.exports = withModuleFederation; diff --git a/packages/react/src/generators/mfe-host/files/common/.babelrc__tmpl__ b/packages/react/src/generators/mfe-host/files/common/.babelrc__tmpl__ new file mode 100644 index 00000000000000..626dc3ed79947d --- /dev/null +++ b/packages/react/src/generators/mfe-host/files/common/.babelrc__tmpl__ @@ -0,0 +1,13 @@ +{ + "presets": [ + [ + "@nrwl/react/babel", { + "runtime": "automatic"<% if (style === '@emotion/styled') { %>,<% } %> + <% if (style === '@emotion/styled') { %>"importSource": "@emotion/react" <% } %> + } + ] + ], + "plugins": [ + <% if (style === 'styled-components') { %>["styled-components", { "pure": true, "ssr": true }]<% } %><% if (style === 'styled-jsx') { %>"styled-jsx/babel"<% } %><% if (style === '@emotion/styled') { %>"@emotion/babel-plugin"<% } %> + ] +} diff --git a/packages/react/src/generators/mfe-host/files/common/.browserslistrc__tmpl__ b/packages/react/src/generators/mfe-host/files/common/.browserslistrc__tmpl__ new file mode 100644 index 00000000000000..f1d12df4faa25a --- /dev/null +++ b/packages/react/src/generators/mfe-host/files/common/.browserslistrc__tmpl__ @@ -0,0 +1,16 @@ +# This file is used by: +# 1. autoprefixer to adjust CSS to support the below specified browsers +# 2. babel preset-env to adjust included polyfills +# +# For additional information regarding the format and rule options, please see: +# https://github.com/browserslist/browserslist#queries +# +# If you need to support different browsers in production, you may tweak the list below. + +last 1 Chrome version +last 1 Firefox version +last 2 Edge major versions +last 2 Safari major version +last 2 iOS major versions +Firefox ESR +not IE 9-11 # For IE 9-11 support, remove 'not'. \ No newline at end of file diff --git a/packages/react/src/generators/mfe-host/files/common/src/app/__fileName__.spec.tsx__tmpl__ b/packages/react/src/generators/mfe-host/files/common/src/app/__fileName__.spec.tsx__tmpl__ new file mode 100644 index 00000000000000..cb810d67d8c663 --- /dev/null +++ b/packages/react/src/generators/mfe-host/files/common/src/app/__fileName__.spec.tsx__tmpl__ @@ -0,0 +1,26 @@ +import { render } from '@testing-library/react'; +<% if (routing) { %> +import { BrowserRouter } from 'react-router-dom'; +<% } %> + +import App from './app'; + +describe('App', () => { + it('should render successfully', () => { + <% if (routing) { %> + const { baseElement } = render(); + <% } else { %> + const { baseElement } = render(); + <% } %> + expect(baseElement).toBeTruthy(); + }); + + it('should have a greeting as the title', () => { + <% if (routing) { %> + const { getByText } = render(); + <% } else { %> + const { getByText } = render(); + <% } %> + expect(getByText(/Welcome <%= projectName %>/gi)).toBeTruthy(); + }); +}); diff --git a/packages/react/src/generators/mfe-host/files/common/src/assets/.gitkeep b/packages/react/src/generators/mfe-host/files/common/src/assets/.gitkeep new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/packages/react/src/generators/mfe-host/files/common/src/environments/environment.prod.ts__tmpl__ b/packages/react/src/generators/mfe-host/files/common/src/environments/environment.prod.ts__tmpl__ new file mode 100644 index 00000000000000..3612073bc31cd4 --- /dev/null +++ b/packages/react/src/generators/mfe-host/files/common/src/environments/environment.prod.ts__tmpl__ @@ -0,0 +1,3 @@ +export const environment = { + production: true +}; diff --git a/packages/react/src/generators/mfe-host/files/common/src/environments/environment.ts__tmpl__ b/packages/react/src/generators/mfe-host/files/common/src/environments/environment.ts__tmpl__ new file mode 100644 index 00000000000000..d9370e924b51bc --- /dev/null +++ b/packages/react/src/generators/mfe-host/files/common/src/environments/environment.ts__tmpl__ @@ -0,0 +1,6 @@ +// This file can be replaced during build by using the `fileReplacements` array. +// When building for production, this file is replaced with `environment.prod.ts`. + +export const environment = { + production: false +}; diff --git a/packages/react/src/generators/mfe-host/files/common/src/favicon.ico b/packages/react/src/generators/mfe-host/files/common/src/favicon.ico new file mode 100644 index 00000000000000..317ebcb2336e08 Binary files /dev/null and b/packages/react/src/generators/mfe-host/files/common/src/favicon.ico differ diff --git a/packages/react/src/generators/mfe-host/files/common/src/index.html b/packages/react/src/generators/mfe-host/files/common/src/index.html new file mode 100644 index 00000000000000..b8cb16b8bf6dcf --- /dev/null +++ b/packages/react/src/generators/mfe-host/files/common/src/index.html @@ -0,0 +1,14 @@ + + + + + <%= className %> + + + + + + +
+ + diff --git a/packages/react/src/generators/mfe-host/files/common/src/polyfills.ts__tmpl__ b/packages/react/src/generators/mfe-host/files/common/src/polyfills.ts__tmpl__ new file mode 100644 index 00000000000000..2adf3d05b6fcf4 --- /dev/null +++ b/packages/react/src/generators/mfe-host/files/common/src/polyfills.ts__tmpl__ @@ -0,0 +1,7 @@ +/** + * Polyfill stable language features. These imports will be optimized by `@babel/preset-env`. + * + * See: https://github.com/zloirock/core-js#babel + */ +import 'core-js/stable'; +import 'regenerator-runtime/runtime'; diff --git a/packages/react/src/generators/mfe-host/files/common/tsconfig.app.json__tmpl__ b/packages/react/src/generators/mfe-host/files/common/tsconfig.app.json__tmpl__ new file mode 100644 index 00000000000000..1e4a14aded7d2a --- /dev/null +++ b/packages/react/src/generators/mfe-host/files/common/tsconfig.app.json__tmpl__ @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "<%= offsetFromRoot %>dist/out-tsc", + "types": ["node"] + }, + "files": [ + <% if (style === 'styled-jsx') { %>"<%= offsetFromRoot %>node_modules/@nrwl/react/typings/styled-jsx.d.ts",<% } %> + "<%= offsetFromRoot %>node_modules/@nrwl/react/typings/cssmodule.d.ts", + "<%= offsetFromRoot %>node_modules/@nrwl/react/typings/image.d.ts" + ], + "exclude": ["**/*.spec.ts", "**/*.test.ts", "**/*.spec.tsx", "**/*.test.tsx", "**/*.spec.js", "**/*.test.js", "**/*.spec.jsx", "**/*.test.jsx"], + "include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"] +} diff --git a/packages/react/src/generators/mfe-host/files/common/tsconfig.json__tmpl__ b/packages/react/src/generators/mfe-host/files/common/tsconfig.json__tmpl__ new file mode 100644 index 00000000000000..877486407b8df1 --- /dev/null +++ b/packages/react/src/generators/mfe-host/files/common/tsconfig.json__tmpl__ @@ -0,0 +1,17 @@ +{ + "extends": "<%= offsetFromRoot %>tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + <% if (style === '@emotion/styled') { %>"jsxImportSource": "@emotion/react",<% } %> + "allowJs": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + } + ] +} 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 00000000000000..02954642b2e6e1 --- /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(remote) {%> "<%= remote %>", <% }); %> + ], +} \ No newline at end of file diff --git a/packages/react/src/generators/mfe-host/files/mfe/src/bootstrap.tsx__tmpl__ b/packages/react/src/generators/mfe-host/files/mfe/src/bootstrap.tsx__tmpl__ new file mode 100644 index 00000000000000..564a677ba41441 --- /dev/null +++ b/packages/react/src/generators/mfe-host/files/mfe/src/bootstrap.tsx__tmpl__ @@ -0,0 +1,7 @@ +<% if (strict) { %>import { StrictMode } from 'react';<% } %> +import * as ReactDOM from 'react-dom'; +<% if (routing) { %>import { BrowserRouter } from 'react-router-dom';<% } %> + +import App from './app/<%= fileName %>'; + +ReactDOM.render(<% if (strict) { %><% } %><% if (routing) { %><% } %><% if (routing) { %><% } %><% if (strict) { %><% } %>, document.getElementById('root')); diff --git a/packages/react/src/generators/mfe-host/files/mfe/src/main.tsx__tmpl__ b/packages/react/src/generators/mfe-host/files/mfe/src/main.tsx__tmpl__ new file mode 100644 index 00000000000000..137c64f9f44756 --- /dev/null +++ b/packages/react/src/generators/mfe-host/files/mfe/src/main.tsx__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 00000000000000..0782e2e2e11c01 --- /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(remote) { %>declare module '<%= remote %>/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 00000000000000..c8782b572c3170 --- /dev/null +++ b/packages/react/src/generators/mfe-host/files/mfe/webpack.config.js__tmpl__ @@ -0,0 +1,6 @@ +const withModuleFederation = require('@nrwl/react/plugins/with-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 00000000000000..bbf8e1f9d4c69b --- /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 00000000000000..cebaac8e3f2cf5 --- /dev/null +++ b/packages/react/src/generators/mfe-host/lib/add-mfe.ts @@ -0,0 +1,25 @@ +import { NormalizedSchema } from '@nrwl/react/src/generators/application/schema'; +import { generateFiles, names } from '@nrwl/devkit'; +import { join } from 'path'; + +export function addMFEFiles(host, options: NormalizedSchema) { + if (host.exists(`apps/${options.projectName}/src/main.tsx`)) { + host.rename( + `apps/${options.projectName}/src/main.tsx`, + `apps/${options.projectName}/src/bootstrap.tsx` + ); + } + + const templateVariables = { + ...names(options.name), + ...options, + tmpl: '', + }; + + generateFiles( + host, + join(__dirname, `../files/mfe`), + options.appProjectRoot, + templateVariables + ); +} diff --git a/packages/react/src/generators/mfe-host/mfe-host.compact.ts b/packages/react/src/generators/mfe-host/mfe-host.compact.ts new file mode 100644 index 00000000000000..e69de29bb2d1d6 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 00000000000000..7fbd0643b131a0 --- /dev/null +++ b/packages/react/src/generators/mfe-host/mfe-host.ts @@ -0,0 +1,35 @@ +import { + formatFiles, + generateFiles, + names, + Tree, + updateJson, +} from '@nrwl/devkit'; +import { Schema } from './schema'; +import applicationGenerator from '../application/application'; +import { normalizeOptions } from '@nrwl/react/src/generators/application/lib/normalize-options'; +import { runTasksInSerial } from '@nrwl/workspace/src/utilities/run-tasks-in-serial'; +import { addMFEFiles } from '@nrwl/react/src/generators/mfe-host/lib/add-mfe'; +import { NormalizedSchema } from '@nrwl/react/src/generators/application/schema'; + +export function updateProjectJson(host: Tree, options: NormalizedSchema) { + updateJson(host, `${options.appProjectRoot}/project.json`, (json) => { + json.targets.build.options[ + 'webpackConfig' + ] = `${options.appProjectRoot}/webpack.config.js`; + return json; + }); +} + +export async function mfeHostGenerator(host: Tree, schema: Schema) { + const options = normalizeOptions(host, schema); + const initApp = await applicationGenerator(host, options); + addMFEFiles(host, options); + updateProjectJson(host, options); + + if (!options.skipFormat) { + await formatFiles(host); + } + + return runTasksInSerial(initApp); +} 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 00000000000000..c158e0db7138f8 --- /dev/null +++ b/packages/react/src/generators/mfe-host/schema.d.ts @@ -0,0 +1,29 @@ +import { SupportedStyles } from '@nrwl/react'; +import { Linter } from '@nrwl/linter'; + +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'; + port?: number; + remotes?: 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 00000000000000..3c48b477d71084 --- /dev/null +++ b/packages/react/src/generators/mfe-host/schema.json @@ -0,0 +1,161 @@ +{ + "$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" + }, + "routing": { + "type": "boolean", + "description": "Generate application with routes.", + "x-prompt": "Would you like to add React Router to this application?", + "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" + }, + "port": { + "type": "number", + "description": "The port at which the remote application should be served." + }, + "remotes": { + "type": "array", + "description": "A list of remote application names that the host application should consume.", + "default": [] + } + }, + "required": ["name"], + "additionalProperties": false +}