- Rust 100%
| crates | ||
| examples | ||
| .gitignore | ||
| .rustfmt.toml | ||
| Cargo.lock | ||
| Cargo.toml | ||
| LICENSE | ||
| README.md | ||
phoenix-rs
A Rust client for Phoenix Framework Channels over WebSockets.
Overview
phoenix-channel connects to a Phoenix server's channel endpoint, handles the join/leave lifecycle, heartbeats, and message serialisation — so you can focus on your application logic instead of the wire protocol.
It is async-first (Tokio), supports TLS through either rustls or native-tls, and reconnects automatically with exponential backoff. The API supports both strongly typed event streams and raw JSON events.
Workspace
| Crate | Description |
|---|---|
phoenix-channel |
Core library |
phoenix-channel-macros |
Derive macro for channel Events |
phoenix-channel-bevy |
Bevy plugin and message bridge |
simple |
Runnable async usage example |
bevy-example |
Runnable Bevy usage example |
bevy-web-example |
Browser Bevy usage example |
Installation
Add to your Cargo.toml:
[dependencies]
phoenix-channel = { git = "https://git.ganz.gay/jakob/phoenix-rs" }
TLS features
| Feature | Backend | Default |
|---|---|---|
rustls |
rustls + native roots | ✓ |
native-tls |
OS TLS stack |
To opt into native-tls:
phoenix-channel = { git = "https://git.ganz.gay/jakob/phoenix-rs", default-features = false, features = ["native-tls"] }
WebAssembly
phoenix-channel supports browser WebAssembly through the browser's native WebSocket and timer
APIs. Native builds continue to use Tokio and tokio-tungstenite; no API changes or feature flags
are required in application code.
Use wasm-bindgen, Trunk, or another browser WebAssembly bundler to expose your application's
entry point. Browser security rules still apply: a page served over HTTPS must connect with
wss://, TLS is handled by the browser rather than the crate's TLS features, and the server must
accept the page's WebSocket origin.
Usage
use phoenix_channel::{ChannelEvent, ClientBuilder, PushResponse};
use serde::Deserialize;
#[derive(Debug, Deserialize, ChannelEvent)]
#[channel(event = "new_msg")]
struct NewMessage {
body: String,
user: String,
}
#[tokio::main]
async fn main() -> phoenix_channel::Result<()> {
// Building starts the background connection task.
let client = ClientBuilder::new()
.url("ws://localhost:4000/socket/websocket")
.build()?;
// This waits for the server to accept the join.
let channel = client.channel("room:lobby", serde_json::json!({})).await?;
// Subscribe before the event can arrive; subscriptions do not replay old events.
let mut events = channel.on::<NewMessage>();
tokio::spawn(async move {
while let Ok(event) = events.recv().await {
println!("{}: {}", event.payload.user, event.payload.body);
}
});
// Push an event and deserialize the server's response.
let response: PushResponse<serde_json::Value> = channel
.push(
"new_msg",
serde_json::json!({"body": "hello from rust", "user": "bot"}),
)
.await?;
println!("push result: {:?}", response.response);
// Leave cleanly before stopping the connection task.
channel.leave().await?;
client.disconnect().await?;
Ok(())
}
The simple example crate contains a runnable client with structured logging and multiple typed subscriptions. Run it against a Phoenix endpoint at the URL used in that file:
RUST_LOG=info cargo run -p simple
The same example runs in a browser with Trunk:
cargo install --locked trunk
rustup target add wasm32-unknown-unknown
cd examples/simple
trunk serve --open
It connects to ws://localhost:4000/socket/websocket by default and writes events to the browser
developer console. Override the endpoint at build time when needed:
PHOENIX_SOCKET_URL=wss://example.com/socket/websocket \
trunk serve --open
When Trunk and Phoenix use different origins, configure the Phoenix socket's check_origin list
to accept the Trunk development server (normally http://localhost:8080).
Bevy
The phoenix-channel-bevy crate makes a joined channel available as a Bevy resource and forwards
registered typed channel payloads through Bevy's message system:
App::new()
.add_plugins(PhoenixChannelPlugin::new(channel))
.add_phoenix_event::<NewMessage>()
.add_systems(Update, read_messages)
.run();
See examples/bevy for a complete headless Bevy app. Run it with:
cargo run -p bevy-example
The separate examples/bevy-web example uses Bevy's browser
schedule runner and the browser-compatible phoenix-channel transport. Run it with Trunk:
cd examples/bevy-web
trunk serve --open
Set PHOENIX_SOCKET_URL before starting Trunk to override the default endpoint. Pages served over
HTTPS must connect to a wss:// endpoint.
Socket authentication
Socket-level parameters are added to the WebSocket handshake URL. Phoenix's vsn=2.0.0 parameter is always included.
use phoenix_channel::ClientBuilder;
fn authenticated_client() -> phoenix_channel::Result<phoenix_channel::Client> {
ClientBuilder::new()
.url("wss://example.com/socket/websocket")
.connect_params(serde_json::json!({ "token": "signed-token" }))
.build()
}
Raw events
Typed events are convenient when the payload schema is stable. For dynamic event names or payloads, subscribe to the raw stream instead:
async fn receive(channel: &phoenix_channel::Channel) {
let mut events = channel.subscribe_raw();
while let Ok(event) = events.recv().await {
println!("{}: {}", event.event, event.payload);
}
}
Key Types
| Type | Description |
|---|---|
ClientBuilder |
Configures and builds a Client |
Client |
Connection handle — join channels, disconnect |
Channel |
Joined channel — push messages, subscribe to events, leave |
ChannelEvent |
Trait associating a payload type with its Phoenix event name |
EventEnvelope |
A typed inbound payload and its Phoenix message metadata |
PushResponse<T> |
Typed server reply to a pushed message |
Error |
Unified error type |
Lifecycle and reconnection
ClientBuilder::build
│
▼
connect WebSocket ─── failure ──▶ wait with backoff ──┐
│ │
▼ │
send phx_join │
│ │
▼ │
Joined ◀──── reconnect and rejoin ◀─────────────┘
│
├── heartbeat every 30 s
├── push and receive application events
└── leave() ──▶ Closed
Unexpected disconnects trigger exponential backoff with light jitter, capped at 32 seconds by default (1 s → 2 s → 4 s → … → 32 s). Active joined channels are rejoined after the socket reconnects. A normal WebSocket close does not reconnect.
Pushes, joins, and leaves wait up to 10 seconds for a Phoenix reply. push_no_reply skips that wait; it confirms only that the message was queued locally, not that the server accepted it.
Error handling
Most operations return phoenix_channel::Result<T>. The variants callers most commonly need to distinguish are:
Error::JoinRejected(payload)when the server rejectsphx_join.Error::PushRejected(payload)when a push or leave receives an error reply.Error::PushTimeout(duration)when no reply arrives in time.Error::NotJoinedwhen an event is pushed before joining or after leaving.Error::ConnectionClosedwhen the transport closes during an operation.
Typed event receivers return EventReceiveError separately. In particular, Lagged(count) means the receiver fell behind the bounded event queue; it can call recv again and continue.
API documentation
Build and open the complete Rust API documentation locally:
cargo doc -p phoenix-channel --open
Dependencies
| Crate | Purpose |
|---|---|
tokio |
Async runtime |
tokio-tungstenite |
WebSocket transport |
serde / serde_json |
Message serialisation |
futures-util |
Stream/sink combinators |
tracing |
Structured logging |
thiserror |
Error types |
url |
Endpoint URL handling |
License
MIT — see LICENSE.