407 lines
11 KiB
TypeScript
407 lines
11 KiB
TypeScript
import {
|
|
ExecutorContext,
|
|
logger,
|
|
parseTargetString,
|
|
readTargetOptions,
|
|
runExecutor,
|
|
workspaceRoot,
|
|
} from '@nx/devkit';
|
|
import { extname, join } from 'path';
|
|
import {
|
|
getModuleFederationConfig,
|
|
getRemotes,
|
|
} from '../../utils/module-federation';
|
|
import { RspackSsrDevServerOptions } from '../ssr-dev-server/schema';
|
|
import ssrDevServerExecutor from '../ssr-dev-server/ssr-dev-server.impl';
|
|
|
|
import {
|
|
combineAsyncIterables,
|
|
createAsyncIterable,
|
|
} from '@nx/devkit/src/utils/async-iterable';
|
|
import { fork } from 'child_process';
|
|
import { cpSync, createWriteStream, existsSync } from 'fs';
|
|
|
|
import {
|
|
parseStaticSsrRemotesConfig,
|
|
type StaticRemotesConfig,
|
|
} from '../../utils/module-federation/parse-static-remotes-config';
|
|
|
|
import fileServerExecutor from '@nx/web/src/executors/file-server/file-server.impl';
|
|
import { waitForPortOpen } from '@nx/web/src/utils/wait-for-port-open';
|
|
import { workspaceDataDirectory } from 'nx/src/utils/cache-directory';
|
|
import { startSsrRemoteProxies } from '../../utils/module-federation/start-ssr-remote-proxies';
|
|
|
|
type ModuleFederationSsrDevServerOptions = RspackSsrDevServerOptions & {
|
|
devRemotes?: (
|
|
| string
|
|
| {
|
|
remoteName: string;
|
|
configuration: string;
|
|
}
|
|
)[];
|
|
|
|
skipRemotes?: string[];
|
|
host: string;
|
|
pathToManifestFile?: string;
|
|
staticRemotesPort?: number;
|
|
parallel?: number;
|
|
ssl?: boolean;
|
|
sslKey?: string;
|
|
sslCert?: string;
|
|
isInitialHost?: boolean;
|
|
};
|
|
|
|
function normalizeOptions(
|
|
options: ModuleFederationSsrDevServerOptions
|
|
): ModuleFederationSsrDevServerOptions {
|
|
return {
|
|
...options,
|
|
ssl: options.ssl ?? false,
|
|
sslCert: options.sslCert ? join(workspaceRoot, options.sslCert) : undefined,
|
|
sslKey: options.sslKey ? join(workspaceRoot, options.sslKey) : undefined,
|
|
};
|
|
}
|
|
|
|
function getBuildOptions(buildTarget: string, context: ExecutorContext) {
|
|
const target = parseTargetString(buildTarget, context);
|
|
|
|
const buildOptions = readTargetOptions(target, context);
|
|
|
|
return {
|
|
...buildOptions,
|
|
};
|
|
}
|
|
|
|
function startSsrStaticRemotesFileServer(
|
|
ssrStaticRemotesConfig: StaticRemotesConfig,
|
|
context: ExecutorContext,
|
|
options: ModuleFederationSsrDevServerOptions
|
|
) {
|
|
if (ssrStaticRemotesConfig.remotes.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// The directories are usually generated with /browser and /server suffixes so we need to copy them to a common directory
|
|
const commonOutputDirectory = join(workspaceRoot, 'tmp/static-remotes');
|
|
for (const app of ssrStaticRemotesConfig.remotes) {
|
|
const remoteConfig = ssrStaticRemotesConfig.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,
|
|
cacheSeconds: -1,
|
|
},
|
|
context
|
|
);
|
|
|
|
return staticRemotesIter;
|
|
}
|
|
|
|
async function startRemotes(
|
|
remotes: string[],
|
|
context: ExecutorContext,
|
|
options: ModuleFederationSsrDevServerOptions
|
|
) {
|
|
const remoteIters: AsyncIterable<{ success: boolean }>[] = [];
|
|
const target = 'serve';
|
|
for (const app of remotes) {
|
|
const remoteProjectServeTarget =
|
|
context.projectGraph.nodes[app].data.targets[target];
|
|
const isUsingModuleFederationSsrDevServerExecutor =
|
|
remoteProjectServeTarget.executor.includes(
|
|
'module-federation-ssr-dev-server'
|
|
);
|
|
|
|
const configurationOverride = options.devRemotes?.find(
|
|
(remote): remote is { remoteName: string; configuration: string } =>
|
|
typeof remote !== 'string' && remote.remoteName === app
|
|
)?.configuration;
|
|
{
|
|
const defaultOverrides = {
|
|
...(options.host ? { host: options.host } : {}),
|
|
...(options.ssl ? { ssl: options.ssl } : {}),
|
|
...(options.sslCert ? { sslCert: options.sslCert } : {}),
|
|
...(options.sslKey ? { sslKey: options.sslKey } : {}),
|
|
};
|
|
|
|
const overrides = {
|
|
watch: true,
|
|
...defaultOverrides,
|
|
...(isUsingModuleFederationSsrDevServerExecutor
|
|
? { isInitialHost: false }
|
|
: {}),
|
|
};
|
|
|
|
remoteIters.push(
|
|
await runExecutor(
|
|
{
|
|
project: app,
|
|
target,
|
|
configuration: configurationOverride ?? context.configurationName,
|
|
},
|
|
overrides,
|
|
context
|
|
)
|
|
);
|
|
}
|
|
}
|
|
return remoteIters;
|
|
}
|
|
|
|
async function buildSsrStaticRemotes(
|
|
staticRemotesConfig: StaticRemotesConfig,
|
|
nxBin,
|
|
context: ExecutorContext,
|
|
options: ModuleFederationSsrDevServerOptions
|
|
) {
|
|
if (!staticRemotesConfig.remotes.length) {
|
|
return;
|
|
}
|
|
|
|
logger.info(
|
|
`Nx is building ${staticRemotesConfig.remotes.length} static remotes...`
|
|
);
|
|
const mapLocationOfRemotes: Record<string, string> = {};
|
|
|
|
for (const remoteApp of staticRemotesConfig.remotes) {
|
|
mapLocationOfRemotes[remoteApp] = `http${options.ssl ? 's' : ''}://${
|
|
options.host
|
|
}:${options.staticRemotesPort}/${
|
|
staticRemotesConfig.config[remoteApp].urlSegment
|
|
}`;
|
|
}
|
|
|
|
await new Promise<void>((resolve) => {
|
|
const childProcess = fork(
|
|
nxBin,
|
|
[
|
|
'run-many',
|
|
'--target=server',
|
|
'--projects',
|
|
staticRemotesConfig.remotes.join(','),
|
|
...(context.configurationName
|
|
? [`--configuration=${context.configurationName}`]
|
|
: []),
|
|
...(options.parallel ? [`--parallel=${options.parallel}`] : []),
|
|
],
|
|
{
|
|
cwd: context.root,
|
|
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
|
}
|
|
);
|
|
|
|
// Add a listener to the child process to capture the build log
|
|
const remoteBuildLogFile = join(
|
|
workspaceDataDirectory,
|
|
// eslint-disable-next-line
|
|
`${new Date().toISOString().replace(/[:\.]/g, '_')}-build.log`
|
|
);
|
|
|
|
const remoteBuildLogStream = createWriteStream(remoteBuildLogFile);
|
|
|
|
childProcess.stdout.on('data', (data) => {
|
|
const ANSII_CODE_REGEX =
|
|
// eslint-disable-next-line no-control-regex
|
|
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
|
|
const stdoutString = data.toString().replace(ANSII_CODE_REGEX, '');
|
|
remoteBuildLogStream.write(stdoutString);
|
|
|
|
// in addition to writing into the stdout stream, also show error directly in console
|
|
// so the error is easily discoverable. 'ERROR in' is the key word to search in webpack output.
|
|
if (stdoutString.includes('ERROR in')) {
|
|
logger.log(stdoutString);
|
|
}
|
|
|
|
if (stdoutString.includes('Successfully ran target server')) {
|
|
childProcess.stdout.removeAllListeners('data');
|
|
logger.info(
|
|
`Nx Built ${staticRemotesConfig.remotes.length} static remotes.`
|
|
);
|
|
resolve();
|
|
}
|
|
});
|
|
|
|
process.on('SIGTERM', () => childProcess.kill('SIGTERM'));
|
|
process.on('exit', () => childProcess.kill('SIGTERM'));
|
|
});
|
|
return mapLocationOfRemotes;
|
|
}
|
|
|
|
export default async function* moduleFederationSsrDevServer(
|
|
ssrDevServerOptions: ModuleFederationSsrDevServerOptions,
|
|
context: ExecutorContext
|
|
) {
|
|
const options = normalizeOptions(ssrDevServerOptions);
|
|
// Force Node to resolve to look for the nx binary that is inside node_modules
|
|
const nxBin = require.resolve('nx/bin/nx');
|
|
const iter = ssrDevServerExecutor(options, context);
|
|
const projectConfig =
|
|
context.projectsConfigurations.projects[context.projectName];
|
|
const buildOptions = getBuildOptions(options.browserTarget, context);
|
|
|
|
let pathToManifestFile = join(
|
|
context.root,
|
|
projectConfig.sourceRoot,
|
|
'assets/module-federation.manifest.json'
|
|
);
|
|
|
|
if (options.pathToManifestFile) {
|
|
const userPathToManifestFile = join(
|
|
context.root,
|
|
options.pathToManifestFile
|
|
);
|
|
|
|
if (!existsSync(userPathToManifestFile)) {
|
|
throw new Error(
|
|
`The provided Module Federation manifest file path does not exist. Please check the file exists at "${userPathToManifestFile}".`
|
|
);
|
|
} else if (extname(userPathToManifestFile) !== '.json') {
|
|
throw new Error(
|
|
`The Module Federation manifest file must be a JSON. Please ensure the file at ${userPathToManifestFile} is a JSON.`
|
|
);
|
|
}
|
|
pathToManifestFile = userPathToManifestFile;
|
|
}
|
|
|
|
if (!options.isInitialHost) {
|
|
return yield* iter;
|
|
}
|
|
|
|
const moduleFederationConfig = getModuleFederationConfig(
|
|
buildOptions.tsConfig,
|
|
context.root,
|
|
projectConfig.root,
|
|
'react'
|
|
);
|
|
|
|
const remoteNames = options.devRemotes?.map((remote) =>
|
|
typeof remote === 'string' ? remote : remote.remoteName
|
|
);
|
|
|
|
const remotes = getRemotes(
|
|
remoteNames,
|
|
options.skipRemotes,
|
|
moduleFederationConfig,
|
|
{
|
|
projectName: context.projectName,
|
|
projectGraph: context.projectGraph,
|
|
root: context.root,
|
|
},
|
|
pathToManifestFile
|
|
);
|
|
|
|
options.staticRemotesPort ??= remotes.staticRemotePort;
|
|
|
|
process.env.NX_MF_DEV_REMOTES = JSON.stringify([
|
|
...(remotes.devRemotes.map((r) =>
|
|
typeof r === 'string' ? r : r.remoteName
|
|
) ?? []),
|
|
projectConfig.name,
|
|
]);
|
|
|
|
const staticRemotesConfig = parseStaticSsrRemotesConfig(
|
|
[...remotes.staticRemotes, ...remotes.dynamicRemotes],
|
|
context
|
|
);
|
|
|
|
const mappedLocationsOfStaticRemotes = await buildSsrStaticRemotes(
|
|
staticRemotesConfig,
|
|
nxBin,
|
|
context,
|
|
options
|
|
);
|
|
|
|
const devRemoteIters = await startRemotes(
|
|
remotes.devRemotes,
|
|
context,
|
|
options
|
|
);
|
|
|
|
const staticRemotesIter = startSsrStaticRemotesFileServer(
|
|
staticRemotesConfig,
|
|
context,
|
|
options
|
|
);
|
|
|
|
startSsrRemoteProxies(
|
|
staticRemotesConfig,
|
|
mappedLocationsOfStaticRemotes,
|
|
options.ssl
|
|
? {
|
|
pathToCert: options.sslCert,
|
|
pathToKey: options.sslKey,
|
|
}
|
|
: undefined
|
|
);
|
|
|
|
return yield* combineAsyncIterables(
|
|
iter,
|
|
...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 host = options.host ?? 'localhost';
|
|
const baseUrl = `http${options.ssl ? 's' : ''}://${host}:${
|
|
options.port
|
|
}`;
|
|
const portsToWaitFor = staticRemotesIter
|
|
? [options.staticRemotesPort, ...remotes.remotePorts]
|
|
: [...remotes.remotePorts];
|
|
|
|
await Promise.all(
|
|
portsToWaitFor.map((port) =>
|
|
waitForPortOpen(port, {
|
|
retries: 480,
|
|
retryDelay: 2500,
|
|
host,
|
|
})
|
|
)
|
|
);
|
|
|
|
logger.info(
|
|
`Nx all ssr remotes have started, server ready at ${baseUrl}`
|
|
);
|
|
next({ success: true, baseUrl });
|
|
} catch (error) {
|
|
throw new Error(
|
|
`Nx failed to start ssr remotes. Check above for errors.`
|
|
);
|
|
} finally {
|
|
done();
|
|
}
|
|
}
|
|
)
|
|
);
|
|
}
|