nexus-web 0.8.0

Low-latency WebSocket, HTTP/1.1, and REST primitives. Sans-IO, zero-copy, SIMD-accelerated.
Documentation
# WebSocket

nexus-web implements RFC 6455 WebSocket with three composable layers:

1. **`Message` / `OwnedMessage`** — user-facing message enum
2. **`FrameReader` / `FrameWriter`** — sans-IO codec
3. **`Client<S>`** — blocking convenience wrapper around a socket

Use the top layer when you have a socket and want WebSocket to work.
Drop down to the codec layer when you own the transport.

## Messages

```rust
use nexus_web::ws::{Message, CloseCode, CloseFrame};

match msg {
    Message::Text(s)          => { /* s: &str, zero-copy into ReadBuf */ }
    Message::Binary(bytes)    => { /* bytes: &[u8] */ }
    Message::Ping(bytes)      => { /* auto-handled by Client; reader yields it */ }
    Message::Pong(bytes)      => { /* ... */ }
    Message::Close(Some(CloseFrame { code, reason })) => { /* ... */ }
    Message::Close(None)      => { /* empty close */ }
}
```

`Message<'_>` borrows from the underlying `ReadBuf`. It is valid until
the next call that advances the reader. Call `.into_owned()` to get an
`OwnedMessage` (uses `bytes::Bytes`) if you need to retain it.

## The `Client` convenience path

`Client<S>` owns a stream + a `FrameReader` + a `FrameWriter` +
a `WriteBuf`. Construct via `ClientBuilder`:

```rust
use nexus_web::ws::Client;
use nexus_net::tls::TlsConfig;
use std::time::Duration;

let tls = TlsConfig::new()?;
let mut ws = Client::builder()
    .tls(&tls)
    .disable_nagle()                  // TCP_NODELAY
    .buffer_capacity(1 << 20)         // 1 MiB ReadBuf
    .max_message_size(16 << 20)       // 16 MiB assembled
    .write_buffer_capacity(64 << 10)  // 64 KiB WriteBuf
    .connect_timeout(Duration::from_secs(3))
    .read_timeout(Duration::from_secs(30))
    .connect("wss://stream.binance.com:9443/ws")?;

ws.send_text(r#"{"method":"SUBSCRIBE","params":["btcusdt@trade"],"id":1}"#)?;

loop {
    match ws.recv()? {
        Some(Message::Text(json))     => handle_json(json),
        Some(Message::Binary(bytes))  => handle_binary(bytes),
        Some(Message::Ping(data))     => ws.send_pong(data)?,
        Some(Message::Pong(_))        => {}
        Some(Message::Close(frame))   => {
            ws.close(CloseCode::Normal, "bye")?;
            break;
        }
        None => {
            // EOF, WouldBlock, or buffer full. See buffers.md.
            break;
        }
    }
}
```

### Role

The builder uses the **client role** by default when you call
`connect()`: outgoing frames are masked (per RFC 6455) and inbound
frames are rejected if they are masked. Use `Client::builder().accept(stream)`
for the **server role** — outbound frames are unmasked, inbound frames
must be masked.

### Control frames

Ping/pong/close are exposed as regular `Message` variants. `Client` does
**not** auto-reply to pings — you see them and decide. This is
intentional: auto-pong hides liveness and complicates tests. If you
want auto-reply, call `ws.send_pong(data)` when you see a `Ping`.

`CloseCode` enumerates the RFC 6455 status codes; use
`CloseCode::Normal`, `CloseCode::GoingAway`, `CloseCode::Policy`, etc.

## Sans-IO parse loop

If you own the transport (mio, io_uring, DPDK, replay), use
`FrameReader` directly:

