use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::pipeline::model::NodeCapability;
use crate::pipeline::model::{DslFlag, DslFlagKind, NodeFieldDef, NodeFieldType, SelectOptionDef};
use crate::pipeline::{
NodeDefinition, PipelineError,
nodes::{NodeExecutionInput, NodeExecutionOutput, NodeHandler},
};
pub const NODE_KIND: &str = "trigger.socket";
const OUTPUT_PIN_OUT: &str = "out";
pub const ANSWER_KEY: &str = "socket";
pub const CONFIG_CODE: &str = "FW_NODE_TRIGGER_SOCKET_CONFIG";
pub const PARSE_WORDS: [&str; 2] = ["json", "text"];
pub const DEFAULT_DELAY: &str = "5s";
pub const DEFAULT_HEARTBEAT: &str = "30s";
fn flag(name: &str, key: &str, description: &str, value: &str) -> DslFlag {
DslFlag {
flag: name.to_string(),
config_key: key.to_string(),
description: description.to_string(),
kind: DslFlagKind::Scalar,
value: value.to_string(),
..Default::default()
}
}
pub fn definition() -> NodeDefinition {
NodeDefinition {
kind: NODE_KIND.to_string(),
capabilities: vec![NodeCapability::Network, NodeCapability::Credential],
title: "Socket Trigger".to_string(),
description: "Runs the pipeline for every message from an outbound WebSocket. It keeps an outbound WebSocket connection to another server open while the pipeline is active and runs the pipeline \
for every message it receives — price feeds, a broker, another Zebflow. `--url` is `ws://` or `wss://`; `--credential` \
supplies auth if the server needs it. A socket reconnects with backoff starting at `--delay` (default 5s); `--max-attempts` \
bounds it (omitted = unlimited, 0 = never reconnect, N = at most N reconnects). Answers one key, `socket`: \
`{ url, message, node_id }` — the message is `input.socket.message` (`$trigger.message`), parsed \
JSON with `--parse json` (default), a string with `text`. To send back on the same connection use \
`ws.message.send --connection <this node id>`. Not for browsers: that is `trigger.room`."
.to_string(),
input_schema: json!({ "type": "object" }),
output_schema: json!({
"type": "object",
"properties": {
"socket": {
"type": "object",
"properties": {
"url": { "type": "string" },
"node_id": { "type": "string" },
"message": { "description": "Received WebSocket message payload." }
}
}
}
}),
input_pins: vec![],
output_pins: vec![OUTPUT_PIN_OUT.to_string()],
script_available: false,
script_bridge: None,
config_schema: json!({
"type": "object",
"required": ["url"],
"properties": {
"url": { "type": "string", "description": "WebSocket server URL (ws:// or wss://)." },
"credential_id": { "type": "string", "description": "Credential for auth headers/tokens." },
"delay": { "type": "string", "description": "First reconnect delay, a duration (5s); backoff doubles it up to 60s. Default: 5s." },
"max_attempts": { "type": "integer", "description": "Reconnects allowed: omitted = unlimited, 0 = never reconnect, N = at most N." },
"heartbeat": { "type": "string", "description": "Ping interval, a duration (30s). Default: 30s." },
"parse": { "type": "string", "enum": PARSE_WORDS, "description": "How a message is read: json (default) or text." }
}
}),
dsl_flags: vec![
DslFlag { required: true, ..flag("--url", "url", "WebSocket server URL (ws:// or wss://).", "text") },
flag("--credential", "credential_id", "Credential id for auth headers/tokens.", "text"),
flag("--delay", "delay", "First reconnect delay (`5s`, `500ms`); backoff doubles it up to 60s. Default: 5s.", "duration"),
flag("--max-attempts", "max_attempts", "Reconnects allowed: omitted = unlimited, 0 = never reconnect, N = at most N.", "number"),
flag("--heartbeat", "heartbeat", "Ping interval (`30s`). Default: 30s.", "duration"),
DslFlag {
choices: PARSE_WORDS.iter().map(|w| w.to_string()).collect(),
..flag("--parse", "parse", "How a received message is read: json (default) or text.", "text")
},
],
fields: vec![
NodeFieldDef {
name: "url".to_string(),
label: "URL".to_string(),
field_type: NodeFieldType::Text,
help: Some("WebSocket server URL (ws:// or wss://).".to_string()),
..Default::default()
},
NodeFieldDef {
name: "credential_id".to_string(),
label: "Credential".to_string(),
field_type: NodeFieldType::Text,
help: Some("Credential ID for auth headers/tokens.".to_string()),
..Default::default()
},
NodeFieldDef {
name: "delay".to_string(),
label: "Reconnect delay".to_string(),
field_type: NodeFieldType::Text,
placeholder: Some(DEFAULT_DELAY.to_string()),
help: Some("First reconnect delay, a duration (5s); backoff doubles it up to 60s.".to_string()),
..Default::default()
},
NodeFieldDef {
name: "max_attempts".to_string(),
label: "Max attempts".to_string(),
field_type: NodeFieldType::Number,
help: Some("Reconnects allowed. Empty: unlimited; 0: never reconnect.".to_string()),
..Default::default()
},
NodeFieldDef {
name: "heartbeat".to_string(),
label: "Heartbeat".to_string(),
field_type: NodeFieldType::Text,
placeholder: Some(DEFAULT_HEARTBEAT.to_string()),
help: Some("Ping interval, a duration (30s).".to_string()),
..Default::default()
},
NodeFieldDef {
name: "parse".to_string(),
label: "Parse".to_string(),
field_type: NodeFieldType::Select,
options: PARSE_WORDS.iter().map(|w| SelectOptionDef { value: w.to_string(), label: w.to_string() }).collect(),
help: Some("How a received message is read: json or text.".to_string()),
..Default::default()
},
],
layout: vec![],
ai_tool: Default::default(),
examples: vec![
crate::pipeline::model::NodeExample::dsl("Follow a price feed", r#"trigger.socket --url wss://feed.example.com/ticks --parse json --delay 2s --max-attempts 10"#)
.output(serde_json::json!({ "socket": { "url": "wss://feed.example.com/ticks", "message": { "symbol": "AUDUSD", "bid": 0.6512 }, "node_id": "n0" } })),
],
..Default::default()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub url: String,
#[serde(default)]
pub credential_id: String,
#[serde(default)]
pub delay: Value,
#[serde(default)]
pub max_attempts: Value,
#[serde(default)]
pub heartbeat: Value,
#[serde(default)]
pub parse: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Settings {
pub delay_ms: u64,
pub max_attempts: Option<u64>,
pub heartbeat_ms: u64,
pub parse: String,
}
impl Settings {
pub fn of(config: &Value) -> Result<Self, PipelineError> {
let config: Config = serde_json::from_value(config.clone())
.map_err(|err| PipelineError::new(CONFIG_CODE, err.to_string()))?;
let duration_ms = |value: &Value, flag: &str, default: &str| -> Result<u64, PipelineError> {
let text = match value {
Value::Null => default.to_string(),
Value::String(s) if s.trim().is_empty() => default.to_string(),
Value::String(s) => s.trim().to_string(),
other => other.to_string(),
};
let duration = crate::pipeline::nodes::shared::units::duration(&text, flag, CONFIG_CODE)?;
Ok(duration.as_millis().max(1) as u64)
};
let max_attempts = match &config.max_attempts {
Value::Null => None,
Value::String(s) if s.trim().is_empty() => None,
Value::Number(n) => Some(n.as_u64().ok_or_else(|| {
PipelineError::new(CONFIG_CODE, format!("--max-attempts must be a whole number of 0 or more, got {n}"))
})?),
Value::String(s) => Some(s.trim().parse::<u64>().map_err(|_| {
PipelineError::new(CONFIG_CODE, format!("--max-attempts must be a whole number of 0 or more, got '{s}'"))
})?),
other => {
return Err(PipelineError::new(CONFIG_CODE, format!("--max-attempts must be a number, got {other}")));
}
};
let parse = crate::pipeline::nodes::shared::limits::choice(&config.parse, &PARSE_WORDS, "json", "--parse", CONFIG_CODE)?;
Ok(Self {
delay_ms: duration_ms(&config.delay, "--delay", DEFAULT_DELAY)?,
max_attempts,
heartbeat_ms: duration_ms(&config.heartbeat, "--heartbeat", DEFAULT_HEARTBEAT)?,
parse: parse.to_string(),
})
}
}
pub struct Node {
#[allow(dead_code)]
config: Config,
}
impl Node {
pub fn new(config: Config) -> Self {
Self { config }
}
}
#[async_trait]
impl NodeHandler for Node {
fn kind(&self) -> &'static str {
NODE_KIND
}
fn input_pins(&self) -> &'static [&'static str] {
&[]
}
fn output_pins(&self) -> &'static [&'static str] {
&[OUTPUT_PIN_OUT]
}
async fn execute_async(
&self,
input: NodeExecutionInput,
) -> Result<NodeExecutionOutput, PipelineError> {
Ok(NodeExecutionOutput {
output_pins: vec![OUTPUT_PIN_OUT.to_string()],
payload: super::answer_under(ANSWER_KEY, input.payload),
trace: vec![format!("trigger.socket: url={}", self.config.url)],
})
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::Settings;
#[test]
fn durations_and_attempts_read_as_written() {
let s = Settings::of(&json!({ "url": "wss://feed.example.com", "delay": "2s", "heartbeat": "500ms", "max_attempts": 3 })).unwrap();
assert_eq!(s, Settings { delay_ms: 2000, max_attempts: Some(3), heartbeat_ms: 500, parse: "json".into() });
let s = Settings::of(&json!({ "url": "wss://feed.example.com" })).unwrap();
assert_eq!((s.delay_ms, s.max_attempts, s.heartbeat_ms), (5000, None, 30000), "omitted: 5s, unlimited, 30s");
let s = Settings::of(&json!({ "url": "wss://feed.example.com", "max_attempts": 0, "parse": "text" })).unwrap();
assert_eq!((s.max_attempts, s.parse.as_str()), (Some(0), "text"), "0 = never reconnect");
}
#[test]
fn a_bad_duration_or_word_is_refused() {
for bad in [json!({ "delay": "5 parsecs" }), json!({ "heartbeat": "soon" }), json!({ "parse": "xml" }), json!({ "max_attempts": -1 })] {
let err = Settings::of(&bad).unwrap_err();
assert_eq!(err.code, super::CONFIG_CODE, "{bad}");
}
}
}