Belune

WebSockets

Subscribe to live metrics, logs, request traces and status changes over one socket.

Every live view in the dashboard — a build scrolling, a metrics chart moving, a container flipping to running — is fed by one WebSocket at /api/ws. You open a single connection and subscribe to the channels you care about, and the server pushes messages until you unsubscribe or disconnect.

OpenAPI describes request/response endpoints, so the reference entry for GET /api/ws can only say that the route exists. The protocol itself lives here.

Connecting

The socket authenticates exactly like the rest of the API — a personal access token with read scope is enough, since read is the lowest rung that reaches it:

npx wscat -c wss://belune.example.com/api/ws \
  -H "Authorization: Bearer $BELUNE_TOKEN"

Use wss:// on production environment, ws:// only against a local dev server.

Two things reject a connection before the upgrade completes:

  • No valid credential — a 401, same as any other route.
  • A disallowed Origin header. Same-origin is always accepted, and anything in CORS_ORIGINS is too. Browsers send Origin automatically; a script that omits it entirely is unaffected, so this only bites a browser client served from somewhere unexpected.

Browsers cannot set headers on a WebSocket, which is why the dashboard authenticates this socket with its session cookie instead. A token is for scripts.

The Protocol

Every frame is JSON. You send:

{ "action": "subscribe", "channel": "metrics:host" }

action is subscribe or unsubscribe; anything else is ignored silently, as is an empty channel. A successful subscribe is not acknowledged — messages simply start arriving. A subscribe the server refuses is answered; see Who May Subscribe.

The server sends:

{ "channel": "metrics:host", "event": "metrics", "data": { } }

data is passed through verbatim from whatever produced it, so its shape depends on the channel. Dispatch on channel first, then event.

Channels

ChannelEventCarriesWho may subscribe
metrics:hostmetricsHost CPU, memory and disk, once per secondAdmins
metrics:app:{applicationId}metricsOne application's container stats, every two secondsProject access
build-logs:{deploymentId}log, doneBuild output, then done with null data when the build endsProject access
container-logs:{sourceId}logRuntime log lines; sourceId is the application or database idProject access
requests:{applicationId}requestProxy request traces for one applicationProject access
requests:allrequestEvery application's request tracesAdmins
container-status:{applicationId}status{ application_id, status } when an app's container changes stateProject access
database-status:{databaseId}status{ database_id, status }, the database equivalentProject access
platformupdate{ updating } when a self-update starts or fails before touching the hostAny signed-in caller

build-logs is the only channel that terminates: it emits done with data: null when the build finishes. Everything else runs until you unsubscribe.

metrics:app:{applicationId} is collected on demand — the poller only samples applications that currently have a subscriber, so the first message can take up to two seconds to arrive. Nothing is buffered from before you subscribed, on any channel.

Who May Subscribe

Opening the socket does not entitle you to every channel. Each subscribe is checked against the same rules as the REST endpoint that serves the same data:

  • metrics:host and requests:all need an admin, exactly like GET /api/metrics/host and GET /api/requests.
  • platform is open to any authenticated caller, pinned tokens included. It carries only what the unauthenticated GET /api/version already reports in its updating flag, and the socket is just the faster way to hear it — treat GET /api/version as the source of truth.
  • Every other channel needs access to the project that owns the resource: its owner, any member when the project is shared, or an admin. For container-logs, the id may be an application or a database.
  • A project-pinned token is also limited to the projects it is pinned to. requests:all spans every project and cannot be narrowed to a pin, so a pinned token is never given it.
  • Anything else is refused — a channel not listed above, an id that does not exist, or an id that is not in the lowercase, hyphenated form the API returns.

A refused subscribe is answered on the channel you asked for, and you are not subscribed to it:

{ "channel": "metrics:host", "event": "error", "data": { "error": "access denied" } }

The message is identical whether the resource is forbidden or does not exist. The socket stays open and your other subscriptions are unaffected.

Access is checked once, when you subscribe, against who you were when the connection opened. A REST call re-validates its token every time; an open socket does not, so a revoked token or a demoted user keeps a live connection until it drops. Reconnecting checks everything again.

Example

Tailing a deployment's build output:

const ws = new WebSocket("wss://belune.example.com/api/ws");

ws.onopen = () =>
  ws.send(JSON.stringify({ action: "subscribe", channel: `build-logs:${deploymentId}` }));

ws.onmessage = (e) => {
  const { channel, event, data } = JSON.parse(e.data);
  if (event === "done") return ws.close();
  if (event === "log") console.log(data);
};

Limits

  • 20 concurrent connections per user by default (MAX_WEBSOCKET_CONNS_PER_USER). Past that the socket is accepted and then immediately closed with 1013 Try Again Later — so handle a close arriving straight after open.
  • Subscribe on one connection, not many. Channels are cheap; connections are the thing that is capped.
  • Slow consumers are disconnected. Each connection has a 64-message buffer, and a client that overflows it ten times in a row is dropped. Reading in a tight loop and processing elsewhere is safer than doing real work inside the message handler, especially on requests:all on a busy install.
  • The server pings every 30 seconds. Browser and library clients answer automatically; a hand-rolled client must reply to pings or it will be closed as dead.

What This Socket Cannot Do

It is read-only. subscribe and unsubscribe are the only actions — nothing here deploys, restarts or writes anything, so a read-scoped token is all it ever needs.

The host shell at /api/ws/terminal/{sessionId} is a different socket with a different protocol, and it requires a live dashboard session — no token reaches it, so it is not part of this reference.

On this page