11
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,3 +23,4 @@ dist-ssr
|
|||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
src-tauri/binaries/
|
src-tauri/binaries/
|
||||||
|
src-tauri/resources/nodejs/
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod nodejs;
|
||||||
pub mod quickjs;
|
pub mod quickjs;
|
||||||
|
|||||||
120
src-tauri/src/commands/nodejs.rs
Normal file
120
src-tauri/src/commands/nodejs.rs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
//! Node.js 流水线命令:脚本由后端 `/api/v1/nodejs-scripts` 下发,
|
||||||
|
//! 由打包在 `resources/nodejs/node.exe` 的 Node 解释器执行。
|
||||||
|
//!
|
||||||
|
//! 前端用法示例:
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! import { invoke } from "@tauri-apps/api/core";
|
||||||
|
//! import { listen } from "@tauri-apps/api/event";
|
||||||
|
//!
|
||||||
|
//! const off = await listen("nodejs:event", (e) => console.log(e.payload));
|
||||||
|
//! const result = await invoke("run_nodejs_script", {
|
||||||
|
//! scriptName: "douyin_pipeline.js",
|
||||||
|
//! params: { shareText: "...", modelConfig: {...} },
|
||||||
|
//! });
|
||||||
|
//! off();
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! 与 QuickJS 流水线对比:
|
||||||
|
//! - 入口约定:`globalThis.__nodejsMain(params)`(QuickJS 那边是 `__quickjsMain`)
|
||||||
|
//! - 事件通道:`nodejs:event`(QuickJS 那边是 `quickjs:event`),payload 形状一致
|
||||||
|
//! - __native 只暴露 `emitProgress` / `log` 两个能力,其它 IO / 网络
|
||||||
|
//! 直接用 Node 自带 API 即可,无需 Rust 端再封一遍桥。
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
use tauri::{AppHandle, Emitter, Window};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
use crate::js_runtime::PipelineEvent;
|
||||||
|
use crate::nodejs;
|
||||||
|
|
||||||
|
const EVENT_NAME: &str = "nodejs:event";
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Clone)]
|
||||||
|
#[serde(tag = "type", rename_all = "camelCase")]
|
||||||
|
pub enum FrontendEvent {
|
||||||
|
Progress {
|
||||||
|
#[serde(rename = "scriptName")]
|
||||||
|
script_name: String,
|
||||||
|
status: String,
|
||||||
|
},
|
||||||
|
Log {
|
||||||
|
#[serde(rename = "scriptName")]
|
||||||
|
script_name: String,
|
||||||
|
level: String,
|
||||||
|
msg: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
fields: Option<Value>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_event_pump(
|
||||||
|
app: AppHandle,
|
||||||
|
window: Window,
|
||||||
|
script_name: String,
|
||||||
|
mut rx: mpsc::UnboundedReceiver<PipelineEvent>,
|
||||||
|
) -> tokio::task::JoinHandle<()> {
|
||||||
|
let win_label = window.label().to_string();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(ev) = rx.recv().await {
|
||||||
|
let payload = match ev {
|
||||||
|
PipelineEvent::Progress(s) => FrontendEvent::Progress {
|
||||||
|
script_name: script_name.clone(),
|
||||||
|
status: s,
|
||||||
|
},
|
||||||
|
PipelineEvent::Log { level, msg, fields } => FrontendEvent::Log {
|
||||||
|
script_name: script_name.clone(),
|
||||||
|
level,
|
||||||
|
msg,
|
||||||
|
fields,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let _ = app.emit_to(&win_label, EVENT_NAME, payload);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按脚本名跑:去后端拉 `scriptName`,包装 runner,spawn 子进程,回送结果。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn run_nodejs_script(
|
||||||
|
app: AppHandle,
|
||||||
|
window: Window,
|
||||||
|
script_name: String,
|
||||||
|
params: Value,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let (tx, rx) = mpsc::unbounded_channel::<PipelineEvent>();
|
||||||
|
let pump = spawn_event_pump(app, window, script_name.clone(), rx);
|
||||||
|
|
||||||
|
let result = nodejs::run_node_script(script_name, params, tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
pump.await.ok();
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 调试:直接把传入的 source 当用户脚本喂给 runner,不走后端。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn run_nodejs_script_source(
|
||||||
|
app: AppHandle,
|
||||||
|
window: Window,
|
||||||
|
script_source: String,
|
||||||
|
params: Value,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let (tx, rx) = mpsc::unbounded_channel::<PipelineEvent>();
|
||||||
|
let pump = spawn_event_pump(app, window, "<debug-source>".to_string(), rx);
|
||||||
|
|
||||||
|
let result = nodejs::run_node_script_source(script_source, params, tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
pump.await.ok();
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列出后端 `scripts/nodejs` 目录下的 `.js` 文件名。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_nodejs_scripts() -> Result<Vec<String>, String> {
|
||||||
|
nodejs::list_scripts().await.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ pub mod commands;
|
|||||||
pub mod ffmpeg;
|
pub mod ffmpeg;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
pub mod js_runtime;
|
pub mod js_runtime;
|
||||||
|
pub mod nodejs;
|
||||||
pub mod oss;
|
pub mod oss;
|
||||||
|
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
@@ -28,6 +29,9 @@ pub fn run() {
|
|||||||
commands::quickjs::run_quickjs_script,
|
commands::quickjs::run_quickjs_script,
|
||||||
commands::quickjs::run_quickjs_script_source,
|
commands::quickjs::run_quickjs_script_source,
|
||||||
commands::quickjs::list_quickjs_scripts,
|
commands::quickjs::list_quickjs_scripts,
|
||||||
|
commands::nodejs::run_nodejs_script,
|
||||||
|
commands::nodejs::run_nodejs_script_source,
|
||||||
|
commands::nodejs::list_nodejs_scripts,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
392
src-tauri/src/nodejs.rs
Normal file
392
src-tauri/src/nodejs.rs
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
//! Bundled Node.js sidecar runner.
|
||||||
|
//!
|
||||||
|
//! 与 QuickJS 路径完全对齐:
|
||||||
|
//! - 脚本源码由后端 `GET /api/v1/nodejs-scripts?name=<file>.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` 推给前端;
|
||||||
|
//! - 用户脚本的最终结果通过赋值 / `return` 的方式(约定函数名为
|
||||||
|
//! `globalThis.__nodejsMain(params)`)返回,runner 把 JSON.stringify 后
|
||||||
|
//! 的结果以 `__RESULT__<json>` 这一行写到 stdout,Rust 抓回来反序列化。
|
||||||
|
//!
|
||||||
|
//! 这套协议有意保持极小:跟 QuickJS 的 `globalThis.__quickjsMain` / `__native`
|
||||||
|
//! 看起来完全对称,方便用户在两种运行时之间迁移脚本。
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Stdio;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
#[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<i32>, 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<PathBuf> {
|
||||||
|
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()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用于设置 `cwd`:让用户脚本里的 `require('xxx')` 能解析到
|
||||||
|
/// `resources/nodejs/node_modules/` 内置依赖。
|
||||||
|
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__。
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const RUNNER_HEADER: &str = 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 () => {
|
||||||
|
// 1) read params JSON from stdin (one-shot)
|
||||||
|
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 ↓↓↓
|
||||||
|
"#;
|
||||||
|
|
||||||
|
const RUNNER_FOOTER: &str = r#"
|
||||||
|
// ↑↑↑ inline user script ends
|
||||||
|
|
||||||
|
// 3) dispatch to __nodejsMain(params)
|
||||||
|
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 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 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<String, NodeError> {
|
||||||
|
let base = api_base().trim_end_matches('/').to_string();
|
||||||
|
let q = urlencoding::encode(script_name);
|
||||||
|
let url = format!("{base}/api/v1/nodejs-scripts?name={q}");
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(120))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| NodeError::FetchScript(e.to_string()))?;
|
||||||
|
let res = client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| NodeError::FetchScript(e.to_string()))?;
|
||||||
|
let body = res
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| NodeError::FetchScript(e.to_string()))?;
|
||||||
|
let v: Value = serde_json::from_str(&body)
|
||||||
|
.map_err(|e| NodeError::FetchScript(format!("响应非 JSON: {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<Vec<String>, NodeError> {
|
||||||
|
let base = api_base().trim_end_matches('/').to_string();
|
||||||
|
let url = format!("{base}/api/v1/nodejs-scripts/list");
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| NodeError::FetchScript(e.to_string()))?;
|
||||||
|
let res = client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| NodeError::FetchScript(e.to_string()))?;
|
||||||
|
let body = res
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| NodeError::FetchScript(e.to_string()))?;
|
||||||
|
let v: Value = serde_json::from_str(&body)
|
||||||
|
.map_err(|e| NodeError::FetchScript(format!("响应非 JSON: {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::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok(names)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 公开入口
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 按脚本名运行:去后端拉源码 → 包装 runner → 启动 node → 收集结果。
|
||||||
|
pub async fn run_node_script(
|
||||||
|
script_name: String,
|
||||||
|
params: Value,
|
||||||
|
events: UnboundedSender<PipelineEvent>,
|
||||||
|
) -> Result<Value, NodeError> {
|
||||||
|
let user_source = fetch_node_script_source(&script_name).await?;
|
||||||
|
run_inline_source(user_source, params, events).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 调试入口:直接传源码字符串,跳过后端拉取。
|
||||||
|
pub async fn run_node_script_source(
|
||||||
|
source: String,
|
||||||
|
params: Value,
|
||||||
|
events: UnboundedSender<PipelineEvent>,
|
||||||
|
) -> Result<Value, NodeError> {
|
||||||
|
run_inline_source(source, params, events).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_inline_source(
|
||||||
|
user_source: String,
|
||||||
|
params: Value,
|
||||||
|
events: UnboundedSender<PipelineEvent>,
|
||||||
|
) -> Result<Value, NodeError> {
|
||||||
|
let node = locate_node().ok_or(NodeError::NotFound)?;
|
||||||
|
let 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 mut child = Command::new(&node)
|
||||||
|
.arg(&script_path)
|
||||||
|
.current_dir(&cwd)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.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__<json> → 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::<Value>(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<String> = 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_file(&script_path).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)
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"resources": [
|
"resources": [
|
||||||
|
"resources/**/*",
|
||||||
"binaries/*"
|
"binaries/*"
|
||||||
],
|
],
|
||||||
"active": true,
|
"active": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user