```rust
use nexus_web::ws::{FrameReader, Role, Message};

let mut reader = FrameReader::builder()
    .role(Role::Client)
    .buffer_capacity(1 << 20)
    .max_message_size(16 << 20)
    .build();

// Event loop: hand the reader some bytes, then drain frames.
loop {
    // 1. Get spare space in the ReadBuf.
    let dst = reader.spare();
    if dst.is_empty() {
        reader.compact();       // reclaim consumed bytes
        continue;
    }
    let n = your_transport.read(dst)?;
    if n == 0 { break; }        // EOF
    reader.filled(n);           // tell reader how much you wrote

    // 2. Drain all complete frames from the buffer.
    while reader.poll()? {
        match reader.next()? {
            Some(Message::Text(s))     => handle_text(s),
            Some(Message::Binary(b))   => handle_binary(b),
            Some(Message::Ping(b))     => enqueue_pong(b),
            Some(Message::Close(_))    => return Ok(()),
            Some(_) | None             => {}
        }
    }
}
```

`poll()` returns `true` when a complete frame is parsed and `false`
when more bytes are needed. `next()` yields the parsed message (or
`None` if the reader just finished an internal control frame). The
returned `Message<'_>` borrows from `reader`; consume it before the
next `poll()`.

### Fragmentation

`FrameReader` reassembles fragmented messages (CONT frames) internally,
up to `max_message_size`. You only see assembled messages. If the
peer sends a 20 MiB message and your limit is 16 MiB, `next()` returns
`Err(ProtocolError::MessageTooLarge)` — the connection is done.

## Sans-IO encode

`FrameWriter` encodes one frame at a time into a buffer:

```rust
use nexus_web::ws::{FrameWriter, Role};
use nexus_net::buf::WriteBuf;

let mut writer = FrameWriter::new(Role::Client);
let mut buf = WriteBuf::new(64 * 1024, 14);  // 14 bytes of header headroom

writer.encode_text_into(b"hello", &mut buf);
your_transport.write_all(buf.data())?;
buf.clear();
```

### Why `WriteBuf` with headroom?

A WebSocket frame header is 2 to 14 bytes and depends on payload length
and masking. nexus-web writes the **payload** first using the headroom
at the front of the buffer, then **prepends** the finalized header.
This avoids a second pass over the payload for masking (mask is
XORed into place while the payload is being written) and eliminates
double-buffering.

See [buffers.md](./buffers.md) for the full model.

### Masking (client role)

Per RFC 6455, client-to-server frames must be masked with a random
32-bit key. nexus-web uses a ChaCha8 PRNG seeded once per `FrameWriter`
for fast, predictable mask generation. Masking is applied via SIMD
(SSE2/AVX2) XOR; at 128B the cost is ~12 cycles.

Server role frames are never masked. The reader enforces both
directions: a client-role reader rejects masked inbound frames; a
server-role reader rejects unmasked inbound frames.

## Handshake

`Client::builder().connect(url)` performs the full HTTP Upgrade
handshake internally. If you need to run the handshake manually (e.g.
custom headers, HTTP proxy tunneling), use the `handshake` module:

```rust
use nexus_web::ws::handshake;

let key = handshake::generate_key();
let req = handshake::build_upgrade_request("api.example.com", "/ws", &key);
// Send `req` on your transport, read response, call:
handshake::validate_response(&response_bytes, &key)?;
```

## Errors

`ws::Error` is the top-level error:

- `Error::Io(io::Error)` — transport failure
- `Error::Protocol(ProtocolError)` — malformed frames, oversized
  messages, masking violations, bad opcode, bad UTF-8
- `Error::Encode(EncodeError)` — outbound encoding failure (buffer
  too small, oversized control frame)
- `Error::Handshake(HandshakeError)` — HTTP upgrade failure
- `Error::Tls(TlsError)` — TLS negotiation / read / write failure

On IO error during a send, the `Client` is marked **poisoned** (a
partial frame may have been written). `is_poisoned()` is `true`, and
subsequent sends return `Err(ConnectionPoisoned)`. See
[errors.md](./errors.md).

## Autobahn conformance

The codec passes all **517/517** cases in the Autobahn Testsuite
(`fuzzingclient` + `fuzzingserver`), including strict UTF-8
validation, fragmentation edge cases, and close-code handling.