Skip to main content

weft_client/
hosted.rs

1// SPDX-License-Identifier: Apache-2.0
2//! One discovered v2 endpoint, with independent RPC streams on its connection.
3use std::time::Duration;
4
5use anyhow::{Context, Result, bail};
6use iroh::{
7    Endpoint, EndpointAddr, RelayMode,
8    endpoint::{Connection, presets},
9};
10use thread_api::{
11    Remote, contract::EndpointKind, credentials::Credentials, transport::IrohTransport,
12};
13
14use crate::{ConnectionOptions, descriptor_url, fetch_endpoint_descriptor};
15
16const ENDPOINT_CLOSE_TIMEOUT: Duration = Duration::from_secs(3);
17
18async fn close_endpoint(endpoint: &Endpoint) {
19    let _ = tokio::time::timeout(ENDPOINT_CLOSE_TIMEOUT, endpoint.close()).await;
20}
21
22/// A direct Weft connection. The application retains operation IDs, credentials,
23/// observed versions and stream checkpoints; reconnect never retries a mutation.
24pub struct HostedClient {
25    pub remote: Remote<IrohTransport<Credentials>>,
26    endpoint: Endpoint,
27    connection: Connection,
28}
29
30impl HostedClient {
31    /// Verify the application's HTTPS descriptor trust, connect once, and
32    /// discover the endpoint's actual v2 implementation inventory.
33    pub async fn connect_server(server: &str, options: &ConnectionOptions) -> Result<Self> {
34        let descriptor = fetch_endpoint_descriptor(
35            &descriptor_url(server)?,
36            &options.trusted_descriptors,
37            &options.http_client,
38        )
39        .await?;
40        Self::connect_endpoint(
41            descriptor.endpoint_addr()?,
42            options.credential.clone(),
43            options.timeout,
44            options.tls_ca_certificate_pem.as_deref(),
45        )
46        .await
47    }
48
49    /// Connect to an endpoint whose public key the application already trusts.
50    /// Relay addresses are routing hints; Iroh authenticates the supplied key.
51    pub async fn connect_endpoint(
52        address: EndpointAddr,
53        credential: Credentials,
54        timeout: Duration,
55        tls_ca_certificate_pem: Option<&str>,
56    ) -> Result<Self> {
57        Self::connect_endpoint_with_description(
58            address,
59            credential,
60            timeout,
61            tls_ca_certificate_pem,
62            None,
63        )
64        .await
65    }
66
67    /// Connect with a description authenticated and retained by the application.
68    /// A mismatched description is discarded and rediscovered on this connection.
69    pub async fn connect_endpoint_with_description(
70        address: EndpointAddr,
71        credential: Credentials,
72        timeout: Duration,
73        tls_ca_certificate_pem: Option<&str>,
74        description: Option<thread_api::contract::DescribeEndpointResponse>,
75    ) -> Result<Self> {
76        if timeout.is_zero() {
77            bail!("hosted progress timeout must be positive");
78        }
79        let endpoint = Self::bind_endpoint(tls_ca_certificate_pem).await?;
80        Self::connect_bound_endpoint(
81            endpoint,
82            address,
83            credential,
84            timeout,
85            description,
86            tls_ca_certificate_pem,
87        )
88        .await
89    }
90
91    /// Bind without routing hints so HTTPS trust discovery can run concurrently.
92    /// Only verified relays are installed before connecting.
93    pub async fn bind_endpoint(tls_ca_certificate_pem: Option<&str>) -> Result<Endpoint> {
94        Self::bind_with_relays(RelayMode::Disabled, tls_ca_certificate_pem).await
95    }
96
97    async fn bind_with_relays(
98        relay_mode: RelayMode,
99        tls_ca_certificate_pem: Option<&str>,
100    ) -> Result<Endpoint> {
101        let mut builder = Endpoint::builder(presets::Minimal).relay_mode(relay_mode);
102        if let Some(pem) = tls_ca_certificate_pem {
103            builder = builder.ca_tls_config(crate::relay_tls::ca_tls_config_from_pem(pem)?);
104        }
105        builder.bind().await.context("bind hosted client endpoint")
106    }
107
108    /// Finish connecting an independently bound endpoint to a verified address.
109    pub async fn connect_bound_endpoint(
110        mut endpoint: Endpoint,
111        address: EndpointAddr,
112        credential: Credentials,
113        timeout: Duration,
114        description: Option<thread_api::contract::DescribeEndpointResponse>,
115        tls_ca_certificate_pem: Option<&str>,
116    ) -> Result<Self> {
117        if timeout.is_zero() {
118            close_endpoint(&endpoint).await;
119            bail!("hosted progress timeout must be positive");
120        }
121        let relays: Vec<_> = address.relay_urls().cloned().collect();
122        if !relays.is_empty() {
123            // Relay transport configuration depends on the verified addresses.
124            // Do not keep an empty relay actor: it emits shutdown errors on
125            // direct-only connections and contaminates JSON diagnostics.
126            close_endpoint(&endpoint).await;
127            endpoint =
128                Self::bind_with_relays(RelayMode::custom(relays), tls_ca_certificate_pem).await?;
129        }
130        heddle_perf_contract::record_network_client_initialization();
131        let connection =
132            match tokio::time::timeout(timeout, endpoint.connect(address, api::HOSTED_ALPN_V1))
133                .await
134                .context("hosted connection deadline elapsed")
135                .and_then(|connection| connection.context("connect to hosted endpoint"))
136            {
137                Ok(connection) => connection,
138                Err(error) => {
139                    close_endpoint(&endpoint).await;
140                    return Err(error);
141                }
142            };
143        let result = async {
144            let transport = IrohTransport::new(
145                connection.clone(),
146                credential,
147                api::framing::MAX_CONTROL_BODY,
148                timeout,
149            )?;
150            if let Some(description) = description {
151                // Validate before consuming the transport so a mismatch can
152                // still fall through to discovery over the same connection.
153                let key = connection.remote_id();
154                if description.endpoint.as_ref().is_some_and(|source| {
155                    source.public_key == key.as_bytes() && source.kind == EndpointKind::Weft as i32
156                }) && description
157                    .supported_packages
158                    .iter()
159                    .any(|package| package == "heddle.api.v1alpha2")
160                {
161                    return Remote::from_description(
162                        transport,
163                        *key.as_bytes(),
164                        EndpointKind::Weft,
165                        description,
166                    )
167                    .context("reuse hosted v2 API");
168                }
169            }
170            Remote::discover(
171                transport,
172                *connection.remote_id().as_bytes(),
173                EndpointKind::Weft,
174            )
175            .await
176            .context("discover hosted v2 API")
177        }
178        .await;
179        match result {
180            Ok(remote) => Ok(Self {
181                remote,
182                endpoint,
183                connection,
184            }),
185            Err(error) => {
186                connection.close(0u32.into(), b"v2 discovery failed");
187                close_endpoint(&endpoint).await;
188                Err(error)
189            }
190        }
191    }
192
193    pub fn local_endpoint_id(&self) -> iroh::EndpointId {
194        self.endpoint.id()
195    }
196
197    pub fn endpoint(&self) -> &Endpoint {
198        &self.endpoint
199    }
200
201    pub fn connection(&self) -> &Connection {
202        &self.connection
203    }
204
205    /// Release the discovered remote and the live Iroh session without closing.
206    ///
207    /// The caller becomes responsible for closing `connection` / `endpoint`.
208    pub fn into_parts(self) -> (Remote<IrohTransport<Credentials>>, Endpoint, Connection) {
209        let this = std::mem::ManuallyDrop::new(self);
210        // SAFETY: `this` is forgotten, so Drop will not close the connection.
211        // Each field is read exactly once.
212        unsafe {
213            (
214                std::ptr::read(&this.remote),
215                std::ptr::read(&this.endpoint),
216                std::ptr::read(&this.connection),
217            )
218        }
219    }
220
221    /// Cancel this connection's streams and await endpoint shutdown.
222    pub async fn close(&self) {
223        self.connection.close(0u32.into(), b"hosted client closed");
224        close_endpoint(&self.endpoint).await;
225    }
226}
227
228impl Drop for HostedClient {
229    fn drop(&mut self) {
230        self.connection.close(0u32.into(), b"hosted client dropped");
231    }
232}