Messages
The JSON envelope, the #[subscribe_message] signature, and the Result/Err return contract that ships error frames.
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.
A handler signature
Section titled “A handler signature”#[subscribe_message("event")] binds a method to that event name in the
envelope:
#[messages]impl ChatGateway { #[subscribe_message("message")] #[public] async fn on_message(&self, msg: SendMessageDto) -> ChatMessageDto { self.svc.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-
&WsClientargument. Deserialized fromenvelope.datawithserde. - 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.
The #[public] above is the message’s access posture, and it is not
optional — see the next section.
Every message declares its posture
Section titled “Every message declares its posture”Each #[subscribe_message] carries exactly one of two attributes, and a
message that carries neither does not compile:
#[subscribe_message("message")]#[public] // no gate, no maskasync fn on_message(&self, msg: SendMessageDto) -> ChatMessageDto { /* ... */ }
#[subscribe_message("users.list")]#[authorize(Read, users::Entity)] // class gate + reply mask, both emittedasync fn list(&self) -> Result<Vec<User>, ServiceError> { Ok(self.svc.list().await?.iter().map(User::from).collect())}This is the same rule a #[query] and a #[tool] carry, for the same
reason: silence is not a decision. A handler nobody thought about the
access of must not ship rows no ability ever filtered.
#[authorize(Action, Entity)] emits two things you would otherwise
write by hand. Before the payload is even deserialized, a class-level
gate asks the caller’s ambient Ability whether it may perform Action
on Entity; a refusal is an error frame and the body never runs. After
the handler returns, the reply is masked — rows the ability refuses are
dropped, columns a field grant withholds are stripped. You write no
masking call.
#[public] is a declaration, not a shortcut. Unlike an HTTP route
there is no anonymous fast path to take on WebSockets; what #[public]
buys is the statement that the posture was decided. The guards bound on
the gateway struct and beside the message still run.
What the mask does, and what it does not
Section titled “What the mask does, and what it does not”The mask acts on the serialized reply. A withheld column is absent from the frame — the same thing the HTTP response shaper does to a body, because a WS envelope promises no schema that a missing key would violate. This is where WebSockets differ from GraphQL and MCP, which reconstruct the operation’s return type and fail the operation when a stripped key was required.
Two cases stay fail-closed, and both are wiring or data faults rather
than policy: no ambient ability (nothing decided what the caller may
read — most often AuthzWsModule was never imported), and a body that
cannot be reconciled with the entity at all. Both answer an error frame
and log a warn on nest_rs::authz.
The payload
Section titled “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 SendMessageDto { author: String, text: String,}
#[subscribe_message("message")]#[public]async fn on_message(&self, msg: SendMessageDto) { /* ... */ }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). Richer checks bind a
pipe on the payload argument: Valid<T> runs
validator rules, Piped<P, T> names a custom transform — a rejection
is an error frame, and the handler body never runs.
#[subscribe_message("trim")]#[public]async fn trim_handler(&self, name: Piped<Trim, String>) -> String { name.into_inner()}A handler that takes no payload accepts null or a missing data key
— the envelope defaults data to null on deserialize:
#[subscribe_message("history")]#[public]async fn history(&self) -> Vec<ChatMessageDto> { self.svc.history()}> {"event":"history","data":null}< {"event":"history","data":[{"author":"ada","text":"hi"}]}The return contract
Section titled “The return contract”The macro inspects the handler’s return type at parse time and picks the reply behavior:
| Return type | Wire effect |
|---|---|
() | No reply frame. Fire-and-forget. |
T | One 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": "send", "data": { "error": "text must not be empty" } }The string comes from e.to_string() — i.e. the Display impl of your
error type. A rejection that carries structured detail adds an errors
member beside it — a Valid<T> payload names the offending fields, the same way
the HTTP body does:
{ "event": "validated", "data": { "error": "invalid payload for `validated`: validation failed", "errors": { "text": [{ "code": "length", "message": "text must not be empty", "params": { "min": 1 } }] } }}Each entry is { code, message, params }. params carries the rule’s own
bounds — min, max, the regex name — so a client renders “at least 1
character” without parsing prose; the rejected value is stripped, so a
too-short password never rides back out. message is null unless the
#[validate] rule set one.
errors is absent, not null, when the failure had nothing structured to
say, so a client can branch on its presence. Every refused dispatch lands a
warn! on the nest_rs::ws target alongside the frame — a pipe rejection, a
payload that does not deserialize, an unknown event, and a handler-returned
Err alike — so a denied dispatch shows up in logs without extra
instrumentation.
A type alias over Result behaves the same. pub type ServiceResult<T> = Result<T, MyError> produces the identical error frame and the identical
warn: the decision is made on the type, so however the return is spelled,
an Err never reaches the reply data.
Three handler patterns
Section titled “Three handler patterns”Fire-and-forget — record state, broadcast a derived event later, no reply to the sender:
#[subscribe_message("typing")]#[public]async fn typing(&self, msg: SendMessageDto, 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")]#[public]async fn presence(&self) -> usize { self.svc.present()}> {"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")]#[public]async fn send(&self, msg: SendMessageDto) -> Result<ChatMessageDto, SendError> { if msg.text.is_empty() { return Err(SendError::Empty); } Ok(self.svc.record(msg))}> {"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, and read data.errors when they need to point at a field.
The envelope itself
Section titled “The envelope itself”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.
Reference
Section titled “Reference”WsEnvelope/WsReply— the wire shape and the dispatch outcome enum.
Going further
Section titled “Going further”- Rooms — server→client pushes via
WsClient. - Server-side push — emit without holding a client.
- Guards — reject envelopes before dispatch.