use std::future::pending;
use futures::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
use aimux_core::error::{AiMuxError, ApiCallError};
use aimux_core::options::TimeoutConfiguration;
use aimux_core::shared::AbortSignal;
pub struct WebSocketRequest {
pub url: String,
pub headers: Vec<(String, String)>,
pub subprotocols: Vec<String>,
pub abort_signal: Option<AbortSignal>,
pub timeout: Option<TimeoutConfiguration>,
}
#[derive(Debug, Clone)]
pub enum WsMessage {
Text(String),
Binary(Vec<u8>),
}
pub struct WsConnection {
stream: WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>,
abort: Option<AbortSignal>,
first_chunk_deadline: Option<tokio::time::Instant>,
chunk_timeout: Option<std::time::Duration>,
total_deadline: Option<tokio::time::Instant>,
}
async fn abort_future(abort: &Option<AbortSignal>) {
match abort {
Some(signal) => signal.cancelled().await,
None => pending().await,
}
}
fn ws_error(msg: impl std::fmt::Display) -> AiMuxError {
AiMuxError::ApiCall(ApiCallError {
message: msg.to_string(),
..Default::default()
})
}
enum ConnectError {
Timeout,
Tungstenite(tokio_tungstenite::tungstenite::Error),
}
async fn connect_with_timeout(
request: tokio_tungstenite::tungstenite::http::Request<()>,
timeout: Option<std::time::Duration>,
) -> Result<
(
WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>,
tokio_tungstenite::tungstenite::http::Response<Option<Vec<u8>>>,
),
ConnectError,
> {
let fut = tokio_tungstenite::connect_async(request);
match timeout {
Some(d) => match tokio::time::timeout(d, fut).await {
Ok(inner) => inner.map_err(ConnectError::Tungstenite),
Err(_) => Err(ConnectError::Timeout),
},
None => fut.await.map_err(ConnectError::Tungstenite),
}
}
pub async fn ws_connect(req: &WebSocketRequest) -> Result<WsConnection, AiMuxError> {
let mut http_req = req
.url
.as_str()
.into_client_request()
.map_err(|e| AiMuxError::InvalidArgument(format!("invalid WebSocket URL: {e}")))?;
for (k, v) in &req.headers {
http_req.headers_mut().insert(
tokio_tungstenite::tungstenite::http::HeaderName::try_from(k.as_str()).map_err(
|e| AiMuxError::InvalidArgument(format!("invalid WS header name {k}: {e}")),
)?,
HeaderValue::from_str(v).map_err(|e| {
AiMuxError::InvalidArgument(format!("invalid WS header value for {k}: {e}"))
})?,
);
}
if !req.subprotocols.is_empty() {
http_req.headers_mut().insert(
"Sec-WebSocket-Protocol",
HeaderValue::from_str(&req.subprotocols.join(", ")).map_err(|e| {
AiMuxError::InvalidArgument(format!("invalid WS subprotocols: {e}"))
})?,
);
}
let first_chunk_deadline = req
.timeout
.as_ref()
.and_then(|t| t.first_chunk_ms)
.map(|ms| tokio::time::Instant::now() + tokio::time::Duration::from_millis(ms));
let connect_deadline = first_chunk_deadline;
let (stream, _response) = tokio::select! {
biased;
_ = abort_future(&req.abort_signal) => {
return Err(AiMuxError::Aborted);
}
res = connect_with_timeout(http_req, connect_deadline.map(|d| d - tokio::time::Instant::now())) => match res {
Ok(v) => v,
Err(ConnectError::Timeout) => {
return Err(AiMuxError::Timeout("websocket connect timed out".into()));
}
Err(ConnectError::Tungstenite(e)) => {
return Err(ws_error(format!("websocket connect failed: {e}")));
}
},
};
Ok(WsConnection {
stream,
abort: req.abort_signal.clone(),
first_chunk_deadline,
chunk_timeout: req
.timeout
.as_ref()
.and_then(|t| t.chunk_ms)
.map(tokio::time::Duration::from_millis),
total_deadline: req
.timeout
.as_ref()
.and_then(|t| t.total_ms)
.map(|ms| tokio::time::Instant::now() + tokio::time::Duration::from_millis(ms)),
})
}
impl WsConnection {
pub async fn send_text(&mut self, text: &str) -> Result<(), AiMuxError> {
tokio::select! {
biased;
_ = abort_future(&self.abort) => Err(AiMuxError::Aborted),
_ = total_deadline_future(&self.total_deadline), if self.total_deadline.is_some() => {
Err(AiMuxError::Timeout("websocket send exceeded total timeout".into()))
}
res = self.stream.send(Message::Text(text.to_string())) => {
res.map_err(|e| ws_error(format!("websocket send failed: {e}")))
}
}
}
pub async fn send_binary(&mut self, bytes: &[u8]) -> Result<(), AiMuxError> {
tokio::select! {
biased;
_ = abort_future(&self.abort) => Err(AiMuxError::Aborted),
_ = total_deadline_future(&self.total_deadline), if self.total_deadline.is_some() => {
Err(AiMuxError::Timeout("websocket send exceeded total timeout".into()))
}
res = self.stream.send(Message::Binary(bytes.to_vec())) => {
res.map_err(|e| ws_error(format!("websocket send failed: {e}")))
}
}
}
pub async fn next(&mut self) -> Option<Result<WsMessage, AiMuxError>> {
let chunk_idle = self.current_chunk_deadline();
loop {
tokio::select! {
biased;
_ = abort_future(&self.abort) => return Some(Err(AiMuxError::Aborted)),
_ = deadline_future(self.first_chunk_deadline), if self.first_chunk_deadline.is_some() => {
return Some(Err(AiMuxError::Timeout("timed out waiting for first websocket event".into())));
}
_ = deadline_future(chunk_idle), if chunk_idle.is_some() => {
return Some(Err(AiMuxError::Timeout("websocket chunk idle timeout".into())));
}
_ = deadline_future(self.total_deadline), if self.total_deadline.is_some() => {
return Some(Err(AiMuxError::Timeout("websocket total timeout".into())));
}
msg = self.stream.next() => match msg {
None => return None,
Some(Ok(Message::Text(t))) => {
self.on_event();
return Some(Ok(WsMessage::Text(t)));
}
Some(Ok(Message::Binary(b))) => {
self.on_event();
return Some(Ok(WsMessage::Binary(b.to_vec())));
}
Some(Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_))) => {
continue;
}
Some(Ok(Message::Close(frame))) => {
let detail = frame
.map(|f| format!(" (code {}: {})", u16::from(f.code), f.reason))
.unwrap_or_default();
return Some(Err(ws_error(format!(
"websocket closed by peer{detail}"
))));
}
Some(Err(e)) => {
return Some(Err(ws_error(format!("websocket error: {e}"))));
}
},
}
}
}
pub async fn close(&mut self) {
let close_fut = self.stream.close(Some(
tokio_tungstenite::tungstenite::protocol::frame::CloseFrame {
code: tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::Normal,
reason: std::borrow::Cow::Borrowed("finished"),
},
));
tokio::select! {
res = close_fut => {
let _ = res;
}
_ = tokio::time::sleep(std::time::Duration::from_secs(5)) => {
tracing::warn!(
"aimux: websocket close handshake timed out; abandoning socket"
);
}
}
}
fn on_event(&mut self) {
self.first_chunk_deadline = None;
}
fn current_chunk_deadline(&self) -> Option<tokio::time::Instant> {
self.chunk_timeout.map(|d| tokio::time::Instant::now() + d)
}
}
async fn deadline_future(deadline: Option<tokio::time::Instant>) {
match deadline {
Some(d) => tokio::time::sleep_until(d).await,
None => pending().await,
}
}
async fn total_deadline_future(deadline: &Option<tokio::time::Instant>) {
match deadline {
Some(d) => tokio::time::sleep_until(*d).await,
None => pending().await,
}
}