plaza_ws 0.7.0

One client-side WebSocket interface across desktop, browser, and in-process, for frame-loop applications.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
# API Reference: `plaza_ws`

`plaza_ws` is one WebSocket client API over three backends: native, browser and an in-process loopback, plus the pump that turns a socket into typed messages.

## Contents

- [1. Core API](#1-core-api)
  - [Trait `Socket`](#trait-socket)
  - [Enum `Event`](#enum-event)
  - [Enum `CloseReason`](#enum-closereason)
  - [Enum `State`](#enum-state)
  - [Trait `SendJson` (feature `json`)](#trait-sendjson-feature-json)
  - [Function `connect`](#function-connect)
  - [Function `connect_boxed`](#function-connect_boxed)
- [2. Module `backlog`](#2-module-backlog)
  - [Function `trim_backlog`](#function-trim_backlog)
  - [Struct `DroppedBacklog`](#struct-droppedbacklog)
- [3. Module `loopback` (feature `loopback`, on by default)](#3-module-loopback-feature-loopback-on-by-default)
  - [Function `loopback::pair`](#function-loopbackpair)
  - [Struct `LoopbackSocket`](#struct-loopbacksocket)
- [4. Module `native` (feature `native`, non-wasm32 only)](#4-module-native-feature-native-non-wasm32-only)
  - [Function `native::connect`](#function-nativeconnect)
  - [Struct `NativeSocket`](#struct-nativesocket)
- [5. Module `miniquad` (feature `miniquad`, wasm32 only)](#5-module-miniquad-feature-miniquad-wasm32-only)
  - [Function `miniquad::connect`](#function-miniquadconnect)
  - [Function `miniquad::page_url`](#function-miniquadpage_url)
  - [Struct `MiniquadSocket`](#struct-miniquadsocket)
  - [Function `plaza_ws_crate_version`](#function-plaza_ws_crate_version)
- [6. Module `pump` (feature `pump`)](#6-module-pump-feature-pump)
  - [Struct `FramePump<C: WireCodec>`](#struct-framepumpc-wirecodec)
  - [Enum `Arrival`](#enum-arrival)
  - [Function `mismatch_message`](#function-mismatch_message)
- [7. Module `scripted` (feature `scripted`)](#7-module-scripted-feature-scripted)
  - [Struct `ScriptedSocket`](#struct-scriptedsocket)
- [8. Feature Flags](#8-feature-flags)
- [9. Error Handling](#9-error-handling)
  - [Enum `WsError`](#enum-wserror)

## 1. Core API

### Trait `Socket`

```rust
pub trait Socket {
  fn send(&self, bytes: &[u8]) -> Result<(), WsError>;
  fn send_text(&self, text: &str) -> Result<(), WsError>;
  fn poll(&mut self, out: &mut Vec<Event>);
  fn state(&self) -> State;
  fn close(&mut self);
  fn is_open(&self) -> bool { self.state() == State::Open }
}
```

A client-side WebSocket. Deliberately small. Anything an application can do itself (reconnection policy, backoff, heartbeats, framing of its own messages) is left to it, because those are the application's decisions and the socket only moves bytes.

#### Method `send`

Sends a binary frame. Sends before [`Event::Open`](#enum-event) are queued, not errors, because a frame loop should not have to hold its own outbox. Returns `Err(WsError::Closed)` once the socket has ended.

#### Method `send_text`

Sends a text frame. Same queuing and error behavior as [`send`](#method-send).

#### Method `poll`

Drains everything that has arrived since the last call, appending to `out`. Never blocks and never awaits. Appends rather than replaces and takes the buffer rather than returning one, so a per-frame call allocates nothing after the first. Call it once per frame and drain the buffer yourself.

#### Method `state`

The socket's current [`State`](#enum-state). Cheap on every backend; the native backend mirrors it in an atomic so no lock or channel round trip is involved.

#### Method `close`

Begins a close. An [`Event::Closed`](#enum-event) follows from [`poll`](#method-poll); calling this twice is harmless.

#### Method `is_open`

Provided. `true` when [`state`](#method-state) is `State::Open`.

### Enum `Event`

```rust
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Event {
  Open,
  Message(Vec<u8>),
  Text(String),
  Closed(CloseReason),
}
```

Something that arrived, in order.

*   **`Open`**: the handshake finished. Sends before this are queued, not errors.
*   **`Message(Vec<u8>)`**: a binary frame.
*   **`Text(String)`**: a text frame.
*   **`Closed(CloseReason)`**: terminal. No further events follow.

### Enum `CloseReason`

```rust
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CloseReason {
  Remote { code: u16, reason: String },
  Local,
  Error(String),
}
```

Why a socket ended.

*   **`Remote`**: the peer closed cleanly, with the code and reason it gave.
*   **`Local`**: this side called [`Socket::close`](#method-close).
*   **`Error(String)`**: the connection failed or was lost. Distinguished from a clean close because an application usually wants to reconnect after one and not the other.

### Enum `State`

```rust
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum State {
  Connecting,
  Open,
  Closed,
}
```

### Trait `SendJson` (feature `json`)

```rust
pub trait SendJson {
  fn send_json<T: serde::Serialize>(&self, value: &T) -> Result<(), WsError>;
}

impl<S: Socket + ?Sized> SendJson for S { /* serde_json::to_string, then send_text */ }
```

Sending a value as JSON text, so call sites are not full of `serde_json::to_string`. A serialization failure returns `WsError::Send`.

An extension trait rather than a method on [`Socket`](#trait-socket), because a generic method cannot be called through a trait object and holding the socket as `Box<dyn Socket>` is exactly what an application does when the transport is chosen by feature flag. The blanket impl covers every socket, sized or not.

Text rather than binary, deliberately. A WebSocket text frame arrives in a browser as a string that `JSON.parse` accepts directly, while a binary frame arrives as a `Blob` or `ArrayBuffer` that a JS client has to decode itself, having first remembered to set `binaryType`.

Send a **bare message rather than an envelope**. A server attaches who a message came from rather than trusting the client to say, because a client that could name itself could name somebody else. This is the same asymmetry `plaza_wire` documents on `SessionMessage`.

### Function `connect`

```rust
pub fn connect(url: &str) -> Result<impl Socket + use<>, WsError>
```

Connects using whichever real transport this build has for its target.

The choice is never ambiguous: `native` exists only off wasm and `miniquad` only on it, so a build that enables both features (the normal shape for an application shipping a desktop and a browser client from one crate) still has exactly one real backend per target. Present when the enabled backend exists for the target: (`native`, not wasm32) or (`miniquad`, wasm32). [`loopback`](#3-module-loopback-feature-loopback-on-by-default) is never chosen here because it connects to a peer rather than to a URL.

### Function `connect_boxed`

```rust
pub fn connect_boxed(url: &str) -> Result<Box<dyn Socket>, WsError>
```

[`connect`](#function-connect), boxed: the form an application holds when the backend is decided by the build rather than written at the call site.

Unlike `connect`, this exists in **every** build. A build with no real backend gets a runtime `WsError::Connect("this build has no socket backend compiled in")` instead of a compile error, because such a build is legitimate (an offline teaching build still compiles its connect path) and every application ends up writing this same fallback arm itself.

## 2. Module `backlog`

Discarding a resume backlog before any of it is parsed. Always compiled; `trim_backlog` and `DroppedBacklog` are re-exported at the crate root.

A hidden browser tab (or a machine that slept) stops running frames while its socket keeps receiving, so the first [`Socket::poll`](#method-poll) after it wakes can hand back minutes of traffic at once. None of it is playable: a client that renders in the past is about to restart its timeline, which discards whatever those messages would have built. Parsing them anyway is where a several-second freeze on refocus comes from, so the drop happens here, on message lengths alone, before any deserialisation.

**When to call it and when not to.** What this cannot know is whether the burst is a *resume* or a *join*: a fresh connection's first poll legitimately carries a welcome and a warm world's whole baseline and that must arrive intact. The caller knows (it has seen a frame before or it has not), which is why this is a function the application calls rather than something `Socket::poll` does on its own. So: call it on the polls of an established session, skip it on the first poll of a new connection.

**The contract it depends on.** Dropping unread is safe only under the recovery contract the plaza blocks implement: the client restarts its timeline and drops its mirror, its next acknowledgement carries the digest of nothing and the server answers with a full baseline. A transport used without that contract should not use this.

### Function `trim_backlog`

```rust
pub fn trim_backlog(events: &mut Vec<Event>, trigger: usize, keep: usize) -> Option<DroppedBacklog>
```

Trims a drained event list down to its newest `keep` payload messages, if it holds more than `trigger` of them.

`None` means the list was an ordinary poll and is untouched. `Some` means it was a backlog: everything but the newest `keep` messages is gone, the caller should treat its timeline as lost and the return value says what was discarded. [`Event::Open`](#enum-event) and [`Event::Closed`](#enum-event) are never dropped, because they carry the connection's own state; they survive in place, in order.

Pick `trigger` several times past what a running frame loop can accumulate between two polls (a few seconds of the stream's message rate) and `keep` around what one send interval holds, so the restarted timeline has something current to anchor on.

### Struct `DroppedBacklog`

```rust
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DroppedBacklog {
  pub messages: u64,
  pub bytes: u64,
}
```

What a trim discarded, for the application's meters and panel. The bytes still crossed the wire: count them as received, because the meters measure the link, not what the client chose to read. `bytes` is counted from payload lengths alone; nothing was parsed.

## 3. Module `loopback` (feature `loopback`, on by default)

An in-process pair, for a host that also plays.

A listen-server has one player who is not on the network. Giving that player a different code path is how the two drift apart: the local one skips serialization and the ordering the wire imposes, so it never shows the bugs a networked client would. Handing it a [`Socket`](#trait-socket) like everyone else's means the host is testing the same client the joiners run.

It is a real pipe. Bytes are serialized and copied exactly as they would be over a socket, so a bug in encoding shows up locally instead of only after someone joins. It adds no latency on purpose; impairment is a separate setting chosen deliberately.

```rust
use plaza_ws::{loopback, Event, Socket};

let (mut client, mut host) = loopback::pair();
client.send(b"hello").unwrap();

let mut events = Vec::new();
host.poll(&mut events);
assert_eq!(events, vec![Event::Open, Event::Message(b"hello".to_vec())]);
```

### Function `loopback::pair`

```rust
pub fn pair() -> (LoopbackSocket, LoopbackSocket)
```

Creates a connected pair. Conventionally the first is the client and the second is the host's end, but they are symmetric.

### Struct `LoopbackSocket`

One end of an in-process pair, backed by `std::sync::mpsc` channels. Behavioral notes, all chosen to match a real socket so code written against one transport works on the other:

*   [`Event::Open`](#enum-event) is delivered on the first `poll` rather than at construction, so a caller written against a real socket, which cannot be open before it has connected, sees the same sequence here.
*   Closing one end closes both immediately (the flag is shared) and a subsequent `send` on either end returns `WsError::Closed`. The far end sees `Event::Closed(CloseReason::Remote { code: 1000, reason: "" })`.
*   A peer that was *dropped* without closing reads as `Event::Closed(CloseReason::Error(..))`, not a clean close, because an application's reconnect decision turns on the difference.
*   `state()` is `Open` until closed; there is no `Connecting` phase.

## 4. Module `native` (feature `native`, non-wasm32 only)

Desktop, over `tungstenite` on a worker thread.

The thread exists so [`Socket::poll`](#method-poll) never blocks. `tungstenite` is blocking, a frame loop cannot block and the alternative (an async runtime) would drag tokio into a client whose whole job is to render at 60 fps. So one thread (named `plaza_ws`) owns the socket and talks to the frame loop through channels. Its inner loop uses a non-blocking stream rather than a blocking read, because a blocking read cannot be interleaved with sends on the same socket; it sleeps 1 ms when there is nothing to do, which costs a millisecond of latency and avoids a spinning core.

### Function `native::connect`

```rust
pub fn connect(url: &str) -> Result<NativeSocket, WsError>
```

Connects to `url` (`ws://` or `wss://`; anything else is `WsError::BadUrl`, checked before any thread is spawned).

Returns as soon as the worker is started, not when the handshake completes, so a frame loop is never blocked by a slow or unreachable host. The socket begins in [`State::Connecting`](#enum-state); sends before [`Event::Open`](#enum-event) are queued rather than rejected and a failure arrives as [`Event::Closed`](#enum-event).

### Struct `NativeSocket`

The frame-loop end of the worker. Further behavior, from the worker loop:

*   Queued sends are flushed before reads on each worker iteration and a `close` goes out before anything else is attempted.
*   Ping/pong and raw frames are handled inside `tungstenite` and never surfaced as events.
*   Dropping the `NativeSocket` sends a close frame, so the far end sees a clean close instead of a reset.
*   A remote close carries the peer's code and reason (1005 when no close frame body was given, 1006 when the connection was found already closed); if the worker vanishes without reporting, `poll` reports `Event::Closed(CloseReason::Error("worker stopped"))` exactly once.

TLS (`wss://`) is available through tungstenite's `rustls-tls-webpki-roots` feature, which this crate's `native` feature enables.

## 5. Module `miniquad` (feature `miniquad`, wasm32 only)

Browser, under a macroquad/miniquad page. The socket lives in JavaScript and this is the thin Rust side of it. See `js/plaza_ws.js`, which must be included in the page after `mq_js_bundle.js` and before `load()`.

**No dependencies, by choice.** The obvious crates for this job are all `wasm-bindgen` underneath and cannot work here and the two crates that do use miniquad's plugin mechanism are barely maintained. The mechanism itself is a handful of `extern "C"` declarations, so the module uses the mechanism and skips the dependency.

The Rust side never allocates in JS and JS never calls back into wasm. Events are queued in JavaScript and drained on demand: ask what kind is at the front, ask how long it is, hand over a buffer, repeat. That is three crossings per event and it removes every reentrancy question, which matters because a callback into wasm during a frame could land in the middle of the borrow the frame loop is already holding.

### Function `miniquad::connect`

```rust
pub fn connect(url: &str) -> Result<MiniquadSocket, WsError>
```

Connects to `url` (`ws://` or `wss://`; anything else is `WsError::BadUrl`). Returns immediately; the browser connects in the background and [`Event::Open`](#enum-event) or [`Event::Closed`](#enum-event) arrives from a later [`poll`](#method-poll). Returns `WsError::Connect` if the `plaza_ws` JS plugin is not loaded in the page.

### Function `miniquad::page_url`

```rust
pub fn page_url() -> String
```

The WebSocket URL for the page this wasm was served from. What a browser client should almost always connect to: the host that served it. Hardcoding `127.0.0.1` works only on the machine doing the hosting.

### Struct `MiniquadSocket`

The Rust handle to a JS-side socket. A scratch buffer is reused across frames so a steady stream of messages allocates nothing. An unclean browser close (`wasClean` false) surfaces as `CloseReason::Error`, a clean one as `CloseReason::Remote` with the code and reason the browser reported.

### Function `plaza_ws_crate_version`

```rust
#[unsafe(no_mangle)]
pub extern "C" fn plaza_ws_crate_version() -> u32
```

Not for calling from Rust; it is the version export miniquad's loader checks the JS plugin against. Without it the loader logs that the plugin "is present in JS bundle, but is not used in the rust code". Exporting it turns that into a real check: a page serving an older `plaza_ws.js` than the wasm was built against now says so, instead of failing somewhere later for no visible reason.

## 6. Module `pump` (feature `pump`)

The client side of plaza's framed protocol, pumped once per frame. Owns the [`Socket`](#trait-socket), a `plaza_client_utils::Timeline` and the kind dispatch: it schedules pings, answers the server's probes, feeds pongs to the clock estimators, sends and checks the `Hello` and hands the application only what it owns. Pulls in `plaza_wire` (with `serde`) and `plaza_client_utils`.

### Struct `FramePump<C: WireCodec>`

```rust
impl<C: WireCodec> FramePump<C> {
  pub fn new(socket: Box<dyn Socket>, wire: C, protocol: u32) -> Self;
  pub fn connect(url: &str, wire: C, protocol: u32) -> Result<Self, WsError>;
  pub fn credential(self, credential: impl Into<Vec<u8>>) -> Self;
  pub fn ping_interval_ms(self, ms: u64) -> Self;               // default PING_INTERVAL_MS = 1000

  pub fn poll(&mut self, now_ms: u64, out: &mut Vec<Arrival>);  // drain + digest in one call
  pub fn drain(&mut self, now_ms: u64, events: &mut Vec<Event>);
  pub fn digest(&mut self, events: &mut Vec<Event>, now_ms: u64, out: &mut Vec<Arrival>);

  pub fn send_ops<T: Serialize>(&mut self, ops: &[T]) -> Option<usize>;
  pub fn send_op<T: Serialize>(&mut self, op: &T) -> Option<usize>;

  pub fn timeline(&self) -> &Timeline;
  pub fn timeline_mut(&mut self) -> &mut Timeline;
  pub fn rtt_ms(&self) -> Option<f32>;
  pub fn pong_rtts(&self) -> (u64, u64);                        // last raw, worst since resume
  pub fn server_time_ms(&self, now_ms: u64) -> u64;
  pub fn on_resume(&mut self);

  pub fn bytes_sent(&self) -> u64;                              // cumulative, probes included
  pub fn bytes_received(&self) -> u64;
  pub fn messages_received(&self) -> u64;
  pub fn is_open(&self) -> bool;
  pub fn state(&self) -> State;
  pub fn close(&mut self);
}
```

`poll` is `drain` plus `digest` glued together. A client that trims a resume backlog needs its hands between the socket and the dispatch, so the two halves are also public: `drain` into a caller-owned event buffer, [`trim_backlog`](#2-module-backlog), call `on_resume` if anything was dropped, then `digest` the survivors.

`protocol` is the build's wire format number (from `plaza_wire::build`); it goes out as the `Hello` when the socket opens and is compared against the server's. `credential` is what this client presents to be admitted by a server whose route did not resolve identity: sent as a `Kind::Credential` right after the `Hello` and before anything the application sends, the bytes exactly as given, so under a text codec they must be text. `send_ops` returns the frame's wire length or `None` if the value would not serialise. The byte counters are cumulative so a windowed meter diffs them; they count everything, probes and answers included.

### Enum `Arrival`

```rust
pub enum Arrival {
  Opened,
  Ops(OpsFrame),
  Mismatch { ours: u32, theirs: u32 },
  Closed(Closed),
}
```

Something the application has to act on; everything the session could finish by itself already has been. `Ops` carries the frame undecoded ([`OpsFrame::body`] feeds your codec's `decode::<Vec<Op>>`, [`OpsFrame::wire_len`] is what it cost tag byte included), because the pump cannot know your `Op` type and the decode is work worth timing where it happens.

### Struct `Closed`

```rust
pub struct Closed {
  pub code: Option<u16>,
  pub detail: Option<Vec<u8>>,
  pub reason: String,
}
impl Closed { pub fn refused(&self) -> bool; }
impl Display for Closed;  // the reason
```

How the connection ended, as the server told it where it did. The server writes a `Kind::Goodbye` last before every close it orders, on every transport and the pump keeps it for the close that follows, so `code` is the goodbye's where one arrived, else the WebSocket close frame's, else `None` for a link that died without one or a close this side asked for. `detail` is what the goodbye carried beside the code, undecoded. `refused()` is a code in 4000 to 4999: the server deciding, so do not reconnect with the same credential. Anything else is worth retrying. `reason` is worded for a person.

### Function `mismatch_message`

```rust
pub fn mismatch_message(ours: u32, theirs: u32) -> String
```

The standard wording for a protocol mismatch, for the common client whose build is a cached browser bundle ("...reload to get the current client"). Word your own if yours is not.

## 7. Module `scripted` (feature `scripted`)

### Struct `ScriptedSocket`

A socket whose arrivals the test scripts: what a hidden tab's receive queue looks like from the Rust side, without a browser. `feed(Event)` / `feed_message(Vec<u8>)` queue arrivals for the next `poll`; `sent()` returns everything sent so far as raw bytes; `close_by_peer(code, reason)` flips the state and queues the `Closed` event behind whatever is already waiting, exactly as a real socket delivers it. Clones share the same queues, so the test keeps one handle while the code under test owns another as its `Box<dyn Socket>`. Pulls in `parking_lot`.

## 8. Feature Flags

| Feature | Default | Effect |
|---|---|---|
| `loopback` | yes | Compiles [`loopback`](#3-module-loopback-feature-loopback-on-by-default). No dependencies. |
| `native` | no | Compiles [`native`](#4-module-native-feature-native-non-wasm32-only) (non-wasm32 targets only) and pulls in `tungstenite` with rustls TLS. |
| `miniquad` | no | Compiles [`miniquad`](#5-module-miniquad-feature-miniquad-wasm32-only) (wasm32 targets only). No dependencies; requires `js/plaza_ws.js` in the page. |
| `json` | no | Compiles [`SendJson`](#trait-sendjson-feature-json) and pulls in `serde` and `serde_json`. Off by default because the transport does not care what format its payloads use. |
| `pump` | no | Compiles [`pump`](#6-module-pump-feature-pump) and pulls in `plaza_wire` (with `serde`) and `plaza_client_utils`. |
| `scripted` | no | Compiles [`scripted`](#7-module-scripted-feature-scripted) and pulls in `parking_lot`. Meant for `dev-dependencies`. |

The module `cfg`s combine feature and target: `native` code exists only when the target is not wasm32 and `miniquad` code only when it is, so enabling both features is safe and each build gets the one that applies, including through the free [`connect`](#function-connect) function.

## 9. Error Handling

### Enum `WsError`

```rust
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum WsError {
  Closed,
  BadUrl(String),
  Connect(String),
  Send(String),
}
```

Implements `Display` and `std::error::Error`.

*   **`Closed`**: the socket is closed; nothing further can be sent.
*   **`BadUrl(String)`**: the URL could not be parsed or its scheme is not `ws`/`wss`.
*   **`Connect(String)`**: the connection could not be established.
*   **`Send(String)`**: a send failed. In practice this variant surfaces from [`SendJson`](#trait-sendjson-feature-json) when serialization fails; transport-level send failures on a live socket arrive as an [`Event::Closed`](#enum-event) instead.

Note the split between the two ways things go wrong. `Result` covers what a call can know immediately (a bad URL, a socket already closed); everything that happens later on the wire (a failed handshake, a lost connection) arrives as an [`Event::Closed`](#enum-event) from [`poll`](#method-poll), so a frame loop has one place to handle it.