pub mod connection;
pub mod fanout;
#[cfg(feature = "redis")]
pub mod redis_fanout;
use std::{future::Future, marker::PhantomData, net::SocketAddr, pin::Pin, sync::Arc, time::Duration};
use axum::{
extract::{
ws::{Message as Frame, WebSocket, WebSocketUpgrade},
ConnectInfo, Query, State,
},
http::HeaderMap,
response::{IntoResponse, Response},
routing::get,
Json, Router,
};
use futures_util::{SinkExt, StreamExt};
use serde::{de::DeserializeOwned, Serialize};
use tokio::sync::mpsc;
use tracing::{debug, info, warn};
pub use connection::{CnctdConnection, ConnectionFormat, ConnectionInfo, Connections};
use self::connection::{Outbound, QueryParams};
use crate::{
auth::CnctdAuth,
router::{error::ErrorResponse, message::Message, SocketRouter},
utils::client_ip,
};
pub type OnBinaryHandler = Arc<dyn Fn(String, Vec<u8>) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
pub type OnDisconnect = Arc<dyn Fn(ConnectionInfo) + Send + Sync>;
#[derive(Clone)]
pub struct SocketConfig<R> {
pub router: R,
pub secret: Option<Vec<u8>>,
pub redis_url: Option<String>,
pub on_disconnect: Option<OnDisconnect>,
pub on_binary: Option<OnBinaryHandler>,
pub path: String,
pub announce_connected: bool,
pub keepalive: Option<Duration>,
}
impl<R> SocketConfig<R> {
pub fn new(router: R, secret: Option<Vec<u8>>, redis_url: Option<String>, on_disconnect: Option<OnDisconnect>) -> Self {
Self {
router,
secret,
redis_url,
on_disconnect,
on_binary: None,
path: "/ws".to_string(),
announce_connected: false,
keepalive: Some(Duration::from_secs(30)),
}
}
pub fn with_keepalive(mut self, every: Option<Duration>) -> Self {
self.keepalive = every;
self
}
pub fn with_connected_announcement(mut self, on: bool) -> Self {
self.announce_connected = on;
self
}
pub fn with_on_binary(mut self, handler: OnBinaryHandler) -> Self {
self.on_binary = Some(handler);
self
}
pub fn with_path(mut self, path: &str) -> Self {
self.path = if path.starts_with('/') { path.to_string() } else { format!("/{}", path) };
self
}
}
pub struct CnctdSocket;
impl CnctdSocket {
pub async fn broadcast_message(msg: &Message) -> anyhow::Result<()> {
Connections::global()?.broadcast(msg).await
}
}
struct WsState<Req, Resp, R> {
router: Arc<R>,
connections: Connections,
secret: Option<Vec<u8>>,
on_disconnect: Option<OnDisconnect>,
on_binary: Option<OnBinaryHandler>,
announce_connected: bool,
keepalive: Option<Duration>,
_marker: PhantomData<fn() -> (Req, Resp)>,
}
impl<Req, Resp, R> Clone for WsState<Req, Resp, R> {
fn clone(&self) -> Self {
Self {
router: self.router.clone(),
connections: self.connections.clone(),
secret: self.secret.clone(),
on_disconnect: self.on_disconnect.clone(),
on_binary: self.on_binary.clone(),
announce_connected: self.announce_connected,
keepalive: self.keepalive,
_marker: PhantomData,
}
}
}
pub(crate) fn router<Req, Resp, R>(config: SocketConfig<R>, connections: Connections) -> Router
where
Req: DeserializeOwned + Send + 'static,
Resp: Serialize + Send + 'static,
R: SocketRouter<Req, Resp>,
{
let state = WsState::<Req, Resp, R> {
router: Arc::new(config.router),
connections,
secret: config.secret,
on_disconnect: config.on_disconnect,
on_binary: config.on_binary,
announce_connected: config.announce_connected,
keepalive: config.keepalive,
_marker: PhantomData,
};
Router::new().route(&config.path, get(upgrade::<Req, Resp, R>)).with_state(state)
}
fn refuse(err: ErrorResponse) -> Response {
(err.status.status_code(), Json(err)).into_response()
}
async fn upgrade<Req, Resp, R>(
ws: WebSocketUpgrade,
State(state): State<WsState<Req, Resp, R>>,
Query(params): Query<QueryParams>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
) -> Response
where
Req: DeserializeOwned + Send + 'static,
Resp: Serialize + Send + 'static,
R: SocketRouter<Req, Resp>,
{
let connections = state.connections.clone();
let connection_id = match (params.connection_id, params.subscriptions) {
(Some(id), _) => {
if !connections.contains(&id).await {
return refuse(ErrorResponse::new(None, Some(format!("unknown connection_id {}", id))));
}
id
}
(None, Some(subs)) => {
let subscriptions: Vec<String> =
subs.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
let format = ConnectionFormat::from_str_opt(params.format.as_deref());
let id = connections.register(subscriptions, client_ip(&headers, Some(peer)), format).await;
info!(connection_id = %id, "inline-registered connection");
id
}
(None, None) => {
return refuse(ErrorResponse::new(
Some(crate::router::error::ErrorCode::BadRequest),
Some("connection_id or subscriptions query parameter required".into()),
));
}
};
if let Some(token) = params.token {
match &state.secret {
Some(secret) => match CnctdAuth::verify_auth_token(secret.clone(), &token) {
Ok(user_id) => {
let _ = connections.update_user_id(&connection_id, &user_id).await;
let _ = connections.update_authenticated(&connection_id, true).await;
}
Err(e) => {
return refuse(ErrorResponse::new(
Some(crate::router::error::ErrorCode::Unauthorized),
Some(format!("invalid token: {}", e)),
));
}
},
None => warn!(connection_id, "token given but the socket has no secret; ignoring"),
}
}
ws.on_upgrade(move |socket| run::<Req, Resp, R>(socket, state, connection_id))
}
async fn run<Req, Resp, R>(socket: WebSocket, state: WsState<Req, Resp, R>, connection_id: String)
where
Req: DeserializeOwned + Send + 'static,
Resp: Serialize + Send + 'static,
R: SocketRouter<Req, Resp>,
{
let connections = state.connections;
let (tx, mut rx) = mpsc::unbounded_channel::<Outbound>();
if let Err(e) = connections.attach_sender(&connection_id, tx.clone()).await {
warn!(connection_id, "could not attach socket: {}", e);
return;
}
debug!(connection_id, "socket open");
if state.announce_connected {
if let Ok(info) = connections.info(&connection_id).await {
if info.format == ConnectionFormat::Json {
let hello = Message::new("socket", "connected", Some(serde_json::to_value(&info).unwrap_or_default()));
if let Ok(s) = serde_json::to_string(&hello) {
let _ = tx.send(Outbound::Text(s));
}
}
}
}
let (mut ws_tx, mut ws_rx) = socket.split();
let outbound = async {
while let Some(out) = rx.recv().await {
let frame = match out {
Outbound::Text(s) => Frame::Text(s.into()),
Outbound::Binary(b) => Frame::Binary(b.into()),
Outbound::Ping => Frame::Ping(Default::default()),
};
if ws_tx.send(frame).await.is_err() {
break;
}
}
};
let router = state.router;
let on_binary = state.on_binary;
let reply_tx = tx.clone();
let id_for_inbound = connection_id.clone();
let inbound = async {
while let Some(Ok(frame)) = ws_rx.next().await {
match frame {
Frame::Text(text) => match serde_json::from_str::<Req>(text.as_str()) {
Ok(msg) => {
if let Some(reply) = router.route(msg, id_for_inbound.clone()).await {
match serde_json::to_string(&reply) {
Ok(s) => {
let _ = reply_tx.send(Outbound::Text(s));
}
Err(e) => warn!(connection_id = %id_for_inbound, "reply not serializable: {}", e),
}
}
}
Err(_) => debug!(connection_id = %id_for_inbound, "dropped text frame that is not a request"),
},
Frame::Binary(bytes) => {
if let Some(handler) = &on_binary {
handler(id_for_inbound.clone(), bytes.to_vec()).await;
}
}
Frame::Close(_) => break,
Frame::Ping(_) | Frame::Pong(_) => {}
}
}
};
let keepalive_tx = tx.clone();
let keepalive = async {
match state.keepalive {
Some(every) => {
let mut tick = tokio::time::interval(every);
tick.tick().await; loop {
tick.tick().await;
if keepalive_tx.send(Outbound::Ping).is_err() {
break;
}
}
}
None => std::future::pending::<()>().await,
}
};
tokio::select! {
_ = outbound => {},
_ = inbound => {},
_ = keepalive => {},
}
debug!(connection_id, "socket closed");
if let Some(callback) = state.on_disconnect {
if let Ok(info) = connections.info(&connection_id).await {
callback(info);
}
}
connections.remove(&connection_id).await;
}