Skip to main content

netget/server/websocket/
actions.rs

1//! WebSocket (RFC 6455) server actions, events and metadata.
2//!
3//! Every action declared here is executed by [`WebSocketProtocol::execute_action`], and every
4//! field an action documents is actually read. Two shapes of the protocol object exist:
5//!
6//! - the registry-wide instance built by [`WebSocketProtocol::new`], used for documentation,
7//!   spawning, and the *async* actions (which address a connection by id and are how the
8//!   model speaks to a client nobody asked it to speak to);
9//! - a per-connection instance built by [`WebSocketProtocol::for_connection`], which owns the
10//!   outbound channel for one socket and therefore can execute the *sync* actions.
11//!
12//! No storage: [`WS_CONNECTIONS`] is a directory of live sockets so the model can name a
13//! recipient. Nothing survives a disconnect, and no message is ever retained.
14
15use crate::llm::actions::{
16    protocol_trait::{ActionResult, Protocol, Server},
17    ActionDefinition, Parameter, ParameterDefinition,
18};
19use crate::protocol::log_template::LogTemplate;
20use crate::protocol::EventType;
21use crate::server::connection::ConnectionId;
22use crate::state::app_state::AppState;
23use crate::state::ServerId;
24use anyhow::{Context, Result};
25use serde_json::json;
26use std::collections::HashMap;
27use std::sync::atomic::{AtomicBool, Ordering};
28use std::sync::{LazyLock, Mutex};
29use tokio::sync::mpsc;
30use tracing::{debug, warn};
31
32// ============================================================================
33// Outbound message channel
34// ============================================================================
35
36/// One thing the model asked us to put on the wire for a connection.
37///
38/// The writer task owns the `SplitSink`; every producer (the per-message handler, an async
39/// action addressed at this connection, a scheduled task) sends one of these instead. That is
40/// why no lock is ever held across an `.await` that performs I/O.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum WsOut {
43    Text(String),
44    Binary(Vec<u8>),
45    Ping(Vec<u8>),
46    Close { code: u16, reason: String },
47}
48
49/// A live WebSocket connection, addressable by the model.
50struct WsConnEntry {
51    server_id: u32,
52    remote_addr: String,
53    path: String,
54    subprotocol: Option<String>,
55    tx: mpsc::UnboundedSender<WsOut>,
56}
57
58/// Directory of *live sockets*, keyed by connection id (globally unique — allocated by
59/// `AppState::get_next_unified_id`). Not storage: an entry exists exactly as long as its
60/// socket does.
61static WS_CONNECTIONS: LazyLock<Mutex<HashMap<u32, WsConnEntry>>> =
62    LazyLock::new(|| Mutex::new(HashMap::new()));
63
64/// Record a connection's writer so actions can address it by id.
65pub fn register_connection(
66    server_id: ServerId,
67    connection_id: ConnectionId,
68    remote_addr: String,
69    path: String,
70    tx: mpsc::UnboundedSender<WsOut>,
71) {
72    if let Ok(mut map) = WS_CONNECTIONS.lock() {
73        map.insert(
74            connection_id.as_u32(),
75            WsConnEntry {
76                server_id: server_id.as_u32(),
77                remote_addr,
78                path,
79                subprotocol: None,
80                tx,
81            },
82        );
83    }
84}
85
86/// Record the negotiated subprotocol once the handshake has completed.
87pub fn set_connection_subprotocol(connection_id: ConnectionId, subprotocol: Option<String>) {
88    if let Ok(mut map) = WS_CONNECTIONS.lock() {
89        if let Some(entry) = map.get_mut(&connection_id.as_u32()) {
90            entry.subprotocol = subprotocol;
91        }
92    }
93}
94
95/// Drop a connection from the directory when its socket ends.
96pub fn unregister_connection(connection_id: ConnectionId) {
97    if let Ok(mut map) = WS_CONNECTIONS.lock() {
98        map.remove(&connection_id.as_u32());
99    }
100}
101
102/// Describe the connections currently open, optionally restricted to one server.
103pub fn list_connections(server_id: Option<u32>) -> Vec<serde_json::Value> {
104    let Ok(map) = WS_CONNECTIONS.lock() else {
105        return Vec::new();
106    };
107    let mut rows: Vec<(u32, serde_json::Value)> = map
108        .iter()
109        .filter(|(_, e)| server_id.is_none_or(|sid| e.server_id == sid))
110        .map(|(id, e)| {
111            (
112                *id,
113                json!({
114                    "connection_id": ConnectionId::new(*id).to_string(),
115                    "server_id": e.server_id,
116                    "remote_addr": e.remote_addr,
117                    "path": e.path,
118                    "subprotocol": e.subprotocol,
119                }),
120            )
121        })
122        .collect();
123    rows.sort_by_key(|(id, _)| *id);
124    rows.into_iter().map(|(_, v)| v).collect()
125}
126
127/// Deliver a message to one connection id, or to every connection when `target` is `"*"`.
128/// Returns the connection ids actually written to.
129fn deliver(target: &str, server_id: Option<u32>, msg: WsOut) -> Vec<String> {
130    let Ok(map) = WS_CONNECTIONS.lock() else {
131        return Vec::new();
132    };
133
134    let wanted = if target == "*" {
135        None
136    } else {
137        match ConnectionId::from_string(target) {
138            Some(id) => Some(id.as_u32()),
139            // An unparseable id matches nothing; the caller turns the empty result into a
140            // descriptive error listing the ids that do exist.
141            None => return Vec::new(),
142        }
143    };
144
145    let mut delivered = Vec::new();
146    for (id, entry) in map.iter() {
147        if let Some(want) = wanted {
148            if *id != want {
149                continue;
150            }
151        }
152        if let Some(sid) = server_id {
153            if entry.server_id != sid {
154                continue;
155            }
156        }
157        if entry.tx.send(msg.clone()).is_ok() {
158            delivered.push(ConnectionId::new(*id).to_string());
159        }
160    }
161    delivered.sort();
162    delivered
163}
164
165// ============================================================================
166// Payload encoding — symmetric in both directions
167// ============================================================================
168
169/// Turn received binary-frame bytes into the `(data, encoding)` pair the model sees.
170///
171/// Printable ASCII is passed through as text so the model can read it; anything else is
172/// base64. The pair is *exactly* what `send_websocket_binary` consumes, so echoing a frame is
173/// `{"type": "send_websocket_binary", "data": event.data, "encoding": event.encoding}` and the
174/// bytes on the wire are identical. Getting this asymmetric is the `send_tcp_data` bug
175/// (`d70bb5b5`), where inbound was encoded and outbound was not and an echo server could not
176/// echo.
177pub fn encode_inbound_payload(bytes: &[u8]) -> (String, &'static str) {
178    if !bytes.is_empty()
179        && bytes
180            .iter()
181            .all(|b| b.is_ascii_graphic() || b.is_ascii_whitespace())
182    {
183        (String::from_utf8_lossy(bytes).into_owned(), "utf8")
184    } else {
185        use base64::Engine as _;
186        (
187            base64::engine::general_purpose::STANDARD.encode(bytes),
188            "base64",
189        )
190    }
191}
192
193/// Turn an action's `data` + `encoding` pair into the exact bytes to put on the wire.
194///
195/// There is deliberately no auto-detection: `"48656c6c6f"` is simultaneously valid text, valid
196/// hex and (nearly) valid base64, and only the sender knows which it means.
197pub fn decode_outbound_payload(data: &str, encoding: Option<&str>) -> Result<Vec<u8>> {
198    match encoding.unwrap_or("utf8") {
199        "utf8" => Ok(data.as_bytes().to_vec()),
200        "base64" => {
201            use base64::Engine as _;
202            let cleaned: String = data.chars().filter(|c| !c.is_ascii_whitespace()).collect();
203            base64::engine::general_purpose::STANDARD
204                .decode(&cleaned)
205                .map_err(|e| {
206                    anyhow::anyhow!(
207                        "Invalid base64 in 'data' ({data:?}): {e}. Use standard base64 with \
208                         padding, e.g. \"AP/+AQ==\" for the 4 bytes 00 ff fe 01. To send this \
209                         string as literal text, set \"encoding\": \"utf8\" instead."
210                    )
211                })
212        }
213        "hex" => {
214            let cleaned: String = data
215                .chars()
216                .filter(|c| !c.is_ascii_whitespace() && *c != ':')
217                .collect();
218            let cleaned = cleaned.strip_prefix("0x").unwrap_or(&cleaned);
219            if cleaned.len() % 2 != 0 {
220                return Err(anyhow::anyhow!(
221                    "Invalid hex in 'data': expected an even number of hex digits, got {} \
222                     ({data:?}). Each byte is two hex digits, e.g. \"48656c6c6f\" = \"Hello\".",
223                    cleaned.len()
224                ));
225            }
226            hex::decode(cleaned).map_err(|e| {
227                anyhow::anyhow!(
228                    "Invalid hex in 'data' ({data:?}): {e}. Use only 0-9/a-f, two digits per \
229                     byte. To send this string as literal text, set \"encoding\": \"utf8\"."
230                )
231            })
232        }
233        other => Err(anyhow::anyhow!(
234            "Invalid 'encoding' value {other:?}. Valid values are \"utf8\" (default — send the \
235             characters of 'data' unchanged), \"base64\" and \"hex\" (decode 'data' into bytes)."
236        )),
237    }
238}
239
240// ============================================================================
241// Protocol
242// ============================================================================
243
244/// WebSocket protocol action handler.
245pub struct WebSocketProtocol {
246    server_id: Option<ServerId>,
247    connection_id: Option<ConnectionId>,
248    out_tx: Option<mpsc::UnboundedSender<WsOut>>,
249    status_tx: Option<mpsc::UnboundedSender<String>>,
250    /// Subprotocols the client offered in `Sec-WebSocket-Protocol`, used to reject an
251    /// `accept_websocket` naming one the client never asked for (RFC 6455 §4.2.2 step 5.5).
252    offered_subprotocols: Vec<String>,
253    /// Set while `websocket_connection_opened` is being answered. An empty text message
254    /// there is dropped: it carries nothing, and websocat reads a zero-length message as the
255    /// end of the stream, so nothing sent after it is ever shown.
256    speaking_first: AtomicBool,
257}
258
259impl Default for WebSocketProtocol {
260    fn default() -> Self {
261        Self::new()
262    }
263}
264
265impl WebSocketProtocol {
266    /// The server an action scopes to: the model's `server_id` if it named one, otherwise
267    /// whichever server this executor is bound to (`None` = every WebSocket server).
268    ///
269    /// This used to be `.map(|v| v as u32)`, which wraps in silence — `4294967297` becomes
270    /// `1`, so a push or a close aimed at a server that does not exist is delivered to
271    /// whichever server happens to be number 1 instead of failing.
272    fn scope_server_id(&self, action: &serde_json::Value) -> Result<Option<u32>> {
273        if let Some(raw) = action.get("server_id").and_then(|v| v.as_u64()) {
274            let id = u32::try_from(raw).map_err(|_| {
275                anyhow::anyhow!(
276                    "server_id {raw} is not a NetGet server id (0-4294967295); take it from \
277                     the server list rather than inventing one"
278                )
279            })?;
280            return Ok(Some(id));
281        }
282        Ok(self.server_id.map(|s| s.as_u32()))
283    }
284
285    /// Registry-wide instance: no connection bound, so only async actions can execute.
286    pub fn new() -> Self {
287        Self {
288            server_id: None,
289            connection_id: None,
290            out_tx: None,
291            status_tx: None,
292            offered_subprotocols: Vec::new(),
293            speaking_first: AtomicBool::new(false),
294        }
295    }
296
297    /// Instance bound to one connection.
298    pub fn for_connection(
299        server_id: ServerId,
300        connection_id: ConnectionId,
301        out_tx: mpsc::UnboundedSender<WsOut>,
302        status_tx: mpsc::UnboundedSender<String>,
303        offered_subprotocols: Vec<String>,
304    ) -> Self {
305        Self {
306            server_id: Some(server_id),
307            connection_id: Some(connection_id),
308            out_tx: Some(out_tx),
309            status_tx: Some(status_tx),
310            offered_subprotocols,
311            speaking_first: AtomicBool::new(false),
312        }
313    }
314
315    /// Mark whether the event being answered is `websocket_connection_opened`, where the
316    /// server speaks first. See `speaking_first`.
317    pub fn set_speaking_first(&self, speaking_first: bool) {
318        self.speaking_first.store(speaking_first, Ordering::SeqCst);
319    }
320
321    fn send(&self, msg: WsOut) -> Result<()> {
322        let tx = self.out_tx.as_ref().context(
323            "This WebSocket action can only run in response to an event on a connection \
324             (no connection is bound). To speak to a connection outside an event, use \
325             push_websocket_text / push_websocket_binary with a connection_id.",
326        )?;
327        tx.send(msg)
328            .map_err(|_| anyhow::anyhow!("WebSocket connection is already closed"))
329    }
330
331    fn log(&self, message: String) {
332        debug!("{}", message);
333        if let Some(tx) = &self.status_tx {
334            let _ = tx.send(format!("[DEBUG] {}", message));
335        }
336    }
337
338    fn conn_label(&self) -> String {
339        self.connection_id
340            .map(|c| c.to_string())
341            .unwrap_or_else(|| "<unbound>".into())
342    }
343
344    // ---- executors -------------------------------------------------------
345
346    fn execute_accept(&self, action: serde_json::Value) -> Result<ActionResult> {
347        let subprotocol = action
348            .get("subprotocol")
349            .and_then(|v| v.as_str())
350            .map(|s| s.trim().to_string())
351            .filter(|s| !s.is_empty());
352
353        // RFC 6455 §4.2.2: the server MUST NOT echo a subprotocol the client did not offer,
354        // and clients that check (websocat does) fail the connection if it does. What the
355        // model decided is to accept; the subprotocol is a detail it got wrong, usually by
356        // copying one from an example. So only that part is refused: the connection opens
357        // without a subprotocol, which is a valid 101 for any client, and the log says what
358        // was dropped. Refusing the whole action instead turned every such accept into the
359        // fail-closed 503 - the network path does not re-prompt the model with the error.
360        let subprotocol = match subprotocol {
361            Some(chosen) if !self.offered_subprotocols.iter().any(|s| *s == chosen) => {
362                let message = format!(
363                    "WebSocket accept {} decision=subprotocol_dropped: {:?} was not offered \
364                     (offered: {:?}); accepting without a subprotocol",
365                    self.conn_label(),
366                    chosen,
367                    self.offered_subprotocols
368                );
369                warn!("{}", message);
370                if let Some(tx) = &self.status_tx {
371                    let _ = tx.send(format!("[WARN] {}", message));
372                }
373                None
374            }
375            other => other,
376        };
377
378        self.log(format!(
379            "WebSocket accept {} subprotocol={:?}",
380            self.conn_label(),
381            subprotocol
382        ));
383
384        Ok(ActionResult::Custom {
385            name: "accept_websocket".to_string(),
386            data: json!({ "subprotocol": subprotocol }),
387        })
388    }
389
390    fn execute_reject(&self, action: serde_json::Value) -> Result<ActionResult> {
391        let status_code = action
392            .get("status_code")
393            .and_then(|v| v.as_u64())
394            .unwrap_or(403);
395        if !(400..=599).contains(&status_code) {
396            return Err(anyhow::anyhow!(
397                "status_code {} is not a rejection status. Use a 4xx or 5xx code, e.g. 401 \
398                 (authentication required), 403 (forbidden), 404 (no such endpoint), \
399                 429 (too many connections), 503 (unavailable).",
400                status_code
401            ));
402        }
403        let reason = action
404            .get("reason")
405            .and_then(|v| v.as_str())
406            .unwrap_or("Upgrade refused")
407            .to_string();
408
409        self.log(format!(
410            "WebSocket reject {} {} {}",
411            self.conn_label(),
412            status_code,
413            reason
414        ));
415
416        Ok(ActionResult::Custom {
417            name: "reject_websocket".to_string(),
418            data: json!({ "status_code": status_code, "reason": reason }),
419        })
420    }
421
422    fn execute_send_text(&self, action: serde_json::Value) -> Result<ActionResult> {
423        let text = action
424            .get("text")
425            .and_then(|v| v.as_str())
426            .context("Missing 'text'. Provide the message body to send as a WebSocket text frame")?
427            .to_string();
428
429        if text.is_empty() && self.speaking_first.load(Ordering::SeqCst) {
430            // Told to say nothing on connect, llama3.1:8b sends "" instead of no action. It
431            // is valid RFC 6455 and carries nothing, and websocat treats a zero-length message
432            // as end of stream (checked against a Python `websockets` server: after one, the
433            // echo that followed was never printed). A reply to a client's own empty message
434            // is not affected: this applies only while the server is speaking first.
435            let message = format!(
436                "WebSocket {} decision=empty_greeting_dropped: an empty text message on \
437                 connection open is treated as no greeting",
438                self.conn_label()
439            );
440            warn!("{}", message);
441            if let Some(tx) = &self.status_tx {
442                let _ = tx.send(format!("[WARN] {}", message));
443            }
444            return Ok(ActionResult::Custom {
445                name: "send_websocket_text".to_string(),
446                data: json!({ "bytes": 0, "dropped": true }),
447            });
448        }
449
450        self.log(format!(
451            "WebSocket -> text {} bytes to {}",
452            text.len(),
453            self.conn_label()
454        ));
455        self.send(WsOut::Text(text.clone()))?;
456
457        Ok(ActionResult::Custom {
458            name: "send_websocket_text".to_string(),
459            data: json!({ "bytes": text.len() }),
460        })
461    }
462
463    fn execute_send_binary(&self, action: serde_json::Value) -> Result<ActionResult> {
464        let data = action
465            .get("data")
466            .and_then(|v| v.as_str())
467            .context("Missing 'data'. Provide the payload, interpreted per the 'encoding' field")?;
468        let encoding = action.get("encoding").and_then(|v| v.as_str());
469        let bytes = decode_outbound_payload(data, encoding)?;
470
471        self.log(format!(
472            "WebSocket -> binary {} bytes to {} (encoding={})",
473            bytes.len(),
474            self.conn_label(),
475            encoding.unwrap_or("utf8")
476        ));
477        self.send(WsOut::Binary(bytes.clone()))?;
478
479        Ok(ActionResult::Custom {
480            name: "send_websocket_binary".to_string(),
481            data: json!({ "bytes": bytes.len() }),
482        })
483    }
484
485    fn execute_send_ping(&self, action: serde_json::Value) -> Result<ActionResult> {
486        let payload = action
487            .get("payload")
488            .and_then(|v| v.as_str())
489            .unwrap_or("")
490            .as_bytes()
491            .to_vec();
492        // RFC 6455 §5.5: control frame payloads are limited to 125 bytes.
493        if payload.len() > 125 {
494            return Err(anyhow::anyhow!(
495                "Ping payload is {} bytes; RFC 6455 §5.5 limits control frames to 125 bytes",
496                payload.len()
497            ));
498        }
499        self.log(format!("WebSocket -> ping to {}", self.conn_label()));
500        self.send(WsOut::Ping(payload))?;
501        Ok(ActionResult::Custom {
502            name: "send_websocket_ping".to_string(),
503            data: json!({}),
504        })
505    }
506
507    fn execute_close(&self, action: serde_json::Value) -> Result<ActionResult> {
508        let (code, reason) = close_fields(&action)?;
509        self.log(format!(
510            "WebSocket -> close {} code={} reason={:?}",
511            self.conn_label(),
512            code,
513            reason
514        ));
515        self.send(WsOut::Close {
516            code,
517            reason: reason.clone(),
518        })?;
519        Ok(ActionResult::CloseConnection)
520    }
521
522    fn execute_push(&self, action: serde_json::Value, binary: bool) -> Result<ActionResult> {
523        let target = action
524            .get("connection_id")
525            .and_then(|v| v.as_str())
526            .context(
527                "Missing 'connection_id': name the connection to write to (see \
528                 list_websocket_connections), or \"*\" to broadcast to every open connection",
529            )?;
530        let server_id = self.scope_server_id(&action)?;
531
532        let (msg, summary) = if binary {
533            let data = action
534                .get("data")
535                .and_then(|v| v.as_str())
536                .context("Missing 'data' for push_websocket_binary")?;
537            let bytes =
538                decode_outbound_payload(data, action.get("encoding").and_then(|v| v.as_str()))?;
539            let len = bytes.len();
540            (WsOut::Binary(bytes), format!("binary {len} bytes"))
541        } else {
542            let text = action
543                .get("text")
544                .and_then(|v| v.as_str())
545                .context("Missing 'text' for push_websocket_text")?
546                .to_string();
547            let len = text.len();
548            (WsOut::Text(text), format!("text {len} bytes"))
549        };
550
551        let delivered = deliver(target, server_id, msg);
552        if delivered.is_empty() {
553            warn!(
554                "WebSocket push to '{}' matched no open connection (server {:?})",
555                target, server_id
556            );
557            return Err(anyhow::anyhow!(
558                "No open WebSocket connection matches '{}'. Open connections: {}",
559                target,
560                serde_json::to_string(&list_connections(server_id)).unwrap_or_default()
561            ));
562        }
563
564        self.log(format!("WebSocket push {} to {:?}", summary, delivered));
565        Ok(ActionResult::Custom {
566            name: if binary {
567                "push_websocket_binary".to_string()
568            } else {
569                "push_websocket_text".to_string()
570            },
571            data: json!({ "delivered_to": delivered }),
572        })
573    }
574
575    fn execute_close_by_id(&self, action: serde_json::Value) -> Result<ActionResult> {
576        let target = action
577            .get("connection_id")
578            .and_then(|v| v.as_str())
579            .context("Missing 'connection_id' (or \"*\" to close every open connection)")?;
580        let server_id = self.scope_server_id(&action)?;
581        let (code, reason) = close_fields(&action)?;
582
583        let delivered = deliver(target, server_id, WsOut::Close { code, reason });
584        if delivered.is_empty() {
585            return Err(anyhow::anyhow!(
586                "No open WebSocket connection matches '{}'. Open connections: {}",
587                target,
588                serde_json::to_string(&list_connections(server_id)).unwrap_or_default()
589            ));
590        }
591        Ok(ActionResult::Custom {
592            name: "close_websocket_connection".to_string(),
593            data: json!({ "closed": delivered, "code": code }),
594        })
595    }
596
597    fn execute_list(&self, action: serde_json::Value) -> Result<ActionResult> {
598        let server_id = self.scope_server_id(&action)?;
599        Ok(ActionResult::Custom {
600            name: "list_websocket_connections".to_string(),
601            data: json!({ "connections": list_connections(server_id) }),
602        })
603    }
604}
605
606/// Read the shared `code` / `reason` pair of the two closing actions.
607fn close_fields(action: &serde_json::Value) -> Result<(u16, String)> {
608    let code = action.get("code").and_then(|v| v.as_u64()).unwrap_or(1000);
609    // RFC 6455 §7.4: 1000-1015 are defined, 3000-4999 are usable by applications.
610    // 1005/1006/1015 must never appear on the wire.
611    let valid = (1000..=1003).contains(&code)
612        || (1007..=1011).contains(&code)
613        || (3000..=4999).contains(&code);
614    if !valid {
615        return Err(anyhow::anyhow!(
616            "close code {} is not sendable. RFC 6455 §7.4 allows 1000 (normal), 1001 (going \
617             away), 1002 (protocol error), 1003 (unsupported data), 1007 (invalid payload), \
618             1008 (policy violation), 1009 (message too big), 1010 (extension expected), \
619             1011 (internal error), or 3000-4999 for application use. 1005, 1006 and 1015 \
620             are reserved and must never be sent.",
621            code
622        ));
623    }
624    let reason = action
625        .get("reason")
626        .and_then(|v| v.as_str())
627        .unwrap_or("")
628        .to_string();
629    if reason.len() > 123 {
630        return Err(anyhow::anyhow!(
631            "close reason is {} bytes; a close frame carries a 2-byte code plus at most 123 \
632             bytes of reason (RFC 6455 §5.5)",
633            reason.len()
634        ));
635    }
636    Ok((code as u16, reason))
637}
638
639// ============================================================================
640// Protocol trait
641// ============================================================================
642
643impl Protocol for WebSocketProtocol {
644    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
645        vec![
646            ParameterDefinition {
647                name: "path".to_string(),
648                type_hint: "string".to_string(),
649                description:
650                    "Only upgrade requests for this exact path are accepted; anything else gets \
651                     HTTP 404 without reaching the model. Omit to accept every path (the model \
652                     still sees the path in the websocket_handshake event and can reject it)."
653                        .to_string(),
654                required: false,
655                example: json!("/ws"),
656                default: None,
657            },
658            ParameterDefinition {
659                name: "max_message_size".to_string(),
660                type_hint: "integer".to_string(),
661                description:
662                    "Largest reassembled message accepted from a client, in bytes (default \
663                     1048576, maximum 67108864). A larger message fails the connection."
664                        .to_string(),
665                required: false,
666                example: json!(1048576),
667                default: Some(serde_json::json!(super::DEFAULT_MAX_MESSAGE_SIZE)),
668            },
669            ParameterDefinition {
670                name: "max_frame_size".to_string(),
671                type_hint: "integer".to_string(),
672                description:
673                    "Largest single frame accepted from a client, in bytes (default 1048576, \
674                     maximum 67108864). Fragmented messages are reassembled up to \
675                     max_message_size."
676                        .to_string(),
677                required: false,
678                example: json!(1048576),
679                default: Some(serde_json::json!(super::DEFAULT_MAX_FRAME_SIZE)),
680            },
681            ParameterDefinition {
682                name: "idle_timeout_secs".to_string(),
683                type_hint: "integer".to_string(),
684                description:
685                    "Seconds an upgraded connection may send no frame at all before the server \
686                     closes it with 1001 (default 600). The server pings at half of it and every \
687                     client answers a ping with a pong automatically, so a live but silent \
688                     client is never closed; a message still being answered never counts as \
689                     idle."
690                        .to_string(),
691                required: false,
692                example: json!(600),
693                default: Some(serde_json::json!(super::IDLE_TIMEOUT.as_secs())),
694            },
695            ParameterDefinition {
696                name: "permessage_deflate".to_string(),
697                type_hint: "boolean".to_string(),
698                description:
699                    "Accept a client's RFC 7692 permessage-deflate offer, so messages are \
700                     compressed on the wire in both directions (default true). Handlers see \
701                     plain text either way; false makes every connection uncompressed."
702                        .to_string(),
703                required: false,
704                example: json!(true),
705                default: Some(serde_json::json!(super::DEFAULT_PERMESSAGE_DEFLATE)),
706            },
707        ]
708    }
709
710    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
711        vec![
712            push_websocket_text_action(),
713            push_websocket_binary_action(),
714            list_websocket_connections_action(),
715            close_websocket_connection_action(),
716        ]
717    }
718
719    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
720        vec![
721            accept_websocket_action(),
722            reject_websocket_action(),
723            send_websocket_text_action(),
724            send_websocket_binary_action(),
725            send_websocket_ping_action(),
726            wait_for_websocket_data_action(),
727            close_websocket_action(),
728        ]
729    }
730
731    fn protocol_name(&self) -> &'static str {
732        "WebSocket"
733    }
734
735    fn get_event_types(&self) -> Vec<EventType> {
736        get_websocket_event_types()
737    }
738
739    fn stack_name(&self) -> &'static str {
740        // Not "ETH>IP>TCP>WebSocket": WebRTC Signaling already claims that string, and
741        // stack names are registered as parser keywords, so a duplicate would make
742        // `parse_from_str` resolve by HashMap iteration order. The HTTP hop is also more
743        // accurate — RFC 6455 starts as an HTTP/1.1 Upgrade.
744        "ETH>IP>TCP>HTTP>WebSocket"
745    }
746
747    fn keywords(&self) -> Vec<&'static str> {
748        // Deliberately no bare "ws" (matches "aws", "wsdl", …) and no bare "web"/"socket".
749        // "websocket" is claimed here, which is why `parse_from_str` checks WebRTC Signaling
750        // (whose keyword is the longer "websocket signaling") ahead of the generic loop.
751        vec![
752            "websocket",
753            "web socket",
754            "rfc 6455",
755            "websocket server",
756            "websocket endpoint",
757        ]
758    }
759
760    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
761        use crate::protocol::metadata::{
762            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
763        };
764
765        ProtocolMetadataV2::builder()
766            .state(DevelopmentState::Beta)
767            .privilege_requirement(PrivilegeRequirement::None)
768            .implementation(
769                "Hand-written RFC 6455 HTTP Upgrade handshake (Sec-WebSocket-Accept via \
770                 tungstenite's derive_accept_key) handed to tokio-tungstenite 0.21 \
771                 WebSocketStream::from_partially_read for framing, masking, fragmentation \
772                 reassembly, automatic pong and the close handshake",
773            )
774            .llm_control(
775                "Whether an upgrade is accepted and with which Sec-WebSocket-Protocol \
776                 subprotocol, every text and binary frame sent, ping payloads, close code and \
777                 reason, plus unprompted pushes to any named open connection or a broadcast",
778            )
779            .e2e_testing(
780                "Two peers, neither of them this repository. (1) websocat 1.14.1, in \
781                 tests/server/websocket/e2e_test.rs::test_websocket_with_websocat, completes a \
782                 handshake, receives an unprompted server greeting and gets its own message \
783                 echoed back. websocat links websocket-0.27.1 / websocket-base-0.26.5 \
784                 (rust-websocket), NOT tungstenite — read out of the installed binary's \
785                 embedded crate paths — so it is a genuinely independent RFC 6455 \
786                 implementation and not the circular case of the peer being the same crate \
787                 this server frames with. That test is not #[ignore]d and FAILS rather than \
788                 skips when websocat is absent; until September 2026 it printed \
789                 \"skipping: websocat is not installed\" and returned Ok(()), a silent pass, \
790                 which is what held this at Experimental. (2) A raw RFC 6455 client \
791                 hand-written in the same file recomputes Sec-WebSocket-Accept from §4.2.2, \
792                 masks its own frames per §5.3 and parses ours byte by byte, asserting that \
793                 the server never masks (§5.1) and that close frames carry a big-endian \
794                 status code (§5.5). (3) RFC 7692 permessage-deflate against Python \
795                 websockets 15 and Node ws 8 (tests/server/websocket/\
796                 deflate_real_client_test.rs): a relay shows every frame NetGet sent had RSV1 \
797                 set and shrank, ws negotiates a 10-bit window with no context takeover, and a \
798                 compressed frame that inflates past max_message_size is refused while \
799                 inflating (1009). Untested: wss:// and any browser.",
800            )
801            .notes(
802                "Validated against websocat 1.14.1 and curl 8.20.0 as real independent \
803                 clients: handshake, subprotocol negotiation, text frames, a binary frame \
804                 round-tripped byte-for-byte through base64, ping/pong and a close handshake \
805                 with a status code. RFC 7692 permessage-deflate is negotiated (on by default, \
806                 permessage_deflate: false turns it off) beneath tungstenite by \
807                 websocket/deflate.rs, honouring window sizes 9-15 and no-context-takeover; \
808                 handlers see plain text either way. Not implemented: TLS (wss:// — put a TLS \
809                 terminator in front) and every other extension. Pong frames from \
810                 the client are logged, not surfaced as an event, so a keepalive pong costs no \
811                 model call. Every connection costs two model calls before the first message \
812                 (websocket_handshake, then websocket_connection_opened); use script or static \
813                 handlers for deterministic endpoints.",
814            )
815            .max_inbound_bytes(crate::server::websocket::MAX_SIZE_LIMIT)
816            .build()
817    }
818
819    fn description(&self) -> &'static str {
820        "WebSocket (RFC 6455) server with LLM-controlled frames and subprotocol negotiation"
821    }
822
823    fn example_prompt(&self) -> &'static str {
824        "Serve a WebSocket endpoint on port 9001 at /ws that greets each client and echoes messages back uppercased"
825    }
826
827    fn group_name(&self) -> &'static str {
828        "Application"
829    }
830
831    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
832        use crate::llm::actions::StartupExamples;
833
834        StartupExamples::new(
835            // LLM mode: the model authors every frame.
836            json!({
837                "type": "open_server",
838                "port": 9001,
839                "base_stack": "websocket",
840                "instruction": "WebSocket chat backend. Accept every upgrade with accept_websocket, \
841                                choosing the 'chat' subprotocol when the client offers it. On \
842                                websocket_connection_opened send a welcome text frame. Answer each \
843                                websocket_text_message in character as a chat server."
844            }),
845            // Script mode: deterministic, no model call per frame.
846            json!({
847                "type": "open_server",
848                "port": 9001,
849                "base_stack": "websocket",
850                "event_handlers": [
851                    {
852                        "event_pattern": "websocket_handshake",
853                        "handler": {
854                            "type": "script",
855                            "language": "python",
856                            "code": "respond([{'type': 'accept_websocket'}] if event['path'] == '/ws' else [{'type': 'reject_websocket', 'status_code': 404, 'reason': 'No such endpoint'}])"
857                        }
858                    },
859                    {
860                        "event_pattern": "websocket_text_message",
861                        "handler": {
862                            "type": "script",
863                            "language": "python",
864                            "code": "respond([{'type': 'send_websocket_text', 'text': event['text'].upper()}])"
865                        }
866                    }
867                ]
868            }),
869            // Static mode: fixed frames, no model call at all.
870            json!({
871                "type": "open_server",
872                "port": 9001,
873                "base_stack": "websocket",
874                "event_handlers": [
875                    {
876                        "event_pattern": "websocket_handshake",
877                        "handler": {
878                            "type": "static",
879                            "actions": [{"type": "accept_websocket"}]
880                        }
881                    },
882                    {
883                        "event_pattern": "websocket_connection_opened",
884                        "handler": {
885                            "type": "static",
886                            "actions": [{"type": "send_websocket_text", "text": "welcome"}]
887                        }
888                    },
889                    {
890                        "event_pattern": "websocket_text_message",
891                        "handler": {
892                            "type": "static",
893                            "actions": [{"type": "send_websocket_text", "text": "{{event.text}}"}]
894                        }
895                    }
896                ]
897            }),
898        )
899    }
900}
901
902impl Server for WebSocketProtocol {
903    fn spawn(
904        &self,
905        ctx: crate::protocol::SpawnContext,
906    ) -> std::pin::Pin<
907        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
908    > {
909        Box::pin(async move {
910            use crate::server::websocket::WebSocketServer;
911            WebSocketServer::spawn_with_llm_actions(
912                ctx.legacy_listen_addr(),
913                ctx.llm_client,
914                ctx.state,
915                ctx.status_tx,
916                ctx.server_id,
917                ctx.startup_params,
918            )
919            .await
920        })
921    }
922
923    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
924        let action_type = action
925            .get("type")
926            .and_then(|v| v.as_str())
927            .context("Missing 'type' field in action")?;
928
929        match action_type {
930            "accept_websocket" => self.execute_accept(action),
931            "reject_websocket" => self.execute_reject(action),
932            "send_websocket_text" => self.execute_send_text(action),
933            "send_websocket_binary" => self.execute_send_binary(action),
934            "send_websocket_ping" => self.execute_send_ping(action),
935            "wait_for_websocket_data" => Ok(ActionResult::WaitForMore),
936            "close_websocket" => self.execute_close(action),
937            "push_websocket_text" => self.execute_push(action, false),
938            "push_websocket_binary" => self.execute_push(action, true),
939            "close_websocket_connection" => self.execute_close_by_id(action),
940            "list_websocket_connections" => self.execute_list(action),
941            _ => Err(anyhow::anyhow!("Unknown WebSocket action: {}", action_type)),
942        }
943    }
944}
945
946// ============================================================================
947// Action definitions
948// ============================================================================
949
950/// The `encoding` parameter shared by every action carrying a binary `data` field.
951fn encoding_parameter() -> Parameter {
952    Parameter {
953        name: "encoding".to_string(),
954        type_hint: "string".to_string(),
955        description: "How to turn 'data' into the bytes of the frame. \"utf8\" (the default when \
956             omitted) sends the characters of 'data' unchanged. \"base64\" decodes 'data' as \
957             standard base64, \"hex\" as two hex digits per byte. There is no auto-detection: \
958             to echo a websocket_binary_message back unchanged, pass that event's 'data' AND \
959             its 'encoding' straight through."
960            .to_string(),
961        required: false,
962    }
963}
964
965pub fn accept_websocket_action() -> ActionDefinition {
966    ActionDefinition {
967        name: "accept_websocket".to_string(),
968        description: "Complete the RFC 6455 upgrade and open the connection. Required: a \
969             websocket_handshake event that is answered with neither accept_websocket nor \
970             reject_websocket is refused with HTTP 503, because silence must not read as \
971             consent. Most clients offer no subprotocol; accept them with no 'subprotocol' \
972             field."
973            .to_string(),
974        parameters: vec![Parameter {
975            name: "subprotocol".to_string(),
976            type_hint: "string".to_string(),
977            description:
978                "Only when the event's 'subprotocols' list is not empty: the one of those \
979                 values to agree on, echoed back in Sec-WebSocket-Protocol. Omit it when the \
980                 list is empty. A value the client did not offer is dropped (RFC 6455 \
981                 forbids echoing it) and the connection opens without one."
982                    .to_string(),
983            required: false,
984        }],
985        example: json!({"type": "accept_websocket"}),
986        log_template: Some(
987            LogTemplate::new()
988                .with_info("WS accept subprotocol={subprotocol}")
989                .with_debug("WebSocket accept_websocket: subprotocol={subprotocol}"),
990        ),
991    }
992}
993
994pub fn reject_websocket_action() -> ActionDefinition {
995    ActionDefinition {
996        name: "reject_websocket".to_string(),
997        description:
998            "Refuse the upgrade with an HTTP error instead of opening the connection. Use it \
999             for the wrong path, a missing credential, an unacceptable Origin, or a \
1000             subprotocol you will not speak."
1001                .to_string(),
1002        parameters: vec![
1003            Parameter {
1004                name: "status_code".to_string(),
1005                type_hint: "integer".to_string(),
1006                description:
1007                    "HTTP status to answer with: 401 authentication required, 403 forbidden, \
1008                     404 no such endpoint, 429 too many connections, 503 unavailable. \
1009                     Defaults to 403. Must be 4xx or 5xx."
1010                        .to_string(),
1011                required: false,
1012            },
1013            Parameter {
1014                name: "reason".to_string(),
1015                type_hint: "string".to_string(),
1016                description: "Short explanation sent as the response body and logged.".to_string(),
1017                required: false,
1018            },
1019        ],
1020        example: json!({"type": "reject_websocket", "status_code": 403, "reason": "Origin not allowed"}),
1021        log_template: Some(
1022            LogTemplate::new()
1023                .with_info("WS reject {status_code}")
1024                .with_debug("WebSocket reject_websocket: {status_code} {reason}"),
1025        ),
1026    }
1027}
1028
1029pub fn send_websocket_text_action() -> ActionDefinition {
1030    ActionDefinition {
1031        name: "send_websocket_text".to_string(),
1032        description:
1033            "Send a WebSocket text frame (opcode 0x1) to this connection. This is the normal \
1034             way to answer a client, and it is also how the server speaks first — it is valid \
1035             on websocket_connection_opened, with nothing received yet. The text is the words \
1036             your instructions give, exactly; the example's text is a placeholder, never data."
1037                .to_string(),
1038        parameters: vec![Parameter {
1039            name: "text".to_string(),
1040            type_hint: "string".to_string(),
1041            description:
1042                "The message body, sent as UTF-8. Send JSON by putting the serialised JSON in \
1043                 here as a string."
1044                    .to_string(),
1045            required: true,
1046        }],
1047        example: json!({"type": "send_websocket_text", "text": "<the message, word for word>"}),
1048        log_template: Some(
1049            LogTemplate::new()
1050                .with_info("-> WS text: {preview(text,60)}")
1051                .with_debug("WebSocket send_websocket_text: {preview(text,120)}")
1052                .with_trace("WebSocket text: {preview(text,2000)}"),
1053        ),
1054    }
1055}
1056
1057pub fn send_websocket_binary_action() -> ActionDefinition {
1058    ActionDefinition {
1059        name: "send_websocket_binary".to_string(),
1060        description: "Send a WebSocket binary frame (opcode 0x2) to this connection. 'data' plus \
1061             'encoding' is the same pair a websocket_binary_message event carries, so echoing \
1062             a frame back unchanged is passing both fields straight through."
1063            .to_string(),
1064        parameters: vec![
1065            Parameter {
1066                name: "data".to_string(),
1067                type_hint: "string".to_string(),
1068                description:
1069                    "The payload, interpreted according to 'encoding': literal characters by \
1070                     default, or decoded bytes when 'encoding' is \"base64\" or \"hex\"."
1071                        .to_string(),
1072                required: true,
1073            },
1074            encoding_parameter(),
1075        ],
1076        example: json!({"type": "send_websocket_binary", "data": "AP/+AQ==", "encoding": "base64"}),
1077        log_template: Some(
1078            LogTemplate::new()
1079                .with_info("-> WS binary (encoding={encoding})")
1080                .with_debug(
1081                    "WebSocket send_websocket_binary: encoding={encoding} {preview(data,120)}",
1082                ),
1083        ),
1084    }
1085}
1086
1087pub fn send_websocket_ping_action() -> ActionDefinition {
1088    ActionDefinition {
1089        name: "send_websocket_ping".to_string(),
1090        description:
1091            "Send a ping control frame to check the connection is alive. The client's pong is \
1092             logged and does not trigger an event. Pings arriving from the client are answered \
1093             with a pong automatically — you never need to send one."
1094                .to_string(),
1095        parameters: vec![Parameter {
1096            name: "payload".to_string(),
1097            type_hint: "string".to_string(),
1098            description: "Optional payload echoed back in the pong, at most 125 bytes.".to_string(),
1099            required: false,
1100        }],
1101        example: json!({"type": "send_websocket_ping", "payload": "keepalive"}),
1102        log_template: Some(LogTemplate::new().with_debug("WebSocket send_websocket_ping")),
1103    }
1104}
1105
1106pub fn wait_for_websocket_data_action() -> ActionDefinition {
1107    ActionDefinition {
1108        name: "wait_for_websocket_data".to_string(),
1109        description:
1110            "Do not answer this message yet. The connection enters the Accumulating state and \
1111             this message is held; the next message of the same kind is joined onto it and \
1112             delivered as one event. Use it when a client splits a logical request across \
1113             several frames."
1114                .to_string(),
1115        parameters: vec![],
1116        example: json!({"type": "wait_for_websocket_data"}),
1117        log_template: Some(LogTemplate::new().with_debug("WebSocket waiting for more data")),
1118    }
1119}
1120
1121pub fn close_websocket_action() -> ActionDefinition {
1122    ActionDefinition {
1123        name: "close_websocket".to_string(),
1124        description:
1125            "Start the RFC 6455 closing handshake on this connection: send a close frame with \
1126             a status code, then wait for the peer's close frame before the socket goes away."
1127                .to_string(),
1128        parameters: vec![
1129            Parameter {
1130                name: "code".to_string(),
1131                type_hint: "integer".to_string(),
1132                description:
1133                    "Close status code (default 1000, normal). 1001 going away, 1002 protocol \
1134                     error, 1003 unsupported data, 1008 policy violation, 1009 message too \
1135                     big, 1011 internal error, or 3000-4999 for your own meanings. 1005, 1006 \
1136                     and 1015 are reserved and cannot be sent."
1137                        .to_string(),
1138                required: false,
1139            },
1140            Parameter {
1141                name: "reason".to_string(),
1142                type_hint: "string".to_string(),
1143                description: "Optional UTF-8 explanation, at most 123 bytes.".to_string(),
1144                required: false,
1145            },
1146        ],
1147        example: json!({"type": "close_websocket", "code": 1000, "reason": "bye"}),
1148        log_template: Some(
1149            LogTemplate::new()
1150                .with_info("WS close {code}")
1151                .with_debug("WebSocket close_websocket: code={code} reason={reason}"),
1152        ),
1153    }
1154}
1155
1156pub fn push_websocket_text_action() -> ActionDefinition {
1157    ActionDefinition {
1158        name: "push_websocket_text".to_string(),
1159        description: "Send a text frame to a connection that did not just say anything — the \
1160             server-initiated direction WebSocket exists for. Use it from a scheduled task to \
1161             stream ticks, or to relay one client's message to another."
1162            .to_string(),
1163        parameters: vec![
1164            Parameter {
1165                name: "connection_id".to_string(),
1166                type_hint: "string".to_string(),
1167                description: "Which connection to write to, e.g. \"conn-7\" from \
1168                     list_websocket_connections, or \"*\" to broadcast to every open connection."
1169                    .to_string(),
1170                required: true,
1171            },
1172            Parameter {
1173                name: "text".to_string(),
1174                type_hint: "string".to_string(),
1175                description: "The message body, sent as UTF-8.".to_string(),
1176                required: true,
1177            },
1178            Parameter {
1179                name: "server_id".to_string(),
1180                type_hint: "integer".to_string(),
1181                description:
1182                    "Restrict a \"*\" broadcast to one server's connections. Defaults to the \
1183                     server this action was produced for."
1184                        .to_string(),
1185                required: false,
1186            },
1187        ],
1188        example: json!({"type": "push_websocket_text", "connection_id": "*", "text": "{\"price\":42}"}),
1189        log_template: Some(
1190            LogTemplate::new()
1191                .with_info("WS push text -> {connection_id}")
1192                .with_debug("WebSocket push_websocket_text: {connection_id}"),
1193        ),
1194    }
1195}
1196
1197pub fn push_websocket_binary_action() -> ActionDefinition {
1198    ActionDefinition {
1199        name: "push_websocket_binary".to_string(),
1200        description: "Send a binary frame to a named open connection, or to all of them."
1201            .to_string(),
1202        parameters: vec![
1203            Parameter {
1204                name: "connection_id".to_string(),
1205                type_hint: "string".to_string(),
1206                description: "Connection to write to, or \"*\" to broadcast.".to_string(),
1207                required: true,
1208            },
1209            Parameter {
1210                name: "data".to_string(),
1211                type_hint: "string".to_string(),
1212                description: "The payload, interpreted according to 'encoding'.".to_string(),
1213                required: true,
1214            },
1215            encoding_parameter(),
1216            Parameter {
1217                name: "server_id".to_string(),
1218                type_hint: "integer".to_string(),
1219                description: "Restrict a \"*\" broadcast to one server's connections.".to_string(),
1220                required: false,
1221            },
1222        ],
1223        example: json!({"type": "push_websocket_binary", "connection_id": "conn-7", "data": "AP/+AQ==", "encoding": "base64"}),
1224        log_template: Some(
1225            LogTemplate::new().with_debug("WebSocket push_websocket_binary: {connection_id}"),
1226        ),
1227    }
1228}
1229
1230pub fn list_websocket_connections_action() -> ActionDefinition {
1231    ActionDefinition {
1232        name: "list_websocket_connections".to_string(),
1233        description:
1234            "List the WebSocket connections that are open right now, with their id, remote \
1235             address, request path and negotiated subprotocol. Nothing is retained after a \
1236             connection closes."
1237                .to_string(),
1238        parameters: vec![Parameter {
1239            name: "server_id".to_string(),
1240            type_hint: "integer".to_string(),
1241            description: "Restrict the list to one server. Defaults to every WebSocket server."
1242                .to_string(),
1243            required: false,
1244        }],
1245        example: json!({"type": "list_websocket_connections"}),
1246        log_template: Some(LogTemplate::new().with_debug("WebSocket list_websocket_connections")),
1247    }
1248}
1249
1250pub fn close_websocket_connection_action() -> ActionDefinition {
1251    ActionDefinition {
1252        name: "close_websocket_connection".to_string(),
1253        description: "Close a named open connection (or all of them) with a status code."
1254            .to_string(),
1255        parameters: vec![
1256            Parameter {
1257                name: "connection_id".to_string(),
1258                type_hint: "string".to_string(),
1259                description: "Connection to close, or \"*\" for every open connection.".to_string(),
1260                required: true,
1261            },
1262            Parameter {
1263                name: "code".to_string(),
1264                type_hint: "integer".to_string(),
1265                description: "Close status code, default 1000.".to_string(),
1266                required: false,
1267            },
1268            Parameter {
1269                name: "reason".to_string(),
1270                type_hint: "string".to_string(),
1271                description: "Optional explanation, at most 123 bytes.".to_string(),
1272                required: false,
1273            },
1274            Parameter {
1275                name: "server_id".to_string(),
1276                type_hint: "integer".to_string(),
1277                description: "Restrict a \"*\" close to one server's connections.".to_string(),
1278                required: false,
1279            },
1280        ],
1281        example: json!({"type": "close_websocket_connection", "connection_id": "conn-7", "code": 1001, "reason": "shutting down"}),
1282        log_template: Some(
1283            LogTemplate::new().with_debug("WebSocket close_websocket_connection: {connection_id}"),
1284        ),
1285    }
1286}
1287
1288// ============================================================================
1289// Action constants
1290// ============================================================================
1291
1292pub static ACCEPT_WEBSOCKET_ACTION: LazyLock<ActionDefinition> =
1293    LazyLock::new(accept_websocket_action);
1294pub static REJECT_WEBSOCKET_ACTION: LazyLock<ActionDefinition> =
1295    LazyLock::new(reject_websocket_action);
1296pub static SEND_WEBSOCKET_TEXT_ACTION: LazyLock<ActionDefinition> =
1297    LazyLock::new(send_websocket_text_action);
1298pub static SEND_WEBSOCKET_BINARY_ACTION: LazyLock<ActionDefinition> =
1299    LazyLock::new(send_websocket_binary_action);
1300pub static SEND_WEBSOCKET_PING_ACTION: LazyLock<ActionDefinition> =
1301    LazyLock::new(send_websocket_ping_action);
1302pub static WAIT_FOR_WEBSOCKET_DATA_ACTION: LazyLock<ActionDefinition> =
1303    LazyLock::new(wait_for_websocket_data_action);
1304pub static CLOSE_WEBSOCKET_ACTION: LazyLock<ActionDefinition> =
1305    LazyLock::new(close_websocket_action);
1306
1307// ============================================================================
1308// Event types — every one of these is emitted by src/server/websocket/mod.rs
1309// ============================================================================
1310
1311/// The frames a handler may produce once the connection is open.
1312fn open_connection_actions() -> Vec<ActionDefinition> {
1313    vec![
1314        SEND_WEBSOCKET_TEXT_ACTION.clone(),
1315        SEND_WEBSOCKET_BINARY_ACTION.clone(),
1316        SEND_WEBSOCKET_PING_ACTION.clone(),
1317        CLOSE_WEBSOCKET_ACTION.clone(),
1318    ]
1319}
1320
1321/// The `answer_with` field of `websocket_handshake`: what this upgrade takes.
1322///
1323/// llama3.1:8b, told only to echo messages or to greet each client, answered websocat's
1324/// upgrade - which offers no subprotocol - with `accept_websocket` naming the example's "chat"
1325/// subprotocol (five runs in five), and in another case with `reject_websocket` 403 "no
1326/// subprotocols offered" (five in five). Neither instruction said anything about the
1327/// handshake. The request now says whether any subprotocol was offered, that none is normal,
1328/// and that a refusal needs a reason from the instructions.
1329pub fn handshake_answer_with(offered: &[String]) -> String {
1330    let later = "This step only opens the connection: any message - a greeting, an echo, a \
1331                 reply - is sent after it has opened, never here";
1332    let refuse = "reject_websocket only when your instructions give a reason to refuse this \
1333                  client (its path, its Origin, a missing token)";
1334    if offered.is_empty() {
1335        format!(
1336            "this client offered no subprotocol, which is normal for most clients and never a \
1337             reason to refuse. Answer exactly {{\"type\": \"accept_websocket\"}} and nothing \
1338             else - unless your instructions give a reason to refuse this client (its path, \
1339             its Origin, a missing token), then reject_websocket. {later}"
1340        )
1341    } else {
1342        format!(
1343            "accept_websocket, naming at most one of the subprotocols this client offered ({}) \
1344             in 'subprotocol', or none. {refuse}. {later}",
1345            offered.join(", ")
1346        )
1347    }
1348}
1349
1350/// The `answer_with` field of `websocket_connection_opened`.
1351///
1352/// The event's example used to be a `{"event":"welcome"}` greeting, and with it the model
1353/// spoke first on every connection: told only to echo messages, it greeted, and told to greet
1354/// with "Welcome to NetGet Eval", it sent the example's `{"event":"welcome"}` instead (real-model
1355/// eval, llama3.1:8b, seed 42). Speaking first is the instruction's call, and the words are its.
1356/// "No action" is spelled out as `{"actions": []}` because, told to "return no action", the model
1357/// sent an empty text message instead, and websocat reads a zero-length message as end of input
1358/// and prints nothing after it - the echo that followed was sent and never shown.
1359pub const OPENED_ANSWER_WITH: &str = "speak first only if your instructions say to greet or send \
1360     something as soon as a client connects: then send_websocket_text whose text is exactly the \
1361     words they give. If they say nothing about connecting - an echo server, a server that only \
1362     answers - answer {\"actions\": []}: no action at all, and never an empty message, which \
1363     some clients read as the end of the stream";
1364
1365/// The `answer_with` field of `websocket_text_message`.
1366pub const TEXT_MESSAGE_ANSWER_WITH: &str = "send_websocket_text with the reply your instructions \
1367     call for; to echo, its text is this message's 'text' field exactly, unchanged";
1368
1369/// Emitted for every well-formed upgrade request, before any 101 is written.
1370/// See `WebSocketServer::handle_connection`.
1371pub static WEBSOCKET_HANDSHAKE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1372    EventType::new(
1373        "websocket_handshake",
1374        "A client asked to upgrade an HTTP request to WebSocket. Decide whether to accept, and \
1375         which of the offered subprotocols (if any) to agree on; answer_with says what this \
1376         request takes. Answering with neither accept_websocket nor reject_websocket refuses \
1377         the upgrade with HTTP 503.",
1378        json!({"type": "accept_websocket"}),
1379    )
1380    .with_parameters(vec![
1381        Parameter {
1382            name: "path".to_string(),
1383            type_hint: "string".to_string(),
1384            description: "Request path, without the query string (e.g. \"/ws\")".to_string(),
1385            required: true,
1386        },
1387        Parameter {
1388            name: "query".to_string(),
1389            type_hint: "string".to_string(),
1390            description: "Raw query string after '?', or empty when there is none. Tokens are \
1391                          often passed here."
1392                .to_string(),
1393            required: false,
1394        },
1395        Parameter {
1396            name: "subprotocols".to_string(),
1397            type_hint: "array".to_string(),
1398            description: "Subprotocols the client offered in Sec-WebSocket-Protocol, in its \
1399                          order of preference. accept_websocket may name one of these and \
1400                          nothing else."
1401                .to_string(),
1402            required: true,
1403        },
1404        Parameter {
1405            name: "answer_with".to_string(),
1406            type_hint: "string".to_string(),
1407            description: "What this handshake takes: accept_websocket (naming one of the \
1408                          offered subprotocols only when there are any), or reject_websocket \
1409                          for a reason the instructions give."
1410                .to_string(),
1411            required: true,
1412        },
1413        Parameter {
1414            name: "origin".to_string(),
1415            type_hint: "string".to_string(),
1416            description: "The Origin header, or empty. Browsers always send it; non-browser \
1417                          clients usually do not."
1418                .to_string(),
1419            required: false,
1420        },
1421        Parameter {
1422            name: "headers".to_string(),
1423            type_hint: "object".to_string(),
1424            description: "All request headers, lowercased names to values (repeated headers \
1425                          joined with ', ')."
1426                .to_string(),
1427            required: true,
1428        },
1429        Parameter {
1430            name: "client_ip".to_string(),
1431            type_hint: "string".to_string(),
1432            description: "Remote IP address of the client".to_string(),
1433            required: true,
1434        },
1435        Parameter {
1436            name: "client_port".to_string(),
1437            type_hint: "integer".to_string(),
1438            description: "Remote port of the client".to_string(),
1439            required: true,
1440        },
1441    ])
1442    .with_actions(vec![
1443        ACCEPT_WEBSOCKET_ACTION.clone(),
1444        REJECT_WEBSOCKET_ACTION.clone(),
1445    ])
1446    .with_alternative_example(json!({
1447        "type": "reject_websocket", "status_code": 404, "reason": "No such endpoint"
1448    }))
1449    .with_log_template(
1450        LogTemplate::new()
1451            .with_info("WS upgrade {path} from {client_ip}:{client_port}")
1452            .with_debug("WebSocket handshake {path} subprotocols={subprotocols}")
1453            .with_trace("WebSocket handshake: {json_pretty(.)}"),
1454    )
1455});
1456
1457/// Emitted immediately after the 101 response is written. This is the server's chance to
1458/// speak first. See `WebSocketServer::run_connection`.
1459pub static WEBSOCKET_CONNECTION_OPENED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1460    EventType::new(
1461        "websocket_connection_opened",
1462        "The WebSocket connection is open and nothing has been received yet. Send a greeting, \
1463         a server-hello frame or an initial snapshot now only if your instructions say to; \
1464         otherwise return no action and stay silent until the client speaks. answer_with says \
1465         which.",
1466        json!({"type": "send_websocket_text", "text": "<the greeting your instructions give>"}),
1467    )
1468    .with_parameters(vec![
1469        Parameter {
1470            name: "path".to_string(),
1471            type_hint: "string".to_string(),
1472            description: "Request path the client connected to".to_string(),
1473            required: true,
1474        },
1475        Parameter {
1476            name: "answer_with".to_string(),
1477            type_hint: "string".to_string(),
1478            description: "Whether to speak first: only when the instructions say to greet or \
1479                          announce on connect"
1480                .to_string(),
1481            required: true,
1482        },
1483        Parameter {
1484            name: "subprotocol".to_string(),
1485            type_hint: "string".to_string(),
1486            description: "The subprotocol agreed in the handshake, or empty if none".to_string(),
1487            required: false,
1488        },
1489        Parameter {
1490            name: "client_ip".to_string(),
1491            type_hint: "string".to_string(),
1492            description: "Remote IP address of the client".to_string(),
1493            required: true,
1494        },
1495        Parameter {
1496            name: "client_port".to_string(),
1497            type_hint: "integer".to_string(),
1498            description: "Remote port of the client".to_string(),
1499            required: true,
1500        },
1501        Parameter {
1502            name: "permessage_deflate".to_string(),
1503            type_hint: "object".to_string(),
1504            description: "The RFC 7692 compression parameters agreed with the client, or null \
1505                when the connection is uncompressed. Messages reach the handler as plain text \
1506                either way."
1507                .to_string(),
1508            required: false,
1509        },
1510    ])
1511    .with_actions(open_connection_actions())
1512    .with_log_template(
1513        LogTemplate::new()
1514            .with_info("WS open {path} {client_ip}:{client_port}")
1515            .with_debug("WebSocket connection opened {path} subprotocol={subprotocol}"),
1516    )
1517});
1518
1519/// Emitted for every reassembled text message. See `WebSocketServer::handle_inbound`.
1520pub static WEBSOCKET_TEXT_MESSAGE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1521    EventType::new(
1522        "websocket_text_message",
1523        "A text message arrived from the client. Answer it, push something to another \
1524         connection, or close.",
1525        json!({"type": "send_websocket_text", "text": "<the reply your instructions call for>"}),
1526    )
1527    .with_parameters(vec![
1528        Parameter {
1529            name: "text".to_string(),
1530            type_hint: "string".to_string(),
1531            description: "The complete message, already reassembled from any fragments".to_string(),
1532            required: true,
1533        },
1534        Parameter {
1535            name: "answer_with".to_string(),
1536            type_hint: "string".to_string(),
1537            description: "How this message is answered: the reply the instructions call for, \
1538                          which for an echo is 'text' itself, unchanged"
1539                .to_string(),
1540            required: true,
1541        },
1542        Parameter {
1543            name: "message_bytes".to_string(),
1544            type_hint: "integer".to_string(),
1545            description: "Length of the message in bytes".to_string(),
1546            required: true,
1547        },
1548        Parameter {
1549            name: "subprotocol".to_string(),
1550            type_hint: "string".to_string(),
1551            description: "The subprotocol agreed in the handshake, or empty if none".to_string(),
1552            required: false,
1553        },
1554    ])
1555    .with_actions({
1556        let mut a = open_connection_actions();
1557        a.push(WAIT_FOR_WEBSOCKET_DATA_ACTION.clone());
1558        a
1559    })
1560    .with_alternative_example(json!({"type": "wait_for_websocket_data"}))
1561    .with_alternative_example(json!({"type": "close_websocket", "code": 1000, "reason": "bye"}))
1562    .with_log_template(
1563        LogTemplate::new()
1564            .with_info("WS <- text {message_bytes}B")
1565            .with_debug("WebSocket text message {message_bytes}B")
1566            .with_trace("WebSocket text: {preview(text,200)}"),
1567    )
1568});
1569
1570/// Emitted for every reassembled binary message. See `WebSocketServer::handle_inbound`.
1571pub static WEBSOCKET_BINARY_MESSAGE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1572    EventType::new(
1573        "websocket_binary_message",
1574        "A binary message arrived from the client. To echo it back unchanged, pass this \
1575         event's 'data' AND its 'encoding' straight into send_websocket_binary.",
1576        json!({"type": "send_websocket_binary", "data": "AP/+AQ==", "encoding": "base64"}),
1577    )
1578    .with_parameters(vec![
1579        Parameter {
1580            name: "data".to_string(),
1581            type_hint: "string".to_string(),
1582            description: "The message payload, read according to the 'encoding' field".to_string(),
1583            required: true,
1584        },
1585        Parameter {
1586            name: "encoding".to_string(),
1587            type_hint: "string".to_string(),
1588            description:
1589                "How to read 'data': \"utf8\" means it is the received bytes as literal text \
1590                 (used when every byte is printable ASCII), \"base64\" means it is the \
1591                 received bytes base64-encoded. send_websocket_binary accepts the same values, \
1592                 so the pair round-trips."
1593                    .to_string(),
1594            required: true,
1595        },
1596        Parameter {
1597            name: "message_bytes".to_string(),
1598            type_hint: "integer".to_string(),
1599            description: "Length of the decoded message in bytes".to_string(),
1600            required: true,
1601        },
1602        Parameter {
1603            name: "subprotocol".to_string(),
1604            type_hint: "string".to_string(),
1605            description: "The subprotocol agreed in the handshake, or empty if none".to_string(),
1606            required: false,
1607        },
1608    ])
1609    .with_actions({
1610        let mut a = open_connection_actions();
1611        a.push(WAIT_FOR_WEBSOCKET_DATA_ACTION.clone());
1612        a
1613    })
1614    .with_log_template(
1615        LogTemplate::new()
1616            .with_info("WS <- binary {message_bytes}B")
1617            .with_debug("WebSocket binary message {message_bytes}B encoding={encoding}"),
1618    )
1619});
1620
1621/// Emitted for every ping control frame. The pong is sent automatically by the framing layer;
1622/// this event exists so the model can react to a heartbeat (e.g. push fresh data).
1623pub static WEBSOCKET_PING_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1624    EventType::new(
1625        "websocket_ping",
1626        "The client sent a ping. A pong is already on its way — this event is only an \
1627         opportunity to react to the heartbeat, for example by pushing fresh data. Returning \
1628         no action is normal.",
1629        json!({"type": "send_websocket_text", "text": "{\"event\":\"heartbeat\"}"}),
1630    )
1631    .with_parameters(vec![
1632        Parameter {
1633            name: "payload".to_string(),
1634            type_hint: "string".to_string(),
1635            description: "Ping payload, read according to 'encoding'".to_string(),
1636            required: true,
1637        },
1638        Parameter {
1639            name: "encoding".to_string(),
1640            type_hint: "string".to_string(),
1641            description: "\"utf8\" or \"base64\", same meaning as on websocket_binary_message"
1642                .to_string(),
1643            required: true,
1644        },
1645    ])
1646    .with_actions(open_connection_actions())
1647    .with_log_template(
1648        LogTemplate::new().with_debug("WebSocket ping received: {preview(payload,64)}"),
1649    )
1650});
1651
1652/// Emitted when the client starts the closing handshake. See `WebSocketServer::run_connection`.
1653pub static WEBSOCKET_CLOSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1654    EventType::new(
1655        "websocket_close",
1656        "The client is closing the connection. The close frame is echoed back automatically; \
1657         this event is the last chance to record what happened or send a final frame.",
1658        json!({"type": "show_message", "message": "client disconnected"}),
1659    )
1660    .with_parameters(vec![
1661        Parameter {
1662            name: "code".to_string(),
1663            type_hint: "integer".to_string(),
1664            description:
1665                "Close status code sent by the client. 1000 normal, 1001 going away, 1005 means \
1666                 the client sent no code at all."
1667                    .to_string(),
1668            required: true,
1669        },
1670        Parameter {
1671            name: "reason".to_string(),
1672            type_hint: "string".to_string(),
1673            description: "Close reason text, or empty".to_string(),
1674            required: false,
1675        },
1676    ])
1677    .with_actions(vec![
1678        SEND_WEBSOCKET_TEXT_ACTION.clone(),
1679        CLOSE_WEBSOCKET_ACTION.clone(),
1680    ])
1681    .with_log_template(
1682        LogTemplate::new()
1683            .with_info("WS close from client code={code}")
1684            .with_debug("WebSocket close received: code={code} reason={reason}"),
1685    )
1686});
1687
1688/// All WebSocket server event types. Each is emitted by `src/server/websocket/mod.rs`.
1689pub fn get_websocket_event_types() -> Vec<EventType> {
1690    vec![
1691        WEBSOCKET_HANDSHAKE_EVENT.clone(),
1692        WEBSOCKET_CONNECTION_OPENED_EVENT.clone(),
1693        WEBSOCKET_TEXT_MESSAGE_EVENT.clone(),
1694        WEBSOCKET_BINARY_MESSAGE_EVENT.clone(),
1695        WEBSOCKET_PING_EVENT.clone(),
1696        WEBSOCKET_CLOSE_EVENT.clone(),
1697    ]
1698}