# `DrinkMeAlice.Ws`

Passive, bounded WebSocket transport over Mint 1.10.1 /
mint_web_socket 1.0.6 — production adaptation of the accepted BF-02A
`SurrealTransport.Ws` probe.

- HTTP/1 only (`protocols: [:http1]`), `mode: :passive` on connect and
  on `Mint.WebSocket.new/5`; inbound data arrives only through bounded
  `Mint.WebSocket.recv/3` calls. The BEAM mailbox is never an unbounded
  buffer for socket data.
- No WebSocket extensions are proposed, so permessage-deflate cannot be
  negotiated — compression is disabled by construction.
- `DrinkMeAlice.FrameGuard` walks raw bytes ahead of
  `Mint.WebSocket.decode/2` and refuses declared or aggregate payloads
  over `:max_message` (1 MiB); Mint-side `buffer`+`fragment` retention
  is separately tripwired at `:max_retained`.
- Caller-driven ping/pong: pings are counted, bounded per batch, and
  answered with one batched send.
- All waits are bounded: connect/upgrade deadlines, `send_timeout`,
  `max_header_list_size` on the upgrade response, per-`recv_frames/2`
  frame/byte/ping bounds.
- Error tuples are `{:error, t(), reason}` / `{:closed, t(), frames}` /
  `{:timeout, t()}`; reasons are sanitized — never Mint structs, frame
  payloads, headers, or auth material.
- Fatal input paths close the owned socket at the recv boundary and
  return `conn: nil`, so `close/2`, `recv_frames/2` and `send_frame/2`
  stay safe and idempotent.

Text payloads are decoded through `DrinkMeAlice.Json` (the bounded
Surreal-dialect profile) by `classify/1` or by the owning
`DrinkMeAlice.Conn` — never by unbounded `:json.decode`.
Request correlation is owned by `Conn`; this module only moves frames.

# `classified`

```elixir
@type classified() ::
  {:text, binary()}
  | {:binary, binary()}
  | {:notification, map(), non_neg_integer()}
  | {:ping, binary()}
  | {:pong, binary()}
  | {:close, non_neg_integer() | nil, binary() | nil}
  | {:malformed, term()}
```

# `t`

```elixir
@type t() :: %DrinkMeAlice.Ws{
  auto_pong: term(),
  close_attempted: term(),
  conn: term(),
  guard: term(),
  max_message: term(),
  max_retained: term(),
  ref: term(),
  retained_peak: term(),
  ws: term()
}
```

# `close`

```elixir
@spec close(t(), timeout()) :: :ok
```

Graceful close: send a close frame at most once, await the peer's close
under a finite deadline, then close the transport on every path.
Always releases the owned socket. Returns `:ok`.

# `connect`

```elixir
@spec connect(
  :inet.hostname() | :inet.ip4_address(),
  :inet.port_number(),
  String.t(),
  keyword()
) :: {:ok, t(), [classified()]} | {:error, term()}
```

Connect over cleartext HTTP/1 and upgrade `path` to a WebSocket.

Returns `{:ok, state, early_frames}` — complete frames whose bytes
arrived coalesced with the 101 response. They pass through the same
guard/ping/close handling as later traffic.

Options: `:connect_timeout`, `:upgrade_timeout`, `:send_timeout`,
`:max_message`, `:protocols` (default `["json"]`), `:headers`,
`:auto_pong`.

# `decode_text`

```elixir
@spec decode_text(binary()) ::
  {:ok, DrinkMeAlice.Json.json()} | {:error, DrinkMeAlice.Error.t()}
```

Decode an inbound text payload through the bounded Surreal JSON
profile. Notifications are already separated by `feed/2`; this is for
RPC replies.

# `feed`

```elixir
@spec feed(t(), binary()) :: {:ok, t(), [classified()]} | {:error, t(), term()}
```

Feed raw inbound bytes through the framing guard, then Mint decode.

Frames in the returned list may include `{:notification, body,
raw_byte_size}` (SurrealDB live-query notifications classified inline —
decoded through the bounded `Json` profile, never stored as terms
beyond the raw byte size) and `{:malformed, reason}` entries.

# `guard_message_bytes`

```elixir
@spec guard_message_bytes(t()) :: non_neg_integer()
```

Payload bytes accumulated in the guard's open fragmented message.

# `recv_frames`

```elixir
@spec recv_frames(t(), timeout()) ::
  {:ok, t(), [classified()]}
  | {:timeout, t()}
  | {:closed, t(), [classified()]}
  | {:error, t(), term()}
```

One bounded socket read + guard + decode. Returns `{:ok, state, frames}`,
`{:timeout, state}`, `{:closed, state, frames}` or `{:error, state, r}`.

Every decoded frame is counted against a per-call cap before any ping
is answered; pings beyond the batch cap fail the call. Server pings are
answered with one batched send. A peer close frame is echoed at most
once.

# `retained_bytes`

```elixir
@spec retained_bytes(t()) :: non_neg_integer()
```

Bytes currently retained inside Mint.WebSocket (buffer + fragment).

# `send_frame`

```elixir
@spec send_frame(t(), Mint.WebSocket.frame() | Mint.WebSocket.shorthand_frame()) ::
  {:ok, t()} | {:error, t(), term()}
```

Encode and send one frame.

# `socket`

Underlying TCP socket (observation only); nil after a fatal error.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
