1use 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#[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
49struct WsConnEntry {
51 server_id: u32,
52 remote_addr: String,
53 path: String,
54 subprotocol: Option<String>,
55 tx: mpsc::UnboundedSender<WsOut>,
56}
57
58static WS_CONNECTIONS: LazyLock<Mutex<HashMap<u32, WsConnEntry>>> =
62 LazyLock::new(|| Mutex::new(HashMap::new()));
63
64pub 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
86pub 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
95pub 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
102pub 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
127fn 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 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
165pub 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
193pub 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
240pub 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 offered_subprotocols: Vec<String>,
253 speaking_first: AtomicBool,
257}
258
259impl Default for WebSocketProtocol {
260 fn default() -> Self {
261 Self::new()
262 }
263}
264
265impl WebSocketProtocol {
266 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 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 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 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 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 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 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 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
606fn 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 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
639impl 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 "ETH>IP>TCP>HTTP>WebSocket"
745 }
746
747 fn keywords(&self) -> Vec<&'static str> {
748 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 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 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 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
946fn 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
1288pub 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
1307fn 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
1321pub 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
1350pub 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
1365pub 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
1369pub 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
1457pub 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
1519pub 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
1570pub 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
1621pub 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
1652pub 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
1688pub 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}