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}