feat(angular): init mfe generator (#6209)

Add generator to scaffold Module Federation configuration for a given Angular application
This commit is contained in:
Colum Ferry 2021-07-02 13:45:36 +01:00 committed by GitHub
parent 80d3999d42
commit dd51c18d12
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 764 additions and 2 deletions

View File

@ -0,0 +1,59 @@
# setup-mfe
Generate a Module Federation configuration for a given Angular application.
## Usage
```bash
nx generate setup-mfe ...
```
By default, Nx will search for `setup-mfe` in the default collection provisioned in `angular.json`.
You can specify the collection explicitly as follows:
```bash
nx g @nrwl/angular:setup-mfe ...
```
Show what will be generated without writing to disk:
```bash
nx g setup-mfe ... --dry-run
```
## Options
### appName
Type: `string`
The name of the application to generate the Module Federation configuration for.
### mfeType
Default: `shell`
Type: `string`
Possible values: `shell`, `remote`
Type of application to generate the Module Federation configuration for.
### port
Type: `number`
The port at which the remote application should be served.
### remotes
Type: `array`
A list of remote application names that the shell application should consume.
### skipFormat
Type: `boolean`
Skip formatting the workspace after the generator completes.

View File

@ -1031,6 +1031,8 @@ Reads a project configuration.
The project configuration is stored in workspace.json and nx.json. The utility will read
both files.
**`throws`** If supplied projectName cannot be found
#### Parameters
| Name | Type | Description |

View File

@ -422,6 +422,11 @@
"id": "ngrx",
"file": "angular/api-angular/generators/ngrx"
},
{
"name": "setup-mfe generator",
"id": "setup-mfe",
"file": "angular/api-angular/generators/setup-mfe"
},
{
"name": "stories generator",
"id": "stories",
@ -1558,6 +1563,11 @@
"id": "ngrx",
"file": "react/api-angular/generators/ngrx"
},
{
"name": "setup-mfe generator",
"id": "setup-mfe",
"file": "react/api-angular/generators/setup-mfe"
},
{
"name": "stories generator",
"id": "stories",
@ -2658,6 +2668,11 @@
"id": "ngrx",
"file": "node/api-angular/generators/ngrx"
},
{
"name": "setup-mfe generator",
"id": "setup-mfe",
"file": "node/api-angular/generators/setup-mfe"
},
{
"name": "stories generator",
"id": "stories",

View File

@ -0,0 +1,59 @@
# setup-mfe
Generate a Module Federation configuration for a given Angular application.
## Usage
```bash
nx generate setup-mfe ...
```
By default, Nx will search for `setup-mfe` in the default collection provisioned in `workspace.json`.
You can specify the collection explicitly as follows:
```bash
nx g @nrwl/angular:setup-mfe ...
```
Show what will be generated without writing to disk:
```bash
nx g setup-mfe ... --dry-run
```
## Options
### appName
Type: `string`
The name of the application to generate the Module Federation configuration for.
### mfeType
Default: `shell`
Type: `string`
Possible values: `shell`, `remote`
Type of application to generate the Module Federation configuration for.
### port
Type: `number`
The port at which the remote application should be served.
### remotes
Type: `array`
A list of remote application names that the shell application should consume.
### skipFormat
Type: `boolean`
Skip formatting the workspace after the generator completes.

View File

@ -1031,6 +1031,8 @@ Reads a project configuration.
The project configuration is stored in workspace.json and nx.json. The utility will read
both files.
**`throws`** If supplied projectName cannot be found
#### Parameters
| Name | Type | Description |

View File

@ -0,0 +1,59 @@
# setup-mfe
Generate a Module Federation configuration for a given Angular application.
## Usage
```bash
nx generate setup-mfe ...
```
By default, Nx will search for `setup-mfe` in the default collection provisioned in `workspace.json`.
You can specify the collection explicitly as follows:
```bash
nx g @nrwl/angular:setup-mfe ...
```
Show what will be generated without writing to disk:
```bash
nx g setup-mfe ... --dry-run
```
## Options
### appName
Type: `string`
The name of the application to generate the Module Federation configuration for.
### mfeType
Default: `shell`
Type: `string`
Possible values: `shell`, `remote`
Type of application to generate the Module Federation configuration for.
### port
Type: `number`
The port at which the remote application should be served.
### remotes
Type: `array`
A list of remote application names that the shell application should consume.
### skipFormat
Type: `boolean`
Skip formatting the workspace after the generator completes.

View File

@ -1031,6 +1031,8 @@ Reads a project configuration.
The project configuration is stored in workspace.json and nx.json. The utility will read
both files.
**`throws`** If supplied projectName cannot be found
#### Parameters
| Name | Type | Description |

View File

@ -122,6 +122,7 @@ myorg/
- [library](/{{framework}}/angular/library) - Creates an Angular library.
- [move](/{{framework}}/angular/move) - Moves an Angular application or library to another folder within the workspace and updates the project configuration.
- [ngrx](/{{framework}}/angular/ngrx) - Adds NgRx support to an application or library.
- [setup-mfe](/{{framework}}/angular/setup-mfe) - Generate a Module Federation configuration for a given Angular application.
- [stories](/{{framework}}/angular/stories) - Creates stories/specs for all components declared in a project.
- [storybook-configuration](/{{framework}}/angular/storybook-configuration) - Adds Storybook configuration to a project.
- [storybook-migrate-defaults-5-to-6](/{{framework}}/angular/storybook-migrate-defaults-5-to-6) - Generates default Storybook configuration files using Storybook version >=6.x specs, for projects that already have Storybook instances and configurations of versions <6.x.

View File

@ -84,7 +84,6 @@
"@storybook/angular": "~6.3.0",
"@storybook/core": "~6.3.0",
"@storybook/react": "~6.3.0",
"@svgr/webpack": "^5.4.0",
"@svgr/webpack": "5.5.0",
"@tailwindcss/typography": "^0.4.0",
"@testing-library/react": "11.2.5",
@ -279,4 +278,4 @@
"ng-packagr/rxjs": "6.6.7",
"**/xmlhttprequest-ssl": "~1.6.2"
}
}
}

