Skip to main content

hclient_core/unversioned/
websocket.rs

1//! The WebSocket seam: a message channel, and the thing that opens one.
2//!
3//! # Why this is not a method on [`Transport`](super::Transport)
4//!
5//! The same reasoning `hclient-tls-quic`'s `QuicTlsConnect` rests on, and
6//! `hclient_rt::TcpAdoptStd` before it: the intersection between "send a
7//! request, read a response" and "exchange messages until somebody closes"
8//! is empty, and an adapter between them would type-check *with an empty
9//! body*. A `Transport::websocket` returning `Err(Unsupported)` would push
10//! the same failure from compile time to run time, on a feature a caller
11//! either has or has not.
12//!
13//! **So the seam expresses itself by being implemented.** A backend that
14//! can do WebSocket implements [`WebSocketConnect`]; one that cannot does
15//! not, and asking it for a WebSocket does not compile. There is
16//! deliberately no capability field to read: a runtime `Unsupported`
17//! would move the same failure from compile time to run time.
18//!
19//! # Why message oriented, rather than "hand back the socket"
20//!
21//! A byte-stream seam is implementable by exactly one of this project's
22//! four backends, and the three it excludes include the browser — the
23//! target whose inclusion is the whole claim. `WebSocket` in a browser is
24//! a wholly separate global reached from no `fetch`-shaped API, it hands
25//! back no bytes, and on Apple platforms `NSURLSessionWebSocketTask` is
26//! message-framed too. So the h1 upgrade is an implementation detail
27//! *underneath* this seam on native, not the seam.
28//!
29//! # What is deliberately not here
30//!
31//! - **`Ping` and `Pong` are not [`Message`] variants.** RFC 6455 §5.5.2
32//!   makes answering a ping the *endpoint's* duty, not the caller's, and
33//!   `hclient-tungstenite` discharges it without telling anybody
34//!   (`crates/hclient-tungstenite/tests/websocket.rs` watches the pong
35//!   leave from the server's side of the wire). A caller-visible `Ping`
36//!   would be
37//!   a variant the browser can neither send nor ever receive, which is the
38//!   capability lie this workspace has caught four times. If a caller
39//!   decision ever turns on one, adding the variant is a compile error at
40//!   every backend — which is the right way round, and why this enum is
41//!   not `#[non_exhaustive]`.
42//! - **Permessage-deflate and subprotocol negotiation** are not
43//!   supported. A subprotocol *can* be asked for, because the request
44//!   carries headers; nothing here checks what came back.
45use crate::Error;
46use futures_core::Stream;
47use futures_sink::Sink;
48use std::future::Future;
49
50/// One WebSocket message, in the vocabulary every backend can speak.
51///
52/// Not `#[non_exhaustive]`: see the module doc. Nothing here is published,
53/// so a new variant costs a rebase inside this workspace and a compile
54/// error is what a backend author should get.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum Message {
57    /// A text message. RFC 6455 §5.6 requires it to be valid UTF-8, which
58    /// `String` is by construction — a backend that reads invalid UTF-8
59    /// off the wire owes an error, not a lossy conversion.
60    Text(String),
61    /// A binary message.
62    Binary(bytes::Bytes),
63    /// The close handshake, in whichever direction it was seen.
64    ///
65    /// Received: the peer is closing, and the [`Stream`] ends after this.
66    /// Sent: the close frame is queued; keep polling the [`Stream`] until
67    /// it ends if the peer's answer matters.
68    Close(Option<CloseFrame>),
69}
70
71/// The close code and reason of a [`Message::Close`].
72///
73/// `u16` rather than an enum of the RFC 6455 §7.4 codes: this seam does
74/// not interpret them, and an enum would have to decide what a reserved
75/// or application-defined code means, and this seam has not decided.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct CloseFrame {
78    /// RFC 6455 §7.4 status code.
79    pub code: u16,
80    /// The reason, which RFC 6455 requires to be valid UTF-8.
81    pub reason: String,
82}
83
84/// An open WebSocket: messages out, messages in.
85///
86/// `Stream` for the receiving half and `Sink` for the sending half, on one
87/// value rather than a split pair, because splitting is something
88/// `futures_util::StreamExt::split` already does for any `Stream + Sink`
89/// and a seam that pre-split would take that choice away from the caller.
90///
91/// # The error type is concrete, unlike [`Transport::Error`](crate::unversioned::Transport::Error)
92///
93/// [`Transport`](super::Transport) carries `type Error` and a `to_error`
94/// hook so a backend whose error is genuinely `!Send` can still implement
95/// it. That escape hatch has no subject here: it exists so a backend can
96/// keep its own *typed source* while `Client` classifies, and there is no
97/// `Client` between this trait and its caller — whatever a backend would
98/// put in its own error, it can put in [`Error`]'s source, which is where
99/// a caller would read it from anyway. One concrete type also keeps
100/// `Stream::Item`'s error and `Sink::Error` the same type without an
101/// associated-type equality the caller has to spell out.
102///
103/// # Ending
104///
105/// The `Stream` ends (`None`) when the connection is finished: after the
106/// peer's [`Message::Close`] has been delivered, or when the connection
107/// broke and the error has already been reported. A `Stream` that has
108/// ended stays ended.
109pub trait WebSocket: Stream<Item = Result<Message, Error>> + Sink<Message, Error = Error> {}
110
111/// A backend that can open a WebSocket.
112///
113/// Implemented either by a transport itself (`hclient_fetch::Fetch`, where
114/// the platform hands back messages) or by a connector over one
115/// (`hclient_tungstenite::Tungstenite`, where it hands back bytes and the
116/// framing is a crate of its own).
117/// Either way a WebSocket opened this way inherits everything the
118/// transport already knows: its runtime, its TLS configuration, its
119/// resolver.
120pub trait WebSocketConnect {
121    /// The open connection.
122    type WebSocket: WebSocket;
123
124    /// Open one.
125    ///
126    /// # What `req` is for, and the duty it puts on the implementer
127    ///
128    /// The URI is the only field with a required interpretation: `ws://`
129    /// and `wss://`, and `http://`/`https://` read as the same two, since
130    /// a caller who already holds an origin should not have to rewrite its
131    /// scheme. Everything else the request carries — headers in
132    /// particular — is a request to the implementer, and
133    /// **a backend that cannot send a header the request carries must
134    /// fail rather than drop it.** That is the rule `hclient-wasi` already
135    /// follows for `wasi:http`'s request options, and it is what keeps
136    /// this seam from becoming the place where an `Authorization` header
137    /// silently does not go out. It is also the whole of the answer for a
138    /// browser backend, which can send no headers at all beyond the
139    /// subprotocol list.
140    ///
141    /// The method and version are ignored: RFC 6455 §4.1 fixes both, and
142    /// a backend is free to build the handshake it must build.
143    ///
144    /// # Cancellation
145    ///
146    /// Dropping this future before it completes stops the attempt, on
147    /// exactly the terms [`Transport::execute`](super::Transport::execute)
148    /// states: no further bytes, nothing waited for, and the socket torn
149    /// down rather than left running.
150    fn websocket(
151        &self,
152        req: http::Request<()>,
153    ) -> impl Future<Output = Result<Self::WebSocket, Error>>;
154}