Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ authors = ["Zach Heylmun <zheylmun@gmail.com>"]
license = "MIT OR Apache-2.0"
repository = "https://github.com/zheylmun/socketeer"

[package.metadata.docs.rs]
all-features = true

[features]
mocking = []
msgpack = ["dep:rmp-serde"]
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,10 @@ use std::time::Duration;
# struct Msg { text: String }
# #[tokio::main]
# async fn main() {
let mut options = ConnectOptions::default();
options.extra_headers.insert("Authorization", "Bearer my-token".parse().unwrap());
options.keepalive_interval = Some(Duration::from_secs(10));
let options = ConnectOptions::builder()
.header("Authorization", "Bearer my-token".parse().unwrap())
.keepalive_interval(Some(Duration::from_secs(10)))
.build();

let socketeer: Socketeer<JsonCodec<Msg, Msg>> =
Socketeer::connect_with("wss://api.example.com/ws", options)
Expand Down
585 changes: 585 additions & 0 deletions docs/superpowers/plans/2026-06-29-public-api-cleanup.md

Large diffs are not rendered by default.

162 changes: 162 additions & 0 deletions docs/superpowers/specs/2026-06-29-public-api-cleanup-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# Public API cleanup for the pre-1.0 breaking change

**Status:** Approved design — ready for implementation planning
**Date:** 2026-06-29
**Scope:** A single breaking-change branch tightening the public API surface
before 1.0. Three themes: stop leaking foreign types, future-proof the
enums/config, and clean the `mocking` surface.

## Problem

A public-API review of socketeer (currently v0.4.0) found the boundary leaks
implementation details and is not future-proofed for additive changes:

1. **Foreign crate types appear in public signatures without being
re-exported.** `tungstenite::Message`, `tungstenite::Error`,
`http::HeaderMap`, and `bytes::Bytes` all surface through the public API
(`send_raw`, `next_raw_message`, `RawCodec`, `Error::WebsocketError`,
`Error::UnexpectedMessageType`, `ConnectOptions::custom_keepalive_message`,
`ConnectOptions::extra_headers`, `HandshakeContext::{recv_raw, send_binary}`).
None is re-exported, so a downstream user must add direct dependencies on
those crates and keep versions in lockstep with socketeer. A tungstenite
bump silently breaks them at the type level.
2. **`Error` and `ConnectOptions` are not `#[non_exhaustive]`.** Adding an
`Error` variant or a `ConnectOptions` field is a breaking change, and
downstream `match`/struct-literal use can't be forced to tolerate growth.
3. **The `mocking` surface leaks an internal fixture and hides the type needed
to use its generic entry point.** `backpressure_probe_server` is a
socketeer-specific test fixture (emits `"burst-0".."burst-4"` / `"alive"`)
that became public only because the integration tests reach it through the
crate root. Separately, `get_mock_address<F: Fn(WebSocketStreamType) -> R>`
requires naming `WebSocketStreamType`, which is `pub(crate)` and never
exported — so the generic entry point is unusable for downstream custom
servers.

## Goals

- No foreign crate type appears in the public API without a socketeer
re-export providing a coherent dependency story.
- `Error` and `ConnectOptions` can gain variants/options later without a
breaking change.
- The `mocking` feature exposes only general-purpose utilities, and its
generic entry point is actually usable downstream.
- All existing behavior is preserved; this is an API-shape change, not a
behavior change.

## Non-goals

- No change to the socket loop, backpressure, codec, or handler behavior.
- No new connection features; `ConnectOptions` keeps exactly today's three
settings, only its construction changes.
- Not removing the `CHANNEL_SIZE` const generic (the zero-capacity guard is a
deferred nicety, see Deferred).

## Design

### A. Re-export the foreign types the API already exposes

Add to the crate root:

```rust
pub use bytes::Bytes;
pub use tokio_tungstenite::tungstenite::{self, Message};
pub use tokio_tungstenite::tungstenite::http;
```