View File

@ -96,6 +96,13 @@
"schema": "./src/generators/upgrade-module/schema.json",
"description": "Sets up an Upgrade Module."
},
"setup-mfe": {
"factory": "./src/generators/setup-mfe/compat",
"schema": "./src/generators/setup-mfe/schema.json",
"description": "Generate a Module Federation configuration for a given Angular application."
},
"web-worker": {
"factory": "./src/generators/web-worker/compat",
"schema": "./src/generators/web-worker/schema.json",
@ -115,6 +122,11 @@
"aliases": ["app"],
"description": "Creates an Angular application."
},
"setup-mfe": {
"factory": "./src/generators/setup-mfe/setup-mfe",
"schema": "./src/generators/setup-mfe/schema.json",
"description": "Generate a Module Federation configuration for a given Angular application."
},
"component-cypress-spec": {
"factory": "./src/generators/component-cypress-spec/component-cypress-spec",
"schema": "./src/generators/component-cypress-spec/schema.json",

View File

@ -11,3 +11,4 @@ export * from './src/generators/storybook-configuration/storybook-configuration'
export * from './src/generators/storybook-migrate-defaults-5-to-6/storybook-migrate-defaults-5-to-6';
export * from './src/generators/storybook-migrate-stories-to-6-2/migrate-stories-to-6-2';
export * from './src/generators/upgrade-module/upgrade-module';
export * from './src/generators/setup-mfe/setup-mfe';

View File

@ -0,0 +1,89 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Init MFE should create webpack configs correctly 1`] = `
"const ModuleFederationPlugin = require(\\"webpack/lib/container/ModuleFederationPlugin\\");
const mf = require(\\"@angular-architects/module-federation/webpack\\");
const path = require(\\"path\\");
const sharedMappings = new mf.SharedMappings();
sharedMappings.register(path.join(__dirname, \\"../../tsconfig.base.json\\"), [
/* mapped paths to share */
]);
module.exports = {
output: {
uniqueName: \\"app1\\",
publicPath: \\"auto\\",
},
optimization: {
runtimeChunk: false,
minimize: false,
},
resolve: {
alias: {
...sharedMappings.getAliases(),
},
},
plugins: [
new ModuleFederationPlugin({
remotes: {
},
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 },
...sharedMappings.getDescriptors(),
},
}),
sharedMappings.getPlugin(),
],
};
"
`;
exports[`Init MFE should create webpack configs correctly 2`] = `
"const ModuleFederationPlugin = require(\\"webpack/lib/container/ModuleFederationPlugin\\");
const mf = require(\\"@angular-architects/module-federation/webpack\\");
const path = require(\\"path\\");
const sharedMappings = new mf.SharedMappings();
sharedMappings.register(path.join(__dirname, \\"../../tsconfig.base.json\\"), [
/* mapped paths to share */
]);
module.exports = {
output: {
uniqueName: \\"remote1\\",
publicPath: \\"auto\\",
},
optimization: {
runtimeChunk: false,
minimize: false,
},
resolve: {
alias: {
...sharedMappings.getAliases(),
},
},
plugins: [
new ModuleFederationPlugin({
name: \\"remote1\\",
filename: \\"remoteEntry.js\\",
exposes: {
'./Component': 'apps/remote1/src/app/app.component.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 },
...sharedMappings.getDescriptors(),
},
}),
sharedMappings.getPlugin(),
],
};
"
`;

View File

@ -0,0 +1,41 @@
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
const mf = require("@angular-architects/module-federation/webpack");
const path = require("path");
const sharedMappings = new mf.SharedMappings();
sharedMappings.register(path.join(__dirname, "../../tsconfig.base.json"), [
/* mapped paths to share */
]);
module.exports = {
output: {
uniqueName: "<%= name %>",
publicPath: "auto",
},
optimization: {
runtimeChunk: false,
minimize: false,
},
resolve: {
alias: {
...sharedMappings.getAliases(),
},
},
plugins: [
new ModuleFederationPlugin({<% if(type === 'remote') { %>
name: "<%= name %>",
filename: "remoteEntry.js",
exposes: {
'./Component': '<%= sourceRoot %>/src/app/app.component.ts',
},<% } %><% if(type === 'shell') { %>
remotes: {
<% remotes.forEach(function(remote) { %>"<%= remote.remoteName %>": "<%= remote.remoteName %>@http://localhost:<%= remote.port %>/remoteEntry.js",<% }); %>
},<% } %>
shared: {<% sharedLibraries.forEach(function (lib) { %>
"<%= lib %>": { singleton: true, strictVersion: true },<% }); %>
...sharedMappings.getDescriptors(),
},
}),
sharedMappings.getPlugin(),
],
};

View File

@ -0,0 +1 @@
module.exports = require('./webpack.config');

View File

@ -0,0 +1,23 @@
import type { Tree } from '@nrwl/devkit';
import type { Schema } from '../schema';
import {
readProjectConfiguration,
updateProjectConfiguration,
} from '@nrwl/devkit';
export function addImplicitDeps(host: Tree, options: Schema) {
if (
options.mfeType === 'shell' &&
Array.isArray(options.remotes) &&
options.remotes.length > 0
) {
const appConfig = readProjectConfiguration(host, options.appName);
appConfig.implicitDependencies = Array.isArray(
appConfig.implicitDependencies
)
? [...appConfig.implicitDependencies, ...options.remotes]
: [...options.remotes];
updateProjectConfiguration(host, options.appName, appConfig);
}
}

View File

@ -0,0 +1,28 @@
import type { Tree } from '@nrwl/devkit';
import type { Schema } from '../schema';
import {
readProjectConfiguration,
updateProjectConfiguration,
} from '@nrwl/devkit';
export function changeBuildTarget(host: Tree, options: Schema) {
const appConfig = readProjectConfiguration(host, options.appName);
appConfig.targets.build.executor = '@nrwl/angular:webpack-browser';
appConfig.targets.build.options = {
...appConfig.targets.build.options,
customWebpackConfig: {
path: `${appConfig.root}/webpack.config.js`,
},
};
appConfig.targets.build.configurations.production = {
...appConfig.targets.build.configurations.production,
customWebpackConfig: {
path: `${appConfig.root}/webpack.prod.config.js`,
},
};
updateProjectConfiguration(host, options.appName, appConfig);
}

View File

@ -0,0 +1,15 @@
import type { Tree } from '@nrwl/devkit';
import type { Schema } from '../schema';
import { joinPathFragments } from '@nrwl/devkit';
export function fixBootstrap(host: Tree, appRoot: string) {
const mainFilePath = joinPathFragments(appRoot, 'src/main.ts');
const bootstrapCode = host.read(mainFilePath, 'utf-8');
host.write(joinPathFragments(appRoot, 'src/bootstrap.ts'), bootstrapCode);
host.write(
mainFilePath,
`import('./bootstrap').catch(err => console.error(err));`
);
}

View File

@ -0,0 +1,35 @@
import type { Tree } from '@nrwl/devkit';
import type { Schema } from '../schema';
import { generateFiles, joinPathFragments, logger } from '@nrwl/devkit';
const SHARED_SINGLETON_LIBRARIES = [
'@angular/core',
'@angular/common',
'@angular/common/http',
'@angular/router',
];
export function generateWebpackConfig(
host: Tree,
options: Schema,
appRoot: string,
remotesWithPorts: { remoteName: string; port: number }[]
) {
if (
host.exists(`${appRoot}/webpack.config.js`) ||
host.exists(`${appRoot}/webpack.prod.config.js`)
) {
logger.warn(
`NOTE: We encountered an existing webpack config for the app ${options.appName}. We have overwritten this file with the Module Federation Config.\n
If this was not the outcome you expected, you can discard the changes we have made, create a backup of your current webpack config, and run the command again.`
);
}
generateFiles(host, joinPathFragments(__dirname, '../files'), appRoot, {
tmpl: '',
type: options.mfeType,
name: options.appName,
remotes: remotesWithPorts ?? [],
sourceRoot: appRoot,
sharedLibraries: SHARED_SINGLETON_LIBRARIES,
});
}

View File

@ -0,0 +1,23 @@
import type { Tree } from '@nrwl/devkit';
import type { Schema } from '../schema';
import { readProjectConfiguration } from '@nrwl/devkit';
export function getRemotesWithPorts(host: Tree, options: Schema) {
// If type is shell and remotes supplied, check remotes exist
const remotesWithPort: { remoteName: string; port: number }[] = [];
if (
options.mfeType === 'shell' &&
Array.isArray(options.remotes) &&
options.remotes.length > 0
) {
for (const remote of options.remotes) {
const remoteConfig = readProjectConfiguration(host, remote);
remotesWithPort.push({
remoteName: remote,
port: remoteConfig.targets['mfe-serve']?.options.port ?? 4200,
});
}
}
return remotesWithPort;
}

View File

@ -0,0 +1,6 @@
export * from './add-implicit-deps';
export * from './change-build-target';
export * from './fix-bootstrap';
export * from './generate-config';
export * from './get-remotes-with-ports';
export * from './setup-serve-target';

View File

@ -0,0 +1,25 @@
import type { Tree } from '@nrwl/devkit';
import type { Schema } from '../schema';
import {
readProjectConfiguration,
updateProjectConfiguration,
} from '@nrwl/devkit';
export function setupServeTarget(host: Tree, options: Schema) {
if (options.mfeType === 'remote') {
const appConfig = readProjectConfiguration(host, options.appName);
const port = options.port ?? 4200;
appConfig.targets['mfe-serve'] = {
executor: '@nrwl/workspace:run-commands',
options: {
command: `nx serve ${options.appName}`,
port,
},
};
updateProjectConfiguration(host, options.appName, appConfig);
}
}

View File

@ -0,0 +1,7 @@
export interface Schema {
appName: string;
mfeType: 'shell' | 'remote';
port?: number;
remotes?: string[];
skipFormat?: boolean;
}

View File

@ -0,0 +1,39 @@
{
"$schema": "http://json-schema.org/schema",
"$id": "GeneratorAngularMFESetup",
"cli": "nx",
"title": "Generate Module Federation Setup for Angular App",
"description": "Create Module Federation configuration files for given Angular Application.",
"type": "object",
"properties": {
"appName": {
"type": "string",
"description": "The name of the application to generate the Module Federation configuration for.",
"$default": {
"$source": "argv",
"index": 0
},
"x-prompt": "What app would you like to generate a Module Federation configuration for?"
},
"mfeType": {
"type": "string",
"enum": ["shell", "remote"],
"description": "Type of application to generate the Module Federation configuration for.",
"default": "shell"
},
"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 shell application should consume."
},
"skipFormat": {
"type": "boolean",
"description": "Skip formatting the workspace after the generator completes."
}
},
"required": ["appName", "mfeType"],
"additionalProperties": false
}

View File

@ -0,0 +1,4 @@
import { convertNxGenerator } from '@nrwl/devkit';
import { setupMfe } from './setup-mfe';
export default convertNxGenerator(setupMfe);

View File

@ -0,0 +1,167 @@
import type { NxJsonConfiguration, Tree } from '@nrwl/devkit';
import { readJson, readProjectConfiguration } from '@nrwl/devkit';
import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing';
import { setupMfe } from './setup-mfe';
import applicationGenerator from '../application/application';
describe('Init MFE', () => {
let host: Tree;
beforeEach(async () => {
host = createTreeWithEmptyWorkspace();
await applicationGenerator(host, {
name: 'app1',
});
await applicationGenerator(host, {
name: 'remote1',
});
});
test.each([
['app1', 'shell'],
['remote1', 'remote'],
])(
'should create webpack configs correctly',
async (app, type: 'shell' | 'remote') => {
// ACT
await setupMfe(host, {
appName: app,
mfeType: type,
});
// ASSERT
expect(host.exists(`apps/${app}/webpack.config.js`)).toBeTruthy();
expect(host.exists(`apps/${app}/webpack.prod.config.js`)).toBeTruthy();
const webpackContetnts = host.read(
`apps/${app}/webpack.config.js`,
'utf-8'
);
expect(webpackContetnts).toMatchSnapshot();
}
);
test.each([
['app1', 'shell'],
['remote1', 'remote'],
])(
'create bootstrap file with the contents of main.ts',
async (app, type: 'shell' | 'remote') => {
// ARRANGE
const mainContents = host.read(`apps/${app}/src/main.ts`, 'utf-8');
// ACT
await setupMfe(host, {
appName: app,
mfeType: type,
});
// ASSERT
const bootstrapContents = host.read(
`apps/${app}/src/bootstrap.ts`,
'utf-8'
);
const updatedMainContents = host.read(`apps/${app}/src/main.ts`, 'utf-8');
expect(bootstrapContents).toEqual(mainContents);
expect(updatedMainContents).not.toEqual(mainContents);
}
);
test.each([
['app1', 'shell'],
['remote1', 'remote'],
])(
'should alter main.ts to import the bootstrap file dynamically',
async (app, type: 'shell' | 'remote') => {
// ARRANGE
const mainContents = host.read(`apps/${app}/src/main.ts`, 'utf-8');
// ACT
await setupMfe(host, {
appName: app,
mfeType: type,
});
// ASSERT
const updatedMainContents = host.read(`apps/${app}/src/main.ts`, 'utf-8');
expect(updatedMainContents).toEqual(
`import('./bootstrap').catch(err => console.error(err));`
);
expect(updatedMainContents).not.toEqual(mainContents);
}
);
test.each([
['app1', 'shell'],
['remote1', 'remote'],
])(
'should change the build target and set correct path to webpack config',
async (app, type: 'shell' | 'remote') => {
// ACT
await setupMfe(host, {
appName: app,
mfeType: type,
});
// ASSERT
const { build } = readProjectConfiguration(host, app).targets;
expect(build.executor).toEqual('@nrwl/angular:webpack-browser');
expect(build.options.customWebpackConfig.path).toEqual(
`apps/${app}/webpack.config.js`
);
}
);
test.each([
['app1', 'shell'],
['remote1', 'remote'],
])(
'should install @angular-architects/module-federation in the monorepo',
async (app, type: 'shell' | 'remote') => {
// ACT
await setupMfe(host, {
appName: app,
mfeType: type,
});
// ASSERT
const { dependencies } = readJson(host, 'package.json');
expect(
dependencies['@angular-architects/module-federation']
).toBeTruthy();
}
);
it('should add the remote config to the shell when --remotes flag supplied', async () => {
// ACT
await setupMfe(host, {
appName: 'app1',
mfeType: 'shell',
remotes: ['remote1'],
});
// ASSERT
const webpackContents = host.read(`apps/app1/webpack.config.js`, 'utf-8');
expect(webpackContents).toContain(
'"remote1": "remote1@http://localhost:4200/remoteEntry.js"'
);
});
it('should update the implicit dependencies of the shell when --remotes flag supplied', async () => {
// ACT
await setupMfe(host, {
appName: 'app1',
mfeType: 'shell',
remotes: ['remote1'],
});
// ASSERT
const nxJson: NxJsonConfiguration = readJson(host, 'nx.json');
expect(nxJson.projects['app1'].implicitDependencies).toContain('remote1');
});
});

View File

@ -0,0 +1,47 @@
import type { Tree } from '@nrwl/devkit';
import type { Schema } from './schema';
import {
readProjectConfiguration,
addDependenciesToPackageJson,
formatFiles,
} from '@nrwl/devkit';
import {
addImplicitDeps,
changeBuildTarget,
fixBootstrap,
generateWebpackConfig,
getRemotesWithPorts,
setupServeTarget,
} from './lib';
export async function setupMfe(host: Tree, options: Schema) {
const projectConfig = readProjectConfiguration(host, options.appName);
const remotesWithPorts = getRemotesWithPorts(host, options);
generateWebpackConfig(host, options, projectConfig.root, remotesWithPorts);
addImplicitDeps(host, options);
changeBuildTarget(host, options);
setupServeTarget(host, options);
fixBootstrap(host, projectConfig.root);
// add package to install
const installPackages = addDependenciesToPackageJson(
host,
{ '@angular-architects/module-federation': '^12.2.0' },
{}
);
// format files
if (!options.skipFormat) {
await formatFiles(host);
}
return installPackages;
}
export default setupMfe;

View File

@ -151,6 +151,7 @@ export function updateWorkspaceConfiguration(
*
* @param host - the file system tree
* @param projectName - unique name. Often directories are part of the name (e.g., mydir-mylib)
* @throws If supplied projectName cannot be found
*/
export function readProjectConfiguration(
host: Tree,