Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Anza Engine: Rust Reference

The Rust engine (crate anza) renders server-templated UI (STUI) from plain .html files: full HTML pages for the first load, and JSON envelopes ({ v: 2, slot, ts, html, sig?, css? }) for fragments, fetches and SSE/WebSocket pushes. Envelopes can be signed with HMAC-SHA256 or Ed25519 over the v2 signature message, and checked with anza::verify_envelope. The behaviour is shared with the TypeScript and Python engines (see the Engine Contract).

┌─────────────────┐   Full page (HTML, open DSD)            ┌─────────────────┐
│   Rust server   ├────────────────────────────────────────►│     Browser     │
│  (crate `anza`) │   Envelopes over fetch / SSE / WS       │ (custom elements│
│                 ├────────────────────────────────────────►│  + your code)   │
└─────────────────┘   { v, slot, ts, html, sig? }           └─────────────────┘

The CLI is a different crate: anza-cli (its binary is anza). These docs cover only the engine crate anza.

Key points

  1. Small core. default = [] in Cargo.toml: no web framework and no async runtime unless you enable the axum, actix or watch features.
  2. Parse once. Setup::run() reads every .html file under the root and splits it into static byte chunks and {{name}} placeholders. Rendering only copies chunks and parameter values into one pre-sized buffer.
  3. Placeholders only. The syntax is {{name}} (value) and {{{name}}} (raw). There are no loops, conditionals, filters or includes. By default values are inserted without HTML escaping: escape anything that comes from users with anza::escape_html, or set setup.escape = true so that {{name}} is escaped (see Rendering Pipeline).
  4. Two outputs. Page returns a Document (full HTML). Fragment and Stream return an Envelope.
  5. Signing. SignMode::Hmac and SignMode::Ed25519 add a hex sig to every envelope. Keys are validated in Setup::run(). anza::verify_envelope checks received envelopes on a server or edge. The browser library does not verify signatures for you (see Cryptographic Verification).
  6. Errors. anza::Error has the variants Validation, NotFound, Template, Crypto and Internal. They display as [Anza:CODE] message, and Error::code() returns the code (NOT_FOUND, …).

Quick example

[dependencies]
anza = "0.6"
serde_json = "1"
use anza::{Fragment, Page, Setup};

fn main() -> Result<(), Box<dyn std::error::Error>> {
  // 1. Load and parse ./templates once.
  let mut setup = Setup::new("templates");
  setup.signing = Setup::hmac(b"super-secret-origin-key-32-bytes");
  let engine = setup.run()?;

  // 2. Full page: templates/pages/home.html inside templates/layout/shell.html.
  let doc = Page::new("/").param("title", "Home Page").run(&engine)?;
  println!("{}", doc.html);

  // 3. Signed fragment envelope from templates/feed/card.html.
  let env = Fragment::new("feed/card.html", "main")
    .param("title", "Real-Time Update")
    .param("author", "Alex")
    .run(&engine)?;
  println!("{}", serde_json::to_string_pretty(&env)?);

  Ok(())
}

Root exports

anza::{Setup, Page, Fragment, Stream, Engine, SignMode, Params, Data, escape_html, verify_envelope, VerifyKey, VerifyOptions, Document, Envelope, Manifest, Error, Result}. Everything else is reached through its module path (anza::crypto::…, anza::stream::…, anza::render::…, anza::engine::…, anza::adapters::…).

Architecture

The engine has three steps: load (read and hash files), parse (split into chunks) and render (copy chunks and values into a buffer, then optionally sign).

  templates/**/*.html
         │  Setup::run()
         ▼
  ┌──────────────┐
  │ File loader  │ ──► SHA-256 digest of each file
  └──────┬───────┘
         ▼
  ┌──────────────┐
  │ Slot parser  │ ──► Vec<Chunk> [ Static, Slot, Raw ] + compiled plan
  └──────┬───────┘
         ▼
  ┌──────────────┐
  │ Cache store  │ ──► RwLock<HashMap<String, Arc<Template>>>
  └──────┬───────┘
         ├───► Engine::render_page()     ► Document
         └───► Engine::render_fragment() ► Envelope (+ signature)

1. Parse once

Templates are parsed once, when Setup::run() is called, into a flat list of Chunks:

  • Chunk::Static(Vec<u8>): raw HTML bytes.
  • Chunk::Slot(String): a placeholder name ({{ title }} becomes "title").
  • Chunk::Raw(String): a raw placeholder ({{{ content }}}), never escaped.

Each template is also compiled once into a binding plan: static text and slot positions, with every distinct name resolved once per render. A render sizes one output buffer and writes into it.

2. Template names

A template’s name is its path relative to the root, /-separated, e.g. feed/card.html. Only files ending in .html are loaded. Names have no leading /, no \ or NUL and no empty, . or .. segment; anything else is a Validation error. Page rendering uses these names:

PathUsed by
layout/shell.htmlOptional document shell for every page. The page goes into {{content}} ({{{content}}} with escape on).
pages/home.htmlRoute /
pages/<route>.htmlRoute /<route>
pages/index.htmlCatch-all for routes without their own page
anything elseFragments, addressed by name, e.g. feed/card.html (limited by Setup::fragments when set)

3. Reloading

Without watch, templates are not reloaded while the process runs. With the watch feature and setup.watch = true, a notify watcher on the root keeps the cache in sync: created and changed .html files are reloaded, removed ones are evicted. A reload that fails keeps the last good template. Dropping the engine (and its FileWatcher) stops watching.

Slot Chunking

Slot extraction turns a template string into a flat list of chunks.

1. Chunk