`tungstenite::Error` is reachable through the re-exported `tungstenite`
module, and `http::HeaderMap` through the re-exported `http`. Downstream code
then names `socketeer::Message`, `socketeer::http::HeaderMap`,
`socketeer::Bytes`, `socketeer::tungstenite::Error` — no direct dependency or
version-matching required. No signatures change; only re-exports are added.

### B. Future-proof `Error` and `ConnectOptions`

- Mark `Error` `#[non_exhaustive]`. Existing variants and their data are
unchanged.
- Convert `ConnectOptions` to **private fields + a builder**, and mark it
`#[non_exhaustive]`:
- Fields (`extra_headers`, `keepalive_interval`, `custom_keepalive_message`)
become private.
- `ConnectOptions::default()` still yields today's defaults (2s keepalive,
empty headers, no custom message).
- A `ConnectOptionsBuilder` (obtained via `ConnectOptions::builder()`)
exposes:
- `keepalive_interval(Option<Duration>) -> Self`
- `header(name, value) -> Self` (append one header)
- `headers(HeaderMap) -> Self` (replace the header map)
- `custom_keepalive_message(Option<Message>) -> Self`
- `build() -> ConnectOptions`
- Read access for the internals stays `pub(crate)` (the crate reads the
fields directly today in `lib.rs`/`config.rs`); no public getters are
added unless a consumer needs them (YAGNI).

This removes the public-fields semver hazard and makes future options
additive (a new builder method, no call-site break).

### C. Clean the `mocking` surface

- **Move `backpressure_probe_server` out of `src/mock_server.rs` into a
`tests/` helper module** (e.g. `tests/support/mod.rs` or an inline module in
`tests/integration.rs`). It is consumed only by the backpressure integration
tests. It leaves the published API entirely.
- **Export `WebSocketStreamType`**: change `pub(crate) use
socket_loop::WebSocketStreamType` to a public re-export so downstream users
can write `get_mock_address(|ws: WebSocketStreamType| async { ... })`. The
generic `get_mock_address` and the bundled `echo_server` / `auth_echo_server`
/ `msgpack_echo_server` stay as they are.

## Components touched

- `src/lib.rs`: add the foreign-type re-exports (A); make
`WebSocketStreamType` a public re-export (C). Update any internal field
reads of `ConnectOptions` to use the new accessors/fields (B).
- `src/error.rs`: add `#[non_exhaustive]` to `Error` (B).
- `src/config.rs`: private fields, `#[non_exhaustive]`, builder (B). Internal
reads in `lib.rs` use `pub(crate)` field access.
- `src/mock_server.rs`: remove `backpressure_probe_server` (C).
- `tests/integration.rs` (+ a `tests/` support module): host
`backpressure_probe_server`; update `ConnectOptions` construction to the
builder (B).
- `README.md` and any doctests: update `ConnectOptions` construction to the
builder (B).
- `CLAUDE.md` (gitignored locally): note the builder + re-exports if it lists
API specifics — not committed.

## Testing

This is a refactor of the API shape; the existing suite (15 unit + 40
integration + 5 doc) is the safety net and must stay green after every task.
Add targeted tests only where new surface appears:

1. **`ConnectOptions` builder** unit tests: `builder().build()` equals
`default()`; each setter overrides exactly its field; `header` appends and
`headers` replaces.
2. **Re-export smoke**: a doc/integration test (or doctest) that names
`socketeer::Message`, `socketeer::Bytes`, `socketeer::http::HeaderMap`,
`socketeer::tungstenite::Error`, and `socketeer::WebSocketStreamType` to
prove the re-exports resolve.
3. Existing backpressure tests keep passing after `backpressure_probe_server`
moves into the test support module.

## Acceptance criteria

