navree

Real-time broadcasting

A Pusher-protocol WebSocket server inside your app: public, private and presence channels, channel authorisation, a signed relay for workers, and Laravel Echo on the front end.

On this page

Featurerealtime When a deal moves on one person's board, everyone else looking at the board should see it move. navree-realtime makes that possible without an external service: it is a Pusher-protocol (version 7) WebSocket server that runs inside your app's own HTTP server. Laravel Echo and pusher-js connect to it unchanged.

You get public, private and presence channels, an endpoint that authorises private channels with your own rules, broadcasting from any service, a signed relay so queue workers and the scheduler can broadcast too, and a way to end a user's subscriptions when their access changes.

Configuration

Add RealtimeModule next to HttpModule. Private and presence channels are authorised by an authenticated route, so the app also needs an authenticator, usually AuthModule (which needs CryptoModule and DatabaseModule):

src/app.rs

This is the reference CRM's realtime section:

config/app.toml
KeyDefaultWhat it does
realtime.app_idnavreeThe application id, used in the relay's URL.
realtime.keynavreeThe public key clients connect with (ws://host/app/{key}): letters, digits, -, _ and ..
realtime.secretemptySigns private and presence subscriptions. Empty means a random secret per process, which is enough: the same process signs and checks. If set, it also keys the relay.
realtime.allowed_origins[]Origins whose pages may connect, or *. Empty means http.cors.allowed_origins. See Allowed origins.
realtime.activity_timeout_secs120Seconds of silence before the server pings a client (at least 1).
realtime.relay_urlemptyWhere other processes send their broadcasts: empty, auto or a URL. See Broadcasting from workers.

From the environment, lists are comma-separated: APP_REALTIME__ALLOWED_ORIGINS=https://crm.example.com,https://admin.example.com. Invalid options fail the boot, naming the key and never echoing the secret. In code, use RealtimeModule::for_root(RealtimeOptions { key, allowed_origins, ..RealtimeOptions::default() }).

RealtimeModule is global. It provides Realtime and dyn Broadcaster (the same instance) and mounts three routes, none of them under the API prefix:

RouteAccessPurpose
GET /app/{key}public, origin-checkedThe WebSocket.
POST /broadcasting/authsigned-in usersSigns private and presence subscriptions.
POST /apps/{app_id}/eventspublic, signedReceives broadcasts relayed by the app's other processes.

Channels

A channel's name says what kind it is:

  • public channels (announcements) are open to anyone who connects;
  • private channels (private-crm.deals) need a signature from your app, given only to users your channel authoriser allows;
  • presence channels (presence-deals.42) are private channels that also tell every subscriber who else is there.

Names are 1 to 200 characters from A-Z a-z 0-9 _ - = @ , . ;. A connection can subscribe to at most 100 channels.

Authorizing channels

Register an authoriser for each family of private or presence channels with m.channel(pattern, authorize), from any module (it doesn't need to import RealtimeModule). This is how the reference CRM does it:

crates/crm-core/src/platform.rs
  • Patterns are written without the private- or presence- prefix. They hold literal text and {name} parameters; a parameter matches one or more characters other than ., and its value is in params (params["id"] for users.{id}).
  • The first matching pattern decides, in module dependency order. A channel no pattern matches is refused.
  • Mistakes fail the boot: a malformed pattern, a pattern written with its prefix, or the same pattern registered twice.
  • The authoriser gets the Principal asking and the AppContext, so it can resolve any service, such as the ability factory above (see Authorization).

It returns a ChannelDecision:

DecisionMeaning
DenyThe auth endpoint answers 403.
AllowThe user may subscribe. On a presence channel they appear with no user_info.
Presence(json)The user may subscribe; on a presence channel the other members see json as their user_info. On a private channel it is the same as Allow.

The auth endpoint

Pusher clients call POST /broadcasting/auth before subscribing to a private or presence channel, with the socket_id the server gave them and the channel_name, as a form or as JSON. The route needs a signed-in user. It runs the matching authoriser and answers with the signature the client sends along with its subscription:

200 OK

The auth value is <key>:<hex HMAC-SHA-256 of "socket_id:channel"> under realtime.secret; for presence channels the response also has channel_data, and the signature covers it. Otherwise:

SituationResponse
No authoriser matches, it returns Deny, or the channel is public403
No signed-in user401
A missing or malformed socket_id or channel_name400 validation error, e.g. channel_name should not be empty
The authoriser returns an error500 (logged)

A subscription with a missing or wrong signature gets pusher:error 4009 (Connection is unauthorized) and is not joined; the connection stays open. To sign subscriptions from your own endpoint, call Realtime::sign(socket_id, channel, channel_data).

Presence channels

On a presence channel, each member is a user id plus the user_info your authoriser returned. Subscribers get the member list when they join ({"presence": {"ids", "hash", "count"}}), then pusher_internal:member_added and member_removed as people come and go. A user with two tabs open counts once, and leaves when their last connection does.

Rust

Broadcasting events

To send an event, resolve Arc<dyn Broadcaster> (from navree::contracts) and call broadcast(channels, event, data). It works the same in every process: the one serving the sockets delivers the event itself, and the others hand it over through the relay.

src/modules/announcements/mod.rs

When the event reports a change to your data, broadcast after the change is committed, never before: a broadcast for a change that rolls back would show users something that never happened. The CRM's deal service does it with the unit of work's after_commit hook:

crates/crm-deals/src/service.rs

Subscribers receive a Pusher frame whose data is the JSON you sent, encoded as a string:

JSON

Event names are 1 to 200 characters and may not start with pusher: or pusher_internal:; anything else is refused before it is sent. Fan-out never waits for a slow client: each connection has a bounded queue (512 frames), and a connection that falls that far behind is closed with code 4100 so it reconnects after a back-off.

Notifications sent on the broadcast channel use the same contract: they go out as notification.created on private-users.{id}.

Broadcasting from the serving process

Realtime::broadcast sends synchronously and returns how many deliveries it queued, which is handy in the process that serves the sockets:

Rust

Broadcasting from workers

The sockets live in the serve process, but queued listeners run in queue:work and scheduled tasks in schedule:work. Set realtime.relay_url so those processes hand their broadcasts to serve:

  • auto: http:// plus http.addr (a wildcard address such as 0.0.0.0 becomes loopback). Use it when workers run on the same host as serve.
  • a URL such as http://api.internal:8080: the base URL of the serving process.
  • empty (the default): broadcasts stay in the process. A worker's broadcasts then reach nobody, silently.

A process relays when it doesn't serve HTTP itself and holds no connections. Its dyn Broadcaster posts each broadcast, and each end_user_subscriptions call, to POST /apps/{app_id}/events on the serving process, which delivers it to its own connections. Errors (the server is down, answers an error, or takes longer than 5 seconds) come back from broadcast, so the caller can log them, as the CRM does.

Relayed messages are signed with HMAC-SHA-256 under a key derived from realtime.secret, or, when there is no secret, from the app key through CryptoModule's Encrypter (an APP_KEY alone, without CryptoModule in the app, doesn't count), in an X-Navree-Relay-Signature: t=<unix seconds>,v1=<hex> header. The receiver refuses a missing or wrong signature (401), a signature more than five minutes away from its own clock (401, so a captured request can't be replayed later) and another app id (404). Without a secret or an app key, the endpoint answers 404 and relay_url is ignored with a warning.

Two more things to know:

  • Maintenance mode blocks the relay. /apps/{app_id}/events is not in http.maintenance_except by default, so while the app is down, workers' broadcasts fail with 503. Add the path to the list if workers keep running during maintenance.
  • Commands warn when serve isn't running. Anything that ends subscriptions or broadcasts from a command, such as db:seed assigning roles, logs a warning for each relay attempt that can't reach serve. The command still succeeds.

Client setup

On the front end, install laravel-echo and pusher-js and point Echo at your app. Use the pusher broadcaster with your realtime.key, your API's host and port, and the auth endpoint:

lib/echo.ts
  • echo.private("crm.deals") subscribes to private-crm.deals; Echo adds the prefix. echo.join("deals.42") joins presence-deals.42.
  • Start the event name with a dot (.deal.updated), or Echo prefixes it with a Laravel namespace (App\Events\…).
  • authEndpoint must reach POST /broadcasting/auth with the user's credentials. A browser app holding a bearer token can add it with auth: { headers: { Authorization: "Bearer …" } }.

Through a backend-for-frontend

The reference front end, apps/crm-web, never lets the browser see a token: its Next.js server keeps them in httpOnly cookies (see the CRM tour). So the WebSocket goes straight to Navree, while channel authorisation goes to a same-origin route that adds the bearer token:

apps/crm-web/src/app/broadcasting/auth/route.ts

Its Echo client uses a custom authorizer that posts {socket_id, channel_name} as JSON to that route. The WebSocket host, port and key come from NEXT_PUBLIC_WS_HOST, NEXT_PUBLIC_WS_PORT and NEXT_PUBLIC_PUSHER_KEY, and NEXT_PUBLIC_REALTIME=0 turns real time off. If the socket can't connect, the app keeps working with refetch-on-focus and polling.

Allowed origins

Browsers let any page open a WebSocket to any server, so the server checks the page's Origin header. A connection is accepted when:

  • its Origin is listed in realtime.allowed_origins (exact origins such as https://crm.example.com, compared case-insensitively), or the list is ["*"];
  • the list is empty and the origin is in http.cors.allowed_origins;
  • its Origin is the server's own origin (its host matches the Host header);
  • it has no Origin at all (not a browser: a native app, a script, a test).

Any other page gets pusher:error 4009 (Origin not allowed) and the socket closes; the attempt is logged as a warning under navree::security. A connection with the wrong key in its URL gets 4001 (Application does not exist).

Connections

When a client connects, the server sends pusher:connection_established with its socket_id and the activity_timeout:

JSON

Clients keep the connection alive with pusher:ping (the server answers pusher:pong). After activity_timeout_secs of silence the server pings the client; if it stays silent for another min(activity_timeout, 30 s), the server closes the connection with 4201 so the client reconnects. Messages are limited to 64 KiB. When the app shuts down, every connection is closed with 4200 (reconnect now), and graceful shutdown waits for them.

Close or error codeMeaningClient behaviour
4001Unknown app keyDoesn't reconnect
4009Origin not allowed, a bad subscription signature (as an error, the socket stays open), or the user was disconnected by the appDoesn't reconnect
4100Too slow: the connection fell behindReconnects after a back-off
4200Reconnect now (shutdown, or the user's subscriptions ended)Reconnects at once
4201No answer to a pingReconnects

Disconnecting users when their access changes

A private subscription is authorised once, when it is made. If a user then loses a role or a permission, or signs out, they would keep receiving events they may no longer see. Navree closes their connections when that happens:

  • navree-access does it after a change commits: a user's roles or direct permissions change, a role loses permissions (for all its members) or a role is deleted;
  • navree-auth does it when a user signs out.

Both call Broadcaster::end_user_subscriptions(user_id), which also works from workers through the relay. The user's connections close with 4200; Echo reconnects and authorises each private channel again with the user's current access, so the channels they may no longer join stay closed. A failure is logged and the change itself stands.

You can do the same yourself with Realtime:

Rust

Both return the number of connections closed in this process. A connection belongs to a user once the auth endpoint has signed a channel for it on their behalf, it has joined a presence channel as them, or its WebSocket handshake was authenticated. A connection that only joined public channels belongs to nobody.

Testing

Channel authorisers run behind POST /broadcasting/auth, so test them over HTTP. TestApp installs a test authenticator when the app has none, and acting_as signs requests in:

tests/channels.rs

To check what your code broadcasts, replace dyn Broadcaster with a recorder:

Rust

The CRM's Playwright suite also checks the whole path in two real browsers: a deal moved by one user moves on another user's board without a reload (apps/crm-web/e2e/realtime.spec.ts).

Popular pages

  • Installation
  • Your first app
  • Routing and controllers
  • Validation
  • Authentication
  • Benchmarks