Realtime is the one three-level chain in the SDK: a realtimeServer owns realtimeChannels,
and a channel owns realtimeMessage handlers. Pass handles, not names, between them: a channel
path is unique only within its server.
A realtime server
A server is off until enabled: true, the one enabled in the SDK that defaults to false.
An enabled server with no active channel still refuses the handshake.
chat.getUrl(baseUrl) builds the socket URL, wss://<host>/ws/<canonical>, from the https://
base URL you already have. The canonical is minted by xanosdk export, so getUrl throws
rather than guess until one is pinned in xano.lock.
Register it with .registerRealtimeServers([chat]).
Channels
A channel’s name is a path, and its input types the path’s {param} segments, not the
message payload.
publish.who decides which clients may send: "nobody" (the default), "anyone" or
"authenticated". A tokenless client connects only if some channel on its server sets
anonymousClients: true.
conversation is the transcript a joiner receives, pushed automatically at join. limit
defaults to 0, which retains nothing, so { enabled: true } alone stores nothing. Always
pass a limit. The transcript is the client’s initial view; you do not need an endpoint that
fetches recent messages.
delivery sets the guarantee ("at_most_once" or "at_least_once") and
perRecipient, which runs a deliver trigger once per
recipient. at_least_once is a client contract too: the client acks what it receives.
- Matching is strict: a literal segment beats a param, so
rooms/lobby and rooms/{room_id}
can coexist, and segment counts must be equal.
room.getChannel({ room_id: 42 }) returns "rooms/42", the path a client joins. Register it
with .registerRealtimeChannels([room]).
Messages
A realtimeMessage is the realtime analogue of a query: a named handler with its own typed
payload and stack. A client sends a frame whose type names it.
inp("body") reads the payload and inp("room_id") reads the channel’s path param, bound when
the client joined, so a sender cannot post into a room it did not join.
What the handler returns is what is delivered, and with "channel" or "others" it is also the
row stored in the transcript, so return everything a later joiner needs to render the message.
Returning null drops the message. A handler that crashes delivers the sender’s original
payload unchanged, so do not rely on a handler alone for redaction.
Register it with .registerRealtimeMessages([send]).
Push from any stack
s.realtime.publish sends a server-authored event into a channel from any stack: an endpoint, a
function, a task or a trigger.
- It skips the channel’s
publish.who, which governs clients. Authorize in your own stack.
- It does not run a
realtimeMessage handler, even one named in message. A channel’s deliver
trigger still runs.
- It fails silently. A missing or disabled server, or a wrong path, returns nothing to check.
For a path known only at runtime (a row’s id), build the channel as a value instead of with
getChannel(). See the object kinds reference.
Control who connects: server triggers
A realtimeServerTrigger runs when a client connects to or disconnects from the server.
connect is a gate. Its response decides admission: return { allowed: c.bool(true) } (an
optional reason reaches the client) or any truthy value to admit. A refused client gets an
error frame and the socket closes with code 4401. disconnect only observes; its return is
ignored. t exposes t.action, t.realtime_server and t.client.
A connect trigger with no response returns nothing, and an empty return denies, so it refuses
every client on the server. A stack that crashes denies too. xanosdk export warns about the
missing response; nothing can warn about the crash. A server with no connect trigger admits
everyone.
Control who joins: channel triggers
A realtimeChannelTrigger is bound to one channel and runs on join, leave or deliver.
join is a gate with the same rules as connect: a truthy response admits, and an empty
response or a crash refuses. Once the response has an allowed key, only a strict true
admits. The trigger’s inputs are t.action, t.channel, t.payload and t.client, so read
the path params and the caller’s identity from s.realtime.get_session.
leave only observes.
deliver runs once per recipient per message, which makes it the tool for per-viewer
redaction and the most expensive action here. Only null drops the message for that
recipient. An object replaces their payload, and anything else, including false, 0 and
"", delivers it unchanged. Inside it, t.client is the sender and s.realtime.get_session
describes the recipient.
A deliver trigger runs only when its channel sets delivery: { perRecipient: true }. Without the
flag, every subscriber receives the unredacted payload, with no error. xanosdk export warns when
either half is missing.
Register both kinds of realtime trigger with .registerTriggers([onConnect, onJoin, onDeliver]).
The client side
npm run xano:routes writes xano/routes.gen.ts, which exports socketUrl() and
channelPath() so a browser bundle can build the socket URL and channel path without importing
the def objects:
- Handshake. The bearer token goes in as the websocket subprotocol; no token means an
anonymous client. The server finishes its handshake a moment after
open, so an immediate
first frame is refused.
- Join before you broadcast. A
broadcast frame needs a type naming one of the channel’s
message handlers.
- Keep it alive. An idle socket is closed after about 10 minutes, so a client that only
listens sends
{ action: "ping" } periodically.
The rest of the typed client is on Typed frontend.
Next