Skip to content

Rust off Rails 0.4.0

Choose a tag to compare

@danielcj danielcj released this 13 Oct 02:39
· 142 commits to main since this release

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 upgrade launches 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 bye protocol and close 1002. Rooms become typed In/Out enums.
  • Apps with SSE: Signal gains an id, pages with sse-connect render a ?since= cursor, and the sse extension moves from a page's head block into the layout.
  • New settings APP_REALTIME_REVALIDATE_SECS and APP_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/mailers answers 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 (or refresh); app.js carries 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/ping up; ok/err/event/presence/pong/slow/bye down), stable error codes, bye before every close, seq resume, WebSocket ping control frames instead of app heartbeats.
  • Typed channels: rooms declare In/Out serde 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_TRANSPORTS lists 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/sockets shows 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):

  1. SSE resume. Detect pub struct Signal { pub stream, pub event } in src/kernel/pubsub.rs. Final shape: Signal { id, stream, event } (row id), plus pubsub::high_water and pubsub::missed (realtime.md#kernel-spec). Converge: add the field (set it in poll_once), add the two functions; in channels::stream take Query<StreamQuery> + HeaderMap, subscribe first, replay missed with ids (or refresh), send ready with the cursor, add .id(sig.id) to live events, skip only exact replayed ids. No migration (the broadcasts.id column already exists).
  2. Pages render a cursor. For every page with sse-connect: read pubsub::high_water(&db) before the page's data and render sse-connect="…?since={cursor}". Add the createEventSource override and the bye listener to static/app.js (realtime.md#client-side). Inertia/SPA: pass the cursor as a prop and use it in ?since=.
  3. The sse extension in the layout. Detect htmx_sse inside a template's {% block head %}: move the script tag into templates/layouts/application.html (after htmx) and delete the block. Add the "every page loads the live-update extension" test.
  4. Channels: switch client and server together (the formats are incompatible, and old clients now get bye protocol "subprotocol offrails.v1 required" + close 1002). Replace kernel/hub.rs, kernel/channels.rs and static/channels.js (or web/src/lib/socket.ts) per channels.md; convert each room to typed In/Out enums, joined(.., since) returning Joined, handle_in(.., msg: Self::In, out: &mut Outbox<Self::Out>) returning Result<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.rs only changes String errors to Rejection.
  5. Connection security. Member gains #[serde(skip)] session: Option<i64>; add SessionAndUser; session::destroy/destroy_all_for_user write the member:<id> / session-ended signal; AppState::new calls hub.watch_revocations(pubsub.subscribe()) and hub.log_frames(is_development); Channels gains session_valid; the SSE stream ends with bye on session_ended. Config: APP_REALTIME_REVALIDATE_SECS (60), APP_REALTIME_TRANSPORTS (all), in Config and .env.example.
  6. Fallback transports. Add the four routes (/socket/sse, /socket/sse/{conn}, /socket/poll, /socket/poll/{conn} GET+POST) with ROUTES rows, and the handlers calling kchannels::sse_open / relay_post / poll_open / poll_get.
  7. Tests: port support::Ws to offrails.v1, add wire_test.rs and the resume/session cases in kernel_pubsub_test.rs, and tests/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:upgrade now 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 new supports opencode too.
  • Vendoring: the rsync excludes are anchored (/skills, /hooks, /evals, /brand…), so tools/brand/ is kept, and --delete removes 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) and kernel::db::drop_idle_test_databases (for test_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/mailers answers 301 explicitly (Redirect::permanent is 308).
  • Magic links: consume is one transaction returning user, session and token; first-login user creation uses ON CONFLICT DO NOTHING (a unique violation aborts a Postgres transaction; a general rule in models.md); Option<CurrentSession> needs OptionalFromRequestParts in axum 0.8; token pages send Cache-Control: no-store and Referrer-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); enqueued returns (job::Model, W); per-email rate limits are tested across IPs with APP_TRUSTED_PROXY + X-Forwarded-For.
  • Docs: #[rustfmt::skip] on ROUTES; kernel pages use no confirm() dialogs (data-confirm pattern for app pages); the e2e package arrives with the first critical flow; port checks with lsof -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):

  1. Detect: grep -rn "execute_unprepared" src/controllers src/tasks. Converge: move each into kernel::db (ping, drop_idle_test_databases) and call those.
  2. Detect: CatchPanicLayer::new() in src/routes.rs, or a CatchPanicLayer::custom(|_| …) closure that ignores the panic payload or doesn't render errors/500.html. Converge: CatchPanicLayer::custom(error::panic_response), rendering errors/500.html; add a test that a panicking handler returns the 500 page.
  3. Detect: migrations::guard is layered before .fallback(...) in src/routes.rs (structural), or, with the app running and a pending migration, GET /no-such-path returns 404 instead of the pending page. Converge: apply migrations::guard after .fallback(...).
  4. Detect: GET /dev/mailers returns 308. Converge: return 301 explicitly.
  5. 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. Add no-store and Referrer-Policy: no-referrer to the token pages, with a test.
  6. htmx apps: detect a 422 re-render without HX-Push-Url: false. Converge: set it once, at the end of error::shape_errors (it sees every 422, whether a handler rendered it or AppError::Invalid did, and covers future forms); add a request test asserting the header on a 422 from an htmx request.
  7. Detect: templates/errors/*.html without /static/app.js. Converge: add <script src="/static/app.js" defer></script>; extend the browser smoke with Forward from a 404.