nx/packages/react/src/executors/module-federation-dev-server/module-federation-dev-server.impl.ts
Jack Hsu 8b1c78caa5
fix(module-federation): do not cache assets from static serve (#27005)
We're currently caching files for an hour when serving the host with
static remotes. This PR fixes the issue by setting `cacheSeconds: -1` on
the static server (disables cache).

This affects development only.
<!-- If this is a particularly complex change or feature addition, you
can request a dedicated Nx release for this pull request branch. Mention
someone from the Nx team or the `@nrwl/nx-pipelines-reviewers` and they
will confirm if the PR warrants its own release for testing purposes,
and generate it for you if appropriate. -->

## Current Behavior
<!-- This is the behavior we have today -->

## Expected Behavior
<!-- This is the behavior we should expect with the changes in this PR
-->

## Related Issue(s)
<!-- Please link the issue being fixed so it gets closed when this is
merged. -->

Fixes #
2024-07-19 10:39:32 -04:00

410 lines
12 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 { workspaceDataDirectory } from 'nx/src/utils/cache-directory';
import { fork } from 'node:child_process';
import { join } from 'node:path';
import { cpSync, createWriteStream } from 'node:fs';
import { existsSync } from 'fs';
import { extname } from 'path';
import { startRemoteProxies } from '@nx/webpack/src/utils/module-federation/start-remote-proxies';
import {
parseStaticRemotesConfig,
type StaticRemotesConfig,
} from '@nx/webpack/src/utils/module-federation/parse-static-remotes-config';
type ModuleFederationDevServerOptions = WebDevServerOptions & {
devRemotes?: (
| string
| {
remoteName: string;
configuration: string;
}
)[];
skipRemotes?: string[];
static?: boolean;
isInitialHost?: boolean;
parallel?: number;
staticRemotesPort?: number;
pathToManifestFile?: string;
};
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
) {
if (
!staticRemotesConfig.remotes ||
staticRemotesConfig.remotes.length === 0
) {
return;
}
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,
cacheSeconds: -1,
},
context
);
return staticRemotesIter;
}
async function startRemotes(
remotes: string[],
context: ExecutorContext,
options: ModuleFederationDevServerOptions,
target: 'serve' | 'serve-static' = 'serve'
) {
const remoteIters: AsyncIterable<{ success: boolean }>[] = [];
for (const app of remotes) {
const remoteProjectServeTarget =
context.projectGraph.nodes[app].data.targets[target];
const isUsingModuleFederationDevServerExecutor =
remoteProjectServeTarget.executor.includes(
'module-federation-dev-server'
);
const configurationOverride = options.devRemotes?.find(
(
r
): r is {
remoteName: string;
configuration: string;
} => typeof r !== 'string' && r.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 =
target === 'serve'
? {
watch: true,
...(isUsingModuleFederationDevServerExecutor
? { isInitialHost: false }
: {}),
...defaultOverrides,
}
: { ...defaultOverrides };
remoteIters.push(
await runExecutor(
{
project: app,
target,
configuration: configurationOverride ?? context.configurationName,
},
overrides,
context
)
);
}
return remoteIters;
}
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'],
}
);
// File to debug build failures e.g. 2024-01-01T00_00_0_0Z-build.log'
const remoteBuildLogFile = join(
workspaceDataDirectory,
`${new Date().toISOString().replace(/[:\.]/g, '_')}-build.log`
);
const stdoutStream = createWriteStream(remoteBuildLogFile);
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, '');
stdoutStream.write(stdoutString);
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.once('exit', (code) => {
stdoutStream.end();
staticProcess.stdout.removeAllListeners('data');
staticProcess.stderr.removeAllListeners('data');
if (code !== 0) {
throw new Error(
`Remote failed to start. A complete log can be found in: ${remoteBuildLogFile}`
);
}
res();
});
process.on('SIGTERM', () => staticProcess.kill('SIGTERM'));
process.on('exit', () => staticProcess.kill('SIGTERM'));
});
return mappedLocationOfRemotes;
}
export default async function* moduleFederationDevServer(
options: ModuleFederationDevServerOptions,
context: ExecutorContext
): AsyncIterableIterator<{ success: boolean; baseUrl?: string }> {
// 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,
cacheSeconds: -1,
},
context
)
: devServerExecutor(options, context);
const p = context.projectsConfigurations.projects[context.projectName];
const buildOptions = getBuildOptions(options.buildTarget, context);
let pathToManifestFile = join(
context.root,
p.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(options.pathToManifestFile) !== '.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* currIter;
}
const moduleFederationConfig = getModuleFederationConfig(
buildOptions.tsConfig,
context.root,
p.root,
'react'
);
const remoteNames = options.devRemotes?.map((r) =>
typeof r === 'string' ? r : r.remoteName
);
const remotes = getRemotes(
remoteNames,
options.skipRemotes,
moduleFederationConfig,
{
projectName: context.projectName,
projectGraph: context.projectGraph,
root: context.root,
},
pathToManifestFile
);
options.staticRemotesPort ??= remotes.staticRemotePort;
// Set NX_MF_DEV_REMOTES for the Nx Runtime Library Control Plugin
process.env.NX_MF_DEV_REMOTES = JSON.stringify(
remotes.devRemotes.map((r) => (typeof r === 'string' ? r : r.remoteName))
);
const staticRemotesConfig = parseStaticRemotesConfig(
[...remotes.staticRemotes, ...remotes.dynamicRemotes],
context
);
const mappedLocationsOfStaticRemotes = await buildStaticRemotes(
staticRemotesConfig,
nxBin,
context,
options
);
const devRemoteIters = await startRemotes(
remotes.devRemotes,
context,
options,
'serve'
);
const staticRemotesIter = startStaticRemotesFileServer(
staticRemotesConfig,
context,
options
);
startRemoteProxies(staticRemotesConfig, mappedLocationsOfStaticRemotes);
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 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: host,
})
)
);
logger.info(`NX All remotes started, server ready at ${baseUrl}`);
next({ success: true, baseUrl: baseUrl });
} catch (err) {
throw new Error(
`Failed to start remotes. Check above for any errors.`,
{
cause: err,
}
);
} finally {
done();
}
}
)
);
}