Skip to content

Commit

Permalink
feat(angular): add dynamic federation support to mfe generator (#9551)
Browse files Browse the repository at this point in the history
  • Loading branch information
Coly010 committed Mar 29, 2022
1 parent f3dde18 commit 5378128
Show file tree
Hide file tree
Showing 17 changed files with 334 additions and 103 deletions.
17 changes: 17 additions & 0 deletions docs/generated/packages/angular.json
Expand Up @@ -191,6 +191,12 @@
"description": "Type of application to generate the Module Federation configuration for.",
"default": "remote"
},
"federationType": {
"type": "string",
"enum": ["static", "dynamic"],
"description": "Use either Static or Dynamic Module Federation pattern for the application.",
"default": "static"
},
"port": {
"type": "number",
"description": "The port at which the remote application should be served."
Expand Down Expand Up @@ -1004,6 +1010,11 @@
"port": {
"type": "number",
"description": "The port on which this app should be served."
},
"dynamic": {
"type": "boolean",
"description": "Should the host app use dynamic federation?",
"default": false
}
},
"required": ["name"],
Expand Down Expand Up @@ -1454,6 +1465,12 @@
"description": "Type of application to generate the Module Federation configuration for.",
"default": "remote"
},
"federationType": {
"type": "string",
"enum": ["static", "dynamic"],
"description": "Use either Static or Dynamic Module Federation pattern for the application.",
"default": "static"
},
"port": {
"type": "number",
"description": "The port at which the remote application should be served."
Expand Down
6 changes: 5 additions & 1 deletion packages/angular/mfe/index.ts
@@ -1 +1,5 @@
export { setRemoteUrlResolver, loadRemoteModule } from './mfe';
export {
setRemoteUrlResolver,
setRemoteDefinitions,
loadRemoteModule,
} from './mfe';
1 change: 1 addition & 0 deletions packages/angular/src/generators/application/lib/add-mfe.ts
Expand Up @@ -14,5 +14,6 @@ export async function addMfe(host: Tree, options: NormalizedSchema) {
skipFormat: true,
skipPackageJson: options.skipPackageJson,
e2eProjectName: options.e2eProjectName,
federationType: options.federationType,
});
}
1 change: 1 addition & 0 deletions packages/angular/src/generators/application/schema.d.ts
Expand Up @@ -30,4 +30,5 @@ export interface Schema {
host?: string;
setParserOptionsProject?: boolean;
skipPackageJson?: boolean;
federationType?: 'static' | 'dynamic';
}
6 changes: 6 additions & 0 deletions packages/angular/src/generators/application/schema.json
Expand Up @@ -138,6 +138,12 @@
"description": "Type of application to generate the Module Federation configuration for.",
"default": "remote"
},
"federationType": {
"type": "string",
"enum": ["static", "dynamic"],
"description": "Use either Static or Dynamic Module Federation pattern for the application.",
"default": "static"
},
"port": {
"type": "number",
"description": "The port at which the remote application should be served."
Expand Down
1 change: 1 addition & 0 deletions packages/angular/src/generators/mfe-host/mfe-host.ts
Expand Up @@ -24,6 +24,7 @@ export default async function mfeHost(tree: Tree, options: Schema) {
routing: true,
remotes: options.remotes ?? [],
port: 4200,
federationType: options.dynamic ? 'dynamic' : 'static',
});

