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.
| module | what it is |
|---|---|
node | the vocabulary (Text, Elem), its Tree and Eq impls, deliver |
dsl | lowercase wrappers over the constructors |
route | an address and an event name to a message |
wire | the JSON encoding of nodes, patches and replies |
render | the tree as HTML text |
reactor | turn (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.
class:adds one("class", ..)prop in front ofprops; an empty class adds none. Nothing is deduplicated.inputalways renders itsvalue, the empty string included, so clearing the model clears the box.
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 guest owns the element's attributes and nothing else. A changed prop
arrives through
attributeChangedCallback; events go out as DOM events the element dispatches on itself, with avalueproperty for aValuelistener. - The element must not set attributes on its own tag: the next
set-selfremoves any the guest did not declare. Keep library state inside, in a shadow root preferably. - Never give a foreign tag children in the view, or patches land inside the widget.
- Key it in a list. Unkeyed, a sibling insert can pair it against another node; keyed, the worst case is a clean replace.
- A
movepatch isinsertBefore, which firesdisconnectedCallbackandconnectedCallbackagain; build so that a reconnect is safe.
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:
- Listeners and keys are dropped; a printed button is dead.
- Text escapes
& < >; an attribute value is double-quoted and escapes& < > ".scriptandstyletext is raw, and text holding the element's own end tag panics. checkedprints as a bare attribute unless it is""or"false".valueon atextareais its content; on aselectit selects nothing (putselectedon theoption).- A repeated prop prints once, at its first position with its last value.
- The 13 void elements print as
<br>, children dropped. A tag or prop name with whitespace, a control character or one of" ' > / =panics. - Either panic names the line that called
to_htmlorto_document. Both takeat: Loc = caller(); a helper that renders for its own caller passesat: aton.
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.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.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
本模块登记的 impl(全程序可见,不必 use):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.
示例
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
本模块登记的 impl(全程序可见,不必 use):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.