WebSockets
Gateways that self-mount on the HTTP transport, with a JSON envelope and per-event handlers.
A WebSocket upgrade is an HTTP GET, so a gateway self-mounts on the
existing HttpTransport — same port, same CORS, same TLS, no second
server. Every frame rides one JSON envelope, { "event": "...", "data": ... }, and each #[subscribe_message("event")] method handles one
event. List the gateway in #[module(providers = [...])] and you have
real-time wired in.
Read this section, then start from the working reference: the
live app in Publish — a chat gateway plus org-scoped
notifications, running the exact shapes below.
nest-rs-ws is a thin layer over poem’s WebSocket
support — the same
transport nest-rs-http already uses. It hides the read/write split,
the outbox writer task, the connection registry, and the dispatch
plumbing behind one decorator.
In the Publish workspace, live comments and notifications
run in live on port 3004.
Install
Section titled “Install”cargo add nest-rs --features ws#[input] on a typed payload carries its serde derives and routes them back
through the framework, and nest_rs::ws::tracing is the gateway’s logger — so
the manifest stops at one line. nestrs g ws <feature> writes it.
A one-event gateway
Section titled “A one-event gateway”The smallest possible gateway: one event, one reply.
use nest_rs::ws::{gateway, messages};
#[gateway(path = "/ws")]#[derive(Default)]pub struct PingGateway;
#[messages]impl PingGateway { #[subscribe_message("ping")] #[public] async fn ping(&self) -> &'static str { "pong" }}#[gateway(path = "/ws")] mounts on the HTTP transport at /ws.
#[messages] turns the impl block into a dispatcher: the read loop
parses each frame’s envelope, matches the event, deserializes data,
calls the handler, and serializes the return into a reply frame on the
same event name.
Wire it in like any other provider:
use nest_rs::core::module;use nest_rs::http::{HttpConfig, HttpModule};use nest_rs::ws::WsModule;
use features::chat::PingGateway;
#[module( imports = [ HttpModule::for_root(HttpConfig { port: 3004, ..Default::default() }), WsModule, ], providers = [PingGateway],)]pub struct LiveModule;HttpModule for the transport, WsModule for the connection registry
(WsServer). The gateway itself is listed in providers — no
.transport(...) call, no second port.
WsModule is not optional for a default-namespace gateway: every
WsClient a handler touches reads that registry. A gateway declares the
dependency, so leaving it out fails the boot through the access graph, naming
both the missing type and the module that provides it:
Error: module access violation: `PingGateway` (in module `LiveModule`) depends on`WsServer`, but `LiveModule` imports no module that provides it. `WsServer` isprovided by `WsModule` — add `WsModule` to `#[module(imports = [...])]` …Import it in whichever module lists the gateway — nestrs g ws writes it into
the generated adapter module. WsModule owns the
namespaced registries too, so the import is the
same one step whichever registry a gateway uses.
Run it
Section titled “Run it”nestrs run dev live2026-06-08T10:23:14Z INFO nest_rs::routes: mounted endpoint kind="ws" path="/ws"Drive it from any WebSocket client. With
websocat:
$ websocat ws://localhost:3004/ws{"event":"ping","data":null}{"event":"ping","data":"pong"}The envelope you send is {event, data}; the server’s reply rides the
same shape on the same event name. From here, every other concern is a
matter of adding one decorator.
A socket can live for hours, so the message is the unit of work: each one
opens its own ws.message span under the trace and the actor the upgrade
established — see Correlation.
The whole conversation is legible from the console — the 101 that opened the
socket, the connection, then a line per message, all under the upgrade’s trace:
INFO nest_rs::operation: http.request method=GET path="/ws" status=101 bytes=0 duration_ms=1.265 client_ip=127.0.0.1 forwarded=false trace_id=01a015e0074575729557467b3d9e2317 span_id=0aa87fe2893b32d0INFO nest_rs::operation: ws.connect conn_id=0 duration_ms=0.093 trace_id=01a015e0074575729557467b3d9e2317 span_id=0aa87fe2893b32d0INFO nest_rs::operation: ws.message event=message conn_id=0 outcome="ok" duration_ms=0.045 trace_id=01a015e0074575729557467b3d9e2317 span_id=3398a131c2baa84aINFO nest_rs::operation: ws.message event=nope conn_id=0 outcome="error" duration_ms=0.011 trace_id=01a015e0074575729557467b3d9e2317 span_id=2a3f10371aa5cbc8Same trace_id throughout, a fresh span_id per message — because a connection
inherits identity, never resources: it takes the upgrade’s trace and actor, and
opens its own request scope per message. Your own handler’s events carry the
message’s ids without naming them; see Logs.
Version the socket
Section titled “Version the socket”#[gateway(path = "/ws", version = "1")] serves at /v1/ws. It is the same
version argument #[controller] takes, resolved through the same
version_path, because a gateway’s mount is an address the client selects —
the edges where it is not refuse the argument with a named
compile error rather than accept a version nothing could apply.
A gateway owns its mount, so the version is part of that ownership: version = "1" and version = "2" on one path are two mounts that both boot with their own
message tables, while two gateways sharing a path and a version still fail boot
naming both.
Only the URI form selects a gateway. NESTRS_HTTP__VERSIONING=header rewrites
controller paths in front of routing and never learns a self-mount’s prefixes, so
a versioned gateway stays at /v1/ws under every strategy — which is just as
well, since a browser cannot set headers on a WebSocket handshake.
In this section
Section titled “In this section”Basics
Section titled “Basics”- Messages — the envelope shape, the
Result/Errreturn contract, payload validation, error frames. - Guards — authenticate at the upgrade, gate per-message envelopes, and the two scopes that split.
- Rooms —
WsClient::join/leave/broadcast/to, the per-connection handle.
All options
Section titled “All options”- Namespaces — several gateways with isolated
registries via
WsServer<N>markers. - Server-side push — reaching connected clients from a service, a scheduled job, or an HTTP route.
Reference
Section titled “Reference”nest-rs-ws— the macro and runtime:#[gateway],#[messages],#[subscribe_message],#[on_connect],#[on_disconnect],WsClient,WsServer,WsModule.apps/live— end-to-end real-time example (ChatGateway, and thenotificationsfeature’sNotificationsGateway) used throughout these pages.
Going further
Section titled “Going further”- HTTP — the transport a gateway self-mounts on.
- GraphQL subscriptions — the schema-shaped realtime surface, for comparison.
- Security — wiring
AuthnGuardandAuthzGuardfor WS the same way as HTTP and GraphQL.