Realtime in the Browser
Maat’s realtime story ends at the server unless something in the page speaks the protocol. Horus is that something: one JavaScript file, no dependencies, no build step.
Two ways to install
A server-rendered Maat application — the starter kit, or anything using Khnum templates — uses the command, and needs no npm at all:
maat horus:install # writes public/js/horus.jsTwo things have to be in place before this command has anything to publish:
dependencies: horus_js: ^0.1.0.withExtensions([horusJsExtension]) // from package:horus_js/horus_js.darthorus_js ships no service provider — the extension is only the
horus:install console command, which copies its bundled asset to
public/js/horus.js. Once wired:
<script src="{{ asset('js/horus.js') }}"></script><script> const horus = new Horus({ key: '{{ thothKey }}', host: '{{ thothHost }}', port: {{ thothPort }}, csrfToken: '{{ csrfToken }}', });
horus.private('conversation.7') .on('message.sent', (message) => append(message)) .whisper('typing', { user: 12 });
horus.presence('chat') .here((members) => setOnline(members)) .joining((member) => online(member)) .leaving((member) => offline(member));</script>A decoupled front end — React, Vue, Svelte, anything with a bundler — installs from npm instead:
npm install @gaitco/horusimport Horus from '@gaitco/horus';
const horus = new Horus({ key: '…', host: 'localhost', port: 6001 });Both ship the same file. The npm package versions in lockstep with the Dart
horus_js package, so a matching version number means a matching client.
The npm path exists for a front end that already has a build step of its
own — it is not a new requirement for a server-rendered Maat application,
which keeps installing with maat horus:install and nothing else.
Channels
channel(name) for a public channel, private(name) and presence(name)
for the two that need authorization. The prefix is added for you: private-
and presence- are protocol detail, and passing a name that already carries
one is accepted rather than double-prefixed.
Both authorized kinds POST socket_id and channel_name to authEndpoint
(/broadcasting/auth by default) with the CSRF token, and send back the
signature the server returned. presence additionally receives the member
list, which is what here is called with.
Members
horus.presence('chat') .here((members) => members.map((m) => m.info.name)) .joining((member) => member.id)Each member is { id, info }, where info is whatever the channel
authorizer returned in routes/channels.dart. id is the user’s
authIdentifier, as a string — the protocol requires a string, so an integer
key arrives as '7'.
Whispers
channel.whisper('typing', {...}) sends an event to the other subscribers
without a request reaching the server’s application code. The listener binds
it under the name the protocol carries:
channel.on('client-typing', (data) => showTypingIndicator(data));The client- prefix is added for you, the same way private- and
presence- are: pass 'typing', not 'client-typing'.
Three conditions, all enforced by the server: the channel must be private or
presence, this connection must already be subscribed to it, and Thoth must
have client_events enabled:
'client_events': true,It is off by default, and a whisper sent without it comes back as a
pusher:error rather than being delivered.
Not receiving your own events
await horus.fetch('/api/conversations/7/messages', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ body: 'hello' }),});horus.fetch is fetch with this connection’s socket id in X-Socket-ID
and the CSRF token attached. It accepts headers in any shape fetch
itself does — a plain object, a Headers instance, or an array of
[name, value] pairs — and merges the socket id and CSRF token into
whichever one you passed. The server reads X-Socket-ID in
broadcast(event).toOthers(request).send() and skips this socket, so the
sender applies its own message once — from the response — instead of twice.
Reconnect
A dropped connection reconnects on its own with exponential backoff and jitter, then resubscribes every channel. Private and presence channels are re-authorized, not replayed: the signature covers the socket id, and the new connection has a new one.
horus.onState((state) => ...) reports connecting, connected,
reconnecting and disconnected, which is enough to drive a “reconnecting…”
banner.
Presence resync, not presence replay. On reconnect, a presence channel’s
member list is seeded fresh from the subscription’s here payload — the
whole map is replaced, and here’s handler runs again with it. Horus does
not synthesize joining/leaving calls for members who joined or left
while the socket was down; inventing transitions the server never sent would
be worse than not reporting them. This means here is the resync point: an
application that keeps its online list only from joining/leaving will
drift after any reconnect, however brief. Re-derive the list from here on
every call, not just the first.
Horus JS and horus_client
Same falcon, two runtimes. horus_client is the typed Dart and Flutter
client (Typed Realtime Clients); Horus JS is the
browser. The browser client supports presence member lists and whispers; the
Dart one does not yet.