EUI applications without a browser

Wire format

The normative text is spec/02-wire-format.md. This page is the same material with the reasoning left in.

Everything below is implemented by eui-proto and pinned by byte-level test vectors, so a second implementation written from the spec alone can check itself against the same numbers.

Primitives

NotationEncoding
u8, u16, u32fixed width, little-endian
varintLEB128 unsigned
svarintLEB128 over zigzag
byteslength-prefixed
strbytes, required to be valid UTF-8

A varint must be minimally encoded. A decoder rejects a multi-byte encoding whose final byte carries no bits, because two spellings of one number is one spelling too many for anything that gets hashed, signed, or compared. That is the classic route to a signature bypass, and it costs one comparison to close.

Session tables

A session owns four append-only tables, all with dense ids assigned by the server starting at 1. Id 0 always means "none" and can never be defined.

TableDefined byCeiling
AtomsDefAtom65 535
StylesDefStyle65 535
Literal coloursDefColor4 095
Bytecode chunksDefChunk4 095
GradientsDefGradient (protocol 6)1 023

Redefining an id is an error. Referencing an undefined id is an error. The tables outlive the tree: a Mount replaces the document and clears none of them, so a second page costs no second DefAtom.

A gradient is two or three stops — each a colour reference and a position — and an angle: whole degrees, or one of CSS's four corners. A record's bg names one by a ColorRef in 0x4000..=0x7FFF, a range carved from the reserved role ids, and nothing else may: fg, border_color and a prop colour carrying one are refused. The client paints it as CSS paints linear-gradient(), stops mixed in sRGB. It is protocol 6, and a server sends an older client the first stop as a solid colour instead.

Font roles are the exception, and the shape of the exception says why. DefFont binds a role — one byte, 0–9 — to the faces that draw it, each named by its BLAKE3 hash and fetched as an asset. A role is not an id the server hands out: 0 and 1 already mean sans and mono before anything is said, and the rest are slots an application fills. So a role may be rebound, and rebinding it is a change of mind rather than an error; the client drops what it shaped under the old face. A style may name a role nothing bound, and the client draws it in sans rather than failing the session — a missing face never costs a page.

The style record

A style record is exactly 64 bytes, fixed layout, no optional fields. It holds computed style: every value is a literal number or an index into a theme scale, and nothing in it needs resolving against a parent.

 0  display        8  basis  (3B)   37  bg            (2B)
 1  wrap          11  width  (3B)   39  fg            (2B)
 2  justify       14  height (3B)   41  border_color  (2B)
 3  align_items   17  min_width     43  border_width  (4B)
 4  align_self    20  min_height    47  radius
 5  grow          23  max_width     48  shadow
 6  shrink        26  max_height    49  opacity
 7  gap           29  padding (4B)  50  font_family
                  33  margin  (4B)  51  font_size
                                    52  font_weight
                                    53  text_align
                                    54  line_clamp
                                    55  text_decoration
                                    56  overflow
                                    57  position
                                    58  z
                                    59  cursor
                                    60  reserved (4B, must be zero)

Enumerated fields reject unknown values rather than clamping them. Clamping is how two implementations quietly disagree about a layout for a year. The reserved tail must be zero for the same reason: accepting garbage there today would make tomorrow's field unusable, because some deployed server would already be putting something else in it.

Nodes

Node := kind:u8  flags:u8  id:varint  style:varint
        [ key      ]  -- flags & 0x01
        [ text     ]  -- flags & 0x02
        [ props    ]  -- flags & 0x04
        [ handlers ]  -- flags & 0x08
        child_count:varint
        child_count × Node

There are seventeen kinds and the set is closed: box, text, image, icon, input, textarea, scroll, list, canvas, spacer, divider, overlay, slot, sizer, audio, video, scene. Adding one is a protocol version bump, because it means shipping a new client. See the widget catalogue for how a button gets built out of these.

A subtree decodes into pre-order arrays, not a tree of boxes. That is not a micro-optimisation: it is what lets the decoder run iteratively with an explicit work stack, so a ten-thousand-deep hostile tree costs a bounds check instead of the call stack.

Patches

DefAtom  DefStyle  DefColor  DefChunk       definitions
DefFont(role, faces)                        a font the application supplies
DefGradient(id, angle, stops)               a linear gradient a bg may name (protocol 6)
Mount(subtree)                              replace everything
Replace(node, subtree)
SetStyle(node, style)
SetText(node, text)
SetProp(node, prop, value)
InsertChild(parent, index, subtree)
RemoveChild(parent, index, count)
MoveChild(parent, from, to)
SetHandler(node, event, handler)
ClearHandler(node, event)
Focus(node)   ScrollTo(node, x, y)
Notify(title, body, tag)                    say one line to the person

MoveChild is the one worth pointing at. Soli's current LiveView diff works on lines of an HTML string, so re-sorting a table has no cheap spelling — the lines all change. Here, a keyed child list that is a permutation of the previous one becomes n moves: reversing fifty rows costs 201 bytes rather than 4 619.

Limits

Every one of these is checked before the memory it bounds is allocated. A hostile server can be annoying; it cannot make the client exhaust itself.

LimitValue
Frame8 MiB
Tree depth256
Nodes1 000 000
Atoms65 535, 64 KiB each, 8 MiB total
Styles65 535
Children per node65 535
Props per node64
Handlers per node16
Ops per batch65 535
Inline string4 KiB
Value nesting4

No error recovery

A malformed frame ends the session. There is no quirks mode, no partial application, and no "ignore what you don't understand" — that last one is how one implementation's frame becomes another's smuggling channel. If a batch fails to apply, the client discards its tree, asks for a resync, and rebuilds.

This is deliberately unforgiving. A patch stream that has drifted is a bug on one side or an attack from the other, and both are better served by a clean rebuild than by a heuristic.

Rendered from doc/docs/eui/wire-format.md in the repository. The normative text is spec/; where it and a prose page disagree the spec wins, and that is a bug worth reporting. Edit this page