nx/packages/react/src/executors/module-federation-dev-server/module-federation-dev-server.impl.ts

344 lines
9.5 KiB
TypeScript

import {
ExecutorContext,
logger,
parseTargetString,
readTargetOptions,
runExecutor,
workspaceRoot,
} from '@nx/devkit';
import devServerExecutor from '@nx/webpack/src/executors/dev-server/dev-server.impl';
import fileServerExecutor from '@nx/web/src/executors/file-server/file-server.impl';
import { WebDevServerOptions } from '@nx/webpack/src/executors/dev-server/schema';
import {
getModuleFederationConfig,
getRemotes,
} from '@nx/webpack/src/utils/module-federation';
import {
combineAsyncIterables,
createAsyncIterable,
} from '@nx/devkit/src/utils/async-iterable';
import { waitForPortOpen } from '@nx/web/src/utils/wait-for-port-open';
import { fork } from 'child_process';
import { basename, dirname, join } from 'path';
import { cpSync } from 'fs';
type ModuleFederationDevServerOptions = WebDevServerOptions & {
devRemotes?: string[];
skipRemotes?: string[];
static?: boolean;
isInitialHost?: boolean;
parallel?: number;
staticRemotesPort?: number;
};
function getBuildOptions(buildTarget: string, context: ExecutorContext) {
const target = parseTargetString(buildTarget, context);
const buildOptions = readTargetOptions(target, context);
return {
...buildOptions,
};
}
function startStaticRemotesFileServer(
staticRemotesConfig: StaticRemotesConfig,
context: ExecutorContext,
options: ModuleFederationDevServerOptions
) {
let shouldMoveToCommonLocation = false;
let commonOutputDirectory: string;
for (const app of staticRemotesConfig.remotes) {
const remoteBasePath = staticRemotesConfig.config[app].basePath;
if (!commonOutputDirectory) {
commonOutputDirectory = remoteBasePath;
} else if (commonOutputDirectory !== remoteBasePath) {
shouldMoveToCommonLocation = true;
break;
}
}
if (shouldMoveToCommonLocation) {
commonOutputDirectory = join(workspaceRoot, 'tmp/static-remotes');
for (const app of staticRemotesConfig.remotes) {
const remoteConfig = staticRemotesConfig.config[app];
cpSync(
remoteConfig.outputPath,
join(commonOutputDirectory, remoteConfig.urlSegment),
{
force: true,
recursive: true,
}
);
}
}
const staticRemotesIter = fileServerExecutor(
{
cors: true,
watch: false,
staticFilePath: commonOutputDirectory,
parallel: false,
spa: false,
withDeps: false,
host: options.host,
port: options.staticRemotesPort,
ssl: options.ssl,
sslCert: options.sslCert,
sslKey: options.sslKey,
},
context
);
return staticRemotesIter;
}
async function startDevRemotes(
remotes: {
remotePorts: any[];
staticRemotes: string[];
devRemotes: string[];
},
context: ExecutorContext
) {
const devRemoteIters: AsyncIterable<{ success: boolean }>[] = [];
for (const app of remotes.devRemotes) {
const remoteProjectServeTarget =
context.projectGraph.nodes[app].data.targets['serve'];
const isUsingModuleFederationDevServerExecutor =
remoteProjectServeTarget.executor.includes(
'module-federation-dev-server'
);
devRemoteIters.push(
await runExecutor(
{
project: app,
target: 'serve',
configuration: context.configurationName,
},
{
watch: true,
...(isUsingModuleFederationDevServerExecutor
? { isInitialHost: false }
: {}),
},
context
)
);
}
return devRemoteIters;
}
async function buildStaticRemotes(
staticRemotesConfig: StaticRemotesConfig,
nxBin,
context: ExecutorContext,
options: ModuleFederationDevServerOptions
) {
if (!staticRemotesConfig.remotes.length) {
return;
}
logger.info(
`NX Building ${staticRemotesConfig.remotes.length} static remotes...`
);
const mappedLocationOfRemotes: Record<string, string> = {};
for (const app of staticRemotesConfig.remotes) {
mappedLocationOfRemotes[app] = `http${options.ssl ? 's' : ''}://${
options.host
}:${options.staticRemotesPort}/${
staticRemotesConfig.config[app].urlSegment
}`;
}
process.env.NX_MF_DEV_SERVER_STATIC_REMOTES = JSON.stringify(
mappedLocationOfRemotes
);
await new Promise<void>((res) => {
const staticProcess = fork(
nxBin,
[
'run-many',
`--target=build`,
`--projects=${staticRemotesConfig.remotes.join(',')}`,
...(context.configurationName
? [`--configuration=${context.configurationName}`]
: []),
...(options.parallel ? [`--parallel=${options.parallel}`] : []),
],
{
cwd: context.root,
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
}
);
staticProcess.stdout.on('data', (data) => {
const ANSII_CODE_REGEX =
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
const stdoutString = data.toString().replace(ANSII_CODE_REGEX, '');
if (stdoutString.includes('Successfully ran target build')) {
staticProcess.stdout.removeAllListeners('data');
logger.info(
`NX Built ${staticRemotesConfig.remotes.length} static remotes`
);
res();
}
});
staticProcess.stderr.on('data', (data) => logger.info(data.toString()));
staticProcess.on('exit', (code) => {
if (code !== 0) {
throw new Error(`Remote failed to start. See above for errors.`);
}
});
process.on('SIGTERM', () => staticProcess.kill('SIGTERM'));
process.on('exit', () => staticProcess.kill('SIGTERM'));
});
}
type StaticRemoteConfig = {
basePath: string;
outputPath: string;
urlSegment: string;
};
type StaticRemotesConfig = {
remotes: string[];
config: Record<string, StaticRemoteConfig> | undefined;
};
export function parseStaticRemotesConfig(
staticRemotes: string[] | undefined,
context: ExecutorContext
): StaticRemotesConfig {
if (!staticRemotes?.length) {
return { remotes: [], config: undefined };
}
const config: Record<string, StaticRemoteConfig> = {};
for (const app of staticRemotes) {
const outputPath =
context.projectGraph.nodes[app].data.targets['build'].options.outputPath;
const basePath = dirname(outputPath);
const urlSegment = basename(outputPath);
config[app] = { basePath, outputPath, urlSegment };
}
return { remotes: staticRemotes, config };
}
export default async function* moduleFederationDevServer(
options: ModuleFederationDevServerOptions,
context: ExecutorContext
): AsyncIterableIterator<{ success: boolean; baseUrl?: string }> {
const initialStaticRemotesPorts = options.staticRemotesPort;
options.staticRemotesPort ??= options.port + 1;
// Force Node to resolve to look for the nx binary that is inside node_modules
const nxBin = require.resolve('nx/bin/nx');
const currIter = options.static
? fileServerExecutor(
{
...options,
parallel: false,
withDeps: false,
spa: false,
cors: true,
},
context
)
: devServerExecutor(options, context);
const p = context.projectsConfigurations.projects[context.projectName];
const buildOptions = getBuildOptions(options.buildTarget, context);
if (!options.isInitialHost) {
return yield* currIter;
}
const moduleFederationConfig = getModuleFederationConfig(
buildOptions.tsConfig,
context.root,
p.root,
'react'
);
const remotes = getRemotes(
options.devRemotes,
options.skipRemotes,
moduleFederationConfig,
{
projectName: context.projectName,
projectGraph: context.projectGraph,
root: context.root,
}
);
if (remotes.devRemotes.length > 0 && !initialStaticRemotesPorts) {
options.staticRemotesPort = options.devRemotes.reduce((portToUse, r) => {
const remotePort =
context.projectGraph.nodes[r].data.targets['serve'].options.port;
if (remotePort >= portToUse) {
return remotePort + 1;
} else {
return portToUse;
}
}, options.staticRemotesPort);
}
const staticRemotesConfig = parseStaticRemotesConfig(
remotes.staticRemotes,
context
);
await buildStaticRemotes(staticRemotesConfig, nxBin, context, options);
const devRemoteIters = await startDevRemotes(remotes, context);
const staticRemotesIter =
remotes.staticRemotes.length > 0
? startStaticRemotesFileServer(staticRemotesConfig, context, options)
: undefined;
return yield* combineAsyncIterables(
currIter,
...devRemoteIters,
...(staticRemotesIter ? [staticRemotesIter] : []),
createAsyncIterable<{ success: true; baseUrl: string }>(
async ({ next, done }) => {
if (!options.isInitialHost) {
done();
return;
}
if (remotes.remotePorts.length === 0) {
done();
return;
}
try {
const portsToWaitFor = staticRemotesIter
? [options.staticRemotesPort, ...remotes.remotePorts]
: [...remotes.remotePorts];
await Promise.all(
portsToWaitFor.map((port) =>
waitForPortOpen(port, {
retries: 480,
retryDelay: 2500,
host: 'localhost',
})
)
);
logger.info(
`NX All remotes started, server ready at http://localhost:${options.port}`
);
next({ success: true, baseUrl: `http://localhost:${options.port}` });
} catch {
throw new Error(
`Timed out waiting for remote to start. Check above for any errors.`
);
} finally {
done();
}
}
)
);
}