Skip to main content

heddle_thread_api/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Native v2 client design. Endpoint ownership, credentials and connection
3//! discovery belong to the application; this crate never obtains a Weft token.
4#[cfg(feature = "native")]
5pub mod authority;
6#[cfg(feature = "replication")]
7pub mod authority_admission;
8#[cfg(feature = "semantic-analysis")]
9pub mod behavior;
10#[cfg(feature = "replication")]
11pub mod boundary_acceptance;
12#[cfg(feature = "replication")]
13pub mod collaboration;
14pub mod content;
15#[cfg(feature = "replication")]
16pub mod creation;
17#[cfg(feature = "signing")]
18pub mod credentials;
19#[cfg(feature = "replication")]
20pub mod evidence;
21#[cfg(feature = "source-transfer")]
22pub mod fetch;
23#[cfg(feature = "replication")]
24pub mod live_replication;
25pub mod observation;
26#[cfg(feature = "root-attachment")]
27pub mod pairing;
28#[cfg(any(feature = "native", feature = "replication", feature = "iroh"))]
29pub mod publication;
30mod reopen;
31#[cfg(feature = "replication")]
32pub mod replication;
33#[cfg(all(feature = "native", feature = "iroh"))]
34pub mod replication_rpc;
35#[cfg(feature = "signing")]
36pub mod request_proof;
37#[cfg(feature = "root-attachment")]
38pub mod root_attachment;
39#[cfg(feature = "replication")]
40pub mod thread_control;
41#[cfg(feature = "replication")]
42pub mod thread_ownership;
43pub mod transport;
44
45use api::v2::client::{Client, ClientError, RpcTransport};
46pub use api::{
47    heddle::api::v1alpha2 as contract,
48    v2::{client::Rpc, rpc},
49};
50use contract::{DescribeEndpointRequest, DescribeEndpointResponse, EndpointKind, ThreadRef};
51pub use reopen::is_reopen_retryable;
52use transport::Error;
53
54/// One authenticated source. A combined Thread retains one source per endpoint;
55/// a hosted action is sent directly to the Weft source, never via a device.
56pub struct Remote<T: RpcTransport<Error = Error>> {
57    pub api: Client<T>,
58    pub description: DescribeEndpointResponse,
59}
60
61impl<T: RpcTransport<Error = Error>> Remote<T> {
62    /// The caller supplies the Iroh-authenticated endpoint key and intended kind.
63    /// Describe is the only bootstrap exception to implemented-method discovery.
64    pub async fn discover(
65        transport: T,
66        endpoint_key: [u8; 32],
67        kind: EndpointKind,
68    ) -> Result<Self, ClientError<Error>> {
69        let bytes = transport
70            .unary(
71                rpc::EndpointServiceDescribeEndpoint::METHOD,
72                prost::Message::encode_to_vec(&DescribeEndpointRequest {
73                    understood_packages: vec!["heddle.api.v1alpha2".into()],
74                }),
75            )
76            .await
77            .map_err(ClientError::Transport)?;
78        let description: DescribeEndpointResponse = prost::Message::decode(bytes.as_slice())?;
79        if description
80            .endpoint
81            .as_ref()
82            .is_none_or(|source| source.public_key != endpoint_key || source.kind != kind as i32)
83            || !description
84                .supported_packages
85                .iter()
86                .any(|p| p == "heddle.api.v1alpha2")
87        {
88            return Err(ClientError::Transport(Error::Protocol(
89                "endpoint identity/package mismatch",
90            )));
91        }
92        let api = Client::new(transport, description.implemented_methods.clone());
93        Ok(Self { api, description })
94    }
95
96    /// Observe any contract view as atomic bounded batches. The bookmark binds
97    /// the exact method and projection as well as the authenticated endpoint.
98    pub async fn observe<M>(
99        &self,
100        mut request: M::Request,
101        resume: Option<observation::Resume>,
102    ) -> Result<observation::Observation<T::Reader, M::Response>, observation::Error>
103    where
104        M: api::v2::client::ServerStreamingRpc,
105        M::Request: observation::ObservationRequest,
106        M::Response: observation::ObservedEvent,
107    {
108        use crate::reopen::ReopenRetryable as _;
109        use observation::ObservationRequest as _;
110        let budget = observation::budget(&self.description)?;
111        let options = request.options_mut();
112        options.budget = Some(budget);
113        options.after_cursor.clear();
114        let mut query = b"heddle-observation-query-v2\0".to_vec();
115        query.extend_from_slice(M::METHOD.path.as_bytes());
116        query.push(0);
117        query.extend_from_slice(&prost::Message::encode_to_vec(&request));
118        observation::validate_resume(&resume, &self.description, &query)?;
119        if let Some(resume) = &resume {
120            request.options_mut().after_cursor = resume.cursor.clone();
121        }
122        let mut attempt = 0;
123        loop {
124            let messages = match self.api.observe::<M>(&request).await {
125                Ok(messages) => messages,
126                Err(error)
127                    if reopen::client_error_is_reopen_retryable(&error)
128                        && attempt + 1 < reopen::ATTEMPTS =>
129                {
130                    attempt += 1;
131                    reopen::backoff(attempt).await;
132                    continue;
133                }
134                Err(error) => return Err(error.into()),
135            };
136            let mut observation = observation::Observation::new(
137                messages,
138                &self.description,
139                budget,
140                resume.clone(),
141                query.clone(),
142            )?;
143            match observation.consume_open().await {
144                Ok(()) => return Ok(observation),
145                Err(error) if error.is_reopen_retryable() && attempt + 1 < reopen::ATTEMPTS => {
146                    attempt += 1;
147                    reopen::backoff(attempt).await;
148                }
149                Err(error) => {
150                    observation.prime_error(error);
151                    return Ok(observation);
152                }
153            }
154        }
155    }
156
157    /// Source-backed analysis uses the same committed view protocol as identity,
158    /// collaboration, checkouts and Thread observations.
159    pub async fn observe_analysis(
160        &self,
161        request: contract::ObserveAnalysisRequest,
162        resume: Option<observation::Resume>,
163    ) -> Result<observation::AnalysisObservation<T::Reader>, observation::Error> {
164        self.observe::<rpc::AnalysisServiceObserveAnalysis>(request, resume)
165            .await
166    }
167
168    /// Binding a Thread is local and costs no RPC. Persist this stable reference
169    /// when discovering/creating the Thread; its display name is never its key.
170    pub fn thread(&self, thread: ThreadRef) -> Thread<'_, T> {
171        Thread {
172            remote: self,
173            reference: thread,
174        }
175    }
176}
177
178pub struct Thread<'a, T: RpcTransport<Error = Error>> {
179    remote: &'a Remote<T>,
180    pub reference: ThreadRef,
181}
182
183impl<T: RpcTransport<Error = Error>> Thread<'_, T> {
184    /// Send a locally prepared original signed intent. Preparation consumes the
185    /// existing overview's field frontier and performs no extra RPC.
186    #[cfg(feature = "replication")]
187    pub async fn revise_intent(
188        &self,
189        command: &thread_control::PreparedControl,
190    ) -> Result<contract::ThreadMutationResponse, ClientError<Error>> {
191        let request = command.revise_intent().map_err(ClientError::Transport)?;
192        if request.thread.as_ref() != Some(&self.reference) {
193            return Err(ClientError::Transport(Error::Protocol(
194                "prepared command belongs to another Thread",
195            )));
196        }
197        self.remote
198            .api
199            .call::<rpc::ThreadServiceReviseIntent>(&request)
200            .await
201    }
202
203    pub async fn observe(
204        &self,
205        sections: &[contract::ThreadSection],
206        mode: contract::ObservationMode,
207        resume: Option<observation::Resume>,
208    ) -> Result<observation::ThreadObservation<T::Reader>, observation::Error> {
209        self.remote
210            .observe::<rpc::ThreadServiceObserveThread>(
211                contract::ObserveThreadRequest {
212                    thread: Some(self.reference.clone()),
213                    sections: sections.iter().map(|section| *section as i32).collect(),
214                    observe: Some(contract::ObserveOptions {
215                        mode: mode as i32,
216                        ..Default::default()
217                    }),
218                    ..Default::default()
219                },
220                resume,
221            )
222            .await
223    }
224}