Skip to main content

plaza_ws/
lib.rs

1//! One client-side WebSocket interface, whatever is underneath.
2//!
3//! `plaza_session` covers the server and is tokio/actix by construction, so a
4//! client cannot use it (a browser client least of all). This crate covers the
5//! client side: the socket a *client* holds, with the same shape on a desktop,
6//! in a browser and in-process.
7//!
8//! # Built for a frame loop
9//!
10//! [`Socket::poll`] is non-blocking and drains into a caller-owned buffer. That
11//! choice is made for macroquad-style applications, which have a synchronous
12//! `loop { ...; next_frame().await }` and nowhere to put a future. An
13//! `async fn recv()` would be the natural Rust API and would be unusable there.
14//! Reusing the buffer also keeps a per-frame call allocation-free, matching how
15//! `plaza_server_utils` hands back its results.
16//!
17//! ```no_run
18//! # use plaza_ws::{Socket, Event};
19//! # fn demo(socket: &mut impl Socket) {
20//! let mut events = Vec::new();
21//! // once per frame
22//! socket.poll(&mut events);
23//! for event in events.drain(..) {
24//!   match event {
25//!     Event::Open => println!("connected"),
26//!     Event::Message(bytes) => println!("{} bytes", bytes.len()),
27//!     Event::Text(text) => println!("{text}"),
28//!     Event::Closed(reason) => println!("gone: {reason:?}"),
29//!   }
30//! }
31//! # }
32//! ```
33//!
34//! # Backends
35//!
36//! Each is a feature. They can be combined: a native host that also plays
37//! enables `native` *and* `loopback` and talks to both over the same trait.
38//!
39//! | feature | where | underneath |
40//! |---|---|---|
41//! | `loopback` | anywhere | in-process channels, no dependencies |
42//! | `native` | desktop | `tungstenite` on a worker thread |
43//! | `miniquad` | browser, under macroquad | our own JS, registered as a miniquad plugin |
44//!
45//! ## The browser backend
46//!
47//! The constraint comes from the *host page's loader* rather than from the
48//! browser.
49//!
50//! `web-sys` (and with it `gloo-net` and `tokio-tungstenite-wasm`) needs
51//! `wasm-bindgen`, which rewrites the module with `wasm-bindgen-cli` and ships
52//! its own JS to instantiate it. miniquad's `mq_js_bundle.js` builds its own
53//! import object, lets plugins extend it and instantiates the raw module
54//! itself. Both want to own instantiation, so under macroquad the wasm-bindgen
55//! route does not work. It also fails silently: miniquad stubs out imports
56//! nothing provides, so such a build loads without error and then does nothing.
57//!
58//! So the `miniquad` backend is a few `extern "C"` declarations against our own
59//! JS plugin (`js/plaza_ws.js`), which needs no crate at all. The two crates
60//! that *do* use miniquad's plugin mechanism, `sapp-jsutils` and `quad-net`, are
61//! barely maintained and the mechanism is small enough not to need them.
62//!
63//! An application that is **not** built on macroquad has the opposite problem
64//! and wants the wasm-bindgen route. It would be a fourth backend
65//! (`tokio-tungstenite-wasm` behind a `web` feature), left out until something
66//! needs it so it is not shipped untested.
67
68#![forbid(unsafe_op_in_unsafe_fn)]
69
70use std::fmt;
71
72pub use backlog::{trim_backlog, DroppedBacklog};
73
74pub mod backlog;
75#[cfg(feature = "loopback")]
76pub mod loopback;
77#[cfg(all(feature = "miniquad", target_arch = "wasm32"))]
78pub mod miniquad;
79#[cfg(all(feature = "native", not(target_arch = "wasm32")))]
80pub mod native;
81#[cfg(feature = "pump")]
82pub mod pump;
83#[cfg(feature = "scripted")]
84pub mod scripted;
85
86/// Something that arrived, in order.
87#[derive(Clone, Debug, PartialEq, Eq)]
88pub enum Event {
89  /// The handshake finished, and [`Socket::state`] is now [`State::Open`].
90  ///
91  /// Sending before this arrives is **backend-dependent**, so a portable
92  /// application waits for it: see [`Socket::send`].
93  Open,
94  Message(Vec<u8>),
95  Text(String),
96  /// Terminal. No further events follow.
97  Closed(CloseReason),
98}
99
100/// Why a socket ended.
101#[derive(Clone, Debug, PartialEq, Eq)]
102pub enum CloseReason {
103  /// The peer closed cleanly, with the code and reason it gave.
104  Remote { code: u16, reason: String },
105  /// This side called [`Socket::close`].
106  Local,
107  /// The connection failed or was lost. Distinguished from a clean close
108  /// because an application usually wants to reconnect after one and not the
109  /// other.
110  Error(String),
111}
112
113/// Where a socket is in its lifecycle. Monotonic: a socket never returns to an
114/// earlier state, so [`Closed`](State::Closed) is terminal and reconnecting
115/// means a new socket.
116#[derive(Clone, Copy, Debug, PartialEq, Eq)]
117pub enum State {
118  /// The handshake is in flight. Not every backend has this phase:
119  /// [`loopback`] is connected the moment it exists and so is never
120  /// `Connecting`.
121  Connecting,
122  /// The handshake finished and messages can be sent.
123  Open,
124  /// Terminal, whether the peer closed, this side called
125  /// [`Socket::close`], or the connection failed. Which of those it was is on
126  /// the [`Event::Closed`] that [`Socket::poll`] delivers.
127  Closed,
128}
129
130#[derive(Clone, Debug, PartialEq, Eq)]
131pub enum WsError {
132  /// The socket is closed; nothing further can be sent.
133  Closed,
134  /// The URL could not be parsed or its scheme is not `ws`/`wss`.
135  BadUrl(String),
136  /// The connection could not be established.
137  Connect(String),
138  /// A value could not be serialised for sending. Raised by
139  /// [`SendJson::send_json`] alone: a transport that cannot deliver bytes
140  /// reports [`Closed`](WsError::Closed), so this names a fault in the value
141  /// rather than in the socket.
142  Send(String),
143}
144
145impl fmt::Display for WsError {
146  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147    match self {
148      WsError::Closed => write!(f, "socket is closed"),
149      WsError::BadUrl(url) => write!(f, "not a usable websocket url: {url}"),
150      WsError::Connect(e) => write!(f, "could not connect: {e}"),
151      WsError::Send(e) => write!(f, "could not send: {e}"),
152    }
153  }
154}
155
156impl std::error::Error for WsError {}
157
158/// A client-side WebSocket.
159///
160/// Deliberately small. Anything an application can do itself (reconnection
161/// policy, backoff, heartbeats, framing of its own messages) is left to it,
162/// because those are the application's decisions and the socket only moves
163/// bytes.
164pub trait Socket {
165  /// Sends one binary message.
166  ///
167  /// Takes `&self` rather than `&mut self`, so a send site does not need
168  /// exclusive access to a socket the frame loop is also polling.
169  ///
170  /// Fails with [`WsError::Closed`] once the connection is gone. **Before the
171  /// handshake completes, the backends differ**, and this is the one place
172  /// they do: [`native`] queues the message and delivers it on connect, while
173  /// the `miniquad` backend refuses it with [`WsError::Closed`] because a browser
174  /// `WebSocket` will not accept data before it opens. Portable code sends
175  /// only after [`Event::Open`] (or checks [`is_open`](Self::is_open)), which
176  /// is what an application wanting to know its message actually left should
177  /// do regardless.
178  fn send(&self, bytes: &[u8]) -> Result<(), WsError>;
179
180  /// Sends one text message. Same rules as [`send`](Self::send).
181  ///
182  /// Prefer text for a JSON protocol: a text frame arrives in a browser as a
183  /// string that `JSON.parse` accepts directly, where a binary frame arrives
184  /// as a `Blob` or `ArrayBuffer` the client has to decode itself.
185  fn send_text(&self, text: &str) -> Result<(), WsError>;
186
187  /// Drains everything that has arrived since the last call, appending to `out`.
188  ///
189  /// Never blocks and never awaits. Appends rather than replaces, and takes the
190  /// buffer rather than returning one, so a per-frame call allocates nothing
191  /// after the first.
192  fn poll(&mut self, out: &mut Vec<Event>);
193
194  /// Where the connection is in its lifecycle, readable at any time without
195  /// polling.
196  ///
197  /// This is the connection's own state, not a report of what [`poll`](Self::poll)
198  /// has handed over: a socket can read [`State::Closed`] while its
199  /// [`Event::Closed`], and any messages that arrived before it, are still
200  /// queued. Drain [`poll`](Self::poll) after seeing a close rather than
201  /// discarding the socket, or the last thing the peer said is lost.
202  fn state(&self) -> State;
203
204  /// Begins a close. A [`Event::Closed`] follows from [`poll`](Self::poll);
205  /// calling this twice is harmless.
206  fn close(&mut self);
207
208  /// Whether messages can be sent right now. See [`send`](Self::send) for why
209  /// this matters before the handshake completes.
210  fn is_open(&self) -> bool {
211    self.state() == State::Open
212  }
213}
214
215/// Sending a value as JSON text, so call sites are not full of
216/// `serde_json::to_string`.
217///
218/// An extension trait rather than a method on [`Socket`], because a generic
219/// method cannot be called through a trait object, and holding the socket as
220/// `Box<dyn Socket>` is exactly what an application does when the transport is
221/// chosen by feature flag. Implemented for every socket, sized or not.
222///
223/// Text rather than binary, deliberately. A WebSocket text frame arrives in a
224/// browser as a string that `JSON.parse` accepts directly, while a binary frame
225/// arrives as a `Blob` or `ArrayBuffer` that a JS client has to decode itself,
226/// having first remembered to set `binaryType`.
227///
228/// Send a **bare message rather than an envelope**. A server attaches who a message
229/// came from rather than trusting the client to say, because a client that
230/// could name itself could name somebody else.
231#[cfg(feature = "json")]
232pub trait SendJson {
233  fn send_json<T: serde::Serialize>(&self, value: &T) -> Result<(), WsError>;
234}
235
236#[cfg(feature = "json")]
237impl<S: Socket + ?Sized> SendJson for S {
238  fn send_json<T: serde::Serialize>(&self, value: &T) -> Result<(), WsError> {
239    match serde_json::to_string(value) {
240      Ok(text) => self.send_text(&text),
241      Err(e) => Err(WsError::Send(e.to_string())),
242    }
243  }
244}
245
246/// Connects using whichever real transport this build has for its target.
247///
248/// The choice is never ambiguous: [`native`] exists only off wasm and
249/// `miniquad` only on it, so a build that enables both features (the normal
250/// shape for an application shipping a desktop and a browser client from one
251/// crate) still has exactly one real backend per target. [`loopback`] is never
252/// chosen here because it connects to a peer rather than to a URL.
253#[cfg(any(
254  all(feature = "native", not(target_arch = "wasm32")),
255  all(feature = "miniquad", target_arch = "wasm32"),
256))]
257pub fn connect(url: &str) -> Result<impl Socket + use<>, WsError> {
258  #[cfg(all(feature = "native", not(target_arch = "wasm32")))]
259  return native::connect(url);
260  #[cfg(all(feature = "miniquad", target_arch = "wasm32"))]
261  return miniquad::connect(url);
262}
263
264/// [`connect`], boxed: the form an application holds when the backend is
265/// decided by the build rather than written at the call site.
266///
267/// Unlike [`connect`], this exists in every build. A build with no real backend
268/// gets a runtime [`WsError::Connect`] instead of a compile error, because such
269/// a build is legitimate (an offline teaching build still compiles its connect
270/// path) and every application ends up writing this same fallback arm itself.
271pub fn connect_boxed(url: &str) -> Result<Box<dyn Socket>, WsError> {
272  #[cfg(any(
273    all(feature = "native", not(target_arch = "wasm32")),
274    all(feature = "miniquad", target_arch = "wasm32"),
275  ))]
276  return connect(url).map(|s| Box::new(s) as Box<dyn Socket>);
277  #[cfg(not(any(
278    all(feature = "native", not(target_arch = "wasm32")),
279    all(feature = "miniquad", target_arch = "wasm32"),
280  )))]
281  {
282    let _ = url;
283    Err(WsError::Connect("this build has no socket backend compiled in".to_owned()))
284  }
285}