anza::engine::slot::Chunk (defined in src/engine/slot/parse.rs):

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Chunk {
  Static(Vec<u8>),
  Slot(String),
  Raw(String),
}
  • Chunk::Static: HTML bytes, written to the output as is.
  • Chunk::Slot: the trimmed placeholder name ({{ user_name }} becomes "user_name"). HTML-escaped when the engine runs with escape: true.
  • Chunk::Raw: the name of a raw placeholder ({{{ content }}}), always inserted as is.

2. extract

anza::engine::slot::extract(template: &str) -> anza::Result<Vec<Chunk>> scans the string once:

  1. Find the next {{; the text before it becomes a Chunk::Static.
  2. If it is {{{, find the next }}} and push a Chunk::Raw; otherwise find the next }} and push a Chunk::Slot with the trimmed text between.
  3. The rest of the string becomes a final Chunk::Static.

An unclosed {{ or {{{, or an empty placeholder, returns Error::Template. There is no other syntax: no loops, conditionals, filters or comments.

use anza::engine::slot::{extract, Chunk};

let chunks = extract("<div class=\"user\">{{name}} is {{status}}</div>")?;

assert_eq!(chunks.len(), 5);
assert_eq!(chunks[1], Chunk::Slot("name".into()));
assert_eq!(chunks[3], Chunk::Slot("status".into()));
// chunks[0], [2] and [4] are Chunk::Static

3. Binding

Each Template compiles its chunks once into an anza::engine::slot::Plan (in src/engine/slot/bind.rs): static text plus slot positions, with each distinct name looked up once per bind. Template::bind runs the plan. For each placeholder, the value comes from params.resolve(name); a missing value writes nothing.

To bind chunks without a Template, use the free functions (they compile a Plan on every call, so keep a Template or Plan for repeated binds):

pub fn string<P: Params + ?Sized>(chunks: &[Chunk], params: &P) -> Result<String>;
pub fn string_with<P: Params + ?Sized>(chunks: &[Chunk], params: &P, escape: bool) -> Result<String>;
pub fn inject<P: Params + ?Sized, W: std::io::Write>(chunks: &[Chunk], params: &P, out: &mut W) -> Result<()>;
pub fn inject_with<P: Params + ?Sized, W: std::io::Write>(chunks: &[Chunk], params: &P, escape: bool, out: &mut W) -> Result<()>;

4. Escaping

With escape off (the default, and in string / inject), {{name}} and {{{name}}} both write values as is, without HTML escaping. With escape on (Setup::escape, or string_with / inject_with with true), {{name}} values are passed through anza::escape_html and {{{name}}} values stay raw. escape_html maps & < > " ' to &amp; &lt; &gt; &quot; &#39;, which is safe in element content and quoted attribute values.

Template Cache

Templates are loaded, hashed with SHA-256 and stored in memory by Setup::run().

1. Template

anza::engine::file::Template (in src/engine/file/load.rs):

#[derive(Debug, Clone)]
pub struct Template {
  pub name: String,      // path relative to the root, e.g. "pages/home.html"
  pub path: PathBuf,     // root.join(name)
  pub raw: String,       // file contents
  pub digest: [u8; 32],  // SHA-256 of the contents
  pub chunks: Vec<Chunk>,
  pub escape: bool,      // Setup::escape: HTML-escape {{name}} values
  // plus a private compiled binding plan
}

impl Template {
  pub fn new(name, path, raw, escape: bool) -> Result<Self>; // parse and compile
  pub fn bind<P: Params + ?Sized>(&self, params: &P) -> Result<String>;
  pub fn render<T: serde::Serialize>(&self, data: &T) -> Result<String>;
}

render converts the value to a serde_json::Value once, then binds it.

At load, every .html file under the root is read. A file larger than Setup::max_template_bytes (default 1 MiB) or that is not UTF-8 is a Template error. Symlinked directories are not followed, and symlinked files that resolve outside the root are skipped. Errors name the template, never its absolute path.

2. Store and engine

pub struct CacheStore {
  pub templates: RwLock<HashMap<String, Arc<Template>>>,
}

pub struct Engine {
  pub root: PathBuf,
  pub cache: Arc<CacheStore>,
  pub watcher: Option<FileWatcher>,
  // private: the validated signer (see `engine.signer()`) and the
  // fragment allowlist
}

Engine is not Clone. Share it with Arc<Engine> (Axum state) or web::Data<Engine> (Actix-web).

Lookup

Engine::get(name) takes a read lock and returns Result<Arc<Template>>, a shared handle (the template is never copied). Lookups only use the cache: request input never reaches the filesystem. A malformed name (leading /, \, NUL, or an empty, . or .. segment) is Error::Validation; an unknown one is Error::NotFound:

let tpl = engine.get("feed/card.html")?;
let html = tpl.bind(&[("title", "Hello World")])?;

3. Manifest

Engine::manifest() returns Result<Manifest>. Manifest::templates is a HashMap<String, String> from template name to the 64-character lowercase hex SHA-256 of the template contents (anza::crypto::digest), the same digest the TypeScript and Python engines produce. It changes whenever a file changes, so it can be used for cache busting or deployment checks.

let manifest = engine.manifest()?;
for (name, digest) in &manifest.templates {
  println!("{name} -> {}", &digest[..12]);
}

Rendering Pipeline

There are two outputs: a full HTML Document for the first load, and an Envelope for fragments and pushes.

                        Incoming request
                               │
                ┌──────────────┴──────────────┐
                │                             │
         Initial HTTP GET            Fetch / SSE / WebSocket
                │                             │
                ▼                             ▼
     Page::new(route).run()    Fragment::new(tpl, slot).run()
                │                             │
        ┌───────┴────────┐            ┌───────┴────────┐
        │ Bind page,     │            │ Bind fragment  │
        │ wrap in shell  │            │ template       │
        └───────┬────────┘            └───────┬────────┘
                │                             ▼
                │                     ┌────────────────┐
                │                     │ Sign v2 message│
                │                     │ (optional)     │
                │                     └───────┬────────┘
                ▼                             ▼
        Document { html }      Envelope { v, slot, ts, html, sig?, css? }

Comparison

Page (Engine::render_page)Fragment (Engine::render_fragment)
Return typeDocument ({ html: String })Envelope ({ v, slot, ts, html, sig, css })
OutputComplete HTML documentJSON (or an SSE / WebSocket frame)
Browser sideParsed by the browser; open DSD attaches shadow rootsYour code puts html into the target; the browser library has no envelope handler and does not check signatures
Typical useFirst paint, SEOPartial updates, live feeds, form responses

The builders Page, Fragment and Stream (Stream is identical to Fragment, named for push use) collect parameters with .param(key, value) and call the engine in .run(&engine). The engine methods take a &HashMap<String, String> directly:

pub fn render_page(&self, route: &str, params: &HashMap<String, String>) -> Result<Document>;
pub fn render_fragment(&self, template_name: &str, slot: &str, params: &HashMap<String, String>) -> Result<Envelope>;

Both return anza::Error on failure: NotFound for an unknown or malformed page route and for an unknown template (or one outside the fragment allowlist), Validation for a malformed template name. The builders also reject an empty route, template or slot with Validation. Errors display as [Anza:CODE] message.

Escaping

Templates have two placeholders: {{name}} inserts a value and {{{name}}} inserts it raw. With escape off (the default) both insert values as is, so escape untrusted input yourself:

use anza::{escape_html, Fragment};

let comment = escape_html(user_input); // & < > " ' -> &amp; &lt; &gt; &quot; &#39;
let env = Fragment::new("feed/comment.html", "feed")
  .param("body", comment)
  .run(&engine)?;

With escape on, the engine escapes {{name}} values for you and leaves {{{name}}} raw. Use {{{content}}} in layout/shell.html, or the page HTML is escaped too:

let mut setup = Setup::new("templates");
setup.escape = true;
let engine = setup.run()?;

escape_html returns a Cow<str>. Its output is safe in element content and in quoted attribute values.

Full-Page SSR & Open Declarative Shadow DOM

A page render returns a complete HTML document. Templates can use open Declarative Shadow DOM (<template shadowrootmode="open">) so that shadow roots exist before any JavaScript runs.

1. Open Declarative Shadow DOM

<dock-main>
  <template shadowrootmode="open">
    <style>
      :host { display: block; padding: 2rem; }
    </style>
    <slot></slot>
  </template>
  <div class="page-content">
    <h1>Rendered on the server</h1>
  </div>
</dock-main>

When the browser parses this HTML it attaches a ShadowRoot to <dock-main> during parsing, so scoped styles apply before scripts load, and the light-DOM content is visible to crawlers. A custom element defined later finds the existing root in this.shadowRoot instead of creating a new one.

The engine itself does not add shadow roots to your templates (except in the fallback document below). Write the <template shadowrootmode="open"> markup in your templates, or build it with anza::render::page::wrap_dsd(tag, inner_html, shadow_content).

2. Rendering a page

use anza::Page;

let doc = Page::new("/article")
  .param("title", "Deep Dive into STUI")
  .param("author", "Alex Rivera")
  .run(&engine)?;

// doc.html holds the full document; doc.as_str() and doc.into_bytes() also work.

3. Page pipeline

Engine::render_page (src/render/page/compile.rs) runs the same pipeline as the TypeScript and Python engines:

  1. Map the route to a template under pages/: / (or an empty route) → pages/home.html, /article and /article/ → pages/article.html, /a/b → pages/a/b.html.
  2. If that template doesn’t exist, use pages/index.html as the catch-all.
  3. Otherwise, or when the route is malformed (.., \, NUL), return Error::NotFound("Page not found"). The route is never echoed into the output or the error. Routes only resolve under pages/, so a route can’t render a fragment, a layout or any other template.
  4. Bind the page template with the params. If layout/shell.html exists, bind it with the same params plus content set to the page HTML (the engine’s content wins over a param of that name). Otherwise emit the default document below.
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Anza App</title>
</head>
<body>
  <dock-main>
    <template shadowrootmode="open">
      <slot></slot>
    </template>
    <!-- page -->
  </dock-main>
</body>
</html>

A minimal shell:

<!DOCTYPE html>
<html lang="en">
<head><title>{{title}}</title></head>
<body>
  <dock-main>{{{content}}}</dock-main>
</body>
</html>

{{{content}}} is always raw. With setup.escape = true, {{content}} would escape the page HTML, so use the triple-brace form in shells.

To answer unknown routes with a 404, match the error:

match Page::new(path).run(&engine) {
  Ok(doc) => Ok(doc),
  Err(anza::Error::NotFound(_)) => Err(StatusCode::NOT_FOUND),
  Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}

There is no route-parameter matching: /article/my-slug looks for pages/article/my-slug.html, then falls back to pages/index.html.

Fragment Envelopes

Fragments and pushes return an Envelope.

1. Envelope

anza::Envelope (in src/models/envelope.rs):

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Envelope {
  #[serde(default)]
  pub v: u32,
  pub slot: String,
  pub ts: u64,
  pub html: String,
  #[serde(skip_serializing_if = "Option::is_none")]
  pub sig: Option<String>,
  #[serde(skip_serializing_if = "Option::is_none")]
  pub css: Option<String>,
}
  • v: wire-format version, always 2 (serialized first). An envelope without v deserializes as 0 and fails verification.
  • slot: the target name you chose when rendering.
  • ts: Unix time in seconds when the envelope was rendered.
  • html: the bound fragment HTML (values escaped only with escape on).
  • sig: lowercase hex signature over the v2 message (see Cryptographic Verification), present for Hmac and Ed25519 signing.
  • css: optional CSS text; the engine never sets it, use .style(css).

Helpers: Envelope::new(slot, ts, html) (sets v: 2), .sign(sig), .style(css) and .message() (returns the v2 message bytes, Vec<u8>, that the signature covers).

2. Rendering a fragment

use anza::Fragment;

let envelope = Fragment::new("feed/card.html", "feed-slot")
  .param("title", "New Event Received")
  .param("status", "processed")
  .run(&engine)?;

println!("slot: {}", envelope.slot);
println!("ts:   {}", envelope.ts);
println!("sig:  {:?}", envelope.sig);

Engine::render_fragment looks up the template, checks the fragment allowlist, binds the params, takes the current time, signs the v2 message with the engine’s key (if any), and returns the envelope. The same function is available as anza::render::fragment::envelope(&engine, template, slot, &params).

3. Fragment allowlist

By default any cached template can be rendered as a fragment. Set Setup::fragments to limit that. An entry ending in / allows that prefix; any other entry allows that exact name:

let mut setup = Setup::new("templates");
setup.fragments = vec!["fragments/".into(), "feed/card.html".into()];
let engine = setup.run()?;

// Allowed: fragments/... and feed/card.html.
// Anything else (pages/home.html, layout/shell.html) is
// Error::NotFound("Template 'pages/home.html' not found"), the same as a
// missing template.

Template names are checked first: a malformed name (leading /, \, NUL, or an empty, . or .. segment) is a Validation error.

4. Structured data

To bind structured data instead of string pairs, bind the template yourself and build the envelope:

use anza::Envelope;

let tpl = engine.get("feed/card.html")?; // Arc<Template>
let html = tpl.render(&article)?; // any serde::Serialize value
let ts = 1_724_771_200;
let mut env = Envelope::new("feed".to_string(), ts, html);
if let Some(sig) = engine.sign_payload(ts, &env.slot, &env.html)? {
  env = env.sign(sig);
}

Cryptographic Verification

TLS protects each hop, but a CDN or proxy that terminates TLS can still change a response. Signed envelopes let whoever receives a fragment check that it came from the origin unchanged.

1. Signing modes

anza::SignMode (in src/engine/cache/mod.rs):

#[derive(Clone)] // Debug is implemented by hand and redacts keys
pub enum SignMode {
  Ed25519 { private_key: Vec<u8>, public_key: Vec<u8> },
  Hmac { secret: Vec<u8> },
  None,
}

Set it on Setup before run():

use anza::{Setup, SignMode};

let mut setup = Setup::new("templates");
setup.signing = Setup::hmac(b"pre-shared-secret-key-32-bytes!!");
// or: files holding the raw 32-byte Ed25519 private seed and public key
setup.signing = Setup::ed25519("keys/origin.key", "keys/origin.pub")?;
// or: no signature (the default)
setup.signing = SignMode::None;
let engine = setup.run()?;
ModeWhat happensVerify with
Ed25519sig = hex Ed25519 signature (64 bytes, 128 hex characters)anza::verify_envelope with VerifyKey::Ed25519(&public_key)
Hmacsig = hex HMAC-SHA256 (32 bytes, 64 hex characters)anza::verify_envelope with VerifyKey::Hmac(&secret)
NoneNo sig—

Notes:

  • Setup::ed25519 returns Result<SignMode>: a missing or unreadable key file is an Error::Crypto right away.
  • Keys are validated once, in Setup::run(). These are Error::Crypto at setup, never an unsigned envelope or a render-time error: an HMAC secret shorter than 32 bytes, an Ed25519 private key that is not a 32-byte seed, and a public key that is given but doesn’t match the seed. public_key may be empty; the engine then derives it from the seed.
  • engine.signer() returns the validated anza::crypto::signer::Signer. The engine does not inject the public key into pages.
  • Debug for SignMode, Signer and VerifyKey prints <redacted> instead of private key material.

2. Signed message

The signature covers the v2 message, exactly as produced by Envelope::message() or anza::crypto::envelope::message(ts, slot, html, css) (both return Vec<u8>):

message  = "anza-sig-v2\n" field(ts) field(slot) field(html) field(css)
field(x) = <decimal UTF-8 byte length of x> ":" x "\n"

ts is written in decimal and an absent css is the empty string. For ts = 1, slot = "a:b", html = "c", the message is anza-sig-v2\n1:1\n3:a:b\n1:c\n0:\n. Every field is length-prefixed, so two different envelopes never share a message. Signatures are lowercase hex, and anza::crypto::hex rejects uppercase, odd-length or non-hex input.

Changing the timestamp, slot, HTML or CSS breaks the signature.

3. Verifying envelopes

anza::verify_envelope checks a received envelope:

use anza::{verify_envelope, Envelope, VerifyKey, VerifyOptions};

let envelope: Envelope = serde_json::from_str(&body)?;
let options = VerifyOptions::new("feed", 60); // expected slot, max age in seconds

verify_envelope(VerifyKey::Hmac(&secret), &envelope, &options)?;
// or: verify_envelope(VerifyKey::Ed25519(&public_key), &envelope, &options)?;

// With the engine's own key (HMAC secret or Ed25519 public key):
engine.verify_envelope(&envelope, &options)?;

VerifyOptions has the public fields max_age_secs, slot, skew_secs (default 5) and now (Option<u64>, None uses the system clock). The checks run in this order; the first failure is an Error::Crypto with this message:

CheckMessage
v is 2unsupported envelope version
sig is presentenvelope is not signed
sig is strict lowercase hexmalformed signature
ts <= now + skew_secsenvelope timestamp is in the future
now - ts <= max_age_secsenvelope expired
slot equals the expected slotslot mismatch
the signature verifies (constant time)bad signature

An HMAC secret shorter than 32 bytes or a public key that is not 32 bytes is invalid verification key. engine.verify_envelope on an engine without signing is signing is off. The slot check stops a fragment signed for one slot from being replayed into another.

4. Who verifies

The Anza browser library does not verify envelopes: it has no envelope or stream handler. Verify on the server or edge that relays fragments with verify_envelope, or in your own browser code by rebuilding the v2 message and calling crypto.subtle.verify (see Ed25519 Signing).

Ed25519 Signing

Ed25519 lets anyone holding the public key verify envelopes without being able to create them.

1. Flow

  1. Generate a keypair once and store the raw 32-byte private seed and public key in two files (the engine does not generate keys).
  2. Configure setup.signing = Setup::ed25519(private_path, public_path)?. A missing file is an error, and Setup::run() checks that the private key is a 32-byte seed and that the public key matches it.
  3. Every envelope gets sig = lowercase hex Ed25519 signature over the v2 message (see Cryptographic Verification).
  4. A verifier with the public key checks the version, freshness, slot and signature.
┌────────────────────────┐
│  Rust origin           │  signs the v2 message with the private key
└───────────┬────────────┘
            ▼
┌────────────────────────┐
│  CDN / proxy           │  forwards the envelope
└───────────┬────────────┘
            ▼
┌────────────────────────┐
│  Verifier (edge,       │  checks sig with the public key
│  service or your JS)   │
└────────────────────────┘

2. Rust API

src/crypto/ed25519/sign.rs and src/crypto/ed25519/verify.rs:

// anza::crypto::ed25519::sign
pub fn payload(private_key_bytes: &[u8], data: &[u8]) -> anza::Result<String>;
// anza::crypto::ed25519::verify
pub fn check(public_key_bytes: &[u8], data: &[u8], signature_hex: &str) -> bool;
use anza::crypto::{ed25519::{sign, verify}, envelope};

// Raw 32-byte keys, e.g. from ed25519-dalek or read from the files above.
let private_key: [u8; 32] = std::fs::read("keys/origin.key")?.try_into().unwrap();
let public_key: [u8; 32] = std::fs::read("keys/origin.pub")?.try_into().unwrap();

let message = envelope::message(1_724_771_200, "feed", "<ui-card>Live</ui-card>", None);
let signature_hex = sign::payload(&private_key, &message)?;

assert!(verify::check(&public_key, &message, &signature_hex));

check returns false (never an error) for a bad key, bad hex or a wrong signature. It only checks the signature: use verify_envelope (below) to also check the version, age and slot.

To create a keypair, use ed25519-dalek (with its rand_core feature) directly:

use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;

let signing_key = SigningKey::generate(&mut OsRng);
std::fs::write("keys/origin.key", signing_key.to_bytes())?;
std::fs::write("keys/origin.pub", signing_key.verifying_key().to_bytes())?;

3. Verifying on the server

A service or edge that holds only the public key verifies a received envelope with anza::verify_envelope:

use anza::{verify_envelope, Envelope, VerifyKey, VerifyOptions};

let public_key = std::fs::read("keys/origin.pub")?;
let envelope: Envelope = serde_json::from_str(&body)?;

// Slot "feed", at most 60 seconds old, up to 5 seconds in the future.
verify_envelope(VerifyKey::Ed25519(&public_key), &envelope, &VerifyOptions::new("feed", 60))?;

Every failure is an Error::Crypto naming the check (see Cryptographic Verification). A public key that is not 32 bytes is invalid verification key.

4. Verifying in the browser (your code)

The Anza browser library does not do this for you. If you want browser-side checks, publish the public key yourself (for example in a <meta name="anza-key"> tag), rebuild the v2 message and verify with Web Crypto. Ed25519 in crypto.subtle is supported in current Chrome, Firefox and Safari.

const enc = new TextEncoder();

// "anza-sig-v2\n" field(ts) field(slot) field(html) field(css)
function signatureMessage({ ts, slot, html, css = '' }) {
  const parts = [enc.encode('anza-sig-v2\n')];
  for (const value of [String(ts), slot, html, css]) {
    const bytes = enc.encode(value);
    parts.push(enc.encode(`${bytes.length}:`), bytes, enc.encode('\n'));
  }
  const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
  let offset = 0;
  for (const p of parts) { out.set(p, offset); offset += p.length; }
  return out;
}

async function verifyEnvelope(envelope, publicKeyBytes, slot, maxAgeSeconds = 60) {
  if (envelope.v !== 2 || !/^[0-9a-f]{128}$/.test(envelope.sig ?? '')) return false;
  const now = Math.floor(Date.now() / 1000);
  if (envelope.ts > now + 5 || now - envelope.ts > maxAgeSeconds) return false;
  if (envelope.slot !== slot) return false;

  const key = await crypto.subtle.importKey('raw', publicKeyBytes, { name: 'Ed25519' }, false, ['verify']);
  const sig = Uint8Array.from(envelope.sig.match(/../g), (h) => parseInt(h, 16));
  return crypto.subtle.verify({ name: 'Ed25519' }, key, sig, signatureMessage(envelope));
}

HMAC-SHA256

HMAC-SHA256 uses one shared secret for signing and verifying. Use it when every verifier is trusted with the secret (internal services, your own edge). Never ship the secret to browsers.

1. Rust API

src/crypto/hmac/sign.rs and src/crypto/hmac/verify.rs:

// anza::crypto::hmac::sign
pub fn payload(secret: &[u8], data: &[u8]) -> anza::Result<String>;
// anza::crypto::hmac::verify
pub fn check(secret: &[u8], data: &[u8], signature_hex: &str) -> bool;
use anza::crypto::{envelope, hmac::{sign, verify}};

let secret = b"my-secure-cluster-shared-secret-32b";
let message = envelope::message(1_724_771_200, "feed", "<ui-alert>OK</ui-alert>", None);

let sig = sign::payload(secret, &message)?;
assert!(verify::check(secret, &message, &sig));

When signing through the engine, Setup::run() rejects a secret shorter than 32 bytes with an Error::Crypto.

2. Constant-time comparison

check recomputes the MAC and compares it with Mac::verify_slice from the hmac crate, which compares in constant time. It returns false for invalid hex (including uppercase or odd-length hex) or a wrong signature.

3. Tampering

Any change to the timestamp, slot or HTML makes verification fail:

let tampered = envelope::message(1_724_771_200, "feed", "<ui-alert>Corrupted</ui-alert>", None);
assert!(!verify::check(secret, &tampered, &sig));

let other_time = envelope::message(1_724_779_999, "feed", "<ui-alert>OK</ui-alert>", None);
assert!(!verify::check(secret, &other_time, &sig));

check alone accepts a replayed old envelope, or one replayed into another slot. anza::verify_envelope also checks the version, the age and the slot:

use anza::{verify_envelope, VerifyKey, VerifyOptions};

verify_envelope(VerifyKey::Hmac(secret), &received, &VerifyOptions::new("feed", 60))?;

It fails with Error::Crypto (envelope expired, slot mismatch, bad signature, …); a secret shorter than 32 bytes is invalid verification key.

Key Derivation with HKDF

The crate includes an HKDF-SHA256 helper (RFC 5869) for deriving per-session keys, for example a per-user HMAC secret from a session token.

No signing mode uses it automatically. To sign per session, derive a key and build an engine with Setup::hmac (or call anza::crypto::hmac::sign::payload) with it yourself.

1. Rust API

src/crypto/hkdf/derive.rs:

// anza::crypto::hkdf::derive
pub fn key(ikm: &[u8], salt: Option<&[u8]>, info: &[u8]) -> anza::Result<[u8; 32]>;
use anza::crypto::{hkdf::derive, hmac::sign};

let session_token = b"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...";
let salt = b"anza-cluster-salt-2026";

let user_key: [u8; 32] = derive::key(session_token, Some(salt), b"user-live-feed")?;

let envelope_message = anza::crypto::envelope::message(1_724_771_200, "feed", "<ui-card>Hi</ui-card>", None);
let sig = sign::payload(&user_key, &envelope_message)?;

2. Properties

  1. Stateless: the same inputs always give the same key, so the server can re-derive it per request.
  2. Isolation: knowing one derived key does not reveal the input key or other derived keys.

Real-Time Streaming

Server-Sent Events (SSE) and WebSockets carry the same Envelope JSON as a fragment fetch. The engine provides the encoders; the transport (and the loop that pushes updates) is your web framework’s.

  Client (EventSource / WS)                 Rust server
            │  1. GET /feed/stream (SSE)                │
            ├──────────────────────────────────────────►│
            │  2. event: template                       │
            │     data: {"v":2,"slot":"feed","ts":…}    │
            │◄──────────────────────────────────────────┤
            │  3. event: template …                     │
            │◄──────────────────────────────────────────┤

Wire format

Each SSE message is:

  1. event: template
  2. data: <Envelope JSON>: compact JSON with v (always 2), slot, ts, html and, when signed, sig
  3. a blank line (\n\n)

A WebSocket message is the envelope JSON as a text frame.

EncoderOutput
anza::stream::sse::encode(&Envelope) -> String"event: template\ndata: {…}\n\n"
anza::stream::ws::encode(&Envelope) -> String"{…}"
anza::adapters::axum::event(Envelope) -> Result<axum::response::sse::Event, axum::Error>Axum SSE Event (feature axum)
anza::adapters::actix::bytes(&Envelope) -> actix_web::web::BytesSSE frame bytes (feature actix)

The browser library has no built-in listener for these events. Use EventSource / WebSocket and apply envelope.html to the target yourself.

Server-Sent Events (SSE)

1. Encoding a frame

src/stream/sse/frame.rs:

use anza::{stream::sse, Envelope};

let envelope = Envelope::new("feed", 1_724_771_200, "<article>Live Post</article>");

let frame: String = sse::encode(&envelope);
assert!(frame.starts_with("event: template\ndata: {"));
assert!(frame.ends_with("\n\n"));

encode returns a String (not a Result). Write it to any streaming response body.

2. Axum SSE route

With the axum feature, anza::adapters::axum::event turns an envelope into an axum::response::sse::Event named template. This example also needs futures-util and tokio (with the time feature) in your own Cargo.toml.

use std::{sync::Arc, time::Duration};

use anza::{adapters::axum::event, Engine, Fragment};
use axum::{
  extract::State,
  response::sse::{Event, KeepAlive, Sse},
};
use futures_util::stream::{self, Stream};

async fn stream_feed(
  State(engine): State<Arc<Engine>>,
) -> Sse<impl Stream<Item = Result<Event, axum::Error>>> {
  let updates = stream::unfold(engine, |engine| async move {
    tokio::time::sleep(Duration::from_secs(2)).await;
    let item = Fragment::new("feed/card.html", "feed")
      .param("title", "Live Event")
      .run(&engine)
      .map_err(axum::Error::new)
      .and_then(event);
    Some((item, engine))
  });

  Sse::new(updates).keep_alive(KeepAlive::default())
}

WebSocket Frames

For WebSockets, send the envelope JSON as a text message.

1. Encoding

src/stream/ws/packet.rs:

use anza::{stream::ws, Envelope};

let envelope = Envelope::new("notifications", 1_724_771_200, "<ui-toast>Order Shipped</ui-toast>");

let text: String = ws::encode(&envelope);
// send `text` as a WebSocket text message with your framework

2. Receiving in the browser

The Anza browser library has no WebSocket envelope handler, so this is your code. If the envelopes are signed and you want to check them in the browser, see Ed25519 Signing. Remember that the engine escapes values in html only when escape is on.

ws.onmessage = async (event) => {
  const envelope = JSON.parse(event.data);

  if (!(await verifyEnvelope(envelope, publicKeyBytes, 'notifications'))) {
    console.error('Rejected envelope');
    return;
  }

  const target = document.querySelector(`[data-slot="${envelope.slot}"]`);
  if (target) target.innerHTML = envelope.html;
};

Framework Adapters

Adapters are behind Cargo features, so the default build has no web framework.

1. Feature flags

[dependencies]
# Axum 0.8
anza = { version = "0.6", features = ["axum"] }

# Actix-web 4
anza = { version = "0.6", features = ["actix"] }

# Everything
anza = { version = "0.6", features = ["full"] }
FeatureProvides
axumIntoResponse for Document (HTML) and Envelope (JSON); anza::adapters::axum::event for SSE
actixResponder for Document (HTML) and Envelope (JSON); anza::adapters::actix::bytes for SSE
watchStarts a notify watcher on the template root and reloads changed templates into the cache
fullAll of the above

2. Sharing the engine

Engine is not Clone. Wrap it in Arc<Engine> for Axum state, or in actix_web::web::Data<Engine> (which is an Arc) for Actix-web.

Axum Integration

With the axum feature, Document converts to an HTML response and Envelope to a JSON response, so handlers can return them directly.

1. Page handler

use std::sync::Arc;

use anza::{Document, Engine, Page};
use axum::{extract::State, http::StatusCode};

async fn home(State(engine): State<Arc<Engine>>) -> Result<Document, StatusCode> {
  Page::new("/")
    .param("title", "Home Page")
    .run(&engine)
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}

2. Fragment handler

use std::sync::Arc;

use anza::{Engine, Envelope, Fragment};
use axum::{
  extract::{Path, State},
  http::StatusCode,
};

async fn card(
  State(engine): State<Arc<Engine>>,
  Path(id): Path<String>,
) -> Result<Envelope, StatusCode> {
  Fragment::new("feed/card.html", "feed")
    .param("id", anza::escape_html(&id)) // values are inserted as is unless `escape` is on
    .param("title", "Updated Card")
    .run(&engine)
    .map_err(|_| StatusCode::NOT_FOUND)
}

3. Router

use std::sync::Arc;

use anza::Setup;
use axum::{routing::get, Router};

#[tokio::main]
async fn main() {
  let engine = Arc::new(Setup::new("templates").run().expect("load templates"));

  let app = Router::new()
    .route("/", get(home))
    .route("/card/{id}", get(card))
    .with_state(engine);

  let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
  axum::serve(listener, app).await.unwrap();
}

For SSE, see Server-Sent Events (SSE).

Actix-web Integration

With the actix feature, Document and Envelope implement actix_web::Responder (HTML and JSON).

1. Handlers

use actix_web::{error::ErrorInternalServerError, get, web};
use anza::{Document, Engine, Envelope, Fragment, Page};

#[get("/")]
async fn index(engine: web::Data<Engine>) -> actix_web::Result<Document> {
  Page::new("/")
    .param("title", "Actix STUI")
    .run(&engine)
    .map_err(ErrorInternalServerError)
}

#[get("/card")]
async fn card(engine: web::Data<Engine>) -> actix_web::Result<Envelope> {
  Fragment::new("feed/card.html", "feed")
    .param("title", "Live")
    .run(&engine)
    .map_err(ErrorInternalServerError)
}

2. Server

use actix_web::{web, App, HttpServer};
use anza::Setup;

#[actix_web::main]
async fn main() -> std::io::Result<()> {
  let engine = web::Data::new(Setup::new("templates").run().expect("load templates"));

  HttpServer::new(move || {
    App::new()
      .app_data(engine.clone())
      .service(index)
      .service(card)
  })
  .bind(("127.0.0.1", 8080))?
  .run()
  .await
}

For SSE bodies, anza::adapters::actix::bytes(&envelope) returns the encoded frame as actix_web::web::Bytes.

Parameter Binding & the Params Trait

Template::bind takes anything that implements anza::Params (src/engine/slot/bind.rs). The builders (Page, Fragment, Stream) and the Engine::render_* methods always use HashMap<String, String>; the other implementations are for binding a template yourself with engine.get(name)?.bind(...).

1. The trait

use std::borrow::Cow;

pub trait Params {
  fn resolve(&self, key: &str) -> Option<Cow<'_, str>>;

  // Optional hooks with default implementations, used by the built-in types
  // to bind faster: prepare() (convert once per bind), indexed() and fill()
  // (resolve every name in one pass). Implementing `resolve` is enough.
}

Cow::Borrowed lets a value be copied straight from your data without an extra String; Cow::Owned is for values that must be formatted (numbers, dates). Returning None renders the placeholder as an empty string.

2. Built-in implementations

TypeNotes
()No values
[(&str, &str); N] and [(&str, &str)]Linear search; no allocation
HashMap<String, String>Borrowed values
serde_json::ValueObject keys; booleans as true/false, integral floats without .0 (3.0 → 3), null is empty, arrays/objects as compact JSON
Data<T: Serialize>Serializes T to a serde_json::Value once per bind
&T where T: ParamsForwards
use std::collections::HashMap;
use anza::Data;

let tpl = engine.get("feed/card.html")?;

let html = tpl.bind(&[("title", "STUI Guide"), ("author", "Sarah")])?;
let html = tpl.bind(&())?;

let mut map = HashMap::new();
map.insert("title".to_string(), "Dynamic Title".to_string());
let html = tpl.bind(&map)?;

let html = tpl.bind(&serde_json::json!({ "title": "From JSON", "count": 3 }))?;

// Any Serialize value (equivalent to tpl.render(&profile)):
let html = tpl.bind(&Data(&profile))?;

HashMap<&str, &str> does not implement Params; use an array of pairs or HashMap<String, String>.

By default all values are inserted without HTML escaping: escape untrusted input with anza::escape_html first. With Setup::escape on, {{name}} values are escaped for you and {{{name}}} values stay raw.

Implementing Params for Your Structs

Implementing Params directly on a struct avoids building a HashMap or a JSON value for each render: string fields are borrowed, and only formatted values allocate.

1. Implementation

use std::borrow::Cow;
use anza::Params;

pub struct Article {
  pub id: i64,
  pub slug: String,
  pub title: String,
  pub author: String,
}

impl Params for Article {
  fn resolve(&self, key: &str) -> Option<Cow<'_, str>> {
    match key {
      "id" => Some(Cow::Owned(self.id.to_string())),
      "slug" => Some(Cow::Borrowed(self.slug.as_str())),
      "title" => Some(Cow::Borrowed(self.title.as_str())),
      "author" => Some(Cow::Borrowed(self.author.as_str())),
      _ => None,
    }
  }
}

2. Usage

let article = Article {
  id: 42,
  slug: "stui-in-rust".into(),
  title: "Server-Templated UI in Rust".into(),
  author: "Alex Rivera".into(),
};

let html = engine.get("feed/article.html")?.bind(&article)?;

Engine::get returns an Arc<Template> (no copy of the template), and bind resolves each distinct placeholder name once and allocates the output buffer once.

3. Compared with the alternatives

TechniqueExtra work per render
impl Params for StructNone for borrowed fields
HashMap<String, String>Building the map (one String per key and value)
serde_json::ValueBuilding the value tree
Data(&value) / Template::renderSerializing the whole value to a serde_json::Value once per render

No benchmark numbers are published for these; measure in your own setup.

Whichever technique you use, values are inserted without HTML escaping unless Setup::escape is on (then {{name}} is escaped and {{{name}}} is raw).

Performance

The engine keeps request-time work small. There is no published benchmark suite for it yet; measure your own templates before relying on specific numbers.

What the design does

  1. Parse at startup. {{…}} delimiters are found once in Setup::run(); no parsing or regular expressions at request time.
  2. Compiled templates. Each template is compiled once into a binding plan; a render resolves each distinct placeholder name once, sizes one String and writes static text and values into it.
  3. Borrowed values. Params returns Cow<str>, so struct fields and HashMap values are copied straight into the output.
  4. Shared read lock. Lookups take a read lock on an RwLock<HashMap>; many requests can read at once. Engine::get returns an Arc<Template>, so a lookup never copies the template.
  5. Signing cost. Keys are decoded and validated once in Setup::run(). HMAC-SHA256 and Ed25519 signing run once per envelope over the v2 message, which allocates the message bytes. Unsigned engines build no message.

Buffer Pre-Sizing & Memory Layout

1. A loaded template

Template
┌─────────────────────────────────────────────────────────────┐
│ name:   String   "feed/card.html"                           │
│ path:   PathBuf  "templates/feed/card.html"                 │
│ raw:    String   "<ui-card>{{title}}</ui-card>"             │
│ digest: [u8; 32] SHA-256 of raw                             │
│ chunks: Vec<Chunk>                                          │
│         ├── Chunk::Static(b"<ui-card>")                     │
│         ├── Chunk::Slot("title")                            │
│         └── Chunk::Static(b"</ui-card>")                    │
│ escape: bool                                                │
│ plan:   compiled static text + slot positions (private)     │
└─────────────────────────────────────────────────────────────┘

Each template keeps the raw text, the chunks and the compiled plan, so memory is roughly three times the template source size. Templates live as Arc<Template> in an RwLock<HashMap> inside an Arc<CacheStore>. They only change when the watch feature reloads them.

2. What a render allocates

During tpl.bind(&params) (anza::engine::slot::Plan::bind):

  1. Prepare: Data<T> is serialized to a serde_json::Value once.
  2. Resolve: each distinct placeholder name is looked up once (pair lists are scanned once for all names).
  3. Allocate: one String, sized from the static text plus the values (or an estimate for maps and objects; it grows if values are longer).
  4. Copy: static text and resolved values are written in order, escaped where escape applies.

Values that Params returns as Cow::Owned (numbers, Data, JSON values) allocate their own strings before being copied.