tea_dom

The DOM half of the Elm architecture: a second vocabulary over packages/tea-core's reconciler contract, the wire format that carries a patch list out of a wasm module, and the reactor turn that answers one host message.

use tea_dom/dsl.{button, div, text}
use tea_dom/reactor.{serve}

pub fn main() -> Unit !io = serve(init(), encode, decode, update, view)

The guest is Dawn compiled to wasm; js/ is the host side (a WASI shim, the reactor driver and the patch interpreter, no dependencies, one <script type="module">; start at js/README.md). examples/projects/tea_dom_counter is the smallest app and examples/projects/tea_dom_todo the next one up.

modulewhat it is
nodethe vocabulary (Text, Elem), its Tree and Eq impls, deliver
dsllowercase wrappers over the constructors
routean address and an event name to a message
wirethe JSON encoding of nodes, patches and replies
renderthe tree as HTML text
reactorturn (pure) and serve (the only !io), with _with_flags and _with_state variants

Views

el("li", class: "row", kids: [
  button(if t.done { "[x]" } else { "[ ]" }, Toggle(id: t.id)),
  el("span", on: [on_click(Edit(id: t.id))], kids: [text(t.title)]),
  button("x", Drop(id: t.id), class: "kill"),
])

el(tag, props, on, kids, class) is the general form; every parameter after the tag has a default. The other helpers are text, div, span, button, input, keyed and foreign.

Events

