Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
[package]
|
||||
name = "websocket"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
base64.workspace = true
|
||||
percent-encoding = "2.3.1"
|
||||
itertools.workspace = true
|
||||
futures.workspace = true
|
||||
futures-util.workspace = true
|
||||
http.workspace = true
|
||||
cfg-if.workspace = true
|
||||
log.workspace = true
|
||||
thiserror.workspace = true
|
||||
pin-project.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
futures-test-sink = "0.1.1"
|
||||
tokio = { workspace = true, features = ["macros", "rt", "time"] }
|
||||
|
||||
[target.'cfg(target_family = "wasm")'.dependencies]
|
||||
ws_stream_wasm = "0.7"
|
||||
graphql-ws-client = {workspace = true, features = ["ws_stream_wasm"]}
|
||||
|
||||
[target.'cfg(not(target_family = "wasm"))'.dependencies]
|
||||
async-io.workspace = true
|
||||
async-net = "2.0.0"
|
||||
async-tungstenite = {version = "0.28.2", features = ["tokio-rustls-manual-roots"]}
|
||||
http-body-util = "0.1"
|
||||
hyper = { workspace = true, features = ["client", "http1"] }
|
||||
hyper-util = { version = "0.1", features = ["tokio"] }
|
||||
tokio = { workspace = true, features = ["io-util", "net", "time"] }
|
||||
tokio-rustls = "0.26.4"
|
||||
graphql-ws-client = {workspace = true, features = ["tungstenite"]}
|
||||
rustls = "0.23.29"
|
||||
rustls-platform-verifier = "0.6.1"
|
||||
@@ -0,0 +1,144 @@
|
||||
//! A common websocket API that works for native and `wasm` targets.
|
||||
//! The returned [`WebSocket`] implements [graphql_ws_client::websockets::WebsocketMessage],
|
||||
//! allowing the returned socket to be used as the backing socket for a graphql websocket client.
|
||||
//! Unfortunately, this means that this crate depends on [`graphql_ws_client`] as a dependency even
|
||||
//! though it doesn't assume anything about the underlying protocol of the websocket. To remove this
|
||||
//! dependency, we would need to move the [`WebsocketMessage`] trait and
|
||||
//! `graphql_ws_client::wasm_websocket_combined_split` into a common location that both this crate
|
||||
//! and[`graphql_ws_client`] depend on.
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), path = "native.rs")]
|
||||
#[cfg_attr(target_family = "wasm", path = "wasm.rs")]
|
||||
mod imp;
|
||||
mod sink_map_err;
|
||||
|
||||
use anyhow::anyhow;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub use async_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use async_tungstenite::tungstenite::http::HeaderValue;
|
||||
use futures_util::{future, SinkExt, TryStreamExt};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use itertools::Itertools;
|
||||
use thiserror::Error;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub use async_tungstenite::tungstenite;
|
||||
|
||||
use crate::sink_map_err::map_err;
|
||||
|
||||
// Unfortunately, `anyhow::Error` does not implement `std::error::Error`, which is required by the
|
||||
// `WebsocketMessage`. To workaround this, we implement a wrapper around `anyhow::Error` using
|
||||
// `thiserror` as suggested in https://github.com/dtolnay/anyhow/issues/63#issuecomment-591011454.
|
||||
#[derive(Error, Debug)]
|
||||
#[error(transparent)]
|
||||
pub struct Error(#[from] anyhow::Error);
|
||||
|
||||
/// The message received / sent to the websocket.
|
||||
#[derive(Debug)]
|
||||
pub struct Message(imp::Message);
|
||||
|
||||
pub trait WebsocketMessage {
|
||||
fn new(text: String) -> Self;
|
||||
|
||||
fn text(&self) -> Option<&str>;
|
||||
|
||||
/// Construct a new message using the `Binary` websocket frame.
|
||||
fn new_binary(bytes: Vec<u8>) -> Self;
|
||||
|
||||
/// Returns the bytes if this message was from a `Binary` websocket frame or `None` if the
|
||||
/// message was from any other frame type.
|
||||
fn binary(&self) -> Option<&[u8]>;
|
||||
|
||||
/// Construct a new message using the `Text` websocket frame.
|
||||
fn new_text(text: String) -> Self;
|
||||
}
|
||||
|
||||
impl WebsocketMessage for Message {
|
||||
fn new(text: String) -> Self {
|
||||
Message(imp::Message::new(text))
|
||||
}
|
||||
|
||||
fn text(&self) -> Option<&str> {
|
||||
self.0.text()
|
||||
}
|
||||
|
||||
fn new_binary(bytes: Vec<u8>) -> Self {
|
||||
Self(imp::Message::new_binary(bytes))
|
||||
}
|
||||
|
||||
fn binary(&self) -> Option<&[u8]> {
|
||||
self.0.binary()
|
||||
}
|
||||
|
||||
fn new_text(text: String) -> Self {
|
||||
Self(imp::Message::new_text(text))
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`WebSocket`] that works natively and on the web. To connect to a websocket
|
||||
/// with just a URL and an optional set of protocols, use [`Websocket::connect`].
|
||||
///
|
||||
/// To connect to a websocket with an enriched client request (e.g. with additional
|
||||
/// request headers), you can also use [`Websocket::connect`] with an [`http::Request`] but
|
||||
/// this support is only available for non-wasm targets; custom request headers are not supported
|
||||
/// for websockets on the web.
|
||||
///
|
||||
/// In either case, the caller will have a [`Websocket`] returned.
|
||||
/// To write or read from the resulting socket, use [`WebSocket::split`].
|
||||
pub struct WebSocket(imp::WebSocket);
|
||||
|
||||
impl WebSocket {
|
||||
/// Split the [`WebSocket`] into separate [`Stream`] and [`Sink`] objects.
|
||||
pub async fn split(self) -> (impl Sink, impl Stream) {
|
||||
let (sink, stream) = self.0.split().await;
|
||||
let sink = sink.with(|item: Message| future::ok(item.0));
|
||||
|
||||
let sink = map_err(sink, |e: imp::Error| Error(anyhow!(e)));
|
||||
let stream = stream.map_err(|e| Error(anyhow!(e))).map_ok(Message);
|
||||
(sink, stream)
|
||||
}
|
||||
|
||||
/// Create the [`WebSocket`] by connecting using the provided `request`.
|
||||
/// For non-wasm WebSockets, the request can be enriched with custom
|
||||
/// request headers.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub async fn connect(
|
||||
request: impl IntoClientRequest,
|
||||
protocols: impl IntoIterator<Item = &str>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let mut request = request.into_client_request()?;
|
||||
let protocols = protocols.into_iter().join(", ");
|
||||
if !protocols.is_empty() {
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("Sec-WebSocket-Protocol", HeaderValue::from_str(&protocols)?);
|
||||
}
|
||||
let socket = imp::connect(request).await?;
|
||||
Ok(Self(socket))
|
||||
}
|
||||
|
||||
/// Create the [`WebSocket`] by connecting against the provided `url`.
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub async fn connect(
|
||||
url: impl AsRef<str>,
|
||||
protocols: impl IntoIterator<Item = &str>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let socket = imp::connect(url, protocols).await?;
|
||||
Ok(Self(socket))
|
||||
}
|
||||
|
||||
pub async fn into_graphql_client_builder(self) -> graphql_ws_client::ClientBuilder {
|
||||
self.0.into_graphql_client_builder().await
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait that defines a [`Sink`] returned by the websocket.
|
||||
pub trait Sink: futures::Sink<Message, Error = Error> + Send + Unpin + 'static {}
|
||||
|
||||
/// Trait that defines a [`Stream`] returned by the websocket.
|
||||
pub trait Stream: futures::Stream<Item = Result<Message, Error>> + Send + Unpin + 'static {}
|
||||
|
||||
impl<T> Sink for T where T: futures::Sink<Message, Error = Error> + Send + Unpin + 'static {}
|
||||
impl<T> Stream for T where T: futures::Stream<Item = Result<Message, Error>> + Send + Unpin + 'static
|
||||
{}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! A WebSocket+TLS client based on `async-tungstenite`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_tungstenite::{
|
||||
tokio::{
|
||||
client_async_tls_with_connector_and_config, connect_async_with_tls_connector, ClientStream,
|
||||
},
|
||||
tungstenite::client::IntoClientRequest,
|
||||
WebSocketStream,
|
||||
};
|
||||
use futures::{Sink, Stream};
|
||||
use futures_util::StreamExt as _;
|
||||
use rustls_platform_verifier::ConfigVerifierExt;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_rustls::TlsConnector;
|
||||
|
||||
use crate::WebsocketMessage;
|
||||
|
||||
mod proxy;
|
||||
|
||||
pub use async_tungstenite::tungstenite::Message;
|
||||
|
||||
pub struct WebSocket(WebSocketStream<ClientStream<TcpStream>>);
|
||||
|
||||
static CLIENT_CONFIG: std::sync::LazyLock<Result<Arc<rustls::ClientConfig>, rustls::Error>> =
|
||||
std::sync::LazyLock::new(|| Ok(Arc::new(rustls::ClientConfig::with_platform_verifier()?)));
|
||||
|
||||
/// Connects to a WebSocket address (optionally secured by TLS).
|
||||
///
|
||||
/// When `HTTPS_PROXY`, `HTTP_PROXY`, or `ALL_PROXY` environment variables are set,
|
||||
/// the connection is tunneled through the specified HTTP proxy using the CONNECT method.
|
||||
/// The `NO_PROXY` environment variable is respected to bypass the proxy for specific hosts.
|
||||
pub async fn connect(request: impl IntoClientRequest + Unpin) -> anyhow::Result<WebSocket> {
|
||||
let request = request.into_client_request()?;
|
||||
let tls_connector = Some(TlsConnector::from(CLIENT_CONFIG.clone()?));
|
||||
|
||||
if let Some(proxy_info) = proxy::resolve_proxy(request.uri())? {
|
||||
log::debug!(
|
||||
"Using HTTP proxy {}:{} for WebSocket connection to {}",
|
||||
proxy_info.host,
|
||||
proxy_info.port,
|
||||
request.uri(),
|
||||
);
|
||||
let tcp_stream = proxy::connect_via_proxy(&proxy_info, request.uri()).await?;
|
||||
let (stream, _response) =
|
||||
client_async_tls_with_connector_and_config(request, tcp_stream, tls_connector, None)
|
||||
.await?;
|
||||
Ok(WebSocket(stream))
|
||||
} else {
|
||||
let stream = connect_async_with_tls_connector(request, tls_connector)
|
||||
.await?
|
||||
.0;
|
||||
Ok(WebSocket(stream))
|
||||
}
|
||||
}
|
||||
|
||||
impl WebSocket {
|
||||
pub async fn split(
|
||||
self,
|
||||
) -> (
|
||||
impl Sink<Message, Error = Error>,
|
||||
impl Stream<Item = Result<Message, Error>>,
|
||||
) {
|
||||
self.0.split()
|
||||
}
|
||||
|
||||
pub async fn into_graphql_client_builder(self) -> graphql_ws_client::ClientBuilder {
|
||||
graphql_ws_client::Client::build(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub type Error = async_tungstenite::tungstenite::Error;
|
||||
|
||||
impl WebsocketMessage for Message {
|
||||
fn new_binary(bytes: Vec<u8>) -> Self {
|
||||
Self::Binary(bytes)
|
||||
}
|
||||
|
||||
fn binary(&self) -> Option<&[u8]> {
|
||||
match self {
|
||||
Message::Binary(bytes) => Some(bytes.as_ref()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn new_text(text: String) -> Self {
|
||||
Self::Text(text)
|
||||
}
|
||||
|
||||
fn new(text: String) -> Self {
|
||||
Self::new_text(text)
|
||||
}
|
||||
|
||||
fn text(&self) -> Option<&str> {
|
||||
match self {
|
||||
Message::Text(text) => Some(text),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
//! HTTP proxy support for WebSocket connections.
|
||||
//!
|
||||
//! Reads standard proxy environment variables (`HTTPS_PROXY`, `HTTP_PROXY`, `ALL_PROXY`)
|
||||
//! and establishes tunneled connections via HTTP CONNECT.
|
||||
//!
|
||||
//! TODO: Switch to tungstenite's native proxy support once it is available and remove this
|
||||
//! module: <https://github.com/snapview/tungstenite-rs/pull/530>
|
||||
|
||||
use std::env;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{bail, Context};
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
use http_body_util::Empty;
|
||||
use hyper::body::Bytes;
|
||||
use hyper_util::rt::TokioIo;
|
||||
use percent_encoding::percent_decode_str;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::timeout;
|
||||
use url::Url;
|
||||
|
||||
const PROXY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const PROXY_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Proxy connection info parsed from environment variables.
|
||||
#[derive(Debug)]
|
||||
pub struct ProxyInfo {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
/// Base64-encoded `user:password` for `Proxy-Authorization: Basic` header.
|
||||
pub basic_auth: Option<String>,
|
||||
}
|
||||
|
||||
/// Reads proxy environment variables and returns proxy info if a proxy should be used
|
||||
/// for the given target URI.
|
||||
///
|
||||
/// Env var precedence:
|
||||
/// - For TLS targets (`wss://`): `HTTPS_PROXY` / `https_proxy`, then `ALL_PROXY` / `all_proxy`.
|
||||
/// - For plain targets (`ws://`): `HTTP_PROXY` / `http_proxy`, then `ALL_PROXY` / `all_proxy`.
|
||||
/// - `NO_PROXY` / `no_proxy` is checked to bypass the proxy for specific hosts.
|
||||
pub fn resolve_proxy(uri: &http::Uri) -> anyhow::Result<Option<ProxyInfo>> {
|
||||
let is_tls = uri.scheme_str() == Some("wss") || uri.scheme_str() == Some("https");
|
||||
let target_host = uri.host().unwrap_or_default();
|
||||
|
||||
let proxy_env = if is_tls {
|
||||
read_env_var("HTTPS_PROXY").or_else(|| read_env_var("ALL_PROXY"))
|
||||
} else {
|
||||
read_env_var("HTTP_PROXY").or_else(|| read_env_var("ALL_PROXY"))
|
||||
};
|
||||
|
||||
let Some((proxy_env_name, proxy_url)) = proxy_env else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if is_no_proxy(target_host) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
parse_proxy_url(&proxy_url)
|
||||
.with_context(|| format!("Invalid proxy URL configured in {proxy_env_name}"))
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
/// Establishes a TCP connection through an HTTP proxy using the CONNECT method.
|
||||
///
|
||||
/// Uses hyper's HTTP/1 client to send the CONNECT request and then extracts
|
||||
/// the underlying `TcpStream` via the upgrade mechanism.
|
||||
pub async fn connect_via_proxy(
|
||||
proxy: &ProxyInfo,
|
||||
target_uri: &http::Uri,
|
||||
) -> anyhow::Result<TcpStream> {
|
||||
let target_host = target_uri.host().context("Target URI has no host")?;
|
||||
let is_tls = target_uri.scheme_str() == Some("wss") || target_uri.scheme_str() == Some("https");
|
||||
let default_port: u16 = if is_tls { 443 } else { 80 };
|
||||
let target_port = target_uri.port_u16().unwrap_or(default_port);
|
||||
|
||||
// 1. TCP connect to the proxy.
|
||||
let stream = timeout(
|
||||
PROXY_CONNECT_TIMEOUT,
|
||||
TcpStream::connect((&*proxy.host, proxy.port)),
|
||||
)
|
||||
.await
|
||||
.context("Timed out connecting to proxy")?
|
||||
.with_context(|| format!("Failed to connect to proxy {}:{}", proxy.host, proxy.port))?;
|
||||
|
||||
// 2. HTTP/1 handshake over the proxy TCP stream.
|
||||
let (mut sender, conn) = timeout(
|
||||
PROXY_HANDSHAKE_TIMEOUT,
|
||||
hyper::client::conn::http1::handshake(TokioIo::new(stream)),
|
||||
)
|
||||
.await
|
||||
.context("Timed out during HTTP handshake with proxy")?
|
||||
.context("HTTP handshake with proxy failed")?;
|
||||
|
||||
// Drive the connection in the background with upgrade support.
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = conn.with_upgrades().await {
|
||||
log::warn!("Proxy connection driver error: {err}");
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Build and send the CONNECT request.
|
||||
let authority = format!("{target_host}:{target_port}");
|
||||
let mut req = hyper::Request::builder()
|
||||
.method(hyper::Method::CONNECT)
|
||||
.uri(&authority)
|
||||
.header(hyper::header::HOST, &authority)
|
||||
.body(Empty::<Bytes>::new())
|
||||
.context("Failed to build CONNECT request")?;
|
||||
|
||||
if let Some(credentials) = &proxy.basic_auth {
|
||||
req.headers_mut().insert(
|
||||
"proxy-authorization",
|
||||
format!("Basic {credentials}")
|
||||
.parse()
|
||||
.context("Invalid Proxy-Authorization header value")?,
|
||||
);
|
||||
}
|
||||
|
||||
let response = timeout(PROXY_HANDSHAKE_TIMEOUT, sender.send_request(req))
|
||||
.await
|
||||
.context("Timed out waiting for CONNECT response from proxy")?
|
||||
.context("Failed to send CONNECT request to proxy")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
bail!("Proxy CONNECT failed with status: {}", response.status());
|
||||
}
|
||||
|
||||
// 4. Upgrade the connection to get the raw stream.
|
||||
let upgraded = hyper::upgrade::on(response)
|
||||
.await
|
||||
.context("Failed to upgrade proxy connection after CONNECT")?;
|
||||
|
||||
// 5. Downcast back to the underlying TcpStream.
|
||||
let downcast = upgraded.downcast::<TokioIo<TcpStream>>().map_err(|_| {
|
||||
anyhow::anyhow!("Failed to downcast upgraded proxy connection to TcpStream")
|
||||
})?;
|
||||
|
||||
Ok(downcast.io.into_inner())
|
||||
}
|
||||
|
||||
/// Reads an environment variable by its canonical (uppercase) name, falling back to lowercase.
|
||||
fn read_env_var(uppercase_name: &str) -> Option<(String, String)> {
|
||||
env::var(uppercase_name)
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(|value| (uppercase_name.to_string(), value))
|
||||
.or_else(|| {
|
||||
let lowercase_name = uppercase_name.to_lowercase();
|
||||
env::var(&lowercase_name)
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(|value| (lowercase_name, value))
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns `true` if `target_host` matches any entry in `NO_PROXY` / `no_proxy`.
|
||||
///
|
||||
/// Supported patterns:
|
||||
/// - `*` matches all hosts.
|
||||
/// - Exact match (case-insensitive).
|
||||
/// - Suffix match with leading `.` (e.g. `.example.com` matches `foo.example.com`).
|
||||
/// - Suffix match without leading `.` (e.g. `example.com` matches `foo.example.com`).
|
||||
fn is_no_proxy(target_host: &str) -> bool {
|
||||
let no_proxy = read_env_var("NO_PROXY")
|
||||
.map(|(_, value)| value)
|
||||
.unwrap_or_default();
|
||||
if no_proxy.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let target = target_host.to_lowercase();
|
||||
for entry in no_proxy.split(',') {
|
||||
let entry = entry.trim().to_lowercase();
|
||||
if entry.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if entry == "*" {
|
||||
return true;
|
||||
}
|
||||
if target == entry {
|
||||
return true;
|
||||
}
|
||||
// Suffix match: ".example.com" matches "foo.example.com"
|
||||
if entry.starts_with('.') && target.ends_with(&entry) {
|
||||
return true;
|
||||
}
|
||||
// Suffix match without leading dot: "example.com" matches "foo.example.com"
|
||||
if target.ends_with(&format!(".{entry}")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Parses a proxy URL string into a `ProxyInfo`.
|
||||
fn parse_proxy_url(raw: &str) -> anyhow::Result<ProxyInfo> {
|
||||
// Many proxy URLs are specified without a scheme (e.g. "proxy.corp:8080").
|
||||
// Prepend "http://" if no scheme is present so the URL parser can handle it.
|
||||
let normalized = if raw.contains("://") {
|
||||
raw.to_string()
|
||||
} else {
|
||||
format!("http://{raw}")
|
||||
};
|
||||
let url = Url::parse(&normalized).context("failed to parse proxy URL")?;
|
||||
match url.scheme() {
|
||||
"http" => {}
|
||||
"https" => bail!("HTTPS proxy URLs are not supported"),
|
||||
scheme => bail!("Unsupported proxy scheme '{scheme}'"),
|
||||
}
|
||||
|
||||
let host = url
|
||||
.host_str()
|
||||
.context("proxy URL is missing a host")?
|
||||
.to_string();
|
||||
let port = url.port_or_known_default().unwrap_or(8080);
|
||||
|
||||
let username = percent_decode_str(url.username())
|
||||
.decode_utf8()
|
||||
.context("proxy username contains invalid percent-encoding")?
|
||||
.into_owned();
|
||||
let password = url
|
||||
.password()
|
||||
.map(|password| {
|
||||
percent_decode_str(password)
|
||||
.decode_utf8()
|
||||
.context("proxy password contains invalid percent-encoding")
|
||||
})
|
||||
.transpose()?
|
||||
.map(|password| password.into_owned());
|
||||
|
||||
let basic_auth = if !username.is_empty() || password.is_some() {
|
||||
let userinfo = format!("{username}:{}", password.unwrap_or_default());
|
||||
Some(BASE64.encode(userinfo))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(ProxyInfo {
|
||||
host,
|
||||
port,
|
||||
basic_auth,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "proxy_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,391 @@
|
||||
use std::sync::Mutex;
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Guard that ensures proxy-related env vars are cleaned up after each test.
|
||||
/// Tests that manipulate env vars must hold this lock to avoid races.
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn clear_proxy_env() {
|
||||
for var in [
|
||||
"HTTPS_PROXY",
|
||||
"https_proxy",
|
||||
"HTTP_PROXY",
|
||||
"http_proxy",
|
||||
"ALL_PROXY",
|
||||
"all_proxy",
|
||||
"NO_PROXY",
|
||||
"no_proxy",
|
||||
] {
|
||||
env::remove_var(var);
|
||||
}
|
||||
}
|
||||
|
||||
fn wss_uri(host: &str) -> http::Uri {
|
||||
format!("wss://{host}").parse().unwrap()
|
||||
}
|
||||
|
||||
fn ws_uri(host: &str) -> http::Uri {
|
||||
format!("ws://{host}").parse().unwrap()
|
||||
}
|
||||
|
||||
fn resolved_proxy_tls(host: &str) -> Option<ProxyInfo> {
|
||||
resolve_proxy(&wss_uri(host)).expect("proxy resolution should succeed")
|
||||
}
|
||||
|
||||
fn resolved_proxy_plain(host: &str) -> Option<ProxyInfo> {
|
||||
resolve_proxy(&ws_uri(host)).expect("proxy resolution should succeed")
|
||||
}
|
||||
|
||||
// -- resolve_proxy tests --
|
||||
|
||||
#[test]
|
||||
fn resolve_proxy_returns_none_when_no_env_vars_set() {
|
||||
let _lock = ENV_LOCK.lock();
|
||||
clear_proxy_env();
|
||||
assert!(resolved_proxy_tls("example.com").is_none());
|
||||
assert!(resolved_proxy_plain("example.com").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_proxy_reads_https_proxy_for_tls() {
|
||||
let _lock = ENV_LOCK.lock();
|
||||
clear_proxy_env();
|
||||
env::set_var("HTTPS_PROXY", "http://proxy.corp:3128");
|
||||
|
||||
let info = resolved_proxy_tls("example.com").expect("should resolve");
|
||||
assert_eq!(info.host, "proxy.corp");
|
||||
assert_eq!(info.port, 3128);
|
||||
assert!(info.basic_auth.is_none());
|
||||
|
||||
// Non-TLS should not use HTTPS_PROXY.
|
||||
assert!(resolved_proxy_plain("example.com").is_none());
|
||||
clear_proxy_env();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_proxy_reads_http_proxy_for_non_tls() {
|
||||
let _lock = ENV_LOCK.lock();
|
||||
clear_proxy_env();
|
||||
env::set_var("HTTP_PROXY", "http://proxy.corp:8080");
|
||||
|
||||
let info = resolved_proxy_plain("example.com").expect("should resolve");
|
||||
assert_eq!(info.host, "proxy.corp");
|
||||
assert_eq!(info.port, 8080);
|
||||
|
||||
// TLS should not use HTTP_PROXY.
|
||||
assert!(resolved_proxy_tls("example.com").is_none());
|
||||
clear_proxy_env();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_proxy_falls_back_to_all_proxy() {
|
||||
let _lock = ENV_LOCK.lock();
|
||||
clear_proxy_env();
|
||||
env::set_var("ALL_PROXY", "http://all-proxy.corp:9999");
|
||||
|
||||
let tls_info = resolved_proxy_tls("example.com").expect("TLS should fall back to ALL_PROXY");
|
||||
assert_eq!(tls_info.host, "all-proxy.corp");
|
||||
assert_eq!(tls_info.port, 9999);
|
||||
|
||||
let plain_info =
|
||||
resolved_proxy_plain("example.com").expect("plain should fall back to ALL_PROXY");
|
||||
assert_eq!(plain_info.host, "all-proxy.corp");
|
||||
assert_eq!(plain_info.port, 9999);
|
||||
clear_proxy_env();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_proxy_prefers_specific_over_all_proxy() {
|
||||
let _lock = ENV_LOCK.lock();
|
||||
clear_proxy_env();
|
||||
env::set_var("HTTPS_PROXY", "http://specific:1111");
|
||||
env::set_var("ALL_PROXY", "http://fallback:2222");
|
||||
|
||||
let info = resolved_proxy_tls("example.com").expect("should resolve");
|
||||
assert_eq!(info.host, "specific");
|
||||
assert_eq!(info.port, 1111);
|
||||
clear_proxy_env();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_proxy_reads_lowercase_env_vars() {
|
||||
let _lock = ENV_LOCK.lock();
|
||||
clear_proxy_env();
|
||||
env::set_var("https_proxy", "http://lower.corp:4444");
|
||||
|
||||
let info = resolved_proxy_tls("example.com").expect("should resolve from lowercase");
|
||||
assert_eq!(info.host, "lower.corp");
|
||||
assert_eq!(info.port, 4444);
|
||||
clear_proxy_env();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_proxy_returns_error_for_malformed_proxy_env() {
|
||||
let _lock = ENV_LOCK.lock();
|
||||
clear_proxy_env();
|
||||
env::set_var("HTTPS_PROXY", "://broken");
|
||||
|
||||
let err = resolve_proxy(&wss_uri("example.com")).expect_err("malformed proxy env should fail");
|
||||
let err_msg = format!("{err:#}");
|
||||
assert!(err_msg.contains("Invalid proxy URL configured in HTTPS_PROXY"));
|
||||
assert!(err_msg.contains("failed to parse proxy URL"));
|
||||
clear_proxy_env();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_proxy_rejects_https_proxy_urls() {
|
||||
let _lock = ENV_LOCK.lock();
|
||||
clear_proxy_env();
|
||||
env::set_var("HTTPS_PROXY", "https://proxy.corp:443");
|
||||
|
||||
let err = resolve_proxy(&wss_uri("example.com")).expect_err("https proxy URLs should fail");
|
||||
let err_msg = format!("{err:#}");
|
||||
assert!(err_msg.contains("Invalid proxy URL configured in HTTPS_PROXY"));
|
||||
assert!(err_msg.contains("HTTPS proxy URLs are not supported"));
|
||||
clear_proxy_env();
|
||||
}
|
||||
|
||||
// -- NO_PROXY tests --
|
||||
|
||||
#[test]
|
||||
fn no_proxy_exact_match() {
|
||||
let _lock = ENV_LOCK.lock();
|
||||
clear_proxy_env();
|
||||
env::set_var("HTTPS_PROXY", "http://proxy:3128");
|
||||
env::set_var("NO_PROXY", "example.com");
|
||||
|
||||
assert!(resolved_proxy_tls("example.com").is_none());
|
||||
assert!(resolved_proxy_tls("other.com").is_some());
|
||||
clear_proxy_env();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_proxy_wildcard() {
|
||||
let _lock = ENV_LOCK.lock();
|
||||
clear_proxy_env();
|
||||
env::set_var("HTTPS_PROXY", "http://proxy:3128");
|
||||
env::set_var("NO_PROXY", "*");
|
||||
|
||||
assert!(resolved_proxy_tls("anything.com").is_none());
|
||||
clear_proxy_env();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_proxy_suffix_with_dot() {
|
||||
let _lock = ENV_LOCK.lock();
|
||||
clear_proxy_env();
|
||||
env::set_var("HTTPS_PROXY", "http://proxy:3128");
|
||||
env::set_var("NO_PROXY", ".warp.dev");
|
||||
|
||||
assert!(resolved_proxy_tls("sessions.app.warp.dev").is_none());
|
||||
|
||||
assert!(resolved_proxy_tls("warp.dev").is_some()); // Exact "warp.dev" != ".warp.dev"
|
||||
assert!(resolved_proxy_tls("other.com").is_some());
|
||||
clear_proxy_env();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_proxy_suffix_without_dot() {
|
||||
let _lock = ENV_LOCK.lock();
|
||||
clear_proxy_env();
|
||||
env::set_var("HTTPS_PROXY", "http://proxy:3128");
|
||||
env::set_var("NO_PROXY", "warp.dev");
|
||||
|
||||
// "sessions.app.warp.dev" ends with ".warp.dev" → matches
|
||||
assert!(resolved_proxy_tls("sessions.app.warp.dev").is_none());
|
||||
// Exact match too
|
||||
assert!(resolved_proxy_tls("warp.dev").is_none());
|
||||
assert!(resolved_proxy_tls("notwarp.dev").is_some());
|
||||
clear_proxy_env();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_proxy_comma_separated() {
|
||||
let _lock = ENV_LOCK.lock();
|
||||
clear_proxy_env();
|
||||
env::set_var("HTTPS_PROXY", "http://proxy:3128");
|
||||
env::set_var("NO_PROXY", "localhost, 127.0.0.1, .internal.corp");
|
||||
|
||||
assert!(resolved_proxy_tls("localhost").is_none());
|
||||
assert!(resolved_proxy_tls("127.0.0.1").is_none());
|
||||
assert!(resolved_proxy_tls("foo.internal.corp").is_none());
|
||||
assert!(resolved_proxy_tls("external.com").is_some());
|
||||
clear_proxy_env();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_proxy_case_insensitive() {
|
||||
let _lock = ENV_LOCK.lock();
|
||||
clear_proxy_env();
|
||||
env::set_var("HTTPS_PROXY", "http://proxy:3128");
|
||||
env::set_var("NO_PROXY", "Example.COM");
|
||||
|
||||
assert!(resolved_proxy_tls("example.com").is_none());
|
||||
assert!(resolved_proxy_tls("EXAMPLE.COM").is_none());
|
||||
clear_proxy_env();
|
||||
}
|
||||
|
||||
// -- parse_proxy_url tests --
|
||||
|
||||
#[test]
|
||||
fn parse_proxy_url_with_scheme() {
|
||||
let info = parse_proxy_url("http://proxy.corp:3128").expect("should parse");
|
||||
assert_eq!(info.host, "proxy.corp");
|
||||
assert_eq!(info.port, 3128);
|
||||
assert!(info.basic_auth.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_proxy_url_without_scheme() {
|
||||
let info = parse_proxy_url("proxy.corp:8080").expect("should parse");
|
||||
assert_eq!(info.host, "proxy.corp");
|
||||
assert_eq!(info.port, 8080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_proxy_url_default_port() {
|
||||
let info = parse_proxy_url("http://proxy.corp").expect("should parse");
|
||||
assert_eq!(info.port, 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_proxy_url_explicit_default_port() {
|
||||
// Explicit :80 should resolve to 80, not be swallowed by the URL parser.
|
||||
let info = parse_proxy_url("http://proxy.corp:80").expect("should parse");
|
||||
assert_eq!(info.port, 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_proxy_url_with_credentials() {
|
||||
let info = parse_proxy_url("http://user:pass@proxy.corp:3128").expect("should parse");
|
||||
assert_eq!(info.host, "proxy.corp");
|
||||
assert_eq!(info.port, 3128);
|
||||
let decoded = String::from_utf8(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(info.basic_auth.as_ref().unwrap())
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(decoded, "user:pass");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_proxy_url_decodes_percent_encoded_credentials() {
|
||||
let info = parse_proxy_url("http://user%40name:p%3Ass@proxy.corp:3128").expect("should parse");
|
||||
let decoded = String::from_utf8(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(info.basic_auth.as_ref().expect("basic auth should exist"))
|
||||
.expect("basic auth should be valid base64"),
|
||||
)
|
||||
.expect("decoded basic auth should be valid UTF-8");
|
||||
assert_eq!(decoded, "user@name:p:ss");
|
||||
}
|
||||
|
||||
// -- connect_via_proxy integration test with mock proxy --
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_via_proxy_sends_correct_connect_request() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let proxy_info = ProxyInfo {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: addr.port(),
|
||||
basic_auth: None,
|
||||
};
|
||||
|
||||
// Spawn a mock proxy that reads the CONNECT request and responds with 200.
|
||||
let mock_proxy = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut buf = vec![0u8; 1024];
|
||||
let n = socket.read(&mut buf).await.unwrap();
|
||||
let request = String::from_utf8_lossy(&buf[..n]).to_string();
|
||||
socket
|
||||
.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
request
|
||||
});
|
||||
|
||||
let target_uri: http::Uri = "wss://target.example.com:443".parse().unwrap();
|
||||
let result = connect_via_proxy(&proxy_info, &target_uri).await;
|
||||
assert!(result.is_ok(), "connect_via_proxy should succeed");
|
||||
|
||||
let request_sent = mock_proxy.await.unwrap();
|
||||
let request_lower = request_sent.to_lowercase();
|
||||
assert!(request_sent.starts_with("CONNECT target.example.com:443 HTTP/1.1\r\n"));
|
||||
assert!(
|
||||
request_lower.contains("host: target.example.com:443\r\n"),
|
||||
"Request should contain Host header: {request_sent}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_via_proxy_sends_auth_header() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let proxy_info = ProxyInfo {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: addr.port(),
|
||||
basic_auth: Some(BASE64.encode("user:secret")),
|
||||
};
|
||||
|
||||
let mock_proxy = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut buf = vec![0u8; 1024];
|
||||
let n = socket.read(&mut buf).await.unwrap();
|
||||
let request = String::from_utf8_lossy(&buf[..n]).to_string();
|
||||
socket.write_all(b"HTTP/1.1 200 OK\r\n\r\n").await.unwrap();
|
||||
request
|
||||
});
|
||||
|
||||
let target_uri: http::Uri = "wss://host.example.com:8443".parse().unwrap();
|
||||
let result = connect_via_proxy(&proxy_info, &target_uri).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let request_sent = mock_proxy.await.unwrap();
|
||||
let expected_auth = format!(
|
||||
"proxy-authorization: Basic {}",
|
||||
BASE64.encode("user:secret")
|
||||
);
|
||||
assert!(
|
||||
request_sent.contains(&expected_auth),
|
||||
"Request should contain auth header: {request_sent}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_via_proxy_fails_on_407() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let proxy_info = ProxyInfo {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: addr.port(),
|
||||
basic_auth: None,
|
||||
};
|
||||
|
||||
tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut buf = vec![0u8; 1024];
|
||||
let _ = socket.read(&mut buf).await.unwrap();
|
||||
socket
|
||||
.write_all(b"HTTP/1.1 407 Proxy Authentication Required\r\n\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let target_uri: http::Uri = "wss://host.example.com:443".parse().unwrap();
|
||||
let result = connect_via_proxy(&proxy_info, &target_uri).await;
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("407"),
|
||||
"Error should mention 407 status: {err_msg}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
use core::pin::Pin;
|
||||
use futures::{Sink, Stream};
|
||||
use futures_util::stream::FusedStream;
|
||||
use pin_project::pin_project;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
/// Maps the error returned by the [`Sink`] using the provided `err` function.
|
||||
pub fn map_err<S, I, E>(sink: S, err: impl FnMut(S::Error) -> E) -> impl Sink<I, Error = E>
|
||||
where
|
||||
S: Sink<I>,
|
||||
{
|
||||
SinkMapErr::new(sink, err)
|
||||
}
|
||||
|
||||
/// Helper struct to map the [`Err`] of an underlying [`Sink`].
|
||||
/// This is a fork of the the `SinkMapErr` defined within `futures-util`
|
||||
/// (https://docs.rs/futures/latest/futures/sink/struct.SinkMapErr.html) except that it does _not_
|
||||
/// panic if the caller tries to write to the sink after a previous attempt to write returned an
|
||||
/// error. See <https://github.com/rust-lang/futures-rs/issues/2108> for more details about the
|
||||
/// issue with the original `SinkMapErr` struct.
|
||||
#[pin_project]
|
||||
struct SinkMapErr<Si, F> {
|
||||
#[pin]
|
||||
sink: Si,
|
||||
err_function: F,
|
||||
}
|
||||
|
||||
impl<Si, F> SinkMapErr<Si, F> {
|
||||
fn new(sink: Si, err_function: F) -> Self {
|
||||
Self { sink, err_function }
|
||||
}
|
||||
}
|
||||
|
||||
impl<Si, F, E, Item> Sink<Item> for SinkMapErr<Si, F>
|
||||
where
|
||||
Si: Sink<Item>,
|
||||
F: FnMut(Si::Error) -> E,
|
||||
{
|
||||
type Error = E;
|
||||
|
||||
fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
let project = self.as_mut().project();
|
||||
let err_function = project.err_function;
|
||||
project.sink.poll_ready(cx).map_err(err_function)
|
||||
}
|
||||
|
||||
fn start_send(mut self: Pin<&mut Self>, item: Item) -> Result<(), Self::Error> {
|
||||
let project = self.as_mut().project();
|
||||
let err_function = project.err_function;
|
||||
project.sink.start_send(item).map_err(err_function)
|
||||
}
|
||||
|
||||
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
let project = self.as_mut().project();
|
||||
let err_function = project.err_function;
|
||||
project.sink.poll_flush(cx).map_err(err_function)
|
||||
}
|
||||
|
||||
fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
let project = self.as_mut().project();
|
||||
let err_function = project.err_function;
|
||||
project.sink.poll_close(cx).map_err(err_function)
|
||||
}
|
||||
}
|
||||
|
||||
/// Implement [`Stream`] by forwarding calls to the underlying [`Sink`].
|
||||
impl<S: Stream, F> Stream for SinkMapErr<S, F> {
|
||||
type Item = S::Item;
|
||||
|
||||
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
self.project().sink.poll_next(cx)
|
||||
}
|
||||
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
self.sink.size_hint()
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: FusedStream, F> FusedStream for SinkMapErr<S, F> {
|
||||
fn is_terminated(&self) -> bool {
|
||||
self.sink.is_terminated()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sink_map_err_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,39 @@
|
||||
use super::*;
|
||||
use futures_test_sink::SinkMock;
|
||||
use futures_util::SinkExt;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, PartialEq, Debug, Copy, Clone)]
|
||||
#[error("Unable to send to sink")]
|
||||
struct UnmappedError(u8);
|
||||
|
||||
#[derive(Error, PartialEq, Debug, Copy, Clone)]
|
||||
#[error("Unable to send to sink")]
|
||||
struct MappedError(u8);
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_map_err() {
|
||||
let poll_results = vec![
|
||||
Poll::Ready(Ok(())),
|
||||
Poll::Pending,
|
||||
Poll::Ready(Err(UnmappedError(0))),
|
||||
Poll::Ready(Err(UnmappedError(1))),
|
||||
]
|
||||
.into_iter();
|
||||
|
||||
let mut sink = SinkMock::with_flush_feedback(poll_results.clone());
|
||||
|
||||
// The unmapped sink should return an `UnmappedError`.
|
||||
assert_eq!(Ok(()), sink.send(()).await);
|
||||
assert_eq!(Err(UnmappedError(0)), sink.send(()).await);
|
||||
assert_eq!(Err(UnmappedError(1)), sink.send(()).await);
|
||||
|
||||
let mut mapped_sink = map_err(SinkMock::with_flush_feedback(poll_results), |err| {
|
||||
MappedError(err.0)
|
||||
});
|
||||
|
||||
// After mapping the item type should be unchanged, but the error should be a `MappedError`.
|
||||
assert_eq!(Ok(()), mapped_sink.send(()).await);
|
||||
assert_eq!(Err(MappedError(0)), mapped_sink.send(()).await);
|
||||
assert_eq!(Err(MappedError(1)), mapped_sink.send(()).await);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
use futures::{Sink, Stream, StreamExt};
|
||||
use itertools::Itertools;
|
||||
use ws_stream_wasm::{WsErr, WsMessage, WsMeta};
|
||||
|
||||
pub use ws_stream_wasm::WsMessage as Message;
|
||||
|
||||
use crate::WebsocketMessage;
|
||||
|
||||
pub async fn connect(
|
||||
url: impl AsRef<str>,
|
||||
protocols: impl IntoIterator<Item = &str>,
|
||||
) -> anyhow::Result<WebSocket> {
|
||||
let protocols = protocols.into_iter().collect_vec();
|
||||
let (meta, stream) = WsMeta::connect(url, (!protocols.is_empty()).then_some(protocols)).await?;
|
||||
Ok(WebSocket { stream, meta })
|
||||
}
|
||||
|
||||
pub type Error = WsErr;
|
||||
|
||||
pub struct WebSocket {
|
||||
stream: ws_stream_wasm::WsStream,
|
||||
meta: WsMeta,
|
||||
}
|
||||
|
||||
impl WebSocket {
|
||||
pub async fn split(
|
||||
self,
|
||||
) -> (
|
||||
impl Sink<Message, Error = WsErr>,
|
||||
impl Stream<Item = Result<Message, WsErr>>,
|
||||
) {
|
||||
let (sink, stream) = self.stream.split();
|
||||
(sink, stream.map(Ok::<_, ws_stream_wasm::WsErr>))
|
||||
}
|
||||
|
||||
pub async fn into_graphql_client_builder(self) -> graphql_ws_client::ClientBuilder {
|
||||
graphql_ws_client::Client::build(
|
||||
graphql_ws_client::ws_stream_wasm::Connection::new((self.meta, self.stream)).await,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl WebsocketMessage for WsMessage {
|
||||
fn new_binary(bytes: Vec<u8>) -> Self {
|
||||
Self::Binary(bytes)
|
||||
}
|
||||
|
||||
fn binary(&self) -> Option<&[u8]> {
|
||||
match self {
|
||||
Self::Binary(bytes) => Some(bytes.as_ref()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn new(text: String) -> Self {
|
||||
Self::new_text(text)
|
||||
}
|
||||
|
||||
fn new_text(text: String) -> Self {
|
||||
Self::Text(text)
|
||||
}
|
||||
|
||||
fn text(&self) -> Option<&str> {
|
||||
match self {
|
||||
WsMessage::Text(text) => Some(text.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user