Skip to content

Commit

Permalink
feat(react): add support for MFEs (#9413)
Browse files Browse the repository at this point in the history
* feat(react): Add MFE  Generator for Host and Remote Apps
Host generator will be able use the mfe-remote generator when remotes are passed into the CLI for the mfe-host command

* docs(react): React Documentation Update

Co-authored-by: Nicholas Cunningham <nico@Nicholass-MacBook-Pro.local>
Co-authored-by: Nicholas Cunningham <ndcunningham>
  • Loading branch information
ndcunningham and Nicholas Cunningham committed Apr 4, 2022
1 parent af37fe9 commit 12f0f19
Show file tree
Hide file tree
Showing 39 changed files with 1,749 additions and 17 deletions.
408 changes: 407 additions & 1 deletion docs/generated/packages/react.json

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions docs/packages.json
Expand Up @@ -192,7 +192,7 @@
"name": "react",
"path": "generated/packages/react.json",
"schemas": {
"executors": [],
"executors": ["mfe-dev-server"],
"generators": [
"init",
"application",
Expand All @@ -204,7 +204,9 @@
"component-story",
"stories",
"component-cypress-spec",
"hook"
"hook",
"mfe-host",
"mfe-remote"
]
}
},
Expand Down
95 changes: 95 additions & 0 deletions e2e/react/src/react.mfe.test.ts
@@ -0,0 +1,95 @@
import { stripIndents } from '@nrwl/devkit';
import {
checkFilesExist,
killPorts,
newProject,
readProjectConfig,
runCLI,
runCLIAsync,
uniq,
updateFile,
} from '@nrwl/e2e/utils';

describe('React MFE', () => {
let proj: string;

beforeEach(() => (proj = newProject()));

it('should generate host and remote apps', async () => {
const shell = uniq('shell');
const remote1 = uniq('remote1');
const remote2 = uniq('remote2');
const remote3 = uniq('remote3');

runCLI(
`generate @nrwl/react:mfe-host ${shell} --style=css --remotes=${remote1},${remote2},${remote3} --no-interactive`
);

checkFilesExist(`apps/${shell}/mfe.config.js`);
checkFilesExist(`apps/${remote1}/mfe.config.js`);
checkFilesExist(`apps/${remote2}/mfe.config.js`);

await expect(runCLIAsync(`test ${shell}`)).resolves.toMatchObject({
combinedOutput: expect.stringContaining('Test Suites: 1 passed, 1 total'),
});

updateFile(
`apps/${shell}/webpack.config.js`,
stripIndents`
const withModuleFederation = require('@nrwl/react/module-federation');
const mfeConfig = require('./mfe.config');
module.exports = withModuleFederation({
...mfeConfig,
remotes: [
['${remote1}', '${remote1}@http://localhost:${readPort(
remote1
)}/remoteEntry.js'],
['${remote2}', 'http://localhost:${readPort(
remote2
)}/remoteEntry.js'],
['${remote3}', 'http://localhost:${readPort(remote3)}'],
],
});
`
);

updateFile(
`apps/${shell}-e2e/src/integration/app.spec.ts`,
stripIndents`
import { getGreeting } from '../support/app.po';
describe('shell app', () => {
it('should display welcome message', () => {
cy.visit('/')
getGreeting().contains('Welcome ${shell}');
});
it('should load remote 1', () => {
cy.visit('/${remote1}')
getGreeting().contains('Welcome ${remote1}');
});
it('should load remote 2', () => {
cy.visit('/${remote2}')
getGreeting().contains('Welcome ${remote2}');
});
it('should load remote 3', () => {
cy.visit('/${remote3}')
getGreeting().contains('Welcome ${remote3}');
});
});
`
);

const e2eResults = runCLI(`e2e ${shell}-e2e --no-watch`);
expect(e2eResults).toContain('All specs passed!');
expect(await killPorts()).toBeTruthy();
}, 500_000);

function readPort(appName: string): number {
const config = readProjectConfig(appName);
return config.targets.serve.options.port;
}
});
16 changes: 16 additions & 0 deletions packages/react/executors.json
@@ -0,0 +1,16 @@
{
"builders": {
"mfe-dev-server": {
"implementation": "./src/executors/mfe-dev-server/compat",
"schema": "./src/executors/mfe-dev-server/schema.json",
"description": "Serve an MFE host or remote application."
}
},
"executors": {
"mfe-dev-server": {
"implementation": "./src/executors/mfe-dev-server/mfe-dev-server.impl",
"schema": "./src/executors/mfe-dev-server/schema.json",
"description": "Serve an MFE host or remote application."
}
}
}
14 changes: 14 additions & 0 deletions packages/react/generators.json
Expand Up @@ -162,6 +162,20 @@
"schema": "./src/generators/hook/schema.json",
"description": "Create a hook.",
"aliases": "c"
},

