1pub mod actions;
3use crate::client::http_fetch::FetchClient;
4use crate::client::llm_budget::call_llm_for_client;
5use crate::llm::actions::client_trait::{Client, ClientActionResult};
6use crate::logging::emit::Log;
7use crate::protocol::{ConnectContext, Event};
8use crate::server::socketio::packet::{self, Eio, Kind, Sio};
9use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
10use crate::state::{AccessLogOwner, ClientStatus};
11pub use actions::SocketIoClientProtocol;
12use anyhow::{bail, ensure, Context, Result};
13use futures::{SinkExt, StreamExt};
14use serde_json::{json, Value};
15use std::collections::{HashMap, HashSet};
16use std::net::SocketAddr;
17use std::sync::Arc;
18use std::time::Duration;
19use tokio::sync::{mpsc, Mutex};
20use tokio_tungstenite::tungstenite::Message;
21
22pub const DEFAULT_TRANSPORT: &str = "websocket";
23const TIMEOUT: Duration = Duration::from_secs(30);
24
25type WsStream =
26 tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
27
28enum Transport {
29 Ws(Mutex<futures::stream::SplitSink<WsStream, Message>>),
30 Polling { fetch: FetchClient, url: String },
31}
32
33impl Transport {
34 fn name(&self) -> &'static str {
35 match self {
36 Transport::Ws(_) => "websocket",
37 Transport::Polling { .. } => "polling",
38 }
39 }
40
41 async fn send(&self, packets: Vec<String>) -> Result<()> {
42 match self {
43 Transport::Ws(sink) => {
44 let mut s = sink.lock().await;
45 for p in packets {
46 s.send(Message::Text(p)).await?;
47 }
48 }
49 Transport::Polling { fetch, url } => {
50 let r = fetch
51 .post(url)
52 .header("Content-Type", "text/plain;charset=UTF-8")
53 .body(packet::join_payload(&packets))
54 .send()
55 .await?;
56 ensure!(
57 r.status().as_u16() == 200,
58 "polling POST answered HTTP {}",
59 r.status()
60 );
61 }
62 }
63 Ok(())
64 }
65}
66
67fn open_info(open: &str) -> Result<String> {
68 let v: Value = serde_json::from_str(open).context("open packet is not JSON")?;
69 let sid = v["sid"]
70 .as_str()
71 .filter(|s| !s.is_empty() && s.len() <= 128)
72 .context("open packet has no sid")?;
73 Ok(sid.to_owned())
74}
75
76pub async fn connect(ctx: ConnectContext) -> Result<SocketAddr> {
77 let p = ctx.startup_params.as_ref();
78 let path = p
79 .map(|p| p.get_optional_string("path"))
80 .transpose()?
81 .flatten()
82 .unwrap_or_else(|| crate::server::socketio::DEFAULT_PATH.to_owned());
83 ensure!(
84 path.starts_with('/') && path.len() <= 128 && !path.contains(['?', '#', ' ']),
85 "path must be an absolute path"
86 );
87 let path = format!("{}/", path.trim_end_matches('/'));
88 let transport = p
89 .map(|p| p.get_optional_string("transport"))
90 .transpose()?
91 .flatten()
92 .unwrap_or_else(|| DEFAULT_TRANSPORT.to_owned());
93 ensure!(
94 transport == "websocket" || transport == "polling",
95 "transport must be websocket or polling"
96 );
97 let namespaces: Vec<String> = match p
98 .map(|p| p.get_optional_array("namespaces"))
99 .transpose()?
100 .flatten()
101 {
102 None => vec!["/".into()],
103 Some(list) => list
104 .iter()
105 .map(|n| {
106 n.as_str()
107 .filter(|n| packet::namespace_ok(n))
108 .map(str::to_owned)
109 .ok_or_else(|| anyhow::anyhow!("namespaces look like /name"))
110 })
111 .collect::<Result<_>>()?,
112 };
113 let auth = p
114 .map(|p| p.get_optional_object("auth"))
115 .transpose()?
116 .flatten()
117 .map(|m| Value::Object(m.clone()));
118 let (incoming_tx, incoming_rx) = mpsc::channel::<Result<Eio>>(256);
119 let local: SocketAddr = "0.0.0.0:0".parse()?;
120 let transport: Arc<Transport> = if transport == "websocket" {
121 let url = format!("ws://{}{path}?EIO=4&transport=websocket", ctx.remote_addr);
122 let mut cfg = tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default();
123 cfg.max_message_size = Some(packet::MAX_PAYLOAD);
124 let (ws, _) = tokio::time::timeout(
125 TIMEOUT,
126 tokio_tungstenite::connect_async_with_config(url.as_str(), Some(cfg), false),
127 )
128 .await
129 .context("WebSocket connect timed out")??;
130 let (sink, mut stream) = ws.split();
131 let first = tokio::time::timeout(TIMEOUT, stream.next())
132 .await
133 .context("no Engine.IO open packet")?;
134 let Some(Ok(Message::Text(t))) = first else {
135 bail!("the server did not open an Engine.IO session")
136 };
137 let Eio::Open(info) = Eio::decode(&t)? else {
138 bail!("expected the Engine.IO open packet")
139 };
140 open_info(&info)?;
141 let reader = tokio::spawn(async move {
142 while let Some(m) = stream.next().await {
143 let item = match m {
144 Ok(Message::Text(t)) => Eio::decode(&t),
145 Ok(Message::Close(_)) | Err(_) => break,
146 Ok(_) => continue,
147 };
148 if incoming_tx.send(item).await.is_err() {
149 break;
150 }
151 }
152 });
153 ctx.state.register_client_task(ctx.client_id, reader).await;
154 Arc::new(Transport::Ws(Mutex::new(sink)))
155 } else {
156 let base = format!("http://{}{path}?EIO=4&transport=polling", ctx.remote_addr);
157 crate::client::http_fetch::check_url(&base)?;
158 #[cfg(not(target_arch = "wasm32"))]
159 let fetch = FetchClient::from_reqwest(
160 crate::llm::ollama_client::configured_for_endpoint(
161 reqwest::Client::builder().timeout(Duration::from_secs(120)),
162 &base,
163 )
164 .build()?,
165 );
166 #[cfg(target_arch = "wasm32")]
167 let fetch = FetchClient::transport(Duration::from_secs(120));
168 let fetch = fetch
169 .with_max_body(packet::MAX_PAYLOAD)
170 .with_user_agent("netget-socketio");
171 let r = fetch
172 .get(&base)
173 .send()
174 .await
175 .context("Engine.IO handshake")?;
176 ensure!(
177 r.status().as_u16() == 200,
178 "Engine.IO handshake answered HTTP {}",
179 r.status()
180 );
181 let body = String::from_utf8(r.bytes().await?.to_vec()).context("handshake is not text")?;
182 let packets = packet::split_payload(&body)?;
183 let Some(Eio::Open(info)) = packets.first().cloned() else {
184 bail!("expected the Engine.IO open packet")
185 };
186 let sid = open_info(&info)?;
187 let url = format!("{base}&sid={sid}");
188 for p in packets.into_iter().skip(1) {
189 incoming_tx.try_send(Ok(p))?;
190 }
191 let poll_fetch = fetch.clone();
192 let poll_url = url.clone();
193 let reader = tokio::spawn(async move {
194 loop {
195 let r = match poll_fetch.get(&poll_url).send().await {
196 Ok(r) if r.status().as_u16() == 200 => r,
197 _ => break,
198 };
199 let Ok(body) = r.bytes().await else { break };
200 let Ok(text) = String::from_utf8(body.to_vec()) else {
201 break;
202 };
203 let items: Vec<Result<Eio>> = match packet::split_payload(&text) {
204 Ok(list) => list.into_iter().map(Ok).collect(),
205 Err(e) => vec![Err(e)],
206 };
207 for item in items {
208 let closing = matches!(item, Ok(Eio::Close));
209 if incoming_tx.send(item).await.is_err() || closing {
210 return;
211 }
212 }
213 }
214 });
215 ctx.state.register_client_task(ctx.client_id, reader).await;
216 Arc::new(Transport::Polling { fetch, url })
217 };
218 let connects: Vec<String> = namespaces
219 .iter()
220 .map(|n| Eio::Message(Sio::new(Kind::Connect, n, None, auth.clone()).encode()).encode())
221 .collect();
222 transport
223 .send(connects)
224 .await
225 .context("sending namespace CONNECT")?;
226 ctx.state
227 .update_client_status(ctx.client_id, ClientStatus::Connected)
228 .await;
229 let external =
230 crate::client::command_support::register_command_channel(&ctx.state, ctx.client_id).await;
231 let (internal_tx, internal_rx) = mpsc::channel::<Value>(16);
232 let (event_tx, mut event_rx) = mpsc::channel::<Event>(64);
233 let events_ctx = ctx.clone();
234 let dispatcher = tokio::spawn(async move {
235 let protocol = SocketIoClientProtocol;
236 while let Some(event) = event_rx.recv().await {
237 let instruction = events_ctx
238 .state
239 .get_instruction_for_client(events_ctx.client_id)
240 .await
241 .unwrap_or_default();
242 let memory = events_ctx
243 .state
244 .get_memory_for_client(events_ctx.client_id)
245 .await
246 .unwrap_or_default();
247 match call_llm_for_client(
248 &events_ctx.llm_client,
249 &events_ctx.state,
250 events_ctx.client_id.to_string(),
251 &instruction,
252 &memory,
253 Some(&event),
254 &protocol,
255 &events_ctx.status_tx,
256 )
257 .await
258 {
259 Ok(result) => {
260 if let Some(memory) = result.memory_updates {
261 events_ctx
262 .state
263 .set_memory_for_client(events_ctx.client_id, memory)
264 .await;
265 }
266 for action in result.actions {
267 if internal_tx.send(action).await.is_err() {
268 return;
269 }
270 }
271 }
272 Err(e) => Log::new(Some(&events_ctx.status_tx))
273 .warn(format!("Socket.IO client handler: {e}")),
274 }
275 }
276 });
277 ctx.state
278 .register_client_task(ctx.client_id, dispatcher)
279 .await;
280 let session_ctx = ctx.clone();
281 let task = tokio::spawn(async move {
282 let result = session(
283 &session_ctx,
284 &transport,
285 auth,
286 external,
287 internal_rx,
288 incoming_rx,
289 event_tx,
290 )
291 .await;
292 let _ = transport.send(vec![Eio::Close.encode()]).await;
293 let status = match result {
294 Ok(()) => ClientStatus::Disconnected,
295 Err(e) => {
296 Log::new(Some(&session_ctx.status_tx)).warn(format!("Socket.IO client ended: {e}"));
297 ClientStatus::Error(e.to_string())
298 }
299 };
300 session_ctx
301 .state
302 .update_client_status(session_ctx.client_id, status)
303 .await;
304 session_ctx
305 .state
306 .remove_client_handle(session_ctx.client_id)
307 .await;
308 let _ = session_ctx.status_tx.send("__UPDATE_UI__".into());
309 });
310 ctx.state.register_client_task(ctx.client_id, task).await;
311 Ok(local)
312}
313
314fn reply(command: Option<ClientCommand>, outcome: Result<ClientSendOutcome>) {
315 if let Some(c) = command {
316 crate::client::command_support::reply(c, outcome);
317 }
318}
319
320#[allow(clippy::too_many_arguments)]
321async fn session(
322 ctx: &ConnectContext,
323 transport: &Transport,
324 auth: Option<Value>,
325 mut external: mpsc::Receiver<ClientCommand>,
326 mut internal: mpsc::Receiver<Value>,
327 mut incoming: mpsc::Receiver<Result<Eio>>,
328 events: mpsc::Sender<Event>,
329) -> Result<()> {
330 let mut connected: HashSet<String> = HashSet::new();
331 let mut pending: HashMap<u64, (String, String)> = HashMap::new();
332 let mut next_ack: u64 = 1;
333 loop {
334 let (action, command) = tokio::select! {
335 c = external.recv() => match c { Some(c) => (c.action.clone(), Some(c)), None => return Ok(()) },
336 a = internal.recv() => match a { Some(a) => (a, None), None => return Ok(()) },
337 m = incoming.recv() => {
338 let p = match m {
339 Some(Ok(p)) => p,
340 Some(Err(e)) => bail!("protocol error from the server: {e}"),
341 None => {
342 events.send(Event::new(&actions::DISCONNECTED_EVENT, json!({"namespace": "*", "reason": "transport close"}))).await.ok();
343 return Ok(());
344 }
345 };
346 match p {
347 Eio::Ping(d) => transport.send(vec![Eio::Pong(d).encode()]).await?,
348 Eio::Close => {
349 events.send(Event::new(&actions::DISCONNECTED_EVENT, json!({"namespace": "*", "reason": "io server disconnect"}))).await.ok();
350 return Ok(());
351 }
352 Eio::Message(m) => {
353 let sio = match Sio::decode(&m) {
354 Ok(s) => s,
355 Err(e) => {
356 Log::new(Some(&ctx.status_tx)).warn(format!("Socket.IO client: {e}"));
357 continue;
358 }
359 };
360 let event = match sio.kind {
361 Kind::Connect => {
362 connected.insert(sio.nsp.clone());
363 let sid = sio.data.as_ref().and_then(|d| d["sid"].as_str()).unwrap_or_default().to_owned();
364 Event::new(&actions::CONNECTED_EVENT, json!({"namespace": sio.nsp, "socket_id": sid, "transport": transport.name()}))
365 }
366 Kind::ConnectError => {
367 let d = sio.data.clone().unwrap_or(Value::Null);
368 let message = d["message"].as_str().or(d.as_str()).unwrap_or("refused").to_owned();
369 Event::new(&actions::CONNECT_ERROR_EVENT, json!({"namespace": sio.nsp, "message": message, "data": d.get("data")}))
370 }
371 Kind::Disconnect => {
372 connected.remove(&sio.nsp);
373 Event::new(&actions::DISCONNECTED_EVENT, json!({"namespace": sio.nsp, "reason": "io server disconnect"}))
374 }
375 Kind::Event => match sio.event() {
376 Ok((name, args)) => Event::new(&actions::EVENT_EVENT, json!({"namespace": sio.nsp, "event": name, "args": args, "ack_id": sio.id})),
377 Err(e) => {
378 Log::new(Some(&ctx.status_tx)).warn(format!("Socket.IO client: {e}"));
379 continue;
380 }
381 },
382 Kind::Ack => {
383 let Some((nsp, event)) = sio.id.and_then(|id| pending.remove(&id)) else { continue };
384 Event::new(&actions::ACK_EVENT, json!({"namespace": nsp, "event": event, "args": sio.data.and_then(|d| d.as_array().cloned()).unwrap_or_default()}))
385 }
386 Kind::BinaryEvent | Kind::BinaryAck => continue,
387 };
388 events.send(event).await.context("Socket.IO event consumer stopped")?;
389 }
390 Eio::Open(_) | Eio::Pong(_) | Eio::Upgrade | Eio::Noop => {}
391 }
392 continue;
393 }
394 };
395 match SocketIoClientProtocol.execute_action(action.clone()) {
396 Ok(ClientActionResult::Disconnect) => {
397 reply(command, Ok(ClientSendOutcome::Disconnected));
398 return Ok(());
399 }
400 Ok(_) => {}
401 Err(e) => {
402 reply(
403 command,
404 Ok(ClientSendOutcome::Rejected {
405 error: e.to_string(),
406 }),
407 );
408 continue;
409 }
410 }
411 let nsp = action["namespace"].as_str().unwrap_or("/").to_owned();
412 let built: Result<Sio> = match action["type"].as_str().unwrap_or_default() {
413 "socketio_emit" => {
414 if !connected.contains(&nsp) {
415 Err(anyhow::anyhow!("not connected to {nsp}"))
416 } else {
417 let id = action["ack"].as_bool().unwrap_or(false).then(|| {
418 let id = next_ack;
419 next_ack += 1;
420 pending.insert(
421 id,
422 (
423 nsp.clone(),
424 action["event"].as_str().unwrap_or_default().to_owned(),
425 ),
426 );
427 id
428 });
429 let mut payload = vec![action["event"].clone()];
430 payload.extend(action["args"].as_array().cloned().unwrap_or_default());
431 Ok(Sio::new(Kind::Event, &nsp, id, Some(Value::Array(payload))))
432 }
433 }
434 "socketio_ack" => Ok(Sio::new(
435 Kind::Ack,
436 &nsp,
437 action["ack_id"].as_u64(),
438 Some(Value::Array(
439 action["args"].as_array().cloned().unwrap_or_default(),
440 )),
441 )),
442 "socketio_connect_namespace" => Ok(Sio::new(
443 Kind::Connect,
444 &nsp,
445 None,
446 action
447 .get("auth")
448 .filter(|a| a.is_object())
449 .cloned()
450 .or_else(|| auth.clone()),
451 )),
452 _ => {
453 connected.remove(&nsp);
454 Ok(Sio::new(Kind::Disconnect, &nsp, None, None))
455 }
456 };
457 let packet = match built {
458 Ok(p) => Eio::Message(p.encode()).encode(),
459 Err(e) => {
460 reply(
461 command,
462 Ok(ClientSendOutcome::Rejected {
463 error: e.to_string(),
464 }),
465 );
466 continue;
467 }
468 };
469 let n = packet.len();
470 let sent = transport.send(vec![packet]).await;
471 if command.is_some() {
472 ctx.state
473 .record_access_log(
474 AccessLogOwner::Client(ctx.client_id.as_u32()),
475 "Socket.IO",
476 None,
477 "injected_action",
478 json!({"type": action["type"], "event": action["event"]}),
479 vec![json!({"ok": sent.is_ok()})],
480 )
481 .await;
482 }
483 match sent {
484 Ok(()) => reply(command, Ok(ClientSendOutcome::Sent { bytes_sent: n })),
485 Err(e) => {
486 reply(command, Err(anyhow::anyhow!("{e}")));
487 return Err(e);
488 }
489 }
490 }
491}