Manual contentsDigging DeeperBrowse 126 chapters
Manual 42 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. A model field declares its timing on the attribute, such as #[model(debounce = 250)]; a debounce is 100, 250, or 500 milliseconds, the durations live:model.debounce.<n>ms accepts, and any other fails to compile. A form submitted with live:submit proposes every model control inside it in one request, which carries up to 127 fields beside the action; live:check refuses a larger form. A proposal the field cannot decode, such as an empty number for a u64 field, is a validation error on that field: the action does not run, the field keeps its value, and live:error shows the error.
  • #[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.
  • #[session] fields live in the visitor's session, not in the snapshot. Each request that runs the component for one visitor reads them from the session, and a request whose outcome is accepted writes them back; a failed action leaves the session as it was. A public seed, which every visitor shares, renders them with their defaults. They need SessionMiddleware on the page and on the Live routes.

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>

The view sees every field that is not secret by its name, such as {{ count }}, and the component itself as component, so {{ component.total() }} calls a method. A field may be named component too: the view then reads the field as component and the component as component_, or as the first of component__, component___, and so on that no field takes.

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. live:key names an element's stable identity across morphs, and it is the one key attribute a template writes: the runtime reads it for morph identity, for morph controls such as live:preserve.self, and for the scopes that keep browser state; data-suprnova-live-key is the engine's own spelling on the roots it renders. live:key values and element ids inside an island use one alphabet, the one the runtime checks at every morph: an ASCII letter or digit first, then letters, digits, _, -, ., and :, at most 128 bytes, each unique in the island. live:check refuses a literal key or id outside it, and a literal id inside a loop, which every item after the first would repeat.

Action arguments

An action directive can pass literal arguments to an action with parameters. Write them after the action name, in the order the action declares its parameters:

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

/// A task list rendered by `live/tasks.html`.
#[derive(LiveComponent)]
#[live(name = "app.tasks", view = "live/tasks.html")]
pub struct Tasks {
    /// Titles of the tasks still open.
    #[public]
    open: Vec<String>,
    /// Whether finished tasks stay visible.
    #[public]
    show_done: bool,
}

#[live]
impl Tasks {
    /// Removes the task at `index`, from `live:click="remove(0)"`.
    #[action]
    pub fn remove(&mut self, index: u64) {
        if let Ok(index) = usize::try_from(index)
            && index < self.open.len()
        {
            self.open.remove(index);
        }
    }

    /// Adds a task, from `live:click="add('Write the report', true)"`.
    #[action]
    pub fn add(&mut self, title: String, show_done: bool) {
        self.open.push(title);
        self.show_done = show_done;
    }
}
<div>
<button type="button" live:click="remove(0)">Remove the first task</button>
<button type="button" live:click="add('Write the report', true)">Add a task</button>
</div>

The arguments are literals, never expressions: JSON numbers, strings in single or double quotes with JSON escapes, true, false, and null, up to 128 of them. Each literal binds the parameter at its position, and you can leave out a trailing parameter whose type is an Option. The same syntax works on every directive that invokes an action: live:click, live:submit, live:change, live:input, live:keydown, and live:init.

live:check checks each call against the action's signature. A call with too many arguments, without a required one, or with a literal of the wrong type, such as remove('first') above, fails with the file, line, and column of the directive. At runtime, the island root lists each action's parameter names in its data-suprnova-live-actions attribute, and the runtime sends remove(0) as the arguments { "index": 0 }.

