Skip to main content

grpc_webnext_client/
tonic_service.rs

1//! tonic interop: drive **generated** client stubs over the tunnel.
2//!
3//! The h2ts path is real HTTP/2, so nothing here translates a protocol — it hands
4//! tonic a [`tower_service::Service`] that moves request bytes out and response
5//! bytes, headers and trailers back. tonic keeps doing what it already does well
6//! (codec, framing, status, compression, interceptors); this crate keeps doing what
7//! tonic cannot do in a browser (dial, tunnel, reconnect). A stub generated by
8//! `tonic-prost-build` runs unmodified:
9//!
10//! ```no_run
11//! # mod pb { pub mod greeter_client { pub struct GreeterClient<T>(T);
12//! #   impl<T> GreeterClient<T> { pub fn new(inner: T) -> Self { Self(inner) } } } }
13//! # fn demo(client: grpc_webnext_client::Client) {
14//! use pb::greeter_client::GreeterClient;
15//!
16//! let mut greeter = GreeterClient::new(client.into_tonic());
17//! # let _ = greeter;
18//! # }
19//! ```
20//!
21//! ## The `Send` bound, honestly — and no, it does not mean threads
22//!
23//! tonic's generated stubs require `T::ResponseBody: Send + 'static`, and the engine
24//! underneath is `!Send` on purpose — that is what a browser is, and asserting
25//! otherwise with `unsafe impl` would be a lie the compiler can no longer check.
26//! [`SendWrapper`] is the middle path: it carries the value across the bound and
27//! records the thread that made it, so a value that really does move threads
28//! **panics** at the boundary instead of racing. Natively, keep the client on a
29//! `LocalSet`, which is where a `!Send` client belongs anyway.
30//!
31//! `Send` is a compile-time marker, not a runtime: requiring it links nothing and
32//! spawns nothing. This feature brings **no threading** — a release build of the stub
33//! path has zero atomic instructions, no shared memory, and no spawn symbols, and
34//! `tokio` resolves with `sync` alone, so `tokio::spawn` (behind `rt`) is not even
35//! compiled in. Plain `wasm32-unknown-unknown`, no `+atomics`, is the supported
36//! configuration. Under wasm threads the rule is one client per worker: moving one
37//! across workers panics at the wrapper, deliberately, rather than racing on `Rc`.
38//!
39//! ## Deadlines
40//!
41//! `grpc-timeout` on the outgoing request is also **enforced locally**, covering the
42//! whole call — opening it and every frame of the response body. This is a
43//! deliberate difference from tonic-over-`Channel`, where `Request::set_timeout`
44//! sets the header and only an `Endpoint::timeout` layer enforces anything. The
45//! header is a request to the far end, and a peer that ignores it leaves a tab
46//! waiting forever; see [`CallOptions::timeout`](crate::CallOptions::timeout), which
47//! makes the same promise on the native API.
48
49use std::future::Future;
50use std::pin::Pin;
51use std::task::{Context, Poll};
52use std::time::Duration;
53
54use bytes::Bytes;
55use futures::stream::{LocalBoxStream, StreamExt};
56use h2ts_client::{RequestBody, Trailers};
57use http::{HeaderMap, HeaderName, HeaderValue};
58use http_body::{Body, Frame};
59use send_wrapper::SendWrapper;
60
61use crate::client::Client;
62
63/// A [`Client`] wearing tonic's transport interface, so generated stubs can drive it.
64///
65/// Cheap to clone, and clones share one tunnel — the same contract [`Client`] has,
66/// which is what makes this a stand-in for `tonic::transport::Channel`. Build one
67/// with [`Client::into_tonic`].
68#[derive(Clone)]
69pub struct TonicService {
70    client: Client,
71}
72
73impl std::fmt::Debug for TonicService {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        f.debug_struct("TonicService").finish_non_exhaustive()
76    }
77}
78
79impl TonicService {
80    pub fn new(client: Client) -> TonicService {
81        TonicService { client }
82    }
83
84    /// The channel underneath, for the things tonic has no vocabulary for —
85    /// [`Client::state`], [`Client::state_changes`], [`Client::is_closed`].
86    pub fn client(&self) -> &Client {
87        &self.client
88    }
89}
90
91impl Client {
92    /// Wrap this channel as a tonic transport, for generated client stubs.
93    pub fn into_tonic(self) -> TonicService {
94        TonicService::new(self)
95    }
96}
97
98impl tower_service::Service<http::Request<tonic::body::Body>> for TonicService {
99    type Response = http::Response<ResponseBody>;
100    /// `tonic::Status` rather than this crate's, because tonic downcasts it back out
101    /// of the boxed error — so a deadline arrives at the caller as DEADLINE_EXCEEDED
102    /// instead of decaying to UNKNOWN with the text in the message.
103    type Error = tonic::Status;
104    /// Deliberately **not** `Send`, and deliberately not wrapped. tonic bounds the
105    /// response *body* with `Send`, never the future — it awaits this inline inside
106    /// `Grpc::streaming`, on whatever executor is polling, which here is one thread.
107    /// Wrapping it would assert something no caller asks for and add a second place a
108    /// cross-thread move could panic, so `ResponseBody` stays the single seam.
109    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
110
111    /// Always ready: there is nothing to reserve. The tunnel is dialed by the call
112    /// itself and shared by every other, so readiness here would be reporting on a
113    /// resource this service does not own.
114    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
115        Poll::Ready(Ok(()))
116    }
117
118    fn call(&mut self, request: http::Request<tonic::body::Body>) -> Self::Future {
119        let client = self.client.clone();
120        Box::pin(send(client, request))
121    }
122}
123
124/// Issue one request and return the response headers plus a body tonic can read.
125async fn send(
126    client: Client,
127    request: http::Request<tonic::body::Body>,
128) -> Result<http::Response<ResponseBody>, tonic::Status> {
129    let (parts, body) = request.into_parts();
130    let path = parts.uri.path().to_string();
131    let timeout = grpc_timeout(&parts.headers);
132    let headers = to_wire_headers(&parts.headers)?;
133    let body = RequestBody::stream(request_chunks(body));
134
135    // The deadline covers the whole call, so the timer that bounds the open is the
136    // same one handed to the body afterwards — `select` returns the loser untouched,
137    // so it carries its *remaining* time rather than restarting per phase. It is
138    // created once and never rechecked against a clock, because
139    // `std::time::Instant::now()` panics on `wasm32-unknown-unknown`.
140    let (response, deadline) = match timeout {
141        None => (client.send(&path, headers, body).await?, None),
142        Some(timeout) => {
143            use futures::future::{select, Either};
144            let mut timer = futures_timer::Delay::new(timeout);
145            let opened = {
146                let open = client.send(&path, headers, body);
147                futures::pin_mut!(open);
148                match select(open, &mut timer).await {
149                    Either::Left((response, _)) => Some(response),
150                    Either::Right(((), _)) => None,
151                }
152            };
153            match opened {
154                Some(response) => (response?, Some(timer)),
155                None => return Err(tonic::Status::deadline_exceeded("deadline exceeded")),
156            }
157        }
158    };
159
160    let mut builder = http::Response::builder().status(response.status);
161    // `raw_headers` rather than the collapsed map: gRPC metadata is multi-valued, and
162    // a `HashMap<String, String>` keeps only one of a repeated key.
163    for header in &response.raw_headers {
164        // Pseudo-headers are HTTP/2's, not HTTP's — `:status` is already the status.
165        if header.name.starts_with(':') {
166            continue;
167        }
168        builder = builder.header(&header.name, &header.value);
169    }
170    let (body, trailers) = response.into_parts();
171    builder
172        .body(ResponseBody::new(body.boxed_local(), trailers, deadline))
173        .map_err(|e| tonic::Status::internal(format!("malformed response headers: {e}")))
174}
175
176/// The request body as chunks h2ts can upload.
177///
178/// tonic has already framed each message (`[compressed flag | u32 len | bytes]`), so
179/// this is a byte pass-through and not a re-encode. Request trailers are dropped: a
180/// gRPC *client* never sends any, and half-close is the end of the stream.
181fn request_chunks(body: tonic::body::Body) -> impl futures::Stream<Item = Vec<u8>> {
182    futures::stream::unfold(Box::pin(body), |mut body| async move {
183        loop {
184            let frame = std::future::poll_fn(|cx| body.as_mut().poll_frame(cx)).await;
185            match frame {
186                Some(Ok(frame)) => match frame.into_data() {
187                    Ok(data) => return Some((data.to_vec(), body)),
188                    Err(_trailers) => continue,
189                },
190                // Ending the body here half-closes the stream mid-message, which the
191                // server reports as a truncated request — the honest outcome, since
192                // there is no way to signal "this body failed" over HTTP/2 except by
193                // resetting, and that would lose the server's own status.
194                Some(Err(_)) | None => return None,
195            }
196        }
197    })
198}
199
200/// The request's headers, as h2ts wants them.
201///
202/// tonic has already set `content-type`, `te`, `user-agent` and the compression
203/// headers, and sanitized the connection-specific ones HTTP/2 forbids, so this is a
204/// conversion and not a policy.
205fn to_wire_headers(headers: &HeaderMap) -> Result<Vec<(String, String)>, tonic::Status> {
206    headers
207        .iter()
208        .map(|(name, value)| {
209            // gRPC restricts ASCII metadata to printable ASCII and requires `-bin`
210            // values to be base64, so anything else is already off-spec — and h2ts
211            // header values are `String`. Refusing beats sending mojibake.
212            let value = value.to_str().map_err(|_| {
213                tonic::Status::internal(format!(
214                    "metadata value for `{name}` is not valid ASCII; \
215                     binary metadata must use a `-bin` key"
216                ))
217            })?;
218            Ok((name.as_str().to_string(), value.to_string()))
219        })
220        .collect()
221}
222
223/// Parse `grpc-timeout` (a positive integer plus a unit) into a duration. Anything
224/// unparseable is `None`: a malformed header means the call is unbounded locally, not
225/// that it fails, since the value is the peer's to enforce in the first place.
226fn grpc_timeout(headers: &HeaderMap) -> Option<Duration> {
227    let raw = headers.get("grpc-timeout")?.to_str().ok()?;
228    let (digits, unit) = raw.split_at_checked(raw.len().checked_sub(1)?)?;
229    let value: u64 = digits.parse().ok()?;
230    Some(match unit {
231        "n" => Duration::from_nanos(value),
232        "u" => Duration::from_micros(value),
233        "m" => Duration::from_millis(value),
234        "S" => Duration::from_secs(value),
235        "M" => Duration::from_secs(value.checked_mul(60)?),
236        "H" => Duration::from_secs(value.checked_mul(3600)?),
237        _ => return None,
238    })
239}
240
241fn to_header_map(headers: std::collections::HashMap<String, String>) -> HeaderMap {
242    let mut map = HeaderMap::with_capacity(headers.len());
243    for (name, value) in headers {
244        // A header the `http` crate rejects cannot be represented; dropping it beats
245        // failing the call, since the status itself is what tonic reads out of here
246        // and a malformed *other* trailer should not bury it.
247        if let (Ok(name), Ok(value)) =
248            (HeaderName::try_from(name), HeaderValue::try_from(value))
249        {
250            map.append(name, value);
251        }
252    }
253    map
254}
255
256/// The response body, as an [`http_body::Body`] tonic can decode.
257///
258/// Data frames pass through untouched; the terminal trailers become the final frame,
259/// which is where tonic reads `grpc-status` from. Backpressure is preserved for free:
260/// the window is replenished only as this is polled, so tonic's own consumption rate
261/// is what throttles the server.
262pub struct ResponseBody(SendWrapper<Inner>);
263
264struct Inner {
265    body: LocalBoxStream<'static, Result<Vec<u8>, h2ts_client::H2Error>>,
266    trailers: Trailers,
267    /// The call's deadline, still running from before the response opened.
268    deadline: Option<futures_timer::Delay>,
269    ended: bool,
270}
271
272impl std::fmt::Debug for ResponseBody {
273    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
274        f.debug_struct("ResponseBody").field("ended", &self.0.ended).finish_non_exhaustive()
275    }
276}
277
278impl ResponseBody {
279    fn new(
280        body: LocalBoxStream<'static, Result<Vec<u8>, h2ts_client::H2Error>>,
281        trailers: Trailers,
282        deadline: Option<futures_timer::Delay>,
283    ) -> ResponseBody {
284        ResponseBody(SendWrapper::new(Inner { body, trailers, deadline, ended: false }))
285    }
286}
287
288impl Body for ResponseBody {
289    type Data = Bytes;
290    type Error = tonic::Status;
291
292    fn poll_frame(
293        self: Pin<&mut Self>,
294        cx: &mut Context<'_>,
295    ) -> Poll<Option<Result<Frame<Bytes>, Self::Error>>> {
296        // Deref through the wrapper: this is the thread check, so a body that
297        // travelled panics here rather than touching `Rc`s from two threads.
298        let inner = &mut *self.get_mut().0;
299        if inner.ended {
300            return Poll::Ready(None);
301        }
302        // The deadline is checked before the body, because a server that sends
303        // headers promptly and then stalls is exactly the case it exists for.
304        if let Some(timer) = inner.deadline.as_mut() {
305            if Pin::new(timer).poll(cx).is_ready() {
306                inner.ended = true;
307                return Poll::Ready(Some(Err(tonic::Status::deadline_exceeded(
308                    "deadline exceeded",
309                ))));
310            }
311        }
312        match inner.body.poll_next_unpin(cx) {
313            Poll::Ready(Some(Ok(chunk))) => Poll::Ready(Some(Ok(Frame::data(Bytes::from(chunk))))),
314            Poll::Ready(Some(Err(e))) => {
315                inner.ended = true;
316                Poll::Ready(Some(Err(tonic::Status::unavailable(format!(
317                    "stream failed: {e}"
318                )))))
319            }
320            Poll::Ready(None) => {
321                inner.ended = true;
322                // The status lives in the trailers. A body that ends without any is a
323                // protocol violation, and tonic says so — better it than a guess here.
324                match inner.trailers.get() {
325                    Some(trailers) => {
326                        Poll::Ready(Some(Ok(Frame::trailers(to_header_map(trailers)))))
327                    }
328                    None => Poll::Ready(None),
329                }
330            }
331            Poll::Pending => Poll::Pending,
332        }
333    }
334
335    fn is_end_stream(&self) -> bool {
336        self.0.ended
337    }
338}
339
340impl From<crate::Status> for tonic::Status {
341    fn from(status: crate::Status) -> tonic::Status {
342        let mut headers = HeaderMap::new();
343        for (name, value) in status.metadata.to_headers() {
344            if let (Ok(name), Ok(value)) =
345                (HeaderName::try_from(name), HeaderValue::try_from(value))
346            {
347                headers.append(name, value);
348            }
349        }
350        tonic::Status::with_metadata(
351            tonic::Code::from_i32(status.code as i32),
352            status.message,
353            tonic::metadata::MetadataMap::from_headers(headers),
354        )
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
363        let mut map = HeaderMap::new();
364        for (name, value) in pairs {
365            map.append(
366                HeaderName::try_from(*name).unwrap(),
367                HeaderValue::try_from(*value).unwrap(),
368            );
369        }
370        map
371    }
372
373    #[test]
374    fn every_grpc_timeout_unit_is_understood() {
375        // tonic picks the most precise unit that fits in 8 digits, so `n` and `u` are
376        // the ones it actually emits — but a peer may send any of them, and a unit we
377        // silently misread would be a deadline off by three orders of magnitude.
378        for (raw, expected) in [
379            ("100n", Duration::from_nanos(100)),
380            ("100u", Duration::from_micros(100)),
381            ("100m", Duration::from_millis(100)),
382            ("2S", Duration::from_secs(2)),
383            ("2M", Duration::from_secs(120)),
384            ("2H", Duration::from_secs(7200)),
385        ] {
386            assert_eq!(grpc_timeout(&headers(&[("grpc-timeout", raw)])), Some(expected), "{raw}");
387        }
388    }
389
390    #[test]
391    fn a_missing_or_malformed_timeout_leaves_the_call_unbounded() {
392        // Unbounded rather than failed: the header is advisory to begin with, so a
393        // value this client cannot read is the peer's problem, not the call's.
394        assert_eq!(grpc_timeout(&headers(&[])), None);
395        for raw in ["", "m", "100", "100x", "-1S", "abcS"] {
396            assert_eq!(grpc_timeout(&headers(&[("grpc-timeout", raw)])), None, "{raw:?}");
397        }
398    }
399
400    #[test]
401    fn tonics_own_encoding_round_trips() {
402        // tonic writes `Request::set_timeout` as microseconds up to 8 digits; the pair
403        // has to agree or every deadline set through a generated stub is wrong.
404        assert_eq!(
405            grpc_timeout(&headers(&[("grpc-timeout", "500000u")])),
406            Some(Duration::from_millis(500))
407        );
408    }
409
410    #[test]
411    fn headers_convert_and_binary_metadata_survives_as_base64() {
412        let mut metadata = tonic::metadata::MetadataMap::new();
413        metadata.insert("x-request-id", "abc-123".parse().unwrap());
414        metadata.insert_bin(
415            "x-trace-bin",
416            tonic::metadata::MetadataValue::from_bytes(&[0, 1, 250]),
417        );
418        let wire = to_wire_headers(&metadata.into_headers()).unwrap();
419
420        assert!(wire.contains(&("x-request-id".to_string(), "abc-123".to_string())));
421        // tonic base64s a `-bin` value on the way in, so it is already ASCII here —
422        // this crate must not encode it a second time.
423        let (_, encoded) = wire.iter().find(|(k, _)| k == "x-trace-bin").expect("the -bin key");
424        use base64::Engine as _;
425        assert_eq!(
426            base64::engine::general_purpose::STANDARD_NO_PAD.decode(encoded).unwrap(),
427            vec![0, 1, 250]
428        );
429    }
430
431    #[test]
432    fn a_non_ascii_metadata_value_is_refused_rather_than_mangled() {
433        let mut map = HeaderMap::new();
434        map.append("x-bad", HeaderValue::from_bytes(&[0xff, 0xfe]).unwrap());
435        let error = to_wire_headers(&map).expect_err("not representable on the wire");
436        assert_eq!(error.code(), tonic::Code::Internal);
437        assert!(error.message().contains("x-bad"), "unhelpful message: {}", error.message());
438    }
439
440    #[test]
441    fn a_status_carries_its_code_message_and_metadata_into_tonic() {
442        let mut metadata = crate::Metadata::new();
443        metadata.insert("x-detail", "quota-exhausted");
444        metadata.insert_bin("x-detail-bin", vec![0, 1, 250]);
445        let status = tonic::Status::from(crate::Status {
446            code: crate::Code::FailedPrecondition,
447            message: "no".into(),
448            metadata,
449        });
450
451        assert_eq!(status.code(), tonic::Code::FailedPrecondition);
452        assert_eq!(status.message(), "no");
453        assert_eq!(status.metadata().get("x-detail").unwrap(), "quota-exhausted");
454        assert_eq!(
455            status.metadata().get_bin("x-detail-bin").unwrap().to_bytes().unwrap().as_ref(),
456            &[0, 1, 250]
457        );
458    }
459}