Repository navigation
Rust off Rails 0.4.0
What's new
- Realtime has its own wire protocol, offrails.v1: typed channels, self-describing JSON frames and stable error codes, replacing the Phoenix v2 format.
- Nothing is lost while a tab is disconnected: signals carry ids, pages render a cursor, and reconnects replay what was missed. Live regions also keep working after a boosted navigation.
- Automatic transport fallback: WebSocket, then SSE + POST, then long-polling.
- Connection security: logout, a password change or a revocation closes that session's sockets and streams at once; pushes are re-authorized, and each user has connection and join caps.
- Upgrade from any agent:
offrails upgradelaunches Claude Code, opencode or Codex with the agent-neutral upgrade recipe, and vendoring uses anchored excludes from a release tag. - Magic-link sign-in verified by a fresh agent's build, with its doc findings fixed.
Breaking changes
- Apps with channels: client and server must switch to offrails.v1 together; old clients get
byeprotocoland close 1002. Rooms become typedIn/Outenums. - Apps with SSE:
Signalgains anid, pages withsse-connectrender a?since=cursor, and the sse extension moves from a page's head block into the layout. - New settings
APP_REALTIME_REVALIDATE_SECSandAPP_REALTIME_TRANSPORTS. - Smaller fixes to adopt: raw SQL in controllers and tasks moves into
kernel::db, panics render the 500 page, the pending-migrations guard goes after the fallback, and/dev/mailersanswers 301.
Upgrading
The two numbered App migration lists below (realtime, steps 1–7; the Linkdrop fixes, steps 1–7) and the vendoring note. The procedure is in upgrade.
Full list of changes
Realtime gets its own wire protocol, offrails.v1: typed channels, sequence-based resume after downtime (WebSocket since, SSE Last-Event-ID), automatic fallback to SSE + POST and then long-polling, and connection security (logout or a password change closes that session's sockets and streams; per-user caps; re-authorized pushes). The security hardening was merged with it and re-verified end to end in the reference app (233 tests on Postgres, ZAP 0 warnings, cargo-deny, gitleaks, browser smoke). Apps can be upgraded from any agent with offrails upgrade (Claude Code, opencode, Codex) and the agent-neutral generators/upgrade.md; vendoring now uses anchored excludes from a release tag. The magic-link accounts variant is verified by a fresh agent's build (Linkdrop), and its 24 doc findings are fixed below.
Realtime: the Off Rails Wire protocol (offrails.v1), resume after downtime, transport fallback, connection security
Spec: guides/channels.md (protocol, transports, security), guides/realtime.md (resume). Verified in the reference app: 223 tests on SQLite and Postgres 18 (39 realtime: conformance, security, all three transports, a 3-node failover), two Playwright specs (a server restart with changes made while it was down; WebSocket → SSE → long-polling), and a two-context Chrome session.
- Fixed: signals sent while a tab was disconnected were lost (a deploy, a restart, a blip): live regions silently went stale. Every signal now carries its id; pages render a cursor (
?since=); reconnects replay what was missed (orrefresh);app.jscarries the cursor across htmx-ext-sse's reconnects. - Fixed: live regions were dead after a boosted navigation. The sse extension was loaded in a page's own
<head>, which a boosted swap never runs. It's in the layout now. - Our own protocol, offrails.v1 (replaces the Phoenix v2 array format): negotiated subprotocol, self-describing JSON object frames (
join/leave/push/pingup;ok/err/event/presence/pong/slow/byedown), stable error codes,byebefore every close,seqresume, WebSocket ping control frames instead of app heartbeats. - Typed channels: rooms declare
In/Outserde enums (#[derive(TS)]in TS modes); undeclared events are refused before the room runs. - Transport fallback: WebSocket → SSE + POST → long-polling, chosen automatically, remembered per tab, upgraded back when possible.
APP_REALTIME_TRANSPORTSlists what the server offers. - Connection security: logout, password change and revocation close that session's sockets and streams at once (4401), across processes and nodes; periodic revalidation closes expired sessions (
APP_REALTIME_REVALIDATE_SECS); pushes re-authorized against current state; per-user caps (connections, topics, joins/min); presence never carries emails; cross-site refused on every transport. /dev/socketsshows each connection's transport and a live decoded frame log (development only).
App migration (reconcile, don't re-create; apps without channels do steps 1–3 only):
- SSE resume. Detect
pub struct Signal { pub stream, pub event }insrc/kernel/pubsub.rs. Final shape:Signal { id, stream, event }(row id), pluspubsub::high_waterandpubsub::missed(realtime.md#kernel-spec). Converge: add the field (set it inpoll_once), add the two functions; inchannels::streamtakeQuery<StreamQuery>+HeaderMap, subscribe first, replaymissedwith ids (orrefresh), sendreadywith the cursor, add.id(sig.id)to live events, skip only exact replayed ids. No migration (thebroadcasts.idcolumn already exists). - Pages render a cursor. For every page with
sse-connect: readpubsub::high_water(&db)before the page's data and rendersse-connect="…?since={cursor}". Add thecreateEventSourceoverride and thebyelistener tostatic/app.js(realtime.md#client-side). Inertia/SPA: pass the cursor as a prop and use it in?since=. - The sse extension in the layout. Detect
htmx_sseinside a template's{% block head %}: move the script tag intotemplates/layouts/application.html(after htmx) and delete the block. Add the "every page loads the live-update extension" test. - Channels: switch client and server together (the formats are incompatible, and old clients now get
byeprotocol"subprotocol offrails.v1 required" + close 1002). Replacekernel/hub.rs,kernel/channels.rsandstatic/channels.js(orweb/src/lib/socket.ts) per channels.md; convert each room to typedIn/Outenums,joined(.., since)returningJoined,handle_in(.., msg: Self::In, out: &mut Outbox<Self::Out>)returningResult<Payload, Rejection>,out.emit(seq, …); rename events on the page (ch.channel(topic, { since }),ch.on("<out event>"),ch.push("<in event>"), errors are{code, message}).cluster.rsonly changesStringerrors toRejection. - Connection security.
Membergains#[serde(skip)] session: Option<i64>; addSessionAndUser;session::destroy/destroy_all_for_userwrite themember:<id>/session-endedsignal;AppState::newcallshub.watch_revocations(pubsub.subscribe())andhub.log_frames(is_development);Channelsgainssession_valid; the SSE stream ends withbyeonsession_ended. Config:APP_REALTIME_REVALIDATE_SECS(60),APP_REALTIME_TRANSPORTS(all), inConfigand.env.example. - Fallback transports. Add the four routes (
/socket/sse,/socket/sse/{conn},/socket/poll,/socket/poll/{conn}GET+POST) withROUTESrows, and the handlers callingkchannels::sse_open/relay_post/poll_open/poll_get. - Tests: port
support::Wsto offrails.v1, addwire_test.rsand the resume/session cases inkernel_pubsub_test.rs, andtests/e2e/realtime.spec.ts(channels.md#testing).
Upgrading apps from any agent
generators/upgrade.md: the upgrade recipe, agent-neutral (Claude Code, Codex, opencode, Cursor)./offrails:upgradenow runs it.offrails upgrade [--check]in an app: compares content fingerprints with the installed framework, then launches Claude Code (/offrails:upgrade), opencode or Codex with the recipe, or prints the prompt.offrails newsupports opencode too.- Vendoring: the
rsyncexcludes are anchored (/skills,/hooks,/evals,/brand…), sotools/brand/is kept, and--deleteremoves files dropped upstream. Vendor a release tag, never a moving working tree..github/is vendored on purpose, so fingerprints match the release.
App migration: none required. Next time you upgrade, vendor with the anchored excludes (generators/new.md step 1); tools/brand/ reappears in .offrails/.
Fixes from the Linkdrop build (a fresh agent, magic-link accounts)
kernel::db::ping(for/up) andkernel::db::drop_idle_test_databases(fortest_db_cleanup): no raw SQL outside the kernel.- Panics render the standalone 500 page via
CatchPanicLayer::custom(error::panic_response). - Pending-migrations guard is layered after the fallback, so unknown paths show the page too.
/dev/mailersanswers 301 explicitly (Redirect::permanentis 308).- Magic links:
consumeis one transaction returning user, session and token; first-login user creation usesON CONFLICT DO NOTHING(a unique violation aborts a Postgres transaction; a general rule in models.md);Option<CurrentSession>needsOptionalFromRequestPartsin axum 0.8; token pages sendCache-Control: no-storeandReferrer-Policy: no-referrer. The variant is now verified (168 tests, both databases, real browser). - htmx: a 422 re-render sends
HX-Push-Url: false, so the address bar keeps the form's URL. - Error pages load
app.js, so Forward from a 404 works; test-standards §9 checks it. - Tests:
wait_until(what, f: impl AsyncFn() -> bool);enqueuedreturns(job::Model, W); per-email rate limits are tested across IPs withAPP_TRUSTED_PROXY+X-Forwarded-For. - Docs:
#[rustfmt::skip]onROUTES; kernel pages use noconfirm()dialogs (data-confirmpattern for app pages); the e2e package arrives with the first critical flow; port checks withlsof -sTCP:LISTEN; direct TLS is added on first use; dates are UTC; guidance for interview answers relayed by another agent.
App migration (reconcile, don't re-create):
- Detect:
grep -rn "execute_unprepared" src/controllers src/tasks. Converge: move each intokernel::db(ping,drop_idle_test_databases) and call those. - Detect:
CatchPanicLayer::new()insrc/routes.rs, or aCatchPanicLayer::custom(|_| …)closure that ignores the panic payload or doesn't rendererrors/500.html. Converge:CatchPanicLayer::custom(error::panic_response), renderingerrors/500.html; add a test that a panicking handler returns the 500 page. - Detect:
migrations::guardis layered before.fallback(...)insrc/routes.rs(structural), or, with the app running and a pending migration,GET /no-such-pathreturns 404 instead of the pending page. Converge: applymigrations::guardafter.fallback(...). - Detect:
GET /dev/mailersreturns 308. Converge: return 301 explicitly. - Magic-link apps: detect a sign-up path that catches a unique violation inside a transaction. Converge:
on_conflict_do_nothing_on([Column::Email])+ select. Addno-storeandReferrer-Policy: no-referrerto the token pages, with a test. - htmx apps: detect a 422 re-render without
HX-Push-Url: false. Converge: set it once, at the end oferror::shape_errors(it sees every 422, whether a handler rendered it orAppError::Invaliddid, and covers future forms); add a request test asserting the header on a 422 from an htmx request. - Detect:
templates/errors/*.htmlwithout/static/app.js. Converge: add<script src="/static/app.js" defer></script>; extend the browser smoke with Forward from a 404.