use std::time::Duration;
use futures::SinkExt;
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::http::Request;
use tokio_tungstenite::tungstenite::Message;
use super::provider::{Handshake, Outgoing};
pub type WsSink = futures::stream::SplitSink<
tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
Message,
>;
pub type WsStream = futures::stream::SplitStream<
tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
>;
impl From<Outgoing> for Message {
fn from(o: Outgoing) -> Self {
match o {
Outgoing::Text(t) => Message::Text(t.into()),
Outgoing::Binary(b) => Message::Binary(b.into()),
}
}
}
pub(crate) fn host_of(url: &str) -> &str {
url.trim_start_matches("wss://")
.trim_start_matches("ws://")
.split('/')
.next()
.unwrap_or_default()
}
pub fn build_request(hs: &Handshake) -> Result<Request<()>, String> {
let mut builder = Request::builder()
.uri(&hs.url)
.header("Host", host_of(&hs.url))
.header("Connection", "Upgrade")
.header("Upgrade", "websocket")
.header("Sec-WebSocket-Version", "13")
.header(
"Sec-WebSocket-Key",
tokio_tungstenite::tungstenite::handshake::client::generate_key(),
);
for (name, value) in &hs.headers {
builder = builder.header(name.as_str(), value.as_str());
}
builder.body(()).map_err(|e| format!("request build failed: {}", e))
}
pub async fn connect(hs: &Handshake) -> Result<(WsSink, WsStream), String> {
use futures::StreamExt;
let request = build_request(hs)?;
let (stream, _) = tokio_tungstenite::connect_async(request)
.await
.map_err(|e| format!("connect failed: {}", e))?;
Ok(stream.split())
}
pub async fn run_send_task(mut sink: WsSink, mut rx: mpsc::Receiver<Outgoing>, name: &'static str) {
while let Some(msg) = rx.recv().await {
if sink.send(msg.into()).await.is_err() {
log::warn!("{}: send failed — closing send task", name);
break;
}
}
let _ = sink.close().await;
log::debug!("{}: send task exited", name);
}
pub async fn run_keepalive_task(
tx: mpsc::Sender<Outgoing>,
interval: Duration,
msg: Outgoing,
name: &'static str,
) {
loop {
tokio::time::sleep(interval).await;
if tx.send(msg.clone()).await.is_err() {
break;
}
log::trace!("{}: sent keepalive", name);
}
log::debug!("{}: keepalive task exited", name);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn host_of_strips_scheme_and_path() {
assert_eq!(host_of("wss://api.sarvam.ai/speech-to-text/ws?a=1"), "api.sarvam.ai");
assert_eq!(host_of("ws://localhost:8080/stt"), "localhost:8080");
assert_eq!(host_of("wss://api.deepgram.com"), "api.deepgram.com");
}
#[test]
fn build_request_includes_provider_headers_and_upgrade() {
let hs = Handshake::new("wss://api.sarvam.ai/speech-to-text/ws?model=saaras%3Av3")
.header("api-subscription-key", "secret");
let req = build_request(&hs).expect("request should build");
let h = req.headers();
assert_eq!(h.get("api-subscription-key").unwrap(), "secret");
assert_eq!(h.get("Host").unwrap(), "api.sarvam.ai");
assert_eq!(h.get("Upgrade").unwrap(), "websocket");
assert_eq!(h.get("Sec-WebSocket-Version").unwrap(), "13");
assert!(h.get("Sec-WebSocket-Key").is_some());
}
#[test]
fn outgoing_converts_to_ws_message() {
assert!(matches!(
Message::from(Outgoing::Text("hi".into())),
Message::Text(_)
));
assert!(matches!(
Message::from(Outgoing::Binary(vec![1, 2, 3])),
Message::Binary(_)
));
}
}