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 isanza). These docs cover only the engine crateanza.
Key points
- Small core.
default = []inCargo.toml: no web framework and no async runtime unless you enable theaxum,actixorwatchfeatures. - Parse once.
Setup::run()reads every.htmlfile 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. - 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 withanza::escape_html, or setsetup.escape = trueso that{{name}}is escaped (see Rendering Pipeline). - Two outputs.
Pagereturns aDocument(full HTML).FragmentandStreamreturn anEnvelope. - Signing.
SignMode::HmacandSignMode::Ed25519add a hexsigto every envelope. Keys are validated inSetup::run().anza::verify_envelopechecks received envelopes on a server or edge. The browser library does not verify signatures for you (see Cryptographic Verification). - Errors.
anza::Errorhas the variantsValidation,NotFound,Template,CryptoandInternal. They display as[Anza:CODE] message, andError::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:
| Path | Used by |
|---|---|
layout/shell.html | Optional document shell for every page. The page goes into {{content}} ({{{content}}} with escape on). |
pages/home.html | Route / |
pages/<route>.html | Route /<route> |
pages/index.html | Catch-all for routes without their own page |
| anything else | Fragments, 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 withescape: 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:
- Find the next
{{; the text before it becomes aChunk::Static. - If it is
{{{, find the next}}}and push aChunk::Raw; otherwise find the next}}and push aChunk::Slotwith the trimmed text between. - 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 & <
> " ', 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 type | Document ({ html: String }) | Envelope ({ v, slot, ts, html, sig, css }) |
| Output | Complete HTML document | JSON (or an SSE / WebSocket frame) |
| Browser side | Parsed by the browser; open DSD attaches shadow roots | Your code puts html into the target; the browser library has no envelope handler and does not check signatures |
| Typical use | First paint, SEO | Partial 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); // & < > " ' -> & < > " '
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:
- Map the route to a template under
pages/:/(or an empty route) →pages/home.html,/articleand/article/→pages/article.html,/a/b→pages/a/b.html. - If that template doesn’t exist, use
pages/index.htmlas the catch-all. - Otherwise, or when the route is malformed (
..,\, NUL), returnError::NotFound("Page not found"). The route is never echoed into the output or the error. Routes only resolve underpages/, so a route can’t render a fragment, a layout or any other template. - Bind the page template with the params. If
layout/shell.htmlexists, bind it with the same params pluscontentset to the page HTML (the engine’scontentwins 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, always2(serialized first). An envelope withoutvdeserializes as0and 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 withescapeon).sig: lowercase hex signature over the v2 message (see Cryptographic Verification), present forHmacandEd25519signing.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, ¶ms).
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()?;
| Mode | What happens | Verify with |
|---|---|---|
Ed25519 | sig = hex Ed25519 signature (64 bytes, 128 hex characters) | anza::verify_envelope with VerifyKey::Ed25519(&public_key) |
Hmac | sig = hex HMAC-SHA256 (32 bytes, 64 hex characters) | anza::verify_envelope with VerifyKey::Hmac(&secret) |
None | No sig | — |
Notes:
Setup::ed25519returnsResult<SignMode>: a missing or unreadable key file is anError::Cryptoright away.- Keys are validated once, in
Setup::run(). These areError::Cryptoat 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_keymay be empty; the engine then derives it from the seed. engine.signer()returns the validatedanza::crypto::signer::Signer. The engine does not inject the public key into pages.DebugforSignMode,SignerandVerifyKeyprints<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:
| Check | Message |
|---|---|
v is 2 | unsupported envelope version |
sig is present | envelope is not signed |
sig is strict lowercase hex | malformed signature |
ts <= now + skew_secs | envelope timestamp is in the future |
now - ts <= max_age_secs | envelope expired |
slot equals the expected slot | slot 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
- Generate a keypair once and store the raw 32-byte private seed and public key in two files (the engine does not generate keys).
- Configure
setup.signing = Setup::ed25519(private_path, public_path)?. A missing file is an error, andSetup::run()checks that the private key is a 32-byte seed and that the public key matches it. - Every envelope gets
sig= lowercase hex Ed25519 signature over the v2 message (see Cryptographic Verification). - 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 callanza::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
- Stateless: the same inputs always give the same key, so the server can re-derive it per request.
- 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:
event: templatedata: <Envelope JSON>: compact JSON withv(always2),slot,ts,htmland, when signed,sig- a blank line (
\n\n)
A WebSocket message is the envelope JSON as a text frame.
| Encoder | Output |
|---|---|
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::Bytes | SSE 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"] }
| Feature | Provides |
|---|---|
axum | IntoResponse for Document (HTML) and Envelope (JSON); anza::adapters::axum::event for SSE |
actix | Responder for Document (HTML) and Envelope (JSON); anza::adapters::actix::bytes for SSE |
watch | Starts a notify watcher on the template root and reloads changed templates into the cache |
full | All 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
| Type | Notes |
|---|---|
() | No values |
[(&str, &str); N] and [(&str, &str)] | Linear search; no allocation |
HashMap<String, String> | Borrowed values |
serde_json::Value | Object 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: Params | Forwards |
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
| Technique | Extra work per render |
|---|---|
impl Params for Struct | None for borrowed fields |
HashMap<String, String> | Building the map (one String per key and value) |
serde_json::Value | Building the value tree |
Data(&value) / Template::render | Serializing 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
- Parse at startup.
{{…}}delimiters are found once inSetup::run(); no parsing or regular expressions at request time. - Compiled templates. Each template is compiled once into a binding plan;
a render resolves each distinct placeholder name once, sizes one
Stringand writes static text and values into it. - Borrowed values.
ParamsreturnsCow<str>, so struct fields andHashMapvalues are copied straight into the output. - Shared read lock. Lookups take a read lock on an
RwLock<HashMap>; many requests can read at once.Engine::getreturns anArc<Template>, so a lookup never copies the template. - 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(¶ms) (anza::engine::slot::Plan::bind):
- Prepare:
Data<T>is serialized to aserde_json::Valueonce. - Resolve: each distinct placeholder name is looked up once (pair lists are scanned once for all names).
- Allocate: one
String, sized from the static text plus the values (or an estimate for maps and objects; it grows if values are longer). - Copy: static text and resolved values are written in order, escaped
where
escapeapplies.
Values that Params returns as Cow::Owned (numbers, Data, JSON values)
allocate their own strings before being copied.