An argument written with {{ }}, such as remove({{ loop.index0 }}), is dynamic: the checker can't prove it, so live:check reports it as unproved. The runtime parses the rendered text with the same grammar, so a quote inside an interpolated string changes the arguments. Interpolate only values that render as a literal, such as numbers.

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/action, /__live/upload, the /__live/async/* control routes and WebSocket handshake, and the immutable /__live/assets/* routes. Startup fails if an application route can claim /__live. Behind a reverse proxy that serves the application under a path prefix, the routes still match /__live/... as the application receives it, and the page names them under the public root: the configuration element's endpoint is /billing/__live/action, and the browser runtime reaches the upload and asynchronous routes beside it (Serving under a path prefix).

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 its first request, an action or a model update.
  • 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.

Shared view data

A layout often needs a value every page has, such as the application's name or the signed-in user, without every view struct carrying a field for it. View::share makes a value available to every document and island the application renders afterwards, as Laravel's View::share does. View::share_for_request does the same for the current request alone, and its values win over the application's. A template reads a shared value with Askama's value filter, naming the type you shared:

use suprnova::{FrameworkError, Middleware, Next, Request, Response, View, async_trait};

pub fn boot() {
    View::share("app_name", "Acme".to_string());
}

pub struct ShareViewer;

#[async_trait]
impl Middleware for ShareViewer {
    async fn handle(&self, request: Request, next: Next) -> Response {
        if let Some(name) = request.header("X-Viewer") {
            View::share_for_request("viewer", name.to_string())?;
        }
        next(request).await
    }
}
<title>{% if let Ok(name) = "app_name"|value::<String> %}{{ name }}{% endif %}</title>
<p>{% if let Ok(viewer) = "viewer"|value::<String> %}Hello, {{ viewer }}{% else %}Hello{% endif %}</p>

A value nobody shared fails the render, so test for it with if let Ok(..) where it can be missing. View::shared::<T>(key) answers the value a render would read: the request's value, else the application's, None when neither is a T. A view keeps reading its own fields by name; shared values arrive only through the value filter.

The application's values belong to the application container, so a test under TestContainer::fake() sees only what it shared. A request's values live in the request's container scope; View::share_for_request returns an error outside one. A page whose render read a value shared for the request is never stored in the render cache, so it is never served to another request. App::inertia_share keeps reaching Inertia props only.

Why Suprnova diverges

Laravel's View::share is process-wide, which is safe because PHP serves one request per process. One Rust process serves many requests at once, so the request's own values need View::share_for_request, and a shared value reaches the template through Askama's runtime values rather than as a variable, because a checked template knows its variables at compile time. A Live component template reads the application's values on every render, a public seed's and an action's included, but never a value shared for the request: a component's view is rendered again on its action requests, and a public seed is shared between visitors. A component template that requires a request's value fails its render.

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 its first request, 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.

Gated actions, uploads, subscriptions and the principal of an asynchronous membership read the route's user: the user of the guard the last AuthMiddleware on the Live routes checked. The principal string your Live gates receive is the bare id for the default guard, as Auth::id() reports it, so a default-guard application sees the value it always saw. The one exception is an id that holds a :: it gets a leading :, so web user admin:9 reaches your gates as :admin:9 and never as admin 9. Behind AuthMiddleware::for_guard(..) naming any other guard - a second session guard, a token guard, or a guard of your application (see Authentication) - it is <guard>:<id>, such as admin:9. A user of another guard in the same session never stands in for the route's user, and a route whose guard has no user is refused.

A :principal topic parameter takes the same value. A <guard>:<id> principal is not a topic segment, so behind a guard other than the default a stream with a :principal topic does not resolve, and its subscription is refused rather than bound to another guard's user. Give such a stream topics that name their audience another way. Logging the guard out, or its Magnetar session failing validation, ends that guard's memberships on this node, as a default-guard logout does.

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/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.

Checking a policy

The engine registers a component only when every upload policy in it is valid. UploadPolicy::validate() returns Ok(()) for a valid policy. For an invalid one it returns the first rule that the policy breaks, as a suprnova::live::UploadPolicyError. Use it in a test of the policy:

use suprnova::live::{UploadPolicy, UploadPolicyError};

#[test]
fn the_avatar_policy_is_valid() {
    assert_eq!(avatar_policy().validate(), Ok(()));

    let no_limit = UploadPolicy::builder().maximum_files(1).build();
    assert_eq!(no_limit.validate(), Err(UploadPolicyError::MaximumFileBytes));
}
Variant The policy breaks this rule
MaximumFiles maximum_files is not set, or is 0
MaximumFileBytes maximum_file_bytes is not set, or is 0
AcceptedType { media_type } An accept_application media type or one of its extensions is not in canonical form. A media type is type/subtype in lower case letters, digits and + . -, at most 127 bytes. It has 1 to 16 extensions of lower case letters and digits, each at most 32 bytes
AcceptedTypes More than 16 accepted types, or one media type declared twice
Dimensions A limit of dimensions is 0
MissingFinalizeAction finalize_action is not set
FinalizeAction { name } The finalize_action name is not a valid action name

The enum is #[non_exhaustive], so a match on it needs a wildcard arm. validate() does not check that the finalize_action names an action of your component, because a policy does not know its component. The registration checks that.

When you register a component, a refused policy fails the registration and logs an error with the field and the rule (the upload policy of a Live field was refused, with the field and reason fields). The error that the registration returns is the same closed invalid_component_upload_metadata error for every rule, so no detail of the component reaches a browser.

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.

A stream ends with the session that opened it. When a session is destroyed on the node holding the stream, through a plain logout, invalidation, id regeneration, or a "log out everywhere", every membership it opened there is retired at once and no later event reaches it. A session destroyed on another node is caught by delivery itself: each membership's session is re-checked against the session store at most once per ten seconds, so events stop within that interval. The stream's gate is asked again before every delivery regardless, so a policy change ends delivery immediately on every node.

Assets and no-build use

The framework serves the exact reviewed runtime artifacts at /__live/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.

Limits

Every limit Live applies is a setting with a default sized for large pages: a data table of tens of thousands of rows renders, travels and morphs without touching one. The server's configuration is the only source. The bootstrap writes every value into the page's configuration element, and the browser runtime applies what it reads there, so it never refuses what the server allows.

Set a limit in the application's .env file:

LIVE_MAX_HTML_BYTES=67108864
LIVE_MAX_RESPONSE_BYTES=67108864
LIVE_MAX_REQUEST_BYTES=67108864
LIVE_MORPH_MAX_NODES=5000000

Or build the configuration in code and bind it before the runtime assembles. The builder starts from the defaults, and LiveConfigBuilder::from_env() starts from the .env values instead:

use suprnova::App;
use suprnova::live::LiveConfig;

let config = LiveConfig::builder()
    .max_html_bytes(64 * 1024 * 1024)
    .max_response_bytes(64 * 1024 * 1024)
    .max_request_bytes(64 * 1024 * 1024)
    .morph_max_nodes(5_000_000)
    .build()
    .expect("the Live limits nest");
App::singleton(config);
Key Default Bounds
LIVE_MAX_REQUEST_BYTES 16 MiB One request body, the snapshot included
LIVE_MAX_RESPONSE_BYTES 16 MiB One response body
LIVE_MAX_HTML_BYTES 16 MiB One island render's HTML
LIVE_MAX_JSON_DEPTH 32 JSON nesting
LIVE_MAX_JSON_ENTRIES 1,000,000 Entries in one request
LIVE_MAX_REQUEST_ITEMS 65,536 Proposals, operations or arguments in one request
LIVE_MAX_RESPONSE_ITEMS 65,536 Validation entries, events, effects, or children of one render
LIVE_MAX_CONTEXT_LIFETIME_MS 30,000 The trusted request context's validity
LIVE_REQUEST_TIMEOUT_MS 60,000 The browser's wait for one response
LIVE_MAX_QUEUED_PER_ISLAND 8 Requests one island queues
LIVE_MAX_PARALLEL_PER_ISLAND 1 Requests one island runs at once
LIVE_MORPH_MAX_NODES 1,000,000 Nodes in one morphed island
LIVE_MORPH_MAX_DEPTH 512 Element nesting in one morphed island
LIVE_MORPH_MAX_KEYS 1,000,000 Keyed elements in one morphed island
LIVE_MORPH_MAX_ATTRIBUTES 10,000,000 Attributes in one morphed island
LIVE_MORPH_MAX_ATTRIBUTES_PER_ELEMENT 4,096 Attributes on one element
LIVE_MORPH_DEADLINE_MS 0, no deadline How long one morph may run
LIVE_ASYNC_MAX_PAYLOAD_BYTES 1 MiB One asynchronous event payload
LIVE_ASYNC_MAX_BUFFER_BYTES 16 MiB One open document's delivery queue
LIVE_ASYNC_MAX_QUEUED_EVENTS 4,096 Events one open document holds before its islands apply them
LIVE_ASYNC_MAX_REPLAY_EVENTS 4,096 Events in one reconnect replay
LIVE_ASYNC_MAX_REPLAY_BYTES 4 MiB One subscription's replay log
LIVE_ASYNC_REPLAY_BUDGET_BYTES 256 MiB Every replay log in the process together
LIVE_ASYNC_MAX_TRANSPORTS_PER_SESSION 64 Open tabs one session streams to
LIVE_ASYNC_MAX_TRANSPORTS 16,384 Streaming connections in the process
LIVE_MAX_REDIRECT_BYTES 64 KiB One redirect or reflected URL
LIVE_UPLOAD_CHUNK_BYTES 8 MiB One upload chunk request
LIVE_UPLOAD_MAX_ACTIVE 8 Upload transfers running at once
LIVE_UPLOAD_MAX_FILE_BYTES 1 GiB One uploaded file
LIVE_UPLOAD_MAX_PENDING_FILES 1,024 Files selected and not yet finished
LIVE_UPLOAD_MAX_PENDING_BYTES 4 GiB Bytes across the files selected and not yet finished
LIVE_UPLOAD_MAX_STORAGE_BYTES 16 GiB The temporary upload store, across every visitor
LIVE_LEDGER_MAX_INSTANCES 100,000 Mounted component instances
LIVE_LEDGER_INSTANCE_LIFETIME_MS 7 days How long one instance lives
LIVE_LEDGER_CLAIM_LEASE_MS 30,000 How long one action holds its instance
LIVE_LEDGER_MAX_ACCEPTED_OUTCOMES 64 Outcomes kept per instance for retries

The byte limits nest: the island HTML travels inside the response, and the response's snapshot comes back in the next request, so LIVE_MAX_HTML_BYTES must be at most LIVE_MAX_RESPONSE_BYTES, which must be at most LIVE_MAX_REQUEST_BYTES. A setting you leave unset follows the one it must fit inside, so lowering only LIVE_MAX_REQUEST_BYTES lowers all three. The upload limits nest the other way: a chunk is part of one file, one file is part of the pending bytes, and the pending bytes are part of the store, so an unset pending or store limit grows to hold a larger file. Every key's type and range is in Environment Variables.

When a limit trips, the message names the limit, both values and the key to change. The browser prints it to the console unless the runtime boots with diagnostics: "off", and the server writes it to the log:

Suprnova Live island HTML size limit exceeded: measured 20971520 bytes,
configured 16777216 bytes. Raise LIVE_MAX_HTML_BYTES in the application's .env
file to allow it.

A request the browser would have to send over LIVE_MAX_REQUEST_BYTES fails in the browser before it is sent. A render over LIVE_MAX_HTML_BYTES fails on the server, which logs the limit field beside the failed operation's cause. A published payload over LIVE_ASYNC_MAX_PAYLOAD_BYTES fails with a LiveStreamError whose limit() names the setting. A file over LIVE_UPLOAD_MAX_FILE_BYTES, or a selection past the pending limits, is refused in the browser before any transfer starts; the server refuses the same file with a 413 and logs the key. suprnova live:inspect prints every limit the application runs under, by its key.

Nothing retires an instance when the visitor closes the page, so the ledger fills at LIVE_LEDGER_MAX_INSTANCES page views per LIVE_LEDGER_INSTANCE_LIFETIME_MS, about 14,000 private-island page views a day at the defaults. A full ledger still mounts every new page: it evicts the instance that expires soonest to make room, which is usually a page the visitor closed long ago. If that page is still open, its next action is told to refresh, and the browser reloads the page with current state. A site whose open pages get evicted raises the instance limit.

Sizing a small host

The defaults admit large pages, and every byte of a request is held while it is read. Live reads a whole request before it checks the request's context, because the context depends on the island the request names. At the defaults (LIVE_MAX_REQUEST_BYTES 16 MiB, LIVE_MAX_JSON_ENTRIES 1,000,000), one request costs its 16 MiB body plus what parsing it builds: about 60 MiB for an array of a million short strings and about 165 MiB for an object of a million members, measured on an optimized build. Concurrent requests add up. On a host with little memory, lower both limits to what the pages need:

LIVE_MAX_REQUEST_BYTES=2097152
LIVE_MAX_JSON_ENTRIES=100000

At 2 MiB and 100,000 entries, the same measurements give about 6 MiB and 16 MiB, so the worst request costs about 18 MiB with its body. The response and island HTML limits follow the request limit down unless set.

Component library

Suprnova ships the foundations of a component library for Live: a token stylesheet with a base layer, and a form family of presentational components built on native controls and the live:model, live:error, and live:loading vocabulary. The base is a runtime artifact. Opt a document in and it arrives as one stylesheet link under the same identity, integrity, and cache contract as the runtime scripts:

let bootstrap = document.bootstrap(LiveBootstrapOptions::esm().with_suprnova_ui())?;

Every rule in it sits inside the suprnova-ui cascade layer, so your own unlayered styles win without a specificity fight. Every visual value is a --sn- custom property for color, font, space, radius, shadow, motion, density, and state, with light and dark values: override a token on :root to retheme, or drop the layer and keep every behavior, name, and state attribute, because a component styles its states from the attributes the checker proves (aria-invalid, aria-busy, aria-expanded, aria-pressed, aria-current, aria-selected, :disabled), never from a class. A Tailwind CSS 4 @theme preset maps the tokens onto Tailwind's namespaces; Tailwind is never required. Components install with live:add, one directory each under the reserved templates/suprnova-ui/ root: the Askama macro view, the stylesheet, the JavaScript when the component has one, and the manifest that names them:

suprnova live:add field
suprnova live:add password-input

live:add records the digest of every file it writes, so a later run replaces a file you never edited when the library changes it, keeps a file you edited, and says so; --force replaces an edited file too. It records each install in suprnova.toml at the project root. A third-party component installs from its library's signed tree by address, such as suprnova live:add acme/acme-ui/counter, from an https:// URL, or from a ./path on disk, under its library's own root; --manifest <path> names a component on disk too. It can carry Rust, which live:add writes under src/live/ and registers. Installing Live Components covers the plan, capabilities, key pins and the record, and Live Component Libraries covers writing a library. Call the macros from your views, serve the vendored stylesheet and script with try_live_ui_assets(), and link them from the document with url::root() in front, so the links still work when a reverse proxy serves the application under a path prefix:

<link rel="stylesheet" href="{{ suprnova::url::root() }}/suprnova-ui/field/field.css">
<link rel="stylesheet" href="{{ suprnova::url::root() }}/suprnova-ui/input/input.css">
{% import "suprnova-ui/field/field.html" as field %}
{% import "suprnova-ui/input/input.html" as input %}
{% call field::field("email", "Email", required=true) %}
{% call input::input("email", kind="email", required=true) %}{% endcall %}
{% endcall %}

try_live_ui_assets() reads the files from templates/suprnova-ui/ under the application base path on each request, so ship that directory with the binary and start the application from the directory that holds it, or set APP_BASE_PATH to that directory. When the directory cannot be read, the application refuses to start and names it.

A third-party library installs its components under its own root, templates/<namespace>-ui/, and each library takes one call of its own: try_live_ui_assets_for("acme") serves templates/acme-ui/ at /acme-ui/{component}/{file} under the same rules, and live:add names the call when it installs the library's first component. Only stylesheets and scripts of at most 1 MiB are served, never a view, a Rust file, a manifest or the install record, and never a file reached through a symbolic link below the root. The server opens the component directory and the file through the root it holds open, so a link swapped in while a request is in flight is refused too, and it checks the 1 MiB bound on the file it then reads. The namespace call refuses suprnova, whose call is try_live_ui_assets(), and the reserved sn and live; try_live_ui_assets_for_from(namespace, directory) serves the same route from an explicit directory.

let router = router.try_live_ui_assets()?.try_live_ui_assets_for("acme")?;

The checker expands the macros, so live:check proves a library view like any other. The form family: field, label, input, textarea, number input, slider, search input, password input with reveal, checkbox and checkbox group, radio group, switch, select, button and link button, button group, fieldset, form actions, validation summary, and file input. Library components are named suprnova.* and the registry refuses that prefix from any other crate; custom elements are light DOM and carry the sn- prefix.

Each value control takes the island's current value, so the page shows it and a submit that changed nothing sends it back unchanged: value= for an input, textarea, number input, slider, search input, and date picker, checked= for a checkbox and a switch, and selected= for a select, a radio group, and a checkbox group, which takes the list of checked values. A password and a one-time code never render their value. Radio and checkbox group inputs are keyed by value, so a choice the user has not sent survives a re-render. A checkbox group is proposed as the list of checked values whatever its number of options, as is any field that more than one checkbox binds; a single checkbox is proposed as a boolean. A render that must replace what the user has typed, such as the one answering a reset, passes a sequence number as authority=, and the next render passes none:

{% call input::input("email", kind="email", value=email, authority=authority) %}{% endcall %}
{% call checkbox::checkbox_group("topics", "Topics", topic_options, topics) %}{% endcall %}

The overlay family ships on the same foundations: tooltip, collapsible and accordion, popover, a single-level dropdown menu, dialog, sheet, and drawer. Each one owns its open state through the browser's own primitive before any script runs: details for disclosures, the popover attribute for popovers and menus, and dialog for the three modals, which the vendored sn-dialog, sn-sheet, and sn-drawer elements open with showModal() and close with focus returned to the trigger. Opening and closing never makes a Live request; only an action you place inside an overlay does. Every overlay root carries a stable key and live:preserve.self, so an open overlay survives a morph that did not replace its region:

{% import "suprnova-ui/dialog/dialog.html" as dialog %}
{% call dialog::dialog_trigger("confirm", "Delete everything", variant="danger") %}{% endcall %}
{% call dialog::dialog("confirm", "confirm", "Delete everything?") %}
<p>This removes every note.</p>
{% call button::button("Delete", action="confirm_delete", variant="danger") %}{% endcall %}
{% call dialog::dialog_close("confirm", "Cancel") %}{% endcall %}
{% endcall %}

The popover attribute sets the supported baseline at Chrome and Edge 114, Firefox 128, and Safari 17. Where CSS anchor positioning exists the popover and menu sit under their trigger; elsewhere the browser centres them. The accordion's single-open mode rests on details name, which older supported releases treat as independent disclosures. The tooltip stays open while the pointer moves from its trigger onto the bubble, so its text can be read or selected, and Escape dismisses it where it is shown, without moving the pointer or focus; the next hover or focus of that trigger shows it again. The dismissal is the vendored sn-tooltip element, and the bubble still shows on hover and on focus in a page that never loads it.

The feedback family and the navigation family follow. Feedback: alert, skeleton, spinner, progress, empty state, and a toast region with a flash region beside it. Each one presents a state the server or the runtime already holds. An alert chooses its role from its variant and marks every variant with a glyph and a hidden label, never color alone. A spinner or skeleton is bound with live:loading.show to a registered action and authored hidden, so the runtime reveals it after its own delay and keeps it past its minimum, and a fast action never flashes it. Progress is the native progress element with a label and a text readout, and carries a value only for determinate work. The empty state takes its reason (empty, no results, no permission, disconnected) from server-rendered state and offers a next action only where the caller renders one. A toast announces once from a polite status region and never takes focus; the vendored sn-toast-region element times toasts out, pauses while the pointer is over any part of a toast or focus is inside it, bounds how many show at once, and answers the dismiss button, with each toast keyed and preserved so a dismissed toast stays dismissed across a morph. A critical error belongs in an alert as well; a toast is never its only surface. Toasts render inside a loop, so their keys pass through the live_key filter, and the island that mounts them exposes it with pub mod filters { pub use suprnova::view::filters::live_key; }. live_key fails the island's render for a value outside the key alphabet, such as an email address; key such data with live_key_digest, which turns any value into a stable key in the alphabet and is exported the same way from suprnova::view::filters. The flash region renders what the previous request left in the session, once:

{% import "suprnova-ui/alert/alert.html" as alert %}
{% import "suprnova-ui/spinner/spinner.html" as spinner %}
{% call alert::alert("saved", variant="success") %}<p>Your changes are saved.</p>{% endcall %}
{% call button::button("Save", action="save") %}{% endcall %}
{% call spinner::spinner(action="save", label="Saving") %}{% endcall %}

Navigation: header bar, footer, sidebar with collapsible groups, breadcrumbs, tabs, pagination, and load more. Every destination is an anchor with a real route URL and every action is a button; the current item carries aria-current from the value you bind, never from the browser's location. The sidebar's groups are native details, keyed and preserved. Tabs require a mode: local panels with tablist semantics, arrow keys from the vendored sn-tabs element, and no request on a change, or route tabs as anchors. Tabs nest: an inner tabs instance selects only its own tabs and panels. Pagination requires a mode too: route pages are canonical links, and Live pages are buttons on your actions whose result reflects the new query into the current history entry through url_intent, with no history entry per page. Load more is a button on a registered action that appends to a keyed list, so the morph keeps every row already there, and the control leaves the view when you render it exhausted. A URL reflection is a protocol 2 result, so an island that paginates through url_intent declares minimum_protocol_version = 2; its keyed rows pass through live_key like a toast does:

{% import "suprnova-ui/tabs/tabs.html" as tabs %}
{% call tabs::tabs("details", mode="local", label="Details") %}
{% call tabs::tab_list("Details") %}
{% call tabs::tab("tab-summary", "panel-summary", "Summary", selected=true) %}{% endcall %}
{% call tabs::tab("tab-history", "panel-history", "History") %}{% endcall %}
{% endcall %}
{% call tabs::tab_panel("panel-summary", "tab-summary", selected=true) %}<p>Summary</p>{% endcall %}
{% call tabs::tab_panel("panel-history", "tab-history") %}<p>History</p>{% endcall %}
{% endcall %}

The data display family closes the built-in set. Presentational: separator, scroll area, aspect image, card, badge, avatar and avatar group, list group, description list and stat card. Each keeps document order and native semantics: the separator is an hr or a labeled separator role, the scroll area is a focusable labeled region that scrolls natively, the aspect image is the img itself with a named ratio, the card is an article or section labeled by its own heading with actions in a labeled group, and the description list is a dl. A badge always carries its text, an avatar names its person in alt or in the label of its initials, and a stat's trend says "Up", "Down" or "Flat" in text before the delta, so no status rests on color alone. The list group keys every item through live_key, so a reorder keeps each node. The chart is server-rendered: the island calls render_chart from suprnova::live::charts, which draws bar or line marks through charts-rs from bounded typed series and returns trusted markup, and the macro renders the SVG beside a text summary and a data table in a disclosure, so the canonical document reads without the picture and no charting script ever reaches the browser:

use suprnova::live::charts::{ChartKind, ChartSeries, render_chart};

pub fn chart_svg(&self) -> TrustedHtml {
    render_chart(
        ChartKind::Bar,
        &["Apr", "May", "Jun"],
        &[ChartSeries::new("Revenue", vec![42.0, 47.0, 51.0])],
    )
    .expect("a bounded fixed series renders")
}

render_chart returns an error rather than drawing a value whose magnitude exceeds 1e9, beyond the range the renderer's axis arithmetic holds.

The datatable is the last component, and one island per table. It is a native table with a caption naming the result count, column headers with scope, and aria-sort on the sorted column. Sort and filter are Live submits on the island's model fields, page changes are Live buttons, and the island declares the applied sort, direction, filter and page as #[url] fields and reflects them through url_intent after every action, so the address bar always holds a shareable URL and the document mounts the same view from it:

#[live(name = "app.invoices", view = "live/invoices.html", minimum_protocol_version = 2)]
pub struct Invoices {
    #[model]
    pub sort: String,
    #[url(key = "sort")]
    pub sorted_by: String,
    #[url(key = "dir")]
    pub direction: String,
    #[model]
    #[url(key = "filter")]
    pub filter: String,
    #[url(key = "page")]
    pub page: u64,
    pub rows: Vec<Invoice>,
}

The live-native family is the last one, the components that only make sense on the running runtime. The upload widget renders the shipped upload protocol: its file input carries live:upload for the island's upload field, its progress element is the runtime's progress root, and cancel, retry and remove act on the temporary reference through live:upload.cancel and its siblings. Every state the domain knows is rendered as text and shown from the progress root's data-live-upload-state, and "ready" reads as verified but not saved, because nothing is durable until the finalizing action runs:

{% call upload::upload("attachment", "Attachment", accept="image/png") %}{% endcall %}
<button type="submit" live:loading.disabled="save_attachment">Save attachment</button>

The live feed and the notification bell sit on a stream-backed island. The runtime writes data-live-stream-state on the island root and announces every change into the [data-live-stream-status] element the macros render (Updates disconnected, Connecting to updates, Updates current, Updates degraded, Reconnecting to updates, Updates closed), so a degraded, reconnecting or closed stream says so and only the current state reads current. Feed items pass through live_key. The account menu is a details disclosure of anchors and a sign-out form that posts with the session's CSRF token; it is a stitch slot under RenderCache, so an application mounts it as its own identity-bound island and the shared shell never holds the principal's name.

The custom-element tier enhances native controls it never replaces. Each element is a light-DOM HTMLElement subclass defined only by its own vendored file, carries the sn- prefix, and holds no form value, because the native input inside it is the control: block the script and the form still submits the same value. The input OTP is one native input (inputmode="numeric", autocomplete="one-time-code", a length pattern) on a transient model, and sn-input-otp mirrors the typed characters into aria-hidden cells. The date picker is a type="date" input, and its year, month and day strips are fieldsets of native radios inside CSS scroll-snap containers, so tap, click and arrow keys select with no script; sn-date-picker composes a complete selection into the input. The combobox is the accessible combobox pattern (role="combobox", aria-expanded, aria-activedescendant, a role="listbox" of options) over a native input with a datalist for the script-free case, and sn-combobox moves the active option and selects. Each option is a (value, label) pair, and the combobox binds two fields: name holds the chosen option's value, through a hidden input that a selection fills and an edit of the text clears, and <name>_query holds the typed text. Pass the island's current value as value and its text as query, so both render back (the option holding the value renders selected). By default the options are your server's answer to the query: render them for the query field on every render, and the element shows all of them while the query they answer is the input's text, whatever your search matched, and keeps an answer to older text hidden, so a stale result never replaces results for a newer query. Pass remote=false for a fixed list, which the element filters by the typed text:

{% call otp::input_otp("code", "One-time code") %}{% for index in cells %}{% call otp::otp_cell(index) %}{% endcall %}{% endfor %}{% endcall %}
{% call date::date_picker("when", "Renewal date", years, months, days, min="2026-01-01", max="2028-12-31") %}{% endcall %}
{% call combo::combobox("country", "Country", countries, query=country_query, placeholder="Type a country", value=country) %}{% endcall %}

Why Suprnova diverges

Laravel ships Blade components and a starter kit's markup; Suprnova ships the library through the framework itself, on Live's own vocabulary, with no client application owning the page. The skin is on by default and removable with nothing breaking, which is what headless means here.

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 holds every limit Live applies; set them with the LIVE_* keys in .env or bind a custom one before the runtime assembles. See Limits.
  • Errors carry closed kinds such as live_document_context_rejected and invalid_live_bootstrap; telemetry labels are closed enumerations.

Why an operation failed

When a trusted part of the engine fails an operation, the browser gets one closed reason and nothing more: the island is told to refresh. The framework records why in the log, so you do not have to reproduce the request. It writes one WARN event for each failed action, and one for each rejected mount.

A failed action writes this event:

Field Holds
message Live operation failed and the browser must refresh the island
component The registered name of the component, such as app.counter
reason The coarse reason the browser gets, such as ExecutionFailed or LedgerUnavailable
cause The closed cause, written in the Debug form, such as Ledger(ProviderUnavailable)

A rejected mount writes one of two events. The message is Live public mount was rejected for a mount that a document asks for, and Live private mount was rejected for a mount that carries a request of its own. Each has these fields:

Field Holds
kind The coarse kind of the mount error, such as LedgerRejected
cause The closed cause, or None when the kind says everything

A refresh that is no failure has no cause and writes no warning. A stale revision is the usual case: the island refreshes and the log stays quiet. A mount refusal with no subsystem error behind it, such as an identity collision, has cause=None.

The cause names the part that failed and the kind of its error. An action cause is one of these groups:

Group Look at
Action The registered action: its lookup, arguments, authorization, dispatch or outcome
Lifecycle The component: a recovery render, a model sync, a parameter change, a lazy completion or a promotion mount. Panicked means the component panicked
Ledger, LedgerSuccessorMismatch The instance ledger: ProviderUnavailable points at the store, CapacityExceeded at a stored record over its 32 KiB bound
Clock The host clock: no time, or a deadline that overflowed
ContextExpired The request context ran out before the new snapshot was signed
Snapshot, Identity The signed state: the kind says which check refused it, such as SignatureInvalid or Expired
View The rendered view: for example TemplateRenderFailed or MissingIslandRoot
OutcomeShape An outcome that is incomplete
CompositionOwnerMissing, CompositionChildUntracked, CompositionChildUnknown, CompositionLineage, ChildParameters The parent and child components of a page: their lineage or their sealed parameters
Host A port of the application: Begin, Commit, Rollback, Reporting or ResponseIntent names which one
ResponseIntents, ResponseSealing The preparation or the sealing of the response

A mount cause is one of Clock, Registry, Snapshot, Canonical, Composition, Random, Lifecycle, View or Ledger. Each names the subsystem that refused the mount and carries the kind of its error.

The values are closed. They never hold the text of an error, the identifier of an instance or anything of a request. You can copy a log line into a report without removing a secret from it.

Recovery

  • A 409 tells the runtime to fresh-render the island; the operation is not replayed.
  • A refresh that follows a failed operation tells the browser only to refresh. The cause is in the log only; see Why an operation failed.
  • 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:add <component> Install a component from the shipped library or a third-party library; see Installing Live Components
suprnova live:registry new|check|sign|rotate-key Scaffold, check, sign and re-key a component library; see Live Component Libraries
suprnova live:check Verify every recorded third-party component offline, then 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