Arizona Architecture
View SourceSource modules
| Module | Purpose |
|---|---|
include/arizona_wire.hrl | Public wire header -- diff op codes (OP_TEXT ... OP_LIST_PATCH), mirrored by every client |
src/arizona.hrl | Private header -- ?EACH constant, #stream{} record |
src/arizona_template.erl | Pure helpers -- binding access (get/2,3), dep tracking (track/1), descriptor constructors (stateful/2, stateless/2,3), each/2, to_bin/1, snapshot utilities |
src/arizona_eval.erl | Template evaluation -- dynamics to snapshots, stateful/stateless child eval, stream/each eval |
src/arizona_render.erl | Render orchestration (target-aware) -- render/1,2, SSR (render_to_iolist/1,2, render_view_to_iolist/2), zip/2, fingerprint_payload/1 |
src/arizona_utf8.erl | UTF-8 output contract -- refuse/3 (names an encoder failure non_utf8_render), dev-mode render_view/2 / locate_live/3, format_error/2 |
src/arizona_renderer.erl | Render-target backend behaviour -- byte emission + attr_command/2; HTML and native JSON backends below |
src/arizona_html.erl | HTML render backend -- emits HTML statics/attrs (the ?html target) |
src/arizona_native.erl | Native render backend -- emits a JSON widget tree (the ?native target); see docs/native.md |
src/arizona_terminal.erl | Terminal render backend -- emits text + ANSI escapes (the ?terminal target); vocabulary in "Terminal render target" below |
src/arizona_terminal_session.erl | Transport-agnostic orchestrator for ?terminal views -- mounts, dispatches key/push events, repaints; parameterized by a driver module + an output fun |
src/arizona_terminal_driver.erl | Terminal UX-policy behaviour -- init/1, keys/2, paint/3, setup/1, teardown/1 (all optional; defaults in arizona_terminal_default_driver) |
src/arizona_terminal_default_driver.erl | Default arizona_terminal_driver -- idiomatic key events, Ctrl-D quits, cursor hide/show, non-flickering repaint, quit/set_title/bell effects; the session falls back to it per callback |
src/arizona_terminal_io.erl | Low-level terminal byte helpers -- keys/1 decodes a raw input read into idiomatic keys (the input complement to OTP's output-only io_ansi) |
src/arizona_terminal_effect.erl | Framework terminal effect builders -- quit/0, set_title/1, bell/0 (the ?terminal analog of arizona_js / arizona_android) |
src/arizona_terminal_tty.erl | Local TTY transport -- raw-mode shell:start_interactive, a blocking input reader, io:put_chars as the output |
src/arizona_terminal_ssh.erl | SSH transport -- serves a ?terminal view over an ssh_server_channel (pty-req, window-change), one live view per connection |
src/arizona_diff.erl | Diff engine -- diff/2,3,4, stream/list diffing, LIS algorithm |
src/arizona_effect.erl | Neutral effect plumbing -- the {arizona_effect, [...]} tuple; encode/1 (HTML attr) + encode_json/1 (raw); op codes in include/arizona_effect.hrl |
src/arizona_js.erl | Web/browser command/effect builders -- push_event, navigate, patch, fetch, toggle/show/hide, focus/blur/scroll_to, on_key, dispatch_event, set_title, reload |
src/arizona_android.erl | Native (?native) command builders -- the portable push_event/1,2 and navigate/1,2 |
src/arizona_os.erl | Native-shell (OS) capability command builders -- typed sugars (set_title, focus, notify, ...) plus the command/2 escape hatch, all funnelled through the single ?EFFECT_OS op |
src/arizona_stream.erl | Pure stream data structure -- create, insert, delete, update, move, sort, reset, clear_stream_pending/2, stream_keys/1, drain marks (drain_mark/1, undrained_ops/2) |
src/arizona_stateful.erl | Behaviour for all live handlers (route-page roots + embedded ?stateful) -- mount/render/handle_*/unmount callbacks, the call_* dispatchers, and format_error/2 |
src/arizona_req.erl | Opaque request -- eager method/path, lazy bindings/params/cookies/headers/body/user_agent, redirect/halted_redirect |
src/arizona_middleware.erl | Request-to-bindings middleware pipeline -- apply_middlewares/3 runner, built-in steps (extract/1, check_origin/2, ...) + the HTTP response transform constructors (etag/0, ...) |
src/arizona_origin.erl | CSRF Origin check -- check/3 (same-origin authority+scheme, or csrf_origins allowlist; missing Origin ok); default-on check_origin/2 step |
src/arizona_crypto.erl | Signed/encrypted value primitives -- sign/2,3+verify/2 (HMAC-SHA256) and encrypt/2,3+decrypt/2 (AES-256-GCM), domain-separated by Purpose, optional #{ttl} |
src/arizona_flash.erl | Signed, one-time flash cookie codec -- encode/1/decode/1 (signed JSON + baked-in TTL), resp_cookie/2; HTTP full-page-redirect path (a live navigate carries flash in-process) |
src/arizona_session.erl | Encrypted, durable session cookie codec -- encode/1/decode/1 (AES-256-GCM via arizona_crypto, TTL = max-age), durable resp_cookie/2; a read does not consume |
src/arizona_session_store.erl | Server-side session store behaviour -- get/put/delete (+ optional child_spec/0); opt-in via session_store, opaque signed-id cookie; mirrors Plug.Session.Store |
src/arizona_session_store_ets.erl | Default in-memory arizona_session_store -- a supervised public-ETS owner + periodic sweep + lazy expiry; get/put/delete run off the request process |
src/arizona_user_agent.erl | User-Agent classification for dual-serve views -- browser/1, os/1, mobile/1 (best-effort); pairs with arizona_req:user_agent/1 |
src/arizona_http.erl | Transport-agnostic HTTP render pipeline -- render/3 runs middlewares and renders; reply/3 folds it into {Status, Headers, Body, Req} |
src/arizona_controller.erl | Controller routes -- dispatch/3 (pipeline + action + flush), reply_effects/1,2 / reply_redirect/1, the action context, reply kinds |
src/arizona_static.erl | Offline static-site generation -- generate/2,3 renders route handlers to HTML files under an out-dir; returns {Written, Failed} |
src/arizona_ws.erl | Transport-agnostic WS upgrade -- prepare/3 resolves + runs middlewares; upgrade/3 answers the request (reply or 101 lines); init/1 starts the socket |
src/arizona_live.erl | Gen_server -- mount (stateful or view), handle_event, handle_info, views map, transport push |
src/arizona_parse_transform.erl | Compile-time transform -- ?html/?native, ?each DSL to #{s, d, f} maps, az-view auto-injection, az-nodiff, attribute compilation, cross-target nesting guard |
src/arizona_socket.erl | Framework-agnostic WebSocket protocol state machine -- JSON encode/decode, event dispatch, navigation, op scoping. Crash closes cleanly; client reconnects via backoff |
src/arizona_reload_controller.erl | Dev hot-reload endpoint as a controller -- a loop reply that streams arizona_reloader broadcasts as Server-Sent Events |
src/arizona_jsonrpc.erl | JSON-RPC 2.0 codec -- decode/1, result/2, error/3, notification/2 |
src/arizona_mcp.erl | MCP server behaviour an app implements (init/1, tools/1, handle_tool/4, optional resources/prompts/channels/terminate) plus the server-push API (broadcast/3, notify/3) |
src/arizona_mcp_handler.erl | Controller -- MCP Streamable HTTP transport (POST/GET/DELETE), the peer/Origin/auth gate, and the pure dispatch/3 / handle_method/4 method dispatch |
src/arizona_mcp_session.erl | Per-session gen_server -- holds the handler state, serializes a session's requests, owns the SSE channel and the resumability buffer |
src/arizona_mcp_session_registry.erl | ETS registry mapping Mcp-Session-Id to a session pid; sweeps dead pids on lookup |
src/arizona_mcp_sup.erl | simple_one_for_one supervisor owning the session processes (so a session outlives its connection) and the registry table |
src/arizona_error.erl | Error-reporting helpers -- did_you_mean/2 (missing-key suggestions for format_error/2) and raise_or_propagate/6 (re-tag a failure at the user's callback, propagate anything else) |
src/arizona_error_page.erl | Dev-mode error page renderer -- pretty-prints compile and runtime errors |
src/arizona_config.erl | App-env reader with env-var resolution -- get_env/1,2 (drop-in for application:get_env) + resolve/1; expands {env, "VAR"} / {env, "VAR", Default} refs, coerced by default type |
src/arizona_app.erl | Application callback -- starts arizona_sup and, when the server env is set, boots its listener through the adapter it names |
src/arizona_server.erl | Behaviour for an HTTP/WS server adapter -- start/2, stop/1, drain/2 (delivers {arizona_drain, Deadline}), recompile_routes/0 |
src/arizona_server_registry.erl | persistent_term set of adapters that ran a listener -- register/1, adapters/0, recompile_routes/0 (called by the dev reloader) |
src/arizona_route.erl | Route kinds and the rules every adapter compiles from -- kind/1, methods/1, steps/1 (CSRF default), transforms/2, live/2, controller/1 |
src/arizona_route_table.erl | The routing-contract matcher for adapters whose router lacks it -- compile/3 (refuses unsupported kinds, shadowing), match/3, allow/2 |
src/arizona_http_transforms.erl | Reference HTTP response transforms over {Status, Headers, Body} -- etag, compress, cors (preflight/3), security headers (wrap/3) |
src/arizona_cookie.erl | Set-Cookie serialization independent of any server -- serialize/3, used by arizona_req:flush/1 |
src/arizona_watcher.erl | Behaviour for a file-system watcher -- init/2, handle_info/2 (raw event to changed paths), optional terminate/1; no implementation in core |
src/arizona_reloader_rule.erl | One reloader rule per process -- runs the configured arizona_watcher, filters by patterns, debounces, calls the callback, broadcasts via arizona_pubsub |
src/arizona_reloader_sup.erl | Supervisor of the rule processes -- a temporary child of arizona_sup, so a crash-looping watch stops hot reload, not the app |
src/arizona_reloader.erl | Dev-mode hot reloader -- recompiles changed .erl files, broadcasts reload messages on the arizona_reloader pubsub topic |
src/arizona_reloader_consistency.erl | Post-reload call-consistency check -- check/1 reports callers whose beam imports chunk names a function a just-reloaded module no longer exports, plus loaded-vs-disk stale modules |
src/arizona_dev_mcp.erl | Dev-time MCP server exposing the running node to a coding agent -- introspection tools plus eval/reload; localhost-only by default (allow_remote_access => false); see docs/mcp.md |
src/arizona_dev_log.erl | Bounded log ring behind the dev MCP's get_logs tool -- a logger handler keeping the last 1024 formatted events; table owned by arizona_sup, handler installed on first MCP session |
src/arizona_pubsub.erl | PubSub -- thin pg wrapper for cross-view communication |
src/arizona_sup.erl | Supervisor -- always starts arizona_pubsub; additionally starts arizona_reloader_sup when the reloader has rules |
src/az.erl | User-facing facade -- shared type aliases and short-form helpers re-exported from internal modules |
API -- arizona_template.erl
Pure helpers for binding access, descriptor construction, and template composition.
get/2,3,get_lazy/3-- binding access with dep tracking via process dictionary ($arizona_deps)track/1-- manually track a binding key as a dependencywith/2-- tracked projection:track/1s each key thenmaps:with(Keys, Bindings); hands a bindings subset to a sub-context while declaring its depsstateful/2-- returns#{stateful => Handler, props => Props}descriptor for child viewsstateless/2-- returns#{callback => Callback, props => Props}descriptor (Callback is a fun/1)stateless/3-- returns#{callback => fun Handler:Fun/1, props => Props}descriptoreach/2-- template composition: 1-argfun(Item)for lists, 2-argfun(Item, Key)for streams, 2-argfun(Key, Value)for maps. Returns#{t, source, template}descriptorto_bin/1-- convert value to binary (binary/integer/float/atom/iolist). A float prints the shortest digits that read back as the same float: positional when1.0e-7 =< |x| < 1.0e21(the range JS prints positionally;1500.0,0.00001), exponent form outside it (1.0e21,1.0e-8). An integral value keeps the Erlang.0(1000.0), and-0.0stays-0.0
API -- arizona_render.erl
HTML output and rendering.
render/1-- evaluate dynamics, produce{HTML, Snapshot}render/2-- likerender/1but threads a views map for stateful children, collects deps:{HTML, Snapshot, Views}render_snapshot/2--render/2without the document:{Snapshot, Views}. Every live mount renders through this: a fingerprinted reconnect or navigate ships the fingerprint payload, and a first connect ships no page at all. Evaluating the snapshot (arizona_eval) is what observes the opaque command attributes the connect frame declaressnapshot_html/1-- rebuild the documentrender/2would have produced from a snapshot, for a root with no fingerprintrender_to_iolist/1-- render a template to iolist (used for layout templates withdiff => false)render_to_iolist/2-- stateful-component SSR (test-only):render_to_iolist(Handler, Opts)where Opts may containlayouts => [{Mod, Fun}]andbindings => map().on_mountis not honored -- it's a route-level conceptrender_view_to_iolist/2-- SSR for a route-level view, page only (static generation):render_view_to_iolist(Handler, Opts). Mounts viaarizona_stateful:call_mount/2, applieson_mounthooks, optionally wraps in a layoutrender_view/2-- the same, returning{ContentType, Page}: the outermost rendered template that records a backend names the document's media type (content_type/0); a backendless layout inherits the page'srender_view/3--render_view/2with the page's template passed through a fun between itsrender/1and the walk: the one mount sequence, whicharizona_utf8:render_view/2(dev mode) uses to check every valueresolve_id/1-- resolves a binary id (passthrough) or a#{s, d}template (renders to binary)zip/2-- interleave statics and evaluated dynamics into iolistfingerprint_payload/1-- convert a snapshot to fingerprint wire format
Error location -- arizona_loc
Every compiled dynamic carries Loc = {Module, Line}, its template source line. Each
evaluator of a located dynamic catches an exception raised while evaluating it and
re-raises it as {arizona_loc, {Module, Line}, Reason}, keeping the class and
stacktrace (arizona_eval:relocate/4). The evaluators are the SSR walk
(arizona_render), the live mount and snapshot render (arizona_eval's eval_one_v/2
and eval_one/1), and the diff's re-evaluation of a changed slot, including the
?each-source and az-nodiff-az-nodiff element paths (eval_each_def/2,
eval_unless_same_nodiff/3). So a crash names the same template line whether it
happens at the first page render, at connect, or after an event changed a binding, and
whether it sits in a top-level value, an attribute, an ?each item, a conditional
branch or a ?stateless body.
An enclosing dynamic never re-wraps a tag a nested one already attached: the deepest
location, closest to the failure, wins. The tag is the reason itself, so it reaches
every consumer unchanged. The dev error page (arizona_error_page) titles the page with
the location and formats the inner reason; the socket's crash log
(Live process ~p crashed: ~p) prints the live process's exit reason, tag included. A
crash in a ?stateful child's mount/1 or handle_update/3 is tagged with the line of
the slot that holds the child, as at SSR. A 2-tuple dynamic (no Loc, built by hand)
is evaluated without the catch.
The catch is one try per located dynamic on the path every slot evaluation already
takes. It adds no reductions; only a crash pays, for the re-raise.
Output is UTF-8 -- non_utf8_render
Every render target writes UTF-8: an HTML response is served as text/html; charset=utf-8, and a WebSocket frame and a ?native document are JSON. So a rendered
binary must be UTF-8. A list is chardata and needs nothing from the app:
arizona_template:to_bin/1 decodes it as Unicode code points, producing UTF-8, and
refuses one that does not decode (a list wrapping non-UTF-8 bytes raises
{non_utf8_render, Byte, Offset}, any other non-chardata bad_template_value). A
binary is emitted as the bytes it holds, unchecked, because checking every value on the
render path was measured at +22% to +49% on non-ASCII SSR (see docs/performance.md,
"What did not work").
The usual source of a non-UTF-8 binary is a bare <<"café">> literal: with no type
spec, each character is stored as one byte, which is Latin-1. Fix it at the source:
- write
~"café"or<<"café"/utf8>>; - convert Latin-1 data from outside (a file, a legacy database, a socket) with
unicode:characters_to_binary(Bin, latin1).
The contract is enforced where it costs nothing:
- Compile time. A template static is not the trap: the parse transform folds a
binary literal in a content or attribute position as UTF-8 text
(
extract_bin_element/1), so{p, [{title, <<"é">>}], [<<"café">>]}renderscafé. Everywhere else in a module using the Arizona headers --mount/1,handle_event/3, a helper, a?getdefault, a conditional branch value, a hoisted variable, a?rawpayload, a?stateless/?statefulprop -- a non-ASCII<<"...">>with no/utf8gets the compile warninglatin1_binary_literal(an error underwarnings_as_errors). It is not re-encoded: such a literal may be data compared against other Latin-1 bytes or a protocol constant, and the transform cannot tell which.~"...",<<"..."/utf8>>and ASCII-only literals never trigger it. There is no suppression option: bytes meant as bytes (a PNG signature, a protocol constant) are spelled so they do not look like text -- integer segments (<<16#89, "PNG\r\n", 16#1A, "\n">>,<<16#E9>>) or a sized string (<<"\351":8>>,<<"é":8>>), which compile to the same bytes. The warning text names both. - Failure paths. The JSON encoder validates UTF-8 as it escapes, so a frame or a
?nativevalue holding other bytes is already refused -- as the encoder's{invalid_byte, _}orunexpected_end, which names neither the value nor the cause.arizona_utf8:refuse/3, run only after the encoder raised, renames it{non_utf8_render, Byte, Offset}, finding the bytes in a structured value too (a command array).?nativeescapes inside the slot's located evaluation, so its error carries the template line; a socket frame failure is logged with the formatted reason and closes 4500 (4501 for the frame delivering a mount), as before. - Dev mode (the dev reloader is wired, the signal that also gates the rich error
page).
arizona_httprenders each page througharizona_utf8:render_view/2instead ofarizona_render:render_view/2: the same mount, the same output bytes, but every dynamic is wrapped so its value is checked inside the slot's locatedtry-- in the page, nested templates,?eachitems,?statelesschildren, and?statefulchildren, whose handler a stand-in (arizona_utf8:mount/1,render/1) wraps so the SSR walk still mounts each one once and marks it a view boundary. A bad value raises{arizona_loc, {Module, Line}, {non_utf8_render, Byte, Offset}}, which reaches the dev error page; the finished document is then checked once (one BIF pass) for what no slot produced, a layout's values. Nothing is rendered twice, somount/1side effects run once, as in production. A refused WebSocket frame is located from the live process's current views (arizona_utf8:locate_live/3, viaarizona_live:view_state/1): each view renders again from the bindings it holds, children skipped and checked from their own, so nothing is mounted, and with the live process's connection state (?connected,?capabilities/?capability,?reconnected) copied in for the render, so it takes the branches that produced the frame. Production pays one dev-flag read per HTTP response and nothing per frame.
format_error/2 (arizona_utf8) names the byte, its offset, the value's bytes (as
integers -- ~p would print a Latin-1 binary as the very text it fails to be) and the
fix. What Offset counts depends on where the bytes were refused:
- A binary -- a JSON encoder's refusal (
?native, an effect command, a socket frame;arizona_utf8:refuse/3) and the dev-mode per-value check: the offset into that binary, which the preview shows. - A list --
arizona_template:to_bin/1decoding chardata: the offset into the decoded output, i.e. how many UTF-8 bytes the elements before the bad one produced. The raising call's argument is the list, so there is no preview. - The finished document -- the dev-mode check after a page renders (bytes no slot produced, such as a layout's): the offset into the document; the preview is the 32 bytes around it.
Handler effects are not template values; the socket encodes them, so a non-UTF-8 string in one is named on the same failure path.
Terminal render target -- ?terminal
?terminal(Elems) renders a view to ANSI text instead of HTML or JSON. The backend
is arizona_terminal (an arizona_renderer), the sibling of arizona_html and
arizona_native. Its transport repaints the whole frame on every update
(arizona_live:render_current/1) rather than applying diff ops, so the backend
writes no az/slot markers -- the diff engine's ops are computed but ignored.
The frame is printed from the live process's diffed snapshot (the root's, with each
child view's current copy refreshed in), not from a fresh render of the template, so a
repaint runs no mount/1 or handle_update/3 and shows exactly the state the process
holds. It follows that a terminal sees what a web client sees: az-nodiff content
renders once, and a slot whose reads are not tracked freezes after its first render.
When the node can reload code (arizona_reloader:enabled/0) a hot reload remounts the view
in place (arizona_terminal_session:handle_reload/1,2): the session joins the reloader
topic at start, and on {arizona_reloader, reload} stops the live process, mounts a fresh one from
the connection's bindings (an SSH channel's current geometry) and repaints, so the view's
state resets as a browser page's does. A terminal client does not reconnect, so this is the
terminal's counterpart of the WebSocket close described under "Exit reason to close code".
Otherwise, edited template text appears once a slot depending on it changes.
Escaping is per-backend via the arizona_renderer:escape/1 callback, selected by the
render target: HTML entity-escapes, terminal control-char sanitizes (strips C0
bytes except \n/\t, DEL, and the C1 controls, and replaces every byte outside
well-formed UTF-8 with U+FFFD, so the output is always valid UTF-8; this assumes a UTF-8
terminal, since an 8-bit one reads UTF-8 continuation bytes 80-9F as C1 controls), native
emits a JSON string. Native alone carries a value
differently on the wire: its client JSON-encodes each d value itself, so the
fingerprint payload takes the raw value from arizona_renderer:wire_escape/1
(wire_attr/2 for a prop) while the zipped document keeps the JSON string.
So a dynamic value in a ?terminal view cannot inject ANSI/OSC escape sequences into
another user's terminal (the terminal analog of XSS). The ?raw opt-out bypasses it --
never wrap untrusted data in ?raw in a ?terminal template.
Elements
| Tag | Emits |
|---|---|
line | its content, a style reset, then a newline -- one terminal row |
col / row | its content, then a style reset -- a transparent container |
text / span | its content, then a style reset -- inline, no newline |
br | a void element emitting a newline |
Only line (newline) and br (void newline) affect layout; col, row, text,
and span are behaviorally identical (open emits nothing, close emits a style
reset) -- the names are documentary. So vertical layout is a col of lines,
and horizontal is several text/span inside one line.
Styling
Set styles with bare-atom boolean attributes mapped to io_ansi escapes:
bold, dim, italic, underline, inverse, and the colours
black/red/green/yellow/blue/magenta/cyan/white plus their light_*
variants. For anything the atoms don't cover, use a valued attribute: {sgr, Escape}
(a raw escape, verbatim), {fg, Index} / {bg, Index} (256-colour palette by
index). Styles do not inherit across containers (every element resets on close),
so put the style on the line/text that needs it. An unknown style atom, an
unknown valued attribute, a dynamic attribute value, or an event-command attribute
is rejected at compile time, not silently dropped. So is an unknown element:
the tag table above is the whole vocabulary, so a tag outside it is a typo rather than
an extension point, and accepting it as a transparent container swallowed the author's
intent silently ({'BR', [], []} emitted a style reset instead of a newline). Terminal
tag names are case-sensitive -- the vocabulary is Arizona's own, not HTML's, so
Line is not line. (This is the opposite of the ?html target, whose tag
classification folds case because the browser's parser does.)
render(Bindings) ->
?terminal(
{col, [], [
{line, [bold, green], [~"== Title =="]},
{line, [], [~"plain ", {span, [yellow], [~"highlighted"]}, ~" tail"]},
{line, [{fg, ~"208"}], [~"256-colour orange"]}
]}
).Conditional rows: a control-flow expression (case/if/begin/receive/try/
maybe) returning a bare element tuple from a tail in a content slot is compiled into a
nested template (inheriting the enclosing target), just as a literal ?html/?terminal
there would be -- no wrap needed:
{col, [], [
case ?get(mode) of
warn -> {line, [yellow], [~"check your input"]};
_ -> <<>>
end
]}For 0-or-N rows, ?each over a list still applies:
?each(fun(Msg) -> {line, [yellow], [Msg]} end, status_rows(?get(mode)))Running a ?terminal view
arizona_terminal_session is the transport-agnostic core: it mounts the view, turns
input into arizona_live:handle_event/4 calls, and repaints. It is parameterized by
a driver (arizona_terminal_driver -- the key map, paint model, and screen
setup/teardown, with defaults in arizona_terminal_default_driver that the session
falls back to per callback) and an output fun. Two transports drive it:
arizona_terminal_tty (a local raw-mode TTY) and arizona_terminal_ssh (a view served over
SSH). arizona_terminal_io:keys/1 decodes a raw input read into idiomatic keys
(char codes, atoms like up/enter, {ctrl, _} / {alt, _}) for drivers to match
on.
Every paint/3 decides continue or stop, the initial paint included: a driver
that stops on the first frame gets its teardown emitted and the live view reaped, and
arizona_terminal_session:start/5 answers quit instead of handing back a session.
The SSH transport serves one view per channel -- a second shell request is refused
rather than mounting a second view over the first, exec is refused (this shell is
interactive only), and input or a resize arriving before the shell request is
dropped or folded into the mount geometry rather than reaching a session that does not
exist yet.
A view drives the terminal back with effects from arizona_terminal_effect (the
?terminal analog of arizona_js / arizona_android): quit/0 stops the session,
set_title/1 sets the terminal title, bell/0 rings it. The default driver's
paint/3 interprets these and repaints in place (no full-screen clear, so no
flicker). Effects tied to a specific paint model (e.g. a scrolling log above a pinned
block) stay the concern of a custom driver, like the demo's.
API -- arizona_diff.erl
Diff engine.
diff/2-- compare new template against old snapshot:{Ops, NewSnapshot}diff/3-- likediff/2with views map:{Ops, NewSnapshot, Views}diff/4-- likediff/3but skips dynamics whose deps haven't changed (takesChangedmap):{Ops, NewSnapshot, Views}
Dep tracking and per-dynamic isolation
Each dynamic owns a private dep set: #{key() => true} of the binding keys
read while it was evaluated. The diff engine compares each dep set against
the Changed map and skips re-evaluation when they don't intersect.
The mechanism is a single process-dictionary slot, '$arizona_deps', holding
the open dependency scope:
eval_one_v(inarizona_eval) opens a scope around each dynamic (open_deps/0, which puts#{}and answers the enclosing scope) and closes it at exit (close_deps/1).arizona_template:get/2,3,arizona_template:get_lazy/3, andarizona_template:track/1write the read key into the open scope.- The dynamic's deps are read back when its scope closes and stored on the
snapshot (parallel to
d).
Scopes nest. Closing a scope opened inside another hands its reads to the
enclosing one, so a dynamic evaluated while another's scope is open counts
toward both. That is deliberate in exactly one place, an ?each's items (below);
everywhere else the enclosing scope is suspended first.
Per-dynamic isolation invariant: every nested-template entry point
runs inside a with_saved_deps wrapper (in arizona_eval), which
suspends the enclosing scope (erases the slot) for its duration and restores
it after. Without this, an inner template's ?get calls would land in the
outer dynamic's deps. Bracketed entry points and what each covers:
eval_template-- the inner template's per-dynamic eval loopeval_stateful-- the whole lifecycle:mount/1/handle_update/3,render/1, and the inner per-dynamic eval loop- the stateless inline path -- the entire
eval_val_vrecursion for#{callback := _, props := _}descriptors, including the callback body and any subsequent unwrapping
Inner dynamics open their own scope, so per-inner-dynamic deps are independent of outer.
An ?each's items are the exception, and they feed the each slot. The items
of an each render inside the each slot's scope (arizona_eval:eval_items/2, one
scope around them all), so whatever the callback reads -- an outer binding
(?each(fun(I) -> {li, [], [I, ?get(suffix)]} end, ?get(items))), at any depth,
an inner each's items included -- becomes a dep of the each slot beside its
source. A change to such a binding re-evaluates the each, and the per-item diff
then patches only the items it changed. The deps are the reads that actually
happened, so a key computed at runtime (?get(Key)), a read in one branch, and a
read in a helper the callback calls all count, with no compile-time analysis. Each
item dynamic keeps its own deps too, which is what per-item skipping reads. Two
consequences of collecting at runtime:
- Every tracked read in an item counts, including an item-field read with an
explicit
arizona_template:get(text, Item)(the only form stream per-item skipping can vouch for; see "A changed stream item reuses only what its deps can vouch for" below), so a view binding that happens to share that name re-evaluates the each too. That over-tracks; the item diff then emits nothing. - An az-nodiff item template renders once, so its items are evaluated with the
scope suspended (
eval_items/2) and their reads never reach the slot.
On the diff path the each slot's own closure is re-run without its items
(eval_each_def/2), and the each diff adds the reads of the items it renders. A
list or map diff renders every item, so the slot's deps come out exact. A stream
diff renders only what its queued ops touch, so the stream snapshot keeps the
items' reads beside it (item_deps), grown by each diff's renders. When a
changed binding is among them, the queued ops alone would leave every other item
stale, so the stream reconciles against its current source in one step instead
of draining (diff_stream/5's Reconcile: a single {reset, #{}}, which
re-renders the visible items and patches only those that differ). A stream nested
inside an item is diffed from a value the walk already evaluated whole, so there
the drain is checked against that render and the container reconciles against it
(stream_relist/7) when the queue does not account for it (nested_drain/4). A
nested stream whose render holds child views always reconciles that way, without
draining: the drain renders the items its ops touch a second time, so each
?stateful among them would run mount/1 twice and the first instance would be
dropped without unmount/1.
Content-slot conditional branches: a case/if/maybe in a content slot
compiles each element branch into a nested template, so by this invariant the
branch's ?get reads land in the nested template's slot, not the enclosing
conditional dynamic's -- which freezes the branch when only a branch-read binding
changes (the conditional's deps would otherwise hold just the scrutinee). The parse
transform (branch_track_touches/1 in arizona_parse_transform) compensates by
injecting arizona_template:track/1 for each literal key read in an element branch
tail, recording them as deps of the conditional dynamic itself. track/1 records the
key without reading the binding, so an absent non-taken-branch key is safe; value
(scalar) branches are left alone (their reads already track when taken). See the
"Bare element tuples in conditional tails" rule in .claude/rules/erlang.md.
On re-render the diff then fine-grains: make_ops/4 diffs a
same-statics nested template's inner dynamics (each globally Az-addressed and
marker-anchored) and patches only the changed inner slot(s) -- the same
per-inner-dynamic diff the view_id child-view path uses (diff_child_dynamics/2),
minus the [VId, ChildOps] wrapper, since a plain nested template is inline in the
parent view. It recurses through nested-nested templates to the deepest slot; an
inner attribute change is a precise ?OP_SET_ATTR. A wholesale ?OP_TEXT re-render
is the fallback only when the statics differ (a different branch / structure change).
?OP_TEXT escaping -- text vs HTML. SSR escapes a dynamic value
(render_dyn/1 -> escape_value/1); the live diff sends it raw (make_op/3 ->
to_bin/1), with escaping completed at the client's output boundary -- the wire
carries text as a bare string and HTML as an object, so the client knows which to do.
A scalar ?get value is a bare binary; the JS client renders it with a text node,
so a < is literal text (never parsed -- can't inject, matches SSR). An HTML fragment
(a nested-template / plain-list-?each zip-map, whose inner values zip_or_fp already
escaped) is a map; the client innerHTMLs it. The escape opt-out ?raw is the one
scalar that must innerHTML, so make_op/3 tags it #{~"raw" => Html} (a map, not a
bare string) -- the only thing distinguishing trusted markup from a ?get value that
merely contains <. The worker sets op[3] = true for the HTML cases. Attribute values
are escaped by the client's setAttribute. A ?raw attribute value is the exception again:
SSR splices it inside name="...", where the parser decodes its entities, so make_op/3
tags it #{~"raw" => Markup} on ?OP_SET_ATTR too and the client decodes it in that same
double-quoted context (a bare string would leave & literal after the first update).
An interpolated value with a literal ?raw part ({href, [~"/a?", ?raw(Q)]}) is markup as
a whole: the transform wraps it in a raw_attr call, which splices the raw parts
verbatim and escapes every other part, static or dynamic, so SSR and the tagged live update
carry the same assembled markup.
(?raw is an HTML-target feature; the
?native/?terminal backends don't HTML-escape, so ?raw there is unsupported -- a
target-aware tag would be the follow-up if it ever needs to behave like a plain value.)
Usage convention: outer-scope ?get reads belong in the props
expression of ?stateful/?stateless -- the parse transform places them
in the outer dynamic's closure, where they track correctly. Eager ?get
calls inside a ?stateless callback body, or inside a stateful's
mount/1 / handle_update/3, are discarded by the wrap; this is
intentional -- those reads belong to the inner scope. Idiomatic callbacks
use a named fun reference (?stateless(fun bar/1, Props)), which cannot
close over outer Bindings and therefore avoids the footgun entirely.
Binding-read inlining
Dep tracking requires a ?get to run inside its slot's '$arizona_deps'
bracket. A read hoisted into the render function body --
render(Bindings) ->
Name = arizona_template:get(name, Bindings),
?html({p, [], [Name]}).-- would otherwise compile the slot to fun() -> Name end: a captured value
with no ?get, so it records empty deps and the slot freezes after the first
render. The parse transform (arizona_parse_transform) closes the gap by
rewriting each interpolated variable back into its defining expression, so the
?get re-executes inside the per-slot bracket -- exactly as if it had been
written inline. This is purely compile-time; the runtime/diff path is unchanged
and idiomatic templates (reads written inside ?html) are unaffected.
collect_inline/1builds a per-clause map of top-levelVar = RHSmatches whose RHS transitively reaches aget/get_lazy/track/withcall (withtracks too, so a hoistedSub = with(Keys, Bindings)must inline back into the slot bracket or its tracking runs outside any bracket and freezes). Rebound vars are dropped; a derivation with no read (e.g.Id = make_uuid()) is left captured: it runs once in the body and every slot sees that one value. A definition that does read runs in the body and again in each slot that uses it (see "Template code is a pure function of the bindings" below).transform_scoped_tail/4does the same per body along the tail of the clause's last expression (thecase/if/begin/try/maybe/receivetails the live root accepts), so a read hoisted inside a branch (a -> N = ?get(n), ?html({p, [], [N]})) inlines into that branch's templates, on top of the enclosing map. A match on a name already bound -- by the clause head, an earlier statement or outside the body -- is an equality test: the match stays as written, so the test still runs and the earlier binding stays used, and its read is still inlined, so the slot stays tracked. The render function re-runs on every diff, so the test runs on every render.inline_vars/2substitutes those variables wherever they appear in a?html/?eachargument -- including inside acasescrutinee or an opaque call -- recursively and scope-aware (fun parameters and comprehension generators shadow; patterns and guards are never substituted, so a substitution can never form an illegal pattern or a guard?get-- a pattern or guard that reads one is tracked by a touch instead, see below). A substitution under a fun parameter, named fun or generator that rebinds a name the definition reads is a compile error (hoisted_var_captured): the copy would read the inner binding.normalize_tail_binds/1lifts a statement-formcasethat binds one variable as the whole body of every branch (case ?get(m) of a -> X = ?get(p); _ -> X = ?get(q) end) into value form (X = case ?get(m) of a -> ?get(p); _ -> ?get(q) end) so the same machinery inlines it.- A now-template-only match is underscore-prefixed so
warnings_as_errors(unused variable) stays satisfied.
The invariant: a slot tracks only the reads performed inside its own closure.
The transform arranges that automatically when a ?get result reaches the slot
through a bare variable -- it rewrites the body binding to _ = get(...) and inlines
a fresh read into the closure, so the read re-runs inside the dependency bracket. It
cannot when the value arrives any other way: bound through a non-bare-var pattern, or
derived by intermediate computation (Side in #{side := Side} = f(?get(k)), which
compiles to fun() -> Side end). Such a slot renders once at SSR and never updates,
and nothing says so -- the view re-renders normally and its sibling slots move, so it
reads as a diff bug rather than a missing dependency. When the slot's value is
computed rather than read, put the read in the slot itself (f(?get(k))).
Reads that do not inline (left captured, slot stays static): a binding
destructured in the function head (render(#{foo := Foo})) or through any
non-bare-var pattern ({ok, V} = ?get(...)) -- a pattern match is an untracked
read; read the whole value with ?get(foo) and destructure with plain Erlang.
Also: a variable bound inside a case branch whose clause head itself binds a
variable, and a rebound variable. Use ?get for the top-level binding and plain
maps:get/2 for sub-structures (only the ?get records a dep, which is the
correct grain). In these cases the read is left captured and the slot keeps its
SSR value.
Template code is a pure function of the bindings. Everything a slot evaluates
is evaluated lazily: the slot expression itself, the definition of a hoisted
variable the template reads (re-inlined into every slot that uses it, and still run
once in the body), and the arguments of an inlined local element helper
(substituted at each use of the parameter, and not evaluated at all when the
parameter is unused). A slot runs once on first render, then again only when its
own dependencies change, while the render function's body runs on every diff
cycle. So one source expression may run zero, one or several times per render, in
no fixed order, and two slots that read the same variable each compute it
themselves -- capturing the value once would not help, since the body and the
slots run on different schedules. Keep that code deterministic and free of side
effects. A value that must be generated once and shared -- a unique id pairing a
label with its input, a timestamp, a random token -- belongs in state: create
it in mount/1 or handle_event/3 and read it with ?get, or derive it from
bindings (<<"field-", (?get(name))/binary>>).
The transform rejects the cases it can see with template_impure_expr, located at
the offending call: a construct from a fixed always-impure set -- a send (!),
receive, make_ref, erlang:unique_integer, the clock reads
(erlang:now/timestamp/system_time/monotonic_time, os:system_time,
calendar:local_time/universal_time), put/erase, spawn*, and any call into
rand, os, ets, io or logger -- anywhere in a hoisted definition that is
re-inlined (check_hoisted_pure/3, at substitution time in iv/2, so a
definition no template uses runs once and is never checked), or in a helper
argument whose parameter does not occur exactly once in the helper body
(check_helper_args_pure/5, counted on the source body, patterns and guards
included). A fun that is only constructed there is not entered, since its body
runs where it is called. Calls into your own functions are not checked.
A tracked read used in a guard is auto-tracked, not frozen: a binding read in a
guard (case Status of active when Confirming -> ..., an if) can't run inside the
slot's dependency bracket on its own -- a guard cannot hold the get/2 call (Erlang
rejects that as an illegal guard expression), so inline_vars leaves guards as bound
variables. Left alone the read would run once in the function body, outside the bracket,
and the slot would silently freeze on that binding. Instead, the guard-bearing iv node
({'case',...} / {'if',...} / {'receive',...} / {'try',...} / {'maybe',...}, and
a nested fun) wraps itself in a block that first reads each tracked binding its clause
guards reference -- begin _ = arizona_template:get(K, Bindings), ..., <expr> end -- for
the track/1 side effect, recording those bindings as slot dependencies
(wrap_touches/4, collecting via clause_tracked_vars/2 / fun_tracked_vars/2 with
per-clause shadow handling). The guard keeps using the captured value, which each diff
cycle rebuilds from current bindings, so a change to a guard binding re-renders the slot.
Reusing the inline expansion as the touch (iv({var,L,V}, Inline)) handles
get/get_lazy/with and transitively-derived vars uniformly. A non-tracked local or a
pattern-bound variable in a guard needs no touch (no binding dependency).
Patterns get the same touch. A pattern cannot hold the call either, and an inlined
variable a pattern reads would freeze the slot the same way: an equality test against a
hoisted variable (case I of Sel -> ... -- the variable is already bound, so the clause
tests it and its body's Sel is the same value, substituted as usual), a map-pattern key,
a binary segment size, or a maybe ?=. Case-like clauses, match expressions, fun
heads (keys and sizes only -- a fun parameter binds) and comprehension generators (keys and
sizes only, the generator's own bindings shadowing later qualifiers) all collect them. A
?= must stay a direct expression of its maybe body, so its touches are placed ahead of
it in that body rather than in a wrapping block. The one fun NOT wrapped is a top-level
?each callback (handled in inline_vars/2): compile_each needs a single-clause fun,
and its guards are over the item param, not outer tracked vars.
Tracked accessors, the sub-map diagnostic, and with/2
get/2,3, get_lazy/3, and with/2 (and their az: aliases) are the
dependency-tracking accessors: each calls track/1 regardless of which map it
reads. That makes reading a nested map a footgun -- Foo = ?get(foo), ?get(bar, Foo) records bar (a key of Foo) as a top-level slot dep, which both
over-tracks and frantically misfires. The parse transform rejects it at compile
time (tracked_get_on_non_bindings_map): a tracked read whose map argument is a
local provably holding a value read out of a map -- bound by a tracked accessor,
maps:get/2,3 or map_get/2, bound to a value by a map pattern (#{user := U}
in a match, a function or fun head, or a case clause), or aliased from such a
local -- is an error. Any other local may be the bindings (a parameter, an alias,
a with/2 projection, a value threaded through a helper or a case), and the
transform cannot tell, so it is not flagged: a false rejection would block correct
code, a missed one only over-tracks. Read sub-structures with plain maps:get/2.
Non-variable map arguments (a literal map, a maps:get call) are never flagged.
Nested-template / passed-bindings freeze. A child template embedded via a raw
function call (or an explicit nested ?html(...) returning a template) renders at
SSR but never updates: the outer slot is fun() -> child(Bindings) end, and
building child fires no ?get at the outer level (the reads live in child's own
closures, isolated by with_saved_deps), so the outer slot captures empty deps and
the diff engine skips it forever. Idiomatic composition is ?stateful/?stateless,
whose props expressions read on the parent slot; an inline nested element is also
fine (it flattens into the parent template). When you must hand a bindings subset to
a sub-context, ?with([keys]) (arizona_template:with([keys], Bindings), or the
az:with/2 alias when Bindings isn't in scope) is the explicit fix: it
track/1s each key (so the outer slot re-renders when any listed key changes) and
returns maps:with(Keys, Bindings) (so the sub-context cannot silently depend on an
untracked key -- an omitted key raises missing_binding rather than freezing). No
with_all: tracking every key makes the slot depend on everything, defeating
fine-grained diffing.
Eager get/3 defaults over-track. ?get(a, ?get(b)) records b even when a
is present, because Erlang evaluates the default argument eagerly and ?get(b)
tracks unconditionally. ?get_lazy(a, fun() -> ?get(b) end) tracks the fallback key
only when the default is actually taken.
A changed stream item reuses only what its deps can vouch for. The main
per-dynamic diff path skips a slot whose deps are empty (the documented frozen residue
above). A changed stream item (arizona_stream:update/3, or a kept item of a reset)
cannot trust its deps that way: the item value is an input to every dynamic, and deps
record only the reads that went through get. A plain maps:get(n, Item), a
head-destructured field (fun(#{text := T}, _) -> ...), a value derived in the
callback body, or a read inside a nested template's closure (a conditional branch, an
inner ?each callback) never reaches them -- and a dynamic that also reads a view
binding has non-empty deps that name none of the fields it used. So the parse
transform decides which dynamics are provable: a per-item template carries
reuse => [boolean()], one flag per dynamic, true only when the dynamic references
no variable but the item key outside the map argument of a tracked read
(arizona_template:get/2,3, get_lazy/3, with/2, or their az aliases) in its
own closure. That map must be the item itself (the bare item parameter, or the
variable side of a Pat = Var head) or the view bindings (bound outside the
callback): a sub-map bound in the head or the body records its own keys, not the
item field that holds it. A dynamic with no reads and no item reference (a static
value, ?connected, the key attribute) is kept on an item update.
arizona_eval:render_stream_item_skipping/6 keeps a dynamic's old
{Az, Val, Deps} only when it is flagged and its deps miss the changed fields;
everything else re-evaluates, and a template without the list (none qualified, a
1-arg callback, a hand-built template) re-renders the whole changed item. An item
whose value did not change at all is still reused whole. The result: untracked item
reads stay current inside stream items, and only a fully tracked dynamic gets
skipped.
?each item bodies must be elements. Because each item compiles into a per-item
template (#{s, d, f}), the callback body must be an element ({Tag, Attrs, Children}),
a list of elements, or a static/mixed fragment. classify_body/1 in
arizona_parse_transform buckets the body; the only non-template bucket, text_dynamic
(a bare value, a runtime binary, a ?stateful/?stateless descriptor, or a case/if),
compiles to one opaque value slot. A scalar value renders at
SSR and diffs (the per-item key is arizona_template:to_bin/1 of its value), but gets no
per-item template diffing -- pointless where a comprehension is the right tool; a template or
descriptor value goes further and crashes on the first diff, when to_bin/1 hits the stored
template/descriptor and raises bad_template_value. So compile_each rejects a
text_dynamic body (each_body_not_element for a 1-arg/list callback,
each_stream_body_not_element for a 2-arg/stream-or-map callback -- the two differ only in fix
advice, since a list has a comprehension fallback and a stream/map, keyed per item, does not)
at compile time. Over a stream, a keyed callback must also return exactly one element:
the keyed ops (OP_INSERT/OP_REMOVE/OP_MOVE/OP_ITEM_PATCH) address an item by the single
element carrying its az-key, and any other root of the item would be left behind on a remove
and out of order on a move. The same 2-arg callback may iterate a map, which diffs through the
list machinery (positionally for a single-root item, else a wholesale container OP_TEXT) and so
takes a list of elements, a text+element fragment or bare text. The source is only known at
runtime, so compile_each stamps such a body multi_root => true and arizona_template:each/2
raises stream_each_multi_root when it is paired with an arizona_stream -- on every evaluation,
so the render and the diff alike -- unless the per-item template is nodiff (never addressed by a
keyed op). A one-element list body is taken as its element. Over a stream that root must carry
az_key (the client looks items up by az-key, so an unkeyed item is unreachable to every keyed
op and the DOM drifts from the stream). A map diffs positionally and needs no key, so this is the
same runtime pairing check: compile_each stamps a single-element body whose root has no literal
az_key with keyless => true, and arizona_template:each/2 raises stream_each_keyless over a
stream, again unless the item is nodiff, and only on a backend whose client applies keyed item ops
(the keyed_item_ops/0 renderer callback; ?terminal redraws whole frames). A keyed callback whose
body is a conditional raises each_stream_body_control_flow instead, because the fix is
different: "wrap the value in an element" reads as advice to pick a tag, when the real constraint is
that every item shares ONE compiled per-item template, so an item's outer tag is fixed at compile
time. That shared template is exactly what makes per-item diffing cheap (statics once, dynamics
per item). The branches themselves may be entirely different elements -- they go inside one stable
item element carrying az_key, where a conditional child may return bare element tuples. Containers
whose child tag is fixed anyway (ul/ol) lose nothing, and a div with display: contents keeps
the wrapper out of layout elsewhere.
That wrapper is not legal in every container, and the two where it is not are the two where a
varying sibling tag is most legitimate. Inside table/tr the parser foster-parents a wrapper
out of the table entirely -- the th/td land in the row, the az-key ends up on a stray
div before the <table>. Inside select, Firefox drops it while Chromium keeps it, so the
idiom silently works in one browser and freezes the list in the other. Both verified in real
browsers. ul/ol is the only container whose child tag genuinely was never free.
Supporting a varying item tag would mean a distinct template per item, giving up the shared statics and reducing a branch swap to a full item re-render.
A tag written as an expression rather than a literal atom reaches this from the other side, and
earlier: extract_element/1 raises computed_element_tag (a literal of the wrong type, a binary
say, still gets the form error invalid_element). It carries the same idiom, because it is the
same constraint seen from the tag rather than from the callback body.
compile_each also rejects a bare list whose item is a template or descriptor
([?html(...)], [?stateless(...)]) -- the item lands in the same fragile value slot --
while keeping a list of elements or a static/dynamic-text fragment.
Map plain values with a list comprehension / lists:map (no per-item diffing); put a
conditional inside an element child ({li, [], [case ... end]}). The reverse is a compile
error too: a comprehension in a content slot builds its list at render, as one value, so an
element-shaped body ([row(X) || X <- Xs], [{li, [], [X]} || ...], [?html(...) || ...])
is element_in_comprehension (is_lc_element_body/2, checked where the helper-inlining walk
reaches the comprehension). A comprehension handed to a function stays data and is untouched.
The callback may be an inline fun or a local single-clause function reference (fun row/1,
or fun row/2 for a stream/map). compile_each looks the function up in the module's forms
(collect_fun_defs/1) and inlines its clause through the same path, so the identical body rules
apply (a non-element body still raises each_body_not_element/each_stream_body_not_element).
Inlining orphans the named function, so parse_transform/2 injects
-compile({nowarn_unused_function, ...}) and -ignore_xref(...) for the consumed pairs --
the function needn't be exported or otherwise used. A same-module explicit ref
(fun ?MODULE:row/1, module literal = the current module) is rewritten to the bare local form
and resolved identically. Rejected: a genuinely remote reference (each_remote_fun_ref --
another module's, or a variable module, body isn't visible to inline; an -imported function
referenced as a bare fun row/1 falls through to each_named_fun_undefined since it isn't in
FunDefs) and a multi-clause function (each_named_fun_multi_clause -- clauses would select
different per-item structures, but there is only one shared per-item template); collapse
multiple clauses into a case inside the returned element.
A whole-body backend wrapper -- ?html(...) (or ?native/?terminal matching the each's
target) -- is accepted: compile_each unwraps it to the element it wraps and builds the
same per-item template as the bare element (both go through compile_body_parts/4 +
scope_az/4 with the same fingerprint, so the result is byte-identical). The two callback
forms reach compile_each as different AST: a named ref resolves to its untransformed
clause, so its body is a raw arizona_template:html(...) call, unwrapped to its argument; an
inline fun's ?html was already compiled bottom-up into a template-map literal, taken
directly (re-wrapping its d-list into the per-item fun). A wrapper as a list item
([?html(...)]) is not unwrapped -- only a whole-body wrapper -- so it stays rejected like
a wrapped descriptor.
A per-item component -- a descriptor inside the item element, {li, [], [?stateless(...)]}
-- compiles, renders, and diffs. A ?stateless child keeps the positional per-item path: an inner
value change ships one OP_LIST_PATCH carrying an OP_ITEM_PATCH for the affected index, and an
append ships an OP_INSERT. A ?stateful child also renders and diffs, but it costs that path:
diff_each_items/6 patches positionally only when the render added no child view
(map_size(NewLocal1) =:= map_size(NewLocal0)), because a child view must be re-mounted by a full
re-render. A plain list bearing per-item ?stateful children therefore falls back to the wholesale
marker OP_TEXT, re-rendering every item on any change. When you want self-diffing children, use a
stream ?each: items are keyed by az_key, each ?stateful item is its own self-diffing
live view (an entry in the root live process's views map, not a process of its own),
and merge_stream_child_views/4 carries the child ids incrementally across diffs. A child
lives exactly as long as its item is in the DOM: whichever way the item leaves -- delete/2,
reset/2, a new stream bound over the slot, a drop eviction, or a sort or insert pushing it
out of a limit window -- its child unmounts, and an item that comes back into the window
mounts a fresh one. A key deleted and re-inserted within one update keeps its child (the new
render reaches it through handle_update/3, not mount/1), while the client gets an
OP_REMOVE and a fresh OP_INSERT, so state the browser holds inside it (?local values,
hooks) starts over.
A keyed container reached through a ?stateful child stays per-item, but by a different
route. diff_stream/4 derives its ops by draining the stream's pending op log, and
arizona_eval clears that log before rendering a child (clear_stream_pending/2, which is what
stops a prop-fed child accumulating one queue entry per root update for the life of the process).
So a container under such a child has an empty log while its order HAS changed: draining alone
emits nothing, and the container silently never updates. diff_dynamics/4 therefore takes the
incremental path only when the log can account for the difference -- an unchanged order needs no
ops, a changed order needs a non-empty log -- and otherwise reconciles the two key orders through
stream_reset/8, which needs no log at all: dropped keys are removed, kept keys patched only
where their dynamics differ, new keys inserted, and the minimal moves emitted from the LIS.
The reconciliation diffs against the state the enclosing walk already evaluated, so it renders
nothing extra: dropped keys are removed, kept keys patched only where their dynamics differ, new
keys inserted, and moves emitted from the LIS. An earlier version handed the source to
stream_reset/8 instead, which rendered the whole list a SECOND time -- twice the work, and it
re-ran every item child's mount/1 / handle_update/3, so a child that subscribes or arms a
timer in mount did it twice per diff. Measured on a container under a ?stateful ancestor: 258
bytes flat at 100 items and at 400, and 1,2 ms per diff at 400 heavy items.
A map-source ?each patches positionally like a list. maybe_single_root_opt/4 stamps
single_root by the item BODY (one top-level element) rather than the source kind, so the 2-arg
each carries it too -- that one compiled template serves both a stream and a map, and a stream
never reads the flag (it keys items by az-key and diffs through diff_stream/4, which has no
positional walk to gate).
Positional patching is always correct -- position N is item N in both the DOM and the new
render -- but not always the smaller patch, and that is what outgrows_re_render/3 decides.
Insert or remove anywhere but the tail and every later item patches with its neighbour's content,
so the ops grow with the list while the wholesale re-render stays one op. Erlang iterates a small
map in term order, so a mid-container insert is the common case there, not the exotic one.
Counting how much of the container changed cannot decide it. The three per-op costs are
structurally different: an ?OP_INSERT carries a whole rendered item, statics included; an
?OP_ITEM_PATCH carries values plus framing; an ?OP_REMOVE is a couple of integers. Wholesale
ships the statics ONCE plus every item's values. A count therefore assumes a statics-to-value
ratio, and the real crossover moves from a few percent to nearly all of the container across
ordinary shapes. Two concrete failures of the count form: a constant tuned on inserts overcharges
positional-on-remove by exactly one statics block, so an early remove was re-rendered wholesale
when staying positional was 4.5x smaller; and a shrink whose surviving prefix is unchanged
produces no item patches at all, so no threshold could ever trip it, leaving a 200-item container
shipping 198 removes where one re-render was 2.2x cheaper.
Before any of that, the walk strips what did not change. diff_list_positional/5
removes the common prefix and, when the lengths differ, the common suffix, and diffs only
the middle. Without it a head insert reads as "every position differs" and emits one item
patch per item plus a tail append -- each patch carrying the value of an item that merely
shifted along. With it the same change is a single ?OP_INSERT at the position the item
actually goes, at any container size, and a single delete is one ?OP_REMOVE. Sub-op
indices address the OLD positions, which is what the client resolves them against:
applyListPatch snapshots the item roots before applying anything, so an insert at N
lands before whatever was at N, and repeated inserts at one index keep their emitted
order. The suffix strip is skipped at equal lengths, where it costs two reverses and
cannot produce a pure insert or remove.
The same question is asked of a keyed stream, which had no size decision at all:
every ?OP_INSERT carries a whole rendered item, so a bulk grow re-sends the item
statics once per item where one container re-render sends them once in total. Measured
on a 554-item stream, a grow from 31 items shipped 85.046 bytes against a 14.325-byte
re-render. Two differences from the list case. The drain does not visit every item, so
there is nothing free to accumulate and the wholesale side is estimated from the old
items' average size against the new visible count. And a stream carrying child views
is never collapsed: the incremental path keeps their bookkeeping
(merge_stream_child_views/4) while the container re-render has none, so collapsing
would re-mount every child and reset its state. That holds for the children the drain
itself rendered too, even when the old items held none: the re-render would evaluate
those items a second time and mount each child again, leaving the drain's instance
never unmounted.
That mostly retires the fallback for insertions -- one op is never worth re-rendering a container for. What still reaches it is a long run of removes, which no amount of stripping collapses.
Both sides are then estimated in bytes. The wholesale side costs nothing extra to obtain:
diff_list_positional/5 already visits every new item, so it accumulates their value bytes as it
walks and returns them beside the ops. wire_bytes/1 approximates the JSON size of the ops
without encoding them -- only the ratio matters.
The comparison is biased toward staying positional, because those ops also preserve the DOM: the
container is never torn down, so focus, scroll position and ?local values inside it survive.
Wholesale has to be clearly smaller (a 3:2 ratio) and to save something worth having in
absolute terms (?RE_RENDER_MIN_SAVING). The absolute floor matters because a short container can
be twice as cheap to re-render while the saving is a hundred-odd bytes, which is not a trade worth
losing an in-progress selection over -- so a 10-item list keeps its per-item ops and a 200-item one
does not.
API -- effect commands (arizona_js / arizona_android / arizona_os / arizona_effect)
Client effect commands, built per platform -- arizona_js (web), arizona_android (native),
arizona_os (native-shell OS capabilities) -- all returning the neutral tuple
{arizona_effect, [OpCode, ...Args]} (type arizona_effect:cmd(), encoded by arizona_effect).
Used in two contexts:
Template attributes -- commands embedded in az-click, az-submit, etc.:
{'button', [{az_click, arizona_js:push_event(~"inc")}], [<<"+">>]}
{'button', [{az_click, [arizona_js:push_event(~"inc"), arizona_js:toggle(~"#modal")]}], [<<"Both">>]}Auto-encoded to HTML-safe JSON by arizona_template:to_bin/1. No explicit encode call needed.
Handler effects -- returned from handle_event/3 and handle_info/2:
handle_event(~"inc", _P, B) ->
{B#{count => Count + 1}, #{}, [arizona_js:set_title(~"Updated")]}.Commands:
push_event/1,2-- send event to server (template-only). Without explicit payload, auto-collects from element context (see below). Explicit payload is merged on top of auto-collected (explicit wins).toggle/1,show/1,hide/1-- element visibility viahiddenattributeadd_class/2,remove_class/2,toggle_class/2-- CSS class manipulationset_attr/3,remove_attr/2,toggle_attr/2,4-- attribute manipulation (toggle_attr/2presence toggle: add if absent, remove if present;toggle_attr/4flips between two values)dispatch_event/2-- dispatch CustomEvent on documentnavigate/1,2-- SPA navigation, replaces the root view (opts:#{replace => true})patch/1,2-- in-place SPA navigation, keeps the root view and re-renders it viahandle_update/3fetch/2-- HTTP request via the browserfetch(), no page reload (opts:method,body,headers,credentials,on_error,keep_alive). The only mode that can set a realSet-Cookie(HttpOnly honored) without navigating, so it suits flows that rotate a session cookie while keeping form fields and showing inline validation. Hits a controller route (e.g.{post, ...}) that returns the{"e": [...]}effects payload viaarizona_controller:reply_effects/1; those effects are applied (against the trigger element) whenever the body parses, even on a4xx(so the controller can use a real status and still drive inline validation). To re-render the submitting view, return anarizona_js:push_eventin the response -- the client relays it over the existing WebSocket and the view re-renders viahandle_event(no pubsub); broadcast overarizona_pubsubonly to reach other views.on_error(plus anarizona:fetch-errorDOM event) runs only when there is no usable effects body -- a non-JSON page, an empty non-2xx, or a network failure. A redirect is anarizona_js:navigateeffect (arizona_controller:reply_redirect/1), not an HTTP 3xx.keep_alive(defaultfalse) maps to the browser'sfetch(url, { keepalive: true })so a request fired just before a navigation completes instead of being cancelled (browser inflight-body cap ~64KB).transition/1,2-- wrap a command (or list) so its DOM change animates in a view transition (opts:#{types => [binary()]}); see "View transitions" belowfocus/1,blur/1-- focus managementreset_form/1-- reset (clear typed fields on) every form matching the selector, imperatively (unlikeaz-form-reset, which fires only on a successful submit/fetch); a non-form match is a safe no-opselect/1-- select (highlight) the first matching<input>/<textarea>'s text (HTMLInputElement.select); a non-input match is a safe no-opcopy_to_clipboard/1-- copy the first match'svalue(form control) ortextContentto the clipboard (navigator.clipboard.writeText); needs a secure context + user gesture, so it is an event command only (not a handler effect); a missing/blocked clipboard is a safe no-opshow_modal/1,close_modal/1-- open the first matching<dialog>as a true modal (showModal, top layer +::backdrop+ ESC-to-close, unlike theopenattribute) / close it (close); a non-dialog match is a safe no-opscroll_to/1,2-- scroll element into view, smoothly by default;scrollIntoViewopts merge onto that default (#{block => <<"center">>}stays smooth,#{behavior => <<"auto">>}jumps)set_title/1-- set document titlereload/0-- reload pageencode/1-- encode single cmd or list of cmds to HTML-safe JSON binary (called automatically byto_bin)
Native-shell (OS) commands (arizona_os). When the app runs inside a native shell
(Tauri/Electron/...), arizona_os builds OS-capability commands -- all through one generic op
?EFFECT_OS carrying a capability name plus args (the shell owns the vocabulary; new
capabilities are new names, not new op codes): set_title/1, focus/0, minimize/0,
maximize/0,1, fullscreen/1, notify/1,2, capture_protection/1, command/1,2. Negotiated via
the _az_caps connect param and read server-side with ?capability(Name); a safe no-op in a
plain browser. See os.md.
Selector targeting: the broadcast commands (toggle/show/hide, the *_class and
*_attr ops, reset_form) act on all elements matching the selector;
focus/blur/scroll_to/select/copy_to_clipboard/show_modal/close_modal
act on the first match.
Payload auto-collection (push_event): When push_event fires on an element, the client
auto-collects a base payload from the element and event context, then merges explicit payload on
top:
- Drop events →
{data_transfer: "dragged-key", drop_index: N}-- drag data fromdataTransferand the position, among theaz-dropcontainer's[az-key]children, of the child the drop landed in;-1when it landed on the container itself (an empty column, the gap after the last item). The whole container is the drop zone, so an empty one accepts a drop. Only an item drag is claimed -- one whosedragstartbegan on an[az-key]element in the page: any other drag (text or a file dropped onto an input in the container's header, onto its blank space, or anywhere outside a container) is the browser's own, anddragover/dropare left uncancelled - Forms →
new FormData(form, submitter)as a map -- all form field values keyed byname; when the form was submitted by a named submit button, that button'sname/valueis included too (the nativeFormDatasubmitter arg), so a form with two submit buttons reports which one fired. A non-submit trigger (a plainaz-clickbutton) has no submitter, so it never drags the enclosing form's fields along -- gathering is a property of submitting the form (the Other case below stays{}). A repeated name is a list. Three kinds of field are lists by their markup, whatever is selected --["a"]for one value and[]for none, never a bare string or a missing key, so a handler matches one shape: a<select multiple>, a checkbox group (more than one checkbox sharing a name), and any field whose name ends in[]. The[]suffix is the one to use for a group whose rendered size comes from data: a group that can render a single box has no second box to mark it as a group, soname="tags"would send a string with one tag and a list with two, whilename="tags[]"always sends a list. The key keeps its brackets ("tags[]"), exactly as a native form POST or afetchbody sends it (the server's query-string parser keeps keys verbatim). A lone checkbox without the suffix stays a single field (its value when checked, absent when not), and a disabled field is left out as the browser leaves it out of a native submit. Behaviour change: a checkbox group or multiple select with one value used to arrive as a bare string, and with none as a missing key; a handler matching those shapes must now match a list - Inputs/selects/textareas →
{value: el.value}-- current input value; a checkbox/radio addschecked, and a<select multiple>sendsvalueas the list of selected values - Other →
{}-- empty
This means az-keydown or az-focusout on an input automatically includes {value: "typed text"},
and az-drop automatically includes {data_transfer, drop_index}. No special attributes needed.
Op codes defined in include/arizona_effect.hrl -- integer constants shared with the client JS
runtime. Same codes for both template commands and server effects.
Key filtering via on_key/2 -- wraps a command so it only executes when the pressed key
matches:
%% Atom -- literal match
{az_keydown, arizona_js:on_key(enter, arizona_js:push_event(~"submit"))}
%% List -- match any
{az_keydown, arizona_js:on_key([enter, escape], arizona_js:push_event(~"close"))}
%% Binary -- regex pattern
{az_keydown, arizona_js:on_key(~"^[a-z0-9]$", arizona_js:push_event(~"type"))}View transitions
A view transition wraps any DOM change in document.startViewTransition; it is not tied to
navigation. The API is mostly CSS -- the framework only starts the transition. Requested
per-trigger (no global switch) by arizona_js:transition(Cmd[, Opts]) (wraps the command whose
DOM change should animate, like on_key/2; Cmd is one command or a list) or the az_transition
attribute on any triggering element (link or az_click/...; bare = cross-fade,
{az_transition, ~"slide back"} = space-separated types).
The client picks sync vs async from the wrapped command:
- Sync effect (
toggle/add_class/...): wrapped in place immediately. navigate: the page swap is a futureOP_REPLACE(op 8); the intent is held in_pendingTransition(kind: 'replace') and the worker message handler wraps that batch (a stray text/attr tick is ignored).push_event: the resulting diff is a future message;_pendingTransition(kind: 'any') wraps the first response batch and is then consumed either way (a no-diff event can't dangle onto a later one). A concurrent server push (e.g. a timer) could race; navigation and sync effects are race-free. (Same-message server diffs still can't be wrapped -- the client applies ops before effects -- so a handler-returnedtransitionpairs withnavigate/push_event.)
Wrapping a mix of sync and async commands animates the async result; sync siblings apply
immediately. The wrap is applied at the worker message handler, so a message's ops and effects
animate together, in order. By default the whole root cross-fades; a view-transition-name on an
element scopes/morphs just it (and must be unique among rendered elements or the browser skips it).
Guards: no-op (instant swap) when startViewTransition is absent or prefers-reduced-motion;
types use the object-form call only when :active-view-transition-type() is supported.
Back/forward is per edge: a transitioned nav stamps both ends of the edge it creates (the outgoing
entry's _azTransitionNext, the new entry's _azTransitionPrev), every SPA entry records its
position (_azIdx), and popstate replays the transition of the edge it crossed -- so traversing that
edge animates in either direction, and Back over a plain edge into an entry that was reached by a
transition does not. Cross-document (real <a href>) navigations animate via the user's
@view-transition { navigation: auto; } CSS with no framework code. All styling
(view-transition-name, ::view-transition-*, :active-view-transition-type) is user CSS. See
.claude/rules/js.md for the client surface.
Client-owned slots -- ?local
A ?local(Key, Init) slot is static to the server, dynamic to the client: the server
renders Init once at SSR and marks the slot diff => false so it is never patched; the
browser owns the value (keyed by Key) and mutates it locally with no WebSocket
round-trip. For UI-only state (dialog open/close, tabs, toggles) the server doesn't need.
Authoring (?local = arizona_template:local/2, also az:local/2):
{'span', [], [?local(~"title", ~"Hello")]} %% content
{'dialog', [{open, ?local(~"modal_open", false)}], [...]} %% boolean attribute
{'div', [{'data-active', ?local(~"tab", ~"home")}], [...]} %% valued attribute (CSS-driven tabs)
{'p', [], [~"Name: ", ?local(~"first", ~"Ada"), ~" ", ?local(~"last", ~"Lovelace")]} %% many content slots + static text
{'a', [{href, [~"/u/", ?local(~"id", ~"1"), ~"/edit"]}], [...]} %% interpolated attribute (static + one local)Compile-time constraints: Key must be a literal binary or atom (an atom normalizes to a
binary); a key can't bind both content and an attribute on one element; not allowed in
?native templates. Under az-nodiff only the element holding a content ?local gets an az
and that slot's marker; a layout's local sits outside every view, so set_all reaches it.
A content ?local does not have to be the sole child -- an element can hold several
content slots, freely mixed with static text and other dynamic children. An attribute value
may also interpolate one ?local with static text around it (the statics become a
prefix/suffix); two ?local in one attribute, or a ?local mixed with a server-owned
dynamic there, are compile errors (local_attr_multiple / local_attr_mixed). Interpolation
is for string-valued attributes (class, style, href, data-*, value); a boolean
attribute must use a whole-value ?local ({disabled, ?local(~"d", false)}, which
renders false → absent / true → bare) -- an interpolated value always renders
name="value", so an interpolated boolean would be disabled="false", i.e. still present.
Mechanism: arizona_template:local/2 returns #{diff => false, az_local => Key, v => Init}
(attributes add target => {attr, Name}). eval preserves it; arizona_diff skips it via
its per-dynamic az_local clauses (never re-evaluated, never patched); arizona_render
unwraps it. Each content slot is an individually comment-marked text node
(<!--az:X-->Init<!--/az-->), exactly like any dynamic text child. The parse transform
bakes a self-describing az-local descriptor attribute (escaped JSON
{c: {slotIdx: key}, a: {attrName: key}, ap: {attrName: [prefix, suffix]}}) onto the element
-- c maps each content slot's dynamic-slot index to its key, a maps each attribute name to
its key, and ap (present only for interpolated attributes) carries the
static prefix/suffix. The client scans [az-local] live (no persistent index): for a content
slot it reconstructs the marker az from the element's runtime az + the slot index
(mirroring arizona_html:text_az/2); for an interpolated attribute it recomposes
prefix ++ value ++ suffix on set and strips them on read. An interpolated attribute's
composed initial value is baked into the bind-map's v ([Prefix, to_bin(Init), Suffix]), so
SSR render and diff-skip reuse the whole-value attribute path unchanged.
Updating (event attributes / handler effects via arizona_js; never sent to the server --
op ?EFFECT_SET_LOCAL):
| Builder | Scope |
|---|---|
arizona_js:set(Key, Value) | closest view of the trigger (markup-only) |
arizona_js:set(ViewId, Key, Value) | a named view |
arizona_js:set_all(Key, Value) | every view (document-wide) |
As a handler effect (returned from handle_event/handle_info), use set/3 or
set_all/2: the closest-view set/2 resolves against the trigger element, which a handler
effect runs without (applyEffects dispatches against <html>), so it is a no-op there.
Client JS API: arizona.set(viewId, key, value), arizona.setAll(key, value),
arizona.get(key) / arizona.get(viewId, key). Content writes are text-only (a < in
a value renders as text, never HTML -- no injection); attribute writes reuse
setAttribute/removeAttribute (with value/checked property sync): boolean true =>
present, false => absent.
Caveats (by design):
- Wholesale re-render resets it. A
?localvalue survives normal per-slot diffs, but an enclosing subtree re-rendered as a unit (anOP_TEXTreplacing a whole slot,OP_REPLACE, an?eachitem swap, a conditional template switch) recreates the slot at its SSR initial -- unless it sits inside an az-nodiff element that is kept. - Inside
?each, items share the slot key.?localkeys are compile-time literals (you can't build?local(<<"open_", Id/binary>>, ...)-- that errorslocal_key_not_literal), so every item rendered from the same template carries the same key andset/set_allupdates all of them at once.?localtherefore can't hold per-item independent client state in a list/stream -- use server state for that. (An item reorder/remove is also a wholesale re-render that resets that item's slot.) - Forced reconnect resets it. A non-1000 socket close fresh-mounts the view.
- Server never reads the value. To use it server-side, read it client-side
(
arizona.get) and include it in apush_eventpayload -- auto-collection ignores[az-local]. - No JS = frozen at initial;
getreturns strings (no type preservation). az-hookupdated()fires on a clientset. A bound element carrying anaz-hookseesupdated()for changes the server never observed -- treat that hook's state as client-local too.- Accessibility is the author's job. Driving a presentational attribute (
open,data-active) does not touch ARIA. A native<dialog open>is announced for free, but the CSS-tabs pattern above sets noaria-selected-- and since?localwrites a literal value (no expressions), per-element ARIA that depends on the slot needs one boolean slot per element or server-tracked state. - Dangerous attributes are the author's responsibility. Content writes are always text
(never HTML), but binding
href/style/anon*handler to author-supplied data is on you -- only the attribute name is fixed at compile time, the value is not escaped.
API -- arizona_roadrunner_router.erl
Roadrunner route compilation. A {reload, ...} entry adds the dev-mode SSE endpoint, and a
build-opts variant supports hot-reload-safe rebuilds.
compile_routes/3-- build the map-shape roadrunner dispatch from route specs and store it under the listener-scoped{arizona_roadrunner_dispatch, Name}persistent term (the keyresolve_route/3reads); threads build-time opts (e.g.compress => false) for replay across hot reloadsroutes/1,2-- expand specs into roadrunner's map-shape route entries without compiling (used by the listener boot path)
Route types:
{live, Path, Handler, Opts}-- live route, GET/HEAD-only (Optsmay containlayouts => [{Mod, Fun}]andbindings => map()){ws, Path, Opts}-- WebSocket endpoint{asset, Path, {priv_dir, App, SubDir}}-- static asset from priv (served via zero-copy sendfile by roadrunner's built-inroadrunner_static){asset, Path, {dir, Dir}}-- static asset from absolute directory- Either asset form is GET/HEAD-only (
roadrunner_staticserves the file whatever the method, so any other verb gets405+Allowfrom the router); acors/1transform on it also letsOPTIONSthrough so the preflight reaches the transform. - Either asset form compiles to
Path/*path, so the catch-all is generated and never appears in the route list. Matching is first-match-wins in list order, so a shorter asset path listed ahead of a longer one it prefixes swallows it -- order the longer prefix first. {Verb, Path, Handler, Opts}(Verb=get/post/put/patch/delete/head/options) -- single-verb controller;{match, Spec, Path, Handler, Opts}covers multi/custom/any-method (Spec= a verb, a list of verbs, a custom uppercase binary like~"PROPFIND", or'*'). Behind the Arizona middleware pipeline (CSRFcheck_originon by default);Optsiscontroller_opts()carryingstate/action/middlewares/check_origin. Dispatches througharizona_controller:dispatch/3, which callsHandler:Action/2(theactionoption, defaulthandle). A wrong-verb request gets405+Allowfrom the adapter's router before any middleware, and two routes may share a path with disjoint verbs for REST-style dispatch. (Declare mutations non-GET: a state-changing GET is CSRF-able regardless -- a cross-site GET nav carries aSameSite=Laxcookie but noOrigin.){reload, Path, Opts}-- dev SSE reload endpoint (arizona_reload_controller, served as a controllerloopreply by any adapter that supports thereloadcapability)
API -- arizona_stream.erl
Pure stream data structure -- create, mutate, and query streams. The #stream{} record is defined
in src/arizona.hrl. All rendering/diffing of streams stays in
arizona_eval.erl/arizona_diff.erl.
new/1,2,3-- create a stream with key function, optional initial items, and optional opts (#{limit => pos_integer() | infinity, on_limit => halt | drop}; see "Limit modes" below)insert/2,3-- insert item (append or at position)delete/2-- delete item by keyupdate/3-- update item by keymove/3-- move item to new position (emits{move, Key, AfterKey}pending op)sort/2-- sort by comparator function (emitsreorderpending op)reset/1,2-- clear stream or replace with new items (emitsresetpending op)to_list/1-- return items in orderget/2-- look up item by key (crashes if key not found)get/3-- look up item by key with default valueget_lazy/3-- look up item by key, callsFun()only if key is missingclear_stream_pending/2-- clear pending ops for stream keys in bindingsstream_keys/1-- scan bindings for stream keyspending_ops/1-- the queued ops, oldest first (stamps stripped)drain_mark/1-- the mark a drain records in its snapshot: the stamp of the last queued op, ornonewhen the queue is emptyundrained_ops/2-- the ops a drain holding that mark has not consumed yet
Keys must be unique once rendered
An item is addressed on the wire and in the DOM by its key's text
(arizona_template:to_bin/1): the key field of OP_INSERT/OP_REMOVE/OP_MOVE/OP_ITEM_PATCH
and the item's az-key. So keys that are distinct terms but render alike -- 1 and ~"1", a
and ~"a" -- are two items to the stream and one address to the client, and an op meant for one
patches the other. Give a stream keys of one type.
Drain marks
Each queued op carries a globally unique, monotonic stamp ({Seq, Op} inside pending). A drain
records drain_mark/1 on its snapshot (drained key) and the next drain of the same slot calls
undrained_ops/2 to resume just past it, so a queue that is re-drained emits only the new ops
instead of replaying its history.
This matters because the queue is drained by the differ but cleared by the live process, which only reaches streams held as top-level bindings. A stream nested inside another value -- a field of a parent stream's item, or a stream inside a map binding -- is never cleared, so every re-eval of the enclosing slot re-drains it.
The resume locates the stamp rather than counting positions off the head. A stream is an
immutable value and can have several divergent successors (a view that keeps a pristine stream and
re-derives the rendered one per event), and position counting cannot tell such siblings apart --
it would drop a sibling's genuine ops. A stamp the queue does not contain (a fork, reset/1,2,
clear_stream_pending/2, a rebuilt or rolled-back stream) falls back to a full drain, which never
skips. The search runs backwards from the newest op and stops at the mark, or the moment it drops
below it, so it costs O(ops added this cycle) rather than O(queue length).
That guard sits at one call site, so the other two ways into diff_stream/4 -- the dep-aware walk
(diff_each/9, which diff/4 uses, i.e. production) and the stream-inside-a-stream-item walk
(diff_item_dynamics_v/3) -- reached the drain straight and dropped the change: on identical
input diff/3 emitted the removals and diff/4 emitted nothing. diff_stream/4 now reconciles
an empty drain itself (drainable_ops/3 substitutes a {reset, #{}}), so every entry point
agrees. The two mechanisms are complementary rather than redundant: stream_relist/7 diffs
against the state the enclosing walk already evaluated, which diff_each/9 does not have.
What a full drain assumes -- and the genesis reset that keeps it true. A drain replays a log
against what the client actually holds, so a full drain is correct only when the queue's own
starting point IS that state. It is for the flow the fallback was written for: the live process
clears a top-level binding's queue right after each drain, so the queue restarts exactly where the
client is. A constructed stream is the case where it is not -- new/1,2,3 starts from an empty
collection, so queueing its items as pending INSERTS described the delta from empty. Bind such a
stream over a slot that already holds one (rather than mutating the stream that is there) and
those inserts were all the differ had: keys the new stream dropped were never mentioned, and keys
present in both were skipped as replays, so a shrink, a reorder and an in-place content change
each diffed to NOTHING and the stale list stayed on screen with no self-heal. The constructors
therefore open the queue with a genesis reset (reset) -- the op that already means
"reconcile the client to my current state" -- which makes the log truthful for any consumer.
It costs the incremental path nothing: a resume that finds its mark skips past it, and replaying
it in a re-drain is idempotent.
A log that reorders drains as one reconcile. The drain replays ops one at a time against
what the client holds, and the one thing it cannot always follow op by op is order: a reorder
(sort/2) carries no order of its own, so the only order the drain can see is the final one,
not the one the client has reached by that point. A positional op after it was placed against an
order the client did not hold yet, and a second sort then found the client "already" in the
final order and moved nothing. So a drained log holding a reorder or a
reset is replaced by one reset (drainable_ops/3): remove what left, patch what stayed, move
the stayers into order, then insert each arrival at its final index. A log of sorts alone needs
no item walk and drains as one reorder from the client's order to the final window instead.
A move and a positional insert are different: each is placed by where it sits in the source
when it is queued (move/3 records the key it lands after, insert/3 an index), so on an
unlimited stream, whose client holds the whole source in order at every step, they replay op
by op exactly. On a limited stream hidden keys count toward that placement but are not on the
page, so there they take the reset too. An unlimited stream takes it for the same reason
when a positional insert or a move was queued while a key the event inserts and deletes again
was in the source: the drain never sends that key, so an index that counted it lands one slot
off and a move anchored on it lands at the tail. An append, or a positional op queued after
the delete, counts no such key and stays op by op. A log of appends, deletes and updates keeps the
op-by-op drain, and a move/3 to the position a key already holds queues nothing. The
invariant, checked by a randomized model test in arizona_stream_SUITE: after a frame's ops the
client shows exactly an SSR of the stream's final state.
A reset measures kept items against the snapshot, not against the pre-reset source. The
reset op carries no items. The drain re-renders a kept item only where its fields differ from
the item the client's DOM was rendered from, which is the source the stream's snapshot keeps
(every stream snapshot does, the post-drain one included). The source as it stood just before
reset/1,2 is not that item whenever the same event touched it first: reset/2 rebuilds the
queue, so an earlier update/3 is gone by the drain, and measuring from the updated item found
nothing to ship.
Each key renders once per drain, from its final item. One event can queue several ops for a
key -- an insert then an update, two updates, an insert, a delete and an insert again -- and
rendering the item once per op ran every ?stateful child in it once per op as well, each time
against the views from before the diff: a second render mounted a second instance (the first was
never unmounted) or ran handle_update/3 again from the pre-event state. So the op-by-op drain
renders an item from the stream's final source (which is already the post-op state) the first
time an op needs it, and a later op for that key reuses that render: an update of a key the drain
already rendered has nothing left to patch, and a re-insert ships the same render again. The client
still receives every op the log implies, in log order, so positional ops placed against the
intermediate order keep their meaning. An update re-renders against the item the client's DOM was
rendered from (the snapshot's source, as for a reset), so the skipped dynamics are those that
every update of the key in the event left alone; the queued {update, Key, Item} op therefore
carries no changed-field set of its own.
The queue of an unclearable nested stream still grows, one retained op term per mutation. Keep a stream that mutates over a long-lived session as a top-level binding.
Limit modes -- limit + on_limit
limit is a visible window, not a hard cap: items past it stay in the source, invisible.
on_limit decides only what happens to the source when a full stream is appended to:
infinity(the default) -- no cap, every item visible.{Limit, halt}(on_limit => halt, the default whenlimitis set) -- onceLimititems are present, further items are kept initemsbut stay outside the window.{Limit, drop}-- a bounded tail -f buffer. An append (insert/2, or theupdate/3upsert of a missing key) to a full stream evicts the oldest (front-of-order) item from the source, so the window slides onto the new tail. Only appends evict: "oldest" is well-defined only for the append flow, so a positionalinsert/3(a window position, not an age) and pre-population (new/3,reset/2, which set the whole order at once) keephaltsemantics. An append to an overfulldropstream evicts down to the limit, so it converges tosize =< Limit. Each eviction queues a{delete, Key}, so a key evicted and re-appended in one event reaches the client as a removal and a fresh insert rather than as a replay of a key it already shows.
Window reconciliation is shared by both modes (apply_limit/6 in arizona_diff). By the time
the diff runs, order already reflects the halt/drop retention decision, so reconciliation is
purely: remove the DOM items that fell out of the window, and insert the newly-visible ones at
their ordered position. Positional back-fill is what lets a delete slide the next hidden item into
the freed slot and a sort bring a hidden item into view at the right spot (appending at -1 only
happened to be right when the new item was last in the window). window_unchanged/4 is the
allocation-light fast path: when the new window equals the pre-frame one element-wise and the
snapshot holds exactly those keys, the whole remove/back-fill pass is skipped -- the common case of
a content update to an already-visible item.
API -- arizona_watcher.erl
Behaviour for the file-system watcher behind the dev reloader. Core ships no implementation, so the
file-system dependency stays out of every build that does not reload code; arizona_watcher_fs (a
separate package on the fs library) is the reference one, added to a dev-only rebar3 profile and
named in the reloader env:
%% rebar.config
{profiles, [{dev, [{deps, [arizona_watcher_fs]}]}]}.
%% config/dev.config
{arizona, [
{reloader, #{
enabled => true,
watcher => arizona_watcher_fs,
rules => [
#{directory => "src", patterns => [".*\\.erl$"],
callback => fun arizona_reloader:reload_erl/1},
#{directory => "priv/static", patterns => [".*\\.css$"],
callback => fun arizona_reloader:reload_css/1}
]
}}
]}.watcher is a module or {Module, Opts} (an Opts map handed to init/2). An implementation is a
callback module run inside the rule process, not a process of its own:
init(Dir, Opts) -> {ok, State} | {error, Reason}-- start watching the absoluteDir, delivering events to the calling processhandle_info(Info, State) -> {ok, Paths, State} | {stop, Reason, State}-- translate one message into the paths whose content changed (created, modified, deleted, renamed into the directory);[]for anything else, including a directory rename or a file moved out.stopreports a dead watch: the rule stops withReasonand is restartedterminate(State) -> ok(optional) -- release what the watch holds; runs on every stop, supervisor shutdown included (the rule traps exits), within the 5 s shutdown timeout
The rule traps exits, so a process the implementation links to it that exits abnormally stops the
rule with that reason. Rules with no watcher, a watcher not on the code path (its package is not
a dependency of the running profile), or a rule without directory fail the boot with a formatted
error naming the fix. An enabled env with no rules needs no watcher (code still reloads through
the dev MCP reload tool).
API -- arizona_reloader_rule.erl
One process per reloader rule, from child_specs/1, under arizona_reloader_sup. That supervisor
is a temporary child of arizona_sup, so a watch that keeps crashing exhausts the reloader's
restart intensity (5 in 10 s) and stops hot reload, not the application.
start_link(Dir, Opts)-- starts a linked gen_server that runsOpts'watcheronDir. Options:watcher(required),patterns(list of regex strings, default[".*"]),callback(fun receiving the list of changed file paths),debounce(ms, default 100). On debounce fire: calls the callback, then broadcasts{arizona_watcher, Dir, Files}(Dirthe rule's absolute directory) viaarizona_pubsubon channelarizona_watcherchild_specs(Reloader)-- the rule processes for areloaderenv map, validating itswatcher
API -- arizona_pubsub.erl
PubSub for cross-view communication. Thin wrapper around pg with scope arizona_pubsub. Channels
are arbitrary terms. Messages are raw data (no wrapper tuple).
subscribe(Channel, Pid)-- subscribePidtoChannel;ok, or{error, already_joined}whenPidis already subscribed. The membership check and the join are not atomic, so concurrent subscribes of the same pid from different processes can both returnokand join it twice (see the moduledoc);?subscribe(Topic)subscribes the calling live process itself, which cannot race itself, so through the macro a topic is joined once. Every view in a live process shares its pid and therefore its membership: a second view subscribing the same topic gets{error, already_joined}(so do not match the result againstok), and one view's?unsubscriberemoves the subscription for all of themunsubscribe(Channel, Pid)-- removePidfromChannel;ok, or{error, not_joined}whenPidwas not subscribedbroadcast(Channel, Data)-- sendDatato all subscribers viaPid ! Databroadcast_from(From, Channel, Data)-- same but excludesFrompidsubscribers(Channel)-- return list of subscriber pids
API -- arizona_live.erl
Simplified gen_server wrapper:
start_link/4-- start with handler module, initial bindings, transport PID, andon_mounthook list. The transport PID receives{arizona_push, ...}updates; passundefinedfor a standalone (non-pushing) process. The live process is transport-agnostic and takes no requeststart_link/5-- as/4plus the connection context the transport knows,#{capabilities => map(), reconnect => boolean(), push_barrier => boolean()}-- the first two are whatcapability/1,capabilities/0, andreconnected/0read./5is the browser WebSocket path (arizona_socket:init/4is its only caller insrc/, always withpush_barrier => true);/4is the terminal transport (arizona_terminal_session). SSR starts no live process at all, so it calls neithermount/1-- callsHandler:mount(Bindings). ExtractsViewId = maps:get(id, Bindings), callsHandler:render(B1)viaarizona_stateful:call_render/2, builds a snapshot viaarizona_render:render_snapshot/2(no document: the connect frame ships none, and evaluating the snapshot observes its command attributes). Returns{ok, ViewId}(no HTML -- SSR is handled separately byarizona_roadrunner_http). It does not check a value's renderability: SSR rendered the same template moments before, and a value only a connected render produces is refused by the first render that ships ithandle_event/4-- unified event dispatch:handle_event(Pid, ViewId, Event, Payload). Checks views map -- ifViewIdis a known child, dispatches to the child handler; if it is the root's id, to the root handler. Returns{ok, Ops, Effects}. An id that is neither is dropped ({ok, [], []}) rather than falling through to the root, so a crafted frame cannot address the root via a bogus id -- and logged as a warning, because the drop is otherwise invisible from both ends (no frame back, no view run). Legitimate causes: an in-flight frame for a view a diff just removed, anaz-targetnaming a view that does not exist, or anaz-viewmarker for a view that was never registered. The client never has to guess: it sendsnullfor "the root view" andarizona_socket'sevent_target/2resolves it against the root id the socket tracks, so apush_eventhandler effect, a hook outside any view, orarizona.pushEvent/2is always recoverable -- a DOM-derived guess would not behandle_info/2-- gen_server callback for Erlang messages (Pid ! Msg,erlang:send_after, etc.). If handler exportshandle_info/2, calls it, diffs, and pushes{arizona_push, RootViewId, Ops, Effects}totransport_pid. Pre-mount messages and handlers withouthandle_info/2are silently dropped. Empty ops+effects are not pushednavigate/3,4--navigate(Pid, NewHandler, InitBindings [, OnMount]). Tears the old page down in this order: cancel timers first (cancel_pending_timers/0erases the whole$arizona_timersregistry, cancels every ref, then drains already-delivered{arizona_view, _, _}messages from the mailbox), then unmount -- embedded child views, then the root, the same children-first order a diff removal andterminate/2use, so every child'sunmount/1runs while the ROOT is still mounted. That guarantee is against the root only:viewsis a flat map walked withmaps:foreach, so among nested views the order is the map's key order -- a nested parent may unmount before its own child. Then mounts the new handler (applying anyOnMounthooks), resets gen_server state (handler, snapshot, views), preservestransport_pidandsent_fps, and returns{ok, NewViewId, PageContent}. The previous root handler's final bindings are carried forward as the floor for the new handler's mount input, minusarizona_eval:restricted_keys/0-- those are route-bound, anddo_mountrequires the new handler's mount to keep them verbatim, so carrying them would force the new route to pretend it is the old one.InitBindings(route static config + middleware enrichments) overrides on key overlap. Keys the new handler omits from its mount return value are dropped on the next navigate. Stateful children's state (inviews) is wiped: a child that was alive on the old page is gone unless the new page re-embeds itpatch/2--patch(Pid, Params). In-place navigation: keeps the current root mounted, runs itshandle_update/3withParams, and re-renders through the diff (no unmount, no remount, no timer cancel, noOP_REPLACE).handler, view id, and child views all survive. Returns{ok, Ops, Effects}-- the diff ops plus the reaction's effects (handle_update's, folded with any child's). Mirrorshandle_root_event, but the reaction ishandle_updateinstead ofhandle_eventdev_info/1(Pid)-- a live process's current view state for dev-time introspection (root handler/bindings plus each child view's), read viasys:get_state/1so a wedged view still answers and nothing is added to the process's own protocol. Backs the dev MCP'sview_state.apply_on_mount/2-- folds theon_mounthook chain:apply_on_mount(OnMount, Bindings). Each hook isfun((Bindings) -> Bindings)or{Module, Function}
Internal state: #state{handler, bindings, snapshot, views, on_mount, transport_pid, sent_fps} where views :: #{ViewId => #{handler, bindings, snapshot}} and sent_fps tracks
fingerprints already shipped to the client for deduplication.
compute_changed/2 builds the Changed map by comparing old and new bindings key-by-key.
push/4 sends {arizona_push, RootViewId, Ops, Effects} to the transport PID -- RootViewId
names the emitting page's root view.
No-ops when PID is undefined or ops and effects are both empty.
Per-view messages and the timer registry
send/2 is self() ! {arizona_view, ViewId, Msg} -- messages are view-id tagged, so
handle_info/2 routes a tagged message to the matching root or child view (an unknown id raises
{unknown_view, ...}). An untagged message still reaches the root through the catch-all clause, but
it can never address an embedded child, so ?send/?send_after are the API.
send_after/3 additionally registers its ref in the $arizona_timers process-dictionary map,
#{ViewId => {Count, Limit, [reference()]}}. A fired ref is pruned when its message is delivered.
A timer the app cancels itself with erlang:cancel_timer/1 (a debounce, an idle watchdog) delivers
nothing, so send_after/3 also drops the view's dead refs when an arm would take the list past
Limit -- twice the live count the previous prune found, with a small floor. Without that the
registry would grow by one ref per re-arm for the page's lifetime; pruning on every arm instead
would cost O(live timers) per arm, quadratic for a view arming many concurrent timers. The
doubling keeps the list within 2x the live timers at O(1) amortized per arm. A removed child
view's timers are cancelled synchronously (and its queued view messages flushed) on unmount; and
navigate/3,4 cancels them all. A render that keeps a child's id but switches its handler
(case ?get(k) of a -> ?stateful(a, #{id => ~"c"}); b -> ?stateful(b, #{id => ~"c"}) end) is a
removal of the old instance too: its timers and queued messages are dropped before its unmount/1,
and before the new handler mounts, so they never reach the new handler's handle_info/2 while a
timer the new handler arms at mount survives.
What the navigate sweep does and does not guarantee. The sweep runs before the unmount
callbacks, so it covers every timer armed during the old page's normal lifetime -- mount/1,
handle_event/3, handle_info/2 -- and none of those can fire into the new page. It does not
cover a timer armed inside unmount/1: cancel_pending_timers/0 has already erased the
registry by then, send_after/3 re-creates it, and nothing sweeps it again before the new mount.
Such a ref survives, and when it fires the new page's root id and views map do not know the old view
id, so handle_info/2 raises {unknown_view, OldViewId, Msg} and the live process dies (or, if the
new page happens to reuse the same view id, the stale message is delivered to a different handler).
So do not arm timers in unmount/1 -- it is a teardown callback, and the ordering is chosen so
the cancel cannot race a timer the old page armed while it was still running.
The same goes for a plain ?send from unmount/1, on a diff removal as well as on navigate: the
removed view's queued view messages are flushed before its unmount/1 runs, so a message the
callback sends to its own id lands after the view is gone and raises {unknown_view, ViewId, Msg}
once the live process reads it. On navigate there is one exception, and it is worse: when the new
page reuses the id (the same root id, or a child id it renders again), the message is delivered to
the new handler's handle_info/2 instead. On terminate/2 the process is exiting, so the
message simply dies with it. Release resources in unmount/1; never message the view being torn
down.
Process-dictionary keys
The live process's dictionary holds the keys below, all read through accessor functions rather
than directly. Some are arizona_live's own connection and scheduling state, the rest are set by
the render/diff, which runs in the same process -- the "Set by" column is authoritative:
| Key | Set by | Read by |
|---|---|---|
$arizona_connected | set true while a transport is attached | connected/0 (the ?connected macro) -- SSR vs live |
$arizona_reconnected | set true when this connection is a reconnection; erased by navigate/3,4 (the new page's mount is its first), kept by patch/2 (same handler only; a cross-handler patch is a navigate) | reconnected/0 (the ?reconnected macro) -- gate one-shot commands with ?connected andalso not ?reconnected |
$arizona_capabilities | the _az_caps map the client advertised at connect | capabilities/0 / capability/1 (?capabilities / ?capability). A UI/effect hint, never authorization |
$arizona_timers | send_after/3 | unmount and navigate/3,4, to cancel |
$arizona_unmounted | unmount_view/3, for every child view a pass unmounts while the state still lists it (a swapped-out handler, a diff removal, the outgoing page's children on navigate), and navigate for the outgoing root (under the key root); erased once the pass has unmounted the last of them (root and child diffs, do_mount/4, navigate/3,4) | terminate/2, so a pass that raised after unmounting a view or the root (its own unmount/1 included) does not unmount it a second time |
$arizona_mounted | note_mounted_view/3, from arizona_eval after each child mount/1 of a pass the live process began (root and child diffs, do_mount/4); erased with $arizona_unmounted | terminate/2, which unmounts the children a pass that raised had mounted, since no state it can see lists them |
$arizona_deps | the per-dynamic eval bracket | arizona_eval, arizona_template:track/1 -- the dependency capture set |
$arizona_update_effects | arizona_eval:set_update_effects/1, seeded with the originating callback's effects before a diff | current_update_effects/0 while child handle_update/3s fold onto it; drain_update_effects/0 erases and returns the final list. Threading it here is what lets a child's effects join the root's with no concatenation at the call site |
{'$arizona_scoped_statics', Prefix, Fp} | scoped_statics/4 in arizona_template | itself -- a memo for the per-slot static prefixing (scope_static/3 on the render backend, per element), keyed by scope prefix + template fingerprint |
Only $arizona_deps, $arizona_update_effects, $arizona_timers, $arizona_unmounted and
$arizona_mounted are ever erased; the rest are set once and left. What makes the scoped-statics
memo different is that it is the one entry that grows unboundedly rather than holding a
fixed-size value. That is deliberate and safe -- prefixing a static list is pure and deterministic
in (Prefix, statics), so a stale entry cannot be wrong, and the payoff is that a re-render or diff
of the same slot (and every level of a deep stateless nest, whose inner statics would otherwise be
re-walked once per ancestor) reuses the cached result. It is bounded by the number of distinct
(slot prefix, fingerprint) pairs the process ever renders, and dies with the process; note it when
reasoning about a long-lived live process's memory, and do not expect a navigate to clear it.
API -- arizona_socket.erl
Framework-agnostic WebSocket protocol state machine. The transport handler
(arizona_roadrunner_ws) feeds frames in and ships return tuples out, so the wire protocol
lives here independent of the server.
init/4--init(Handler, Bindings, Req, Opts). Traps exits, startsarizona_live:start_link/4(passingself()as transport PID and anyon_mounthooks), mounts. On reconnect (#{reconnect => true}), also renders and returnsOP_REPLACEframe -- unless the client flaggedfps_follow(the_az_fps_followupgrade param): then the mount+render is DEFERRED until the client'scached_fpsframe arrives, so the resync payload dedups statics against the announced fingerprints (a deploy-drain reconnect storm re-ships roughly the dynamics instead of every page's full statics). Any other first frame (or a?RESYNC_TIMEOUT_MSbackstop) flushes the resync undeduped first, the frame's own reply following it on the wire (reply_many); a client without the flag keeps the immediate resync. Opts:reconnect(boolean),fps_follow(boolean),on_mount(list ofarizona_live:on_mount_hook/0). The route adapter for SPA navigate is recovered fromReqviaarizona_req:adapter/1handle_in/2-- decode incoming text frame: ping/pong,["cached_fps", FpList],["navigate", #{~"path" := Path, ~"qs" := Qs}](replace),["patch", #{~"path", ~"qs"}](in-place, same-handler ->patch/2, else falls back to navigate),[target, event, payload]handle_info/2-- handle{arizona_push, ViewId, Ops, Effects}fromarizona_live(ops tagged with the view id the push names), thearizona_resync_timeoutbackstop, andEXITsignals from the live process. On a synchronous event/patch/navigate reply, every push already queued in the socket mailbox up to the live process's barrier is taken, whatever view id it names, and shipped first (causal wire order), in the reply frame ahead of its own ops. For a navigate their ops ride ahead of theOP_REPLACE: one may carry the first statics of a fingerprint the replace then references bare, and the replace supersedes them anyway. Their effects are dropped and logged by op code (a client applies a frame's effects after its ops, so they would run against the new page), with a warning for a droppednavigate/patchthat carried a flash, at any wrapper depth (transition,on_key, afetch'son_error). Only this socket navigates its live process:arizona_live:navigate/3,4are transport-facing (apps navigate through thearizona_js:navigate/patcheffects)
Exit reason to close code -- four cases, plus the mount-failure close, and the difference matters because the JS worker treats any non-1000 code as reconnectable:
| Exit reason | Close code | Client behaviour |
|---|---|---|
{shutdown, drain} | ?CLOSE_GOING_AWAY (1001) | Reconnects -- the deliberate drain path (RFC 6455 §7.4 "going away"); the main thread preserves form state |
normal | 1000 | No reconnect |
shutdown / {shutdown, _} (any other reason) | 1000 | No reconnect -- opt into the reconnect path by exiting {shutdown, drain} explicitly |
| anything else, after the view mounted (a crash) | ?CLOSE_CRASH (4500) | Reconnects with no page reload: the worker's backoff reconnect remounts the view in place and the main thread restores typed form fields. A crash within 10 s of the view's last mount (the main thread tells the worker: a first connect's frame, a reconnect's resync, a navigate's OP_REPLACE) does not reset the backoff, so a view that crashes right after every remount keeps backing off; the native clients apply the same rule from their OP_REPLACE. Crash remount is deliberately not attempted server-side |
| any failure to mount the view | ?CLOSE_MOUNT_FAILED (4501) | Reconnects, and reloads the page after a random 5-10 s delay (the page may be stale, e.g. after a deploy); a reconnect that mounts first cancels the reload. Consecutive reloads of one path are counted per tab, in one sessionStorage key holding {path, count} (a reload of another path starts over), so a failing path, which may carry a secret, never outlives the tab; only a successful mount removes it (pagehide and disconnect() just cancel the pending reload); from the 10th on the delay is 30 s, logged once per page load, and retrying never stops. Form fields saved on the close are lost by the reload, unless a reconnect mounts first and restores them. Never resets the backoff |
An application stop takes the drain path: arizona_app:prep_stop/1 drains the arizona_http
listener (its adapter's drain/2) instead of
stopping it, so a restart or deploy closes every live socket 1001 and its clients reconnect --
to the new node once it is up. The
listen socket closes first, each view gets handle_drain/2, and the stop waits up to the
server config's drain_timeout (milliseconds, default 5000) for the sessions before killing
what is left.
A dev reload closes the same way. While the node can broadcast a code reload
(arizona_reloader:enabled/0: the reloader app env holds enabled => true and a rules
list, or an arizona_dev_mcp route is mounted, whose reload tool swaps code without the
watchers -- the router marks that at route compile, not at the first MCP session, so a view
connected before the agent is covered too), each WebSocket session process joins the
arizona_reloader topic at connect and closes 1001 on {arizona_reloader, reload}, so its
client reconnects and remounts on fresh code.
Without it a live view outlives every reload, and a fun held in its state (a stream's key
fun, a fun prop, a closure the app stored) points into code the module's second reload
purges: the next callback that calls it raises {badfun, _} and the socket closes 4500.
The transport process joins rather than the live process, so the membership never meets an
app's own ?subscribe, and the session exits on the first close, so a burst of broadcasts
closes it once. A reload_css broadcast changes no code and closes nothing.
Browser pages close too. A page whose layout runs the SSE reload script
(arizona-reloader.js, connected to a {reload, ...} route) reloads 50 ms after the
event, well before the worker's first reconnect (backoff(0), about 1 s), so the close only
costs that page a brief az-disconnected (a reload slower than that lets the worker reconnect
first, one extra mount on fresh code that the reload then discards); a page without the
script, which the socket cannot tell apart from a native client, reconnects and remounts
instead of keeping the stale view.
A production node (neither a reloader env nor a dev MCP route) reads one persistent term
and the app env once per connection and joins nothing.
The mount sites that close 4501 on any raise: the connect's mount in init/4 (a first connect,
and an unflagged reconnect's mount + render), the deferred reconnect resync (flush_resync/1,
whichever leg settles it), and a navigate (including a patch that degrades to one) -- a crash
exit under its call, whether in the new page's mount or the old page's unmount. Each covers
encoding the frame that delivers the view, since until it arrives the client has no mounted view.
A resync or navigate mount that crashes takes the live process down under the call, so it follows
the dropped-frame rule below: the socket notes the failed call was a mount, and the queued crash
exit then closes 4501 instead of 4500 (a live process already gone before the call closes per its
own exit). A navigate/patch whose middleware halts without a redirect is not a mount failure but
a deliberate refusal: the socket replies with a full navigation to the requested URL
(arizona_js:navigate(Url, #{full => true})), so the browser loads it over HTTP and shows the
middleware's own response at once -- a reconnect's upgrade, refused by the same middleware, never
would. A native client takes a full navigate as a fresh connection to that path and query.
A raise inside the socket itself after the mount closes the same way as a crash: handle_in/2
and handle_info/2 each catch anything their handling raises -- a handler crash, or a frame that
cannot be JSON-encoded, such as a value holding invalid UTF-8 -- and turn it into a logged 4500.
The transport has no catch of its own, so without that the session would end with no close frame
at all (a bare 1006). One raise is not a socket crash: a call into the live process that fails with
the process no longer alive -- gone before the call, or exited under it (a drain landing while an
event, navigate, patch or resync frame is in flight). The frame is dropped and the close is left to
the queued 'EXIT', which arrives behind every push the process sent before exiting -- so a
drain's handle_drain/2 effects still ship -- and then closes per the mapping above: 1001 for
{shutdown, drain}, 1000 for a deliberate end, a logged 4500 for a crash (4501 when the failed
call was a mount). A failed call to a live process that is still alive has no exit to wait for and
is a crash, as is a noproc from any other process a middleware or route adapter calls.
Return type (result()): {ok, Socket} | {reply, iodata(), Socket} |
{reply_many, [iodata()], Socket} | {close, Code, Reason, Socket}. reply_many exists for the
deferred-resync flush: a non-cached_fps first frame produces the resync frame and its own
reply, in that order, on one return.
The #socket{} record carries pid, view_id, handler, req, pending_flash, pending_resync -- the
post-mount state needed to dispatch events and navigate. handler is the current root handler (so
a patch frame can tell same-handler in-place patch from a full navigate); pending_flash holds a
one-shot flash carried in-process across an SPA navigate/patch; pending_resync holds the backstop
timer ref while a flagged reconnect waits for its cached_fps frame (undefined once flushed, or
when the connection never deferred); layouts holds the connect route's layouts, so either frame
kind can tell an in-place swap from one needing the whole page rebuilt; flash_replay holds the
path whose middlewares produced pending_flash, when they did. Flash over a WS navigate requires
a destination that stays on the socket: a target degrading to a full-page navigation destroys it
(and a WS frame has no Set-Cookie leg), so a middleware-set flash is replayed by navigating to
the stashed requested path instead of the target -- the halt re-runs over HTTP and redirects with a
real flash cookie. flash_replay holds the redirect target beside that path so the reroute fires
only for the redirect it belongs to, never hijacking an unrelated navigation. A handler-set flash,
which nothing reproduces, is dropped with a logged warning. A gate on the navigation a flash was
carried into treats it the way HTTP treats the signed cookie on a 3xx. A gate that only redirects
puts it back on the socket for the redirect; a replayable one keeps its original path so a chain
of gates replays from the first, but only when the gated navigation is the redirect it was armed
for (a navigation that beat the follow-up frame is never rerouted through that gate). A gate that
flashes replaces it, so the live path and the replay agree. A fetch_flash step that read it
before the gate halted spends it (arizona_req records that read), as that read clears the
cookie on the 3xx. A halt without a redirect is a full load like any other, replayed or dropped
loudly. A handler effect's flash is captured
from a bare navigate/patch and from one a transition wraps (both run when the frame lands);
one an on_key or a fetch's on_error wraps runs later, if ever, so its flash is stripped with a
warning rather than stashed for whatever navigation comes next. A flash opt that never passes
through the socket -- an event attribute, a controller response -- has no carrier at all, so
arizona_effect's encoders refuse it (flash_outside_live_effect) instead of shipping it to a
client that would drop it. The route adapter is recovered from
req on demand. mount_failed marks a dropped frame whose failed live call was a mount, so the
queued crash exit closes 4501.
Op scoping happens at JSON-encode time, in two steps. flatten_ops/2 walks the nested
[[ChildViewId, ChildOps] | ...] shape arizona_live produces and returns a flat list of
{ViewId, RawOp} tuples, each tagged with its owning view -- it does no string surgery.
op_encoder/2, the custom encoder passed to json:encode/2, then emits each op's target inline as
"<ViewId>:<Az>". Doing the scoping in the encoder means the target string is built once, straight
into the output iodata, instead of allocating a rewritten op list first. Inner ops of an
OP_ITEM_PATCH stay item-relative and are not scoped.
Other internal functions: encode_reply/3 (build the #{<<"o">> => Ops, <<"e">> => Effects} JSON),
flush_resync/1 + resync_then/2 (the deferred-resync flush), close_crash/1 and
close_mount_failed/1 (close tuples), dispatch_event/4, handle_navigate/3 (drives SPA
navigate by invoking the adapter's resolve_route/3).
SPA navigate / patch route resolution
There are two SPA navigation modes, picked per-link by the client:
navigate(az-navigate/arizona_js:navigate) -- replaces the root view: the live process unmounts the old root, mounts the new handler, and ships oneOP_REPLACEof the whole view element.patch(az-patch/arizona_js:patch) -- keeps the root view: the live process delivers the new route's params to the current root'shandle_update/3and re-renders through the diff, shipping only the changed slots. The process, view id, child views, and DOM (chrome, scroll, open menus) all survive.arizona_socketdecides per frame: it tracks the current roothandleron the#socket{}, and a patch whose path resolves to the same handler is applied in place (arizona_live:patch/2); a patch to a different handler can't keep the view, so it degrades to a full navigate/replace, and so does one whose bindings -- the route's, after its middlewares -- name anidother than the live view's (two routes on one handler with different ids are two views; the replace reuses those bindings, so no middleware runs twice). Persistent live chrome is therefore an ordinary app pattern (a view with a swappable?stateful(?get(page), ...)child), not a framework feature -- demo fixturearizona_patch_demo(/patch-demo/:section).
A patch runs no mount step. mount/1 and on_mount run whenever the view (re)mounts --
initial GET, reconnect, navigate -- and not on a patch, which by design keeps the view alive.
This is deliberate: on_mount is a mount input (its output feeds mount/1), so re-running it on a
patch -- where there is no mount/1 -- would dump a mount-time transform straight onto the live
bindings and clobber the state the patch preserves. Per-arrival request-shaped derivation (session,
path params) belongs in a middleware, which does run on a patch (do_patch) and whose output
arrives as Params; handler-specific per-navigation logic goes in handle_update/3. So: mount-time
setup -> mount/1/on_mount; every-arrival setup -> middleware; per-navigation reaction ->
handle_update/3.
Both modes resolve the target route the same way. arizona_socket invokes the optional
resolve_route/3 callback declared on the arizona_req behaviour:
-callback resolve_route(Path :: binary(), Qs :: binary(), Raw :: term()) ->
{ok, module(), arizona_live:route_opts(), arizona_req:request()} | error.
-optional_callbacks([resolve_route/3]).On a live route the callback returns {ok, Handler, RouteOpts, NavReq} -- a handler module, the
route's static options (arizona_live:route_opts/0 -- bindings, on_mount, layouts,
middlewares), and a navigate-scoped arizona_req carrying the new URL. Cookies/headers on the
returned Request are inherited from the original upgrade Req (rebuilt from its raw value), except
the framework cookies the upgrade itself changed (a route middleware, flushed onto the 101):
arizona_socket replays them onto the rebuilt request (internally, in arizona_req). A written or
cleared session is replayed as the browser now holds it, so a navigate's middlewares see it -- in
store mode the signed id, resolved through the store on read, so an in-place store write or a
revocation is seen as well. A flash the upgrade consumed or newly set is dropped from the rebuilt
request: the consumed one is gone from the browser, and the new one belongs to its next HTTP
request, which would show it a second time (nothing over the socket can clear the cookie the
browser holds). The replay is read-only (nothing re-emits or re-commits it), and it is the cookie
as of connect: one the browser receives later (a controller fetch that rewrites it) is invisible to
the socket until the next reconnect. path, bindings (path bindings from the router), and
params reflect the new path/qs. In particular path is the resolved
route path (the _az_path value), not the /ws transport path the WebSocket rode in on, so a
middleware reading arizona_req:path/1 on a navigate/upgrade sees the same logical route as a
plain HTTP GET.
The Path is client-supplied (the _az_path upgrade param, or a navigate/patch frame), so the
callback returns error -- never crashes -- when it does not resolve to a live route: no matching
route, a non-live route (controller/asset/ws), or a method mismatch. A WS upgrade to such a path
is rejected with 404; an in-session navigate/patch degrades to a full-page navigation
(arizona_js:navigate(Path, #{full => true})) rather than tearing down the live session.
A target that does resolve degrades the same way when its layouts differ from the ones
already on screen. Layouts render once, at SSR: ?OP_REPLACE swaps only the view inside them
and a patch keeps the shell outright, so no frame can re-render one, and serving such a target
in place would drop the new page into the old page's shell. arizona_socket tracks the connect
route's layouts (threaded arizona_ws:prepare/3 -> arizona_socket:init/4) and compares by
whole-list term equality -- apply_layouts/4 nests the list, so an inner layer wraps the
replaced view exactly as the outer one does and a difference at any depth disqualifies an
in-place swap. The check sits on both frame handlers, which also covers the middleware
halt-redirect path: that halt emits a navigate effect the client replays as an ordinary
navigate frame.
Shipped implementation: arizona_roadrunner_req exports the optional resolve_route/3 and
runs roadrunner_router:match/3 against the compiled dispatch stored by
arizona_roadrunner_router.
API -- arizona_req.erl
Opaque request abstraction backed by the transport adapter. Constructed via
new/3(Adapter, Raw, #{method := ..., path := ...}); other fields are lazy-loaded via the
adapter's behaviour callbacks on first access and cached in the returned request.
Accessors (eager):
method/1-- HTTP method (~"GET",~"POST", ...)path/1-- the logical route path, no query string. On a WebSocket SPA-navigate or upgrade this is the resolved route path (from_az_path), not the/wstransport path -- so it matches a plain HTTP GET to the same route on both transportsscheme/1-- the connection's own scheme (httpsonly on TLS); whatcheck_origin/2compares theOriginagainst, after folding inX-Forwarded-Protorequest_id/1-- the transport's request id, for log correlationadapter/1-- thearizona_reqadapter module backing this requestraw/1-- the native transport value the adapter wraps
Accessors (lazy, return {Value, Req1}):
bindings/1-- route-pattern path bindings (map of atoms)params/1-- parsed query string as a proplistcookies/1-- parsed cookies as a proplistheaders/1-- request headers as a mapuser_agent/1-- theUser-Agentheader (pairs witharizona_user_agent:browser/1etc.)body/1-- the request body
Response stash (a live view and a middleware cannot reply directly, so they stash):
put_resp_header/3,put_resp_cookie/4,put_resp_status/2-- accumulate onto the requestresp_headers/1,resp_cookies/1,resp_status/1-- pure getters the transport reads at flush;resp_cookies/1only builds the cookie list, it performs no side effectcommit_session/1-- called at response flush (byflush/1). In server-side store mode this is the write that persists the session, so the persist is commit-on-success: a handler that crashes afterput_session/3leaves the store untouchedflush/1-- what a server adapter calls once per response: commits the session and returns the header lines to add (stashed headers, then oneset-cookieper cookie, serialized byarizona_cookie:serialize/3), whatever the response's shapehalt_reply/1-- the{Status, Headers}of a middleware halt (redirect, else stashed status, else403),flush/1's lines included
Every response a transport builds flushes the stash, the WebSocket upgrade included: a cookie, a
header or a session write a route middleware stashes during arizona_ws:prepare/3 rides the
101 Switching Protocols (and, in store mode, commits to the store) exactly as it would ride a
page response. A navigate/patch frame has no HTTP response, so no cookie or header stashed there
is flushed (a flash has its own in-process carry instead).
Other:
set_raw/2-- swap the native raw value, clear all lazy cachescall_resolve_route/4-- invoke the adapter's optionalresolve_route/3(SPA navigate)redirect/2(Req, Location)/redirect/3(Req, Status, Location)-- stash a 3xx redirect intent in the request. Transports pick it up on halt and translate uniformly (HTTP 3xx reply, orarizona_js:navigateeffect on WS navigate)halted_redirect/1-- returns{Status, Location}ifredirect/2,3was called, elseundefinedlocation_kind/1-- reads aLocationas a browser will (leading C0/space trimmed, tab/LF/CR removed):localfor a path on this origin (one leading/), the only form a SPA navigation follows;externalfor anhttp/httpsURL, a scheme-relative//hostor/\host, or a relative path (resolved against the current URL, as an HTTPLocationis), which the transports send as a full load;unsafefor any other scheme, which never reaches the client- flash (one-request):
put_flash/3(set),flash/1(read) -- signed messages cleared on read; survive a full-page HTTP redirect (signedaz_flashcookie) and a WebSocket SPA navigate (arizona_socketin-process carry, exactly-once, no cookie). The WS carry needs a destination that stays on the socket, so a navigate degrading to a full-page navigation splits by origin: a flash a halting middleware set is reproducible, and the socket sends the full navigation to the gated path instead of the target so the halt replays over HTTP and redirects with a real flash cookie; a flash an in-view handler set has no request-side generator and is dropped with a logged warning.flash_out/1,consume_flash/1,seed_flash/2are the transport-side plumbing - session (durable):
put_session/3,delete_session/2,clear_session/1,session/1,get_session/2,3,read_session/1-- encrypted state; a read does not consume, the response re-emits the cookie only on a write.session_id/1returns the opaque id in server-side store mode (undefinedin cookie mode or before there is a session), so an app can record it to revoke later;session_error/1reports a store read failure, kept distinct from a genuinely absent session
Session and flash cookie security
Both cookies are keyed off the secret_key app env (arizona_crypto warns below 32 random
bytes). Flash is signed (arizona_flash, HMAC-SHA256 + a baked-in TTL, so a cookie replayed
past its Max-Age is rejected rather than merely soft-expired); the session is encrypted
(arizona_session, AES-256-GCM) with an absolute expiry stamped inside, so a client can neither
read, forge, nor outlive it.
| App env | Default | Effect |
|---|---|---|
secret_key_previous | [] | Rotated-out keys verify/decrypt still accept (signing uses secret_key only). Each must be a non-empty binary like the primary, else every sign, encrypt, verify and decrypt raises secret_key_previous_invalid: an empty key is a public constant. From an env var, write {env, "OLD_SECRET_KEY", []} (unset or empty resolves to []), not a ~"" default, which resolves to [<<>>] |
session_max_age | 604800 (7 days) | Session cookie Max-Age and the expiry baked into the ciphertext (or, in store mode, into the signed id) |
session_max_bytes | 4096 | Cap on the serialized Set-Cookie line -- name + value + attributes, what browsers actually count. arizona_session:encode/1 errors {session_too_large, ...} past it instead of letting the browser silently drop the cookie |
session_secure | false | The session cookie's Secure flag |
flash_secure | false | The flash cookie's Secure flag |
session_store | unset (cookie mode) | An arizona_session_store module; must be set before the app boots (arizona_sup reads it at init) |
session_store_sweep_ms | 60000 | arizona_session_store_ets periodic sweep interval (plus lazy expiry) |
session_secure/flash_secure default false because a Secure cookie is silently dropped
over plain-HTTP dev and a TLS-terminating proxy cannot be auto-detected -- set them true in
production. Enabling one also switches that cookie to its __Host- prefixed name
(__Host-az_session / __Host-az_flash), whose browser-enforced Secure + Path=/ + Domain-less
rules close sibling-subdomain cookie tossing.
API -- arizona_controller.erl
Reply helpers for controller routes -- plain HTTP handlers (a verb tag like {post, ...}, or
{match, ...}) that the browser reaches through arizona_js:fetch/2. Because a controller answers
a real HTTP request, its response can carry a Set-Cookie (HttpOnly honored) that the WebSocket
transport cannot, which is what makes it the right place to rotate a session cookie while the page
stays put. The adapter dispatches the route through dispatch/2, which runs the middleware
pipeline (Origin check default-on) and then calls Handler:Action/2 -- the action option,
default handle -- with the post-middleware arizona_req:request() and a context,
#{state := State, bindings := Bindings} (the route's state option and the bindings the
pipeline produced, such as session after fetch_session). The action returns
{Reply, Request}, and what the returned request carries (session, flash, cookies) is flushed
ahead of the reply's headers.
A reply is one of four kinds. A buffered {Status, Headers, Body} works on every adapter; a
{stream, Status, Headers, Fun}, a {sendfile, Status, Headers, {Path, Offset, Length}} and a
{loop, Status, Headers, State} (messages reach the controller's handle_info(Msg, Push, State), a client gone away as {arizona_disconnect, Reason}) are capabilities the adapter
declares (arizona_server capabilities/0): dispatch/3 refuses one it lacks with
{unsupported_reply, Kind, Adapter} before the session commits, rather than degrade the reply.
Roadrunner and Cowboy have all four.
reply_effects/1(Effects)-- a200whose body is the{"e": [...]}effects wire payload, the same shape the WebSocket sends. A fetch form auto-resets on this leg (az-form-resetfires only on a 2xx)reply_effects/2(Status, Effects)-- the same with an explicit status. Thefetchcommand applies response effects on any status, so an error leg returns a real422: the effects still run (inline validation) while the form's typed fields survivereply_redirect/1(Location)-- sugar forreply_effects([arizona_js:navigate(Location)]). A fetch-followed HTTP 3xx cannot drive a SPA navigation (redirect: 'manual'yields an opaque-redirect whoseLocationis unreadable), so a redirect is delivered as an effect. ALocationthat is not a path on this origin (arizona_req:location_kind/1) goes out withfull => true, sincepushStaterefuses another origin; a relative one loads resolved against the current URL, as over HTTP. An unsafe scheme (javascript:,data:) raises{unsafe_redirect, Location}rather than reach the client
The response is deliberately effects-only, no ops: a stateless controller holds no diff
snapshot to make ops from (hence the accept: application/json request header). To re-render the
submitting view, return an arizona_js:push_event -- the client relays it over the existing
WebSocket and the view re-renders through its own handle_event/3. To reach other views or
users, broadcast over arizona_pubsub with a topic scoped by user/session. Either way the view
renders from its own state; the response effects are for request-local imperative UI only.
The behaviour expects adapters to implement parse_bindings/1, parse_params/1,
parse_cookies/1, parse_headers/1, read_body/1, scheme/1 against their native request
type. scheme/1 reports the connection's own scheme (https only on TLS), which
check_origin/2 compares the Origin against after folding in X-Forwarded-Proto.
API -- arizona_middleware.erl
The request-to-bindings middleware pipeline. A middleware reduces a request into the plain
bindings a mount/1 handler consumes, keeping the live process and handlers free of any
request/transport coupling.
apply_middlewares/3(Middlewares, Req, Bindings)-- threads a request and bindings map through a list ofmiddleware()left-to-right, returning{cont, Req1, Bindings1}or{halt, HaltReq}; stops on the first halt. Run by the HTTP and WS transports before mounting a view.extract/1(Keys)-- builds a middleware copying selected request data into bindings so a handler reads it with?get(Key). Keys:path_bindings,params,headers,cookies,body,method,user_agent. Being a call rather than a term, it is writable only where routes are declared in Erlang; a route declared inconfig/sys.configis parsed byfile:consult/1and must name a 2-arity wrapper ({my_app_mw, path_bindings}) instead.put_request/2-- escape-hatch middleware exposing the whole request under therequestbinding for lazy access; preferextract/1for specific data.fetch_flash/2/fetch_session/2-- read the flash (consuming) / session (non-consuming) cookie into theflash/sessionbinding. Opt-in; both run on the GET render and the WS upgrade, so a live view is seeded at mount.check_origin/2-- CSRF Origin check (seearizona_origin); the router prepends it tolive/controller routes by default.
API -- arizona_http.erl
Shared HTTP render pipeline that serves an Arizona view as the initial page response. Wraps the
native request in an arizona_req:request(), runs any route middlewares, and calls
arizona_render:render_view/2 (render_view_to_iolist/2 paired with the document's media
type). The transport handler translates the returned result into its native reply shape.
render/3(Handler, Req, Opts)-- returns one of (each threads the request back so the transport can flush any middleware-stashed response headers/cookies):{halt, Request}-- middleware halted; the transport decodes the stashed redirect (arizona_req:halted_redirect/1) or status off the request viaarizona_req:halt_reply/1, which answers a halt carrying neither with403(one default for pages, controllers, and the WS upgrade alike){ok, resp_status(), ContentType, iolist(), Request}-- rendered page body; the status defaults to 200, but a view or middleware may stash a non-200 (e.g. 401).ContentTypeis the document's media type, answered by its render backend'scontent_type/0(HTML for an?htmlview,application/jsonfor a?nativeone){error, 500, ContentType, iolist(), Request}-- rendered error page body (crash or stashed hot-reload error), under the error page's own media type
arizona_roadrunner_http consumes this helper and maps the three result shapes into roadrunner's
reply tuple.
API -- arizona_ws.erl
Transport-agnostic upgrade bootstrap for WebSocket handlers.
prepare/3(QS, Adapter, AdapterState)-- accepts the pre-parsed upgrade query string ([{binary(), binary() | true}]), reads the_az_path,_az_reconnect,_az_fps_follow, and_az_capsframework keys (_az_capsis a JSON object of native-shell capabilities; an undecodable or non-object value is treated as "no capabilities" rather than crashing the upgrade), strips them to compute the user-visible query string, resolves the target route via the adapter'sresolve_route/3callback, runs middlewares, and returns:{halt, az:request()}-- middleware blocked the upgrade; caller extracts the native raw viaarizona_req:raw/1to emit its transport responsenot_found-- the client-supplied_az_pathdid not resolve to a live route; the caller rejects the upgrade with404(never crashes on the attacker-controllable path){cont, State}--Stateis a map carryinghandler,bindings,on_mount,req,reconnect,fps_follow,capabilitiesthat the caller threads intoarizona_socket:init/4
arizona_roadrunner_ws collapses to a few lines that call parse_qs, invoke
arizona_ws:prepare/3, and wire the result into roadrunner's callback contract.
API -- arizona_static.erl
Offline static-site generation -- renders route handlers to HTML files with no server, live
process, or WebSocket (built on arizona_render:render_view_to_iolist/2).
generate/2,3(OutDir, Specs[, DefaultOpts])-- renders each spec underOutDir, writes the HTML files, and returns{Written, Failed}(the paths plus{Spec, Reason}for any spec that failed -- amount/rendercrash, a non-mapOpts, or a write error; one failure does not stop the batch). A spec is{Handler, Outfile}or{Handler, Outfile, Opts};Outfileis joined ontoOutDir(an absoluteOutfilebypasses it and..segments are honoured: it is the caller's own path, not untrusted input).Opts(and the optionalDefaultOpts, which each spec'sOptsoverride) is the offline subset ofarizona_live:route_opts/0(bindings/on_mount/layouts; nomiddlewares). The client<script>lives in the layout: a pure-static page loadsarizona-static.min.jsand callsstart()instead ofconnect('/ws'), and browser-side commands (?local, toggles, dialogs, PiP) run with no server. Each page with event attributes embeds their proven names as<script type="application/json" id="arizona-attrs">before its last</body>-- what the socket's connect frame carries on a live page -- andstart()delegates exactly those.
Roadrunner handlers
arizona_roadrunner_http.erl-- thin HTTP handler. Delegates the pipeline toarizona_http:render/3and translates its result into roadrunner's{Response, Req}reply shapearizona_roadrunner_ws.erl-- thin WebSocket handler. Delegates the upgrade toarizona_ws:prepare/3, flushes the middlewares' stash onto the 101, and forwards frames toarizona_socket. Translatesarizona_socket:result()to roadrunner return tuples
Data flow
SSR (HTTP): arizona_roadrunner_http:handle/1 delegates to arizona_http:render/3, which wraps
the native req in an arizona_req:request(), runs any route middlewares
(arizona_middleware:apply_middlewares/3), then calls arizona_render:render_view(Handler, Opts)
where Opts may contain layouts => [{Mod, Fun}], bindings => map(), and on_mount => [...].
render_view/2 mounts the page via arizona_stateful:call_mount/2, applies on_mount hooks,
renders to page HTML, then optionally injects the rendered page into mount bindings as
inner_content -- as an opaque nested template, not iodata (see Slots below) -- and passes the
bindings to the layout's render/1. A layout is a stateless HTML shell (DOCTYPE, head, body,
scripts) with no markers or az attributes: ?inner_content is itself what marks the whole layout
az-nodiff, so an explicit az_nodiff attribute is redundant and no layout fixture in the repo
writes one. A ?stateful inside a layout is a render-time error (stateful_in_layout): layouts
render on the request-free SSR path, which has no views accumulator, so the child is mounted and
discarded while its az-view marker -- baked into the module's compiled statics, so it cannot be
stripped here -- still reaches the DOM naming a view the server never registered. The check is at
render rather than compile time because the disqualifying property is the path, not the template:
a ?stateful under a user az-nodiff renders on the live path, is registered, and works. Use
?stateless for layout chrome; chrome that must stay live across navigation belongs in a view the
routes share, linked with az_patch. A layout must share the page's render target
(cross_target_layout otherwise): the page reaches it as a backendless template, which the
nested-template target check waves through. A ?native document, like a ?native view, must
have a single root node (multi_root_layout otherwise): a native document is one JSON value, and
several top-level nodes would be served comma-joined. Only the final document root counts, so a
multi-root layout wrapped by a single-root one renders; a layer that adds no node of its own
(?inner_content returned bare, ?native([?inner_content])) passes the wrapped root through. The
parse transform stamps the shape (multi_root, a literal list of several nodes at any depth of
nested lists) when the backend's single_root_document/0 asks for it, and the render threads the
document root through the layers, since only the route config says which functions are layouts.
Roots known only at runtime are not judged, nor is a layout with no node (?native([]), an empty
document). ?html and ?terminal layouts may have several roots. layouts is always a list,
applied outermost-first
([Root, Section] produces Root(Section(Page))); an empty list renders the page directly, with no
wrapper. Route config provides bindings, on_mount, layouts, middlewares, and check_origin
-- the single canonical arizona_live:route_opts/0. URL data (path bindings, query params) does
NOT flat-merge into Bindings -- a route opts into arizona_middleware:extract/1 middlewares (or
arizona_middleware:put_request/2) to project what its handler needs. Middleware halts that call
arizona_req:redirect/2,3 surface as a {redirect, Status, Location} result that the transport
emits as a 3xx reply.
WebSocket mount: arizona_roadrunner_ws:handle/1 delegates to arizona_ws:prepare/3, which
reads the framework query keys -- _az_path, _az_reconnect, _az_fps_follow, _az_caps --
resolves the route, runs middlewares (so the Origin check gates the upgrade too), and returns the
state for the transport to hand to arizona_socket:init/4. The socket calls
arizona_live:start_link/5 (passing self() as transport PID, the route on_mount hooks, and the
connection context #{capabilities, reconnect}) then arizona_live:mount/1. Mount establishes the
server-side snapshot (matching SSR). Returns {ok, ViewId} where ViewId comes from
maps:get(id, MountBindings).
A flagged reconnect (_az_fps_follow=1, which the worker sets on every reconnect open) defers
that whole mount+render until the client's promised cached_fps frame arrives, so the resync
OP_REPLACE dedups its statics against the announced fingerprints -- see arizona_socket:init/4
above for the full handshake and its 1 s backstop.
Handlers detect the connected context via the ?connected macro (delegates to
arizona_live:connected()), which reads a process-dictionary flag set in arizona_live:init/1;
?reconnected (arizona_live:reconnected()) additionally distinguishes a re-opened socket from a
first connect, so a one-shot command (notify, focus) can be gated with ?connected andalso not ?reconnected while a declarative one (set_title, fullscreen) is re-asserted on every
connect. The flag is per live process and describes the current page's mount: a navigate after a
reconnect clears it, since the page it mounts is new to this connection, and a same-handler patch
keeps it with the view (a patch to a different handler degrades to a navigate and clears it). For
post-connection effects, handlers use ?send(arizona_connected) in mount and handle it in
handle_info/2.
Server push (handle_info) -- per-view routing: Messages sent via ?send(Msg) /
arizona_live:send(ViewId, Msg) are tagged as {arizona_view, ViewId, Msg} and routed to the
correct handler's handle_info/2 -- root or child. The root view ID is matched from
#state.bindings, children from the views map. Unknown view IDs crash with
{unknown_view, ViewId, Msg}. Plain untagged messages (Pid ! Msg) route to the root handler for
backward compatibility. Delayed sends use ?send_after(Time, Msg) /
arizona_live:send_after(ViewId, Time, Msg). PubSub subscriptions use ?subscribe(Topic) /
?unsubscribe(Topic). After handle_info/2 returns, the template is re-rendered and diffed, and
ops+effects are sent as {arizona_push, RootViewId, Ops, Effects} to the transport PID.
Events: Client sends [target, eventName, payload] over WebSocket. arizona_roadrunner_ws
dispatches to arizona_live:handle_event/4. The gen_server checks the views map -- if target is a
known child view, dispatches to child; otherwise dispatches to root handler. Returns
{ok, Ops, Effects}. Ops are scoped with "viewId:target" format. The response is sent as a JSON
envelope {"o": scopedOps, "e": effects}.
SPA navigation + unmount: Client clicks [az-navigate] link -> JS intercepts, calls
history.pushState, sends ["navigate", {path, qs}] over WebSocket. arizona_socket's navigate
handler resolves the new route via the adapter, then runs the new route's middlewares (lifecycle
parity with HTTP init and WS upgrade). If middleware halts with arizona_req:redirect/2,3, the
socket emits [arizona_js:navigate(Location, #{replace => true})] as a client effect -- no
arizona_live:navigate call happens; the browser replaces the gated URL it already pushed
(an HTTP 3xx never leaves it in history either, so Back does not bounce off the gate) and the
follow-up navigate frame runs middleware again on the redirect target. That needs a Location
that is a path on this origin (arizona_req:location_kind/1); any other -- an identity
provider -- cannot be SPA-navigated, so the socket instead has the browser load the gated path
over HTTP, where the same halt is a real 3xx that also carries any cookie the middleware set
before redirecting (the gate's middlewares run twice, the cost the flash replay already pays).
An unsafe-scheme Location takes the same road, so it never reaches the client. Every full load
the socket degrades a navigate/patch frame to (no live route, a layout change, these halts, the
flash replay) carries replace => true and runs as location.replace: it finishes a navigation
the client already recorded. On cont, arizona_live:navigate/4 is called. Before mounting the
new handler it cancels every pending send_after timer (cancel_pending_timers/0, over the
whole $arizona_timers registry) and then unmounts the outgoing page children first, then the
root (unmount_removed_views/1 over the old views map, then arizona_stateful:call_unmount/2
on the old root).
Discarding the page is a removal, so it unmounts exactly like a diff removal does, and
terminate/2 uses the same order for any exit reason -- every child's unmount/1 runs while the
root is still mounted, and no handler has to propagate unmount by hand. Note the guarantee is
against the root: views is flat and walked with maps:foreach, so nested views unmount in key
order, not parent-before-child. Then the new handler is mounted and gen_server state is reset. The
mount runs in a handle_continue/2 from a state whose snapshot and views are cleared, which is
what terminate/2 reads as "nothing mounted", so a new mount that raises exits the process without
unmounting the old page a second time.
Returns {ok, NewViewId, PageHTML, Observed}. The WS handler sends one frame: the old page's pushes
queued ahead of the live process's barrier, their ops first, then [OP_REPLACE, OldViewId, PageHTML] (their effects are dropped with a log line, see handle_info/2 above). Browser
back/forward also triggers navigate via popstate.
The page-swap window is closed by the client. The server replaces the root while handling the
navigate frame, but the old DOM stays on screen until the OP_REPLACE arrives, and an event sent
from it in between is dispatched by view id alone -- to the new root, which often shares the id
(page), where it either crashes on an event it does not handle or runs the old page's intent.
The server cannot protect itself without a wire change: frames are handled in order, so by the
time the stale event is read the replace has already happened, and the event carries nothing that
tells it from one sent after the client applied the swap (dropping the root id for "a while" would
eat legitimate events from the new page). So the web client drops every view event raised between
posting a navigate and its reply -- the OP_REPLACE, a reconnect's resync OP_REPLACE, or a
full-page navigate effect (the server kept the old view) -- exactly as a full page load would; a
redirect reply is itself a navigate and re-arms. The window counts navigates in flight, so
back-to-back navigates keep it open until the last reply (the server answers each navigate frame
once, in order); an old-page push carrying a navigate effect ahead of the reply is
indistinguishable from a reply and settles one early, as is the OP_REPLACE of a patch the server
degrades to a replace while a navigate is in flight. A dropped event is logged by name. A patch
does not arm the window: it usually keeps the view, and the client cannot know ahead that the
server will degrade a cross-handler patch to a replace, so that case keeps the window. The native
clients (Android/iOS) do not gate yet.
SPA patch (in-place): Client clicks an [az-patch] link (or runs arizona_js:patch) -> JS
sends ["patch", {path, qs}]. arizona_socket resolves the route + runs middlewares as for
navigate, then compares the resolved handler against the tracked root handler: same handler ->
arizona_live:patch/2 (the root's handle_update/3 runs, then a diff-in-place, returning {ok, Ops, Effects} -- no unmount/remount/OP_REPLACE); different handler -> falls back to the
navigate/replace path above, and so does a post-middleware id binding other than the live view's
(mounted with the bindings already produced). The client tags the
history entry (_azNav) and stamps the outgoing entry so back/forward replays the patch, keeping
live chrome across the edge. See "SPA navigate / patch route resolution".
Stateful children: arizona_template:stateful(Handler, Props) returns a descriptor map. During
eval_val_v/2, the engine checks if the child is already in the views map -- if so, calls
handle_update/3 (or maps:merge if not exported); if not, calls Handler:mount(Props). Child
templates are recursively rendered/diffed with their own snapshots. The views map uses a
{OldViews, NewViews} tuple during eval -- OldViews is read-only for child lookup, NewViews
accumulates only children rendered this cycle. Children removed from the template (conditional
rendering) are pruned from the views map and their unmount/1 callback is called if exported.
Every instance's unmount/1 runs exactly once, on removal or on the way out: one that raises
stops neither its siblings' nor the root's (each still runs, and the first error is re-raised after
the last), terminate/2 skips a view (or a navigate's outgoing root) the failed pass already
unmounted, and it unmounts a child the failed pass had mounted before raising.
The views map is keyed by id, and events, timers and messages are routed by it, so a child's id
must be unique among the views on the page -- the root view's own id included. A live render or
diff (armed by arizona_live around each; see arizona_eval:claim_view_ids/1) refuses a second claim
of an id in one pass with {duplicate_view_id, Id, FirstHandler, SecondHandler}: every ?stateful
it evaluates claims its id, and every view it keeps without evaluating -- a dep-skipped slot's
subtree, the children of the stream items a drain does not touch -- claims its id as it is kept. A
child's own event renders only that child's subtree, so there the views mounted elsewhere on the
page count as claimed too. Without the check the two instances collapsed into one entry: one was
never unmounted, the other's slot was updated from its state, and every event reached only one.
Re-rendering the same view in a later pass, keeping it, or swapping its handler under the same id
are one claim each. SSR keeps no views map and does not check.
Slots: Slots are implemented via stateless children and bindings. A layout places the page it
wraps with the ?inner_content macro; the binding key is fixed (apply_layouts/4 injects
inner_content into the layout's bindings), not configurable. Stateless components receive props
with arbitrary content:
%% Layout slot -- ?inner_content places the wrapped page AND marks the layout az-nodiff
render(Bindings) ->
?html({body, [], [?inner_content]}).
%% Component slot -- via stateless child props
?stateless(render_card, #{label => ~"Hello", content => SomeTemplate})Use the macro, not maps:get(inner_content, Bindings): the parse transform recognizes
?inner_content as a block, which is what marks the whole layout az-nodiff (a layout renders once
at SSR and is never diffed, so it needs no az targets). A raw maps:get is invisible to it, so
the layout keeps diff markers and the page lands in an ordinary escaping value slot.
az-nodiff -- explicit or a layout's implicit one -- inherits downward through the compile unit:
conditional-branch templates, nested ?html, ?each item templates (at any depth), hoisted
constructors and inlined local helpers all compile nodiff and emit no az attributes or slot
markers, so the HTML is plain. az_nodiff on an ?each item's root element, or on a bare
element in a conditional branch, reaches the templates nested in it the same way. Inheritance stops
where what is built can land in a live view's diffed slot (a nodiff value there never updates in
place): a ?stateful child's props, and any fun
(anonymous or named) other than an ?each callback, since a fun prop is called later by whichever
view receives it. A ?stateless callback is a separate compile unit and keeps its own markers. A
?local under a nodiff template keeps the az and slot marker the client resolves it by, and
nothing else does.
A slot whose current value is a nodiff template still diffs as a slot: the server decides whether the az-nodiff element exists and which one it is, the client owns what is inside. See Embedding client-side components.
The page arrives as an opaque nested template (#{s := [Page], d := []}), not iodata and not a
binary. That is what lets a content slot splice it verbatim rather than copying and
UTF-8-re-decoding the whole page once per layout layer. Consequences: it belongs in a content slot
whole -- fine in a case tail or handed to a child as a prop, but iolist_size/1 on it raises
badarg, and in an attribute value it raises bad_template_value carrying the whole page in
the error term. az:inner_content/1 documents the full set of accepted and rejected placements;
arizona_render_SUITE's ssr_inner_content_* cases pin them.
Embedding client-side components
An az-nodiff element is rendered by the server once, then owned by the client. That is the
seam for a component another library draws -- a chart, a map, a React or Svelte island: mount it
from an az_hook on the az-nodiff element's root, and the server never touches its DOM again.
render(Bindings) ->
?html({main, [], [
case ?get(view) of
chart ->
{'div', ['az-nodiff', {az_hook, ~"Chart"}, {data_series, ?get(series_json)},
{az_key, ?get(dataset)}], [~"Loading chart..."]};
table ->
{'div', ['az-nodiff', {az_hook, ~"Grid"}], []};
none ->
<<>>
end
]}).hooks.Chart = {
mounted() {
this.chart = new ChartLib(this.el, JSON.parse(this.el.dataset.series));
this.onSeries = (e) => this.chart.update(e.detail);
document.addEventListener('chart:series', this.onSeries);
},
destroyed() {
document.removeEventListener('chart:series', this.onSeries);
this.chart.destroy();
},
};The rules.
- Nothing inside an az-nodiff element is ever patched: no op targets it, and its dynamics are not evaluated once it is on screen.
- The server still decides whether the az-nodiff element exists. The slot holding it diffs like any
other: it can hide the az-nodiff element, show it, or switch it to another one. A switch replaces
the slot through its markers, so the old widget's
destroyed()runs while its element is still attached and the new one'smounted()runs after. - The same az-nodiff element chosen again keeps the existing DOM untouched, even when the values it was rendered from changed. It is never re-rendered.
What "the same az-nodiff element" means. An az-nodiff element is identified by its template's
fingerprint, the conditional branch (clause) that produced it, and an optional az_key on its root.
Two clauses that produce identical markup -- or call the same helper, chart(~"line") and
chart(~"bar") -- are two az-nodiff elements, so moving from one to the other remounts. The values
an az-nodiff element was rendered from are deliberately not part of its identity: that would remount
the widget, and destroy its state, on every change. When a change should remount (a different
dataset), put that value in az_key: a new key value replaces the az-nodiff element, and the key's
reads are dependencies of the slot. A child view whose own root is az-nodiff is identified by its
id; a new id replaces it.
The branch is taken from the conditional in the az-nodiff element's own slot, not from any conditional above it. When a switch higher up lands on a wrapper with the same statics, the diff descends into the wrapper and the az-nodiff element's slot still chooses the same branch, so the az-nodiff element is kept:
case ?get(tab) of
sales -> {section, [{class, ~"tab"}], [chart(?get(series))]};
costs -> {section, [{class, ~"tab"}], [chart(?get(series))]}
end
%% chart/1 returns {'div', ['az-nodiff', {az_hook, ~"Chart"}, ...], [...]}Moving from sales to costs keeps the chart. To remount it, give its root an az_key that
names what changed ({az_key, ?get(tab)}).
Props. A widget reads its initial props from attributes at mount (data-*, parsed in
mounted()); the server-rendered children are a placeholder for the first paint. Later updates go
to the widget, not through the diff: return arizona_js:dispatch_event(~"chart:series", Payload)
from a handler and let the hook listen for it on document, or remount with a new az_key. The
widget talks back with this.pushEvent(name, payload). Reads inside an az-nodiff element are not
tracked on its slot -- they can never patch -- so only the reads that choose the az-nodiff element
(and its az_key) re-run the slot. When that slot re-runs and lands on the same az-nodiff element,
the check runs before the az-nodiff element is evaluated, so it costs O(1) whatever the az-nodiff
element's size. Paths that re-evaluate a whole enclosing template -- a plain-list ?each item, a
wrapper the diff descends into, diff/3 -- evaluate the az-nodiff element and then compare it, so
they cost the az-nodiff element's size; the result is the same (no op, child views kept).
Child views inside an az-nodiff element take their props at mount only. An az-nodiff element is
never re-rendered, so a parent-driven handle_update/3 would move the child's state past a DOM no
op will ever touch; the same handler at the same id keeps its view on every path the az-nodiff
element is reached through (a branch, a ?stateless body, a list item). That holds when a switch
replaces the az-nodiff element too: a child with the same handler at the same id keeps its state,
and the new props are ignored. A child still handles its own events.
Wholesale re-renders keep the az-nodiff element. Some changes re-render the markup around an
az-nodiff element whole instead of patching it: a plain-list ?each that falls back to its
container ?OP_TEXT (always, when its items are themselves az-nodiff elements), a stream's
container render, and the ?OP_REPLACE that resyncs a view after a reconnect. The new markup holds
a fresh copy of every az-nodiff element in it, so the client carries the old ones over: each
az-nodiff element's root keeps its directive in the HTML with its identity as the value,
az-nodiff="TOKEN" (the fingerprint, branch and az_key above, spelled by
arizona_template:nodiff_token/3), and an az-nodiff element whose old and new copies sit at the
same place under the same name keeps its existing DOM -- the old element is moved into the new
markup in place of the new copy, and its hook sees neither destroyed() nor mounted(). "The same
place" is the slot that holds it plus, on the way up, its own and each enclosing element's az-key
and view id, so an az-nodiff element in a keyed item follows its key and a child view whose root is
an az-nodiff element is kept only under the same id. An az-nodiff element the new render does not
name there is replaced as before.
List items are kept only when keyed. Unkeyed items share one template, so nothing tells which
old item a new one is -- that is exactly why such a list re-renders wholesale. An unkeyed ?each
item whose root is an az-nodiff element therefore keeps a bare az-nodiff -- client-owned, never
carried over, and nothing inside it is either -- and when two az-nodiff elements inside unkeyed
items share a name and place, neither is kept: both remount for their own item. Give the item an
az_key ({li, ['az-nodiff', {az_key, Id}], [...]}) and its widget follows it through adds, drops,
reorders and a reconnect.
A reconnect mounts the view afresh, so what survives it is decided by what the fresh mount renders:
an az-nodiff element its initial state chooses again keeps its widget and client state; one chosen
by state the old process had built up (another branch, a later az_key) is replaced by the
az-nodiff element the fresh state names. A navigate is a new page and keeps nothing.
Caveats.
- A kept az-nodiff element is moved, not re-created, so its element leaves the document for an
instant: focus inside it is lost, and so is the state of anything the platform resets on removal
(an
<iframe>reloads, media restarts). Its JavaScript state, form values and?localvalues stay. - An az-nodiff element holding a child view is replaced by a reconnect anyway: the reconnect mounted that view afresh, and the DOM it left behind no longer matches its state.
- A switch above the az-nodiff element's slot to a wrapper with different statics moves the
az-nodiff element into a different slot, so it is remounted; a wrapper with the same statics keeps
it (see "What the same az-nodiff element means"), and
az_keyremounts it. - Only a template whose single root element carries
az-nodiffis named. A root of a multi-element nodiff fragment keeps a bareaz-nodiff, and the?nativeand?terminaltargets (whose clients keep no az-nodiff elements) emit none; all are re-created by a wholesale re-render. - The directive is written bare. Its value in the HTML is the compiler's, so an authored one
(
{'az-nodiff', V},{az_nodiff, true}) is a compile error. - The first paint is the server's placeholder. Mount the component from the hook's
mounted()rather than hydrating the server markup; the server never rendered the component itself. az-*attributes inside the widget's own DOM are markup Arizona did not render: the client delegates only the event names the compiled templates declared, and mounts hooks only in markup it inserted. Call the exportedmountHooks(root)on the widget's root after rendering such markup; it scans for both.
Connection events
The client announces connection changes as two CustomEvents on document, in the same style
as arizona:fetch-error. They are for client code the server cannot reach while the socket is
down -- a hooked widget, an embedded component, a status badge:
arizona:disconnected, detail{code, reloadInMs}-- the connection was lost. Fired once per outage, and again when a page reload gets scheduled during it: the first close of a live (or not yet live) page announces the outage, and the worker's reconnect attempts that fail while the server is still down report closes of their own, which announce nothing until a mount has ended the outage -- unless one is a4501that schedules the reload, the moment a widget has to save its state. Write listeners to be idempotent.codeis the close code the browser reports: the server's (1001drain,4500crash,4501mount failure) or, for a connection that ended without a close frame -- a network drop, a failed handshake, a first connect that never opened --1006.reloadInMsis the delay of the page reload this4501scheduled,nullotherwise (including a4501while a reload is already pending). A1000close (a deliberate end, which the client does not reconnect after),disconnect(), andpagehidefire nothing: the last two terminate the worker, so no close reaches the page.arizona:connected, detail{reconnect}-- a view mounted on a new socket, fired once the ops of the frame that proves it (the first frame of a first connect, the resync of a reconnect) have been applied -- after a view transition's deferred update when one wraps that frame, and not at all if the socket closes before then.reconnectmeans this page had a mounted view before:falsefor the page load's first mount (even one reached through a reconnect after a first connect that never opened),truefor every later one, including a bfcache restore, which has noarizona:disconnectedbefore it. A navigate'sOP_REPLACEreply on a live socket is not a connection event and fires nothing.
So a widget can pause while the server is unreachable and refresh what it may have missed once the view is back:
hooks.Ticker = {
mounted() {
this.off = new AbortController();
const { signal } = this.off;
document.addEventListener('arizona:disconnected', () => this.pause(), { signal });
document.addEventListener(
'arizona:connected',
(e) => e.detail.reconnect && this.refresh(),
{ signal },
);
},
destroyed() {
this.off.abort();
},
pause() { /* stop polling, grey out */ },
refresh() { /* refetch, resume */ },
};The az-connected/az-disconnected classes on <html> remain the CSS-side view of the same
state; they flip on the socket's open and close, before the mount the event waits for.
Handler callbacks
Two handler kinds, each with its own header:
- Live handlers (
arizona_stateful) includearizona_stateful.hrl(sets-behaviour(arizona_stateful), parse transform, template macros). Mount ismount/1. The same behaviour serves route-level pages -- spawned as a live-process root, with request data arriving as bindings viaarizona_middleware:extract/1middlewares on the route -- and embedded components, instantiated from a parent template via?stateful(Handler, Props). - Pure stateless templates include
arizona_stateless.hrl(parse transform only, no behaviour). Invoked via?stateless(Callback, Props)or?stateless(Mod, Fun, Props).
%% Route-level page
-module(my_page).
-include("arizona_stateful.hrl").
-export([mount/1, render/1, handle_event/3]).
%% Construct a fresh map -- never `maps:merge(Defaults, Bindings)` or `Bindings#{...}`.
%% A navigate carries arbitrary keys forward from previously visited pages, so
%% merging lets a foreign key collide with a handler-owned default. Pull each
%% accepted override out explicitly.
mount(Bindings) ->
{#{
id => ~"page",
count => maps:get(count, Bindings, 0)
}, #{}}.
render(Bindings) ->
?html({'div', [], [?get(count, 0)]}).
handle_event(~"inc", _Payload, Bindings) ->
{Bindings#{count => maps:get(count, Bindings, 0) + 1}, #{}, []}.Callback signatures (see src/arizona_stateful.erl for authoritative types):
mount(Bindings) -> {NewBindings, Resets}. %% Required
render(Bindings) -> #{s => [...], d => [...]}. %% Required
handle_event(Event, Payload, Bindings) -> {NewBindings, Resets, Effects}. %% Optional
handle_info(Info, Bindings) -> {NewBindings, Resets, Effects}. %% Optional
handle_update(Props, Bindings, Effects) -> {NewBindings, Resets, Effects}. %% Optional (embedded child OR patched root)
handle_drain(Deadline, Bindings) -> ok | {stop, Bindings, Effects}. %% Optional
unmount(Bindings) -> term(). %% OptionalResets is a map of binding values reapplied on top of NewBindings after the callback returns --
typically #{}, or a subset of keys you want cleared (form fields, transient flags). Effects is a
list of arizona_js:cmd() values to run client-side after the diff is applied.
Mount must produce Bindings containing an id key -- this becomes the ViewId used for event
routing and navigate targeting. The parse transform auto-injects az-view as a boolean attribute
on the root element of render/1 when the module declares the arizona_stateful behaviour. Using
az_view manually outside this context raises a compile error.
The injection (and the {id, ?get(id)} enforcement below) happens only when the parse transform
sees a literal template call (?html/?native) as render/1's tail expression. A route-root
render/1 that delegates instead -- render(B) -> page(B). -- falls through both: the root
carries no az-view marker and skips the id check, so events resolve to the enclosing view with
no diagnostic. This is not caught at compile time (a non-template tail is legal for embeddable
components), so it is a documented constraint: a route-root view's render/1 must inline its
?html(...), composing sub-views as ?stateful/?stateless children within it rather than
delegating the whole body.
id attribute restriction: The root element's id MUST use {id, ?get(id)} (or the equivalent
arizona_template:get(id, Bindings) / az:get(id, Bindings)). Static binaries and composed values
are not allowed. This is a compile-time check.
The id serves three roles simultaneously: DOM id (document.getElementById), views map key
(server-side state tracking), and wire protocol target (op scoping). All three must be the same
value. Using ?get(id) ensures the template renders the same value the parent passed in Props and
the server uses for tracking. id is a restricted key -- the handler's mount callback cannot
override the value passed by the parent in Props, and no later callback that keeps the view
running (handle_update/3 or the default patch merge, handle_event/3, handle_info/2, a
handle_drain/2 that does not stop) may change or drop it: each raises
restricted_key_modified.
Dispatcher error tagging. Each arizona_stateful:call_* wrapper catches error:undef and
error:function_clause only when the failing stack frame is the user's exact callback, and
re-raises with a structured reason carrying an error_info annotation: {missing_callback, Mod, Name, Arity}, {unhandled_event, Mod, Event, Bindings}, {unhandled_info, Mod, Info, Bindings},
{unhandled_update, Mod, Props, Bindings}, {unhandled_unmount, Mod, Bindings}, or
{render_no_clause, Mod, Bindings}. arizona_stateful:format_error/2 turns each into a sentence
that names the offending module/event and (when known) the view id. Errors raised from inside a
callback's body propagate untagged.
handle_event/3 effects: a list of arizona_js:cmd() tuples, built using functions in
arizona_js.erl. Same commands used in template attributes. Common effects:
arizona_js:dispatch_event(Name, Payload)-- dispatches a CustomEvent on the client's documentarizona_js:set_title(Title)-- sets the browser document titlearizona_js:reload()-- reloads the page- Any other
arizona_jscommand (toggle, show, hide, etc.) also works as an effect
handle_info/2 is invoked for any Erlang term landing in the gen_server mailbox (?send,
?send_after, pubsub, raw Pid ! Msg). If not exported, messages are silently dropped (checked via
erlang:function_exported/3).
handle_update/3 reacts to new props arriving from an enclosing context: a parent re-rendering an
embedded child with new Props, or a patch navigation delivering the new route's params to a
route root (navigation as the root's prop source). If not exported, the framework merges Props
into Bindings directly (call_handle_update/4). Effects is the accumulator of effects already
queued for this update cycle; return it extended with this callback's own effects (the default
returns it verbatim) -- so a patch reaction can set the document title or redirect, threaded without
any caller-side concatenation. See "SPA navigate / patch route resolution".
For an embedded child, "new props" means "the diff re-evaluated this child's ?stateful
descriptor while the view already existed under the same id and handler". That happens when the
slot holding the descriptor re-evaluates, and the props may then be term-equal to the previous ones:
a read outside them (case ?get(tab) of _ -> ?stateful(c, #{x => ?get(x)}) end, an event changing
only tab) or a prop derived from a changed binding whose value did not (x => ?get(n) div 10).
The framework does not filter equal props out: a view entry keeps the child's bindings, which the
callback may have transformed, and not the props it was last given, so there is nothing to compare
against without storing a second copy per view. Keep handle_update/3 idempotent for equal props,
and compare against the child's own bindings when a side effect (subscribe, timer) must run only on
an actual change.
The descriptor is not re-evaluated, so handle_update/3 is not called, when:
- the slot is dep-skipped (none of its reads changed);
- the child sits in a stream
?eachitem the diff did not touch -- an insert evaluates only the inserted and changed items, not the existing ones; - the child's own root is az-nodiff, or it sits inside an az-nodiff element: it takes its props at mount only, so changed props never reach it either.
And it is mount/1, not handle_update/3, that runs on the child's first appearance and when a
render keeps the id but switches the handler (the old instance is unmounted first).
unmount/1 runs when an instance is removed -- either the parent stopped rendering it (conditional,
a list or stream item leaving the DOM, or navigate) or the live process is shutting down. Use it to
release resources (timers, external subscriptions). Return value is discarded. Do not ?send or
?send_after to the view from it: on a removal or a navigate the view is gone by the time the
message arrives, so the live process raises {unknown_view, ...}, or delivers it to a new handler
that reuses the id (see "Per-view messages and the timer registry").
Dynamic eval return values
iodata()-- rendered value#{s => ..., d => ...}-- nested template (recursively rendered/diffed)#{stateful => Handler, props => Props}-- stateful child descriptor#{callback => Fun, props => Props}-- stateless child descriptorremove-- sentinel that triggersOP_REMOVE_NODE
Change tracking
arizona_template:get/2,3 tracks which binding keys each dynamic reads via the process dictionary
key '$arizona_deps'. The structure is a map of keys, e.g. #{count => true, title => true}, or
undefined when inactive.
Lifecycle per dynamic (in eval_one_v):
erlang:put('$arizona_deps', #{})-- start tracking- The dynamic's fun is called -- each
arizona_template:get(Key, Bindings)addsKey => true erlang:erase('$arizona_deps')-- harvest the dep map- Deps are stored in the snapshot as
deps => [#{key() => true}], parallel to thedlist
Nesting: Stateful children and nested templates save/restore '$arizona_deps'
(SavedDeps = erlang:get(...) before, erlang:put(..., SavedDeps) after) to prevent child get
calls from polluting the parent's dep list.
Inactive paths: render/1, diff/2, and SSR never set '$arizona_deps', so
arizona_template:track/1 sees undefined and is a no-op.
Consumption: Only diff/4 uses deps. deps_changed/2 checks whether any key in a dynamic's dep
map appears in the Changed map (via maps:intersect). If none do, the dynamic is skipped entirely
-- its fun is never called.
Op codes
Defined in the public header include/arizona_wire.hrl. Every client mirrors them by hand
(assets/js/arizona.js, its worker, e2e/utils/native_client.js, the Android Wire.kt, the iOS
Wire.swift), and arizona_wire_SUITE checks each copy against the header. The web client must
declare the whole set; a native client must declare every op its backend can emit (all but
OP_LIST_PATCH, which arizona_native:supports_list_patch/0 opts out of).
| Code | Name | Args | Description |
|---|---|---|---|
| 0 | OP_TEXT | [target, value] | Replace marker content (text, nested tmpl, plain each) |
| 1 | OP_SET_ATTR | [target, attr, value] | Set attr (command: its array; native bool: JSON bool) |
| 2 | OP_REM_ATTR | [target, attr] | Remove attribute |
| 4 | OP_REMOVE_NODE | [target] | Remove element |
| 5 | OP_INSERT | [target, key, pos, html] | Stream insert (pos=-1 -> append, otherwise index) |
| 6 | OP_REMOVE | [target, key] | Stream remove |
| 7 | OP_ITEM_PATCH | [target, key, innerOps] | Stream item patch |
| 8 | OP_REPLACE | [target, html] | Element swap (navigate); target is the OLD view id |
| 9 | OP_MOVE | [target, key, afterKey] | Stream move (afterKey=null -> prepend) |
| 10 | OP_LIST_PATCH | [target, subOps] | Single-root plain-list ?each positional item patch |
Code 3 is unassigned. It was OP_UPDATE ([target, html], an innerHTML write on the
resolved element), removed once every container full render moved to the marker-aware OP_TEXT
-- a content slot's az is not reliably carried by an element, so innerHTML on whatever the
client resolves destroys the slot's static siblings (the whole view when that element is the
view root). Nothing emits it and no client claims it. It is free to reuse for a genuinely new
op: codes 0..9 emit as a single byte (arizona_socket's op-code encoder) and 10+ cost an extra
one per op, so the cheap range is worth reclaiming -- but a reuse must land on the server and
all four clients together, since a code means whatever both ends agree it means.
A content-slot dynamic -- a value, a nested template, or a plain-list ?each -- is anchored
by <!--az:X-->...<!--/az--> comment markers in SSR (no wrapper element carries the slot az),
so its diff patch replaces only the span between the markers, leaving the slot's static siblings
and the enclosing element intact. A single-root plain-list ?each patches in place with
OP_LIST_PATCH: its subOps address items by DOM-order position between the markers
([OP_ITEM_PATCH, idx, innerOps] patches item idx, [OP_REMOVE, idx] removes it, and
[OP_INSERT, idx, html] inserts), so an item change never touches the container's childList.
The wholesale marker-aware OP_TEXT re-render is the fallback -- used when positional patching is
unsound (the old slot was not a list, the item is not a single root element, or the list rendered a
per-item child view), and also when it would be sound but larger (the container shifted far enough
that per-item patches outgrow one re-render -- see outgrows_re_render/3 above). A scalar value
or a nested template still patches via that OP_TEXT.
That fallback re-renders the whole container, so list_changed/3 decides whether it is warranted
by whether a slot RENDERS differently (item_changed/2), not by term inequality. A value can
differ as a term yet render the same bytes: an integer against its binary (1 and ~"1", both
"1") is invisible. Answering on
term inequality tore the container down to rebuild byte-identical markup, losing focus, scroll
position and every ?local inside it for nothing.
The same question is asked of EVERY slot, not only a container's, by
maybe_make_ops/5 -- one decision point shared by the plain and the dep-aware walk.
A value that changed as a term has not necessarily changed on screen, and the write is
not free: an ?OP_SET_ATTR is a style-recalc trigger, and the client's
applySetAttrOp additionally assigns the live property for value, which can move a
caret or drop a selection in an input the user is working in. An attribute is compared
like any other value with one exception -- a boolean is not a value there. true
renders as a bare attribute and false removes it outright, so neither may be called
equal to a string that happens to print the same: true against ~"true" really is a
change, from a bare attribute to name="true".
Nothing is rendered to answer it in the common case. collapses_to_same_bytes/2 runs only once
the terms already differ, and two same-type scalars are settled by their types alone: to_bin/1
is the identity on binaries, and integer_to_binary/atom_to_binary and the shortest
round-trip float format are injective, so two distinct ones cannot print the same. Only the pairs
that genuinely can collapse are rendered -- a value against a different type. An escape marker is
deliberately not comparable: to_bin/1 unwraps it, so {arizona_esc, ~"<b>"} and a bare
~"<b>" look identical to it, while the wholesale re-render escapes the wrapped one and not the
bare one -- treating them as equal would drop a visible change and in one direction leave
unescaped markup on screen. A pair SHARING the marker is unwrapped and compared, which is what
makes the comparison reach real templates at all, since the parse transform marker-wraps every
content slot value. A map (nested template, each snapshot, child view) or a descriptor tuple is
never rendered either: it counts as changed, the conservative answer. The snapshot is still
advanced to the new values when the re-render is suppressed, so nothing drifts.
Context -- inside <svg> a <title> is not raw text. The classification takes the content
context (raw_text_kind/2, second argument html | foreign), threaded through the transform by
content_context/2: <svg> opens foreign content and <foreignObject> switches back. In foreign
content an element is ordinary parsed markup -- comments really are comments -- so title is
classified none there and its slot keeps markers and stays diffable. It is the only tag that
differs: script/style stay raw in both, and textarea cannot appear in foreign content.
This matters because an SVG <title> is a graphic's accessible name, so a bound one would
otherwise freeze after SSR with no symptom. MathML is deliberately not tracked -- it would need
its own integration points to be correct, and leaving it in html keeps existing behaviour rather
than half-handling it.
The context reaches a construct compiled elsewhere through the top-down marking pass. A local
element helper is spliced into the caller's element AST before that element compiles, so it
carries the context directly. A ?each is compiled bottom-up, before the enclosing element,
so the pass appends the backend and the content context it sits in to the call as two bare atom
arguments (each(Fun, Source, Backend, Context)), and the per-item template compiles in that
context. Because the marking happens at the call site, a fun row/1 callback
defined elsewhere in the module inherits it too.
An ?each is marked once, at the place it appears in the template that renders it. A hoisted
Items = ?each(...) is bound outside every template, where the pass can only give it the default
target and context, so that marking is provisional: the hoisted value has it stripped
(prepare_inline/2) and is marked again where Items is spliced in (splice_vars/4, at the root
of the ?html/?native/?terminal that uses it, or the target and context of the ?each whose
callback or source uses it). It therefore compiles exactly as the same ?each written at the use
site -- native nodes under ?native, foreign content inside <svg> -- and the splice is checked
like written code, so a hoisted ?html used inside ?native is a cross_target_nesting compile
error rather than a render-time cross_target_child. A local element helper does not re-mark
what it is handed: its body is marked, but an argument -- a written ?each already compiled
bottom-up, or a spliced hoisted one already marked -- keeps the context of the call site. So
wrap(?each(...)) with wrap(X) -> {svg, [], [X]} called from HTML content compiles its items as
HTML; write the ?each inside the <svg> element for SVG treatment.
A ?stateless/?stateful child is the one construct that cannot: it is a separate template
with no call site at compile time, so a <title> there is classified html and its slot comes
out markerless. That much is not fixable -- the same child may be used in both contexts, and
nothing in its own source says which.
The update still lands, though, because the child's own slot in the caller does have markers.
make_ops/5 checks whether a markerless slot inside a nested template changed value, and
escalates to a whole re-render of that template (markerless_changed/2) rather than dropping the
change the way diff_dynamics/4 does at a template's top level. The distinction is capability,
not policy: a top-level markerless slot has no enclosing target to patch against, a nested one
does.
Backends name their own markers, so the transform holds no target vocabulary: a backend with one content context returns the same marker for every context, which makes the renaming inert for it by its own answer rather than by a target check in the pass.
A local element helper is still the better factoring for SVG chrome. That is already the
documented split -- a helper factors markup, ?stateless is for a component with its own
props -- and the helper keeps the fine-grained patch (one slot, one value) where the child pays a
re-render of its whole template for the same change. The child is correct either way now, just
coarser. None of this is fixable by classifying title as none everywhere and
special-casing <head>: that inverts which side fails silently, and the other side fails worse.
Comment markers inside a real HTML <title> are literal text, so a layout's page title would
render <!--az:0-->Home<!--/az--> in the browser tab -- visible corruption traded for an
invisible frozen accessible name. A compile warning is no better: the only available signal is
"a <title> holding a dynamic", which is exactly what a legitimate <head> title looks like.
Raw text is an HTML-only classification. All four tags are raw text in HTML, but inside
<svg> the parser is in foreign content where script and style content is ordinary parsed
markup: a comment there is a real comment node and a bare < starts an element. They are
therefore none in foreign, which makes the slot both escaped and diffable -- the parser
decodes the references straight back, so a > b reaches CSS as a > b unchanged. Classifying
them raw there had forced the author to mark the value ?raw, splicing it verbatim into a
context that parses markup, where the </script>/</style> neutralization behind that opt-out
is not the breakout available.
Exception -- raw-text elements (script/style/textarea/title). The browser does not
parse HTML comments inside these, so a comment marker becomes literal content and corrupts it (an
inline module script's <!-- is even a SyntaxError). A dynamic content slot inside a raw-text
element is therefore markerless: it renders at SSR with Az = undefined, and the diff engine
skips any undefined-Az dynamic (diff_dynamics/3 and diff_dynamics_v/5).
The full set follows the HTML tree builder, not just the four common tags: <xmp>, <iframe>,
<noembed> and <noframes> are RAWTEXT exactly like <style> (the list lives in
arizona_html), so they classify raw too and their slots need the same ?raw opt-out. A value
there cannot close the element: the neutralizer checks only each element's own end tag there
(</xmp becomes <\/xmp; </script and </style are plain text in an <xmp> and are left
alone). Nothing decodes in RAWTEXT, so no rewrite is invisible: the backslash shows in an <xmp>,
which displays its content, but only in text that would otherwise have closed it, and
<iframe>/<noembed>/<noframes> content is never displayed. Two relatives are deliberately left
out. <noscript> is RAWTEXT only where scripting is enabled, and there it is never displayed; it is
displayed only where scripting is off, where it parses as ordinary HTML and no live view ever
connects, so it stays an ordinary element whose values are escaped for that audience. Do not put
sanitized ?raw HTML inside a <noscript>: a scripting-enabled browser reads its content as
RAWTEXT, so markup a sanitizer passes as harmless (<p title="</noscript><img src=x onerror=...>">)
closes the element there and runs. <plaintext> has no end tag at all -- everything after it, the
rest of the page and the client script included, would read as text -- so the HTML backend refuses
it at compile time.
The address goes on the element instead. Markers cannot live inside a raw-text element, but
an attribute on it is fine, so compile_element/5 gives such an element its own az and
compile_element_children/7 folds its whole content -- statics and dynamics both -- into one
nested template held there. Any inner change re-renders that element's content, shipped as a
single ?OP_TEXT against its az by make_ops/5's markerless escalation. Without this the value
silently never updated, in the same template where a sibling <p> patched normally, and a
<textarea> or an SVG <title> inside a ?stateless/?stateful child froze at its SSR value
with no diagnostic. The escalation's granularity is what makes it safe: one raw-text element, not
the enclosing template, so a <textarea> is untouched when a sibling <title> changes.
Escaping is preserved structurally rather than reimplemented -- the children compile through the
ordinary compile_children/3 walk, so each dynamic keeps its own policy (raw verbatim through
raw_text/4 neutralization, escapable HTML-escaped via esc_spec).
An executable <script> is the one case where updating is questionable, and az-nodiff is the
answer. A script element carries an "already started" flag, so mutating its text after it has run
does not re-execute it: the element's source would read one thing while the code in effect says
another. That is a browser rule, not a framework choice. The update is still right for the
sanctioned use -- a ?raw(json:encode(Data)) data island, whose consumer reads textContent later
-- so the fold applies by default and the fold guard honours az-nodiff, which keeps an executable
script markerless and render-once exactly as before. The backend classifies the tag via the
arizona_renderer raw_text_kind/2 callback: raw (script, style and the other RAWTEXT
elements) renders the value
verbatim (the browser decodes no character references there, so HTML-escaping would corrupt it
-- this is what makes a ?raw JSON-LD blob or a computed inline boot-script URL correct);
escapable (textarea/title) HTML-escapes a scalar (references are decoded there) but is still
markerless. Because a raw slot is spliced byte for byte and escaping is impossible there, the
parse transform requires the value to carry a literal ?raw(...) at the slot
(dynamic_in_raw_text): an unmarked ?get in a <script> would otherwise be the one content
position where user data reaches the output unescaped, free to close the JavaScript string it sits
in. Literal script/CSS text is static, not a slot, so it is unaffected; escapable elements escape
and so are not gated. The opt-out marks the value trusted for HTML escaping, not for the raw-text
tokenizer -- a trusted JSON blob's own string data can still spell an element breakout -- so
arizona_html:raw_text/4 unwraps the ?raw and neutralizes the payload anyway. It takes the
tag, because which sequences break out is a property of that element's tokenizer state rather
than of raw text at large. </script/</style becomes <\/script in both elements -- strictly
only the appropriate end tag (the one whose name matches the element being parsed) closes a
raw-text element, so </style> inside a <script> is ordinary text, but both names are neutralized
in both as a harmless superset that keeps this half of the rule tag-independent. <!-- and
<script (which together reach script-data-double-escaped, where the element's own </script>
stops closing it and the rest of the document is swallowed) have their < rewritten as \u003c,
but only inside <script>: <style> content is RAWTEXT, which has no escaped state, so
rewriting them there would defend nothing while corrupting the stylesheet (\u003c is a JS/JSON
escape a CSS parser reads as u003c, and <!--/--> are legitimate CSS CDO/CDC tokens). A
tag name counts only where the tokenizer reads one -- followed by whitespace, / or >, or by
the end of the value, which a neighbour may complete -- so i<scripts.length is left alone. It
also takes the slot's static neighbours (the template text right before and after it in the
element), because the tokenizer reads static and value as one stream: ~"var s = '<" followed by
a value /script>... closes the element though the value holds no <. When the < is the
static's, the value's first byte is escaped instead (\u002f in a script, a space-terminated
CSS hex escape \2f in a style), and a value ending in </scr is judged against a static
ipt> after it. Every rewrite therefore decodes back to the original in the context it is applied
to. It covers every
shape to_bin/1 can turn into attacker-chosen bytes: a binary, an atom (rendered with
atom_to_binary, so it carries bytes exactly like a binary), chardata -- the documented remedy
?raw(json:encode(Data)) hands it an iolist, not a binary, so a binary-only guard would wave a
breakout through on the very form the compile error recommends -- and a ?raw wrapping any of
those. An integer, a float and an effect command (whose JSON arizona_effect:encode/1 already
HTML-escapes) cannot spell a sequence, so they pass through.
Where the known text ends, a partial sequence counts. A slot sees at most 8 bytes of each
static neighbour; past them -- another slot, whose bytes exist only at render time, or the
element's end -- the next bytes are unknown, so a value ending in a strict prefix of a sequence
(<, </scr, <!, <!-, <scr) is neutralized as if the rest followed. Every slot therefore
defuses the partial it holds, whatever its neighbours are: a constant ?raw(~"x='<") before an
attacker's /script>..., two values split by a static (~"</scr", ~"i", ~"pt>"), and three
slots reaching the double escape (~"<!", ~"--<scr", ~"ipt>") all stay inside the element.
The one thing not checked is the template's own static text, which is the author's markup: statics
spelling a close tag around an empty value (~"</scr", empty, ~"ipt>") are the author's own,
and an empty value has no byte to escape.
A dynamic attribute on a raw-text element
stays fully diffable -- only
the content slot is markerless. Limitation: the slot will not update after the initial render, and
?local is unsupported inside a raw-text element (no marker to address); a live ?get there
silently freezes at its first value. Addressing a marker-anchored slot with an op that writes the
resolved element whole is a bug: no element carries the slot az, so the client resolves the
slot through its comment marker to the enclosing element, where such a write clobbers its
siblings -- and when the enclosing element is the view root, the whole view. That is why every
content-slot ?each container render is the marker-aware OP_TEXT: both the plain-list clause
and the order-keyed stream clause of arizona_diff's make_op/3 emit it, as do
full_update/5 and diff_stream/4's no-order (type-switch) clause. It is also why the
innerHTML op (code 3) was removed outright rather than kept for the cases that happened to
resolve correctly. A stream's incremental ops keep their own op codes: they carry
the same container az as the target (the item is named by key in a later field) but mutate one
keyed child instead of the container's whole content, so the full-render op code does not govern
them -- their own limitation is placement, below. The client refuses
OP_REPLACE/OP_REMOVE_NODE on a marker-resolved target outright, so a stray one
warns and skips instead of destroying a view.
The stream -> list type switch is consistent with this: a binding that was an
arizona_stream and becomes a plain list routes through diff_list/4 -> full_update/5 -> OP_TEXT,
which replaces only the surviving marker span -- so the list renders as real HTML and the slot's
siblings survive. The stream's keyed az-key items are element children, and stream ops
(insert/remove/move/patch look items up among the slot span's keyed children) never delete the
comment markers, so the marker is still present at switch time.
The list -> stream switch and every other stream container full render now use the same
OP_TEXT, so a stream ?each sharing a content slot with static siblings re-renders in place
instead of clobbering them.
Stream incremental ops are anchored to the slot span.
insertItemEl and moveItemEl resolve slotBounds(el, az) and place relative to the slot's
markers when the container has them -- a tail insert goes before the closing <!--/az-->, a
move-to-head after the opening one, and keyedChildren restricts positional indexing to the span.
So an each sharing its container with static siblings no longer strands items after a footer or
before a header.
The marker-only container (no element carries the slot az at all -- a stream among a live
root's static siblings, or in a content-slot list fragment) works the same way: resolveOpTarget
resolves it through its opening marker to the enclosing element, and the markers are that
element's direct children, so slotBounds finds the span and OP_INSERT/OP_MOVE place within
it.
The same holds inside a stream item. An item's innerOps resolve through the same three arms
scoped to the item (element, compound base, marker), so a nested stream whose marker sits in a
deeper element of the item -- behind a conditional fragment, say -- is placed within its own span.
An inner az that resolves to nothing is skipped with a warning; it is never retargeted at the item
itself, which would let an OP_TEXT rewrite the whole item.
Key lookup is scoped to the span too. Two streams can share one parent element -- a second
?each among siblings takes the compound az <El>:1, whose base is the same element -- and their
keys can overlap. Lookups by key therefore read only the keyed children between the stream's own
markers, so an OP_REMOVE or OP_ITEM_PATCH for one stream never lands on the other's item.
Target scoping
Patch targets on the wire are "viewId:relativeTarget". Root view <<"page">>: "page:0",
"page:1". Child view <<"counter">>: "counter:0", "counter:1". Bare targets (no :) resolve
to the view root via document.getElementById(target) -- used by OP_REPLACE during navigate.
arizona_live emits ops with relative targets, nesting a child view's ops as
[ChildViewId, ChildOps]. arizona_socket applies the view id in two steps, both at
JSON-encode time: flatten_ops/2 walks that nesting and returns a flat list of {ViewId, RawOp}
tuples, each tagged with its owning view (no string surgery); the custom encoder op_encoder/2,
passed to json:encode/2, then writes each target inline as "<ViewId>:<Az>". Building the scoped
target straight into the output iodata avoids allocating a rewritten op list first. Inner ops of an
OP_ITEM_PATCH are item-relative and are deliberately not scoped.
The socket's op flattening unwraps the [ChildViewId, ChildOps] nesting only at the top
level of an ops list, so a ?stateful child inside a stream ?each item ships the wrapper still
nested inside its OP_ITEM_PATCH innerOps. The client tells the two apart by the head: an op
code is a number, a wrapper's head is the child view id, whose ops it resolves against
getElementById(ChildViewId) and applies with the same item-op dispatcher (so a grandchild
wrapper recurses).
Repeated stateless components -- per-instance az namespacing
A stateless component's inner az ids derive only from its own template fingerprint (<Fp>-<n>,
baked at compile time by scope_az/4), and it has no view_id boundary of its own. So the same
render function rendered more than once in a view (?stateless(card, #{...}) twice) would emit
byte-identical az targets; on the client every diff op resolves with querySelector (first
match) and lands on the first DOM instance. To keep each instance an independent diff target,
arizona_template:scope_slot/2 namespaces a slot value's snapshot -- its fingerprint, its statics'
baked az/marker ids, and every inner dynamic az (recursing through inline nested templates) --
by the enclosing slot az at runtime, where the slot is in scope. A stateless child evaluates to
a plain snapshot map (the #{callback, props} clause of arizona_eval:eval_val/eval_val_v and
arizona_render:render_ssr_val returns it directly, with no wrapper tag), and the per-dynamic
caller (eval_one/eval_one_v/render_ssr_one, which knows the slot az) applies
scope_slot/2. So instance A at slot
<<"p-0">> becomes p-0-<Fp>-<n> and instance B at slot <<"p-0:1">> becomes p-0-1-<Fp>-<n>;
nested stateless children compose the full slot path. The prefix is made colon-free (: -> -)
because the client treats : as the content-slot-index separator (a base element az never
contains one) and recovers a base element by stripping at the first colon -- an internal colon
in the prefix would misroute multi-slot and ?each-among-siblings ops, so no client change is
needed.
scope_slot/2 discriminates purely by structure (it delegates to the same scope_val/2 used when
recursing inside a scoped snapshot): a stateless child and an inline nested template (a
conditional-tail branch, or an explicit nested ?html) are byte-identical #{s, d} snapshot maps,
so both are namespaced by the slot az -- there is no tag telling them apart, and none is needed.
Uniform scoping is also strictly more correct than scoping only stateless children: two
structurally identical conditional branches in different slots share a fingerprint, so without the
slot prefix their inner ids would collide on the client exactly as repeated stateless children do.
A stateful child (its own view_id boundary), an ?each container (items addressed relative
to the container, not by global az), and a client-owned ?local slot are left untouched -- only
the container/element az in the enclosing statics is prefixed. A stateful child is skipped by its
snapshot's view_id: the live path sets it via make_child_snap, and the SSR path
(render_ssr_val) sets it too, so the SSR HTML (which the client reuses on connect) and the live
snapshot produce identical ids -- without it, a stateful child nested in a stateless parent would
be scoped only at SSR and its diff ops would miss the DOM.
Prefixing a statics list is a binary:replace per element -- pure and deterministic in
(prefix, statics) -- so scope_snapshot memoizes it per (prefix, fingerprint) in the process
dictionary: a re-render or diff of the same slot (and each level of a deep nest, whose inner
statics would otherwise be re-walked once per ancestor) reuses the cached scoped statics instead of
re-scoping. Only the first render of a given slot pays the walk; the fingerprint keys the statics
the same way the client's statics cache does.
Scoping must not touch user content
A static carries framework markers (az="…", <!--az:…-->) and user bytes, and a static text
child is spliced verbatim (the raw-HTML seam), so a page that shows markup carries those same
sequences as ordinary content. Both scoping passes therefore discriminate rather than
search-and-replace blindly:
- Compile time (
scope_az/4) is structural. The parse transform accumulates each static as a list of segments, with the markers it emits held as tagged{az_attr, Id}/{az_slot, Id}entries rather than bytes; scoping rebuilds exactly those through the backend'saz_attr/1/text_slot_open/1with the fingerprint prepended, and copies literal segments through untouched. The fingerprint is taken over the unscoped segments, so scoping does not change it. Each dynamic attribute also leaves a{dyn_attr, Name, Bytes}segment carrying its name: HTML renders the name inside the dynamic, so without it two templates differing only in a dynamic attribute's name would share a fingerprint and the diff would patch one into the other, leaving the old attribute. - Runtime (the
scope_static/3renderer callback) has only bytes, so it anchors on the template's own fingerprint: every frameworkazin a compiled static is<Fp>-<id>, so the backend matchesaz="<Fp>/<!--az:<Fp>(HTML) or"az":"<Fp>(native), never the bare marker opener. After a pass the statics read<Prefix>-<Fp>-<id>andfbecomes<Prefix>-<Fp>, so re-scoping an already-scoped snapshot anchors correctly too. The terminal backend writes no markers, so itsscope_static/3is the identity.
Server integration
The core engine (arizona_template, arizona_render, arizona_diff, arizona_live,
arizona_stream, arizona_pubsub, arizona_js, parse transform) is transport-agnostic. The
server is an arizona_server adapter package (the server env's required adapter), not part of
Arizona; each adapter routes natively and must pass the black-box
arizona_server_conformance_SUITE through its own arizona_conformance_driver -- the full
contract is in adapters.md. The arizona_roadrunner package wires roadrunner in
through a thin layer of modules that translate between roadrunner's callbacks and Arizona's shared
pipeline (arizona_cowboy does the same for Cowboy with three):
arizona_roadrunner_server-- thearizona_serveradapter: boots the listener and compiles routesarizona_roadrunner_router-- compiles route specs into roadrunner's dispatcharizona_roadrunner_http-- HTTP handler; sends whatarizona_http:reply/3returnsarizona_roadrunner_ws-- WebSocket handler; answers the upgrade witharizona_ws:upgrade/3(the middlewares' stash rides the 101), starts the socket witharizona_ws:init/1, forwards frames toarizona_socket, and relays drain througharizona_socket:drain/2arizona_roadrunner_controller-- controller-route handler; maps whatarizona_controller:dispatch/3returns onto roadrunner, routing a loop's messages to the controller'shandle_info/3arizona_roadrunner_req-- implements thearizona_reqbehaviour (the request abstraction consumed by handlers), including the optionalresolve_route/3for SPA navigate
The arizona_req behaviour is the boundary between the server and the engine: handlers and the
shared pipeline only ever see an arizona_req:request(), never roadrunner's native request type.
Configuration -- arizona_config
Every arizona app-env value may be a literal or an environment-variable reference resolved at
startup. arizona_config centralizes both the read and the resolution:
get_env/1/get_env/2-- drop-in replacements forapplication:get_env(arizona, Key[, Default])that pass the stored value throughresolve/1. The scattered scalar reads (arizona_origin,arizona_session,arizona_flash,arizona_crypto,arizona_sup,arizona_req,arizona_session_store_ets) call these.resolve/1-- expands references in an arbitrary term. Called once at the top of each adapter'sstart/2(arizona_roadrunner_server,arizona_cowboy_server) to resolve the wholeserveropts map, so nestedport/scheme/tls/proto_optsreferences work and every direct caller (the test server, CT suites) benefits without change.
Reference forms:
{env, "VAR"}-- required. Returns$VARas a binary; raisesenv_not_set(with aformat_error/2message) when unset. For a secret with no sensible default.{env, "VAR", Default}-- optional. Coerces$VARto the type ofDefault, or returnsDefaultwhen unset.
Coercion is by the default's Erlang type (env vars are always strings): integer, float (an
integer value like "2" reads as 2.0), boolean ("true"/"false", case-insensitive), binary,
atom (list_to_existing_atom/1), or list (a comma-split into trimmed binaries -- the
csrf_origins / secret_key_previous shape). The required form yields a binary. A default of
any other type (a map, a tuple, a list of atoms) has no coercion and raises unsupported_default
(with a format_error/2 message) whether or not the variable is set.
resolve/1 recurses into maps, lists, and 2-tuples only:
resolve({env, Var}) -> ...; %% required
resolve({env, Var, Default}) -> ...; %% optional, coerced
resolve(Map) when is_map(Map) -> #{K => resolve(V) || K := V <- Map};
resolve(List) when is_list(List) -> [resolve(E) || E <- List];
resolve({A, B}) -> {resolve(A), resolve(B)};
resolve(Other) -> Other.Limiting tuple recursion to 2-tuples (proplist pairs like {port, ...}, {certfile, ...}) is
the safety property: route tuples ({live, Path, Handler, Opts} and friends -- all >=3-tuples)
fall to the Other clause and pass through untouched, so an operator-supplied literal {env, _, _} sitting inside a route's bindings/state is never rewritten. Resolution is idempotent, so
resolving in start/2 is safe even if a caller already resolved.
The flip side is that env is a reserved tag: a reference is recognised by shape alone, so a
proplist pair that merely happens to be keyed env ({env, "production"} among a listener's
options) is read as a reference to the variable production -- substituted when it is set, and
raising env_not_set when it is not. That message says {env, _} is the reference form, so a
hijacked pair is diagnosable; the fix is to key the option something else.
MCP server
An Arizona app exposes a Model Context Protocol (MCP) server to AI agents (Claude Code, Cursor,
Claude Desktop) by implementing the arizona_mcp behaviour and mounting it on a route:
{mcp, ~"/mcp", my_mcp, #{sessions => true, origins => [~"http://localhost:3000"]}}MCP rides the same transport as everything else -- it is pure application-layer code, a controller
(arizona_route:controller/1 turns an {mcp, ...} route into arizona_mcp_handler behind no
pipeline), with no MCP-aware feature in any server. The boundary: the server adapter supplies the
mechanism (routing, the request, the controller {loop, ...} reply, the
{arizona_disconnect, _} signal); Arizona supplies the JSON-RPC 2.0 envelope, arizona_sse
framing, the MCP lifecycle, the tool/resource/prompt registry, the session lifecycle, and the
peer/Origin/auth policy.
Transport
MCP's Streamable HTTP maps onto controller reply kinds with no new transport feature:
- POST -- the client sends one JSON-RPC message. The handler decodes and dispatches; it returns
a buffered
application/jsonreply, or -- for atools/callcarrying a_meta.progressTokenfrom a client that acceptstext/event-stream-- answers as an SSE stream (the tool'snotifications/progress, then the result, then close). - GET (session mode) -- opens the long-lived server-to-client SSE channel as
{loop, 200, [event-stream headers], State};handle_info/3forwards server-initiated notifications and stops the loop on{arizona_disconnect, _}. - DELETE (session mode) -- tears the session down.
The channel works on every protocol the adapter sends loops over; roadrunner wires them on h1, h2,
and h3 (h2/h3 multiplex many sessions over one connection with no head-of-line blocking). Every
Push flushes one frame to the wire with no coalescing, so a notifications/* message reaches the
agent at emit time.
Progress streaming
When a token-bearing tools/call streams (the client accepted SSE), the tool runs in a worker
process so the connection loop is free to push its progress as it arrives. In stateless mode the
loop spawns the worker; in session mode the session spawns it (so the session stays responsive
to a cancel) and the worker reports its threaded-back state on completion. Either way the tool emits
via the Ctx handed to handle_tool/4 -- arizona_mcp:progress/2,3 relays each
notifications/progress to the POST's loop, which pushes it; the final result is the last frame,
then the loop stops. The context is inert (a no-op) for a non-streaming call, so a tool can always
call progress/2,3.
A JSON-RPC object is encoded by whoever produces it, never by the process that writes it to the
wire. json:encode/1 rejects a binary that is not valid UTF-8, and a callback module can return
one; encoding at the producer keeps that failure inside the producer's crash guard, which answers
-32603 like any other callback crash. So the tool worker frames its own progress notifications and
its final result (arizona_mcp_handler:message_frame/1), and the connection loop only pushes bytes;
the buffered reply paths encode inside the same guard. The one object that cannot be answered is a
server-initiated notification -- it carries no id -- so an unencodable one is logged and dropped
rather than taken out on the session process, which would kill every request in flight on it.
Dispatch, cancellation, and timeouts
Requests run in a worker process (the only exceptions are resources/subscribe/unsubscribe, which
own a session-keyed pubsub subscription and run no app code), so the session process never blocks on
app code -- it stays responsive to cancels, idle-reap, and server pushes even while a tool runs.
Buffered (non-streaming) requests are served through a one-at-a-time queue (so their state
threading stays serialized) and replied to asynchronously; a request that outlives
request_timeout_ms (default 60s) frees the client with a -32603 while the session keeps running
it. A streaming tools/call runs concurrently in its own worker, reading a state snapshot and
threading nothing back -- so the serialized buffered queue is the only path that mutates session
state, and nothing races on it.
Any in-flight request is cancellable: a notifications/cancelled {requestId} (routed to the
session) or a client disconnect kills the worker and frees the caller -- a buffered caller with a
-32603, a streaming POST loop told to stop. The session monitors each worker, so an unexpected
worker death frees the caller with a -32603 rather than hanging it.
An in-flight streaming request holds its session alive the way an attached channel does -- the idle TTL is suspended while a stream runs, so a long stream is never reaped mid-run, and re-arms once it finishes or is cancelled. A buffered worker deliberately does not hold the session: the idle TTL stays the safety net that reaps, and so kills, a stuck buffered tool. Tearing the session down (DELETE, idle TTL, or shutdown) kills any still-running worker, so a blocking tool cannot orphan and run forever.
Stateless vs session mode
Without sessions => true every request is self-contained: the handler runs init/1 per request,
dispatches, and discards the state. With sessions => true, initialize mints an Mcp-Session-Id
and starts an arizona_mcp_session process (owned by arizona_mcp_sup, not linked to the
connection, so it outlives any single request); later requests carrying that id route to the process
via arizona_mcp_session_registry, which runs them one at a time (each in a worker, so a slow tool
never blocks the session) against its held state. A stateful callback's returned state threads back
into the session, so the session is genuinely stateful across requests; a crash answers -32603 and
leaves the prior state intact. An idle session is reaped after session_ttl_ms (default 5 minutes).
Resumability and server push
The session assigns a monotonic id to each emitted SSE event and buffers the last
session_buffer_max of them; a client that reconnects with Last-Event-ID gets the newer events
replayed. Two ways to push a notifications/* message:
broadcast/3-- publishes to anarizona_pubsubchannel; every session subscribed to it (via its optionalchannels/1callback) forwards the notification. Decoupled -- the caller holds no session reference.notify/3-- pushes to one session by itsMcp-Session-Id.
Both are no-ops for a session with no attached SSE channel.
Resource subscriptions and templates
resources/subscribe joins the session to a per-uri arizona_pubsub channel
({mcp_resource, Uri}); arizona_mcp:resource_updated/1 broadcasts to it, so every subscribed
session forwards notifications/resources/updated. resources/unsubscribe leaves the channel, and
pg auto-removes a session on exit, so there is no per-session subscription bookkeeping. It is the
same fan-out path as broadcast/3 + channels/1, just keyed per uri and joined at runtime. The
optional resource_templates/1 callback backs resources/templates/list (URI templates like
mem://user/{id}), paginated through the same list_reply.
Logging and completion
logging/setLevel stores a minimum severity in the session map (log_min_severity, default
info), threaded back like any other state; arizona_mcp:log/3 resolves the level severity
caller-side and casts to the session, which emits a notifications/message only when the message is
at or above the stored minimum. completion/complete decodes the ref ({prompt, Name} |
{resource, Uri}) and the partial argument, calls the optional complete/3 callback, and caps the
returned values at 100, setting hasMore. Both are capability-gated (logging / completions).
Pagination
The */list methods paginate with opaque cursors, framework-side: the tools/1 / resources/1 /
prompts/1 callbacks keep returning their full list, and the transport slices it by an offset
cursor (base64 of the offset), emitting nextCursor when more remain. The page size is the route's
page_size opt (default 50), so a list that fits returns in one page with no cursor. A malformed
cursor is a -32602; an offset past the end returns an empty final page. The callbacks never see a
cursor. Because the cursor is an offset, it assumes a stable list across pages -- a list mutated
mid-pagination can skip or repeat an item (acceptable for the near-static MCP lists, and a
list_changed notification has the client re-list anyway).
Resource limits and keep-alive
Three opt-in knobs bound a session-mode route. max_sessions caps the live sessions per route
(the registry tags each session with the request path it opened on and counts by it, so two routes
sharing a handler module still cap independently); an initialize past the cap is answered with a
503 plus a JSON-RPC error body. The count is read just before starting, so a burst of concurrent
initializes can admit a few over the cap -- fine for a soft limit. session_max_pending (default
- bounds the per-session buffered-dispatch queue; a dispatch arriving with the queue full is
rejected with a -32603 rather than enqueued.
session_keepalive_ms(default 30s,infinitydisables) makes the session emit a periodicroadrunner_sse:comment/1over an attached channel so idle proxies don't close the stream; the comment carries no event id, so it is never buffered for resumption, and the timer runs only while a channel is attached.
Capability gating and security
init/1 returns an atom-keyed capability map; the transport serves resources/* and prompts/*
only when the matching capability was advertised, answering method not found otherwise. Tools are
always available. Before any dispatch a request passes the security gate: the Origin allowlist (a
DNS-rebinding defense), the loopback-peer check unless allow_remote_access => true
(arizona_req:peer/1), and the route's optional auth hook, which is handed the
arizona_req:request().
Known caveat -- loop backpressure
The SSE channel pushes notifications into the loop process's mailbox. If an app pushed faster than the client drains, an h1 loop mailbox is unbounded (h2/h3 have implicit stream-window backpressure). A dev-tool MCP server pushes trivial volume, so this is not on the critical path; it matters only for a deployment streaming high-volume notifications.