//! Bundled Node.js sidecar runner. //! //! 与 QuickJS 路径完全对齐: //! - 脚本源码由后端 `GET /api/v1/nodejs-scripts?name=.js` 下发, //! 约定响应形如 `{ ok: true, data: { source: "..." } }`; //! - 在 `src-tauri/resources/nodejs/node.exe`(开发期)/ 同目录 //! `resources/nodejs/node.exe`(打包后)的 Node 解释器中执行; //! - 进度 / 日志由用户脚本通过 `globalThis.__native.emitProgress(s)` //! / `__native.log(level, msg, fields)` 写到 stderr,Rust 这边解析后 //! 转成 `PipelineEvent` 推给前端; //! - 用户脚本通过 `require('./xxx.js')` 引用同目录模块;Rust 在启动前 //! 按 require 图从服务端递归拉取并写入临时 bundle,Node 原生 CommonJS //! `require` 加载(比 ESM `import` 更简单,与 `.cjs` runner 一致); //! - 用户脚本的最终结果通过 `globalThis.__nodejsMain(params)` 返回,runner //! 把 JSON.stringify 后的结果以 `__RESULT__` 写到 stdout。 //! //! 这套协议有意保持极小:跟 QuickJS 的 `globalThis.__quickjsMain` / `__native` //! 看起来完全对称,方便用户在两种运行时之间迁移脚本。 use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::Stdio; use once_cell::sync::Lazy; use regex::Regex; use serde_json::Value; use thiserror::Error; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::Command; use tokio::sync::mpsc::UnboundedSender; use uuid::Uuid; use crate::js_runtime::PipelineEvent; /// 与后端 `nodejs_scripts._SAFE_NAME` 一致:仅允许扁平 `.js` 文件名。 static RE_SAFE_SCRIPT: Lazy = Lazy::new(|| Regex::new(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*\.js$").unwrap()); static RE_REQUIRE: Lazy = Lazy::new(|| Regex::new(r#"require\s*\(\s*['"]([^'"]+\.js)['"]\s*\)"#).unwrap()); const INLINE_ENTRY: &str = "__inline_entry__.js"; #[derive(Debug, Error)] pub enum NodeError { #[error("node.exe 未找到(请检查 resources/nodejs/ 是否存在)")] NotFound, #[error("io: {0}")] Io(#[from] std::io::Error), #[error("拉取脚本失败: {0}")] FetchScript(String), #[error("node 进程非零退出 status={status:?}, stderr={stderr}")] NonZero { status: Option, stderr: String }, #[error("脚本未输出 __RESULT__ 行")] NoResult, #[error("json: {0}")] Json(#[from] serde_json::Error), } // --------------------------------------------------------------------------- // 定位 node.exe // --------------------------------------------------------------------------- fn exe_name() -> &'static str { if cfg!(windows) { "node.exe" } else { "node" } } pub fn locate_node() -> Option { if let Ok(p) = std::env::var("AICLIENT_NODE_PATH") { let pb = PathBuf::from(p); if pb.exists() { return Some(pb); } } if let Ok(exe) = std::env::current_exe() { if let Some(dir) = exe.parent() { let c = dir.join("resources").join("nodejs").join(exe_name()); if c.exists() { return Some(c); } } } let dev = PathBuf::from("src-tauri/resources/nodejs").join(exe_name()); if dev.exists() { return Some(dev); } let dev2 = PathBuf::from("resources/nodejs").join(exe_name()); if dev2.exists() { return Some(dev2); } Some(PathBuf::from(exe_name())) } /// 用于 `NODE_PATH`:让用户脚本里的 `require('puppeteer')` 等解析到 /// `resources/nodejs/node_modules/`(需在该目录执行 `npm install`)。 pub fn nodejs_dir() -> Option { locate_node().and_then(|p| p.parent().map(|x| x.to_path_buf())) } // --------------------------------------------------------------------------- // Runner 模板(文件模式:入口脚本与 require 依赖均落盘,Node 原生 require) // --------------------------------------------------------------------------- fn build_file_runner(entry_script: &str) -> String { format!( r#" // ---- aiclient auto-generated nodejs runner (do not edit) ---- "use strict"; (function () {{ function emit(obj) {{ try {{ process.stderr.write("__EVT__" + JSON.stringify(obj) + "\n"); }} catch (_) {{}} }} globalThis.__native = {{ emitProgress(status) {{ emit({{ type: "progress", status: String(status) }}); }}, log(level, msg, fields) {{ emit({{ type: "log", level: String(level || "info"), msg: String(msg), fields: fields == null ? null : fields, }}); }}, }}; }})(); (async () => {{ let raw = ""; process.stdin.setEncoding("utf8"); for await (const chunk of process.stdin) raw += chunk; const params = raw ? JSON.parse(raw) : null; require("./{entry_script}"); if (typeof globalThis.__nodejsMain !== "function") {{ process.stderr.write( "__EVT__" + JSON.stringify({{ type: "log", level: "error", msg: "脚本未注册 globalThis.__nodejsMain", }}) + "\n" ); process.exit(1); }} try {{ const result = await globalThis.__nodejsMain(params); process.stdout.write( "__RESULT__" + JSON.stringify(result === undefined ? null : result) + "\n" ); process.exit(0); }} catch (e) {{ const msg = (e && (e.stack || e.message)) || String(e); process.stderr.write( "__EVT__" + JSON.stringify({{ type: "log", level: "error", msg }}) + "\n" ); process.exit(2); }} }})(); "# ) } fn normalize_script_name(name: &str) -> Option { let base = Path::new(name.trim()) .file_name() .and_then(|s| s.to_str())?; if RE_SAFE_SCRIPT.is_match(base) { Some(base.to_string()) } else { None } } fn extract_require_deps(source: &str) -> Vec { RE_REQUIRE .captures_iter(source) .filter_map(|c| c.get(1).map(|m| m.as_str().to_string())) .collect() } /// 从入口脚本名出发,按 `require('*.js')` 递归拉取服务端模块。 async fn resolve_script_bundle(entry: &str) -> Result, NodeError> { let entry = normalize_script_name(entry) .ok_or_else(|| NodeError::FetchScript(format!("无效的入口脚本名: {entry}")))?; let mut modules: HashMap = HashMap::new(); let mut stack = vec![entry]; while let Some(name) = stack.pop() { if modules.contains_key(&name) { continue; } let source = fetch_node_script_source(&name).await?; for dep in extract_require_deps(&source) { if let Some(dep_name) = normalize_script_name(&dep) { if !modules.contains_key(&dep_name) { stack.push(dep_name); } } } modules.insert(name, source); } Ok(modules) } /// 调试源码:仅拉取其中 require 到的服务端模块,入口为内存写入的 `__inline_entry__.js`。 async fn resolve_script_bundle_for_inline( inline_source: &str, ) -> Result, NodeError> { let mut modules: HashMap = HashMap::new(); let mut stack: Vec = extract_require_deps(inline_source) .into_iter() .filter_map(|d| normalize_script_name(&d)) .collect(); while let Some(name) = stack.pop() { if modules.contains_key(&name) { continue; } let source = fetch_node_script_source(&name).await?; for dep in extract_require_deps(&source) { if let Some(dep_name) = normalize_script_name(&dep) { if !modules.contains_key(&dep_name) { stack.push(dep_name); } } } modules.insert(name, source); } modules.insert(INLINE_ENTRY.to_string(), inline_source.to_string()); Ok(modules) } async fn write_script_bundle( modules: &HashMap, entry: &str, ) -> Result<(PathBuf, PathBuf), NodeError> { let tmp_dir = std::env::temp_dir().join("aiclient-node"); tokio::fs::create_dir_all(&tmp_dir).await?; let bundle_dir = tmp_dir.join(format!("bundle-{}", Uuid::new_v4())); tokio::fs::create_dir_all(&bundle_dir).await?; for (name, source) in modules { tokio::fs::write(bundle_dir.join(name), source).await?; } let runner_path = bundle_dir.join("_runner.cjs"); let runner_src = build_file_runner(entry); tokio::fs::write(&runner_path, runner_src).await?; Ok((bundle_dir, runner_path)) } // --------------------------------------------------------------------------- // 从后端拉脚本源码 // --------------------------------------------------------------------------- fn api_base() -> String { std::env::var("AICLIENT_API_BASE").unwrap_or_else(|_| "http://127.0.0.1:8001".to_string()) } async fn fetch_node_script_source(script_name: &str) -> Result { let q = urlencoding::encode(script_name); let path = format!("/nodejs-scripts?name={q}"); let (_status, v) = crate::api_client::request_json("GET", &path, None, HashMap::new()) .await .map_err(|e| NodeError::FetchScript(e))?; if !v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false) { let msg = v .get("message") .and_then(|x| x.as_str()) .unwrap_or("未知错误"); return Err(NodeError::FetchScript(msg.to_string())); } let source = v .get("data") .and_then(|d| d.get("source")) .and_then(|s| s.as_str()) .ok_or_else(|| NodeError::FetchScript("响应缺少 data.source".into()))?; Ok(source.to_string()) } /// 后端 `scripts/nodejs` 目录下的 `.js` 文件名列表。 pub async fn list_scripts() -> Result, NodeError> { let (_status, v) = crate::api_client::request_json("GET", "/nodejs-scripts/list", None, HashMap::new()) .await .map_err(|e| NodeError::FetchScript(e))?; if !v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false) { let msg = v .get("message") .and_then(|x| x.as_str()) .unwrap_or("未知错误"); return Err(NodeError::FetchScript(msg.to_string())); } let names = v .get("data") .and_then(|d| d.get("names")) .and_then(|n| n.as_array()) .map(|arr| { arr.iter() .filter_map(|x| x.as_str().map(|s| s.to_string())) .collect::>() }) .unwrap_or_default(); Ok(names) } // --------------------------------------------------------------------------- // 公开入口 // --------------------------------------------------------------------------- /// 按脚本名运行:去后端拉入口及 require 依赖 → 落盘 → 启动 node → 收集结果。 pub async fn run_node_script( script_name: String, params: Value, events: UnboundedSender, config_env: HashMap, ) -> Result { let entry = normalize_script_name(&script_name) .ok_or_else(|| NodeError::FetchScript(format!("无效的脚本名称: {script_name}")))?; let modules = resolve_script_bundle(&entry).await?; run_script_bundle(&entry, modules, params, events, config_env).await } /// 调试入口:直接传源码;其中 `require('*.js')` 仍从服务端拉取依赖。 pub async fn run_node_script_source( source: String, params: Value, events: UnboundedSender, config_env: HashMap, ) -> Result { let modules = resolve_script_bundle_for_inline(&source).await?; run_script_bundle(INLINE_ENTRY, modules, params, events, config_env).await } async fn run_script_bundle( entry: &str, modules: HashMap, params: Value, events: UnboundedSender, config_env: HashMap, ) -> Result { let node = locate_node().ok_or(NodeError::NotFound)?; let node_modules_cwd = nodejs_dir().unwrap_or_else(std::env::temp_dir); let (bundle_dir, script_path) = write_script_bundle(&modules, entry).await?; let mut cmd = Command::new(&node); cmd.arg(&script_path) .current_dir(&bundle_dir) .env( "NODE_PATH", node_modules_cwd .join("node_modules") .to_string_lossy() .to_string(), ) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); for (key, value) in config_env { cmd.env(key, value); } let mut child = cmd.spawn()?; // 把 params JSON 灌进 stdin if let Some(mut stdin) = child.stdin.take() { let json = serde_json::to_vec(¶ms)?; stdin.write_all(&json).await?; stdin.shutdown().await?; } // stderr → 解析 __EVT__ → PipelineEvent let stderr = child.stderr.take().expect("stderr piped"); let events_tx = events.clone(); let stderr_task = tokio::spawn(async move { let mut reader = BufReader::new(stderr).lines(); let mut tail = String::new(); while let Ok(Some(line)) = reader.next_line().await { if let Some(rest) = line.strip_prefix("__EVT__") { if let Ok(v) = serde_json::from_str::(rest) { let typ = v.get("type").and_then(|x| x.as_str()).unwrap_or(""); match typ { "progress" => { let s = v .get("status") .and_then(|x| x.as_str()) .unwrap_or("") .to_string(); let _ = events_tx.send(PipelineEvent::Progress(s)); } "log" => { let level = v .get("level") .and_then(|x| x.as_str()) .unwrap_or("info") .to_string(); let msg = v .get("msg") .and_then(|x| x.as_str()) .unwrap_or("") .to_string(); let fields = v.get("fields").cloned().filter(|x| !x.is_null()); let _ = events_tx.send(PipelineEvent::Log { level, msg, fields }); } _ => { // 兜底:未知 __EVT__ 类型也当 log 抛出 let _ = events_tx.send(PipelineEvent::Log { level: "info".into(), msg: rest.to_string(), fields: None, }); } } } } else { // 纯文本 stderr(比如 node 自己打印的 warning) → 当 info log let _ = events_tx.send(PipelineEvent::Log { level: "warn".into(), msg: line.clone(), fields: None, }); tail.push_str(&line); tail.push('\n'); } } tail }); // stdout → 找 __RESULT__ 那一行 let stdout = child.stdout.take().expect("stdout piped"); let events_tx2 = events.clone(); let stdout_task = tokio::spawn(async move { let mut reader = BufReader::new(stdout).lines(); let mut result: Option = None; while let Ok(Some(line)) = reader.next_line().await { if let Some(rest) = line.strip_prefix("__RESULT__") { result = Some(rest.to_string()); } else if !line.is_empty() { // 用户 console.log 的内容也透传给前端 let _ = events_tx2.send(PipelineEvent::Log { level: "info".into(), msg: line, fields: None, }); } } result }); let status = child.wait().await?; let stderr_tail = stderr_task.await.unwrap_or_default(); let result_line = stdout_task.await.unwrap_or(None); let _ = tokio::fs::remove_dir_all(&bundle_dir).await; if !status.success() { return Err(NodeError::NonZero { status: status.code(), stderr: stderr_tail, }); } let result_str = result_line.ok_or(NodeError::NoResult)?; let v: Value = serde_json::from_str(&result_str)?; Ok(v) }