feat(react): use TS config files for module federation (#19455)

This commit is contained in:
Colum Ferry 2023-10-12 16:42:41 +01:00 committed by GitHub
parent cbeed02ee5
commit 14643b68b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 171 additions and 65 deletions

View File

@ -164,7 +164,7 @@
"typescriptConfiguration": { "typescriptConfiguration": {
"type": "boolean", "type": "boolean",
"description": "Whether the module federation configuration and webpack configuration files should use TS.", "description": "Whether the module federation configuration and webpack configuration files should use TS.",
"default": false "default": true
} }
}, },
"required": ["name"], "required": ["name"],

View File

@ -162,7 +162,7 @@
"typescriptConfiguration": { "typescriptConfiguration": {
"type": "boolean", "type": "boolean",
"description": "Whether the module federation configuration and webpack configuration files should use TS.", "description": "Whether the module federation configuration and webpack configuration files should use TS.",
"default": false "default": true
} }
}, },
"required": ["name"], "required": ["name"],

View File

@ -45,10 +45,10 @@ describe('React Module Federation', () => {
`generate @nx/react:remote ${remote3} --style=css --host=${shell} --no-interactive` `generate @nx/react:remote ${remote3} --style=css --host=${shell} --no-interactive`
); );
checkFilesExist(`apps/${shell}/module-federation.config.js`); checkFilesExist(`apps/${shell}/module-federation.config.ts`);
checkFilesExist(`apps/${remote1}/module-federation.config.js`); checkFilesExist(`apps/${remote1}/module-federation.config.ts`);
checkFilesExist(`apps/${remote2}/module-federation.config.js`); checkFilesExist(`apps/${remote2}/module-federation.config.ts`);
checkFilesExist(`apps/${remote3}/module-federation.config.js`); checkFilesExist(`apps/${remote3}/module-federation.config.ts`);
await expect(runCLIAsync(`test ${shell}`)).resolves.toMatchObject({ await expect(runCLIAsync(`test ${shell}`)).resolves.toMatchObject({
combinedOutput: expect.stringContaining('Test Suites: 1 passed, 1 total'), combinedOutput: expect.stringContaining('Test Suites: 1 passed, 1 total'),
@ -60,15 +60,15 @@ describe('React Module Federation', () => {
expect(readPort(remote3)).toEqual(4203); expect(readPort(remote3)).toEqual(4203);
updateFile( updateFile(
`apps/${shell}/webpack.config.js`, `apps/${shell}/webpack.config.ts`,
stripIndents` stripIndents`
const { composePlugins, withNx, ModuleFederationConfig } = require('@nx/webpack'); import { composePlugins, withNx, ModuleFederationConfig } from '@nx/webpack';
const { withReact } = require('@nx/react'); import { withReact } from '@nx/react';
const { withModuleFederation } = require('@nx/react/module-federation'); import { withModuleFederation } from '@nx/react/module-federation';
const baseConfig = require('./module-federation.config'); import baseConfig from './module-federation.config';
const config = { const config: ModuleFederationConfig = {
...baseConfig, ...baseConfig,
remotes: [ remotes: [
'${remote1}', '${remote1}',
@ -114,8 +114,17 @@ describe('React Module Federation', () => {
); );
if (runE2ETests()) { if (runE2ETests()) {
const e2eResults = runCLI(`e2e ${shell}-e2e --no-watch --verbose`); const e2eResultsSwc = runCLI(`e2e ${shell}-e2e --no-watch --verbose`);
expect(e2eResults).toContain('All specs passed!'); expect(e2eResultsSwc).toContain('All specs passed!');
await killPorts(readPort(shell));
await killPorts(readPort(remote1));
await killPorts(readPort(remote2));
await killPorts(readPort(remote3));
const e2eResultsTsNode = runCLI(`e2e ${shell}-e2e --no-watch --verbose`, {
env: { NX_PREFER_TS_NODE: 'true' },
});
expect(e2eResultsTsNode).toContain('All specs passed!');
await killPorts(readPort(shell)); await killPorts(readPort(shell));
await killPorts(readPort(remote1)); await killPorts(readPort(remote1));
await killPorts(readPort(remote2)); await killPorts(readPort(remote2));
@ -137,15 +146,20 @@ describe('React Module Federation', () => {
// check files are generated without the layout directory ("apps/") and // check files are generated without the layout directory ("apps/") and
// using the project name as the directory when no directory is provided // using the project name as the directory when no directory is provided
checkFilesExist(`${shell}/module-federation.config.js`); checkFilesExist(`${shell}/module-federation.config.ts`);
checkFilesExist(`${remote}/module-federation.config.js`); checkFilesExist(`${remote}/module-federation.config.ts`);
// check default generated host is built successfully // check default generated host is built successfully
const buildOutput = runCLI(`run ${shell}:build:development`); const buildOutputSwc = runCLI(`run ${shell}:build:development`);
expect(buildOutput).toContain('Successfully ran target build'); expect(buildOutputSwc).toContain('Successfully ran target build');
const buildOutputTsNode = runCLI(`run ${shell}:build:development`, {
env: { NX_PREFER_TS_NODE: 'true' },
});
expect(buildOutputTsNode).toContain('Successfully ran target build');
// check serves devRemotes ok // check serves devRemotes ok
const shellProcess = await runCommandUntil( const shellProcessSwc = await runCommandUntil(
`serve ${shell} --devRemotes=${remote} --verbose`, `serve ${shell} --devRemotes=${remote} --verbose`,
(output) => { (output) => {
return output.includes( return output.includes(
@ -153,7 +167,20 @@ describe('React Module Federation', () => {
); );
} }
); );
await killProcessAndPorts(shellProcess.pid, shellPort); await killProcessAndPorts(shellProcessSwc.pid, shellPort);
const shellProcessTsNode = await runCommandUntil(
`serve ${shell} --devRemotes=${remote} --verbose`,
(output) => {
return output.includes(
`All remotes started, server ready at http://localhost:${shellPort}`
);
},
{
env: { NX_PREFER_TS_NODE: 'true' },
}
);
await killProcessAndPorts(shellProcessTsNode.pid, shellPort);
}, 500_000); }, 500_000);
it('should support different versions workspace libs for host and remote', async () => { it('should support different versions workspace libs for host and remote', async () => {
@ -305,31 +332,31 @@ describe('React Module Federation', () => {
// update host and remote to use library type var // update host and remote to use library type var
updateFile( updateFile(
`${shell}/module-federation.config.js`, `${shell}/module-federation.config.ts`,
stripIndents` stripIndents`
const { ModuleFederationConfig } = require('@nx/webpack'); import { ModuleFederationConfig } from '@nx/webpack';
const config = { const config: ModuleFederationConfig = {
name: '${shell}', name: '${shell}',
library: { type: 'var', name: '${shell}' }, library: { type: 'var', name: '${shell}' },
remotes: ['${remote}'], remotes: ['${remote}'],
}; };
module.exports = config; export default config;
` `
); );
updateFile( updateFile(
`${shell}/webpack.config.prod.js`, `${shell}/webpack.config.prod.ts`,
`module.exports = require('./webpack.config');` `export { default } from './webpack.config';`
); );
updateFile( updateFile(
`${remote}/module-federation.config.js`, `${remote}/module-federation.config.ts`,
stripIndents` stripIndents`
const { ModuleFederationConfig } = require('@nx/webpack'); import { ModuleFederationConfig } from '@nx/webpack';
const config = { const config: ModuleFederationConfig = {
name: '${remote}', name: '${remote}',
library: { type: 'var', name: '${remote}' }, library: { type: 'var', name: '${remote}' },
exposes: { exposes: {
@ -337,13 +364,13 @@ describe('React Module Federation', () => {
}, },
}; };
module.exports = config; export default config;
` `
); );
updateFile( updateFile(
`${remote}/webpack.config.prod.js`, `${remote}/webpack.config.prod.ts`,
`module.exports = require('./webpack.config');` `export { default } from './webpack.config';`
); );
// Update host e2e test to check that the remote works with library type var via navigation // Update host e2e test to check that the remote works with library type var via navigation
@ -379,11 +406,25 @@ describe('React Module Federation', () => {
expect(remoteOutput).toContain('Successfully ran target build'); expect(remoteOutput).toContain('Successfully ran target build');
if (runE2ETests()) { if (runE2ETests()) {
const hostE2eResults = runCLI(`e2e ${shell}-e2e --no-watch --verbose`); const hostE2eResultsSwc = runCLI(`e2e ${shell}-e2e --no-watch --verbose`);
const remoteE2eResults = runCLI(`e2e ${remote}-e2e --no-watch --verbose`); const remoteE2eResultsSwc = runCLI(
`e2e ${remote}-e2e --no-watch --verbose`
);
expect(hostE2eResults).toContain('All specs passed!'); expect(hostE2eResultsSwc).toContain('All specs passed!');
expect(remoteE2eResults).toContain('All specs passed!'); expect(remoteE2eResultsSwc).toContain('All specs passed!');
const hostE2eResultsTsNode = runCLI(
`e2e ${shell}-e2e --no-watch --verbose`,
{ env: { NX_PREFER_TS_NODE: 'true' } }
);
const remoteE2eResultsTsNode = runCLI(
`e2e ${remote}-e2e --no-watch --verbose`,
{ env: { NX_PREFER_TS_NODE: 'true' } }
);
expect(hostE2eResultsTsNode).toContain('All specs passed!');
expect(remoteE2eResultsTsNode).toContain('All specs passed!');
} }
}, 500_000); }, 500_000);

View File

@ -253,7 +253,9 @@ function buildTargetWebpack(
if (options.webpackConfig) { if (options.webpackConfig) {
customWebpack = resolveCustomWebpackConfig( customWebpack = resolveCustomWebpackConfig(
options.webpackConfig, options.webpackConfig,
options.tsConfig options.tsConfig.startsWith(context.root)
? options.tsConfig
: join(context.root, options.tsConfig)
); );
} }

View File

@ -54,9 +54,12 @@ function getModuleFederationConfig(
let moduleFederationConfigPath = moduleFederationConfigPathJS; let moduleFederationConfigPath = moduleFederationConfigPathJS;
// create a no-op so this can be called with issue // create a no-op so this can be called with issue
const fullTSconfigPath = tsconfigPath.startsWith(workspaceRoot)
? tsconfigPath
: join(workspaceRoot, tsconfigPath);
let cleanupTranspiler = () => {}; let cleanupTranspiler = () => {};
if (existsSync(moduleFederationConfigPathTS)) { if (existsSync(moduleFederationConfigPathTS)) {
cleanupTranspiler = registerTsProject(join(workspaceRoot, tsconfigPath)); cleanupTranspiler = registerTsProject(fullTSconfigPath);
moduleFederationConfigPath = moduleFederationConfigPathTS; moduleFederationConfigPath = moduleFederationConfigPathTS;
} }

View File

@ -2,6 +2,8 @@ import {
ExecutorContext, ExecutorContext,
getPackageManagerCommand, getPackageManagerCommand,
logger, logger,
parseTargetString,
readTargetOptions,
runExecutor, runExecutor,
workspaceRoot, workspaceRoot,
} from '@nx/devkit'; } from '@nx/devkit';
@ -16,6 +18,8 @@ import {
tapAsyncIterable, tapAsyncIterable,
} from '@nx/devkit/src/utils/async-iterable'; } from '@nx/devkit/src/utils/async-iterable';
import { execSync, fork } from 'child_process'; import { execSync, fork } from 'child_process';
import { existsSync } from 'fs';
import { registerTsProject } from '@nx/js/src/internal';
type ModuleFederationDevServerOptions = WebSsrDevServerOptions & { type ModuleFederationDevServerOptions = WebSsrDevServerOptions & {
devRemotes?: string | string[]; devRemotes?: string | string[];
@ -23,29 +27,70 @@ type ModuleFederationDevServerOptions = WebSsrDevServerOptions & {
host: string; host: string;
}; };
function getBuildOptions(buildTarget: string, context: ExecutorContext) {
const target = parseTargetString(buildTarget, context);
const buildOptions = readTargetOptions(target, context);
return {
...buildOptions,
};
}
function getModuleFederationConfig(
tsconfigPath: string,
workspaceRoot: string,
projectRoot: string
) {
const moduleFederationConfigPathJS = join(
workspaceRoot,
projectRoot,
'module-federation.config.js'
);
const moduleFederationConfigPathTS = join(
workspaceRoot,
projectRoot,
'module-federation.config.ts'
);
let moduleFederationConfigPath = moduleFederationConfigPathJS;
const fullTSconfigPath = tsconfigPath.startsWith(workspaceRoot)
? tsconfigPath
: join(workspaceRoot, tsconfigPath);
// create a no-op so this can be called with issue
let cleanupTranspiler = () => {};
if (existsSync(moduleFederationConfigPathTS)) {
cleanupTranspiler = registerTsProject(fullTSconfigPath);
moduleFederationConfigPath = moduleFederationConfigPathTS;
}
try {
const config = require(moduleFederationConfigPath);
cleanupTranspiler();
return config.default || config;
} catch {
throw new Error(
`Could not load ${moduleFederationConfigPath}. Was this project generated with "@nx/react:host"?\nSee: https://nx.dev/concepts/more-concepts/faster-builds-with-module-federation`
);
}
}
export default async function* moduleFederationSsrDevServer( export default async function* moduleFederationSsrDevServer(
options: ModuleFederationDevServerOptions, options: ModuleFederationDevServerOptions,
context: ExecutorContext context: ExecutorContext
) { ) {
let iter: any = ssrDevServerExecutor(options, context); let iter: any = ssrDevServerExecutor(options, context);
const p = context.projectsConfigurations.projects[context.projectName]; const p = context.projectsConfigurations.projects[context.projectName];
const buildOptions = getBuildOptions(options.browserTarget, context);
const moduleFederationConfigPath = join( const moduleFederationConfig = getModuleFederationConfig(
buildOptions.tsConfig,
context.root, context.root,
p.root, p.root
'module-federation.config.js'
); );
let moduleFederationConfig: any;
try {
moduleFederationConfig = require(moduleFederationConfigPath);
} catch {
// TODO(jack): Add a link to guide
throw new Error(
`Could not load ${moduleFederationConfigPath}. Was this project generated with "@nx/react:host"?`
);
}
const remotesToSkip = new Set(options.skipRemotes ?? []); const remotesToSkip = new Set(options.skipRemotes ?? []);
const remotesNotInWorkspace: string[] = []; const remotesNotInWorkspace: string[] = [];
const knownRemotes = (moduleFederationConfig.remotes ?? []).filter((r) => { const knownRemotes = (moduleFederationConfig.remotes ?? []).filter((r) => {

View File

@ -32,7 +32,9 @@ export function addPathToExposes(
) { ) {
const moduleFederationConfigPath = joinPathFragments( const moduleFederationConfigPath = joinPathFragments(
projectPath, projectPath,
'module-federation.config.js' tree.exists(joinPathFragments(projectPath, 'module-federation.config.ts'))
? 'module-federation.config.ts'
: 'module-federation.config.js'
); );
updateExposesProperty( updateExposesProperty(
@ -51,9 +53,11 @@ export function addPathToExposes(
export function checkRemoteExists(tree: Tree, remoteName: string) { export function checkRemoteExists(tree: Tree, remoteName: string) {
const remote = getRemote(tree, remoteName); const remote = getRemote(tree, remoteName);
if (!remote) return false; if (!remote) return false;
const hasModuleFederationConfig = tree.exists( const hasModuleFederationConfig =
joinPathFragments(remote.root, 'module-federation.config.js') tree.exists(
); joinPathFragments(remote.root, 'module-federation.config.js')
) ||
tree.exists(joinPathFragments(remote.root, 'module-federation.config.ts'));
return hasModuleFederationConfig ? remote : false; return hasModuleFederationConfig ? remote : false;
} }

View File

@ -170,7 +170,7 @@
"typescriptConfiguration": { "typescriptConfiguration": {
"type": "boolean", "type": "boolean",
"description": "Whether the module federation configuration and webpack configuration files should use TS.", "description": "Whether the module federation configuration and webpack configuration files should use TS.",
"default": false "default": true
} }
}, },
"required": ["name"], "required": ["name"],

View File

@ -168,7 +168,7 @@
"typescriptConfiguration": { "typescriptConfiguration": {
"type": "boolean", "type": "boolean",
"description": "Whether the module federation configuration and webpack configuration files should use TS.", "description": "Whether the module federation configuration and webpack configuration files should use TS.",
"default": false "default": true
} }
}, },
"required": ["name"], "required": ["name"],

View File

@ -32,6 +32,7 @@ import { normalizeOptions } from './lib/normalize-options';
async function getWebpackConfigs( async function getWebpackConfigs(
options: NormalizedWebpackExecutorOptions, options: NormalizedWebpackExecutorOptions,
projectRoot: string,
context: ExecutorContext context: ExecutorContext
): Promise<Configuration | Configuration[]> { ): Promise<Configuration | Configuration[]> {
if (options.isolatedConfig && !options.webpackConfig) { if (options.isolatedConfig && !options.webpackConfig) {
@ -41,11 +42,12 @@ async function getWebpackConfigs(
} }
let customWebpack = null; let customWebpack = null;
if (options.webpackConfig) { if (options.webpackConfig) {
customWebpack = resolveCustomWebpackConfig( customWebpack = resolveCustomWebpackConfig(
options.webpackConfig, options.webpackConfig,
options.tsConfig options.tsConfig.startsWith(context.root)
? options.tsConfig
: join(context.root, options.tsConfig)
); );
if (typeof customWebpack.then === 'function') { if (typeof customWebpack.then === 'function') {
@ -153,7 +155,7 @@ export async function* webpackExecutor(
); );
} }
const configs = await getWebpackConfigs(options, context); const configs = await getWebpackConfigs(options, metadata.root, context);
return yield* eachValueFrom( return yield* eachValueFrom(
of(configs).pipe( of(configs).pipe(

View File

@ -17,7 +17,7 @@ export type SharedWorkspaceLibraryConfig = {
getReplacementPlugin: () => NormalModuleReplacementPlugin; getReplacementPlugin: () => NormalModuleReplacementPlugin;
}; };
export type Remotes = string[] | [remoteName: string, remoteUrl: string][]; export type Remotes = Array<string | [remoteName: string, remoteUrl: string]>;
export interface SharedLibraryConfig { export interface SharedLibraryConfig {
singleton?: boolean; singleton?: boolean;

View File

@ -2,15 +2,24 @@ import { registerTsProject } from '@nx/js/src/internal';
export function resolveCustomWebpackConfig(path: string, tsConfig: string) { export function resolveCustomWebpackConfig(path: string, tsConfig: string) {
const cleanupTranspiler = registerTsProject(tsConfig); const cleanupTranspiler = registerTsProject(tsConfig);
const customWebpackConfig = require(path); const maybeCustomWebpackConfig = require(path);
cleanupTranspiler(); cleanupTranspiler();
// If the user provides a configuration in TS file // If the user provides a configuration in TS file
// then there are 2 cases for exporing an object. The first one is: // then there are 3 cases for exporing an object. The first one is:
// `module.exports = { ... }`. And the second one is: // `module.exports = { ... }`. And the second one is:
// `export default { ... }`. The ESM format is compiled into: // `export default { ... }`. The ESM format is compiled into:
// `{ default: { ... } }` // `{ default: { ... } }`
return customWebpackConfig.default || customWebpackConfig; // There is also a case of
// `{ default: { default: { ... } }`
const customWebpackConfig =
'default' in maybeCustomWebpackConfig
? 'default' in maybeCustomWebpackConfig.default
? maybeCustomWebpackConfig.default.default
: maybeCustomWebpackConfig.default
: maybeCustomWebpackConfig;
return customWebpackConfig;
} }
export function isRegistered() { export function isRegistered() {