Manual contentsDigging DeeperBrowse 113 chapters
Manual 13 min read

Live

Suprnova Live is the framework's server-driven interaction engine. A Live component is a Rust struct whose state lives on the server, whose view is an Askama template, and whose actions run over a signed protocol from a small browser runtime that morphs the re-rendered HTML in place. There is no client-side state model to keep in sync, no build tool to install to use the shipped runtime, and no inline JavaScript in your documents.

This chapter covers the application-facing surface: authoring a component, registering it, serving documents and islands, the security boundaries every Live request crosses, uploads, asynchronous updates, assets, testing, diagnostics, and recovery. Everything here uses only suprnova::live and suprnova::view.

Quick start

A project created by suprnova new is Live-ready: it ships src/live/mod.rs with an empty component registry and a routes() function, its bootstrap binds the registry, and cmd/main.rs installs the routes. Scaffold a component, then check it:

suprnova live:make Counter
suprnova live:check

live:make writes src/live/counter.rs and templates/live/counter.html, registers the component in src/live/mod.rs, and prints the next steps. live:check builds your application and proves every registered view against the integrated checker.

Authoring a component

use suprnova::live::{LiveComponent, live};

/// A counter rendered by `live/counter.html`.
#[derive(LiveComponent)]
#[live(name = "app.counter", view = "live/counter.html")]
pub struct Counter {
    /// Current count, exposed to the view.
    #[public]
    count: u64,
}

#[live]
impl Counter {
    /// Increments the counter in response to `live:click="increment"`.
    #[action]
    pub fn increment(&mut self) {
        self.count += 1;
    }
}
  • name is the registered component name. Use a dotted, kebab-case name such as app.counter; the CLI derives <package>.<kebab>.
  • view is the template identity, relative to the template root.
  • #[public] fields are rendered and carried in the signed snapshot. #[model] fields additionally accept browser proposals through live:model.
  • #[action] methods are the only entry points the browser can invoke. They receive validated arguments and may return typed outcomes such as a redirect or a flash.

Every field type must implement Default; a fresh island starts from those defaults unless a mount hook says otherwise.

Views

Views are Askama templates. The template root is templates/ unless an askama.toml names other directories, so live/counter.html lives at templates/live/counter.html:

<div>
<p>Count: {{ count }}</p>
<button type="button" live:click="increment">Increment</button>
</div>

Directives use the closed live: grammar: live:click, live:submit, live:model, live:upload, live:key, live:loading, and the rest of the documented set. The checker proves every directive against the component: an unknown action, an unknown model field, a raw safe filter, or an accessibility violation fails live:check with the file, line, and column.

Documents that place islands are ordinary views declared with #[suprnova::view]; the only unescaped value they accept is TrustedHtml through the trusted_html filter.

Registration and bootstrap

src/live/mod.rs owns the registry and the routes:

use suprnova::live::{LiveRegistry, RegistryError};

pub mod counter;

/// Builds the registry of every Live component in this application.
pub fn registry() -> Result<LiveRegistry, RegistryError> {
    let registry = LiveRegistry::builder()
        .register::<counter::Counter>()?
        .build();
    Ok(registry)
}

Bind it during bootstrap so the server, the workers, and the suprnova live:* commands see the same components:

suprnova::App::singleton(crate::live::registry().expect("Live component registry"));

The registry is immutable once the runtime assembles. A duplicate component name or view, or a component whose actions need validation without a validation port, fails registration with a typed RegistryError.

Routes

