use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::infra::transport::ws::{EmitTarget, RoomCmd, WsHub};
use crate::pipeline::model::{DslFlag, DslFlagKind, LayoutItem};
use crate::pipeline::{
NodeDefinition, PipelineError,
nodes::{NodeExecutionInput, NodeExecutionOutput, NodeHandler},
};
pub const NODE_KIND: &str = "n.ws.emit";
const INPUT_PIN_IN: &str = "in";
const OUTPUT_PIN_OUT: &str = "out";
pub fn definition() -> NodeDefinition {
NodeDefinition {
kind: NODE_KIND.to_string(),
title: "WS Emit".to_string(),
description: "Broadcasts a named event to WebSocket sessions in a room. \
Does not modify shared state — use n.ws.sync_state for state changes. \
Use --room to target rooms from server-initiated pipelines (scheduled jobs, \
webhooks, AI agents) where no WS client is the trigger. \
--to: all (everyone), session (sender only), others (all except sender)."
.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": {
"event": {
"type": "string",
"description": "Application-level event name sent to clients. Examples: chat, player_shot, door_opened. Default: 'event'."
},
"to": {
"type": "string",
"enum": ["all", "session", "others"],
"description": "Recipient targeting. all = everyone, session = sender only, others = all except sender. Default: all."
},
"payload_path": {
"type": "string",
"description": "JSON pointer into the payload to extract the emit body. Empty = whole payload (or payload.payload if present)."
},
"room": {
"type": "string",
"description": "Static room id override. Required for server-initiated pipelines (AI agents, scheduled jobs, webhooks) without a WS trigger."
}
}
}),
dsl_flags: vec![
DslFlag {
flag: "--event".to_string(),
config_key: "event".to_string(),
description: "Event name clients receive. Examples: chat, player_shot. Default: event.".to_string(),
kind: DslFlagKind::Scalar,
required: false,
},
DslFlag {
flag: "--to".to_string(),
config_key: "to".to_string(),
description: "Recipient: all (broadcast), session (sender only), others (all except sender). Default: all.".to_string(),
kind: DslFlagKind::Scalar,
required: false,
},
DslFlag {
flag: "--payload-path".to_string(),
config_key: "payload_path".to_string(),
description: "JSON pointer into payload to extract the emit body. 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,
},
],
fields: {
use crate::pipeline::model::{NodeFieldDef, NodeFieldType, SelectOptionDef};
vec![
NodeFieldDef { name: "event".to_string(), label: "Event".to_string(), field_type: NodeFieldType::Text, default_value: Some(serde_json::json!("message")), help: Some("Event name emitted to WebSocket clients.".to_string()), ..Default::default() },
NodeFieldDef { name: "room".to_string(), label: "Room".to_string(), field_type: NodeFieldType::Text, help: Some("WebSocket room to broadcast into when target is Room.".to_string()), ..Default::default() },
NodeFieldDef { name: "to".to_string(), label: "To".to_string(), field_type: NodeFieldType::Select, options: vec![
SelectOptionDef { value: "room".to_string(), label: "Room — broadcast to all members".to_string() },
SelectOptionDef { value: "sender".to_string(), label: "Sender — reply to triggering socket only".to_string() },
], help: Some("Choose whether to broadcast to the room or reply only to the triggering sender.".to_string()), ..Default::default() },
NodeFieldDef { name: "payload_path".to_string(), label: "Payload Path".to_string(), field_type: NodeFieldType::Text, help: Some("JSON pointer to emit as event payload. Empty emits the whole input payload.".to_string()), ..Default::default() },
]
},
layout: vec![
LayoutItem::Field("event".to_string()),
LayoutItem::Row { row: vec![LayoutItem::Field("room".to_string()), LayoutItem::Field("to".to_string())] },
LayoutItem::Field("payload_path".to_string()),
],
ai_tool: Default::default(),
..Default::default()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub event: String,
#[serde(default)]
pub to: String,
#[serde(default)]
pub payload_path: String,
#[serde(default)]
pub room: String,
}
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_EMIT_NO_ROOM",
"n.ws.emit: room_id missing — set --room or ensure n.trigger.ws is upstream",
));
}
let session_id = input
.payload
.get("session_id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let room_key = format!("{}/{}/{}", owner, project, room_id);
let target = match self.config.to.as_str() {
"session" => EmitTarget::Session(session_id.clone()),
"others" => EmitTarget::Others(session_id.clone()),
_ => EmitTarget::All,
};
let emit_payload = if self.config.payload_path.is_empty() {
input
.payload
.get("payload")
.cloned()
.unwrap_or_else(|| input.payload.clone())
} else {
let ptr = if self.config.payload_path.starts_with('/') {
self.config.payload_path.clone()
} else {
format!("/{}", self.config.payload_path)
};
input.payload.pointer(&ptr).cloned().unwrap_or(Value::Null)
};
let event = if self.config.event.is_empty() {
"event".to_string()
} else {
self.config.event.clone()
};
if let Some(room) = self.ws_hub.get_room(&room_key) {
room.send_cmd(RoomCmd::Emit {
event: event.clone(),
payload: emit_payload,
to: target,
});
}
Ok(NodeExecutionOutput {
output_pins: vec![OUTPUT_PIN_OUT.to_string()],
payload: input.payload,
trace: vec![format!(
"n.ws.emit: event={} to={} room={}",
event, self.config.to, room_id
)],
})
}
}