use crate::project::{self, ProjectBuild, ProjectBuildOptions};
use std::env;
use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;
use std::time::{Duration, Instant};
const FARM_BUILD_SOURCE: &str = include_str!("../embedded/tools/farm-build.mjs");
const SERVER_LIFECYCLE_EXPORTS_SOURCE: &str =
include_str!("../embedded/tools/server-lifecycle-exports.txt");
const FARM_DEV_SOURCE: &str = include_str!("../embedded/tools/farm-dev.mjs");
const ACTION_DEV_SOURCE: &str = include_str!("../embedded/tools/noxid-action-dev.mjs");
const NATIVE_ESM_SOURCE: &str = include_str!("../embedded/tools/native-esm.mjs");
static FARM_TOOLS_COUNTER: AtomicU64 = AtomicU64::new(0);
struct EmbeddedFarmTools {
directory: PathBuf,
build_host_root: PathBuf,
}
impl EmbeddedFarmTools {
fn prepare(project_root: &Path) -> Result<Self, String> {
let resolution_root = farm_dependency_root(project_root).ok_or_else(|| {
"error[BUILD_HOST_MISSING]: Noxid cannot resolve the required @farmfe/core build host; Noxid prefers the project installation and then checks the compiler installation, so run `pnpm add -D @farmfe/core@^1.7.0` in the project root, then rerun the command"
.to_string()
})?;
let parent = resolution_root.join("target");
fs::create_dir_all(&parent).map_err(|error| {
format!(
"cannot prepare the embedded Farm tools under {}: {error}",
parent.display()
)
})?;
for _ in 0..100 {
let suffix = FARM_TOOLS_COUNTER.fetch_add(1, Ordering::Relaxed);
let directory =
parent.join(format!("noxid-farm-tools-{}-{suffix}", std::process::id()));
match fs::create_dir(&directory) {
Ok(()) => {
for (name, source) in [
("farm-build.mjs", FARM_BUILD_SOURCE),
(
"server-lifecycle-exports.txt",
SERVER_LIFECYCLE_EXPORTS_SOURCE,
),
("farm-dev.mjs", FARM_DEV_SOURCE),
("noxid-action-dev.mjs", ACTION_DEV_SOURCE),
("native-esm.mjs", NATIVE_ESM_SOURCE),
] {
if let Err(error) = fs::write(directory.join(name), source) {
let _ = fs::remove_dir_all(&directory);
return Err(format!("cannot prepare the embedded Farm tools: {error}"));
}
}
return Ok(Self {
directory,
build_host_root: resolution_root,
});
}
Err(error) if error.kind() == ErrorKind::AlreadyExists => continue,
Err(error) => {
return Err(format!(
"cannot create a directory for the embedded Farm tools: {error}"
));
}
}
}
Err("cannot allocate a unique directory for the embedded Farm tools".into())
}
fn farm_build(&self) -> PathBuf {
self.directory.join("farm-build.mjs")
}
fn farm_dev(&self) -> PathBuf {
self.directory.join("farm-dev.mjs")
}
fn action_dev(&self) -> PathBuf {
self.directory.join("noxid-action-dev.mjs")
}
}
impl Drop for EmbeddedFarmTools {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.directory);
}
}
struct StagedFarmInput {
directory: PathBuf,
}
impl StagedFarmInput {
fn prepare(project_root: &Path) -> Result<Self, String> {
let target = project_root.join("target");
sweep_stale_farm_inputs(&target)?;
let directory = target.join(format!("noxid-farm-input-{}", std::process::id()));
if directory.exists() {
fs::remove_dir_all(&directory)
.map_err(|error| format!("cannot clear {}: {error}", directory.display()))?;
}
Ok(Self { directory })
}
fn path(&self) -> &Path {
&self.directory
}
fn remove(self) -> Result<(), String> {
fs::remove_dir_all(&self.directory)
.map_err(|error| format!("cannot remove {}: {error}", self.directory.display()))
}
}
fn sweep_stale_farm_inputs(target: &Path) -> Result<(), String> {
let entries = match fs::read_dir(target) {
Ok(entries) => entries,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(format!(
"cannot inspect Farm staging under {}: {error}",
target.display()
));
}
};
for entry in entries {
let entry = entry.map_err(|error| {
format!(
"cannot inspect Farm staging under {}: {error}",
target.display()
)
})?;
if !entry
.file_type()
.map_err(|error| format!("cannot inspect {}: {error}", entry.path().display()))?
.is_dir()
{
continue;
}
let name = entry.file_name();
let Some(pid) = name
.to_str()
.and_then(|name| name.strip_prefix("noxid-farm-input-"))
.and_then(|value| value.parse::<u32>().ok())
.filter(|pid| *pid > 0)
else {
continue;
};
if process_is_alive(pid) {
continue;
}
fs::remove_dir_all(entry.path()).map_err(|error| {
format!(
"cannot remove stale Farm input {}: {error}",
entry.path().display()
)
})?;
}
Ok(())
}
#[cfg(unix)]
fn process_is_alive(pid: u32) -> bool {
Command::new("kill")
.args(["-0", &pid.to_string()])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|status| status.success())
}
#[cfg(windows)]
fn process_is_alive(pid: u32) -> bool {
let filter = format!("PID eq {pid}");
Command::new("tasklist")
.args(["/FI", &filter, "/FO", "CSV", "/NH"])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.is_ok_and(|output| {
output.status.success()
&& String::from_utf8_lossy(&output.stdout)
.split(',')
.nth(1)
.is_some_and(|field| field.trim_matches('"').parse::<u32>() == Ok(pid))
})
}
#[cfg(not(any(unix, windows)))]
fn process_is_alive(_pid: u32) -> bool {
true
}
impl Drop for StagedFarmInput {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.directory);
}
}
fn farm_dependency_root(project_root: &Path) -> Option<PathBuf> {
project_root
.ancestors()
.find(|candidate| {
candidate
.join("node_modules/@farmfe/core/package.json")
.is_file()
})
.map(Path::to_path_buf)
.or_else(|| {
env::current_exe().ok().and_then(|executable| {
executable.parent().and_then(|parent| {
parent
.ancestors()
.find(|candidate| {
candidate
.join("node_modules/@farmfe/core/package.json")
.is_file()
})
.map(Path::to_path_buf)
})
})
})
}
fn compiler_dependency_root(build_host_root: &Path) -> Result<PathBuf, String> {
let compiler_root = env::current_exe().ok().and_then(|executable| {
executable.parent().and_then(|parent| {
parent
.ancestors()
.find(|candidate| {
candidate
.join("node_modules/postgres/package.json")
.is_file()
})
.map(Path::to_path_buf)
})
});
compiler_root
.or_else(|| {
build_host_root
.join("node_modules/postgres/package.json")
.is_file()
.then(|| build_host_root.to_path_buf())
})
.ok_or_else(|| {
"error[BUILD_HOST_INTERNAL_DEPENDENCY_MISSING]: Noxid's Farm build host cannot resolve its compiler-owned `postgres` bundling alias from the compiler installation; reinstall the Noxid compiler toolchain, then rerun the command"
.to_string()
})
}
fn validate_build_host(build_host_root: &Path) -> Result<(), String> {
let package_json = build_host_root.join("node_modules/@farmfe/core/package.json");
let probe = r#"import path from "node:path";
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
try {
const packageJson = path.resolve(process.argv[1]);
const entry = createRequire(packageJson).resolve("@farmfe/core");
const farm = await import(pathToFileURL(entry).href);
if (typeof farm.build !== "function") throw new Error("the package does not export a callable build function");
} catch (error) {
process.stdout.write(String(error?.message ?? error).replace(/\s+/g, " ").trim());
process.exitCode = 1;
}
"#;
let output = Command::new("node")
.args(["--input-type=module", "--eval", probe])
.arg(&package_json)
.current_dir(build_host_root)
.output()
.map_err(|error| format!("cannot validate the Farm build host: {error}"))?;
if output.status.success() {
return Ok(());
}
let reason = String::from_utf8_lossy(&output.stdout);
let reason = if reason.trim().is_empty() {
"Node could not import the package entry point"
} else {
reason.trim()
};
Err(format!(
"error[BUILD_HOST_UNUSABLE]: the selected @farmfe/core package at {} cannot be loaded: {reason}. Repair or reinstall it with `pnpm add -D @farmfe/core@^1.7.0` from the project root, then rerun the command",
package_json.display()
))
}
pub fn serve_project(
input: PathBuf,
mut options: ProjectBuildOptions,
port: u16,
) -> Result<(), String> {
options.development = true;
options.out_dir = absolute(&options.out_dir)?;
let mut session = project::ProjectSession::default();
session.build(&input, &options)?;
let base = project::base_path(&input)?;
let root = project_root(&input)?;
let tools = EmbeddedFarmTools::prepare(&root)?;
let action_port = port.checked_add(1).ok_or(
"noxid dev needs one additional port for the action host; choose --port below 65535",
)?;
let action_child =
spawn_action_host(&options.out_dir, action_port, &root, &tools.action_dev())?;
let mut action_host = Some(ChildGuard(Some(action_child)));
let mut action_restart_at = None;
let child = Command::new("node")
.arg(tools.farm_dev())
.arg(&options.out_dir)
.arg(port.to_string())
.arg(&base)
.arg(action_port.to_string())
.current_dir(&root)
.spawn()
.map_err(|error| format!("cannot start Farm development host: {error}"))?;
let mut host = ChildGuard(Some(child));
let mut stamp = project::project_revision(&input)?;
println!("noxid dev compiling {} with semantic HMR", input.display());
loop {
if let Some(guard) = action_host.as_mut() {
if let Some(status) = guard
.0
.as_mut()
.expect("action host child is present")
.try_wait()
.map_err(|error| format!("cannot inspect development action host: {error}"))?
{
eprintln!(
"noxid: development renderer exited with status {status}; serving the last valid client application while it restarts"
);
guard.0.take();
action_host = None;
action_restart_at = Some(Instant::now() + Duration::from_millis(250));
}
} else if action_restart_at.is_some_and(|deadline| Instant::now() >= deadline) {
match spawn_action_host(&options.out_dir, action_port, &root, &tools.action_dev()) {
Ok(child) => {
println!("noxid: development renderer restarted on port {action_port}");
action_host = Some(ChildGuard(Some(child)));
action_restart_at = None;
}
Err(error) => {
eprintln!("noxid: renderer restart failed; retrying shortly: {error}");
action_restart_at = Some(Instant::now() + Duration::from_millis(500));
}
}
}
if let Some(status) = host
.0
.as_mut()
.expect("Farm child is present")
.try_wait()
.map_err(|error| format!("cannot inspect Farm development host: {error}"))?
{
return Err(format!("Farm development host exited with status {status}"));
}
let current = project::project_revision(&input)?;
if current != stamp {
stamp = current;
match session.build(&input, &options) {
Ok(build) => {
println!(
"incremental compile: {} target(s) compiled, {} reused; Farm receives changed files only",
build.compiled_targets, build.reused_targets,
);
if let Some(guard) = action_host.as_mut() {
guard.stop();
}
action_host = None;
match spawn_action_host(
&options.out_dir,
action_port,
&root,
&tools.action_dev(),
) {
Ok(child) => {
action_host = Some(ChildGuard(Some(child)));
action_restart_at = None;
}
Err(error) => {
eprintln!(
"noxid: rebuilt the client application, but the renderer did not restart; retrying shortly: {error}"
);
action_restart_at = Some(Instant::now() + Duration::from_millis(500));
}
}
}
Err(error) => {
project::write_development_diagnostics(&options.out_dir, &error)?;
eprintln!("noxid: rebuild failed; serving the last valid application\n{error}");
}
}
}
thread::sleep(Duration::from_millis(60));
}
}
fn spawn_action_host(
out_dir: &Path,
port: u16,
root: &Path,
action_dev: &Path,
) -> Result<Child, String> {
let mut child = Command::new("node")
.arg(action_dev)
.arg(out_dir.join("server/handler.js"))
.arg(port.to_string())
.current_dir(root)
.spawn()
.map_err(|error| format!("cannot start Noxid development action host: {error}"))?;
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Some(status) = child
.try_wait()
.map_err(|error| format!("cannot inspect Noxid development action host: {error}"))?
{
return Err(format!(
"Noxid development action host exited before becoming ready: {status}"
));
}
if std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() {
return Ok(child);
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return Err(format!(
"Noxid development action host did not become ready on port {port} within 5 seconds"
));
}
thread::sleep(Duration::from_millis(20));
}
}
struct ChildGuard(Option<Child>);
impl Drop for ChildGuard {
fn drop(&mut self) {
if let Some(child) = self.0.as_mut() {
let _ = child.kill();
let _ = child.wait();
}
}
}
impl ChildGuard {
fn stop(&mut self) {
if let Some(mut child) = self.0.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}
pub fn bundle_project(
input: &Path,
out_dir: &Path,
title: Option<String>,
) -> Result<ProjectBuild, String> {
let runtime = project::server_runtime(input)?;
bundle_project_for_runtime(input, out_dir, title, &runtime)
}
pub fn bundle_project_for_runtime(
input: &Path,
out_dir: &Path,
title: Option<String>,
runtime: &str,
) -> Result<ProjectBuild, String> {
let out_dir = absolute(out_dir)?;
let root = project_root(input)?;
let tools = EmbeddedFarmTools::prepare(&root)?;
let compiler_dependencies = compiler_dependency_root(&tools.build_host_root)?;
validate_build_host(&tools.build_host_root)?;
let native = StagedFarmInput::prepare(&root)?;
let build = project::build_project(
input,
&ProjectBuildOptions {
out_dir: native.path().to_path_buf(),
title,
development: false,
strict_npm: false,
},
)?;
let base = project::base_path(input)?;
let result = Command::new("node")
.arg(tools.farm_build())
.arg(native.path())
.arg(&out_dir)
.arg(&base)
.arg(runtime)
.arg(if build.native_esm_eligible {
"native-esm"
} else {
"farm-runtime"
})
.arg(&compiler_dependencies)
.current_dir(&root)
.status();
let result = result.map_err(|error| format!("cannot start Farm build host: {error}"))?;
if !result.success() {
return Err(format!("Farm build host exited with status {result}"));
}
copy_native_artifacts(native.path(), &out_dir)?;
project::prerender_output(&out_dir, &build)?;
native.remove()?;
Ok(build)
}
fn absolute(path: &Path) -> Result<PathBuf, String> {
if path.is_absolute() {
Ok(path.to_path_buf())
} else {
Ok(std::env::current_dir()
.map_err(|error| format!("cannot resolve current directory: {error}"))?
.join(path))
}
}
fn project_root(input: &Path) -> Result<PathBuf, String> {
let root = if input.is_dir() {
input
} else {
input.parent().unwrap_or_else(|| Path::new("."))
};
let app_root = fs::canonicalize(root)
.map_err(|error| format!("cannot resolve project root {}: {error}", root.display()))?;
Ok(app_root
.ancestors()
.find(|candidate| candidate.join("package.json").is_file())
.unwrap_or(&app_root)
.to_path_buf())
}
fn copy_native_artifacts(source: &Path, destination: &Path) -> Result<(), String> {
let mut pending = vec![source.to_path_buf()];
while let Some(directory) = pending.pop() {
for entry in fs::read_dir(&directory)
.map_err(|error| format!("cannot read {}: {error}", directory.display()))?
{
let entry = entry.map_err(|error| error.to_string())?;
let path = entry.path();
if entry
.file_type()
.map_err(|error| error.to_string())?
.is_dir()
{
pending.push(path);
continue;
}
let relative = path
.strip_prefix(source)
.map_err(|_| "native Farm artifact escaped its build root")?;
let extension = path.extension().and_then(|value| value.to_str());
let server_manifest = relative
.components()
.next()
.is_some_and(|component| component.as_os_str() == "server")
&& extension == Some("json");
if !server_manifest && !matches!(extension, Some("css" | "json")) {
continue;
}
let target = destination.join(relative);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("cannot create {}: {error}", parent.display()))?;
}
fs::copy(&path, &target).map_err(|error| {
format!(
"cannot copy Farm support artifact {} to {}: {error}",
path.display(),
target.display()
)
})?;
}
}
Ok(())
}