Skip to content

Messages

Every frame in and out of a gateway rides one shape:

{ "event": "history", "data": [{ "author": "ada", "text": "hi" }] }

event is a string, data is whatever JSON the handler asks for. That’s it — no separate ack channel, no second protocol for errors, no per-event wire format to negotiate. One envelope handles fire-and-forget, request/response, server pushes, and error replies.

#[subscribe_message("event")] binds a method to that event name in the envelope:

#[messages]
impl ChatGateway {
#[subscribe_message("message")]
async fn on_message(&self, msg: SendMessage) -> ChatMessage {
self.room.record(msg)
}
}

The macro reads three things from the signature:

  • The event name — the literal in #[subscribe_message(...)].
  • The payload type — the first non-receiver, non-&WsClient argument. Deserialized from envelope.data with serde.
  • The return type — drives the reply, per the contract below.

A &WsClient parameter is the per-connection handle; it can appear in any position alongside the payload.

Whatever you put in data arrives in your handler typed. The macro calls serde_json::from_value under the hood:

#[derive(serde::Deserialize)]
struct SendMessage {
author: String,
text: String,
}
#[subscribe_message("message")]
async fn on_message(&self, msg: SendMessage) { /* ... */ }

A malformed payload (wrong shape, missing fields) is treated as a dispatch error: the connection stays open, the client receives an error frame on the same event name (see below). Validate richer shapes with #[derive(validator::Validate)] and reject explicitly via the Result-returning form.

A handler that takes no payload accepts null or a missing data key — the envelope defaults data to null on deserialize:

#[subscribe_message("history")]
async fn history(&self) -> Vec<ChatMessage> {
self.room.history()
}
Terminal window
> {"event":"history","data":null}
< {"event":"history","data":[{"author":"ada","text":"hi"}]}

The macro inspects the handler’s return type at parse time and picks the reply behavior:

Return typeWire effect
()No reply frame. Fire-and-forget.
TOne frame: {"event":"<event>","data":<T serialized>}.
Result<(), E>Ok(()) → silent. Err(e) → error frame (see below).
Result<T, E>Ok(t) → reply with t. Err(e) → error frame.

The error frame is shaped like any other envelope:

{ "event": "message", "data": { "error": "author `banned` is not allowed to post" } }

The string comes from e.to_string() — i.e. the Display impl of your error type. A warn! lands in the nest_rs::ws target alongside the frame, so a denied dispatch shows up in logs without extra instrumentation.

Fire-and-forget — record state, broadcast a derived event later, no reply to the sender:

#[subscribe_message("typing")]
async fn typing(&self, msg: SendMessage, client: &WsClient) {
let _ = client.broadcast("typing", &msg);
}

The handler returns (), so no envelope flows back to the sender on this event. The client.broadcast(...) call pushes a server→client event on a different event name — see Rooms.

Request/response — the client asks, the server answers on the same event:

#[subscribe_message("presence")]
async fn presence(&self) -> usize {
self.room.present()
}
Terminal window
> {"event":"presence","data":null}
< {"event":"presence","data":1}

The return value is serialized straight into data. Clients correlate by event name; if you need multiple in-flight requests of the same kind, include an id field in the payload and echo it in the response.

Explicit failure — the operation can fail and the client needs to see why:

#[derive(thiserror::Error, Debug)]
enum SendError {
#[error("text must not be empty")]
Empty,
#[error("rate limited")]
RateLimit,
}
#[subscribe_message("send")]
async fn send(&self, msg: SendMessage) -> Result<ChatMessage, SendError> {
if msg.text.is_empty() {
return Err(SendError::Empty);
}
Ok(self.room.record(msg))
}
Terminal window
> {"event":"send","data":{"author":"ada","text":""}}
< {"event":"send","data":{"error":"text must not be empty"}}

The wire shape stays the same envelope — clients branch on the presence of data.error.

If you ever need to build or parse a frame by hand (a custom adapter, a test harness), nest_rs_ws::WsEnvelope is the canonical shape:

use nest_rs_ws::WsEnvelope;
let frame = WsEnvelope::encode("ping", &"pong")?;
let parsed: WsEnvelope = serde_json::from_str(&frame)?;
assert_eq!(parsed.event, "ping");

A missing data key decodes to serde_json::Value::Null, so older clients sending bare {"event":"ping"} still hit handlers that take no payload.

  • Rooms — server→client pushes via WsClient.
  • Server-side push — emit without holding a client.
  • Guards — reject envelopes before dispatch.