use crate::project::{self, ProjectBuildOptions};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
pub fn run(mut arguments: impl Iterator<Item = String>) -> Result<(), String> {
let operation = arguments.next().ok_or("noxid task requires `run <name>`")?;
if operation != "run" {
return Err(format!(
"unknown noxid task operation `{operation}`; expected `run <name>`"
));
}
let name = arguments
.next()
.ok_or("noxid task run requires a task name")?;
if arguments.next().is_some() {
return Err("noxid task run accepts exactly one task name".into());
}
let project = std::env::current_dir()
.map_err(|error| format!("cannot resolve the current project directory: {error}"))?;
run_in(&project, &name)
}
pub(crate) fn run_in(project_root: &Path, name: &str) -> Result<(), String> {
if !project::task_schedules(project_root)?
.iter()
.any(|(task, _)| task == name)
{
return Err(format!(
"error[TASK_NOT_FOUND]: no scheduled task `{name}` is declared under server/tasks"
));
}
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|error| error.to_string())?
.as_nanos();
let output =
std::env::temp_dir().join(format!("noxid-task-run-{}-{nonce}", std::process::id()));
let _cleanup = TemporaryBuild(output.clone());
project::build_project(
project_root,
&ProjectBuildOptions {
out_dir: output.clone(),
title: None,
development: true,
strict_npm: false,
},
)?;
let handler = output.join("server/handler.js");
if !handler.is_file() {
return Err(
"error[TASK_HANDLER_MISSING]: task build did not emit server/handler.js".into(),
);
}
let base = project::base_path(project_root)?;
let prefix = if base == "/" {
String::new()
} else {
base.trim_end_matches('/').to_string()
};
let url = format!(
"http://noxid.local{prefix}/_noxid/tasks/{}",
percent_encode_path_segment(name)
);
let script = r#"const [handlerPath, taskUrl] = process.argv.slice(1);
const { pathToFileURL } = await import("node:url");
const { fetch: handle } = await import(pathToFileURL(handlerPath).href);
const response = await handle(new Request(taskUrl, { method: "POST" }), process.env, Object.create(null));
const body = await response.text();
if (!response.ok) { process.stderr.write(body + "\n"); process.exitCode = 1; }
else process.stdout.write(body + "\n");
"#;
let result = Command::new("node")
.args(["--input-type=module", "-e", script])
.arg(&handler)
.arg(&url)
.current_dir(project_root)
.output()
.map_err(|error| format!("cannot start Node.js task runner: {error}"))?;
if !result.status.success() {
return Err(format!(
"task `{name}` was refused:\n{}",
String::from_utf8_lossy(&result.stderr).trim()
));
}
print!("{}", String::from_utf8_lossy(&result.stdout));
Ok(())
}
fn percent_encode_path_segment(value: &str) -> String {
let mut encoded = String::new();
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
encoded.push(char::from(byte));
} else {
encoded.push_str(&format!("%{byte:02X}"));
}
}
encoded
}
struct TemporaryBuild(PathBuf);
impl Drop for TemporaryBuild {
fn drop(&mut self) {
if self.0.starts_with(std::env::temp_dir()) {
let _ = fs::remove_dir_all(&self.0);
}
}
}