Statum is a stateful web application framework for Vala, built on top of Astralis (HTTP server), Invercargill (data structures / JSON), and Inversion (dependency injection).
It provides:
statum-mkpstm), precompressed with gzip/zstd/brotli and served with
encoding negotiation and ETags./_statum/action/{guid},
invoked from the client via stm-action attributes, carrying strongly-typed
private data. Browser Server
─────── ──────
GET / ──────────────────────► StatumPage (precompressed HTML)
page loads, statum.js boots
GET /_statum/entrypoint ────► StatumEntrypoint → directives (set/clear/...)
client applies directives
client binds stm-* attributes
POST /_statum/action/{guid} ► StatumAction → directives
(with X-Statum-Slot headers) (mutate state, push to channels)
home.html — the page template, composed with a layout:
<!DOCTYPE html>
<html>
<head>
<pstm-uri>/</pstm-uri>
<pstm-template>main</pstm-template>
</head>
<body>
<p>Counter: <span stm-text="counter.value">0</span></p>
<button stm-action="counter.bump">Bump</button>
</body>
</html>
main.html — the layout template:
<!DOCTYPE html>
<html>
<head>
<pstm-name>main</pstm-name>
<pstm-title>My App</pstm-title>
<script pstm-res-src="statum.js"></script>
</head>
<body>
<header><h1>My App</h1></header>
<main><pstm-content></pstm-content></main>
</body>
</html>
HomeEntrypoint.vala — hydrates the page:
public class CounterPublic : Object {
public int value { get; set; }
public Model.ActionDto bump { get; set; }
}
public class HomeEntrypoint : StatumEntrypoint {
public override async DirectiveBuilder handle() throws GLib.Error {
var counter = new CounterPublic();
counter.value = 0;
counter.bump = action_registry.author_private<BumpAction, BumpPrivate>(new BumpPrivate());
return directives().set_typed<CounterPublic>("counter", Scope.PAGE, counter);
}
}
Main.vala — wires it all up:
void main(string[] args) {
var app = new WebApplication(8080);
app.add_module<StatumModule>();
var statum = app.configure_with<StatumConfigurator>();
statum.add_page<HomePage, HomeEntrypoint>(); // route from <pstm-uri>
statum.action<BumpAction>(); // GUID auto-assigned
app.run();
}
<head>
<pstm-uri>/dashboard</pstm-uri>
<pstm-template>main</pstm-template>
<link rel="stm-guard" require="auth" href="/" pre-entrypoint>
</head>
The guard redirects unauthenticated visitors to / before the entrypoint loads.
Statum pages are HTML files processed at build time by statum-mkpstm.
pstm-*)All pstm-* elements must appear inside <head> (except pstm-content and
pstm-fragment, which live in <body>).
| Directive | Where | Purpose |
|---|---|---|
<pstm-uri>/path</pstm-uri> |
head | The page's route. Required. Supports templates: /cats/{species}. |
<pstm-template>main</pstm-template> |
head | Wraps this page in the named template (by <pstm-name>). |
<pstm-name>main</pstm-name> |
head | Names this file so pages/fragments can reference it. |
<pstm-title>…</pstm-title> |
head | Page title, merged up the template chain. |
<pstm-content></pstm-content> |
body (template) | The outlet where the child page's body is inserted. |
<pstm-fragment name="card" /> |
body | Inlines a fragment (component) by name. |
pstm-materialise-as="div" |
<body> attr |
When this body is composited into a template or fragment, it is wrapped in the named element. |
pstm-res-src="logo.png" |
any attr | Rewrites to src="/_statum/resource/logo.png". Also pstm-res-href, etc. |
When a page is composed into a template (or a fragment is inlined), the child's
<head> children are merged into the parent's <head>. This lets pages,
templates, and fragments each declare their own <style>, <script>, <meta>,
<link> tags.
A fragment is an HTML file with a <pstm-name> and a <body> (no
<pstm-content>). It is inlined wherever a <pstm-fragment name="…"> appears.
Fragment <head> tags merge into the composed document. If the fragment's
<body> has pstm-materialise-as, the body becomes that element.
stm-guard)A <link rel="stm-guard"> in <head> redirects the page when a predicate fails:
<link rel="stm-guard" require="auth" href="/login" pre-entrypoint>
require — an eval predicate (same language as stm-if). When falsy, redirect
to href.pre-entrypoint — also evaluate on initial load (using hydrated state) before
the entrypoint request. Without it, the guard evaluates only on render cycles
(after hydration, and on every state change).Guards are client-side; they are a display/UX convenience, not a security boundary (authoritative access control lives at the entrypoint/action level).
# Generate a page (from HTML → Vala StatumPage subclass)
statum-mkpstm -o HomePage.vala -n HomePage --ns=MyApp home.html
# Generate a precompressed resource (CSS, JS, images)
statum-mkres -o LogoResource.vala -n logo.png -c image/png logo.png
# Generate static signing/encryption keys for web-config.json
statum-genkeys
In Meson, these are wired as custom_targets (see example/meson.build).
A slot is a unit of application state, identified by a type name and a random key. The server creates slots; the client holds them and sends them back on subsequent requests.
| Scope | Persistence | Use case |
|---|---|---|
Scope.PAGE |
In-memory, gone on navigate | Transient page data |
Scope.FLOW |
Per-tab (sessionStorage) |
Multi-step flow within a tab |
Scope.SESSION |
Per-browser (localStorage) |
Login state, preferences |
Scope.DEVICE |
Per-device (localStorage) |
Long-lived settings |
A slot's data is a snapshot (public + private properties), wrapped in a frame (signed by the server). The client holds frames and sends them back:
key=…; as_at=…): sent while the snapshot is still cached
server-side (within transmit_after).X-Statum-Slot headerBrowsers coalesce multiple headers into one comma-separated value. The server
splits on , to recover individual slots. The client sends one X-Statum-Slot
header per held slot on every entrypoint/action/slots request.
Private slot data is encrypted (X25519-Seal + Ed25519 signature) under a namespace. For action private data, the namespace is the action type name (so one action cannot decrypt another's private blob).
An entrypoint is a StatumEntrypoint subclass that returns directives to
hydrate a page after it loads. The framework matches the uri query parameter
against the page's <pstm-uri> route and dispatches to the bound entrypoint.
public class HomeEntrypoint : StatumEntrypoint {
public override async DirectiveBuilder handle() throws GLib.Error {
// Typed model with an action reference field
var counter = new CounterPublic();
counter.value = 0;
counter.bump = action_registry.author_private<BumpAction, BumpPrivate>(new BumpPrivate { delta = 1 });
return directives()
.set_typed<CounterPublic>("counter", Scope.PAGE, counter)
.set_typed<PagePublic>("page", Scope.PAGE, new PagePublic { login = action_registry.author<LoginAction>() });
}
}
| Field | Type | Description |
|---|---|---|
request |
StatumRequest |
URI, route params, held slots, query, form |
state_service |
StateService |
Low-level slot create/sign/update/clear |
channel_service |
ChannelService |
Realtime push (set/clear) |
action_registry |
ActionRegistry |
Author action references |
encryption_provider |
EncryptionProvider |
Seal/unseal private data |
CounterPublic? cart = request.held_as<CounterPublic>("counter");
string? species = request.route("species"); // from /cats/{species}
Actions are POST/GET handlers auto-mapped to GUID endpoints. They receive the
client's held slots (via X-Statum-Slot) and optional private data (via
X-Statum-Private), mutate state, and return directives.
public class LogoutAction : StatumAction {
public override async DirectiveBuilder handle() throws GLib.Error {
HeldSlot auth;
if (request.held.try_get("auth", out auth)) {
yield state_service.clear_slot(auth.key);
return directives().clear(auth.key).navigate("/");
}
return directives().navigate("/");
}
}
public class BumpAction : TypedStatumAction<BumpPrivate> {
public override async DirectiveBuilder handle() throws GLib.Error {
var delta = request_private != null ? ((!)request_private).delta : 1;
return directives().update_held<CounterPublic>("counter", c => c.value += delta);
}
}
TypedStatumAction<TPrivate> decrypts the X-Statum-Private header into
request_private (typed TPrivate). If the private data is absent or cannot be
decrypted, the framework returns an error directive (code "bad_private") and
does not call handle(). Override private_error_message /
private_error_code to customise.
Actions are not addressed by URL — they are embedded in slot public data as
references, and invoked from the client via stm-action:
// No private data
var ref = action_registry.author<LoginAction>();
// With typed private data
var ref = action_registry.author_private<CheckoutAction, CheckoutPrivate>(
new CheckoutPrivate { order_id = "abc" });
The returned ActionDto is embedded in a typed model's field
(public Model.ActionDto bump { get; set; }) or set directly on a
PropertyDictionary. It round-trips automatically through update_held.
statum.action<LoginAction>();
statum.action<LogoutAction>();
statum.action<BumpAction>();
No route is specified — a GUID is auto-assigned at registration.
The Statum client (statum.js) is embedded in the library and served from
/_statum/resource/statum.js. Include it in your template:
<script pstm-res-src="statum.js"></script>
It auto-initialises on DOMContentLoaded.
<body stm-entrypoint="/custom-entrypoint"
stm-slots="/custom-slots"
stm-channel="/custom-channel"
stm-worker="/custom-worker">
stm-*)| Attribute | Description |
|---|---|
stm-text="counter.value" |
Sets the element's text content from the expression. |
stm-html="cart.summary" |
Sets the element's inner HTML. |
stm-if="auth != null" |
Shows/hides the element (and its stm-else-if/stm-else siblings). |
stm-else-if="auth.role == 'admin'" |
Next sibling of stm-if/stm-else-if. |
stm-else |
Next sibling of stm-if/stm-else-if. |
stm-class.active="cart.count > 0" |
Toggles the class active based on the predicate. |
stm-disabled="!user.canSubmit" |
Disables the element + descendants when truthy. |
stm-action="cart.checkout" |
Invokes the action reference resolved from state. On a <form>, collects form data; on a button, fires on click. |
stm-data-key="value" |
Provides data to the enclosing stm-action. |
<link rel="stm-guard" require="auth" href="/login" pre-entrypoint>
Evaluated on every render cycle; pre-entrypoint also evaluates before the
entrypoint fetch. On failure, the page redirects to href.
<div stm-preloader>Loading…</div> <!-- shown until entrypoint loads -->
<div stm-content> <!-- hidden until entrypoint loads -->
Page content here.
</div>
// Read current state
var cart = statum.state("cart"); // → { value: 0, bump: {…} }
// Listen for changes
statum.on("cart", function(data, old) {
console.log("cart changed", data);
});
// Perform an action programmatically
statum.act("cart.checkout", { qty: 2 })
.then(function(directives) { … })
.catch(function(err) { … });
StatumModuleRegisters the framework services and endpoints. Add it to your application:
app.add_module<StatumModule>();
StatumConfiguratorBinds pages, actions, and resources:
var statum = app.configure_with<StatumConfigurator>();
statum.add_page<HomePage, HomeEntrypoint>(); // page + entrypoint
statum.add_page<DashboardPage, DashboardEntrypoint>();
statum.add_static_page<AboutPage>(); // page, no entrypoint
statum.action<LoginAction>(); // GUID action
statum.action<LogoutAction>();
statum.add_resource<LogoResource>(); // CSS/JS/image resource
Routes are derived from <pstm-uri> (not passed manually). A page without a
<pstm-uri> fails at startup.
DirectiveBuilderStarted via directives() on any handler. Fluent, self-returning:
| Method | Description |
|---|---|
.set(Slot) |
Signs a fresh slot → set directive. |
.set_typed<T>(type, scope, model) |
Creates + signs from a typed public model. |
.set_private_typed<T1,T2>(type, scope, pub, priv) |
Creates + signs from typed public + private models. |
.update(key, State) |
Mutates + pushes to channels + signs → set. |
.update_held<T>(type, c => …) |
Typed read-mutate-write of a held slot (preserves action refs). |
.clear(key) |
clear directive. |
.navigate(uri) |
Terminal: redirect the client. |
.notify(kind, message) |
Surface a notification (kind is app-defined). |
.error(message, code?, data?) |
Report an error to the client. |
.subscribe(key) |
Subscribe to realtime updates for a slot. |
.unsubscribe(key) |
Unsubscribe. |
.set_frame(FrameDto) |
set from an already-signed frame. |
.invalidate(signature) |
invalidate (drop a bad frame). |
StatumRequest| Member | Type | Description |
|---|---|---|
uri |
string |
The request URI. |
route_params |
ReadOnlyAssociative<string,string> |
Route params from <pstm-uri> templates. |
held |
ReadOnlyAssociative<string,HeldSlot> |
Held slots by type name. |
query |
Catalogue<string,string> |
Query parameters. |
action_private |
Properties? |
Decrypted action private (untyped). |
form |
FormData? |
Parsed form body. |
held_as<T>(type) |
T? |
Typed read of a held slot's public data. |
route(name) |
string? |
Raw route parameter. |
StateService| Method | Description |
|---|---|
new_slot(scope, state) |
Creates + caches a slot, returns it. |
get_slot(key) |
Reads a cached slot (pure, no mutation). |
sign_slot(key) |
Signs a slot's current state → FrameDto. |
update(key, state) async |
Mutates, signs, pushes to channels → FrameDto. |
clear_slot(key) async |
Removes + pushes clear event. |
restore_slot(frame) |
Re-caches a slot from a verified frame. |
ActionRegistry| Method | Description |
|---|---|
author<TAction>(method) |
Action reference (no private). |
author_private<TAction,TPrivate>(model, method) |
Action reference with sealed typed private. |
Scope enumPAGE, FLOW, SESSION, DEVICE, UNIDIRECTIONAL.
GObjectMappingMaps GObject models to/from Properties via introspection (no per-model
mapper). ActionDto fields round-trip as nested JSON via the mapper registry.
| Method | Description |
|---|---|
to_properties(Object) |
Model → Properties. |
from_properties(Type, Properties) |
Properties → model (Object). |
from_properties_typed<T>(Type, Properties) |
Convenience: returns T. |
register_mapper<T>(mapper) |
Register a nested-object mapper. |
get_string/get_int/get_bool/get_double(props, key) |
Typed ad-hoc getters. |
web-config.json)To make signed frames and encrypted private blobs survive a server restart, configure static keys:
statum-genkeys > web-config.json
{
"statum": {
"signing_secret_key": "…",
"signing_public_key": "…",
"encryption_signing_secret_key": "…",
"encryption_signing_public_key": "…",
"encryption_sealing_secret_key": "…",
"encryption_sealing_public_key": "…"
}
}
Without these, the server generates random keys per restart (fine for development; a warning is logged).