Skip to content

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:

Terminal window
maat horus:install # writes public/js/horus.js

Two things have to be in place before this command has anything to publish:

pubspec.yaml
dependencies:
horus_js: ^0.1.0
bootstrap/app.dart
.withExtensions([horusJsExtension]) // from package:horus_js/horus_js.dart

horus_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:

Terminal window
npm install @gaitco/horus
import 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:

config/thoth.dart
'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.