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 = {}; for (const remoteApp of staticRemotesConfig.remotes) { mapLocationOfRemotes[remoteApp] = `http${options.ssl ? 's' : ''}://${ options.host }:${options.staticRemotesPort}/${ staticRemotesConfig.config[remoteApp].urlSegment }`; } await new Promise((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(); } } ) ); }