A listener is On { event, payload, to_msg }. payload says what the host brings back: NoData, Value (the element's value; a checkbox gives "true" / "false") or Key (ev.key). to_msg: fn(String) -> M turns that string into a message, so a constructor is enough:

on_click(Save)                    # NoData
on_value("input", SetDraft)       # Value
on_key("keydown", KeyPressed)     # Key

Two listeners are equal when their event and payload are; to_msg is not compared. A test that wants the message a listener produces calls deliver:

let l = on_value("input", SetDraft)
assert deliver(l, "buy milk") == SetDraft(text: "buy milk")

A payload that does not match what the listener declared is refused with bad-request.

Keyed children

keyed(tag, kids: rows) takes List[(String, Node[M])], so a forgotten key is a type error; node.with_key keys one node. Keyed rows keep their DOM elements, and with them focus, caret, selection and scroll, when a row is inserted, removed or moved. Deleting a middle row of fifty is 2 patches keyed and 26 unkeyed.

Third-party mounts

A widget that owns its own DOM (an editor, a chart) goes behind a custom element. The page registers the tag; the element mounts in connectedCallback and cleans up in disconnectedCallback, which the browser fires when a patch adds or removes it. Build the node with dsl.foreign(tag, props, on, class), which has no kids.

The wire

One JSON object per line in each direction. A message never crosses: the host is told event names and payload kinds, and sends back an address, an event name and at most one string, which the guest resolves against the current tree. The model crosses as opaque text, so a turn is a function of its inputs and one transcript replays alike on the JVM, natively and on wasm. A Cmd adds updates inside the turn; the one exception is Fetch, which rides the reply as "fetch":[{"url","tag"}] for the host to run and answers as a later supply request ({"op":"supply","model","tag","ok","body"|"error"}). An application that commands fetches passes turn_shown / serve_with_state its reading of the outcome, supplied: fn(tag, Result[String, String]) -> M. A resource that is read-only data (not model) is folded into the retained state with absorbed: fn(S, tag, Result[String, String]) -> S, which runs before the message is folded.

HTML

render.to_html(w) prints a tree as HTML and to_document(w) prefixes <!DOCTYPE html>:

let p = el("p", class: "count", kids: [text("0")])
assert to_html(p) == "<p class=\"count\">0</p>"

The output parses to the DOM the bridge would build for the same tree:

Flags

use tea_dom/reactor.{serve_with_flags}

pub fn main() -> Unit !io =
  serve_with_flags(init, encode, decode, update, view)

fn init(flags: Option[String]) -> Model = ...

init is called once with the init line's optional flags string (the page sends JSON.stringify(...); the guest parses it). None means the line had no flags field. examples/projects/tea_dom_flags is the smallest example.

Failure

serve catches a panic in a turn: the host gets an error reply with kind panic, keeps the model it had, and the next message is answered normally.

Changes between versions: CHANGELOG.md. Design: `docs/dom-bridge-design.md` (in Chinese).

API

tea_dom/dsl

dsl.text

fn text[M](s: String) -> Node[M]

A text node.

dsl.el

fn el[M](tag: String, props: List[(String, String)] = [], on: List[On[M]] = [], kids: List[Node[M]] = [], class: String = "") -> Node[M]

The general element: tag, properties, listeners, children, class.

The first four are in the constructor's order, so every positional call still reads; past the tag they are all optional, so el("hr"), el("ul", kids: rows) and el("h1", class: "title", kids: [text(t)]) are each the whole call.

class: is sugar for exactly one prop, and that prop goes first: el("li", class: "row") is el("li", [("class", "row")]), and el("li", [("id", "a")], class: "row") has props [("class", "row"), ("id", "a")]. Prepending rather than appending is what keeps the order a view already had, since a class list written by hand spelled the class first; the wire carries props in order and a transcript would see the difference.

Nothing is deduplicated. A call passing both a class: and a ("class", ..) inside props produces both pairs, in that order, and what a browser does with a repeated attribute is the browser's rule and not this package's. The sugar is one leading prop, no more and no less.

dsl.on_click

fn on_click[M](msg: M) -> On[M]

msg when this element is clicked, and nothing else crosses.

The three spellings below are the whole of what a view needs: a listener is a record, and a record literal at every call site would put payload: in front of a reader on the line where the interesting word is the message. Anything they do not cover is On { .. } itself, which is public for that reason.

A click carries nothing, so this takes the message itself and the constant function is built here. The two below take the function, because there the message is a function of what arrived.

dsl.on_value

fn on_value[M](event: String, f: fn(String) -> M) -> On[M]

The element's value, turned into a message by f.

A bare constructor name is already a fn(String) -> M, so the usual call is on_value("input", SetDraft) and the application writes no plumbing at all.

The event is a parameter because the choice between input (a message per keystroke) and change (one when the field is left) is the application's and is the difference between eight turns and one.

dsl.on_key

fn on_key[M](event: String, f: fn(String) -> M) -> On[M]

ev.key, turned into a message by f. keydown is the usual event; the host does not call preventDefault for this kind, or the keystroke would be swallowed on its way to the element.

dsl.keyed

fn keyed[M](tag: String, props: List[(String, String)] = [], on: List[On[M]] = [], kids: List[(String, Node[M])] = [], class: String = "") -> Node[M]

An element whose children are paired by identity rather than by position. Each child arrives with the string that names it, which is the shape Html.Keyed.node has in Elm and it is not decoration: a list of pairs is the only spelling in which forgetting one key is a type error rather than a silent return to index pairing.

props, on, kids and class default exactly as el's do, so the usual call is keyed("ul", class: "list", kids: rows).

A Text child cannot hold a key, so a list containing one falls back to index pairing however this is called. Wrap it in a span to key it.

dsl.div

fn div[M](kids: List[Node[M]] = [], class: String = "") -> Node[M]

A container and nothing else, which is what most of a view is made of. div(kids: rows), div(class: "row", kids: rows), or div() for the empty one; anything wanting a listener or a second prop is el("div", ..).

dsl.span

fn span[M](s: String, class: String = "") -> Node[M]

A span holding one string.

dsl.button

fn button[M](label: String, msg: M, class: String = "") -> Node[M]

A button labelled label whose click means msg. The DOM analogue of tea_term/dsl.button, and like it the message is data in the tree.

dsl.input

fn input[M](class: String = "", value: String = "", on: List[On[M]] = []) -> Node[M]

A text field: the one input a view builds often enough to have a word for, and the fifth and last of them.

No kids parameter, for the reason foreign has none: an <input> has no children, and a signature is a better place to say so than a comment.

value is rendered always, the empty string included. A field this builds is controlled -- the document shows what the model holds -- so a message that clears the model has to be able to clear the box, and a value prop that disappeared when the model went empty would leave the browser's own text sitting there.

dsl.foreign

fn foreign[M](tag: String, props: List[(String, String)] = [], on: List[On[M]] = [], class: String = "") -> Node[M]

An element whose inside belongs to someone else: a custom element the page registered, with third-party JavaScript running behind it.

The signature is the rule. There is no kids parameter, so "a foreign element has no guest children" is a spelling rather than a convention: with the guest-side list empty, diff never emits an address into the element, and whatever the library builds inside it is invisible to the reconciler and untouched by every patch. Lifecycle is the browser's -- connectedCallback when a patch mounts it, disconnectedCallback when replace/remove/truncate take it out -- so no hook, no callback and no handle enters the tree.

Everything else about the node is ordinary: props reach the element as attributes, listeners hear the events it dispatches on itself, and on the wire it is a plain elem the bridge cannot tell from one. In a list, give it an identity with with_key, as with any element whose rebuild is expensive. The README's "Third-party mounts" section is the full contract.

tea_dom/node

impls registered by this module (visible program-wide, no use needed): Show[Payload], Show[Node[M]], Eq[On[M]], Hash[On[M]], Show[On[M]], Eq[Node[M]], Hash[Node[M]], Tree[Node[M]]

Payload

type Payload = NoData | Value | Key

What a listener asks the host to bring back with the event.

A closed set, like every other list this wire has: the host knows these kinds and no others, so what can arrive is decided here rather than by whatever the browser happened to put on the event object. One string crosses, never a record of fields, which is what keeps Request free of json/value.Json and the guest free of anything with an identity.

NoData nothing beyond the event's name. The whole of what a listener could say before payloads existed, and still the default. Value the element's value: ev.target.value, with a checkbox or a radio normalised by the host to "true"/"false" rather than given a kind of its own. Blazor, Dioxus and LiveView each arrived at that one slot separately. Key ev.key: the one thing about a keystroke that is not on the element.

On

type On[M] = { event: String, payload: Payload, to_msg: fn(String) -> M }

One listener: the event to hear, what to bring back with it, and what it means.

to_msg takes the payload string and answers the message. A listener that declared NoData is handed "" and ignores it, so the shape is one function for all three kinds rather than a sum over them. A bare constructor name is already a fn(String) -> M in Dawn, which is why on_value("input", SetDraft) is the whole spelling of a value-carrying listener.

A record rather than a tuple because this is the place a listener declares things to the host, and a payload kind is the first of them rather than the only one: a capability bit (prevent, passive) or a predicate the host evaluates per event is the same declaration site, and a record grows a field where a tuple would have to change shape again. { ..base, field: v } is how one is spelled with the rest left alone.

Node

type Node[M] = Text(s: String) | Elem(tag: String, props: List[(String, String)], on: List[On[M]], kids: List[Node[M]], key: Option[String])

A pure description of a DOM subtree. M is the application's message type.

props is what the host sets on the element (attributes, value, a class list -- the interpretation is the bridge's, see packages/tea-dom/js), and on names, per event type, what the host should bring back with the event and how to turn it into a message (On, above). Both are ordered lists rather than maps because order is part of == and therefore part of what the reconciler can see; a host that wants set semantics sorts before it builds.

key is this element's identity among its siblings, and it is a field rather than a reserved property on purpose: the host never sees it. Pairing is settled here, and what crosses to the bridge is the ops that pairing produced, so the wire format does not know keys exist. Text has no key and cannot get one, which is the same shape Elm's Html.Keyed has -- a keyed list is a list of elements.

node.deliver

fn deliver[M](listener: On[M], payload: String) -> M

The message an event carrying payload means at this listener. The eliminator for On, and the answer to the question tree == stopped being able to answer.

reactor.settle is this plus the check that what arrived is what the listener declared; a view test wants the message and not the refusal, so this is the spelling an application asserts with. The example builds its listener by hand, since tea_dom/dsl is written over this module; an application writes on_value("input", SetDraft) instead.

Written here rather than repeated in calling modules because the named eliminator is the readable spelling and the basis of reactor.settle.

Example

let draft = On { event: "input", payload: Value, to_msg: text => "draft: ${text}" }
assert deliver(draft, "buy milk") == "draft: buy milk"

node.with_key

fn with_key[M](w: Node[M], k: String) -> Node[M]

w with k as its identity among its siblings. A Text is returned unchanged: it has nowhere to keep one, so a child list holding text pairs by index whatever this is asked. Total for the same reason rekid is.

tea_dom/reactor

Shown

type Shown[M] = { model: String, tree: Node[M] }

The tree a successful turn left the host holding, and the model text it was rendered from.

The host's document is the tree the last reply described, by construction: an init ships the whole of it and every later reply ships the patches from the previous one. So the guest can keep that tree instead of rebuilding it, and an event turn then needs one view instead of two. model is the text the same reply carried, which is what the host hands back next time if nothing on its side intervened, and is how a turn tells whether the kept tree is still the host's.

reactor.turn

fn turn[Mo, M](line: String, init: Mo, encode: fn(Mo) -> String, decode: fn(String) -> Mo, update: fn(Mo, M) -> (Mo, Cmd[M]), view: fn(Mo) -> Node[M]) -> String

Answer one request line.

init is the model a fresh page starts from, encode/decode are the application's own opaque text for a model, and update/view are the two pure functions tea_core/app.App names. They arrive as parameters rather than through the trait for the reason tea_core/app records at length: a generic function over A: App can do nothing with an A.View, and the reconciler needs Eq on the tree, which an associated type cannot carry.

The two requests:

init the host has an empty container. The reply is the initial model and one replace at the root, which is the only patch that can describe "there was nothing and now there is a document". event the host names an address, an event and at most one string. The listener is looked up in view(decode(model)) -- the tree the host is holding, since the host's document is that tree by construction -- the string is handed to that listener's to_msg, update folds the result in, tea_core/cmd.fold_msg folds whatever the command asked for after it, and the reply is the new model plus diff(old, new). One reply per request either way: a command adds updates, never lines. supply the host answering a Fetch an earlier reply asked for (see turn_shown). Only the stateful entry points take it; this one answers bad-request, and a Fetch commanded here panics in fold_msg, because nothing could ever answer it.

M: Eq and nothing else. The Fill bound this used to carry is gone with the trait: a listener holds the function, so there is nothing for an application to declare.

The Eq that stayed is no longer about listeners, and it is worth saying why it stayed anyway. diff needs Eq[Node[M]], and Node[M] has no impl Eq of its own, so the compiler synthesises one from the structure; the synthesis asks every type parameter of the ADT for a bound, whether or not the parameter reaches a comparable position. Here it does not: M occurs only under On[M], whose impl Eq reads two fields and never touches it. impl[M] Tree[Node[M]] and route.at therefore lost the bound outright; this one survives the change for a reason that has nothing to do with what a listener carries. An application pays nothing for it, since an ordinary message ADT is structurally comparable without declaring anything.

An application that wants what the page knew before it existed takes turn_with_flags instead. This one ignores the flags an init may carry, which is the whole difference between the two and is why an application that never asked for flags cannot be given any: its init is a value, and a value has nowhere to put a string.

reactor.turn_with_flags

fn turn_with_flags[Mo, M](line: String, init: fn(Option[String]) -> Mo, encode: fn(Mo) -> String, decode: fn(String) -> Mo, update: fn(Mo, M) -> (Mo, Cmd[M]), view: fn(Mo) -> Node[M]) -> String

Answer one request line, with the initial model computed from the flags the host sent.

The one difference from turn: init is fn(Option[String]) -> Mo rather than an Mo, and it is called once per init request with whatever the line's flags field held. None is a line with no such field, which is every line every host writes today, so turn(line, m, ..) is exactly turn_with_flags(line, _ => m, ..) and is written as that below.

Why a function of the flags rather than a model plus a patch step (fn(Mo, String) -> Mo), which was the other shape that fits. A model built from flags often has no meaningful value without them -- a palette with no index has nothing to search, a session-scoped view has no user -- and the patch shape forces one into existence and then hopes every field gets overwritten. Elm's init : flags -> model is the same call for the same reason, and it makes "there were no flags" a case the application writes rather than a state it has to detect.

Why Option[String] and not String. Absent and empty are different answers everywhere else on this wire, and they are different here too: a host that has nothing to say omits the field, and a host that has an empty string to say sends one. An application that does not care collapses them in one line; one that does cannot get the distinction back afterwards.

reactor.turn_with_state

fn turn_with_state[S, Mo, M](line: String, state: Option[S], init: fn(Option[String]) -> (S, Mo), encode: fn(Mo) -> String, decode: fn(String) -> Mo, update: fn(S, Mo, M) -> (Mo, Cmd[M]), view: fn(S, Mo) -> Node[M], supplied: Option[fn(String, Result[String, String]) -> M] = None, absorbed: Option[fn(S, String, Result[String, String]) -> S] = None) -> (Option[S], String)

Answer one request while a read-only value lives in the guest rather than in the model text crossing the wire.

init splits the optional flags into (retained, model). Only the model is passed to encode and decode; update and view receive the retained value separately, and update cannot return a replacement for it. The first component of this function's answer is therefore a replacement made by the host's side of the protocol, never by the application's update: Some after a successful Init, and after a Supply when the application passed absorbed (below), and None on every Event and every refusal. serve_with_state is the sole place that commits that replacement.

The current state is explicit so this remains a pure function. Besides making each branch testable, that keeps retained state out of the request and reply JSON by construction: the only wire codec here is the Mo codec.

reactor.turn_shown

fn turn_shown[S, Mo, M](line: String, state: Option[S], shown: Option[Shown[M]], init: fn(Option[String]) -> (S, Mo), encode: fn(Mo) -> String, decode: fn(String) -> Mo, update: fn(S, Mo, M) -> (Mo, Cmd[M]), view: fn(S, Mo) -> Node[M], supplied: Option[fn(String, Result[String, String]) -> M] = None, absorbed: Option[fn(S, String, Result[String, String]) -> S] = None) -> (Option[S], Option[Shown[M]], String)

turn_with_state, given the tree the previous turn rendered.

shown is consulted only for an event, and only when its model is the very text the event carries; anything else -- None, a host that edited or restored its model, a session that started in another instance -- renders the old tree from the decoded model as turn_with_state always did. That comparison is the whole of the invalidation rule, and it is a string equality over the model text (the search panel's is about 300 bytes).

What the comparison buys and what it does not. Equal text means the host is holding the document this guest last described, so routing through the kept tree routes through exactly what the reader saw. It does not prove that view(decode(model)) is that same tree. For a codec that round-trips what the view reads -- the search panel's and the retained-state fixture's, the two serve_with_state applications here -- the two are equal, and this turn answers byte for byte what turn_with_state answers. For a codec that drops a field the view reads, they are not: the listener that resolves, and therefore the message its to_msg builds, comes from the tree the host is actually holding, while update still folds it into decode(model). That is the one case in which the two entry points can differ, and it is a codec that already cannot replay its own page, so it is recorded here rather than guarded against.

The second component is the tree this turn left the host holding: Some after a successful init or event, None after any refusal, whose reply carries no patches and so leaves the host's document, and the kept tree, where they were. serve_with_state is the only caller that keeps it.

supplied is the application's reading of a fetch's outcome: the tag its Fetch named and the host's Result (Err carries the host's sentence for a network failure or a non-2xx status), turned into a message. It is a parameter and not part of the model or the request because a function cannot cross the wire, and it is not stored in the command because the guest does not remember commands between turns: a supply is a fresh request that arrives with the model like an event, and the tag is all the guest needs to know which request this answers. With None, a supply is bad-request. The turn that answers a supply is otherwise an event turn: same fold, same diff, and it may itself command more fetches.

absorbed is what a fetched resource adds to the retained state. The retained state exists so that a large read-only value stays out of the model text crossing the wire on every turn; a resource the guest fetched after init is the same kind of value (the search panel's text fragments are a hundred kilobytes it must keep and must not re-send per keystroke), and the message supplied builds could only put it in the model. So a supply may fold the outcome into a new retained state first. The function is total over the outcome (an Err is an outcome too), it runs once per supply before the message is folded, and its result is what update and view receive in that very turn and every later one. It is not an update that returns a replacement: the application's own messages still cannot touch the retained state, only the host's answer to a request the application made can. A turn that panics commits nothing, so a failing absorbed leaves the old state.

reactor.serve

fn serve[Mo, M](init: Mo, encode: fn(Mo) -> String, decode: fn(String) -> Mo, update: fn(Mo, M) -> (Mo, Cmd[M]), view: fn(Mo) -> Node[M]) -> Unit !io

Answer every request on stdin, one line each way, until end of input.

A failing application is answered rather than propagated: catch_panic is an isolation point (spec 9.8), so a panic in update or view becomes an error reply and the loop goes on to the next line. A blank line is skipped, because a host that writes "n" between messages should not be told it sent a malformed request.

reactor.serve_with_flags

fn serve_with_flags[Mo, M](init: fn(Option[String]) -> Mo, encode: fn(Mo) -> String, decode: fn(String) -> Mo, update: fn(Mo, M) -> (Mo, Cmd[M]), view: fn(Mo) -> Node[M]) -> Unit !io

serve, for an application whose initial model is a function of the flags the host sent. turn_with_flags is the difference and the only one.

The loop is written out a second time rather than factored with serve into a shared pump. That is deliberate and it is about evidence, not about taste: serve is what four recorded transcripts replay through, and leaving its bytes untouched makes those transcripts evidence about code that did not change rather than about a refactor that happened to keep them green. The two bodies differ in one identifier, and the day a third joins them is the day to factor all three at once.

reactor.serve_with_state

fn serve_with_state[S, Mo, M](init: fn(Option[String]) -> (S, Mo), encode: fn(Mo) -> String, decode: fn(String) -> Mo, update: fn(S, Mo, M) -> (Mo, Cmd[M]), view: fn(S, Mo) -> Node[M], supplied: Option[fn(String, Result[String, String]) -> M] = None, absorbed: Option[fn(S, String, Result[String, String]) -> S] = None) -> Unit !io

serve_with_flags, with the init-only part retained by the guest across calls to a wasm reactor's dawn_turn as well as across lines on the JVM.

The runtime slot is reached only through std/reactor's typed pump. A turn is caught before its optional replacement is committed, so a panic during a new init leaves the earlier state installed. Blank lines preserve the old loop contract: they produce no reply and do not replace or mutate the slot (the pump may still read its installed Root).

tea_dom/render

render.to_html

fn to_html[M](w: Node[M], at: Loc = caller()) -> String

w as HTML: no whitespace added, no doctype, the same bytes every time. Listeners and keys do not appear; see the file head for the other rules. A tree it refuses (a bad name, a raw-text element holding its own end tag) panics with the caller's location, or at when one is handed on.

render.to_document

fn to_document[M](w: Node[M], at: Loc = caller()) -> String

w as a whole document: <!DOCTYPE html> and then to_html(w). Nothing is wrapped around the tree. A root that is not html is still a document, the parser supplies html, head and body itself, and a page that wants them spelled says el("html", kids: [..]).

tea_dom/route

route.listeners

fn listeners[M](root: Node[M]) -> List[(List[Int], List[On[M]])]

Every node that listens for something, paired with the address tea_core/diff addresses it by and the listeners it declares. Pre-order, so the order is the order a reader meets the elements; nodes with no listeners are not mentioned.

This is what the bridge needs at the boundary: the host attaches a listener exactly where this says one exists, and hands back the address it fired at.

route.at

fn at[M](root: Node[M], path: List[Int], event: String) -> Option[On[M]]

The listener for event at path, or None when nothing there listens for it.

The whole listener rather than just its to_msg, because the caller needs both halves of it: the function is what the event means, and the payload kind is what the host was supposed to bring back, which is the only thing that can say whether what did arrive is what was asked for. A record leaves room for the next thing a listener declares without changing this signature.

None is a real answer and the bridge treats it as an error rather than as a no-op: the host only ever attaches a listener where listeners said there was one, so an event with no message means the host is dispatching against a tree the model no longer produces. Swallowing that would turn a desync into a click that silently does nothing.

The walk goes down the address and nowhere else: one step per index, so a lookup costs the depth of the node rather than the size of the tree. The addresses are fold_preorder's (and therefore diff's), which is what listeners reports and what the test below holds the two to.

tea_dom/wire

impls registered by this module (visible program-wide, no use needed): Show[Request]

Request

type Request = Init(flags: Option[String]) | Event(model: String, path: List[Int], event: String, payload: Option[String]) | Supply(model: String, tag: String, outcome: Result[String, String]) | Bad(reason: String)

What the host asked for this turn. Bad carries the reason rather than panicking: a malformed line is the host's mistake and the guest answers it with an error message, which is a thing a transcript can assert on.

payload is None when the line carried no payload field, which is what a listener that asked for nothing produces. A field that is present but is not a string is Bad: the set of things that can cross is closed, and a number arriving where a string was declared is a host that has forked the contract rather than a value to coerce.

Init carries the same shape for the same reason. flags is what the page knew before the guest existed -- a search index, a session, a locale -- handed to the application's init once and never again, which is Elm's Flags. It is None when the line carried no flags field, so the {"op":"init"} every host sends today means what it always meant: start from nothing. One Option[String] and not a Json, because the boundary admits one string per field and the application decodes it: putting json/value.Json in here would make the inbound half as wide as whatever the page felt like sending, which is the width payload refused.

Supply is the host answering a Fetch an earlier reply asked for: the model as the host holds it now, the tag the guest chose, and the outcome as a value. A failed request -- no network, a non-2xx status -- is Err with the host's sentence, not a malformed line, because the application is the one that decides what a missing resource means. On the wire the outcome is "ok":true,"body":<string> or "ok":false,"error":<string>, and any other shape is Bad.

wire.enc_self

fn enc_self[M](n: Node[M]) -> Json

An element's own data: everything but its children. The payload of a SetSelf, and the first half of a full node.

wire.enc_node

fn enc_node[M](n: Node[M]) -> Json

A whole subtree: an element's own data plus its children, recursively.

wire.enc_patch

fn enc_patch[M](p: Patch[Node[M]]) -> Json

One patch. The seven op names are the seven tea_core/diff.Op constructors and there is no eighth: a bridge that grows a case this list does not have is a bridge that forked the contract.

The keys that produced insert/remove/move do not appear here and have no encoding. Pairing is settled in the guest, so what the host is told is where a child goes, never why.

wire.reply_ok

fn reply_ok[M](model: String, ps: List[Patch[Node[M]]]) -> String

A successful turn: the model the host must hand back next time, and the edits it must make to the document it is holding.

Written straight to text rather than built as enc_patch values and handed to json/render, and the bytes are the same: the test below holds the two spellings equal over every op, every listener kind and strings that need escaping. The reason is cost. A reply re-rendering forty rows of the search panel is about 22 KB, and building it as a Json value first -- a Map per node, then rendering each level into a string its parent copies again -- was 2.9 ms of a 10 ms turn. Here every string is quoted once, by the same render the tree spelling uses, and the pieces are joined once at the end. enc_node, enc_self and enc_patch stay as the readable statement of the shape; this is the same shape, written for speed.

wire.reply_ok_asking

fn reply_ok_asking[M](model: String, ps: List[Patch[Node[M]]], asked: List[FetchReq]) -> String

reply_ok for a turn whose commands asked the host to fetch. The requests ride after the patches as "fetch":[{"url":..,"tag":..}], and the field is absent when there are none, so every reply that asked for nothing is byte-identical to what it was before the arm existed (the recorded transcripts rely on that). The host acts on them after it has applied the patches; the guest has done nothing but name them.

wire.reply_err

fn reply_err(kind: String, message: String) -> String

A turn that could not happen. kind is the machine's word for it (the failure's kind when a panic was caught, bad-request when the line did not parse, no-handler when the address dispatched nowhere) and error is the sentence. The host holds its document unchanged: an error carries no patches at all, so there is no half-applied frame.

wire.decode_request

fn decode_request(line: String) -> Request

Read one inbound line. Never panics and never partially succeeds: either every field a request needs is there and well typed, or the answer is Bad with the reason.