"mfe-host": {
"factory": "./src/generators/mfe-host/mfe-host#mfeHostGenerator",
"schema": "./src/generators/mfe-host/schema.json",
"description": "Generate a host react application",
"aliases": "host"
},

"mfe-remote": {
"factory": "./src/generators/mfe-remote/mfe-remote#mfeRemoteGenerator",
"schema": "./src/generators/mfe-remote/schema.json",
"description": "Generate a remote react application",
"aliases": "remote"
}
}
}
1 change: 1 addition & 0 deletions packages/react/index.ts
Expand Up @@ -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';
7 changes: 7 additions & 0 deletions packages/react/module-federation.ts
@@ -0,0 +1,7 @@
import { withModuleFederation } from './src/mfe/with-module-federation';

export * from './src/mfe/webpack-utils';
export * from './src/mfe/with-module-federation';
export { withModuleFederation as default };

module.exports = withModuleFederation;
1 change: 1 addition & 0 deletions packages/react/package.json
Expand Up @@ -23,6 +23,7 @@
"url": "https://github.com/nrwl/nx/issues"
},
"homepage": "https://nx.dev",
"builders": "./executors.json",
"schematics": "./generators.json",
"ng-update": {
"requirements": {},
Expand Down
2 changes: 1 addition & 1 deletion packages/react/plugins/webpack.ts
Expand Up @@ -2,7 +2,7 @@ import type { Configuration } from 'webpack';
import ReactRefreshPlugin = require('@pmmmwh/react-refresh-webpack-plugin');

// Add React-specific configuration
function getWebpackConfig(config: Configuration) {
export function getWebpackConfig(config: Configuration) {
config.module.rules.push({
test: /\.svg$/,
oneOf: [
Expand Down
5 changes: 5 additions & 0 deletions packages/react/src/executors/mfe-dev-server/compat.ts
@@ -0,0 +1,5 @@
import { convertNxExecutor } from '@nrwl/devkit';

import mfeDevServer from './mfe-dev-server.impl';

export default convertNxExecutor(mfeDevServer);
111 changes: 111 additions & 0 deletions packages/react/src/executors/mfe-dev-server/mfe-dev-server.impl.ts
@@ -0,0 +1,111 @@
import { ExecutorContext, runExecutor } from '@nrwl/devkit';
import devServerExecutor, {
WebDevServerOptions,
} from '@nrwl/web/src/executors/dev-server/dev-server.impl';
import { join } from 'path';

type MFEDevServerOptions = WebDevServerOptions & {
apps?: string[];
};

export default async function* mfeDevServer(
options: MFEDevServerOptions,
context: ExecutorContext
) {
let iter = devServerExecutor(options, context);
const p = context.workspace.projects[context.projectName];

const mfeConfigPath = join(context.root, p.root, 'mfe.config.js');

let mfeConfig: any;
try {
mfeConfig = require(mfeConfigPath);
} catch {
// TODO(jack): Add a link to guide
throw new Error(
`Could not load ${mfeConfigPath}. Was this project generated with "@nrwl/react:mfe-host"?`
);
}

// Remotes can be specified with a custom location
// e.g.
// ```
// remotes: ['app1', 'http://example.com']
// ```
// This shouldn't happen for local dev, but we support it regardless.
let apps = options.apps ?? mfeConfig.remotes ?? [];
apps = apps.map((a) => (Array.isArray(a) ? a[0] : a));

for (const app of apps) {
iter = combineAsyncIterators(
iter,
await runExecutor(
{
project: app,
target: 'serve',
configuration: context.configurationName,
},
{},
context
)
);
}

return yield* iter;
}

// TODO(jack): Extract this helper
function getNextAsyncIteratorFactory(options) {
return async (asyncIterator, index) => {
try {
const iterator = await asyncIterator.next();

return { index, iterator };
} catch (err) {
if (options.errorCallback) {
options.errorCallback(err, index);
}
if (options.throwError !== false) {
return Promise.reject(err);
}

return { index, iterator: { done: true } };
}
};
}

async function* combineAsyncIterators(
...iterators: { 0: AsyncIterator<any> } & AsyncIterator<any>[]
) {
let [options] = iterators;
if (typeof options.next === 'function') {
options = Object.create(null);
} else {
iterators.shift();
}

const getNextAsyncIteratorValue = getNextAsyncIteratorFactory(options);

try {
const asyncIteratorsValues = new Map(
iterators.map((it, idx) => [idx, getNextAsyncIteratorValue(it, idx)])
);

do {
const { iterator, index } = await Promise.race(
asyncIteratorsValues.values()
);
if (iterator.done) {
asyncIteratorsValues.delete(index);
} else {
yield iterator.value;
asyncIteratorsValues.set(
index,
getNextAsyncIteratorValue(iterators[index], index)
);
}
} while (asyncIteratorsValues.size > 0);
} finally {
await Promise.allSettled(iterators.map((it) => it.return()));
}
}
82 changes: 82 additions & 0 deletions packages/react/src/executors/mfe-dev-server/schema.json
@@ -0,0 +1,82 @@
{
"title": "Web Dev Server",
"description": "Serve a web application.",
"cli": "nx",
"type": "object",
"properties": {
"buildTarget": {
"type": "string",
"description": "Target which builds the application."
},
"port": {
"type": "number",
"description": "Port to listen on.",
"default": 4200
},
"host": {
"type": "string",
"description": "Host to listen on.",
"default": "localhost"
},
"ssl": {
"type": "boolean",
"description": "Serve using `HTTPS`.",
"default": false
},
"sslKey": {
"type": "string",
"description": "SSL key to use for serving `HTTPS`."
},
"sslCert": {
"type": "string",
"description": "SSL certificate to use for serving `HTTPS`."
},
"watch": {
"type": "boolean",
"description": "Watches for changes and rebuilds application.",
"default": true
},
"liveReload": {
"type": "boolean",
"description": "Whether to reload the page on change, using live-reload.",
"default": true
},
"hmr": {
"type": "boolean",
"description": "Enable hot module replacement.",
"default": false
},
"publicHost": {
"type": "string",
"description": "Public URL where the application will be served."
},
"open": {
"type": "boolean",
"description": "Open the application in the browser.",
"default": false
},
"allowedHosts": {
"type": "string",
"description": "This option allows you to whitelist services that are allowed to access the dev server."
},
"memoryLimit": {
"type": "number",
"description": "Memory limit for type checking service process in `MB`."
},
"maxWorkers": {
"type": "number",
"description": "Number of workers to use for type checking."
},
"baseHref": {
"type": "string",
"description": "Base url for the application being built."
},
"apps": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of remote applications to serve in addition to the host application."
}
}
}
Expand Up @@ -45,5 +45,6 @@ export function normalizeOptions(
fileName,
styledModule,
hasStyles: options.style !== 'none',
devServerPort: options.devServerPort ?? 4200,
};
}

0 comments on commit 12f0f19

Please sign in to comment.