getting-started.md 19 KB

Getting Started with Statum

What is Statum

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:

  • Server-rendered HTML pages composed from templates and fragments at build time (statum-mkpstm), precompressed with gzip/zstd/brotli and served with encoding negotiation and ETags.
  • Client-carried state — signed and (for private data) encrypted snapshots that the browser holds and sends back, so the server is stateless between requests (no session store, no database for ephemeral state).
  • Entrypoint hydration — after a page loads, a lightweight JS client fetches directives that set the initial slot data and bind the DOM.
  • GUID actions — POST/GET handlers auto-mapped to /_statum/action/{guid}, invoked from the client via stm-action attributes, carrying strongly-typed private data.
  • Realtime updates — a SharedWorker-backed SSE channel pushes slot changes to subscribed tabs.

Architecture at a glance

 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)

Quick Start Examples

1. A page with an entrypoint

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();
}

2. A guarded page

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


HTML Templates

Statum pages are HTML files processed at build time by statum-mkpstm.

Build-time directives (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.

Head merging

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.

Fragments (components)

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.

Guards (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).

Build tools

# 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).


Slots and Snapshots

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.

Slot scopes

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

Snapshots and frames

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:

  • Reference form (key=…; as_at=…): sent while the snapshot is still cached server-side (within transmit_after).
  • Base64 frame form: the full signed frame, sent when the server needs the data re-sent (always-send slots, or after a cache miss).

The X-Statum-Slot header

Browsers 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 data

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


Entrypoint Handlers

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>() });
    }
}

Available fields (injected)

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

Reading held slots (typed)

CounterPublic? cart = request.held_as<CounterPublic>("counter");
string? species = request.route("species"); // from /cats/{species}

Action Handlers

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.

Basic action (no typed private)

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("/");
    }
}

Typed action (with strongly-typed private data)

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.

Authoring action references

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.

Registration

statum.action<LoginAction>();
statum.action<LogoutAction>();
statum.action<BumpAction>();

No route is specified — a GUID is auto-assigned at registration.


JS API Reference

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.

Configuration overrides (body attributes)

<body stm-entrypoint="/custom-entrypoint"
       stm-slots="/custom-slots"
       stm-channel="/custom-channel"
       stm-worker="/custom-worker">

HTML binding attributes (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-markdown="post.body" Renders the value as safe markdown (all source HTML escaped first, then formatted).
stm-h-demotion="1" Demotes child <h1><h5> tags by N levels. Runs after stm-html/stm-markdown.
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.

Guards

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

Boot states

<div stm-preloader>Loading…</div>   <!-- shown until entrypoint loads -->
<div stm-content>                   <!-- hidden until entrypoint loads -->
    Page content here.
</div>

Public JS API

// 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) { … });

// Hooks: no-arg callbacks before/after each render cycle
statum.beforeEvaluate(function() {
    document.body.classList.add('evaluating');
});
statum.afterEvaluate(function() {
    highlightCodeBlocks();
});

Vala API Reference

StatumModule

Registers the framework services and endpoints. Add it to your application:

app.add_module<StatumModule>();

StatumConfigurator

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

DirectiveBuilder

Started 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.
.form_submit(form_id, action?, method?) Terminal: submit a form by ID (optionally override action + method).
.form_reset(form_id) Terminal: reset a form by ID.
.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.
.set_global(type) Signs and emits set for a global broadcast slot.
.subscribe_global(type) Subscribes to a global broadcast slot's live updates.
.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.
new_global_slot(type, scope, state) Creates a global slot with deterministic key "global:" + type.
update_global(type, state) async Updates a global slot (sign + push to all subscribers).
update_global_typed<T>(type, mutator) async Typed read-mutate-write of a global slot.
clear_global(type) async Clears a global slot + pushes clear.

ActionRegistry

Method Description
author<TAction>(method) Action reference (no private).
author_private<TAction,TPrivate>(model, method) Action reference with sealed typed private.

Scope enum

PAGE, FLOW, SESSION, DEVICE, UNIDIRECTIONAL.

GObjectMapping

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

Static keys (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).