Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(react): add support for MFEs #9413

Merged
merged 2 commits into from Apr 4, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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,
};
}