.offrails/generators/channel.md
Generator: channel (live updates, rooms, presence)
TL;DR
- With the
realtimepart:cargo offrails g channel postwritessrc/channels/post.rs(topicpost:<id>,authorizeon every join and push: the post's owner whenpostis a scoped model, else only the person's ownpost:<their id>;broadcast(&s, id, event, &data)), its arm inChannels::authorize, andtests/app/post_channel_test.rs. Broadcast after the commit; subscribe the page withstatic/channels.js.- Signal (the default):
channel book:{id} reviews-changed→ a broadcast in the model fn, the stream's authorization, and a live region that re-fetches.- Room (
--room [--presence]) → anoffrails.v1channel with typedIn/Outmessages, join/push authorization and resume.- Use a room only when the client talks back or needs presence; otherwise a signal.
Read the rest when adding live updates, chat or presence.
channel book:{id} reviews-changed # signal: "viewers see new reviews appear without refreshing"
channel book:{id} --room --presence # room: "readers chat about the book and see who's reading"Pick the kind first (channels.md#when-to-use-which):
- the page only needs to know something changed, so it re-fetches → a signal (this recipe, below; realtime.md);
- the client sends events (chat, typing, moves), or the page shows who's here → a room (the room recipe).
Both were executed in the reference app (htmx) on 2026-10-12.
Signal recipe
- Spec: add an acceptance criterion such as "Readers looking at a book see new reviews appear without refreshing" (
AC-reviews-<n>) to the feature spec. - First channel only:
sea-orm-cli migrate generate create_broadcasts(id,stream,event,created_at), then migrate and prove rollback;src/kernel/pubsub.rsper the spec;pubsub: PubSub::new(db.clone())inAppState::new;- in
main.rsserve: start realtime (pubsub.start(), pluscluster::startwithAPP_CLUSTER) only once migrations are current: ifmigrations::pendingis empty, start it before building the router and fail fast on error; otherwise spawn a task that waits with the worker'swait_for_migrationsloop and starts it then (realtime). Never.await?a table read before the listener is up: on a fresh database the app crashes withno such table: broadcastsinstead of showing the pending-migrations page. Putpubsub.close()inside the graceful-shutdown future; pubsub::delete_older_than(db, 1 day)inCleanupJob;src/channels/mod.rswithstream+authorize_stream, the route/streams/{stream},paths::stream(name), and aROUTESrow withAccess::User.
- This channel:
src/channels/<plural>.rswithauthorize(s, user, id): load the record, thenpolicies::authorize(policies::<plural>::can_view(..));- a new arm in
authorize_stream(Some(("book", id)) => books::authorize(..)); - the stream-name function in the model (
review::stream(book_id)) and the event const (review::CHANGED = "reviews-changed").
- Broadcast from the model fns that change what viewers see (create, update, destroy):
pubsub::broadcast(&txn, &stream(book_id), CHANGED), inside the transaction, after the write succeeded. - Live region, by mode:
htmx:- a partial (
templates/<plural>/_list.html) whose root carrieshx-get,hx-trigger="sse:<event>, sse:refresh"andhx-swap="outerHTML", included inside<div hx-ext="sse" sse-connect="…">; - a GET route that returns the fragment for
HxRequest(redirect otherwise), e.g.GET /books/{book_id}/reviews→reviews::index; - the sse extension script via
{% block head %}.
- a partial (
inertia: auseEventStream(path, { "<event>": () => router.reload({ only: ["<prop>"] }), refresh: () => router.reload() })hook inweb/src/lib/.spa: auseStreamhook callingqueryClient.invalidateQueries(…).
- Tests (
tests/app/<plural>_test.rsandkernel_pubsub_test.rsfor the first channel): the cases in realtime.md#testing. Useopen_stream,poll_onceandnext_event, never sleeps. - Real-browser smoke: two contexts. One watches the page, the other makes the change, and the watcher sees it without a reload. Repeat after a Back/Forward on the watcher (test-standards §9).
Room recipe (--room)
Implements guides/channels.md.
-
Spec first:
product/features/<topic>_room.mdwith AC IDs for:- presence: join and leave, two tabs counting as one;
- delivery and storage;
- validation reasons;
- replay after a reconnect;
- who may join (logged out, private topics);
- the per-person rate limit;
- text shown as typed.
Ask the feature-level questions first: who can join, is anything stored, who can delete.
-
First room only (the kernel):
cargo add axum --features wsandcargo add --dev tokio-tungstenite@<the version axum resolved>. CheckCargo.lockso the test client doesn't compile a second tungstenite;src/kernel/hub.rs,src/kernel/channels.rsandsrc/kernel/cluster.rsper the spec, plus thecluster_nodesmigration. That migration is cheap and keeps step 3 below free later;hub: Hub::new(config.node.clone().unwrap_or_else(node_id))inAppState;APP_NODEandAPP_CLUSTERinConfigand.env.example;- in
serve:kernel::cluster::start(..)whenconfig.cluster, andhub.close()in the graceful-shutdown future; src/channels/mod.rs: thesocket,socket_sse,socket_poll_open,socket_pollandsocket_sendhandlers (viaSessionAndUser),member_for(user, session), theAppRoomenum withimpl Room(typedIn/Out), andimpl Channels for AppState(authorize+session_valid);- the routes
/socket,/socket/sse,/socket/sse/{conn},/socket/poll,/socket/poll/{conn}(GET+POST),paths::socket(), andROUTESrows withAccess::User; - session revocation:
session::destroy/destroy_all_for_userwrite themember:<id>signal;hub.watch_revocations(..)andhub.log_frames(..)inAppState::new;APP_REALTIME_REVALIDATE_SECSandAPP_REALTIME_TRANSPORTSinConfig; static/channels.js(htmx), loaded in the layout beforeapp.js, orweb/src/lib/socket.ts(TS modes, typed by the rooms' exportedIn/Out);/dev/socketswith its tab (transport column and frame log);Ws(offrails.v1),serve,session_cookieandsession_forintests/app/support.rs, plustests/app/wire_test.rs(conformance and security, once per app).
-
This room:
- data: a migration and model for anything durable (messages), with a
(parent_id, id)index for replay; - room code in
src/channels/<topic>_room.rs:- state (the newest N items in a
VecDeque, and acompleteflag); - typed
In/Outenums (#[serde(tag = "event", content = "data")], plusTSin TS modes); load,joined(from memory when it coverssince),joined_from_db, both returningJoined { data, replay: (seq, Out), gap };handle_in(msg: In), in this order: rate limit → validate → insert → memory →out.emit(row_id, Out::…)→ reply; errors areRejection { code, message };
- state (the newest N items in a
- wiring:
- an
AppRoomvariant and its arms; - an arm in
authorizethat reuses the page's policy;
- an
- the page:
- the server renders the newest N items with
data-id; - the client joins with
{ since: max(data-id) }; - put
hx-boost="false"on the room's form.
- the server renders the newest N items with
- data: a migration and model for anything durable (messages), with a
-
Tests: every case in channels.md#testing for this room. If the app runs on Postgres in production, the cluster case still applies to the kernel.
-
Real-browser smoke:
- two contexts: avatars both ways, a live message rendered as literal text, and leaving through a boosted link;
- then once by hand: restart the server with both pages open, and check they reconnect and fill in what they missed.