use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::language::{
COMPILE_TARGET_BACKEND, CompileOptions, CompiledProgram, ExecutionContext, LanguageEngine, ModuleSource, SourceKind,
};
use crate::pipeline::model::{LayoutItem, NodeCapability, NodeExample, NodeFieldDef, NodeFieldType, SidebarItem, SidebarSection};
use crate::pipeline::{
NodeDefinition, PipelineError,
nodes::{NodeExecutionInput, NodeExecutionOutput, NodeHandler},
};
pub const INPUT_PIN_IN: &str = "in";
pub const OUTPUT_PIN_OUT: &str = "out";
pub const ANSWER_KEY: &str = "script";
#[derive(Debug)]
pub struct Language {
pub kind: &'static str,
pub title: &'static str,
pub name: &'static str,
pub editor: &'static str,
pub run_code: &'static str,
pub config_code: &'static str,
pub parse_code: &'static str,
pub rejected_code: &'static str,
pub compile_code: &'static str,
pub input_pin_code: &'static str,
}
pub fn definition(language: &Language) -> NodeDefinition {
let kind = language.kind;
NodeDefinition {
kind: kind.to_string(),
capabilities: vec![NodeCapability::Filesystem, NodeCapability::Process],
title: language.title.to_string(),
description: format!(
"Runs {name} in a sandbox and adds what it returns as `script`. The body after `--` is the function body of `async function(input, n, ctx)`, and whatever it \
`return`s is added to the payload as `script` — the rest is kept, so the next node reads `input.script.x`. `input` is the \
current payload (right after a webhook, `input.webhook.body.x`; the request anywhere, `$trigger.body.x`); \
`ctx.trigger.params/query/auth` is the request and `ctx.nodes.<id>` an earlier node's output. It cannot set a status or header \
(`web.response.send` does), `return null` does not stop the run (`logic.if` does), and `fetch`, `setTimeout`, `require` and \
`import` are blocked (`http.response.fetch` calls out). Keep it to shaping data: compose, rename, compute — a run has 1 s and \
its return 256 KB (`DenoSandboxError: timeout exceeded` means do less here or split the work). A `__signal` key in \
the returned object is stripped and shown as live progress in the Studio; `__zf_files` stores files for the run.",
name = language.name
),
input_schema: json!({ "type": "object", "description": "Upstream payload available as `input`; it is kept and `script` is added." }),
output_schema: json!({ "type": "object", "properties": { "script": { "description": "What the code returned." } } }),
input_pins: vec![INPUT_PIN_IN.to_string()],
output_pins: vec![OUTPUT_PIN_OUT.to_string()],
script_available: false,
script_bridge: None,
config_schema: Default::default(),
dsl_flags: vec![],
fields: vec![NodeFieldDef {
name: "source".to_string(),
label: "Source".to_string(),
field_type: NodeFieldType::CodeEditor,
language: Some(language.editor.to_string()),
span: Some("full".to_string()),
help: Some("The function body. What it returns is added as `script`.".to_string()),
default_value: Some(json!("return input;")),
sidebar: sidebar(),
..Default::default()
}],
layout: vec![LayoutItem::Field("source".to_string())],
ai_tool: crate::pipeline::model::NodeAiToolDefinition {
registered: true,
tool_name: format!("run_{}", kind.split('.').next().unwrap_or("javascript")),
tool_description: format!("Execute a {} snippet in the Deno sandbox. Args: code (required).", language.name),
tool_input_schema: json!({
"type": "object",
"properties": { "code": { "type": "string", "description": format!("{} code to execute", language.name) } },
"required": ["code"]
}),
},
examples: vec![
NodeExample::dsl("Shape rows for a page", &format!(r#"{kind} -- "return {{ posts: input.query.rows.map(r => ({{ ...r, when: r.created_at.slice(0, 10) }})), total: input.query.row_count }}""#))
.input(json!({ "query": { "rows": [{ "title": "Hi", "created_at": "2026-09-13T04:00:00Z" }], "row_count": 1 } }))
.output(json!({ "query": { "rows": [{ "title": "Hi", "created_at": "2026-09-13T04:00:00Z" }], "row_count": 1 }, "script": { "posts": [{ "title": "Hi", "created_at": "2026-09-13T04:00:00Z", "when": "2026-09-13" }], "total": 1 } }))
.note("The next node reads `input.script.posts`."),
NodeExample::dsl("Combine two earlier nodes", &format!(r#"{kind} -- "return {{ user: $nodes.b.query.rows[0], orders: $nodes.c.query.rows }}""#))
.note("Anywhere in graph mode downstream of `b` and `c`: wired from both, it runs once, when both have answered (a `$nodes` reference must point upstream)."),
],
..Default::default()
}
}
fn sidebar() -> Vec<SidebarSection> {
vec![
SidebarSection {
title: "Input".to_string(),
items: vec![
SidebarItem { label: "input".to_string(), type_hint: Some("any".to_string()), description: Some("Upstream payload from the previous node.".to_string()) },
],
},
SidebarSection {
title: "Context".to_string(),
items: vec![
SidebarItem { label: "ctx.pipeline".to_string(), type_hint: Some("string".to_string()), description: Some("Current pipeline id.".to_string()) },
SidebarItem { label: "ctx.request_id".to_string(), type_hint: Some("string".to_string()), description: Some("Unique execution request id.".to_string()) },
SidebarItem { label: "ctx.nodes".to_string(), type_hint: Some("object".to_string()), description: Some("Map of all previous node outputs. Access by node id: ctx.nodes['a'].".to_string()) },
SidebarItem { label: "ctx.placeholder".to_string(), type_hint: Some("object".to_string()), description: Some("Resolved placeholder values (credentials, config).".to_string()) },
],
},
SidebarSection {
title: "Trigger".to_string(),
items: vec![
SidebarItem { label: "ctx.trigger".to_string(), type_hint: Some("object|null".to_string()), description: Some("Full trigger event snapshot from the entry node.".to_string()) },
SidebarItem { label: "ctx.trigger.auth".to_string(), type_hint: Some("object|null".to_string()), description: Some("Verified JWT claims — immutable across the pipeline.".to_string()) },
SidebarItem { label: "ctx.trigger.params".to_string(), type_hint: Some("object".to_string()), description: Some("URL path params (:id etc) from the request.".to_string()) },
SidebarItem { label: "ctx.trigger.query".to_string(), type_hint: Some("object".to_string()), description: Some("Query string params from the request.".to_string()) },
SidebarItem { label: "ctx.trigger.headers".to_string(), type_hint: Some("object".to_string()), description: Some("Safe subset of request headers.".to_string()) },
],
},
SidebarSection {
title: "Return".to_string(),
items: vec![
SidebarItem { label: "return value".to_string(), type_hint: Some("any".to_string()), description: Some("Added to the payload as `script`; downstream reads input.script.".to_string()) },
SidebarItem { label: "__signal".to_string(), type_hint: Some("string|object|array".to_string()), description: Some("Optional key in return value for real-time signals. Stripped before downstream.".to_string()) },
],
},
SidebarSection {
title: "Built-ins".to_string(),
items: vec![
SidebarItem { label: "console.log(...)".to_string(), type_hint: Some("void".to_string()), description: Some("Log to pipeline trace output.".to_string()) },
SidebarItem { label: "n.time.now()".to_string(), type_hint: Some("number".to_string()), description: Some("Current Unix timestamp in milliseconds.".to_string()) },
SidebarItem { label: "n.math.imul(a, b)".to_string(), type_hint: Some("number".to_string()), description: Some("32-bit integer multiply.".to_string()) },
SidebarItem { label: "n.math.u32(v)".to_string(), type_hint: Some("number".to_string()), description: Some("Coerce to an unsigned 32-bit integer.".to_string()) },
],
},
]
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub source: String,
}
pub struct Node {
node_id: String,
compiled: CompiledProgram,
language: &'static Language,
engine: std::sync::Arc<dyn LanguageEngine>,
}
impl Node {
pub fn build(
node_id: &str,
config: &Value,
language: &'static Language,
engine: std::sync::Arc<dyn LanguageEngine>,
) -> Result<Self, PipelineError> {
let config: Config =
serde_json::from_value(config.clone()).map_err(|err| PipelineError::new(language.config_code, err.to_string()))?;
if config.source.trim().is_empty() {
return Err(PipelineError::new(language.config_code, format!("node '{node_id}' has no source; write it after --")));
}
let module = ModuleSource {
id: format!("pipeline:{node_id}"),
source_path: None,
kind: SourceKind::Tsx,
code: config.source,
};
let ir = engine
.parse(&module)
.map_err(|err| PipelineError::new(language.parse_code, format!("node '{node_id}': {err}")))?;
let compiled = engine
.compile(
&ir,
&CompileOptions { target: COMPILE_TARGET_BACKEND.to_string(), optimize_level: 1, emit_trace_hints: true },
)
.map_err(|err| {
PipelineError::new(
crate::pipeline::error_class::wrapper_for(&err.code, language.rejected_code, language.compile_code),
format!("node '{node_id}': {err}"),
)
})?;
Ok(Self { node_id: node_id.to_string(), compiled, language, engine })
}
}
const ENGINE_KEYS: &[&str] = &["__signal", "__zf_files"];
pub fn answer(payload: &Value, mut returned: Value) -> Value {
let lifted: Vec<(&str, Value)> = ENGINE_KEYS
.iter()
.filter_map(|key| returned.as_object_mut().and_then(|map| map.remove(*key)).map(|value| (*key, value)))
.collect();
let mut answer = json!({ ANSWER_KEY: returned });
for (key, value) in lifted {
answer[key] = value;
}
crate::pipeline::nodes::shared::util::with_answer(payload, answer)
}
#[async_trait]
impl NodeHandler for Node {
fn kind(&self) -> &'static str {
self.language.kind
}
fn input_pins(&self) -> &'static [&'static str] {
&[INPUT_PIN_IN]
}
fn output_pins(&self) -> &'static [&'static str] {
&[OUTPUT_PIN_OUT]
}
async fn execute_async(&self, input: NodeExecutionInput) -> Result<NodeExecutionOutput, PipelineError> {
if input.input_pin != INPUT_PIN_IN {
return Err(PipelineError::new(
self.language.input_pin_code,
format!("unsupported input pin '{}'", input.input_pin),
));
}
let read = |key: &str| input.metadata.get(key).and_then(Value::as_str).unwrap_or_default().to_string();
let ctx = ExecutionContext {
project: read("project"),
pipeline: read("pipeline"),
request_id: read("request_id"),
trigger: input.metadata.get("trigger").cloned().unwrap_or(Value::Null),
metadata: input.metadata.clone(),
};
let out = self
.engine
.run(&self.compiled, input.payload.clone(), &ctx)
.map_err(|err| PipelineError::new(self.language.run_code, format!("node '{}': {}", self.node_id, err)))?;
let mut trace = vec![format!("node_kind={}", self.language.kind)];
trace.extend(out.trace);
Ok(NodeExecutionOutput {
output_pins: vec![OUTPUT_PIN_OUT.to_string()],
payload: answer(&input.payload, out.value),
trace,
})
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
#[test]
fn the_return_is_script_and_the_payload_is_kept() {
assert_eq!(super::answer(&json!({ "kept": 1 }), json!({ "a": 2 })), json!({ "kept": 1, "script": { "a": 2 } }));
assert_eq!(super::answer(&json!({ "kept": 1 }), json!([1, 2])), json!({ "kept": 1, "script": [1, 2] }));
assert_eq!(super::answer(&json!("bare"), json!(3)), json!({ "script": 3 }));
assert_eq!(
super::answer(&json!({}), json!({ "a": 1, "__signal": "half", "__zf_files": [] })),
json!({ "script": { "a": 1 }, "__signal": "half", "__zf_files": [] })
);
}
}