Skip to content

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.

Terminal window
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.

The smallest possible gateway: one event, one reply.

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

apps/api/src/module.rs
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:

Terminal window
Error: module access violation: `PingGateway` (in module `LiveModule`) depends on
`WsServer`, but `LiveModule` imports no module that provides it. `WsServer` is
provided 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.

Terminal window
nestrs run dev live
Terminal window
2026-06-08T10:23:14Z INFO nest_rs::routes: mounted endpoint kind="ws" path="/ws"

Drive it from any WebSocket client. With websocat:

Terminal window
$ 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:

Terminal window
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=0aa87fe2893b32d0
INFO nest_rs::operation: ws.connect conn_id=0 duration_ms=0.093 trace_id=01a015e0074575729557467b3d9e2317 span_id=0aa87fe2893b32d0
INFO nest_rs::operation: ws.message event=message conn_id=0 outcome="ok" duration_ms=0.045 trace_id=01a015e0074575729557467b3d9e2317 span_id=3398a131c2baa84a
INFO nest_rs::operation: ws.message event=nope conn_id=0 outcome="error" duration_ms=0.011 trace_id=01a015e0074575729557467b3d9e2317 span_id=2a3f10371aa5cbc8

Same 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.

#[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.

  • Messages — the envelope shape, the Result/Err return contract, payload validation, error frames.
  • Guards — authenticate at the upgrade, gate per-message envelopes, and the two scopes that split.
  • RoomsWsClient::join / leave / broadcast / to, the per-connection handle.
  • 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.
  • 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 the notifications feature’s NotificationsGateway) used throughout these pages.
  • HTTP — the transport a gateway self-mounts on.
  • GraphQL subscriptions — the schema-shaped realtime surface, for comparison.
  • Security — wiring AuthnGuard and AuthzGuard for WS the same way as HTTP and GraphQL.