Router::try_live() installs the reserved namespace exactly once: /__live/v1/action, /__live/v1/upload, the /__live/v1/async/* control routes and WebSocket handshake, and the immutable /__live/v1/assets/* routes. Startup fails if an application route can claim /__live.

The reserved request routes carry a strict policy: every request needs session, origin, CSRF, principal, tenant, and rate-limit facts. The framework records the session and the CSRF proof; your application attaches the rest with the route guard:

use std::sync::Arc;
use std::time::Duration;

use suprnova::live::{LiveTenantMiddleware, LiveTenantResolver};
use suprnova::rate_limit::memory::InMemoryRateLimiter;
use suprnova::{AuthMiddleware, FrameworkError, RateLimitMiddleware, Request, Router, SlidingWindowConfig, async_trait};

pub fn routes(router: Router) -> Result<Router, FrameworkError> {
    let limiter = Arc::new(InMemoryRateLimiter::new());
    router.try_live_with(|guard| {
        guard
            .middleware(AuthMiddleware::optional())
            .middleware(LiveTenantMiddleware::new(Arc::new(SingleTenant)))
            .middleware(RateLimitMiddleware::new(
                limiter,
                SlidingWindowConfig { max_requests: 600, window: Duration::from_secs(60) },
                |request: &Request| format!("live:{}", request.ip().unwrap_or_else(|| "anon".into())),
            ))
    })
}

struct SingleTenant;

#[async_trait]
impl LiveTenantResolver for SingleTenant {
    async fn resolve(&self, _request: &Request) -> Result<Option<String>, FrameworkError> {
        Ok(None)
    }
}

Install the routes from the entry point so the runtime and the mount catalog are ready before the first request:

Application::new()
    .bootstrap(bootstrap::register)
    .try_routes(|| live::routes(routes::register()))
    .run()
    .await;

Documents and islands

A document route declares its islands once, renders them through LiveDocument, and emits the bootstrap tags:

use std::collections::BTreeMap;

use suprnova::live::{CanonicalValue, LiveBootstrapOptions, LiveDocument, LiveMount, MountFlags};
use suprnova::view::{AssetSet, DocumentResponseIntent, TrustedHtml, ViewName};
use suprnova::{FrameworkError, HttpResponse, Request, Response, Router, StatusCode};

mod filters {
    pub use suprnova::view::filters::trusted_html;
}

#[suprnova::view(path = "live/page.html")]
struct Page<'a> {
    bootstrap: &'a TrustedHtml,
    counter: &'a TrustedHtml,
}

pub fn install(router: Router) -> Result<Router, FrameworkError> {
    let mount = LiveMount::<Counter>::identity_bound("/dashboard", "counter", "dashboard-counter")?;
    let handler_mount = mount.clone();
    let router: Router = router
        .get("/dashboard", move |request: Request| {
            let mount = handler_mount.clone();
            async move { render(request, &mount).await }
        })
        .middleware(AuthMiddleware::redirect_to("/login"))
        .into();
    router.try_live_mount(&mount)
}

async fn render(request: Request, mount: &LiveMount<Counter>) -> Response {
    let result: Result<HttpResponse, FrameworkError> = async {
        let mut document = LiveDocument::from_request(&request)?;
        let counter = document
            .mount(mount, CanonicalValue::Object(BTreeMap::new()), MountFlags::empty())
            .await?;
        let bootstrap = document.bootstrap(LiveBootstrapOptions::esm())?;
        document
            .render(
                ViewName::parse("live/page.html").map_err(|_| FrameworkError::internal("view"))?,
                &Page { bootstrap: bootstrap.html(), counter: counter.html() },
                DocumentResponseIntent::html(StatusCode::OK).map_err(|_| FrameworkError::internal("intent"))?,
                AssetSet::empty(),
            )
            .map_err(FrameworkError::from)
    }
    .await;
    result.map_err(|_| HttpResponse::text("Live document failed").status(500))
}
  • LiveMount::public_seed declares an island any visitor may render; its state is a reusable seed promoted to an instance on the first action.
  • LiveMount::identity_bound declares an island that belongs to the current session and principal; the document route must authenticate.
  • Mount every island before bootstrap, and call bootstrap once. The bootstrap emits the inert configuration element and the script tags for the ESM or classic strategy, adding the upload and asynchronous roles when a mounted component needs them and the Stimulus bridge on request.
  • The document template places {{ bootstrap|trusted_html }} in <head> and each island where it belongs.

Security boundaries

Live never bypasses the framework's middleware. What each request needs:

Fact Recorded by
Session SessionMiddleware
Origin and CSRF CsrfMiddleware with origin verification enabled
Principal AuthMiddleware on its authenticated branch
Tenant LiveTenantMiddleware with your resolver
Rate limit RateLimitMiddleware on its allowed branch

The shipped runtime sends the Live media type and the browser's own Sec-Fetch-Site header; it carries no session token. The CSRF middleware verifies that proof for every Live request on its own, whatever origin policy you configure, so a same-origin Live request passes with the stateless CSRF disposition while a cross-site or header-less request falls back to token validation and is refused. Ordinary routes keep token validation under the default policy; using Live relaxes nothing else:

global_middleware!(CsrfMiddleware::new());

Anonymous visitors render public seeds, and they can act on them when the guard uses AuthMiddleware::optional(): a signed-in principal is recorded, an anonymous visitor continues, and the mount kind decides. A public seed then promotes for the visitor's own session on the first action, while an identity-bound island still refuses a request without principal evidence. With AuthMiddleware::new() the guard answers 401 for every anonymous request before any engine work. Identity-bound islands require a session and a principal; the tenant is bound into the island's scope whenever your resolver names one, and a resolver that cannot determine the tenant must return an error rather than None. Every rejection is closed: a 409 for a stale or tampered snapshot carries no body, and production messages never include snapshots, tokens, cookies, or rendered HTML.

Uploads

Declare an upload policy on a model field:

use suprnova::live::{LiveComponent, UploadPolicy, UploadReplacement, UploadScan, UploadType, live};

fn avatar_policy() -> UploadPolicy {
    UploadPolicy::builder()
        .maximum_files(1)
        .maximum_file_bytes(512 * 1024)
        .replacement(UploadReplacement::RetirePrevious)
        .accept(UploadType::Png)
        .scan(UploadScan::Disabled)
        .finalize_action("save_avatar")
        .build()
}

#[derive(LiveComponent)]
#[live(name = "app.avatar-uploader", view = "live/avatar-uploader.html")]
pub struct AvatarUploader {
    #[model]
    #[upload(policy = avatar_policy)]
    avatar: String,
}

#[live]
impl AvatarUploader {
    #[action]
    pub fn save_avatar(&mut self) {}
}

The view binds the field with <input type="file" live:upload="avatar">. The runtime creates, transfers, and completes the upload through /__live/v1/upload; the file waits in quarantine until the declared finalize action runs, when the framework hands it to your UploadFinalizer. Bind the finalizer, and any scanner or validator, before the runtime assembles:

App::singleton(LiveUploadHost::new().with_finalizer(Arc::new(AppUploadFinalizer::default())));

Uploads are authorized per field and control through the gate. Define the abilities live:<component>.upload.<field>.<Control> for Create, Reacquire, Status, Queue, BeginTransfer, PutChunk, Complete, Accept, BeginFinalize, CommitFinalize, Cancel, Reject, Expire, and Fail.

A browser that lost its transfer grant reacquires it through a route your application owns outside the reserved namespace:

let router: Router = router
    .try_live_upload_reacquisition("/account/uploads/{handle}/reacquire")?
    .middleware(AuthMiddleware::new())
    .into();

The route requires the same facts as an action, answers only the session and principal that created the upload, and returns a fresh grant with the current transfer state.

Asynchronous updates

A component declares the streams it listens to; the browser runtime subscribes over SSE or WebSocket and falls back to polling:

use suprnova::live::{EventPayloadMetadata, LiveComponent, live};

pub struct ActivityPosted;

impl EventPayloadMetadata for ActivityPosted {
    const NAME: &'static str = "activity.posted";
    const VERSION: u16 = 1;
}

#[derive(LiveComponent)]
#[live(
    name = "app.activity-feed",
    view = "live/activity-feed.html",
    minimum_protocol_version = 2,
    streams(stream(name = "activity", topics("activity"), events(ActivityPosted)))
)]
pub struct ActivityFeed {
    #[public]
    headline: String,
}

Define the ability live:<component>.stream.<name> for subscribers, then publish from anywhere in the application:

let streams = LiveStreams::resolve()?;
streams.event::<ActivityPosted>("activity", LiveEventTarget::Island, payload).await?;
streams.refresh("activity").await?;

A refresh tells subscribed islands to fresh-render; an event is delivered to the island's registered handlers. Polling is the ordinary fresh render: the island's state catches up whenever a transport is unavailable, but event payloads published in between are not replayed to their handlers, which the runtime reports as a degraded stream rather than a current one. A component that declares exactly one stream gets its island root subscribed for it; a component with several streams subscribes each through the runtime's registered calls.

Assets and no-build use

The framework serves the exact reviewed runtime artifacts at /__live/v1/assets/<identity>/<file> with immutable caching, strong validators, and integrity attributes in the bootstrap tags. A strict script-src 'self' policy holds because documents contain no inline script. To publish the same bytes to a CDN or a static directory:

suprnova live:assets --out public/__live

The publication is atomic and refuses to replace a directory whose bytes differ unless you pass --replace.

Testing

suprnova::live::testing prepares a router's runtime and mount catalog for in-process tests. The application tests in app/tests/live_*.rs show the complete pattern: an in-memory database, a seeded session cookie, the real global middleware stack, and requests through handle_request:

let router = app::live::routes(app::routes::register())?;
let runtime = prepare_live_router_for_test(&router)?;
App::singleton(runtime.clone());

Decode an island's snapshot from its data-suprnova-live-snapshot attribute, post an action with the session cookie and Sec-Fetch-Site: same-origin, and assert on the accepted render. A stale snapshot answers 409 with an empty body; a missing principal answers 401.

Diagnostics and operations

  • suprnova live:check proves every registered view; --allow-unproved accepts dynamic structures the checker deliberately makes no claim about.
  • suprnova live:inspect reports the bound registry, configuration limits, installed upload capabilities, assembled runtime services, and the asset identity without exposing state or secrets.
  • LiveConfig bounds request and response bytes and the trusted context lifetime; bind a custom one before the runtime assembles.
  • Errors carry closed kinds such as live_document_context_rejected and invalid_live_bootstrap; telemetry labels are closed enumerations.

Recovery

  • A 409 tells the runtime to fresh-render the island; the operation is not replayed.
  • A closed asynchronous transport is retired and the runtime reconnects with a new transport generation; a stale generation is refused.
  • A session that expires or rotates invalidates identity-bound work; the application exposes its sign-in path and the visitor continues from a fresh document.

Live runs complete without RenderCache. Caching Live documents is RenderCache's job; see RenderCache.

CLI reference

Command Purpose
suprnova live:make <name> Scaffold a component and its view and register it
suprnova live:check Prove every registered view with the integrated checker
suprnova live:inspect Report safe runtime, registry, provider, and artifact state
suprnova live:assets --out <dir> Publish the reviewed runtime artifacts atomically