Skip to content

Commit cf43cdd

Browse files
tylerhawkesclaude
andcommitted
feat(xmtp_api_grpc): bidi_stream transport primitive (native tonic; default-unsupported elsewhere)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9eb4ed4 commit cf43cdd

3 files changed

Lines changed: 86 additions & 4 deletions

File tree

crates/xmtp_api_grpc/src/grpc_client/client.rs

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,11 @@ impl GrpcClient {
8181
}
8282

8383
/// Builds a tonic request from a body and a generic HTTP Request
84-
fn build_tonic_request(
84+
fn build_tonic_request<B>(
8585
&self,
8686
request: http::request::Builder,
87-
body: Bytes,
88-
) -> Result<tonic::Request<Bytes>, Status> {
87+
body: B,
88+
) -> Result<tonic::Request<B>, Status> {
8989
let request = request
9090
.body(body)
9191
.map_err(|e| tonic::Status::from_error(Box::new(e)))?;
@@ -187,6 +187,36 @@ impl Client for GrpcClient {
187187
});
188188
Ok(response.to_http().map(Into::into))
189189
}
190+
191+
// Full-duplex needs a real HTTP/2 transport; the gRPC-Web service used on
192+
// wasm cannot carry it, so the browser keeps the trait's default error.
193+
#[cfg(not(target_arch = "wasm32"))]
194+
async fn bidi_stream(
195+
&self,
196+
request: request::Builder,
197+
path: PathAndQuery,
198+
body: xmtp_common::BoxDynStream<'static, Bytes>,
199+
) -> Result<http::Response<BytesStream>, ApiClientError> {
200+
let this = self.clone();
201+
// client requires to be moved so it lives long enough for streaming response future.
202+
let response = async move {
203+
let mut client = this.inner.clone();
204+
this.wait_for_ready(&mut client).await?;
205+
let request = this.build_tonic_request(request, body)?;
206+
let codec = TransparentCodec::default();
207+
client.streaming(request, path, codec).await
208+
};
209+
let req = crate::streams::NonBlockingStreamRequest::new(
210+
Box::pin(response) as crate::streams::ResponseFuture
211+
);
212+
let response = crate::streams::send(req).await.map_err(GrpcError::from)?;
213+
let response = response.map(|body| {
214+
BytesStream::new(GrpcStream {
215+
inner: EscapableTonicStream::new(body),
216+
})
217+
});
218+
Ok(response.to_http().map(Into::into))
219+
}
190220
}
191221

192222
#[xmtp_common::async_trait]
@@ -311,7 +341,7 @@ pub mod tests {
311341
let request = client
312342
.build_tonic_request(
313343
Default::default(),
314-
PublishRequest { envelopes: vec![] }.encode_to_vec().into(),
344+
prost::bytes::Bytes::from(PublishRequest { envelopes: vec![] }.encode_to_vec()),
315345
)
316346
.unwrap();
317347

crates/xmtp_proto/src/traits.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,22 @@ pub trait Client: MaybeSend + MaybeSync {
143143
body: Bytes,
144144
) -> Result<http::Response<BytesStream>, ApiClientError>;
145145

146+
/// Open a bidirectional stream (XIP-83). `body` is the outbound stream of
147+
/// encoded protobuf messages (one `Bytes` item per message); the response
148+
/// carries the inbound message stream. Transports without full-duplex
149+
/// support (e.g. gRPC-Web in the browser) keep this default and error.
150+
async fn bidi_stream(
151+
&self,
152+
request: request::Builder,
153+
path: http::uri::PathAndQuery,
154+
body: BoxDynStream<'static, Bytes>,
155+
) -> Result<http::Response<BytesStream>, ApiClientError> {
156+
let _ = (request, path, body);
157+
Err(ApiClientError::OtherUnretryable(
158+
"bidirectional streaming is not supported by this transport".into(),
159+
))
160+
}
161+
146162
/// start a "fake" stream that does not create a TCP connection and will always be pending
147163
fn fake_stream(&self) -> http::Response<BytesStream> {
148164
let fake = FakeEmptyStream::new();
@@ -179,6 +195,15 @@ where
179195
) -> Result<http::Response<BytesStream>, ApiClientError> {
180196
(**self).stream(request, path, body).await
181197
}
198+
199+
async fn bidi_stream(
200+
&self,
201+
request: request::Builder,
202+
path: http::uri::PathAndQuery,
203+
body: BoxDynStream<'static, Bytes>,
204+
) -> Result<http::Response<BytesStream>, ApiClientError> {
205+
(**self).bidi_stream(request, path, body).await
206+
}
182207
}
183208

184209
#[xmtp_common::async_trait]
@@ -203,6 +228,15 @@ where
203228
) -> Result<http::Response<BytesStream>, ApiClientError> {
204229
(**self).stream(request, path, body).await
205230
}
231+
232+
async fn bidi_stream(
233+
&self,
234+
request: request::Builder,
235+
path: http::uri::PathAndQuery,
236+
body: BoxDynStream<'static, Bytes>,
237+
) -> Result<http::Response<BytesStream>, ApiClientError> {
238+
(**self).bidi_stream(request, path, body).await
239+
}
206240
}
207241

208242
#[xmtp_common::async_trait]
@@ -227,6 +261,15 @@ where
227261
) -> Result<http::Response<BytesStream>, ApiClientError> {
228262
(**self).stream(request, path, body).await
229263
}
264+
265+
async fn bidi_stream(
266+
&self,
267+
request: request::Builder,
268+
path: PathAndQuery,
269+
body: BoxDynStream<'static, Bytes>,
270+
) -> Result<http::Response<BytesStream>, ApiClientError> {
271+
(**self).bidi_stream(request, path, body).await
272+
}
230273
}
231274

232275
#[xmtp_common::async_trait]

crates/xmtp_proto/src/traits/boxed_client.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@ where
4848
) -> Result<http::Response<BytesStream>, ApiClientError> {
4949
self.inner.stream(request, path, body).await
5050
}
51+
52+
async fn bidi_stream(
53+
&self,
54+
request: request::Builder,
55+
path: http::uri::PathAndQuery,
56+
body: xmtp_common::BoxDynStream<'static, Bytes>,
57+
) -> Result<http::Response<BytesStream>, ApiClientError> {
58+
self.inner.bidi_stream(request, path, body).await
59+
}
5160
}
5261

5362
pub trait ToBoxedClient {

0 commit comments

Comments
 (0)