use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::infra::transport::ws::{RoomCmd, StateOp, WsHub, interpolate_path};
use crate::pipeline::model::{DslFlag, DslFlagKind, LayoutItem};
use crate::pipeline::{
NodeDefinition, PipelineError,
nodes::{NodeExecutionInput, NodeExecutionOutput, NodeHandler},
};
pub const NODE_KIND: &str = "n.ws.sync_state";
const INPUT_PIN_IN: &str = "in";
const OUTPUT_PIN_OUT: &str = "out";
pub fn definition() -> NodeDefinition {
NodeDefinition {
kind: NODE_KIND.to_string(),
title: "WS Sync State".to_string(),
description: "Patches the shared state of a WebSocket room and optionally broadcasts \
the update. Supports dynamic paths like /players/{session_id}. \
Use --silent for high-frequency updates (30 fps); the room tick loop \
will batch them. Use --room to target a room from server-side pipelines \
(scheduled jobs, webhooks) where no WS client is the trigger."
.to_string(),
input_schema: json!({ "type": "object" }),
output_schema: json!({ "type": "object" }),
input_pins: vec![INPUT_PIN_IN.to_string()],
output_pins: vec![OUTPUT_PIN_OUT.to_string()],
script_available: false,
script_bridge: None,
config_schema: json!({
"type": "object",
"properties": {
"op": {
"type": "string",
"enum": ["set", "merge", "delete"],
"description": "State mutation type. set = replace value at path. merge = shallow-merge object. delete = remove key. Default: set."
},
"path": {
"type": "string",
"description": "JSON-pointer destination path. Supports {key} placeholders resolved from payload. Examples: /counter, /players/{session_id}. Empty = root."
},
"value_path": {
"type": "string",
"description": "JSON pointer into the incoming payload to extract the value to write. Empty = use the whole payload (or payload.payload if present)."
},
"room": {
"type": "string",
"description": "Static room id override. Required for server-initiated pipelines (scheduled jobs, webhooks) where no WS client is the trigger."
},
"silent": {
"type": "boolean",
"description": "If true, accumulate via the 33ms tick loop instead of broadcasting immediately. Use for high-frequency streams (>=10 Hz). Default: false."
}
}
}),
dsl_flags: vec![
DslFlag {
flag: "--op".to_string(),
config_key: "op".to_string(),
description: "State mutation: set (replace), merge (shallow-merge), delete. Default: set.".to_string(),
kind: DslFlagKind::Scalar,
required: false,
},
DslFlag {
flag: "--path".to_string(),
config_key: "path".to_string(),
description: "JSON-pointer destination. Supports {key} placeholders from payload. Example: /players/{session_id}.".to_string(),
kind: DslFlagKind::Scalar,
required: false,
},
DslFlag {
flag: "--value-path".to_string(),
config_key: "value_path".to_string(),
description: "JSON pointer into the payload to extract the value. Empty = whole payload.".to_string(),
kind: DslFlagKind::Scalar,
required: false,
},
DslFlag {
flag: "--room".to_string(),
config_key: "room".to_string(),
description: "Static room id for server-initiated pipelines without a WS trigger.".to_string(),
kind: DslFlagKind::Scalar,
required: false,
},
DslFlag {
flag: "--silent".to_string(),
config_key: "silent".to_string(),
description: "Batch via 33ms tick loop instead of immediate broadcast. Recommended for >=10 Hz update streams.".to_string(),
kind: DslFlagKind::Bool,
required: false,
},
],
fields: {
use crate::pipeline::model::{NodeFieldDef, NodeFieldType, SelectOptionDef};
vec![
NodeFieldDef { name: "room".to_string(), label: "Room".to_string(), field_type: NodeFieldType::Text, help: Some("WebSocket room to sync state into.".to_string()), ..Default::default() },
NodeFieldDef { name: "op".to_string(), label: "Operation".to_string(), field_type: NodeFieldType::Select, options: vec![
SelectOptionDef { value: "set".to_string(), label: "Set — replace entire state".to_string() },
SelectOptionDef { value: "patch".to_string(), label: "Patch — deep-merge into state".to_string() },
SelectOptionDef { value: "delete".to_string(), label: "Delete — remove key from state".to_string() },
SelectOptionDef { value: "clear".to_string(), label: "Clear — wipe entire state".to_string() },
], help: Some("State mutation operation to apply before optional broadcast.".to_string()), ..Default::default() },
NodeFieldDef { name: "path".to_string(), label: "State Path".to_string(), field_type: NodeFieldType::Text, help: Some("Dot-separated key path in shared state.".to_string()), ..Default::default() },
NodeFieldDef { name: "value_path".to_string(), label: "Value Path".to_string(), field_type: NodeFieldType::Text, help: Some("Payload path to read the value from.".to_string()), ..Default::default() },
NodeFieldDef { name: "silent".to_string(), label: "Silent (no broadcast)".to_string(), field_type: NodeFieldType::Checkbox, help: Some("Update server-side without broadcasting.".to_string()), ..Default::default() },
]
},
layout: vec![
LayoutItem::Field("room".to_string()),
LayoutItem::Row { row: vec![LayoutItem::Field("op".to_string()), LayoutItem::Field("path".to_string())] },
LayoutItem::Row { row: vec![LayoutItem::Field("value_path".to_string()), LayoutItem::Field("silent".to_string())] },
],
ai_tool: Default::default(),
..Default::default()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub op: String,
#[serde(default)]
pub path: String,
#[serde(default)]
pub value_path: String,
#[serde(default)]
pub room: String,
#[serde(default)]
pub silent: bool,
}
pub struct Node {
config: Config,
ws_hub: Arc<WsHub>,
}
impl Node {
pub fn new(config: Config, ws_hub: Arc<WsHub>) -> Result<Self, PipelineError> {
Ok(Self { config, ws_hub })
}
}
#[async_trait]
impl NodeHandler for Node {
fn kind(&self) -> &'static str {
NODE_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> {
let owner = input
.metadata
.get("owner")
.and_then(Value::as_str)
.unwrap_or_default();
let project = input
.metadata
.get("project")
.and_then(Value::as_str)
.unwrap_or_default();
let room_id = if !self.config.room.is_empty() {
self.config.room.clone()
} else {
input
.payload
.get("room_id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
};
if room_id.is_empty() {
return Err(PipelineError::new(
"FW_WS_SYNC_STATE_NO_ROOM",
"n.ws.sync_state: room_id missing — set --room or ensure n.trigger.ws is upstream",
));
}
let room_key = format!("{}/{}/{}", owner, project, room_id);
let resolved_path = interpolate_path(&self.config.path, &input.payload);
let op = match self.config.op.as_str() {
"merge" => StateOp::Merge,
"delete" => StateOp::Delete,
_ => StateOp::Set,
};
if let Some(room) = self.ws_hub.get_room(&room_key) {
let value = if matches!(op, StateOp::Delete) {
None
} else {
let base = if self.config.value_path.is_empty() {
input
.payload
.get("payload")
.cloned()
.unwrap_or_else(|| input.payload.clone())
} else {
let ptr = if self.config.value_path.starts_with('/') {
self.config.value_path.clone()
} else {
format!("/{}", self.config.value_path)
};
input.payload.pointer(&ptr).cloned().unwrap_or(Value::Null)
};
Some(base)
};
let cmd = if self.config.silent {
RoomCmd::PatchStateSilent {
op,
path: resolved_path.clone(),
value,
}
} else {
RoomCmd::PatchState {
op,
path: resolved_path.clone(),
value,
}
};
room.send_cmd(cmd);
}
Ok(NodeExecutionOutput {
output_pins: vec![OUTPUT_PIN_OUT.to_string()],
payload: input.payload,
trace: vec![format!(
"n.ws.sync_state: op={} path={} silent={}",
self.config.op, resolved_path, self.config.silent
)],
})
}
}