This commit is contained in:
fengchuanhn@gmail.com
2026-05-17 12:54:04 +08:00
parent e6c8c18be0
commit f1fce871bb
28 changed files with 2566 additions and 235 deletions

View File

@@ -8,16 +8,21 @@
//! - 进度 / 日志由用户脚本通过 `globalThis.__native.emitProgress(s)`
//! / `__native.log(level, msg, fields)` 写到 stderrRust 这边解析后
//! 转成 `PipelineEvent` 推给前端;
//! - 用户脚本的最终结果通过赋值 / `return` 的方式(约定函数名为
//! `globalThis.__nodejsMain(params)`返回runner 把 JSON.stringify 后
//! 的结果以 `__RESULT__<json>` 这一行写到 stdoutRust 抓回来反序列化。
//! - 用户脚本通过 `require('./xxx.js')` 引用同目录模块Rust 在启动前
//! 按 require 图从服务端递归拉取并写入临时 bundleNode 原生 CommonJS
//! `require` 加载(比 ESM `import` 更简单,与 `.cjs` runner 一致);
//! - 用户脚本的最终结果通过 `globalThis.__nodejsMain(params)` 返回runner
//! 把 JSON.stringify 后的结果以 `__RESULT__<json>` 写到 stdout。
//!
//! 这套协议有意保持极小:跟 QuickJS 的 `globalThis.__quickjsMain` / `__native`
//! 看起来完全对称,方便用户在两种运行时之间迁移脚本。
use std::path::PathBuf;
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};
@@ -27,6 +32,15 @@ use uuid::Uuid;
use crate::js_runtime::PipelineEvent;
/// 与后端 `nodejs_scripts._SAFE_NAME` 一致:仅允许扁平 `.js` 文件名。
static RE_SAFE_SCRIPT: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*\.js$").unwrap());
static RE_REQUIRE: Lazy<Regex> =
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/ 是否存在)")]
@@ -76,91 +90,168 @@ pub fn locate_node() -> Option<PathBuf> {
Some(PathBuf::from(exe_name()))
}
/// 用于设置 `cwd`:让用户脚本里的 `require('xxx')` 解析到
/// `resources/nodejs/node_modules/` 内置依赖
/// 用于 `NODE_PATH`:让用户脚本里的 `require('puppeteer')` 解析到
/// `resources/nodejs/node_modules/`(需在该目录执行 `npm install`
pub fn nodejs_dir() -> Option<PathBuf> {
locate_node().and_then(|p| p.parent().map(|x| x.to_path_buf()))
}
// ---------------------------------------------------------------------------
// Runner 模板
//
// HEADER 安装 __native 桥,开始 IIFE 异步函数;
// 用户脚本原样夹在中间(通常用户脚本里会赋值 globalThis.__nodejsMain
// FOOTER 调用 __nodejsMain(params)、捕获异常、写 __RESULT__。
// Runner 模板(文件模式:入口脚本与 require 依赖均落盘Node 原生 require
// ---------------------------------------------------------------------------
const RUNNER_HEADER: &str = r#"
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 {
(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({
}} 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 () => {
// 1) read params JSON from stdin (one-shot)
(async () => {{
let raw = "";
process.stdin.setEncoding("utf8");
for await (const chunk of process.stdin) raw += chunk;
const params = raw ? JSON.parse(raw) : null;
// 2) inline user script begins ↓↓↓
"#;
require("./{entry_script}");
const RUNNER_FOOTER: &str = r#"
// ↑↑↑ inline user script ends
// 3) dispatch to __nodejsMain(params)
if (typeof globalThis.__nodejsMain !== "function") {
if (typeof globalThis.__nodejsMain !== "function") {{
process.stderr.write(
"__EVT__" + JSON.stringify({
"__EVT__" + JSON.stringify({{
type: "log",
level: "error",
msg: "脚本未注册 globalThis.__nodejsMain",
}) + "\n"
}}) + "\n"
);
process.exit(1);
}
try {
}}
try {{
const result = await globalThis.__nodejsMain(params);
process.stdout.write(
"__RESULT__" + JSON.stringify(result === undefined ? null : result) + "\n"
);
process.exit(0);
} catch (e) {
}} catch (e) {{
const msg = (e && (e.stack || e.message)) || String(e);
process.stderr.write(
"__EVT__" + JSON.stringify({ type: "log", level: "error", msg }) + "\n"
"__EVT__" + JSON.stringify({{ type: "log", level: "error", msg }}) + "\n"
);
process.exit(2);
}
})();
"#;
}}
}})();
"#
)
}
fn build_runner(user_source: &str) -> String {
let mut s = String::with_capacity(
RUNNER_HEADER.len() + user_source.len() + RUNNER_FOOTER.len(),
);
s.push_str(RUNNER_HEADER);
s.push_str(user_source);
s.push_str(RUNNER_FOOTER);
s
fn normalize_script_name(name: &str) -> Option<String> {
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<String> {
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<HashMap<String, String>, NodeError> {
let entry = normalize_script_name(entry)
.ok_or_else(|| NodeError::FetchScript(format!("无效的入口脚本名: {entry}")))?;
let mut modules: HashMap<String, String> = 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<HashMap<String, String>, NodeError> {
let mut modules: HashMap<String, String> = HashMap::new();
let mut stack: Vec<String> = 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<String, String>,
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))
}
// ---------------------------------------------------------------------------
@@ -247,46 +338,59 @@ pub async fn list_scripts() -> Result<Vec<String>, NodeError> {
// 公开入口
// ---------------------------------------------------------------------------
/// 按脚本名运行:去后端拉源码 → 包装 runner → 启动 node → 收集结果。
/// 按脚本名运行:去后端拉入口及 require 依赖 → 落盘 → 启动 node → 收集结果。
pub async fn run_node_script(
script_name: String,
params: Value,
events: UnboundedSender<PipelineEvent>,
config_env: HashMap<String, String>,
) -> Result<Value, NodeError> {
let user_source = fetch_node_script_source(&script_name).await?;
run_inline_source(user_source, params, events).await
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<PipelineEvent>,
config_env: HashMap<String, String>,
) -> Result<Value, NodeError> {
run_inline_source(source, params, events).await
let modules = resolve_script_bundle_for_inline(&source).await?;
run_script_bundle(INLINE_ENTRY, modules, params, events, config_env).await
}
async fn run_inline_source(
user_source: String,
async fn run_script_bundle(
entry: &str,
modules: HashMap<String, String>,
params: Value,
events: UnboundedSender<PipelineEvent>,
config_env: HashMap<String, String>,
) -> Result<Value, NodeError> {
let node = locate_node().ok_or(NodeError::NotFound)?;
let cwd = nodejs_dir().unwrap_or_else(std::env::temp_dir);
let node_modules_cwd = nodejs_dir().unwrap_or_else(std::env::temp_dir);
// 写到 OS 临时目录而非 resources/,避免污染只读资源
let tmp_dir = std::env::temp_dir().join("aiclient-node");
tokio::fs::create_dir_all(&tmp_dir).await?;
let script_path = tmp_dir.join(format!("script-{}.cjs", Uuid::new_v4()));
tokio::fs::write(&script_path, build_runner(&user_source)).await?;
let (bundle_dir, script_path) = write_script_bundle(&modules, entry).await?;
let mut child = Command::new(&node)
.arg(&script_path)
.current_dir(&cwd)
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())
.spawn()?;
.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() {
@@ -377,7 +481,7 @@ async fn run_inline_source(
let stderr_tail = stderr_task.await.unwrap_or_default();
let result_line = stdout_task.await.unwrap_or(None);
let _ = tokio::fs::remove_file(&script_path).await;
let _ = tokio::fs::remove_dir_all(&bundle_dir).await;
if !status.success() {
return Err(NodeError::NonZero {