return installTask;
Expand Down
1 change: 1 addition & 0 deletions packages/angular/src/generators/mfe-host/schema.d.ts
@@ -1,4 +1,5 @@
export interface Schema {
name: string;
remotes?: string[];
dynamic?: boolean;
}
5 changes: 5 additions & 0 deletions packages/angular/src/generators/mfe-host/schema.json
Expand Up @@ -27,6 +27,11 @@
"port": {
"type": "number",
"description": "The port on which this app should be served."
},
"dynamic": {
"type": "boolean",
"description": "Should the host app use dynamic federation?",
"default": false
}
},
"required": ["name"]
Expand Down
@@ -1,5 +1,14 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Init MFE --federationType=dynamic should create a host with the correct configurations 1`] = `
"import { setRemoteDefinitions } from '@nrwl/angular/mfe';
fetch('/assets/mfe.manifest.json')
.then((res) => res.json())
.then(definitions => setRemoteDefinitions(definitions))
.then(() => import('./bootstrap').catch(err => console.error(err)))"
`;

exports[`Init MFE should add a remote application and add it to a specified host applications router config 1`] = `
"import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
Expand Down Expand Up @@ -44,6 +53,34 @@ exports[`Init MFE should add a remote application and add it to a specified host
}"
`;

exports[`Init MFE should add a remote to dynamic host correctly 1`] = `
"import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { NxWelcomeComponent } from './nx-welcome.component';
import { RouterModule } from '@angular/router';
import { loadRemoteModule } from '@nrwl/angular/mfe';
@NgModule({
declarations: [
AppComponent,
NxWelcomeComponent
],
imports: [
BrowserModule,
RouterModule.forRoot([{
path: 'remote1',
loadChildren: () => loadRemoteModule('remote1', './Module').then(m => m.RemoteEntryModule)
}], {initialNavigation: 'enabledBlocking'})
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
"
`;

exports[`Init MFE should create webpack and mfe configs correctly 1`] = `
"const { withModuleFederation } = require('@nrwl/angular/module-federation');
const config = require('./mfe.config');
Expand Down
139 changes: 101 additions & 38 deletions packages/angular/src/generators/setup-mfe/lib/add-remote-to-host.ts
@@ -1,12 +1,16 @@
import type { Tree } from '@nrwl/devkit';
import { ProjectConfiguration, Tree, updateJson } from '@nrwl/devkit';
import type { Schema } from '../schema';

import { readProjectConfiguration, joinPathFragments } from '@nrwl/devkit';
import { tsquery } from '@phenomnomnominal/tsquery';
import { ArrayLiteralExpression } from 'typescript';
import { addRoute } from '../../../utils/nx-devkit/ast-utils';
import {
addImportToModule,
addRoute,
} from '../../../utils/nx-devkit/ast-utils';

import * as ts from 'typescript';
import { insertImport } from '@nrwl/workspace/src/utilities/ast-utils';

export function checkIsCommaNeeded(mfeRemoteText: string) {
const remoteText = mfeRemoteText.replace(/\s+/g, '');
Expand All @@ -17,61 +21,106 @@ export function checkIsCommaNeeded(mfeRemoteText: string) {
: false;
}

export function addRemoteToHost(host: Tree, options: Schema) {
export function addRemoteToHost(tree: Tree, options: Schema) {
if (options.mfeType === 'remote' && options.host) {
const hostProject = readProjectConfiguration(host, options.host);
const hostMfeConfigPath = joinPathFragments(
hostProject.root,
'mfe.config.js'
const hostProject = readProjectConfiguration(tree, options.host);
const pathToMfeManifest = joinPathFragments(
hostProject.sourceRoot,
'assets/mfe.manifest.json'
);
const hostFederationType = determineHostFederationType(
tree,
pathToMfeManifest
);

if (!hostMfeConfigPath || !host.exists(hostMfeConfigPath)) {
throw new Error(
`The selected host application, ${options.host}, does not contain a mfe.config.js. Are you sure it has been set up as a host application?`
);
if (hostFederationType === 'static') {
addRemoteToStaticHost(tree, options, hostProject);
} else if (hostFederationType === 'dynamic') {
addRemoteToDynamicHost(tree, options, pathToMfeManifest);
}

const hostMFEConfig = host.read(hostMfeConfigPath, 'utf-8');
const webpackAst = tsquery.ast(hostMFEConfig);
const mfRemotesNode = tsquery(
webpackAst,
'Identifier[name=remotes] ~ ArrayLiteralExpression',
{ visitAllChildren: true }
)[0] as ArrayLiteralExpression;

const endOfPropertiesPos = mfRemotesNode.getEnd() - 1;
const isCommaNeeded = checkIsCommaNeeded(mfRemotesNode.getText());

const updatedConfig = `${hostMFEConfig.slice(0, endOfPropertiesPos)}${
isCommaNeeded ? ',' : ''
}'${options.appName}',${hostMFEConfig.slice(endOfPropertiesPos)}`;

host.write(hostMfeConfigPath, updatedConfig);

const declarationFilePath = joinPathFragments(
hostProject.sourceRoot,
'decl.d.ts'
);

const declarationFileContent =
(host.exists(declarationFilePath)
? host.read(declarationFilePath, 'utf-8')
(tree.exists(declarationFilePath)
? tree.read(declarationFilePath, 'utf-8')
: '') + `\ndeclare module '${options.appName}/Module';`;
host.write(declarationFilePath, declarationFileContent);
tree.write(declarationFilePath, declarationFileContent);

addLazyLoadedRouteToHostAppModule(host, options);
addLazyLoadedRouteToHostAppModule(tree, options, hostFederationType);
}
}

function determineHostFederationType(
tree: Tree,
pathToMfeManifest: string
): 'dynamic' | 'static' {
return tree.exists(pathToMfeManifest) ? 'dynamic' : 'static';
}

function addRemoteToStaticHost(
tree: Tree,
options: Schema,
hostProject: ProjectConfiguration
) {
const hostMfeConfigPath = joinPathFragments(
hostProject.root,
'mfe.config.js'
);

if (!hostMfeConfigPath || !tree.exists(hostMfeConfigPath)) {
throw new Error(
`The selected host application, ${options.host}, does not contain a mfe.config.js or mfe.manifest.json file. Are you sure it has been set up as a host application?`
);
}

const hostMFEConfig = tree.read(hostMfeConfigPath, 'utf-8');
const webpackAst = tsquery.ast(hostMFEConfig);
const mfRemotesNode = tsquery(
webpackAst,
'Identifier[name=remotes] ~ ArrayLiteralExpression',
{ visitAllChildren: true }
)[0] as ArrayLiteralExpression;

const endOfPropertiesPos = mfRemotesNode.getEnd() - 1;
const isCommaNeeded = checkIsCommaNeeded(mfRemotesNode.getText());

const updatedConfig = `${hostMFEConfig.slice(0, endOfPropertiesPos)}${
isCommaNeeded ? ',' : ''
}'${options.appName}',${hostMFEConfig.slice(endOfPropertiesPos)}`;

tree.write(hostMfeConfigPath, updatedConfig);
}

function addRemoteToDynamicHost(
tree: Tree,
options: Schema,
pathToMfeManifest: string
) {
updateJson(tree, pathToMfeManifest, (manifest) => {
return {
...manifest,
[options.appName]: `http://localhost:${options.port}`,
};
});
}

