clanker 2 недель назад
Родитель
Сommit
d337fde714
1 измененных файлов с 530 добавлено и 0 удалено
  1. 530 0
      getting-started.md

+ 530 - 0
getting-started.md

@@ -0,0 +1,530 @@
+# Getting Started with Statum
+
+## What is Statum
+
+Statum is a stateful web application framework for Vala, built on top of
+[Astralis](https://github.com/search/astralis) (HTTP server),
+[Invercargill](https://github.com/search/invercargill) (data structures / JSON),
+and [Inversion](https://github.com/search/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:
+
+```html
+<!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:
+
+```html
+<!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:
+
+```vala
+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:
+
+```vala
+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
+
+```html
+<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:
+
+```html
+<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
+
+```bash
+# 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_target`s (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.
+
+```vala
+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)
+
+```vala
+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)
+
+```vala
+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)
+
+```vala
+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`:
+
+```vala
+// 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
+
+```vala
+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:
+
+```html
+<script pstm-res-src="statum.js"></script>
+```
+
+It auto-initialises on DOMContentLoaded.
+
+### Configuration overrides (body attributes)
+
+```html
+<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-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
+
+```html
+<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
+
+```html
+<div stm-preloader>Loading…</div>   <!-- shown until entrypoint loads -->
+<div stm-content>                   <!-- hidden until entrypoint loads -->
+    Page content here.
+</div>
+```
+
+### Public JS API
+
+```js
+// 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) { … });
+```
+
+---
+
+## Vala API Reference
+
+### `StatumModule`
+
+Registers the framework services and endpoints. Add it to your application:
+
+```vala
+app.add_module<StatumModule>();
+```
+
+### `StatumConfigurator`
+
+Binds pages, actions, and resources:
+
+```vala
+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. |
+| `.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` 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:
+
+```bash
+statum-genkeys > web-config.json
+```
+
+```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).