use std::{collections::HashMap, sync::Arc, time::Duration};
use axum::extract::ws::{Message, WebSocket};
use chrono::Utc;
use serde_json::Value;
use tokio::sync::mpsc;
use uuid::Uuid;
use crate::{
models::{
is_valid_renderer_name, GraphicInstance, InstanceSnapshot, InstanceState, RenderTarget,
RendererId, RendererMessage, ServerMessage,
},
store::renderers::{RendererRegistry, RendererSession},
AppState,
};
const PING_TIMEOUT: Duration = Duration::from_secs(30);
const CHANNEL_SIZE: usize = 64;
fn sanitize_for_logs(s: &str) -> String {
s.chars()
.filter(|c| !c.is_control() || *c == ' ')
.take(100)
.collect()
}
fn snapshot_to_instance(snapshot: &InstanceSnapshot) -> GraphicInstance {
let state = if snapshot.current_step.is_some() {
InstanceState::Playing
} else {
InstanceState::Loaded
};
GraphicInstance {
instance_id: snapshot.instance_id,
graphic_id: snapshot.graphic_id.clone(),
data: snapshot.data.clone(),
loaded_at: Utc::now(),
state,
current_step: snapshot.current_step,
}
}
struct Hello {
id: RendererId,
name: String,
render_target: RenderTarget,
render_target_schema: Option<Value>,
description: Option<String>,
custom_actions: Option<Value>,
render_characteristics: Option<Value>,
instances: Option<Vec<InstanceSnapshot>>,
}
pub async fn handle_session(mut socket: WebSocket, state: AppState, query: String) {
let Some(hello) = wait_for_hello(&mut socket).await else {
return;
};
if !is_valid_renderer_name(&hello.name) {
tracing::warn!(
"renderer hello with invalid name rejected: '{}'",
sanitize_for_logs(&hello.name)
);
let close_msg = "invalid renderer id: must be 1-64 of A-Z a-z 0-9 - _ ., and not '.' or '..'";
let _ = socket
.send(Message::Close(Some(axum::extract::ws::CloseFrame {
code: 1008, reason: close_msg.into(),
})))
.await;
return;
}
if !state.access.authorize_name(&hello.name, &query).await {
tracing::warn!(
"renderer name authorization failed: '{}'",
sanitize_for_logs(&hello.name)
);
let _ = socket
.send(Message::Close(Some(axum::extract::ws::CloseFrame {
code: 1008, reason: "renderer name not authorized".into(),
})))
.await;
return;
}
let renderer_id = hello.id.clone();
let connection_id = Uuid::new_v4();
let (tx, mut rx) = mpsc::channel::<ServerMessage>(CHANNEL_SIZE);
let instances = hello
.instances
.as_ref()
.map(|snapshots| {
snapshots
.iter()
.map(|s| (s.instance_id, snapshot_to_instance(s)))
.collect()
})
.unwrap_or_default();
let session = RendererSession {
id: renderer_id.clone(),
name: hello.name.clone(),
connected_at: Utc::now(),
render_target: hello.render_target,
render_target_schema: hello.render_target_schema,
description: hello.description,
custom_actions: hello.custom_actions,
render_characteristics: hello.render_characteristics,
sender: tx,
instances,
pending: HashMap::new(),
messages_sent: std::sync::atomic::AtomicU64::new(0),
messages_received: std::sync::atomic::AtomicU64::new(0),
timed_out: false,
connection_id,
};
if let Err(err) = state.renderers.register(session).await {
tracing::warn!("renderer registration failed: {err}");
let _ = socket
.send(Message::Close(Some(axum::extract::ws::CloseFrame {
code: 1008, reason: "renderer name already connected".into(),
})))
.await;
return;
}
state
.access
.on_renderer_connected(&hello.name, &query)
.await;
let welcome = serde_json::json!({ "type": "welcome", "rendererId": renderer_id });
if socket
.send(Message::Text(welcome.to_string()))
.await
.is_err()
{
state
.renderers
.unregister(&renderer_id, connection_id)
.await;
return;
}
run_session(&mut socket, &mut rx, &renderer_id, &state.renderers).await;
state.renderers.unregister(&renderer_id, connection_id).await;
tracing::info!("renderer {renderer_id} disconnected");
}
async fn wait_for_hello(socket: &mut WebSocket) -> Option<Hello> {
match tokio::time::timeout(Duration::from_secs(10), socket.recv()).await {
Ok(Some(Ok(Message::Text(text)))) => match serde_json::from_str::<RendererMessage>(&text) {
Ok(RendererMessage::Hello {
renderer_id,
render_target,
capabilities,
instances,
}) => {
let instance_count = instances.as_ref().map_or(0, |v| v.len());
tracing::info!(
"renderer hello: {} (renderTarget: {}, instances: {})",
sanitize_for_logs(&renderer_id),
render_target,
instance_count
);
let capability = |key: &str| capabilities.get(key).filter(|v| !v.is_null()).cloned();
Some(Hello {
id: renderer_id.clone(), name: renderer_id,
render_target,
render_target_schema: capability("renderTargetSchema"),
description: capability("description")
.and_then(|v| v.as_str().map(str::to_owned)),
custom_actions: capability("customActions"),
render_characteristics: capability("renderCharacteristics"),
instances,
})
}
Ok(_) => {
tracing::warn!(
"renderer's first message wasn't hello: {}",
sanitize_for_logs(&text)
);
None
}
Err(err) => {
tracing::warn!(
"renderer sent an invalid hello (missing/invalid renderTarget?): {err} — raw: {}",
sanitize_for_logs(&text)
);
None
}
},
Ok(Some(Ok(_))) => {
tracing::warn!("renderer's first WS frame wasn't a text message");
None
}
Ok(Some(Err(err))) => {
tracing::warn!("WS error while waiting for renderer hello: {err}");
None
}
Ok(None) => {
tracing::warn!("renderer closed the connection before sending hello");
None
}
Err(_) => {
tracing::warn!("renderer didn't send hello within 10s");
None
}
}
}
async fn run_session(
socket: &mut WebSocket,
rx: &mut mpsc::Receiver<ServerMessage>,
renderer_id: &str,
registry: &Arc<RendererRegistry>,
) {
let mut last_ping = tokio::time::Instant::now();
loop {
let timeout = tokio::time::sleep_until(last_ping + PING_TIMEOUT);
tokio::select! {
msg = socket.recv() => match msg {
Some(Ok(Message::Text(text))) => match serde_json::from_str::<RendererMessage>(&text) {
Ok(RendererMessage::Ping) => {
last_ping = tokio::time::Instant::now();
let _ = socket.send(Message::Text(r#"{"type":"pong"}"#.into())).await;
}
Ok(RendererMessage::Hello { .. }) => {}
Ok(result_msg) => {
if let Some(request_id) = result_msg.request_id() {
registry.resolve(renderer_id, request_id, result_msg).await;
}
}
Err(err) => {
tracing::warn!("renderer {renderer_id} sent an unparseable message: {err}");
}
},
Some(Ok(Message::Close(_))) | None => break,
_ => {}
},
cmd = rx.recv() => match cmd {
Some(msg) => {
if let Ok(json) = serde_json::to_string(&msg) {
if socket.send(Message::Text(json)).await.is_err() {
break;
}
}
}
None => break,
},
_ = timeout => {
tracing::warn!("renderer {renderer_id} ping timeout, disconnecting");
break;
}
}
}
}