use axum::{
extract::ws::{Message, WebSocket, WebSocketUpgrade},
http::HeaderMap,
response::Response,
};
use futures_util::{SinkExt, StreamExt};
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message as WsMessage;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
const WS_QUEUE_CAPACITY: usize = 64;
#[derive(Debug)]
enum RelayMsg {
Text(String),
Binary(Vec<u8>),
Ping(Vec<u8>),
Pong(Vec<u8>),
Close,
}
pub async fn relay(
ws: WebSocketUpgrade,
port: u16,
uri: &axum::http::Uri,
headers: HeaderMap,
client_ip: Option<std::net::IpAddr>,
) -> Response {
let target = upstream_ws_url(uri, port);
ws.on_upgrade(move |socket| async move {
if let Err(e) = relay_inner(socket, target, port, headers, client_ip).await {
tracing::warn!("ws relay terminated (port {}): {}", port, e);
}
})
}
fn upstream_ws_url(uri: &axum::http::Uri, port: u16) -> String {
super::upstream_url(uri, port).replacen("http://", "ws://", 1)
}
async fn relay_inner(
socket: WebSocket,
target: String,
port: u16,
headers: HeaderMap,
client_ip: Option<std::net::IpAddr>,
) -> anyhow::Result<()> {
let mut req: tokio_tungstenite::tungstenite::handshake::client::Request =
target.into_client_request()?;
let rewritten = super::rewrite_request_headers(&headers, port, true, client_ip);
for (name, value) in rewritten.iter() {
let lower = name.as_str().to_ascii_lowercase();
if lower == "sec-websocket-key" || lower == "sec-websocket-version" {
continue;
}
req.headers_mut().insert(name.clone(), value.clone());
}
let (upstream, _) = tokio_tungstenite::connect_async(req)
.await
.map_err(|e| anyhow::anyhow!("upstream ws connect failed: {e}"))?;
let (mut client_tx, mut client_rx) = socket.split();
let (mut up_tx, mut up_rx) = upstream.split();
let (to_up_tx, mut to_up_rx) = mpsc::channel::<RelayMsg>(WS_QUEUE_CAPACITY);
let (to_client_tx, mut to_client_rx) = mpsc::channel::<RelayMsg>(WS_QUEUE_CAPACITY);
let mut read_client = tokio::spawn(async move {
while let Some(msg) = client_rx.next().await {
let Ok(msg) = msg else { break };
let close = matches!(msg, Message::Close(_));
if let Some(r) = axum_to_relay(msg) {
enqueue(&to_up_tx, r, "client→upstream");
}
if close {
break;
}
}
});
let mut write_up = tokio::spawn(async move {
while let Some(msg) = to_up_rx.recv().await {
let close = matches!(msg, RelayMsg::Close);
if up_tx.send(relay_to_ws(msg)).await.is_err() {
break;
}
if close {
break;
}
}
let _ = up_tx.close().await;
});
let mut read_up = tokio::spawn(async move {
while let Some(msg) = up_rx.next().await {
let Ok(msg) = msg else { break };
let close = matches!(msg, WsMessage::Close(_));
if let Some(r) = ws_to_relay(msg) {
enqueue(&to_client_tx, r, "upstream→client");
}
if close {
break;
}
}
});
let mut write_client = tokio::spawn(async move {
while let Some(msg) = to_client_rx.recv().await {
let close = matches!(msg, RelayMsg::Close);
if client_tx.send(relay_to_axum(msg)).await.is_err() {
break;
}
if close {
break;
}
}
let _ = client_tx.close().await;
});
tokio::select! {
_ = &mut read_client => {}
_ = &mut write_up => {}
_ = &mut read_up => {}
_ = &mut write_client => {}
}
read_client.abort();
read_up.abort();
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), &mut write_up).await;
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), &mut write_client).await;
write_up.abort();
write_client.abort();
Ok(())
}
fn enqueue<T>(tx: &mpsc::Sender<T>, item: T, label: &str) {
match tx.try_send(item) {
Ok(()) => {}
Err(mpsc::error::TrySendError::Full(_)) => {
tracing::warn!("ws relay {label} queue full, dropped incoming frame");
}
Err(mpsc::error::TrySendError::Closed(_)) => {}
}
}
fn axum_to_relay(msg: Message) -> Option<RelayMsg> {
Some(match msg {
Message::Text(t) => RelayMsg::Text(t.to_string()),
Message::Binary(b) => RelayMsg::Binary(b.to_vec()),
Message::Ping(d) => RelayMsg::Ping(d.to_vec()),
Message::Pong(d) => RelayMsg::Pong(d.to_vec()),
Message::Close(_) => RelayMsg::Close,
})
}
fn ws_to_relay(msg: WsMessage) -> Option<RelayMsg> {
Some(match msg {
WsMessage::Text(t) => RelayMsg::Text(t.to_string()),
WsMessage::Binary(b) => RelayMsg::Binary(b.to_vec()),
WsMessage::Ping(d) => RelayMsg::Ping(d.to_vec()),
WsMessage::Pong(d) => RelayMsg::Pong(d.to_vec()),
WsMessage::Close(_) => RelayMsg::Close,
_ => return None,
})
}
fn relay_to_ws(msg: RelayMsg) -> WsMessage {
match msg {
RelayMsg::Text(t) => WsMessage::Text(t.into()),
RelayMsg::Binary(b) => WsMessage::Binary(b.into()),
RelayMsg::Ping(d) => WsMessage::Ping(d.into()),
RelayMsg::Pong(d) => WsMessage::Pong(d.into()),
RelayMsg::Close => WsMessage::Close(None),
}
}
fn relay_to_axum(msg: RelayMsg) -> Message {
match msg {
RelayMsg::Text(t) => Message::Text(t.into()),
RelayMsg::Binary(b) => Message::Binary(b.into()),
RelayMsg::Ping(d) => Message::Ping(d.into()),
RelayMsg::Pong(d) => Message::Pong(d.into()),
RelayMsg::Close => Message::Close(None),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn enqueue_drops_incoming_when_full() {
let (tx, mut rx) = mpsc::channel::<i32>(2);
enqueue(&tx, 1, "test");
enqueue(&tx, 2, "test");
enqueue(&tx, 3, "test"); assert_eq!(rx.try_recv().unwrap(), 1);
assert_eq!(rx.try_recv().unwrap(), 2);
assert!(rx.try_recv().is_err()); }
#[test]
fn enqueue_no_drop_below_capacity() {
let (tx, mut rx) = mpsc::channel::<i32>(2);
enqueue(&tx, 1, "test");
enqueue(&tx, 2, "test");
assert_eq!(rx.try_recv().unwrap(), 1);
assert_eq!(rx.try_recv().unwrap(), 2);
}
#[test]
fn upstream_ws_url_swaps_scheme() {
let uri: axum::http::Uri = "/proxy/3000/hmr?x=1".parse().unwrap();
assert_eq!(upstream_ws_url(&uri, 3000), "ws://127.0.0.1:3000/hmr?x=1");
}
#[test]
fn close_message_roundtrips_through_relay_types() {
assert!(matches!(axum_to_relay(Message::Close(None)), Some(RelayMsg::Close)));
assert!(matches!(relay_to_ws(RelayMsg::Close), WsMessage::Close(_)));
assert!(matches!(relay_to_axum(RelayMsg::Close), Message::Close(_)));
}
}