Skip to main content

grpc_webnext_client/
client.rs

1//! The client: all four RPC cardinalities over one h2ts tunnel.
2//!
3//! Everything here is single-threaded (`Rc`, `!Send`), which is what a wasm
4//! frontend actually is — nothing asserts `Send` to satisfy a runtime this target
5//! does not have.
6
7use std::cell::RefCell;
8use std::future::Future;
9use std::rc::Rc;
10
11use futures::future::{FutureExt, LocalBoxFuture, Shared};
12use futures::stream::{LocalBoxStream, Stream, StreamExt};
13use h2ts_client::{
14    ConnectOptions, H2Connection, RequestBody, RequestInit, Response, Trailers, Transport,
15};
16
17use crate::codec::{encode_message, Deframer};
18use crate::metadata::Metadata;
19use crate::state::{ConnectivityState, StateWatch};
20use crate::status::{Code, Status};
21
22/// Opens a tunnel on demand. The client holds one so it can **redial**, which is
23/// what makes it a gRPC channel rather than a handle to a socket.
24pub type Connector =
25    Rc<dyn Fn() -> LocalBoxFuture<'static, Result<H2Connection, Status>>>;
26
27type SharedDial = Shared<LocalBoxFuture<'static, Result<Rc<H2Connection>, Status>>>;
28
29const CONTENT_TYPE: &str = "application/grpc+proto";
30
31/// Per-call options.
32#[derive(Debug, Clone, Default)]
33pub struct CallOptions {
34    pub metadata: Metadata,
35    /// Deadline for the call.
36    ///
37    /// Sent as `grpc-timeout` **and** enforced locally, because the header alone
38    /// guarantees nothing: it is a request to the far end, and a peer that ignores
39    /// it leaves the caller waiting forever. The header still goes out so anything
40    /// that *does* enforce it — a server, a proxy, an upstream — can.
41    ///
42    /// On a stream this covers the **whole call**, not just its opening: one timer
43    /// spans the request and every message read after it. Bounding only the open
44    /// would miss the case the deadline exists for, a peer that answers promptly and
45    /// then stalls.
46    pub timeout: Option<std::time::Duration>,
47    /// Reject an inbound message larger than this (RESOURCE_EXHAUSTED).
48    pub max_message_bytes: Option<usize>,
49}
50
51impl CallOptions {
52    pub fn new() -> CallOptions {
53        CallOptions::default()
54    }
55    pub fn with_metadata(mut self, metadata: Metadata) -> Self {
56        self.metadata = metadata;
57        self
58    }
59    pub fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
60        self.timeout = Some(timeout);
61        self
62    }
63    pub fn with_max_message_bytes(mut self, max: usize) -> Self {
64        self.max_message_bytes = Some(max);
65        self
66    }
67}
68
69/// A grpc-webnext client: a gRPC **channel**, not a handle to a socket.
70///
71/// Cheap to clone — clones share one tunnel, which is the point, since calls
72/// multiplex over it. When that tunnel dies the channel redials on the next call,
73/// the way `tonic::transport::Channel` does: the call that discovers the failure
74/// reports it, and the one after reconnects. Watch [`Client::state_changes`] if the
75/// application wants to say something about it.
76#[derive(Clone)]
77pub struct Client {
78    inner: Rc<Inner>,
79}
80
81struct Inner {
82    /// `None` for a client built over a caller-supplied transport: that transport
83    /// is consumed, so there is nothing to redial with.
84    connector: Option<Connector>,
85    tunnel: RefCell<Option<Rc<H2Connection>>>,
86    /// The dial in flight, shared so concurrent calls open **one** socket rather
87    /// than one each.
88    dialing: RefCell<Option<SharedDial>>,
89    state: StateWatch,
90    authority: String,
91}
92
93impl Client {
94    /// Build a client over an existing h2ts connection. Single-shot: there is no
95    /// way to redial, so a dropped tunnel stays dropped. Prefer
96    /// [`connect`](crate::connect), which reconnects.
97    pub fn from_connection(conn: H2Connection, authority: impl Into<String>) -> Client {
98        let state = StateWatch::default();
99        state.set(ConnectivityState::Ready);
100        Client {
101            inner: Rc::new(Inner {
102                connector: None,
103                tunnel: RefCell::new(Some(Rc::new(conn))),
104                dialing: RefCell::new(None),
105                state,
106                authority: authority.into(),
107            }),
108        }
109    }
110
111    /// Build a **reconnecting** client from something that can open a tunnel on
112    /// demand. Each call to `connector` must yield a fresh connection, with its
113    /// driver already spawned.
114    pub fn with_connector(connector: Connector, authority: impl Into<String>) -> Client {
115        Client {
116            inner: Rc::new(Inner {
117                connector: Some(connector),
118                tunnel: RefCell::new(None),
119                dialing: RefCell::new(None),
120                state: StateWatch::default(),
121                authority: authority.into(),
122            }),
123        }
124    }
125
126    /// Build a client over any byte transport, returning the driver future the
127    /// caller must poll. Spawn it: `wasm_bindgen_futures::spawn_local` in a
128    /// browser, `tokio::task::spawn_local` on a `LocalSet` natively.
129    ///
130    /// Single-shot, since the transport is consumed — see
131    /// [`from_connection`](Self::from_connection).
132    pub fn over_transport(
133        transport: Transport,
134        authority: impl Into<String>,
135        options: ConnectOptions,
136    ) -> (Client, impl Future<Output = ()>) {
137        let (conn, driver) = h2ts_client::connect(transport, options);
138        (Client::from_connection(conn, authority), driver)
139    }
140
141    /// The channel's connectivity state.
142    pub fn state(&self) -> ConnectivityState {
143        // A tunnel that died since the last call has not been noticed yet.
144        if matches!(self.inner.state.get(), ConnectivityState::Ready)
145            && self.inner.tunnel.borrow().as_ref().is_none_or(|c| c.is_closed())
146        {
147            self.inner.state.set(ConnectivityState::Idle);
148        }
149        self.inner.state.get()
150    }
151
152    /// Watch connectivity transitions — gRPC's `WaitForStateChange`, as a stream.
153    /// Repeats are collapsed, so every item is a real change.
154    pub fn state_changes(&self) -> impl Stream<Item = ConnectivityState> + 'static {
155        self.inner.state.watch()
156    }
157
158    /// True if there is no usable tunnel right now. A reconnecting client will open
159    /// one on the next call; a single-shot one will not.
160    pub fn is_closed(&self) -> bool {
161        self.inner.tunnel.borrow().as_ref().is_none_or(|c| c.is_closed())
162    }
163
164    /// The live tunnel, dialing one if needed.
165    async fn tunnel(&self) -> Result<Rc<H2Connection>, Status> {
166        // Fast path: a live tunnel. Never hold the borrow across an await.
167        let cached_is_dead = {
168            let tunnel = self.inner.tunnel.borrow();
169            match tunnel.as_ref() {
170                Some(conn) if !conn.is_closed() => return Ok(conn.clone()),
171                Some(_) => true,
172                None => false,
173            }
174        };
175        if cached_is_dead {
176            // Report the disconnect before the redial. A watcher that only ever saw
177            // Connecting -> Ready could not tell a reconnect from a first connect,
178            // which is the difference between "still fine" and "we dropped and
179            // recovered".
180            self.inner.tunnel.borrow_mut().take();
181            self.inner.state.set(ConnectivityState::Idle);
182        }
183
184        let Some(connector) = self.inner.connector.clone() else {
185            return Err(Status::unavailable(
186                "the tunnel is closed and this client cannot redial \
187                 (built over a caller-supplied transport)",
188            ));
189        };
190
191        // Join a dial already in flight rather than opening a second socket.
192        let dial = {
193            let mut dialing = self.inner.dialing.borrow_mut();
194            match dialing.as_ref() {
195                Some(shared) => shared.clone(),
196                None => {
197                    self.inner.state.set(ConnectivityState::Connecting);
198                    let shared: SharedDial =
199                        async move { connector().await.map(Rc::new) }.boxed_local().shared();
200                    *dialing = Some(shared.clone());
201                    shared
202                }
203            }
204        };
205
206        let result = dial.await;
207        // Clear the slot so a later failure redials rather than replaying this one.
208        self.inner.dialing.borrow_mut().take();
209        match result {
210            Ok(conn) => {
211                self.inner.state.set(ConnectivityState::Ready);
212                *self.inner.tunnel.borrow_mut() = Some(conn.clone());
213                Ok(conn)
214            }
215            Err(e) => {
216                self.inner.state.set(ConnectivityState::TransientFailure);
217                Err(e)
218            }
219        }
220    }
221
222    /// Note that the tunnel we just used is gone, so the next call redials.
223    fn forget(&self, dead: &Rc<H2Connection>) {
224        let mut tunnel = self.inner.tunnel.borrow_mut();
225        if tunnel.as_ref().is_some_and(|c| Rc::ptr_eq(c, dead)) {
226            tunnel.take();
227            self.inner.state.set(ConnectivityState::Idle);
228        }
229    }
230
231    /// Unary: one message out, one message back.
232    pub async fn unary(
233        &self,
234        path: &str,
235        request: Vec<u8>,
236        options: CallOptions,
237    ) -> Result<UnaryResponse, Status> {
238        self.single_response(path, RequestBody::Bytes(encode_message(&request)), &options).await
239    }
240
241    /// Client streaming: a stream of messages out, one message back.
242    ///
243    /// The request body is uploaded as the stream yields, under HTTP/2 flow
244    /// control — a slow server pushes back rather than the messages piling up here.
245    pub async fn client_streaming<S>(
246        &self,
247        path: &str,
248        requests: S,
249        options: CallOptions,
250    ) -> Result<UnaryResponse, Status>
251    where
252        S: Stream<Item = Vec<u8>> + 'static,
253    {
254        let body = RequestBody::stream(requests.map(|m| encode_message(&m)));
255        self.single_response(path, body, &options).await
256    }
257
258    /// Server streaming: one message out, a stream of messages back.
259    pub async fn server_streaming(
260        &self,
261        path: &str,
262        request: Vec<u8>,
263        options: CallOptions,
264    ) -> Result<Streaming, Status> {
265        let body = RequestBody::Bytes(encode_message(&request));
266        self.open_stream(path, body, &options).await
267    }
268
269    /// Bidirectional streaming: a stream out, a stream back, concurrently.
270    pub async fn bidi_streaming<S>(
271        &self,
272        path: &str,
273        requests: S,
274        options: CallOptions,
275    ) -> Result<Streaming, Status>
276    where
277        S: Stream<Item = Vec<u8>> + 'static,
278    {
279        let body = RequestBody::stream(requests.map(|m| encode_message(&m)));
280        self.open_stream(path, body, &options).await
281    }
282
283    /// Open a streaming response, with the deadline covering the **whole call**.
284    ///
285    /// One timer spans both phases: opening the stream and every message read after
286    /// it. Bounding only the open would be worse than useless here — a server that
287    /// sends headers promptly and then stalls is exactly the case a deadline is for,
288    /// and it is the case that would slip through.
289    ///
290    /// The timer is created once and handed to [`Streaming`] still running, rather
291    /// than recomputed from a start instant, because `std::time::Instant::now()`
292    /// panics on `wasm32-unknown-unknown` — there is no clock behind it. A single
293    /// live `Delay` needs no clock reads at all.
294    async fn open_stream(
295        &self,
296        path: &str,
297        body: RequestBody,
298        options: &CallOptions,
299    ) -> Result<Streaming, Status> {
300        use futures::future::{select, Either};
301
302        let Some(timeout) = options.timeout else {
303            return Ok(Streaming::new(self.request(path, body, options).await?, options, None));
304        };
305
306        let mut timer = futures_timer::Delay::new(timeout);
307        // Scoped so the borrow of `timer` ends before it is moved into the stream.
308        // `select` hands the loser back untouched, so the timer carries its
309        // *remaining* time onward instead of restarting per phase.
310        let opened = {
311            let open = self.request(path, body, options);
312            futures::pin_mut!(open);
313            match select(open, &mut timer).await {
314                Either::Left((response, _)) => Some(response),
315                Either::Right(((), _)) => None,
316            }
317        };
318        match opened {
319            Some(response) => Ok(Streaming::new(response?, options, Some(timer))),
320            None => Err(Status::new(Code::DeadlineExceeded, "deadline exceeded")),
321        }
322    }
323
324    /// Issue a request whose response is exactly one message, and read it to the
325    /// terminal status.
326    async fn single_response(
327        &self,
328        path: &str,
329        body: RequestBody,
330        options: &CallOptions,
331    ) -> Result<UnaryResponse, Status> {
332        match options.timeout {
333            Some(timeout) => {
334                deadline(timeout, self.single_response_inner(path, body, options)).await
335            }
336            None => self.single_response_inner(path, body, options).await,
337        }
338    }
339
340    async fn single_response_inner(
341        &self,
342        path: &str,
343        body: RequestBody,
344        options: &CallOptions,
345    ) -> Result<UnaryResponse, Status> {
346        let mut response = self.request(path, body, options).await?;
347
348        // The status is in the trailers, which only exist once the body is done.
349        let bytes = response
350            .bytes()
351            .await
352            .map_err(|e| Status::unavailable(format!("response body failed: {e}")))?;
353
354        let mut deframer = Deframer::new(options.max_message_bytes);
355        let messages = deframer.push(&bytes)?;
356        if deframer.pending() > 0 {
357            return Err(Status::new(Code::Internal, "response body ended mid-message (truncated)"));
358        }
359
360        // Trailers first: a non-OK status explains a missing message, so reporting
361        // "no message" over the server's own reason would bury the cause.
362        let status = match response.trailers() {
363            Some(trailers) => Status::from_headers(&trailers).unwrap_or_else(|| {
364                Status::new(Code::Internal, "response trailers carried no grpc-status")
365            }),
366            None => Status::new(Code::Internal, "response ended without trailers"),
367        };
368        if !status.is_ok() {
369            return Err(status);
370        }
371
372        let message = messages
373            .into_iter()
374            .next()
375            .ok_or_else(|| Status::new(Code::Internal, "response carried no message"))?;
376        Ok(UnaryResponse {
377            message,
378            headers: Metadata::from_headers(&response.headers),
379            trailers: status.metadata,
380        })
381    }
382
383    /// Issue the request and wait for response headers. A trailers-only response —
384    /// how gRPC reports a call that failed before producing anything — becomes the
385    /// error here, so no caller has to special-case it.
386    async fn request(
387        &self,
388        path: &str,
389        body: RequestBody,
390        options: &CallOptions,
391    ) -> Result<Response, Status> {
392        let conn = self.tunnel().await?;
393        let mut headers = vec![
394            ("content-type".to_string(), CONTENT_TYPE.to_string()),
395            ("te".to_string(), "trailers".to_string()),
396        ];
397        if let Some(timeout) = options.timeout {
398            // `m` is milliseconds; never send 0, which would mean "already expired".
399            headers.push(("grpc-timeout".to_string(), format!("{}m", options_millis(timeout))));
400        }
401        headers.extend(options.metadata.to_headers());
402
403        let response = match conn
404            .request(RequestInit {
405                method: Some("POST".to_string()),
406                path: Some(path.to_string()),
407                authority: Some(self.inner.authority.clone()),
408                scheme: Some("http".to_string()),
409                headers,
410                body,
411            })
412            .await
413        {
414            Ok(response) => response,
415            Err(e) => {
416                // The call that discovers a dead tunnel reports the failure; dropping it
417                // here is what makes the *next* call redial. Same shape as tonic's
418                // `Reconnect`, which returns to Idle on error rather than retrying in
419                // place — replaying a request the server may already have seen is a
420                // policy decision, not the transport's to make.
421                if conn.is_closed() {
422                    self.forget(&conn);
423                }
424                return Err(Status::unavailable(format!("request failed: {e}")));
425            }
426        };
427
428        if response.status != 200 {
429            return Err(Status::unavailable(format!("HTTP {}", response.status)));
430        }
431        // A status in the *headers* means trailers-only: the call is already over.
432        if let Some(status) = Status::from_headers(&response.headers) {
433            return Err(if status.is_ok() {
434                Status::new(Code::Internal, "trailers-only response reported OK with no message")
435            } else {
436                status
437            });
438        }
439        Ok(response)
440    }
441}
442
443fn options_millis(timeout: std::time::Duration) -> u128 {
444    timeout.as_millis().max(1)
445}
446
447/// Race a call against its deadline. Losing the race is DEADLINE_EXCEEDED, and
448/// dropping the future is what actually stops the work — the h2ts stream is reset
449/// on drop, so the server learns about it.
450async fn deadline<T>(
451    timeout: std::time::Duration,
452    work: impl std::future::Future<Output = Result<T, Status>>,
453) -> Result<T, Status> {
454    use futures::future::{select, Either};
455    let timer = futures_timer::Delay::new(timeout);
456    futures::pin_mut!(work);
457    futures::pin_mut!(timer);
458    match select(work, timer).await {
459        Either::Left((result, _)) => result,
460        Either::Right(((), _)) => {
461            Err(Status::new(Code::DeadlineExceeded, "deadline exceeded"))
462        }
463    }
464}
465
466/// An in-flight response stream.
467///
468/// Backpressure is real and needs no code here: the body replenishes the HTTP/2
469/// receive window only as it is polled, so a consumer that stops reading stops the
470/// server rather than filling memory. That is the same guarantee the TypeScript
471/// client gets, and for the same reason — it is HTTP/2 doing its job.
472pub struct Streaming {
473    /// Initial metadata, available as soon as the stream opens.
474    pub headers: Metadata,
475    // (the body is a boxed stream, so this is hand-written rather than derived)
476    body: LocalBoxStream<'static, Result<Vec<u8>, h2ts_client::H2Error>>,
477    trailers: Trailers,
478    deframer: Deframer,
479    ready: std::collections::VecDeque<Vec<u8>>,
480    ended: bool,
481    failed: Option<Status>,
482    /// The call's deadline, still running from before the stream opened. `None`
483    /// when the call set no timeout.
484    deadline: Option<futures_timer::Delay>,
485}
486
487impl std::fmt::Debug for Streaming {
488    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
489        f.debug_struct("Streaming")
490            .field("headers", &self.headers)
491            .field("ended", &self.ended)
492            .field("failed", &self.failed)
493            .finish_non_exhaustive()
494    }
495}
496
497impl Streaming {
498    fn new(
499        response: Response,
500        options: &CallOptions,
501        deadline: Option<futures_timer::Delay>,
502    ) -> Streaming {
503        let headers = Metadata::from_headers(&response.headers);
504        // `into_parts` is what makes a gRPC stream expressible at all: the body and
505        // the trailers carrying the terminal status must both outlive each other.
506        let (body, trailers) = response.into_parts();
507        Streaming {
508            headers,
509            body: body.boxed_local(),
510            trailers,
511            deframer: Deframer::new(options.max_message_bytes),
512            ready: Default::default(),
513            ended: false,
514            failed: None,
515            deadline,
516        }
517    }
518
519    /// The next response message, or `None` once the stream ends.
520    ///
521    /// After `None`, call [`Streaming::status`] for how it ended — a stream that
522    /// finishes cleanly still has a status, and it is not always OK.
523    pub async fn message(&mut self) -> Result<Option<Vec<u8>>, Status> {
524        loop {
525            if let Some(message) = self.ready.pop_front() {
526                return Ok(Some(message));
527            }
528            if let Some(status) = self.failed.clone() {
529                return Err(status);
530            }
531            if self.ended {
532                return Ok(None);
533            }
534            // Race the body against the call's deadline. Without this a server that
535            // sends headers and then stalls holds the caller forever: the request's
536            // `grpc-timeout` is advisory, and a stalled stream is precisely what it
537            // is advising about.
538            let chunk = {
539                use futures::future::{select, Either};
540                let body = &mut self.body;
541                match self.deadline.as_mut() {
542                    Some(timer) => match select(body.next(), timer).await {
543                        Either::Left((chunk, _)) => Some(chunk),
544                        Either::Right(((), _)) => None,
545                    },
546                    None => Some(body.next().await),
547                }
548            };
549            let Some(chunk) = chunk else {
550                return Err(self.fail(Status::new(Code::DeadlineExceeded, "deadline exceeded")));
551            };
552            match chunk {
553                Some(Ok(chunk)) => match self.deframer.push(&chunk) {
554                    Ok(messages) => self.ready.extend(messages),
555                    Err(status) => return Err(self.fail(status)),
556                },
557                Some(Err(e)) => {
558                    return Err(self.fail(Status::unavailable(format!("stream failed: {e}"))))
559                }
560                None => {
561                    self.ended = true;
562                    // A body that stops mid-message is truncated; handing that back as a
563                    // clean end of stream would lose data silently.
564                    if self.deframer.pending() > 0 {
565                        return Err(self.fail(Status::new(
566                            Code::Internal,
567                            "response body ended mid-message (truncated)",
568                        )));
569                    }
570                }
571            }
572        }
573    }
574
575    /// The terminal status. Only meaningful once [`Streaming::message`] has
576    /// returned `None`; before that the trailers have not arrived.
577    pub fn status(&self) -> Status {
578        if let Some(status) = &self.failed {
579            return status.clone();
580        }
581        match self.trailers.get() {
582            Some(trailers) => Status::from_headers(&trailers).unwrap_or_else(|| {
583                Status::new(Code::Internal, "response trailers carried no grpc-status")
584            }),
585            // A stream that ends without trailers never delivered a status, which is
586            // the server violating the protocol — not a silent success.
587            None => Status::new(Code::Internal, "response ended without trailers"),
588        }
589    }
590
591    /// Drain to the end and return the terminal status.
592    pub async fn finish(&mut self) -> Status {
593        loop {
594            match self.message().await {
595                Ok(Some(_)) => continue,
596                Ok(None) => return self.status(),
597                Err(status) => return status,
598            }
599        }
600    }
601
602    fn fail(&mut self, status: Status) -> Status {
603        self.ended = true;
604        self.failed = Some(status.clone());
605        status
606    }
607}
608
609/// A completed single-response call.
610#[derive(Debug, Clone)]
611pub struct UnaryResponse {
612    pub message: Vec<u8>,
613    /// Initial metadata (response headers).
614    pub headers: Metadata,
615    /// Trailing metadata — not the same thing, and often where the detail is.
616    pub trailers: Metadata,
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622
623    #[test]
624    fn a_sub_millisecond_timeout_never_becomes_zero() {
625        // `grpc-timeout: 0m` means "already expired", so rounding down would turn a
626        // very short deadline into an instant failure.
627        assert_eq!(options_millis(std::time::Duration::from_micros(1)), 1);
628        assert_eq!(options_millis(std::time::Duration::from_millis(250)), 250);
629    }
630}