Files
nrwl--nx/packages/web/src/executors/file-server/file-server.impl.ts
T
2026-07-13 12:38:36 +08:00

292 lines
8.1 KiB
TypeScript

import { signalToCode } from '@nx/devkit/internal';
import { execFileSync, fork } from 'child_process';
import * as pc from 'picocolors';
import {
ExecutorContext,
getPackageManagerCommand,
output,
parseTargetString,
readTargetOptions,
} from '@nx/devkit';
import { copyFileSync, unlinkSync } from 'fs';
import { Schema } from './schema';
import { join, resolve } from 'path';
import { readModulePackageJson } from 'nx/src/utils/package-json';
import { daemonClient } from 'nx/src/daemon/client/client';
import { interpolate } from 'nx/src/tasks-runner/utils';
import { stripGlobToBaseDir } from '@nx/js/internal';
import detectPort from 'detect-port';
function getHttpServerArgs(options: Schema) {
const {
buildTarget,
parallel,
host,
proxyUrl,
ssl,
sslCert,
sslKey,
proxyOptions,
watch,
spa,
cacheSeconds,
...rest
} = options;
const args = [`-c${options.cacheSeconds}`];
for (const [key, value] of Object.entries(rest)) {
if (typeof value === 'boolean' && value) {
args.push(`--${key}`);
} else if (typeof value === 'string') {
args.push(`--${key}=${value}`);
}
}
if (host) {
args.push(`-a=${host}`);
}
if (ssl) {
args.push(`-S`);
}
if (sslCert) {
args.push(`-C=${sslCert}`);
}
if (sslKey) {
args.push(`-K=${sslKey}`);
}
if (proxyUrl) {
args.push(`-P=${proxyUrl}`);
}
if (proxyOptions) {
Object.keys(options.proxyOptions).forEach((key) => {
args.push(`--proxy-options.${key}=${options.proxyOptions[key]}`);
});
}
return args;
}
function getBuildTargetCommand(options: Schema, context: ExecutorContext) {
const target = parseTargetString(options.buildTarget, context);
const cmd = ['nx', 'run'];
if (target.configuration) {
cmd.push(`${target.project}:${target.target}:${target.configuration}`);
} else {
cmd.push(`${target.project}:${target.target}`);
}
if (options.parallel) {
cmd.push(`--parallel`);
}
if (options.maxParallel) {
cmd.push(`--maxParallel=${options.maxParallel}`);
}
return cmd;
}
function getBuildTargetOutputPath(options: Schema, context: ExecutorContext) {
if (options.staticFilePath) {
return options.staticFilePath;
}
let outputPath: string;
try {
const target = parseTargetString(options.buildTarget, context);
const buildOptions = readTargetOptions(target, context);
if (buildOptions?.outputPath) {
outputPath = buildOptions.outputPath;
} else {
const project = context.projectGraph.nodes[context.projectName];
const buildTarget = project.data.targets[target.target];
outputPath = buildTarget.outputs?.[0];
if (outputPath) {
outputPath = interpolate(outputPath, {
projectName: project.data.name,
projectRoot: project.data.root,
});
outputPath = stripGlobToBaseDir(outputPath);
}
}
} catch (e) {
throw new Error(`Invalid buildTarget: ${options.buildTarget}`);
}
if (!outputPath) {
throw new Error(
`Unable to get the outputPath from buildTarget ${options.buildTarget}. Make sure ${options.buildTarget} has an outputPath property or manually provide an staticFilePath property`
);
}
return outputPath;
}
function createFileWatcher(
project: string | undefined,
changeHandler: () => void
) {
return daemonClient.registerFileWatcher(
{
watchProjects: project ? [project] : 'all',
includeGlobalWorkspaceFiles: true,
includeDependencies: true,
},
async (error, val) => {
if (error === 'reconnecting') {
// Silent - daemon restarts automatically on lockfile changes
return;
} else if (error === 'reconnected') {
// Silent - reconnection succeeded
return;
} else if (error === 'closed') {
throw new Error(
'Failed to reconnect to daemon after multiple attempts'
);
} else if (error) {
throw new Error(`Watch error: ${error?.message ?? 'Unknown'}`);
} else if (val?.changedFiles.length > 0) {
changeHandler();
}
}
);
}
export default async function* fileServerExecutor(
options: Schema,
context: ExecutorContext
) {
if (!options.buildTarget && !options.staticFilePath) {
throw new Error("You must set either 'buildTarget' or 'staticFilePath'.");
}
if (options.watch && !options.buildTarget) {
throw new Error(
"Watch error: You can only specify 'watch' when 'buildTarget' is set."
);
}
let running = false;
let disposeWatch: () => void;
if (options.buildTarget) {
// Run the build target through the workspace package manager so workspaces
// that pin a non-npm manager (e.g. devEngines.packageManager) don't fail.
const pmc = getPackageManagerCommand();
const run = () => {
if (!running) {
running = true;
/**
* Expose a variable to the build target to know if it's being run by the serve-static executor
* This is useful because a config might need to change if it's being run by serve-static without the user's input
* or if being ran by another executor (eg. E2E tests)
* */
process.env.NX_SERVE_STATIC_BUILD_RUNNING = 'true';
try {
const args = getBuildTargetCommand(options, context);
execFileSync(pmc.exec, args, {
stdio: [0, 1, 2],
shell: true,
windowsHide: true,
});
} catch {
throw new Error(
`Build target failed: ${pc.bold(options.buildTarget)}`
);
} finally {
process.env.NX_SERVE_STATIC_BUILD_RUNNING = undefined;
running = false;
}
}
};
if (!daemonClient.enabled() && options.watch) {
output.warn({
title:
'Nx Daemon is not enabled. Static server is not watching for changes.',
});
}
if (daemonClient.enabled() && options.watch) {
disposeWatch = await createFileWatcher(context.projectName, run);
}
// perform initial run
run();
}
const port = await detectPort(options.port || 8080);
const outputPath = getBuildTargetOutputPath(options, context);
if (options.spa) {
const src = join(outputPath, 'index.html');
const dst = join(outputPath, '404.html');
// See: https://github.com/http-party/http-server#magic-files
copyFileSync(src, dst);
// We also need to ensure the proxyUrl is set, otherwise the browser will continue to throw a 404 error
// This can cause unexpected behaviors and failures especially in automated test suites
options.proxyUrl ??= `http${options.ssl ? 's' : ''}://localhost:${port}?`;
}
const args = getHttpServerArgs(options);
const { path: pathToHttpServerPkgJson, packageJson } = readModulePackageJson(
'http-server',
module.paths
);
const pathToHttpServerBin = packageJson.bin['http-server'];
const pathToHttpServer = resolve(
pathToHttpServerPkgJson.replace('package.json', ''),
pathToHttpServerBin
);
// detect port as close to when used to prevent port being used by another process
// when running in parallel
args.push(`-p=${port}`);
const serve = fork(pathToHttpServer, [outputPath, ...args], {
stdio: 'pipe',
cwd: context.root,
env: {
FORCE_COLOR: 'true',
...process.env,
},
});
const processExitListener = () => {
serve.kill();
if (disposeWatch) {
disposeWatch();
}
if (options.spa) {
unlinkSync(join(outputPath, '404.html'));
}
};
process.on('exit', processExitListener);
process.on('SIGTERM', processExitListener);
serve.stdout.on('data', (chunk) => {
if (chunk.toString().indexOf('GET') === -1) {
process.stdout.write(chunk);
}
});
serve.stderr.on('data', (chunk) => {
process.stderr.write(chunk);
});
yield {
success: true,
baseUrl: `${options.ssl ? 'https' : 'http'}://${options.host}:${port}`,
};
return new Promise<{ success: boolean }>((res) => {
serve.on('exit', (code, signal) => {
if (code === null) code = signalToCode(signal);
if (code == 0) {
res({ success: true });
} else {
res({ success: false });
}
});
});
}