Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Recoverable server-driven Live UI

The v13 prerelease (13.0.0-alpha.1) adds rullst::live::recovery in the existing Core and facade, with no extra feature or browser dependency. It is an explicitly mounted native-server API; the browser uses the supplied small ES module. Protocol and Chromium acceptance have passed. Hosted workspace/platform and extracted-package source admission passed in PR #236. Final release admission remains separate.

The older LiveComponent, Live::mount and make:live example keep their per-connection state and HTMX WebSocket protocol. Their live_ws_handler now limits incoming frames and messages to 64 KiB (previously the 64 MiB transport default) and ignores Ping/Pong keepalive frames instead of ending the session, but still has no origin check, connection cap or idle timeout. Adopting the recovery API is an application change; upgrading a dependency does not migrate those components.

Application contract

Implement RecoverableLiveView with three asynchronous methods:

MethodRequired application behavior
authorizeRecheck the captured authenticated session, expiry, current tenant membership and permission for this exact component/resource. Return Unauthorized on revocation; storage failure is an error.
snapshotRead authoritative state and render a complete LiveSnapshot with its persistent monotonic u64 revision. Escape untrusted values before including them in HTML.
applyValidate the requested action, fields and current write permission. Atomically compare command.expected_revision() with the domain revision in the same transaction as the mutation, then advance it. On mismatch return Conflict without writing.

LiveScope binds an authenticated tenant, account and component key, not a browser-supplied role or identity. Construct it from verified server state and TenantMembership::select. Scope construction alone does not authorize access. The implementation uses static dispatch and mounts no routes automatically.

For SQL, a bound update such as UPDATE ... SET revision = revision + 1 WHERE tenant = ? AND id = ? AND revision = ? belongs inside the domain transaction, alongside any required current authorization check. Check the affected row count before reporting success. A separate read followed by an unconditional write cannot protect two simultaneous sockets. Never interpolate form fields into SQL identifiers or treat a field named tenant as the trusted scope.

Route and browser composition

This adapter receives a scope and view already resolved by the host’s authenticated route. It is not an authentication extractor:

#![allow(unused)]
fn main() {
use rullst::live::recovery::{
    LiveRecovery, LiveRecoveryConfig, LiveScope, RecoverableLiveView,
};
use rullst::web::axum::{
    extract::WebSocketUpgrade, http::HeaderMap, response::Response,
};

fn configured_live() -> Result<LiveRecovery, rullst::live::recovery::LiveRecoveryError> {
    Ok(LiveRecovery::new(
        LiveRecoveryConfig::try_new("https://academy.example")?,
    ))
}

async fn authorized_upgrade<C: RecoverableLiveView>(
    shared: &LiveRecovery,
    websocket: WebSocketUpgrade,
    headers: &HeaderMap,
    trusted_scope: LiveScope,
    authenticated_view: C,
) -> Response {
    shared.upgrade(websocket, headers, trusted_scope, authenticated_view).await
}
}

Create the handler once in application state; its clones share admission limits. Use Server or apply_security_baseline for the HTTP routes and enforce ordinary application authentication/ownership. Serve LIVE_RECOVERY_MODULE at an explicit same-origin JavaScript route with a JavaScript content type. Load a separate application module under the site’s CSP; no inline-script exemption is needed. The browser application module can contain:

import { connectLive } from '/assets/rullst-live.js';

const live = connectLive(
  document.getElementById('lesson-view'),
  '/live/lesson',
  document.getElementById('connection-status'),
);
// A false return means the action was not sent. There is no offline queue.
// live.send('save-answer', { answer: '42' });

The root and optional status element must already exist. Render <button type="button" data-live-action="increment"> for an action without fields, or a form with data-live-action="save-answer" and named string inputs. Duplicate field names and file inputs are rejected. Submit buttons delegate to the form; the named fields, rather than the submit button value, form the command. Only one action can be pending. Controls with data-live-action are disabled while unavailable or awaiting a response; programmatic send enforces the same admission even when a form’s submit button has no such attribute.

Every accepted snapshot replaces the root’s HTML. This is not DOM diffing: unsent edits, focus, selection and root-child event listeners may be replaced. Keep unsaved drafts outside this root if the application needs to preserve them, and use delegated listeners. Server-rendered HTML is trusted content; the transport is not a sanitizer. Never include secrets, executable user HTML or unescaped form values in a snapshot.

Recovery and outcomes

