Skip to content

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.

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

crates/features/src/chat/ws/gateway.rs
#[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-&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.

The #[public] above is the message’s access posture, and it is not optional — see the next section.

Each #[subscribe_message] carries exactly one of two attributes, and a message that carries neither does not compile:

crates/features/src/chat/ws/gateway.rs
#[subscribe_message("message")]
#[public] // no gate, no mask
async fn on_message(&self, msg: SendMessageDto) -> ChatMessageDto { /* ... */ }
#[subscribe_message("users.list")]
#[authorize(Read, users::Entity)] // class gate + reply mask, both emitted
async 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.

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.

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

crates/features/src/chat/ws/gateway.rs
#[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.

crates/features/src/chat/ws/gateway.rs
#[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:

crates/features/src/chat/ws/gateway.rs
#[subscribe_message("history")]
#[public]
async fn history(&self) -> Vec<ChatMessageDto> {
self.svc.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": "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.

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

crates/features/src/chat/ws/gateway.rs
#[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:

crates/features/src/chat/ws/gateway.rs
#[subscribe_message("presence")]
#[public]
async fn presence(&self) -> usize {
self.svc.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:

crates/features/src/chat/ws/gateway.rs
#[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))
}
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, and read data.errors when they need to point at a field.

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:

nest_rs::ws::WsEnvelope
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.