use std::sync::Arc;
use std::sync::OnceLock;
use tokio::sync::Mutex;
use crate::sdk::cdp::ws_client::Connection;
use crate::sdk::endpoint::Endpoint;
use crate::shared::error::{Error, ErrorCode};
pub(crate) fn ensure_rustls_provider() {
static ONCE: OnceLock<()> = OnceLock::new();
ONCE.get_or_init(|| {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
});
}
#[derive(Clone)]
pub struct Client {
inner: Arc<ClientInner>,
}
pub(crate) struct ClientInner {
pub(crate) endpoint: Endpoint,
pub(crate) token: Option<String>,
pub(crate) profile: Option<String>,
pub(crate) http: reqwest::Client,
pub(crate) hostless: bool,
pub(crate) inline_host: Option<crate::sdk::inline::InlineHost>,
cdp: Mutex<Option<Arc<Connection>>>,
profile_info: Mutex<Option<Arc<crate::sdk::profile::info::ProfileInfo>>>,
}
impl Client {
pub fn connect(endpoint: &str) -> Result<Self, Error> {
ensure_rustls_provider();
let endpoint = Endpoint::parse(endpoint)?;
let mut http_builder = reqwest::Client::builder()
.user_agent(concat!("afhttp/", env!("CARGO_PKG_VERSION")))
.no_proxy();
#[cfg(unix)]
if let Endpoint::Unix { path } = &endpoint {
http_builder = http_builder.unix_socket(path.clone());
}
let http = http_builder.build().map_err(|e| {
Error::new(
ErrorCode::InternalError,
format!("reqwest client build failed: {e}"),
)
})?;
Ok(Self {
inner: Arc::new(ClientInner {
endpoint,
token: None,
profile: None,
http,
hostless: false,
inline_host: None,
cdp: Mutex::new(None),
profile_info: Mutex::new(None),
}),
})
}
pub fn http_only() -> Result<Self, Error> {
let mut client = Self::connect("ws://127.0.0.1:0")?;
if let Some(inner) = Arc::get_mut(&mut client.inner) {
inner.hostless = true;
}
Ok(client)
}
#[must_use]
pub fn with_token(mut self, token: impl Into<String>) -> Self {
let token = token.into();
if let Some(inner) = Arc::get_mut(&mut self.inner) {
inner.token = Some(token);
inner.cdp = Mutex::new(None);
inner.profile_info = Mutex::new(None);
} else {
let new = ClientInner {
endpoint: self.inner.endpoint.clone(),
token: Some(token),
profile: self.inner.profile.clone(),
http: self.inner.http.clone(),
hostless: self.inner.hostless,
inline_host: self.inner.inline_host.clone(),
cdp: Mutex::new(None),
profile_info: Mutex::new(None),
};
self.inner = Arc::new(new);
}
self
}
#[must_use]
pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
let profile = profile.into();
if let Some(inner) = Arc::get_mut(&mut self.inner) {
inner.profile = Some(profile);
inner.cdp = Mutex::new(None);
} else {
let new = ClientInner {
endpoint: self.inner.endpoint.clone(),
token: self.inner.token.clone(),
profile: Some(profile),
http: self.inner.http.clone(),
hostless: self.inner.hostless,
inline_host: self.inner.inline_host.clone(),
cdp: Mutex::new(None),
profile_info: Mutex::new(None),
};
self.inner = Arc::new(new);
}
self
}
#[must_use]
pub fn profile(&self) -> Option<&str> {
self.inner.profile.as_deref()
}
#[must_use]
pub fn endpoint(&self) -> &Endpoint {
&self.inner.endpoint
}
#[must_use]
pub fn token(&self) -> Option<&str> {
self.inner.token.as_deref()
}
pub(crate) fn http(&self) -> &reqwest::Client {
&self.inner.http
}
pub(crate) fn is_hostless(&self) -> bool {
self.inner.hostless
}
pub(crate) fn has_inline_host(&self) -> bool {
self.inner.inline_host.is_some()
}
pub(crate) async fn inline_host_started(&self) -> bool {
match &self.inner.inline_host {
Some(inline) => inline.is_started().await,
None => false,
}
}
#[cfg(feature = "host")]
pub(crate) fn with_inline_host(mut self, inline_host: crate::sdk::inline::InlineHost) -> Self {
if let Some(inner) = Arc::get_mut(&mut self.inner) {
inner.inline_host = Some(inline_host);
inner.hostless = false;
inner.cdp = Mutex::new(None);
inner.profile_info = Mutex::new(None);
} else {
let new = ClientInner {
endpoint: self.inner.endpoint.clone(),
token: self.inner.token.clone(),
profile: self.inner.profile.clone(),
http: self.inner.http.clone(),
hostless: false,
inline_host: Some(inline_host),
cdp: Mutex::new(None),
profile_info: Mutex::new(None),
};
self.inner = Arc::new(new);
}
self
}
pub(crate) async fn effective_endpoint(&self) -> Result<Endpoint, Error> {
if self.inner.hostless {
return Err(Error::new(
ErrorCode::RenderUnavailable,
"this client has no afhttp host endpoint",
));
}
if let Some(inline) = &self.inner.inline_host {
inline.endpoint().await
} else {
Ok(self.inner.endpoint.clone())
}
}
pub(crate) async fn cdp_connection(&self) -> Result<Arc<Connection>, Error> {
let mut guard = self.inner.cdp.lock().await;
if let Some(conn) = guard.as_ref() {
return Ok(conn.clone());
}
let endpoint = self.effective_endpoint().await?;
let conn =
Arc::new(Connection::connect_endpoint(&endpoint, self.token(), self.profile()).await?);
*guard = Some(conn.clone());
Ok(conn)
}
pub async fn close(&self) {
if let Some(conn) = self.inner.cdp.lock().await.take() {
conn.close();
}
}
#[must_use]
pub fn fetch(&self, url: impl Into<String>) -> crate::sdk::fetch::FetchBuilder {
crate::sdk::fetch::FetchBuilder::new(self.clone(), url.into())
}
pub async fn profile_info(&self) -> Result<Arc<crate::sdk::profile::info::ProfileInfo>, Error> {
let mut guard = self.inner.profile_info.lock().await;
if let Some(info) = guard.as_ref() {
return Ok(info.clone());
}
if self.inner.hostless {
return Err(Error::new(
ErrorCode::HostUnreachable,
"GET /profile: no afhttp host endpoint configured",
));
}
let endpoint = self.effective_endpoint().await?;
let base = endpoint.http_base();
let url = profile_url(&base)?;
let mut req = self.http().get(&url);
if let Some(token) = self.token() {
req = req.bearer_auth(token);
}
let resp = req
.send()
.await
.map_err(|e| Error::new(ErrorCode::HostUnreachable, format!("GET {url}: {e}")))?;
let status = resp.status();
if !status.is_success() {
return Err(Error::new(
ErrorCode::HostUnreachable,
format!("GET {url}: status {status}"),
));
}
let bytes = resp.bytes().await.map_err(|e| {
Error::new(
ErrorCode::InternalError,
format!("profile_info: read response: {e}"),
)
})?;
let info: crate::sdk::profile::info::ProfileInfo =
crate::shared::afdata::decode_result(&bytes)?;
let arc = Arc::new(info);
*guard = Some(arc.clone());
Ok(arc)
}
}
fn profile_url(base: &str) -> Result<String, Error> {
let url = url::Url::parse(&format!("{base}/profile")).map_err(|e| {
Error::new(
ErrorCode::InvalidEndpoint,
format!("profile URL from endpoint {base:?}: {e}"),
)
})?;
Ok(url.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn connect_rejects_bad_endpoint() {
let err = Client::connect("ftp://nope").err();
assert!(err.is_some());
}
#[test]
fn connect_accepts_ws() {
let c = Client::connect("ws://localhost:9222").unwrap();
assert!(matches!(c.endpoint(), Endpoint::Ws { .. }));
assert!(c.token().is_none());
}
#[test]
fn with_token_attaches() {
let c = Client::connect("ws://localhost:9222")
.unwrap()
.with_token("secret");
assert_eq!(c.token(), Some("secret"));
}
}