Typed Realtime Clients
A Maat handler broadcasts an event; a Flutter widget rebuilds. Between those
two lines are a WebSocket, a signature, a reconnect and a token that expires.
package:horus_client/realtime.dart is that middle.
final Stream<OrderUpdated> updates = realtime.stream(orderUpdated(7));Read Broadcasting for the server half and WebSockets with Thoth for the server itself.
The three pieces
import 'package:horus_client/realtime.dart';
// 1. The typed eventfinal class OrderUpdated { const OrderUpdated({required this.id, required this.status});
factory OrderUpdated.fromJson(Map<String, Object?> json) => OrderUpdated( id: json['id']! as int, status: json['status']! as String, );
final int id; final String status;}
// 2. Where it arrives. The channel matches the event's broadcastOn(),// prefix included; the event name matches its broadcastAs().ChannelEvent<OrderUpdated> orderUpdated(int id) => ChannelEvent( channel: 'private-orders.$id', event: 'OrderUpdated', decode: (json) => OrderUpdated.fromJson(json! as Map<String, Object?>),);
// 3. The connectionfinal realtime = HorusRealtime( socketUri: Uri.parse('ws://localhost:6001/app/$appKey'), client: api,);ChannelEvent<T> is the realtime twin of Endpoint<T>: a location and a
decoder. The two halves of a client read alike on purpose.
In Flutter
StreamBuilder<OrderUpdated>( stream: realtime.stream(orderUpdated(order.id)), builder: (context, snapshot) => switch (snapshot) { AsyncSnapshot(hasError: true) => const Text('Reconnecting…'), AsyncSnapshot(:final data?) => Text(data.status), _ => Text(order.status), },);Nothing in the module imports Flutter, and nothing imports dart:io on a path
the browser reaches — package:web_socket_channel picks the browser or the IO
implementation — so the same code runs on mobile, desktop and web.
A channel is subscribed when its first listener arrives and dropped when its last one cancels, so a screen that pops stops paying for it. Two widgets on one channel share a single subscription.
Private channels
A private or presence channel needs a signature from the application before
Thoth will accept the subscription. HorusRealtime gets one by POSTing to
/broadcasting/auth through the HorusClient you gave it — the same base URL
and the same credentials as every other call, which is why it takes a client
rather than a URL and a token of its own.
final api = HorusClient( Uri.parse('https://orders.example.com'), headersProvider: () async => {'authorization': 'Bearer ${await tokens.access()}'},);final realtime = HorusRealtime(socketUri: socketUri, client: api);The server decides who may subscribe, in a provider:
Broadcast.channel('orders.{id}', (user, params) => ordersOwnedBy(user).contains(int.parse(params['id']!)));A refusal errors that one stream — as a ChannelAuthorizationException — and
leaves the rest of the connection running. One order a customer may not watch
is not a reason to drop the four they may.
Public channels need no client at all.
Reconnect, and why it re-authorizes
Thoth signs a subscription as HMAC(secret, "$socketId:$channelName"). A
reconnect gets a new socket id, so every signature the client is holding is
already dead.
HorusRealtime therefore does not replay stored subscribe frames. On each
reconnect it waits for the new socket id, re-authorizes every channel that
still has listeners, and resubscribes with fresh signatures. A channel whose
last listener cancelled while the connection was down is not resubscribed.
Retries use exponential backoff with full jitter — 500 ms doubling to a 30 s cap. The jitter is not decoration: without it, every client dropped by one server restart comes back at the same instant and knocks it over again.
HorusRealtime( socketUri: socketUri, client: api, backoff: exponentialBackoff(base: Duration(seconds: 1), cap: Duration(minutes: 1)),);A refusal that retrying cannot fix — a wrong app key (4001) or an unsupported protocol (4007) — stops instead of spinning and errors every listener so the application can say so. A capacity refusal (4100) keeps the client on its normal reconnect backoff because room may become available.
Watch the connection with realtime.statuses, a Stream<RealtimeStatus> of
connecting, connected, reconnecting and disconnected.
Expired tokens
onAuthFailure runs once when /broadcasting/auth answers 401 or 403, then
the authorization is retried:
HorusRealtime( socketUri: socketUri, client: api, onAuthFailure: () => tokens.refresh(),);Because headersProvider is consulted per request, the refreshed token
reaches the retry with nothing else to wire up. A second refusal is a real
refusal, not an expired token: that channel’s stream gets the error and the
rest of the socket carries on.
Not hearing your own events
A client that just made a change already has the new state in the response. Receiving the broadcast too makes it apply the change twice.
Give the HTTP client the connection’s socket id and the problem goes away:
api.socketIdProvider = () => realtime.socketId;Every request then carries X-Socket-ID, which is what
PendingBroadcast.toOthers(request) reads on the server:
await broadcast(OrderUpdated(...)).toOthers(request).send();The exclusion is per socket, not per channel — every other subscriber still
receives it. While the connection is down socketId is null and no header is
sent, so a stale id never excludes a socket that no longer exists.
Not covered yet
- Presence member lists and
client-events. This client ignores those frames rather than half-supporting them. The gap is client-side only: Thoth implements both — presence member lists atthoth_handler.dart:178,221-230,332-345andclient-event forwarding at:395— so a JavaScript client talking to the same server gets them today. See Realtime in the Browser for Horus, the browser client that does. - Generating
ChannelEvents from the server. The typed event is declared by hand. The shape above is the one a generator would emit, so adopting one later changes nothing at the call site. See Building JSON APIs for the request half. - Redis and multi-node fan-out. Thoth’s registry is in memory and one process is the supported deployment; see WebSockets with Thoth.
The orders example runs all of this against a real Thoth, a real Maat application and a real socket.