ServicesWebSocket

WebSocket

How real-time connections work on the platform: the two root-mounted upgrade endpoints (/ws/liveness and /ws/pubsub/{channel}), both auth modes, the JSON message protocol, connection lifecycle and limits, and the admin connection controls. Every example on this page was executed against a live server.

When to reach for it: streaming pub/sub messages or liveness updates to browsers, CLIs, and services - anywhere polling an HTTP endpoint would be too slow or too chatty.

When not to: some services run their own sockets on this same layer, following the same auth and lifecycle rules - collaborative document change feeds belong to Live JSON (/ws/live-json/{document_id}), entity change subscriptions to ECS, and topic streams to Consensus.

Concepts

  • Two sockets. /ws/liveness is the general multiplexed socket, carrying liveness entity subscriptions plus any number of in-band pub/sub subscriptions; /ws/pubsub/{channel} is bound to one channel at the handshake.
  • The handshake is the gate - authentication, scope checks, and (on the pub/sub socket) per-channel authorization all run before the upgrade. A denied client never gets a socket; it gets a normal enveloped HTTP error.
  • Two auth modes - the Authorization: Bearer header, or a ?token= query parameter for browser WebSocket clients that cannot set headers. Prefer the header elsewhere; query strings tend to end up in logs.
  • Frames are JSON with a type field (ping, subscribe, pubsub_message, ...). Unparseable input gets an in-band error frame back, not a disconnect.
  • Connections die with their token - a socket is torn down when the JWT that opened it expires; long-lived clients reconnect with a fresh one.
  • Scopes confine sockets - a token carrying a scope claim needs liveness:read for /ws/liveness and pubsub:read for /ws/pubsub/{channel}. Pub/sub capability tokens open the general socket but are confined to its pub/sub half.

Connecting

const ws = new WebSocket(`ws://localhost:4000/ws/liveness?token=${jwt}`);

Anything that can set headers should send Authorization: Bearer instead. A successful handshake answers 101 Switching Protocols; a missing or invalid token answers 401 with the standard envelope (captured live):

{
  "status": 401,
  "msg": "WebSocket authentication failed: Missing Authorization header or token query parameter",
  "error": "websocket_auth_failed"
}

A token whose scope claim lacks the needed service scope is also refused at the handshake (requires 'liveness:read' / requires 'pubsub:read'); tokens without a scope claim are unrestricted.

The message protocol

Client-sent frames: ping, subscribe / unsubscribe (liveness entities; entity_id omitted means all), pubsub_subscribe / pubsub_unsubscribe (channels). Server-sent: pong, subscribe echoes as acknowledgements, liveness_update, pubsub_message, pubsub_denied, and error. A live exchange on /ws/liveness, client frames on the left:

{"type":"ping"}                              -> {"type":"pong"}
{"type":"subscribe","entity_id":"sensor-1"}  -> {"type":"subscribe","entity_id":"sensor-1"}
not json                                     -> {"type":"error","message":"Invalid message format: ... Expected JSON with 'type' field."}

Field-level schemas for every frame are in the WebSocket API reference.

Pub/sub over WebSocket

The dedicated socket subscribes at the handshake; the CLI wraps it:

snug pubsub listen -c u.5JY5XUIVDLNWX5WV.docs4     # blocks; CTRL+C to stop
snug pubsub publish -c u.5JY5XUIVDLNWX5WV.docs4 -p '{"greeting":"hello"}'

The listener receives each publish as a pubsub_message frame (captured):

{
  "type": "pubsub_message",
  "channel": "u.5JY5XUIVDLNWX5WV.docs4",
  "message_id": "msg_jDEGDYUFqBWp",
  "payload": { "greeting": "hello" },
  "publisher_id": "docs-wave",
  "timestamp": 1787893459
}

Channel access is capability-based (channels live in dot-separated namespaces like u.{namespace}.*; mint a grant with snug pubsub token - see the Pub/Sub API). The two sockets enforce it differently, both verified live: /ws/pubsub/{channel} denies at the handshake (403 channel_forbidden, missing 'subscribe' capability for '...') so an unauthorized subscriber never opens a socket, while an in-band pubsub_subscribe on the general socket answers a pubsub_denied frame with the same reason and leaves the connection open.

Sending pubsub_unsubscribe for the dedicated socket's own channel closes that connection cleanly (close code 1000); on the general socket it just drops the one subscription. And one confinement rule: a pub/sub capability token may open /ws/liveness and subscribe to channels there, but its liveness subscribe frames are refused with an error frame (this credential is confined to pub/sub).

Connection lifecycle

  • Token expiry closes the socket. Verified with a 25-second token: the server tore the connection down the moment the JWT expired.
  • Keepalive is server-driven - the server pings on an interval and drops connections that stop answering or go idle (defaults: ping every 30s, pong timeout 60s, idle cutoff 300s). App-level {"type":"ping"} also counts as activity.
  • Per-user connection cap (20 concurrent sockets by default). Verified live: the 21st connection completes the 101 upgrade and is then immediately closed, so treat an instant close-after-connect as "over the cap", not a network error. /ws/pubsub/{channel} opens one socket per channel, which is how the cap is usually hit.
  • In-band messages are rate limited per message type (subscribe, ping, and general buckets). Verified live: a ping flood was answered with an error frame (Rate limit exceeded. Please wait 1 seconds before sending more ping messages.); the connection stayed open and worked again after the wait.

Admin: inspecting and pruning connections

Requires a platform admin token; non-admins get 403 FORBIDDEN.

snug websocket stats     # health buckets, per-user counts, rate limit config
snug websocket cleanup   # force-drop stale/unhealthy/unresponsive sockets

Over HTTP, GET /admin/websockets/stats (captured live with one held connection, trimmed):

{
  "status": 200,
  "msg": "OK",
  "data": {
    "health": { "total": 1, "healthy": 1, "unhealthy": 0, "stale": 0, "unresponsive": 0 },
    "connections_by_user": { "docs-wave": 1 },
    "rate_limit_stats": [
      { "endpoint": "/ws/subscribe", "max_requests": 300, "window_seconds": 60, "configured": true },
      ...
    ]
  }
}

POST /admin/websockets/cleanup answers {"removed": n, "remaining": m}; healthy connections are left alone (verified: cleanup removed 0 with one healthy connection held).

Limits and configuration

Connection totals (100 global / 20 per user by default), ping and idle timing, per-message-type rate limits, handshake admission control (excess concurrent handshakes are rejected with close code 1013), and outbound queue budgets are all tunable via the WEBSOCKET_* variables in the WebSocket CONFIG reference.

Reference

On this page