// TODO(colum): future work: allow dev to pass to path to routing module
function addLazyLoadedRouteToHostAppModule(host: Tree, options: Schema) {
const hostAppConfig = readProjectConfiguration(host, options.host);
function addLazyLoadedRouteToHostAppModule(
tree: Tree,
options: Schema,
hostFederationType: 'dynamic' | 'static'
) {
const hostAppConfig = readProjectConfiguration(tree, options.host);
const pathToHostAppModule = `${hostAppConfig.sourceRoot}/app/app.module.ts`;
if (!host.exists(pathToHostAppModule)) {
if (!tree.exists(pathToHostAppModule)) {
return;
}

const hostAppModule = host.read(pathToHostAppModule, 'utf-8');
const hostAppModule = tree.read(pathToHostAppModule, 'utf-8');
if (!hostAppModule.includes('RouterModule.forRoot(')) {
return;
}
Expand All @@ -83,13 +132,27 @@ function addLazyLoadedRouteToHostAppModule(host: Tree, options: Schema) {
true
);

if (hostFederationType === 'dynamic') {
sourceFile = insertImport(
tree,
sourceFile,
pathToHostAppModule,
'loadRemoteModule',
'@nrwl/angular/mfe'
);
}
const routeToAdd =
hostFederationType === 'dynamic'
? `loadRemoteModule('${options.appName}', './Module')`
: `import('${options.appName}/Module')`;

sourceFile = addRoute(
host,
tree,
pathToHostAppModule,
sourceFile,
`{
path: '${options.appName}',
loadChildren: () => import('${options.appName}/Module').then(m => m.RemoteEntryModule)
loadChildren: () => ${routeToAdd}.then(m => m.RemoteEntryModule)
}`
);
}
22 changes: 17 additions & 5 deletions packages/angular/src/generators/setup-mfe/lib/fix-bootstrap.ts
@@ -1,14 +1,26 @@
import type { Tree } from '@nrwl/devkit';
import type { Schema } from '../schema';

import { joinPathFragments } from '@nrwl/devkit';

export function fixBootstrap(host: Tree, appRoot: string) {
export function fixBootstrap(tree: Tree, appRoot: string, options: Schema) {
const mainFilePath = joinPathFragments(appRoot, 'src/main.ts');
const bootstrapCode = host.read(mainFilePath, 'utf-8');
host.write(joinPathFragments(appRoot, 'src/bootstrap.ts'), bootstrapCode);
const bootstrapCode = tree.read(mainFilePath, 'utf-8');
tree.write(joinPathFragments(appRoot, 'src/bootstrap.ts'), bootstrapCode);

host.write(
const bootstrapImportCode = `import('./bootstrap').catch(err => console.error(err))`;

const fetchMfeManifestCode = `import { setRemoteDefinitions } from '@nrwl/angular/mfe';
fetch('/assets/mfe.manifest.json')
.then((res) => res.json())
.then(definitions => setRemoteDefinitions(definitions))
.then(() => ${bootstrapImportCode})`;

tree.write(
mainFilePath,
`import('./bootstrap').catch(err => console.error(err));`
options.mfeType === 'host' && options.federationType === 'dynamic'
? fetchMfeManifestCode
: bootstrapImportCode
);
}
1 change: 1 addition & 0 deletions packages/angular/src/generators/setup-mfe/lib/index.ts
Expand Up @@ -7,4 +7,5 @@ export * from './fix-bootstrap';
export * from './generate-config';
export * from './get-remotes-with-ports';
export * from './set-tsconfig-target';
export * from './setup-host-if-dynamic';
export * from './setup-serve-target';
@@ -0,0 +1,19 @@
import type { Tree } from '@nrwl/devkit';
import type { Schema } from '../schema';

import { readProjectConfiguration, joinPathFragments } from '@nrwl/devkit';

export function setupHostIfDynamic(tree: Tree, options: Schema) {
if (options.federationType === 'static' || options.mfeType === 'remote') {
return;
}

const pathToMfeManifest = joinPathFragments(
readProjectConfiguration(tree, options.appName).sourceRoot,
'assets/mfe.manifest.json'
);

if (!tree.exists(pathToMfeManifest)) {
tree.write(pathToMfeManifest, '{}');
}
}

1 comment on commit 5378128

@vercel
Copy link

@vercel vercel bot commented on 5378128 Mar 29, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.