On connection or reconnection, the server sends current full state. Commands use rullst.live.v1, a random 32-hex correlation ID and a decimal-string revision to avoid JavaScript integer rounding. A stale command yields a fresh snapshot with conflict. A successfully committed action yields applied and a newer revision.

The browser emits rullst:live-state with { state, pending } and rullst:live-result with { id, outcome }. Outcomes are recovered, applied, conflict or unknown. If the connection fails while an action is pending, unknown means it may already have committed. The client never automatically replays it. Recover authoritative state before allowing a person to retry an external effect; use separate durable domain idempotency for payments or email. The correlation ID alone does not supply deduplication.

Reconnect attempts use capped exponential backoff with jitter (250 milliseconds to a base cap of ten seconds, at most twelve consecutive failed attempts). A received valid snapshot resets the retry budget. Handshake/action waits have a thirty-second browser deadline. reconnect() explicitly retries with a fresh budget; dispose() removes listeners and closes the socket. Recovery converges to state, not replay of every missed transient event or automatic broadcast of changes made by other clients. A still-connected client learns other changes on its next action/conflict or explicit reconnect.

Origin must match the configured HTTPS origin exactly; omitted, multiple, null and foreign origins fail. The only offered subprotocol must be rullst.live.v1. For local fixtures, loopback_for_tests permits HTTP at a literal loopback IP. The production route must not infer this configuration from an untrusted Host or forwarded header. Use secure application cookies and trusted TLS/proxy setup.

Authorization runs before upgrade, before domain actions, after domain I/O and before sending each snapshot, and periodically while connected. An observed revocation closes with 4401; the client clears its root and reports denied. Changing or clearing a browser cookie alone cannot revoke an existing socket: revoke its captured server session. Already-committed actions are not rolled back by later revocation. A race after the final authorization check cannot recall bytes already sent; the host owns transaction-level permission checks.

HTTP upgrade failures are intentionally opaque to browser WebSocket clients; they use bounded reconnect and eventually unavailable. Ordinary disconnects can retain the previously displayed HTML. Do not interpret that cached display as a current authorization decision. An explicit forbidden/invalid close clears it; applications can also clear their view on logout or any disconnected state.

Bounds and deployment

ResourceDefault and hard boundary
Shared connections128; configurable 1–1,024 per cloned handler family
Actions per connection128; configurable 1–1,024, then reconnect required
Callback or sendFive seconds; configurable 100 milliseconds–ten seconds
Session revalidationEvery five seconds; configurable 100 milliseconds–thirty seconds
Connection lifetimeOne hour; configurable one second–one hour
Command/frame16 KiB; 32 unique fields, each name/action at most 64 ASCII bytes, each value at most 2 KiB
Snapshot64 KiB UTF-8 HTML; encoded response at most 512 KiB
Socket buffers16 KiB read/write buffer targets; 1 MiB maximum write buffer

The server pings periodically and closes a peer that stays silent for more than three revalidation intervals of waiting. Time the server spends in its own callbacks and sends, when it does not read the socket, is not counted as the peer’s silence. Domain callbacks must perform asynchronous, cancellation-safe work; a Tokio timeout cannot preempt blocking CPU work or prove that a canceled database operation rolled back. Limits bound this handler, not all process memory or aggregate users across replicas. Use ingress/per-account rate limits for repeated reconnects and infrastructure-level abuse controls.

Oversized frames terminate the connection before domain actions. Rejecting the payload at the transport limit can reset TCP before a close frame reaches the peer; clients must not depend on receiving an application close code in that case.

Application persistence and shared revision/permission authority are required for restart or cross-replica recovery. Neither this handler nor the browser persists domain state. Keep revision epochs consistent when restoring backups. Configure the proxy for WebSocket upgrades and suitable idle timeouts; TLS, proxy behavior, replication and failover need deployment-specific validation. This candidate does not add WebSocket ticket issuance, frame encryption, DOM patching, an offline mutation queue or automatic starter migration.

Executable acceptance

The protocol suite uses actual WebSockets, a parameterized SQLite domain transaction and the production HTTP baseline. It tests concurrent revision conflicts, uncertain committed actions, origin/session/tenant denial, revocation before disclosure, binary/oversized input, admission, idle peers, action limits, lifetimes and callback deadlines.

The Chromium journey exercises two pages, offline/online transitions, exactly one send of an uncertain action, read-only learner permissions, CSRF-protected logout and recovery after killing and restarting the actual application process against the same database. It is explicitly executed in the existing Linux workspace and coverage jobs. These disposable fixtures use no real account credentials. They establish local behavior, not release admission or a guarantee for every proxy/browser/deployment.