- No foreign crate type is reachable in the public API without a socketeer
re-export (verified by the smoke test + manual scan).
- `Error` and `ConnectOptions` are `#[non_exhaustive]`; `ConnectOptions`
fields are private and only constructible via `default()`/builder.
- `backpressure_probe_server` is no longer part of the published API;
`WebSocketStreamType` is.
- `cargo test --all-features` green (existing + new).
- `cargo clippy --all-features --tests --benches -- -Dclippy::all
-Dclippy::pedantic` clean, and clean across `--no-default-features` and
`--features tracing`.
- `cargo fmt --check` clean.
- `#![deny(missing_docs)]` satisfied (builder methods documented).

## Deferred

- Compile-time `CHANNEL_SIZE > 0` guard (`const { assert!(...) }` in
`connect_with_codec`): a real correctness-by-construction nicety, but
orthogonal to the boundary cleanup; revisit separately.
- Public getters on `ConnectOptions`: add only when a downstream consumer
needs to read options back (YAGNI for now).
109 changes: 105 additions & 4 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ use crate::Error;
/// Configuration options for a WebSocket connection.
///
/// Controls HTTP headers on the upgrade request, keepalive behavior,
/// and other connection-level settings.
/// and other connection-level settings. Construct via
/// [`ConnectOptions::default`] or [`ConnectOptions::builder`].
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct ConnectOptions {
/// Extra HTTP headers for the WebSocket upgrade request (e.g., cookies, auth tokens).
pub extra_headers: http::HeaderMap,
pub(crate) extra_headers: http::HeaderMap,
/// Idle timeout before sending a keepalive. `None` disables keepalives entirely.
/// Defaults to 2 seconds.
///
Expand All @@ -21,11 +23,11 @@ pub struct ConnectOptions {
/// (so it will not observe a server close), and a consumer that stops
/// reading entirely can leave the connection parked until it resumes — in
/// addition to the server potentially dropping the idle connection.
pub keepalive_interval: Option<Duration>,
pub(crate) keepalive_interval: Option<Duration>,
/// If set, send this message as the keepalive instead of a WebSocket Ping frame.
/// Useful for APIs that expect a custom keepalive payload — e.g. Interactive
/// Brokers' literal text `tic`, or a binary heartbeat in a msgpack protocol.
pub custom_keepalive_message: Option<Message>,
pub(crate) custom_keepalive_message: Option<Message>,
}

impl Default for ConnectOptions {
Expand All @@ -39,6 +41,14 @@ impl Default for ConnectOptions {
}

impl ConnectOptions {
/// Start building a [`ConnectOptions`] from the defaults.
#[must_use]
pub fn builder() -> ConnectOptionsBuilder {
ConnectOptionsBuilder {
options: ConnectOptions::default(),
}
}

/// Build an HTTP request from the URL and configured headers.
///
/// Uses tungstenite's `IntoClientRequest` to generate a properly formed
Expand All @@ -54,3 +64,94 @@ impl ConnectOptions {
Ok(request)
}
}

/// Builder for [`ConnectOptions`]. Obtain one with [`ConnectOptions::builder`],
/// chain setters, and finish with [`ConnectOptionsBuilder::build`].
#[derive(Clone, Debug)]
pub struct ConnectOptionsBuilder {
options: ConnectOptions,
}

impl ConnectOptionsBuilder {
/// Set the idle timeout before a keepalive is sent. `None` disables keepalives.
#[must_use]
pub fn keepalive_interval(mut self, interval: Option<Duration>) -> Self {
self.options.keepalive_interval = interval;
self
}

/// Set a single HTTP header on the WebSocket upgrade request. If a header with
/// the same name was already set, its previous value is replaced.
#[must_use]
pub fn header<K: http::header::IntoHeaderName>(
mut self,
name: K,
value: http::HeaderValue,
) -> Self {
self.options.extra_headers.insert(name, value);
self
}

/// Replace the entire set of extra HTTP headers.
#[must_use]
pub fn headers(mut self, headers: http::HeaderMap) -> Self {
self.options.extra_headers = headers;
self
}

/// Send this message as the keepalive instead of a WebSocket Ping frame.
#[must_use]
pub fn custom_keepalive_message(mut self, message: Option<Message>) -> Self {
self.options.custom_keepalive_message = message;
self
}

/// Finish building, producing the configured [`ConnectOptions`].
#[must_use]
pub fn build(self) -> ConnectOptions {
self.options
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn builder_default_matches_default() {
let built = ConnectOptions::builder().build();
let default = ConnectOptions::default();
assert_eq!(built.keepalive_interval, default.keepalive_interval);
assert_eq!(
built.custom_keepalive_message,
default.custom_keepalive_message
);
assert_eq!(built.extra_headers, default.extra_headers);
}

#[test]
fn builder_sets_each_field() {
let opts = ConnectOptions::builder()
.keepalive_interval(None)
.custom_keepalive_message(Some(Message::text("ping")))
.header("x-test", "v".parse().unwrap())
.build();
assert_eq!(opts.keepalive_interval, None);
assert_eq!(opts.custom_keepalive_message, Some(Message::text("ping")));
assert_eq!(opts.extra_headers.get("x-test").unwrap(), "v");
}

#[test]
fn headers_replaces_whole_map() {
let mut map = http::HeaderMap::new();
map.insert("a", "1".parse().unwrap());
map.insert("b", "2".parse().unwrap());
let opts = ConnectOptions::builder()
.header("z", "0".parse().unwrap())
.headers(map)
.build();
assert_eq!(opts.extra_headers.len(), 2);
assert_eq!(opts.extra_headers.get("a").unwrap(), "1");
assert!(opts.extra_headers.get("z").is_none());
}
}
1 change: 1 addition & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use tokio_tungstenite::tungstenite::Message;
/// Error type for the Socketeer library.
/// This type is used to represent all possible external errors that can occur when using the Socketeer library.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum Error {
/// Url Parse Error
#[error("Failed to parse URL: {}", 0)]
Expand Down
16 changes: 10 additions & 6 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,25 @@ mod mock_server;
mod socket_loop;
mod split;

pub use bytes::Bytes;
#[cfg(feature = "msgpack")]
pub use codec::MsgPackCodec;
pub use codec::{Codec, JsonCodec, RawCodec};
pub use config::ConnectOptions;
pub use config::{ConnectOptions, ConnectOptionsBuilder};
pub use error::Error;
pub use handler::{ConnectionHandler, HandshakeContext, NoopHandler};
#[cfg(all(feature = "mocking", feature = "msgpack"))]
pub use mock_server::msgpack_echo_server;
#[cfg(feature = "mocking")]
pub use mock_server::{
EchoControlMessage, auth_echo_server, backpressure_probe_server, echo_server, get_mock_address,
};
pub use mock_server::{EchoControlMessage, auth_echo_server, echo_server, get_mock_address};
pub use split::{ReuniteError, SocketeerRx, SocketeerTx};
pub use tokio_tungstenite::tungstenite::{self, Message, http};

pub(crate) use socket_loop::WebSocketStreamType;
/// The concrete `WebSocketStream` type the mock-server handlers operate on.
/// Re-exported so downstream code can write custom servers for
/// [`get_mock_address`]. Primarily useful with the `mocking` feature, which
/// gates `get_mock_address` and the built-in test servers.
pub use socket_loop::WebSocketStreamType;
use socket_loop::{
TerminalError, TxChannelPayload, poll_recv_raw, recv_raw, send_close, send_confirmed,
socket_loop_split,
Expand All @@ -35,7 +39,7 @@ use std::task::{Context, Poll};

use futures::{Stream, StreamExt};
use tokio::sync::mpsc;
use tokio_tungstenite::{connect_async, tungstenite::Message};
use tokio_tungstenite::connect_async;

#[cfg(feature = "tracing")]
use tracing::{debug, info, instrument};
Expand Down
Loading
Loading