Skip to content

Rooms

The WsClient handle — join, leave, broadcast, scoped emit_to — and the room semantics behind them.

A room is a label attached to a connection. Connections that share a label receive the same broadcast. There is no per-room object, no configuration, no lifecycle — joining a room means appending a string to a HashSet<String> on your Conn; leaving means removing it. Broadcasting to a room walks the connection registry and filters by membership. That’s the whole model.

The handle you reach rooms through is WsClient: a per-connection struct a handler asks for by adding &WsClient to the signature.

Any #[subscribe_message], #[on_connect], or #[on_disconnect] handler can take &WsClient in any non-receiver position. The macro spots the reference (distinct from an owned payload) and passes the current connection’s handle:

crates/features/src/chat/ws/gateway.rs
use nest_rs::ws::{gateway, messages, WsClient};
#[gateway(path = "/ws")]
#[derive(Default)]
pub struct ChatGateway;
#[messages]
impl ChatGateway {
#[on_connect]
async fn joined(&self, client: &WsClient) {
client.join("lobby");
}
#[subscribe_message("join")]
#[public]
async fn join_room(&self, room: String, client: &WsClient) {
client.join(&room);
}
#[subscribe_message("leave")]
#[public]
async fn leave_room(&self, room: String, client: &WsClient) {
client.leave(&room);
}
}

A connection can sit in any number of rooms at once; a room “exists” exactly as long as one connection holds it. Disconnect drops the membership atomically — no stale rows, no cleanup hook to write.

WsClient exposes four send methods, each scoping a different way:

MethodReaches
client.emit(event, &data)This connection only.
client.to(room, event, &data)Every connection in room.
client.broadcast(event, &data)Every live connection, including this one.
client.registry()The underlying WsServer — for advanced cases.

Every method serializes data with serde and returns either a boolean (one connection) or a count of outboxes that accepted the frame. A closed outbox returns false / 0 silently — the writer task drops the channel on disconnect, so the dispatch loop never blocks on a slow client.

crates/features/src/chat/ws/gateway.rs
#[subscribe_message("say")]
#[public]
async fn say(&self, msg: SendMessage, client: &WsClient) {
let _ = client.broadcast("said", &msg);
let _ = client.to("lobby", "said", &msg);
}

broadcast reaches the whole gateway; to scopes by room. client.to(room, ...) does not require the sender to be in room — a user can post into any room they know the name of, which is why authorization belongs in a guard and not in the membership table.

The chat gateway uses all four scopes — joining at connect, moving rooms by event, broadcasting peer-visible events, replying with a payload:

crates/features/src/chat/ws/gateway.rs (from the demo)
#[gateway(path = "/ws")]
#[use_guards(AuthnGuard)]
pub struct ChatGateway {
#[inject]
svc: Arc<ChatService>,
}
#[messages]
impl ChatGateway {
#[on_connect]
async fn joined(&self, client: &WsClient) {
client.join("lobby");
self.svc.connected();
}
#[on_disconnect]
async fn left(&self) {
self.svc.disconnected();
}
#[subscribe_message("message")]
#[public]
#[use_guards(ModerationGuard)]
async fn on_message(&self, message: SendMessageDto) -> Result<(), serde_json::Error> {
self.svc.record(message)?;
Ok(())
}
#[subscribe_message("history")]
#[public]
async fn history(&self) -> Vec<ChatMessageDto> {
self.svc.history()
}
#[subscribe_message("typing")]
#[public]
async fn typing(&self, message: SendMessageDto, client: &WsClient) {
if let Err(e) = client.broadcast("typing", &message) {
tracing::warn!(target: "features::chat", error = %e, "broadcast failed");
}
}
}

Drive it from two terminals at once and the picture comes through:

Terminal window
$ websocat ws://localhost:3000/ws # tab A
{"event":"message","data":{"author":"ada","text":"hi"}}
{"event":"typing","data":{"author":"bob","text":"..."}}
$ websocat ws://localhost:3000/ws # tab B
{"event":"typing","data":{"author":"bob","text":"..."}}
{"event":"message","data":{"author":"ada","text":"hi"}}

message is recorded by the service, which calls WsServer::broadcast("message", ...) from outside the handler — see Server-side push. typing broadcasts through the client handle directly because there is no state to record.

WsClient is great when a handler reacts to a single message. The moment something outside a handler needs to reach connected clients — a scheduled task, an HTTP route, a queue processor — there is no client to thread through. Inject WsServer instead and push without the per-connection handle.

That’s the next page.

  • WsClient — join, leave, emit, to, broadcast, id, registry.