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:
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 ;
/// A counter rendered by `live/counter.html`.
nameis the registered component name. Use a dotted, kebab-case name such asapp.counter; the CLI derives<package>.<kebab>.viewis the template identity, relative to the template root.#[public]fields are rendered and carried in the signed snapshot.#[model]fields additionally accept browser proposals throughlive:model. A model field declares its timing on the attribute, such as#[model(debounce = 250)]; a debounce is 100, 250, or 500 milliseconds, the durationslive:model.debounce.<n>msaccepts, and any other fails to compile. A form submitted withlive:submitproposes every model control inside it in one request, which carries up to 127 fields beside the action;live:checkrefuses a larger form. A proposal the field cannot decode, such as an empty number for au64field, is a validation error on that field: the action does not run, the field keeps its value, andlive:errorshows 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 needSessionMiddlewareon 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:
Count: {{ count }}
Increment
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 ;
/// A task list rendered by `live/tasks.html`.
Remove the first task
Add a task
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 ;
/// Builds the registry of every Live component in this application.
Bind it during bootstrap so the server, the workers, and the suprnova live:* commands see the same components:
singleton;
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 Arc;
use Duration;
use ;
use InMemoryRateLimiter;
use ;
;
Install the routes from the entry point so the runtime and the mount catalog are ready before the first request:
new
.bootstrap
.try_routes
.run
.await;
Documents and islands
A document route declares its islands once, renders them through
LiveDocument, and emits the bootstrap tags:
use BTreeMap;
use ;
use ;
use ;
async
LiveMount::public_seeddeclares 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_bounddeclares an island that belongs to the current session and principal; the document route must authenticate.- Mount every island before
bootstrap, and callbootstraponce. 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 ;
;
{% if let Ok(name) = "app_name"|value:: %}{{ name }}{% endif %}
{% if let Ok(viewer) = "viewer"|value:: %}Hello, {{ viewer }}{% else %}Hello{% endif %}
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!;
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 ;
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:
singleton;
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 ;
| 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?
.middleware
.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 ;
;
Define the ability live:<component>.stream.<name> for subscribers, then
publish from anywhere in the application:
let streams = resolve?;
streams..await?;
streams.refresh.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:
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 App;
use LiveConfig;
let config = builder
.max_html_bytes
.max_response_bytes
.max_request_bytes
.morph_max_nodes
.build
.expect;
singleton;
| 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?;
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:
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:
{% 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?;
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?") %}
This removes every note.
{% 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") %}Your changes are saved.{% 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) %}Summary{% endcall %}
{% call tabs::tab_panel("panel-history", "tab-history") %}History{% 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 ;
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:
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 %}
Save attachment
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 = routes?;
let runtime = prepare_live_router_for_test?;
singleton;
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:checkproves every registered view;--allow-unprovedaccepts dynamic structures the checker deliberately makes no claim about.suprnova live:inspectreports the bound registry, configuration limits, installed upload capabilities, assembled runtime services, and the asset identity without exposing state or secrets.LiveConfigholds every limit Live applies; set them with theLIVE_*keys in.envor bind a custom one before the runtime assembles. See Limits.- Errors carry closed kinds such as
live_document_context_rejectedandinvalid_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
409tells 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 |
