use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::pipeline::model::{DslFlag, DslFlagKind, LayoutItem, NodeFieldDef, NodeFieldType};
use crate::pipeline::nodes::basic::trigger::webhook::{AUTH_MODES, auth_fields, auth_flags};
use crate::pipeline::{
NodeDefinition, PipelineError,
nodes::{NodeExecutionInput, NodeExecutionOutput, NodeHandler},
};
pub const NODE_KIND: &str = "trigger.room";
const INPUT_PIN_IN: &str = "in";
const OUTPUT_PIN_OUT: &str = "out";
pub const ANSWER_KEY: &str = "room";
pub fn definition() -> NodeDefinition {
NodeDefinition {
kind: NODE_KIND.to_string(),
title: "WebSocket Trigger".to_string(),
description: "Runs when a browser connected to this project's WebSocket sends an event. It is the server half of a chat, a live \
board, a multiplayer scene. `--room` scopes it to one room (empty = any), `--event` to one event name (empty = any); the \
same `--auth` / `--credential` / `--role` flags as `trigger.webhook` guard the connection. Answers one key, `room`: \
`{ room_id, session_id, event, payload, auth? }` — what the client sent is `input.room.payload` (`$trigger.payload`). Answer with `ws.message.send` (to the room or one session) or \
`ws.state.put` / `ws.state.update` / `ws.state.delete` (shared state every client mirrors); a `web.response.send` here answers nobody. The server raises \
`$connect` and `$disconnect` (payload `{ reason }`) on each connection's ordered queue — only `--event $connect` / \
`--event $disconnect` receive them, and clients cannot send `$` events. The client is a plain `WebSocket` to \
`/ws/{owner}/{project}/rooms/{room}` receiving `joined`, `state_patch`, `event` and `resync` \
(help topic `pipeline/examples/realtime-game`)."
.to_string(),
input_schema: json!({
"type": "object",
"properties": {
"room_id": {
"type": "string",
"description": "Room id from the WS URL (/ws/{owner}/{project}/rooms/{room_id})."
},
"session_id": {
"type": "string",
"description": "Unique identifier for the connected client session."
},
"event": {
"type": "string",
"description": "Event name sent by the client, e.g. \"move\", \"chat\"."
},
"payload": {
"type": "object",
"description": "Event body sent by the client."
}
}
}),
output_schema: json!({
"type": "object",
"properties": {
"room": {
"type": "object",
"properties": {
"room_id": { "type": "string" },
"session_id": { "type": "string" },
"event": { "type": "string" },
"payload": { "type": "object" },
"auth": {}
}
}
}
}),
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": {
"room": {
"type": "string",
"description": "Room id to match. Empty string (default) matches any room. Exact string equality — no wildcards."
},
"event": {
"type": "string",
"description": "Event name to match. Empty string (default) matches any event sent by the client. Exact string equality."
},
"auth": {
"type": "string",
"enum": AUTH_MODES,
"description": "Authentication mode. none = open (default). jwt/hmac/api_key require credential_id."
},
"credential_id": {
"type": "string",
"description": "Credential ID used for auth verification. Required when auth is not none."
},
"role": {
"type": "array",
"items": { "type": "string" },
"description": "Required roles for this trigger. JWT claim 'roles' must match one. Empty = any authenticated user."
}
}
}),
dsl_flags: [
DslFlag {
flag: "--room".to_string(),
config_key: "room".to_string(),
description: "Room id to scope this trigger to. Omit to match any room."
.to_string(),
kind: DslFlagKind::Scalar,
required: false,
value: "text".to_string(),
..Default::default()
},
DslFlag {
flag: "--event".to_string(),
config_key: "event".to_string(),
description: "Event name to match. Omit to match any event from connected clients."
.to_string(),
kind: DslFlagKind::Scalar,
required: false,
value: "text".to_string(),
..Default::default()
},
].into_iter().chain(auth_flags("the connection")).collect(),
fields: [
NodeFieldDef {
name: "room".to_string(),
label: "Room".to_string(),
field_type: NodeFieldType::Text,
help: Some("Room name pattern to listen on.".to_string()),
..Default::default()
},
NodeFieldDef {
name: "event".to_string(),
label: "Event".to_string(),
field_type: NodeFieldType::Text,
help: Some("WebSocket event name to listen for.".to_string()),
..Default::default()
},
].into_iter().chain(auth_fields("On failure the event is silently dropped.")).collect(),
layout: vec![
LayoutItem::Field("room".to_string()),
LayoutItem::Field("event".to_string()),
LayoutItem::Field("auth".to_string()),
LayoutItem::Row { row: vec![LayoutItem::Field("credential_id".to_string()), LayoutItem::Field("role".to_string())] },
],
ai_tool: Default::default(),
examples: vec![
crate::pipeline::model::NodeExample::dsl("Chat message in", r#"trigger.room --room lobby --event chat.send"#)
.output(serde_json::json!({ "room": { "room_id": "lobby", "session_id": "s_8f2", "event": "chat.send", "payload": { "text": "hello" } } }))
.note("Then `| ws.message.send --event chat.message --body \"{{ { from: input.room.session_id, text: input.room.payload.text } }}\"`."),
],
..Default::default()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub room: String,
#[serde(default)]
pub event: String,
#[serde(default)]
pub auth: String,
#[serde(default)]
pub credential_id: String,
#[serde(default)]
pub role: Vec<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] {
&[INPUT_PIN_IN]
}
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: crate::pipeline::nodes::basic::trigger::answer_under(ANSWER_KEY, input.payload),
trace: vec!["trigger.room: passthrough".to_string()],
})
}
}