From bf58aabb881d376b8368691ba9fda307fc753923 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 24 Mar 2022 09:28:01 +0000 Subject: [PATCH] feat(angular): add generator to migrate old mfe config (#9366) --- .../generators/convert-to-with-mf.md | 48 ++ docs/map.json | 5 + packages/angular/generators.json | 10 + .../convert-to-with-mf.spec.ts.snap | 29 ++ .../convert-to-with-mf.compat.ts | 4 + .../convert-to-with-mf.spec.ts | 166 +++++++ .../convert-to-with-mf.test-data.ts | 449 ++++++++++++++++++ .../convert-to-with-mf/convert-to-with-mf.ts | 54 +++ .../is-host-remote-config.spec.ts.snap | 7 + .../write-new-webpack-config.spec.ts.snap | 37 ++ .../lib/check-name-matches.spec.ts | 37 ++ .../lib/check-name-matches.ts | 29 ++ .../lib/check-shared-npm-packages.spec.ts | 182 +++++++ .../lib/check-shared-npm-packages.ts | 68 +++ .../lib/get-webpack-config-path.ts | 25 + .../convert-to-with-mf/lib/index.ts | 6 + .../lib/is-host-remote-config.spec.ts | 135 ++++++ .../lib/is-host-remote-config.ts | 73 +++ .../lib/parse-ast-webpack-config.ts | 16 + .../lib/write-new-webpack-config.spec.ts | 87 ++++ .../lib/write-new-webpack-config.ts | 55 +++ .../generators/convert-to-with-mf/schema.d.ts | 3 + .../generators/convert-to-with-mf/schema.json | 20 + 23 files changed, 1545 insertions(+) create mode 100644 docs/generated/api-angular/generators/convert-to-with-mf.md create mode 100644 packages/angular/src/generators/convert-to-with-mf/__snapshots__/convert-to-with-mf.spec.ts.snap create mode 100644 packages/angular/src/generators/convert-to-with-mf/convert-to-with-mf.compat.ts create mode 100644 packages/angular/src/generators/convert-to-with-mf/convert-to-with-mf.spec.ts create mode 100644 packages/angular/src/generators/convert-to-with-mf/convert-to-with-mf.test-data.ts create mode 100644 packages/angular/src/generators/convert-to-with-mf/convert-to-with-mf.ts create mode 100644 packages/angular/src/generators/convert-to-with-mf/lib/__snapshots__/is-host-remote-config.spec.ts.snap create mode 100644 packages/angular/src/generators/convert-to-with-mf/lib/__snapshots__/write-new-webpack-config.spec.ts.snap create mode 100644 packages/angular/src/generators/convert-to-with-mf/lib/check-name-matches.spec.ts create mode 100644 packages/angular/src/generators/convert-to-with-mf/lib/check-name-matches.ts create mode 100644 packages/angular/src/generators/convert-to-with-mf/lib/check-shared-npm-packages.spec.ts create mode 100644 packages/angular/src/generators/convert-to-with-mf/lib/check-shared-npm-packages.ts create mode 100644 packages/angular/src/generators/convert-to-with-mf/lib/get-webpack-config-path.ts create mode 100644 packages/angular/src/generators/convert-to-with-mf/lib/index.ts create mode 100644 packages/angular/src/generators/convert-to-with-mf/lib/is-host-remote-config.spec.ts create mode 100644 packages/angular/src/generators/convert-to-with-mf/lib/is-host-remote-config.ts create mode 100644 packages/angular/src/generators/convert-to-with-mf/lib/parse-ast-webpack-config.ts create mode 100644 packages/angular/src/generators/convert-to-with-mf/lib/write-new-webpack-config.spec.ts create mode 100644 packages/angular/src/generators/convert-to-with-mf/lib/write-new-webpack-config.ts create mode 100644 packages/angular/src/generators/convert-to-with-mf/schema.d.ts create mode 100644 packages/angular/src/generators/convert-to-with-mf/schema.json diff --git a/docs/generated/api-angular/generators/convert-to-with-mf.md b/docs/generated/api-angular/generators/convert-to-with-mf.md new file mode 100644 index 0000000000000..94b78a236ef6c --- /dev/null +++ b/docs/generated/api-angular/generators/convert-to-with-mf.md @@ -0,0 +1,48 @@ +--- +title: '@nrwl/angular:convert-to-with-mf generator' +description: + 'Converts an old micro frontend configuration to use the new withModuleFederation helper. It will run successfully if the following conditions are met: + - Is either a host or remote application + - Shared npm package configurations have not been modified + - Name used to identify the Micro Frontend application matches the project name + + _**Note:** This generator will overwrite your webpack config. If you have additional custom configuration in your config file, it will be lost!_' +--- + +# @nrwl/angular:convert-to-with-mf + +Converts an old micro frontend configuration to use the new withModuleFederation helper. It will run successfully if the following conditions are met: + +- Is either a host or remote application +- Shared npm package configurations have not been modified +- Name used to identify the Micro Frontend application matches the project name + +_**Note:** This generator will overwrite your webpack config. If you have additional custom configuration in your config file, it will be lost!_ + +## Usage + +```bash +nx generate convert-to-with-mf ... +``` + +By default, Nx will search for `convert-to-with-mf` in the default collection provisioned in `workspace.json`. + +You can specify the collection explicitly as follows: + +```bash +nx g @nrwl/angular:convert-to-with-mf ... +``` + +Show what will be generated without writing to disk: + +```bash +nx g convert-to-with-mf ... --dry-run +``` + +## Options + +### project + +Type: `string` + +The name of the micro frontend project to migrate. diff --git a/docs/map.json b/docs/map.json index b53138ac6be00..849be08ba0520 100644 --- a/docs/map.json +++ b/docs/map.json @@ -673,6 +673,11 @@ "id": "move", "file": "generated/api-angular/generators/move" }, + { + "name": "convert-to-with-mf generator", + "id": "convert-to-with-mf", + "file": "generated/api-angular/generators/convert-to-with-mf" + }, { "name": "ngrx generator", "id": "ngrx", diff --git a/packages/angular/generators.json b/packages/angular/generators.json index 6b36f9d3de66a..0d2da21b85c6d 100644 --- a/packages/angular/generators.json +++ b/packages/angular/generators.json @@ -84,6 +84,11 @@ "aliases": ["mv"], "description": "Moves an Angular application or library to another folder within the workspace and updates the project configuration." }, + "convert-to-with-mf": { + "factory": "./src/generators/convert-to-with-mf/convert-to-with-mf.compat", + "schema": "./src/generators/convert-to-with-mf/schema.json", + "description": "Converts an old micro frontend configuration to use the new withModuleFederation helper. It will run successfully if the following conditions are met: \n - Is either a host or remote application \n - Shared npm package configurations have not been modified \n - Name used to identify the Micro Frontend application matches the project name \n\n _**Note:** This generator will overwrite your webpack config. If you have additional custom configuration in your config file, it will be lost!_" + }, "mfe-host": { "factory": "./src/generators/mfe-host/mfe-host.compat", "schema": "./src/generators/mfe-host/schema.json", @@ -239,6 +244,11 @@ "aliases": ["mv"], "description": "Moves an Angular application or library to another folder within the workspace and updates the project configuration." }, + "convert-to-with-mf": { + "factory": "./src/generators/convert-to-with-mf/convert-to-with-mf", + "schema": "./src/generators/convert-to-with-mf/schema.json", + "description": "Converts an old micro frontend configuration to use the new withModuleFederation helper. It will run successfully if the following conditions are met: \n - Is either a host or remote application \n - Shared npm package configurations have not been modified \n - Name used to identify the Micro Frontend application matches the project name \n\n _**Note:** This generator will overwrite your webpack config. If you have additional custom configuration in your config file, it will be lost!_" + }, "mfe-host": { "factory": "./src/generators/mfe-host/mfe-host", "schema": "./src/generators/mfe-host/schema.json", diff --git a/packages/angular/src/generators/convert-to-with-mf/__snapshots__/convert-to-with-mf.spec.ts.snap b/packages/angular/src/generators/convert-to-with-mf/__snapshots__/convert-to-with-mf.spec.ts.snap new file mode 100644 index 0000000000000..41e5af9750453 --- /dev/null +++ b/packages/angular/src/generators/convert-to-with-mf/__snapshots__/convert-to-with-mf.spec.ts.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`convertToWithMF should migrate a standard previous generated host config correctly 1`] = ` +"const { withModuleFederation } = require('@nrwl/angular/module-federation'); + module.exports = withModuleFederation({ + name: 'host1', + remotes: [['remote1', 'http://localhost:4201']], + });" +`; + +exports[`convertToWithMF should migrate a standard previous generated remote config correctly 1`] = ` +"const { withModuleFederation } = require('@nrwl/angular/module-federation'); + module.exports = withModuleFederation({ + name: 'remote1', + exposes: { + './Module': 'apps/remote1/src/app/remote-entry/entry.module.ts', + }, + });" +`; + +exports[`convertToWithMF should migrate a standard previous generated remote config using object shared syntax correctly 1`] = ` +"const { withModuleFederation } = require('@nrwl/angular/module-federation'); + module.exports = withModuleFederation({ + name: 'remote1', + exposes: { + './Module': 'apps/remote1/src/app/remote-entry/entry.module.ts', + }, + });" +`; diff --git a/packages/angular/src/generators/convert-to-with-mf/convert-to-with-mf.compat.ts b/packages/angular/src/generators/convert-to-with-mf/convert-to-with-mf.compat.ts new file mode 100644 index 0000000000000..1389226105582 --- /dev/null +++ b/packages/angular/src/generators/convert-to-with-mf/convert-to-with-mf.compat.ts @@ -0,0 +1,4 @@ +import { convertNxGenerator } from '@nrwl/devkit'; +import convertToWithMF from './convert-to-with-mf'; + +export default convertNxGenerator(convertToWithMF); diff --git a/packages/angular/src/generators/convert-to-with-mf/convert-to-with-mf.spec.ts b/packages/angular/src/generators/convert-to-with-mf/convert-to-with-mf.spec.ts new file mode 100644 index 0000000000000..feeba0457f2a2 --- /dev/null +++ b/packages/angular/src/generators/convert-to-with-mf/convert-to-with-mf.spec.ts @@ -0,0 +1,166 @@ +import { addProjectConfiguration } from '@nrwl/devkit'; +import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing'; +import { + STANDARD_HOST_MFE_CONFIG, + STANDARD_REMOTE_MFE_CONFIG, + OLD_OBJECT_SHARED_SYNTAX, + ERROR_NAME_DOESNT_MATCH, + ERROR_SHARED_PACKAGES_DOESNT_MATCH, +} from './convert-to-with-mf.test-data'; +import convertToWithMF from './convert-to-with-mf'; + +describe('convertToWithMF', () => { + it('should migrate a standard previous generated host config correctly', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(2); + addProjectConfiguration(tree, 'host1', { + name: 'host1', + root: 'apps/host1', + sourceRoot: 'apps/host1/src', + targets: { + build: { + executor: '@nrwl/angular:webpack-browser', + options: { + customWebpackConfig: { + path: 'apps/host1/webpack.config.js', + }, + }, + }, + }, + }); + + tree.write('apps/host1/webpack.config.js', STANDARD_HOST_MFE_CONFIG); + + // ACT + await convertToWithMF(tree, { + project: 'host1', + }); + + // ASSERT + const webpackSource = tree.read('apps/host1/webpack.config.js', 'utf-8'); + expect(webpackSource).toMatchSnapshot(); + }); + + it('should migrate a standard previous generated remote config correctly', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(2); + addProjectConfiguration(tree, 'remote1', { + name: 'remote1', + root: 'apps/remote1', + sourceRoot: 'apps/remote1/src', + targets: { + build: { + executor: '@nrwl/angular:webpack-browser', + options: { + customWebpackConfig: { + path: 'apps/remote1/webpack.config.js', + }, + }, + }, + }, + }); + + tree.write('apps/remote1/webpack.config.js', STANDARD_REMOTE_MFE_CONFIG); + + // ACT + await convertToWithMF(tree, { + project: 'remote1', + }); + + // ASSERT + const webpackSource = tree.read('apps/remote1/webpack.config.js', 'utf-8'); + expect(webpackSource).toMatchSnapshot(); + }); + + it('should migrate a standard previous generated remote config using object shared syntax correctly', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(2); + addProjectConfiguration(tree, 'remote1', { + name: 'remote1', + root: 'apps/remote1', + sourceRoot: 'apps/remote1/src', + targets: { + build: { + executor: '@nrwl/angular:webpack-browser', + options: { + customWebpackConfig: { + path: 'apps/remote1/webpack.config.js', + }, + }, + }, + }, + }); + + tree.write('apps/remote1/webpack.config.js', OLD_OBJECT_SHARED_SYNTAX); + + // ACT + await convertToWithMF(tree, { + project: 'remote1', + }); + + // ASSERT + const webpackSource = tree.read('apps/remote1/webpack.config.js', 'utf-8'); + expect(webpackSource).toMatchSnapshot(); + }); + + it('should throw when the uniqueName doesnt match the project name', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(2); + addProjectConfiguration(tree, 'remote1', { + name: 'remote1', + root: 'apps/remote1', + sourceRoot: 'apps/remote1/src', + targets: { + build: { + executor: '@nrwl/angular:webpack-browser', + options: { + customWebpackConfig: { + path: 'apps/remote1/webpack.config.js', + }, + }, + }, + }, + }); + + tree.write('apps/remote1/webpack.config.js', ERROR_NAME_DOESNT_MATCH); + + // ACT & ASSERT + await expect( + convertToWithMF(tree, { + project: 'remote1', + }) + ).rejects.toThrow(); + }); + + it('should throw when the shared npm packages configs has been modified', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(2); + addProjectConfiguration(tree, 'host1', { + name: 'host1', + root: 'apps/host1', + sourceRoot: 'apps/host1/src', + targets: { + build: { + executor: '@nrwl/angular:webpack-browser', + options: { + customWebpackConfig: { + path: 'apps/host1/webpack.config.js', + }, + }, + }, + }, + }); + + tree.write( + 'apps/host1/webpack.config.js', + ERROR_SHARED_PACKAGES_DOESNT_MATCH + ); + + // ACT & ASSERT + await expect( + convertToWithMF(tree, { + project: 'host1', + }) + ).rejects.toThrow(); + }); +}); diff --git a/packages/angular/src/generators/convert-to-with-mf/convert-to-with-mf.test-data.ts b/packages/angular/src/generators/convert-to-with-mf/convert-to-with-mf.test-data.ts new file mode 100644 index 0000000000000..04ca8907cd496 --- /dev/null +++ b/packages/angular/src/generators/convert-to-with-mf/convert-to-with-mf.test-data.ts @@ -0,0 +1,449 @@ +export const STANDARD_HOST_MFE_CONFIG = `const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin'); +const mf = require('@angular-architects/module-federation/webpack'); +const path = require('path'); +const share = mf.share; + +/** + * We use the NX_TSCONFIG_PATH environment variable when using the @nrwl/angular:webpack-browser + * builder as it will generate a temporary tsconfig file which contains any required remappings of + * shared libraries. + * A remapping will occur when a library is buildable, as webpack needs to know the location of the + * built files for the buildable library. + * This NX_TSCONFIG_PATH environment variable is set by the @nrwl/angular:webpack-browser and it contains + * the location of the generated temporary tsconfig file. + */ +const tsConfigPath = + process.env.NX_TSCONFIG_PATH ?? + path.join(__dirname, '../../tsconfig.base.json'); + +const workspaceRootPath = path.join(__dirname, '../../'); +const sharedMappings = new mf.SharedMappings(); +sharedMappings.register( + tsConfigPath, + [ + /* mapped paths to share */ + ], + workspaceRootPath +); + +module.exports = { + output: { + uniqueName: 'host1', + publicPath: 'auto', + }, + optimization: { + runtimeChunk: false, + }, + experiments: { + outputModule: true, + }, + resolve: { + alias: { + ...sharedMappings.getAliases(), + }, + }, + plugins: [ + new ModuleFederationPlugin({ + remotes: { + remote1: 'http://localhost:4201/remoteEntry.mjs', + }, + shared: share({ + '@angular/core': { + singleton: true, + strictVersion: true, + requiredVersion: 'auto', + includeSecondaries: true, + }, + '@angular/common': { + singleton: true, + strictVersion: true, + requiredVersion: 'auto', + includeSecondaries: true, + }, + '@angular/common/http': { + singleton: true, + strictVersion: true, + requiredVersion: 'auto', + includeSecondaries: true, + }, + '@angular/router': { + singleton: true, + strictVersion: true, + requiredVersion: 'auto', + includeSecondaries: true, + }, + rxjs: { + singleton: true, + strictVersion: true, + requiredVersion: 'auto', + includeSecondaries: true, + }, + ...sharedMappings.getDescriptors(), + }), + library: { + type: 'module', + }, + }), + sharedMappings.getPlugin(), + ], +}; +`; + +export const STANDARD_REMOTE_MFE_CONFIG = `const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin'); +const mf = require('@angular-architects/module-federation/webpack'); +const path = require('path'); +const share = mf.share; + +/** + * We use the NX_TSCONFIG_PATH environment variable when using the @nrwl/angular:webpack-browser + * builder as it will generate a temporary tsconfig file which contains any required remappings of + * shared libraries. + * A remapping will occur when a library is buildable, as webpack needs to know the location of the + * built files for the buildable library. + * This NX_TSCONFIG_PATH environment variable is set by the @nrwl/angular:webpack-browser and it contains + * the location of the generated temporary tsconfig file. + */ +const tsConfigPath = + process.env.NX_TSCONFIG_PATH ?? + path.join(__dirname, '../../tsconfig.base.json'); + +const workspaceRootPath = path.join(__dirname, '../../'); +const sharedMappings = new mf.SharedMappings(); +sharedMappings.register( + tsConfigPath, + [ + /* mapped paths to share */ + ], + workspaceRootPath +); + +module.exports = { + output: { + uniqueName: 'remote1', + publicPath: 'auto', + }, + optimization: { + runtimeChunk: false, + }, + experiments: { + outputModule: true, + }, + resolve: { + alias: { + ...sharedMappings.getAliases(), + }, + }, + plugins: [ + new ModuleFederationPlugin({ + name: 'remote1', + filename: 'remoteEntry.js', + exposes: { + './Module': 'apps/remote1/src/app/remote-entry/entry.module.ts', + }, + shared: share({ + '@angular/core': { + singleton: true, + strictVersion: true, + requiredVersion: 'auto', + includeSecondaries: true, + }, + '@angular/common': { + singleton: true, + strictVersion: true, + requiredVersion: 'auto', + includeSecondaries: true, + }, + '@angular/common/http': { + singleton: true, + strictVersion: true, + requiredVersion: 'auto', + includeSecondaries: true, + }, + '@angular/router': { + singleton: true, + strictVersion: true, + requiredVersion: 'auto', + includeSecondaries: true, + }, + rxjs: { + singleton: true, + strictVersion: true, + requiredVersion: 'auto', + includeSecondaries: true, + }, + ...sharedMappings.getDescriptors(), + }), + library: { + type: 'module', + }, + }), + sharedMappings.getPlugin(), + ], +}; +`; + +export const OLD_OBJECT_SHARED_SYNTAX = `const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin'); +const mf = require('@angular-architects/module-federation/webpack'); +const path = require('path'); +const share = mf.share; + +/** + * We use the NX_TSCONFIG_PATH environment variable when using the @nrwl/angular:webpack-browser + * builder as it will generate a temporary tsconfig file which contains any required remappings of + * shared libraries. + * A remapping will occur when a library is buildable, as webpack needs to know the location of the + * built files for the buildable library. + * This NX_TSCONFIG_PATH environment variable is set by the @nrwl/angular:webpack-browser and it contains + * the location of the generated temporary tsconfig file. + */ +const tsConfigPath = + process.env.NX_TSCONFIG_PATH ?? + path.join(__dirname, '../../tsconfig.base.json'); + +const workspaceRootPath = path.join(__dirname, '../../'); +const sharedMappings = new mf.SharedMappings(); +sharedMappings.register( + tsConfigPath, + [ + /* mapped paths to share */ + ], + workspaceRootPath +); + +module.exports = { + output: { + uniqueName: 'remote1', + publicPath: 'auto', + }, + optimization: { + runtimeChunk: false, + }, + experiments: { + outputModule: true, + }, + resolve: { + alias: { + ...sharedMappings.getAliases(), + }, + }, + plugins: [ + new ModuleFederationPlugin({ + name: 'remote1', + filename: 'remoteEntry.js', + exposes: { + './Module': 'apps/remote1/src/app/remote-entry/entry.module.ts', + }, + shared: { + '@angular/core': { + singleton: true, + strictVersion: true, + }, + '@angular/common': { + singleton: true, + strictVersion: true, + }, + '@angular/common/http': { + singleton: true, + strictVersion: true, + }, + '@angular/router': { + singleton: true, + strictVersion: true, + }, + rxjs: { + singleton: true, + strictVersion: true, + }, + ...sharedMappings.getDescriptors(), + }, + library: { + type: 'module', + }, + }), + sharedMappings.getPlugin(), + ], +};`; + +export const ERROR_NAME_DOESNT_MATCH = `const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin'); +const mf = require('@angular-architects/module-federation/webpack'); +const path = require('path'); +const share = mf.share; + +/** + * We use the NX_TSCONFIG_PATH environment variable when using the @nrwl/angular:webpack-browser + * builder as it will generate a temporary tsconfig file which contains any required remappings of + * shared libraries. + * A remapping will occur when a library is buildable, as webpack needs to know the location of the + * built files for the buildable library. + * This NX_TSCONFIG_PATH environment variable is set by the @nrwl/angular:webpack-browser and it contains + * the location of the generated temporary tsconfig file. + */ +const tsConfigPath = + process.env.NX_TSCONFIG_PATH ?? + path.join(__dirname, '../../tsconfig.base.json'); + +const workspaceRootPath = path.join(__dirname, '../../'); +const sharedMappings = new mf.SharedMappings(); +sharedMappings.register( + tsConfigPath, + [ + /* mapped paths to share */ + ], + workspaceRootPath +); + +module.exports = { + output: { + uniqueName: 'somethingelse', + publicPath: 'auto', + }, + optimization: { + runtimeChunk: false, + }, + experiments: { + outputModule: true, + }, + resolve: { + alias: { + ...sharedMappings.getAliases(), + }, + }, + plugins: [ + new ModuleFederationPlugin({ + name: 'remote1', + filename: 'remoteEntry.js', + exposes: { + './Module': 'apps/remote1/src/app/remote-entry/entry.module.ts', + }, + shared: share({ + '@angular/core': { + singleton: true, + strictVersion: true, + requiredVersion: 'auto', + includeSecondaries: true, + }, + '@angular/common': { + singleton: true, + strictVersion: true, + requiredVersion: 'auto', + includeSecondaries: true, + }, + '@angular/common/http': { + singleton: true, + strictVersion: true, + requiredVersion: 'auto', + includeSecondaries: true, + }, + '@angular/router': { + singleton: true, + strictVersion: true, + requiredVersion: 'auto', + includeSecondaries: true, + }, + rxjs: { + singleton: true, + strictVersion: true, + requiredVersion: 'auto', + includeSecondaries: true, + }, + ...sharedMappings.getDescriptors(), + }), + library: { + type: 'module', + }, + }), + sharedMappings.getPlugin(), + ], +}; +`; + +export const ERROR_SHARED_PACKAGES_DOESNT_MATCH = `const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin'); +const mf = require('@angular-architects/module-federation/webpack'); +const path = require('path'); +const share = mf.share; + +/** + * We use the NX_TSCONFIG_PATH environment variable when using the @nrwl/angular:webpack-browser + * builder as it will generate a temporary tsconfig file which contains any required remappings of + * shared libraries. + * A remapping will occur when a library is buildable, as webpack needs to know the location of the + * built files for the buildable library. + * This NX_TSCONFIG_PATH environment variable is set by the @nrwl/angular:webpack-browser and it contains + * the location of the generated temporary tsconfig file. + */ +const tsConfigPath = + process.env.NX_TSCONFIG_PATH ?? + path.join(__dirname, '../../tsconfig.base.json'); + +const workspaceRootPath = path.join(__dirname, '../../'); +const sharedMappings = new mf.SharedMappings(); +sharedMappings.register( + tsConfigPath, + [ + /* mapped paths to share */ + ], + workspaceRootPath +); + +module.exports = { + output: { + uniqueName: 'host1', + publicPath: 'auto', + }, + optimization: { + runtimeChunk: false, + }, + experiments: { + outputModule: true, + }, + resolve: { + alias: { + ...sharedMappings.getAliases(), + }, + }, + plugins: [ + new ModuleFederationPlugin({ + remotes: { + remote1: 'http://localhost:4201/remoteEntry.mjs', + }, + shared: share({ + '@angular/core': { + singleton: true, + strictVersion: true, + requiredVersion: '13.0.0', + includeSecondaries: true, + }, + '@angular/common': { + singleton: true, + strictVersion: true, + requiredVersion: 'auto', + includeSecondaries: true, + }, + '@angular/common/http': { + singleton: true, + strictVersion: false, + requiredVersion: 'auto', + includeSecondaries: true, + }, + '@angular/router': { + singleton: true, + strictVersion: true, + requiredVersion: 'auto', + includeSecondaries: true, + }, + rxjs: { + singleton: true, + strictVersion: true, + requiredVersion: 'auto', + includeSecondaries: true, + }, + ...sharedMappings.getDescriptors(), + }), + library: { + type: 'module', + }, + }), + sharedMappings.getPlugin(), + ], +}; +`; diff --git a/packages/angular/src/generators/convert-to-with-mf/convert-to-with-mf.ts b/packages/angular/src/generators/convert-to-with-mf/convert-to-with-mf.ts new file mode 100644 index 0000000000000..ef331267956ba --- /dev/null +++ b/packages/angular/src/generators/convert-to-with-mf/convert-to-with-mf.ts @@ -0,0 +1,54 @@ +import { logger, Tree } from '@nrwl/devkit'; +import type { Schema } from './schema'; + +import { readProjectConfiguration, formatFiles } from '@nrwl/devkit'; +import { getMfeProjects } from '../../utils/get-mfe-projects'; +import { + checkOutputNameMatchesProjectName, + checkSharedNpmPackagesMatchExpected, + getWebpackConfigPath, + isHostRemoteConfig, + parseASTOfWebpackConfig, + writeNewWebpackConfig, +} from './lib'; + +export default async function convertToWithMF(tree: Tree, schema: Schema) { + const projects = new Set(getMfeProjects(tree)); + + if (!projects.has(schema.project)) { + throw new Error( + `Could not find project "${schema.project}" with a Micro Frontend configuration in your workspace. Please check the name of the project you're wishing to convert exists.` + ); + } + + const project = readProjectConfiguration(tree, schema.project); + const pathToWebpackConfig = getWebpackConfigPath(project, schema.project); + const webpackAst = parseASTOfWebpackConfig(tree, pathToWebpackConfig); + + if (!checkOutputNameMatchesProjectName(webpackAst, schema.project)) { + throw new Error( + `Cannot automatically migrate "${schema.project}" to "withModuleFederation" micro frontend webpack config. + "uniqueName" in webpack config (${pathToWebpackConfig}) does not match project name.` + ); + } + + if (!checkSharedNpmPackagesMatchExpected(webpackAst)) { + throw new Error( + `Cannot automatically migrate "${schema.project}" to "withModuleFederation" micro frontend webpack config. + There are npm packages being shared with a custom configuration in webpack config (${pathToWebpackConfig}).` + ); + } + + logger.warn( + `This Micro Frontend configuration conversion will overwrite "${schema.project}"'s current webpack config. If you have anything custom that is not related to Micro Frontends, it will be lost. You should be able to see the changes in your version control system.` + ); + + const updatedWebpackConfig = writeNewWebpackConfig( + webpackAst, + isHostRemoteConfig(webpackAst), + schema.project + ); + tree.write(pathToWebpackConfig, updatedWebpackConfig); + + await formatFiles(tree); +} diff --git a/packages/angular/src/generators/convert-to-with-mf/lib/__snapshots__/is-host-remote-config.spec.ts.snap b/packages/angular/src/generators/convert-to-with-mf/lib/__snapshots__/is-host-remote-config.spec.ts.snap new file mode 100644 index 0000000000000..26ea2e502efbb --- /dev/null +++ b/packages/angular/src/generators/convert-to-with-mf/lib/__snapshots__/is-host-remote-config.spec.ts.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`isHostRemoteConfig should return remote when correct remote config found 1`] = ` +"{ + './Module': 'apps/remote1/src/app/remote-entry/entry.module.ts' + }" +`; diff --git a/packages/angular/src/generators/convert-to-with-mf/lib/__snapshots__/write-new-webpack-config.spec.ts.snap b/packages/angular/src/generators/convert-to-with-mf/lib/__snapshots__/write-new-webpack-config.spec.ts.snap new file mode 100644 index 0000000000000..889987fc91bb6 --- /dev/null +++ b/packages/angular/src/generators/convert-to-with-mf/lib/__snapshots__/write-new-webpack-config.spec.ts.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`writeNewWebpackConfig should convert config that is both remote and host correctly 1`] = ` +"const { withModuleFederation } = require('@nrwl/angular/module-federation'); + module.exports = withModuleFederation({ + name: 'both1', + remotes: [['remote1', 'http://localhost:4201']], + exposes: { + './Module': 'apps/both/src/app/remote-entry/entry.module.ts' + }, + });" +`; + +exports[`writeNewWebpackConfig should convert config that is neither remote and host correctly 1`] = ` +"const { withModuleFederation } = require('@nrwl/angular/module-federation'); + module.exports = withModuleFederation({ + name: 'neither', + });" +`; + +exports[`writeNewWebpackConfig should convert host config correctly 1`] = ` +"const { withModuleFederation } = require('@nrwl/angular/module-federation'); + module.exports = withModuleFederation({ + name: 'host1', + remotes: [['remote1', 'http://localhost:4201']], + });" +`; + +exports[`writeNewWebpackConfig should convert remote config correctly 1`] = ` +"const { withModuleFederation } = require('@nrwl/angular/module-federation'); + module.exports = withModuleFederation({ + name: 'remote1', + exposes: { + './Module': 'apps/remote1/src/app/remote-entry/entry.module.ts' + }, + });" +`; diff --git a/packages/angular/src/generators/convert-to-with-mf/lib/check-name-matches.spec.ts b/packages/angular/src/generators/convert-to-with-mf/lib/check-name-matches.spec.ts new file mode 100644 index 0000000000000..0de7043ffd977 --- /dev/null +++ b/packages/angular/src/generators/convert-to-with-mf/lib/check-name-matches.spec.ts @@ -0,0 +1,37 @@ +import { tsquery } from '@phenomnomnominal/tsquery'; +import { checkOutputNameMatchesProjectName } from './check-name-matches'; +describe('checkOutputNameMatchesProjectName', () => { + it('should return true if the uniqueName matches the project name', () => { + // ARRANGE + const sourceText = `module.exports = { + output: { + uniqueName: 'proj' + } + }`; + + const ast = tsquery.ast(sourceText); + + // ACT + const result = checkOutputNameMatchesProjectName(ast, 'proj'); + + // ASSERT + expect(result).toBeTruthy(); + }); + + it('should return false if the uniqueName does not match the project name', () => { + // ARRANGE + const sourceText = `module.exports = { + output: { + uniqueName: 'app1' + } + }`; + + const ast = tsquery.ast(sourceText); + + // ACT + const result = checkOutputNameMatchesProjectName(ast, 'proj'); + + // ASSERT + expect(result).toBeFalsy(); + }); +}); diff --git a/packages/angular/src/generators/convert-to-with-mf/lib/check-name-matches.ts b/packages/angular/src/generators/convert-to-with-mf/lib/check-name-matches.ts new file mode 100644 index 0000000000000..94efb92c58bc4 --- /dev/null +++ b/packages/angular/src/generators/convert-to-with-mf/lib/check-name-matches.ts @@ -0,0 +1,29 @@ +import type { SourceFile } from 'typescript'; +import { tsquery } from '@phenomnomnominal/tsquery'; + +export function checkOutputNameMatchesProjectName( + ast: SourceFile, + projectName: string +) { + const OUTPUT_SELECTOR = + 'PropertyAssignment:has(Identifier[name=output]) > ObjectLiteralExpression:has(PropertyAssignment:has(Identifier[name=uniqueName]))'; + const UNIQUENAME_SELECTOR = + 'ObjectLiteralExpression > PropertyAssignment:has(Identifier[name=uniqueName]) > StringLiteral'; + + const outputNodes = tsquery(ast, OUTPUT_SELECTOR, { visitAllChildren: true }); + if (outputNodes.length === 0) { + // If the output isnt set in the config, then we can still set the project name correctly + return true; + } + + const uniqueNameNodes = tsquery(outputNodes[0], UNIQUENAME_SELECTOR, { + visitAllChildren: true, + }); + if (uniqueNameNodes.length === 0) { + // If the uniqeName isnt set in the config, then we can still set the project name correctly + return true; + } + const uniqueName = uniqueNameNodes[0].getText().replace(/'/g, ''); + + return uniqueName === projectName; +} diff --git a/packages/angular/src/generators/convert-to-with-mf/lib/check-shared-npm-packages.spec.ts b/packages/angular/src/generators/convert-to-with-mf/lib/check-shared-npm-packages.spec.ts new file mode 100644 index 0000000000000..ebb028fdb319d --- /dev/null +++ b/packages/angular/src/generators/convert-to-with-mf/lib/check-shared-npm-packages.spec.ts @@ -0,0 +1,182 @@ +import { tsquery } from '@phenomnomnominal/tsquery'; +import { checkSharedNpmPackagesMatchExpected } from './check-shared-npm-packages'; + +describe('checkSharedNpmPackagesMatchExpected', () => { + it('should return true if all shared packages match the config object we expect', () => { + // ARRANGE + const sourceText = `module.exports = { + plugins: [ + new ModuleFederationPlugin({ + shared: share({ + '@angular/core': { + singleton: true, + strictVersion: true, + requiredVersion: 'auto', + includeSecondaries: true + }, + '@angular/common': { + singleton: true, + strictVersion: true, + requiredVersion: 'auto', + includeSecondaries: true + }, + 'rxjs': { + singleton: true, + strictVersion: true, + requiredVersion: 'auto', + includeSecondaries: true + }, + }) + }) + ] + }`; + + const ast = tsquery.ast(sourceText); + + // ACT + const result = checkSharedNpmPackagesMatchExpected(ast); + + // ASSERT + expect(result).toBeTruthy(); + }); + + it('should return true if all shared packages match the config object we expect using object syntax', () => { + // ARRANGE + const sourceText = `module.exports = { + plugins: [ + new ModuleFederationPlugin({ + shared: { + '@angular/core': { + singleton: true, + strictVersion: true, + }, + '@angular/common': { + singleton: true, + strictVersion: true, + }, + 'rxjs': { + singleton: true, + strictVersion: true, + }, + } + }) + ] + }`; + + const ast = tsquery.ast(sourceText); + + // ACT + const result = checkSharedNpmPackagesMatchExpected(ast); + + // ASSERT + expect(result).toBeTruthy(); + }); + + it('should return false if any shared packages do not match the config object we expect using object syntax', () => { + // ARRANGE + const sourceText = `module.exports = { + plugins: [ + new ModuleFederationPlugin({ + shared: { + '@angular/core': { + singleton: true, + strictVersion: true, + }, + '@angular/common': { + singleton: true, + strictVersion: false, + }, + 'rxjs': { + singleton: true, + strictVersion: true, + }, + } + }) + ] + }`; + + const ast = tsquery.ast(sourceText); + + // ACT + const result = checkSharedNpmPackagesMatchExpected(ast); + + // ASSERT + expect(result).toBeFalsy(); + }); + + it('should return true if we arent sharing packages with the share helper', () => { + // ARRANGE + const sourceText = `module.exports = { + plugins: [ + new ModuleFederationPlugin({ + shared: share({}) + }) + ] + }`; + + const ast = tsquery.ast(sourceText); + + // ACT + const result = checkSharedNpmPackagesMatchExpected(ast); + + // ASSERT + expect(result).toBeTruthy(); + }); + + it('should return true if we arent sharing packages with the standard shared syntax', () => { + // ARRANGE + const sourceText = `module.exports = { + plugins: [ + new ModuleFederationPlugin({ + shared: {} + }) + ] + }`; + + const ast = tsquery.ast(sourceText); + + // ACT + const result = checkSharedNpmPackagesMatchExpected(ast); + + // ASSERT + expect(result).toBeTruthy(); + }); + + it('should return false if any shared packages does not match the config object we expect', () => { + // ARRANGE + const sourceText = `module.exports = { + plugins: [ + new ModuleFederationPlugin({ + shared: share({ + '@angular/core': { + singleton: true, + strictVersion: true, + requiredVersion: 'auto', + includeSecondaries: true + }, + '@angular/common': { + singleton: true, + strictVersion: false, + requiredVersion: 'auto', + includeSecondaries: true + }, + 'rxjs': { + singleton: true, + strictVersion: true, + requiredVersion: 'auto', + includeSecondaries: true + }, + }) + }) + ] + }`; + + const ast = tsquery.ast(sourceText); + + // ACT + const result = checkSharedNpmPackagesMatchExpected(ast); + + // ASSERT + expect(result).toBeFalsy(); + }); +}); diff --git a/packages/angular/src/generators/convert-to-with-mf/lib/check-shared-npm-packages.ts b/packages/angular/src/generators/convert-to-with-mf/lib/check-shared-npm-packages.ts new file mode 100644 index 0000000000000..85f57d5f73c46 --- /dev/null +++ b/packages/angular/src/generators/convert-to-with-mf/lib/check-shared-npm-packages.ts @@ -0,0 +1,68 @@ +import type { SourceFile, Node } from 'typescript'; +import { tsquery } from '@phenomnomnominal/tsquery'; + +export function checkSharedNpmPackagesMatchExpected(ast: SourceFile) { + const SHARE_HELPER_SELECTOR = + 'PropertyAssignment:has(Identifier[name=shared]) > CallExpression:has(Identifier[name=share])'; + const SHARED_PACKAGE_CONFIG_SELECTOR = + 'ObjectLiteralExpression > PropertyAssignment > ObjectLiteralExpression'; + + const shareHelperNodes = tsquery(ast, SHARE_HELPER_SELECTOR, { + visitAllChildren: true, + }); + + let sharedPackageConfigNodes: Node[]; + let settingsToMatch: string[] = []; + if (shareHelperNodes.length === 0) { + // if we arent sharing using share helper, check for standard object sharing syntax + const SHARED_OBJECT_SELECTOR = + 'PropertyAssignment:has(Identifier[name=shared]) > ObjectLiteralExpression'; + const sharedObjectNodes = tsquery(ast, SHARED_OBJECT_SELECTOR, { + visitAllChildren: true, + }); + + if (sharedObjectNodes.length === 0) { + // nothing is being shared, we're safe to continue + return true; + } + + sharedPackageConfigNodes = tsquery( + sharedObjectNodes[0], + SHARED_PACKAGE_CONFIG_SELECTOR, + { visitAllChildren: true } + ); + + settingsToMatch = [`singleton: true`, `strictVersion: true`]; + } else { + sharedPackageConfigNodes = tsquery( + shareHelperNodes[0], + SHARED_PACKAGE_CONFIG_SELECTOR, + { visitAllChildren: true } + ); + + settingsToMatch = [ + `singleton: true`, + `strictVersion: true`, + `requiredVersion: 'auto'`, + ]; + } + + if (sharedPackageConfigNodes.length === 0) { + // we arent sharing configs with the share helper, so we can safely continue + return true; + } + + let packagesMatch = true; + for (const configNode of sharedPackageConfigNodes) { + const configText = configNode.getText(); + packagesMatch = settingsToMatch.every((setting) => + configText.includes(setting) + ); + + if (!packagesMatch) { + break; + } + } + + return packagesMatch; +} diff --git a/packages/angular/src/generators/convert-to-with-mf/lib/get-webpack-config-path.ts b/packages/angular/src/generators/convert-to-with-mf/lib/get-webpack-config-path.ts new file mode 100644 index 0000000000000..2fccee3f1a0be --- /dev/null +++ b/packages/angular/src/generators/convert-to-with-mf/lib/get-webpack-config-path.ts @@ -0,0 +1,25 @@ +import type { ProjectConfiguration } from '@nrwl/devkit'; + +export function getWebpackConfigPath( + project: ProjectConfiguration, + projectName: string +) { + let pathToWebpackConfig = ''; + for (const target of Object.values(project.targets ?? {})) { + if ( + target.executor === '@nrwl/angular:webpack-browser' && + target.options.customWebpackConfig?.path + ) { + pathToWebpackConfig = target.options.customWebpackConfig?.path; + break; + } + } + + if (!pathToWebpackConfig) { + throw new Error( + `Could not find webpack config for \`${projectName}\` in your workspace.` + ); + } + + return pathToWebpackConfig; +} diff --git a/packages/angular/src/generators/convert-to-with-mf/lib/index.ts b/packages/angular/src/generators/convert-to-with-mf/lib/index.ts new file mode 100644 index 0000000000000..f1da8f1bdf7b6 --- /dev/null +++ b/packages/angular/src/generators/convert-to-with-mf/lib/index.ts @@ -0,0 +1,6 @@ +export * from './check-name-matches'; +export * from './check-shared-npm-packages'; +export * from './get-webpack-config-path'; +export * from './is-host-remote-config'; +export * from './parse-ast-webpack-config'; +export * from './write-new-webpack-config'; diff --git a/packages/angular/src/generators/convert-to-with-mf/lib/is-host-remote-config.spec.ts b/packages/angular/src/generators/convert-to-with-mf/lib/is-host-remote-config.spec.ts new file mode 100644 index 0000000000000..b201cc2fffe92 --- /dev/null +++ b/packages/angular/src/generators/convert-to-with-mf/lib/is-host-remote-config.spec.ts @@ -0,0 +1,135 @@ +import { tsquery } from '@phenomnomnominal/tsquery'; +import { + isHostRemoteConfig, + getRemotesFromHost, + getExposedModulesFromRemote, +} from './is-host-remote-config'; + +describe('isHostRemoteConfig', () => { + it('should return host when correct host config found', () => { + // ARRANGE + const sourceText = `module.exports = { + plugins: [ + new ModuleFederationPlugin({ + remotes: { + remote1: 'http://localhost:4201/remoteEntry.mjs' + } + }) + ] + }`; + + const ast = tsquery.ast(sourceText); + + // ACT + const result = isHostRemoteConfig(ast); + + // ASSERT + expect(result).toEqual('host'); + }); + + it('should return remote when correct remote config found', () => { + // ARRANGE + const sourceText = `module.exports = { + plugins: [ + new ModuleFederationPlugin({ + exposes: { + './Module': 'apps/remote1/src/app/remote-entry/entry.module.ts' + } + }) + ] + }`; + + const ast = tsquery.ast(sourceText); + + // ACT + const result = isHostRemoteConfig(ast); + + // ASSERT + expect(result).toEqual('remote'); + }); + + it('should return both when correct remote and host config found', () => { + // ARRANGE + const sourceText = `module.exports = { + plugins: [ + new ModuleFederationPlugin({ + remotes: { + remote1: 'http://localhost:4201/remoteEntry.mjs' + }, + exposes: { + './Module': 'apps/both/src/app/remote-entry/entry.module.ts' + } + }) + ] + }`; + + const ast = tsquery.ast(sourceText); + + // ACT + const result = isHostRemoteConfig(ast); + + // ASSERT + expect(result).toEqual('both'); + }); + + it('should return false when no valid config found', () => { + // ARRANGE + const sourceText = `module.exports = { + plugins: [ + new ModuleFederationPlugin({ + }) + ] + }`; + + const ast = tsquery.ast(sourceText); + + // ACT + const result = isHostRemoteConfig(ast); + + // ASSERT + expect(result).toBeFalsy(); + }); + + it('should return remotes from the host correctly', () => { + // ARRANGE + const sourceText = `module.exports = { + plugins: [ + new ModuleFederationPlugin({ + remotes: { + remote1: 'http://localhost:4201/remoteEntry.mjs' + } + }) + ] + }`; + + const ast = tsquery.ast(sourceText); + + // ACT + const result = getRemotesFromHost(ast); + + // ASSERT + expect(result).toEqual([['remote1', 'http://localhost:4201']]); + }); + + it('should return remote when correct remote config found', () => { + // ARRANGE + const sourceText = `module.exports = { + plugins: [ + new ModuleFederationPlugin({ + exposes: { + './Module': 'apps/remote1/src/app/remote-entry/entry.module.ts' + } + }) + ] + }`; + + const ast = tsquery.ast(sourceText); + + // ACT + const result = getExposedModulesFromRemote(ast); + + // ASSERT + // this needs to be snapshot because prettier formats a literal string incorrectly, causing test failure + expect(result).toMatchSnapshot(); + }); +}); diff --git a/packages/angular/src/generators/convert-to-with-mf/lib/is-host-remote-config.ts b/packages/angular/src/generators/convert-to-with-mf/lib/is-host-remote-config.ts new file mode 100644 index 0000000000000..e550c7f4576a5 --- /dev/null +++ b/packages/angular/src/generators/convert-to-with-mf/lib/is-host-remote-config.ts @@ -0,0 +1,73 @@ +import type { SourceFile } from 'typescript'; +import { tsquery } from '@phenomnomnominal/tsquery'; + +export type IsHostRemoteConfigResult = 'host' | 'remote' | 'both' | false; + +const REMOTES_EXPRESSION_SELECTOR = + 'PropertyAssignment:has(Identifier[name=remotes]) > ObjectLiteralExpression'; +const EXPOSES_EXPRESSION_SELECTOR = + 'PropertyAssignment:has(Identifier[name=exposes]) > ObjectLiteralExpression'; +const PROPERTY_SELECTOR = 'ObjectLiteralExpression > PropertyAssignment'; + +export function isHostRemoteConfig(ast: SourceFile): IsHostRemoteConfigResult { + let isHost = false; + let isRemote = false; + + const remotesNodes = tsquery(ast, REMOTES_EXPRESSION_SELECTOR, { + visitAllChildren: true, + }); + if (remotesNodes.length > 0) { + isHost = true; + } + + const exposesNodes = tsquery(ast, EXPOSES_EXPRESSION_SELECTOR, { + visitAllChildren: true, + }); + if (exposesNodes.length > 0) { + isRemote = true; + } + + let result: IsHostRemoteConfigResult = + isHost && isRemote ? 'both' : isHost ? 'host' : isRemote ? 'remote' : false; + return result; +} + +export function getRemotesFromHost(ast: SourceFile) { + const remotesObjectNodes = tsquery(ast, REMOTES_EXPRESSION_SELECTOR, { + visitAllChildren: true, + }); + if (remotesObjectNodes.length === 0) { + return []; + } + + const remotesNodes = tsquery(remotesObjectNodes[0], PROPERTY_SELECTOR, { + visitAllChildren: true, + }); + + if (remotesNodes.length === 0) { + return []; + } + + const remotes = []; + for (const remoteNode of remotesNodes) { + const remoteText = remoteNode.getText(); + const remoteParts = remoteText + .split(':') + .map((part) => part.trim().replace(/'/g, '')); + const remoteName = remoteParts.shift(); + const remoteLocation = remoteParts.join(':').replace(/\/[^\/]+$/, ''); + remotes.push([remoteName, remoteLocation]); + } + return remotes; +} + +export function getExposedModulesFromRemote(ast: SourceFile) { + const exposesObjectNodes = tsquery(ast, EXPOSES_EXPRESSION_SELECTOR, { + visitAllChildren: true, + }); + if (exposesObjectNodes.length === 0) { + return {}; + } + + return exposesObjectNodes[0].getText(); +} diff --git a/packages/angular/src/generators/convert-to-with-mf/lib/parse-ast-webpack-config.ts b/packages/angular/src/generators/convert-to-with-mf/lib/parse-ast-webpack-config.ts new file mode 100644 index 0000000000000..f86126c85f63d --- /dev/null +++ b/packages/angular/src/generators/convert-to-with-mf/lib/parse-ast-webpack-config.ts @@ -0,0 +1,16 @@ +import type { Tree } from '@nrwl/devkit'; +import { tsquery } from '@phenomnomnominal/tsquery'; + +export function parseASTOfWebpackConfig( + tree: Tree, + pathToWebpackConfig: string +) { + if (!tree.exists(pathToWebpackConfig)) { + throw new Error( + `Cannot migrate webpack config at \`${pathToWebpackConfig}\` as it does not exist. Please ensure this file exists and that the path to the file is correct.` + ); + } + + const source = tree.read(pathToWebpackConfig, 'utf-8'); + return tsquery.ast(source); +} diff --git a/packages/angular/src/generators/convert-to-with-mf/lib/write-new-webpack-config.spec.ts b/packages/angular/src/generators/convert-to-with-mf/lib/write-new-webpack-config.spec.ts new file mode 100644 index 0000000000000..a89db33da573d --- /dev/null +++ b/packages/angular/src/generators/convert-to-with-mf/lib/write-new-webpack-config.spec.ts @@ -0,0 +1,87 @@ +import { tsquery } from '@phenomnomnominal/tsquery'; +import { writeNewWebpackConfig } from './write-new-webpack-config'; + +describe('writeNewWebpackConfig', () => { + it('should convert host config correctly', () => { + // ARRANGE + const sourceText = `module.exports = { + plugins: [ + new ModuleFederationPlugin({ + remotes: { + remote1: 'http://localhost:4201/remoteEntry.mjs' + } + }) + ] + }`; + + const ast = tsquery.ast(sourceText); + + // ACT + const result = writeNewWebpackConfig(ast, 'host', 'host1'); + + // ASSERT + expect(result).toMatchSnapshot(); + }); + + it('should convert remote config correctly', () => { + // ARRANGE + const sourceText = `module.exports = { + plugins: [ + new ModuleFederationPlugin({ + exposes: { + './Module': 'apps/remote1/src/app/remote-entry/entry.module.ts' + } + }) + ] + }`; + + const ast = tsquery.ast(sourceText); + + // ACT + const result = writeNewWebpackConfig(ast, 'remote', 'remote1'); + + // ASSERT + expect(result).toMatchSnapshot(); + }); + + it('should convert config that is both remote and host correctly', () => { + // ARRANGE + const sourceText = `module.exports = { + plugins: [ + new ModuleFederationPlugin({ + remotes: { + remote1: 'http://localhost:4201/remoteEntry.mjs' + }, + exposes: { + './Module': 'apps/both/src/app/remote-entry/entry.module.ts' + } + }) + ] + }`; + + const ast = tsquery.ast(sourceText); + + // ACT + const result = writeNewWebpackConfig(ast, 'both', 'both1'); + + // ASSERT + expect(result).toMatchSnapshot(); + }); + + it('should convert config that is neither remote and host correctly', () => { + // ARRANGE + const sourceText = `module.exports = { + plugins: [ + new ModuleFederationPlugin({}) + ] + }`; + + const ast = tsquery.ast(sourceText); + + // ACT + const result = writeNewWebpackConfig(ast, false, 'neither'); + + // ASSERT + expect(result).toMatchSnapshot(); + }); +}); diff --git a/packages/angular/src/generators/convert-to-with-mf/lib/write-new-webpack-config.ts b/packages/angular/src/generators/convert-to-with-mf/lib/write-new-webpack-config.ts new file mode 100644 index 0000000000000..87dd62a1f8023 --- /dev/null +++ b/packages/angular/src/generators/convert-to-with-mf/lib/write-new-webpack-config.ts @@ -0,0 +1,55 @@ +import type { SourceFile } from 'typescript'; +import { + getExposedModulesFromRemote, + getRemotesFromHost, + IsHostRemoteConfigResult, +} from './is-host-remote-config'; + +export function writeNewWebpackConfig( + ast: SourceFile, + mfType: IsHostRemoteConfigResult, + projectName: string +) { + let webpackConfig = ''; + if (!mfType) { + webpackConfig = `const { withModuleFederation } = require('@nrwl/angular/module-federation'); + module.exports = withModuleFederation({ + name: '${projectName}', + });`; + } else if (mfType === 'host') { + const remotes = hostRemotesToString(ast); + webpackConfig = `const { withModuleFederation } = require('@nrwl/angular/module-federation'); + module.exports = withModuleFederation({ + name: '${projectName}', + remotes: ${remotes}, + });`; + } else if (mfType === 'remote') { + const exposedModules = getExposedModulesFromRemote(ast); + webpackConfig = `const { withModuleFederation } = require('@nrwl/angular/module-federation'); + module.exports = withModuleFederation({ + name: '${projectName}', + exposes: ${exposedModules}, + });`; + } else if (mfType === 'both') { + const remotes = hostRemotesToString(ast); + const exposedModules = getExposedModulesFromRemote(ast); + webpackConfig = `const { withModuleFederation } = require('@nrwl/angular/module-federation'); + module.exports = withModuleFederation({ + name: '${projectName}', + remotes: ${remotes}, + exposes: ${exposedModules}, + });`; + } + + return webpackConfig; +} + +function hostRemotesToString(ast: SourceFile) { + const remotes: string = getRemotesFromHost(ast) + .reduce( + (acc, remotePair) => `['${remotePair[0]}', '${remotePair[1]}'], ${acc}`, + '' + ) + .trim(); + return `[${remotes.endsWith(',') ? remotes.slice(0, -1) : remotes}]`; +} diff --git a/packages/angular/src/generators/convert-to-with-mf/schema.d.ts b/packages/angular/src/generators/convert-to-with-mf/schema.d.ts new file mode 100644 index 0000000000000..e76548202a2ae --- /dev/null +++ b/packages/angular/src/generators/convert-to-with-mf/schema.d.ts @@ -0,0 +1,3 @@ +export interface Schema { + project: string; +} diff --git a/packages/angular/src/generators/convert-to-with-mf/schema.json b/packages/angular/src/generators/convert-to-with-mf/schema.json new file mode 100644 index 0000000000000..5f3e3912911ae --- /dev/null +++ b/packages/angular/src/generators/convert-to-with-mf/schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "ConvertToWithMFGenerator", + "cli": "nx", + "title": "Convert to withModuleFederation Generator Options Schema", + "type": "object", + "description": "Converts an old micro frontend configuration to use the new withModuleFederation helper. It will run successfully if the following conditions are met: \n - Is either a host or remote application \n - Shared npm package configurations have not been modified \n - Name used to identify the Micro Frontend application matches the project name \n\n _**Note:** This generator will overwrite your webpack config. If you have additional custom configuration in your config file, it will be lost!_", + "additionalProperties": false, + "properties": { + "project": { + "type": "string", + "description": "The name of the micro frontend project to migrate.", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What micro frontend project would you like to migrate?" + } + } +}