EUI applications without a browser

02 — Wire format

Status: normative. Implemented by crates/eui-proto, verified by the test vectors in 09-conformance.md.

1. Primitives

NotationEncoding
u8, u16, u32, u64fixed width, little-endian
f32, f64IEEE-754, little-endian
varintLEB128 unsigned, at most 5 bytes for a u32, 10 for a u64
svarintLEB128 over zigzag: (n << 1) ^ (n >> 63)
bytesvarint length followed by that many bytes
strbytes, REQUIRED to be well-formed UTF-8

A varint MUST be minimally encoded: a decoder MUST reject a multi-byte encoding whose final byte is 0x00, and MUST reject a varint that would overflow its target width. Non-minimal encodings are the classic route to signature-bypass and cache-poisoning bugs, so they are an error here rather than a normalisation step.

2. Session tables

A session owns four append-only tables. Ids are dense and assigned by the server, starting at 1. Id 0 is always "none" and MUST NOT be defined.

TableDefined byMax entries
AtomsDefAtom65 535
StylesDefStyle65 535
Colors (literal)DefColor4 095
Chunks (bytecode)DefChunk4 095

Redefining an existing id is an error. A reference to an undefined id is an error. Tables are never cleared: they are session-scoped and append-only, and a session that needs a clean slate is a new session. This is what lets the definitions in a batch precede the Mount that uses them.

2.1 Atoms

DefAtom := id:varint  value:str

An atom's value MUST NOT exceed 64 KiB, and the sum of all atom values in a session MUST NOT exceed 8 MiB.

An atom is the unit of string deduplication. A server SHOULD intern any string it will send more than once: property names, list keys, repeated labels, enumerated values.

3. Style records

DefStyle := id:varint  record:StyleRecord

A StyleRecord is exactly 64 bytes, fixed layout, no padding negotiation, no optional fields. It holds computed style: every value is either a literal number or a reference into the theme's token scales, and nothing in it needs resolution against a parent.

OffSizeFieldNotes
01display0 row, 1 column, 2 stack, 3 grid, 4 none
11wrap0 nowrap, 1 wrap, 2 wrap-reverse
21justify0 start, 1 center, 2 end, 3 between, 4 around, 5 evenly
31align_items0 start, 1 center, 2 end, 3 stretch, 4 baseline
41align_selfas align_items, plus 5 auto (inherit from parent)
51growinteger factor, 0–255
61shrinkinteger factor, 0–255
71gapspace scale index
83basisDim
113widthDim
143heightDim
173min_widthDim
203min_heightDim
233max_widthDim
263max_heightDim
294paddingtop, right, bottom, left — space scale indices
334margintop, right, bottom, left — space scale indices
372bgColorRef
392fgColorRef
412border_colorColorRef
434border_widthtop, right, bottom, left — device-independent px
471radiusradius scale index
481shadowshadow scale index
491opacity0–255, 255 = opaque
501font_family0 sans, 1 mono, 2..9 an application font role (§5, DefFont)
511font_sizetext scale index; the default record carries 2, base
521font_weight0 regular, 1 medium, 2 semibold, 3 bold
531text_align0 start, 1 center, 2 end, 3 justify
541line_clamp0 = unlimited
551text_decorationbitfield: 1 underline, 2 strikethrough
561overflow0 visible, 1 clip, 2 scroll
571position0 flow, 1 absolute, 2 pointer, and from version 7 3 absolute_start, 4 absolute_center, 5 absolute_end — an absolute child that names the edge it sits against across its parent (meaningful inside stack; see 04-layout.md §5)
581zstacking order within the parent
591cursor0 default, 1 pointer, 2 text, 3 grab, …
601transition0 none, else motion scale index + 1, at most 5 (05 §2): colours and opacity animate into this record (03 §5)
611animationbit set: 1 spin (the node turns about its centre while on screen), 2 enter (it arrives when mounted), 4 exit (its painting is kept while it leaves), and from version 6 8 pulse (its painting dims to half and back every 2 s) and 16 bounce (its painting is lifted a quarter of its height and let fall every 1 s) — 03 §5
621blurbackdrop blur, as the standard deviation of a Gaussian in device-independent px; 0 none (03 §2)
631motion_kind0 fade, 1 leading, 2 trailing, 3 top, 4 bottom, 5 scale, 6 paired: which way an enter arrives and an exit leaves (03 §5.2)

A decoder MUST reject any enumerated field whose value is outside the range defined above, any animation bit this revision does not define, and a motion_kind on a record carrying neither enter nor exit — a direction with nothing going that way. Rejecting unknown values rather than clamping them is what keeps two implementations from silently diverging.

Offset 63 was this record's last reserved byte, and motion_kind is what it was kept for. §3 fixes the record at 64 bytes, so there was never more than one further field in it; the next one is a wider record and a version bump, and that was always the price.

3.1 Dim

Dim := tag:u8  value:u16
tagMeaning
0auto — value MUST be 0
1device-independent pixels
2percent of the parent's content box, in hundredths (5000 = 50 %)
3flex fraction (fr), in hundredths
4space scale index — value MUST be ≤ 255

3.2 ColorRef

A u16:

  • 0 — none / inherit from the parent
  • 1..=0x3FFF — a theme colour role id (see 05-theme.md); the ids past the last role are reserved and refused (05 §1)
  • 0x4000..=0x7FFF — from version 6, value & 0x3FFF names an entry in the session's gradient table (§5.3). 0x4000 itself names none and MUST be rejected
  • 0x8000..=0xFFFF — value & 0x7FFF indexes the session's literal colour table

A gradient is a background and nothing else. A decoder MUST reject a gradient reference in a record's fg or border_color, and in a Value colour (§4.4): text, a stroke and a canvas path are drawn in one colour, and a reference that means a picture where a colour was asked for is a record two clients would draw differently. The range was carved from the reserved role ids, so a client that predates it reads such a bg as a role it does not know — which is why a server sends none to a session below version 6 (§5.3).

DefColor := id:varint  rgba:u32       -- 0xRRGGBBAA, sRGB

Literal colours exist for brand marks and data visualisation. A server SHOULD prefer roles: only roles follow the user's mode, contrast and density settings.

4. Nodes

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

Node ids are assigned by the server, MUST be non-zero, and MUST be unique within a session for as long as the node exists. An id MAY be reused after the node has been removed.

4.1 Kinds

kindName
0x01box
0x02text
0x03image
0x04icon
0x05input
0x06textarea
0x07scroll
0x08list
0x09canvas
0x0Aspacer
0x0Bdivider
0x0Coverlay
0x0Dslot
0x0Esizer
0x0Faudio
0x10video
0x11scene

This set is closed. Adding a kind is a protocol version bump, because it means shipping a new client. 0x11 took this protocol to version 2: a client that knows only version 1 meets it as a decode error and ends the session, which is the deliberate price and the reason an application that asked for scene raises the floor its manifest advertises (01 §2.1). An event kind is the same bargain read from the other end — a client that does not know 0x20 level (03 §7, 06 §1) fails the same way on the handler that names it — and that took the protocol to version 3. An op is the same bargain again: 0x15 DefFont and the font roles it binds (§5.1) took it to version 4. The three are paid for differently, though: a kind is refused at the handshake through the manifest's floor, while an event is left out at encode time, because a capability is declared before anything renders and a handler is a key in a view that has not run yet. A font role is both — an application that declares one at boot raises its floor, and one that declares a face mid-session falls back to sans for the sessions already open rather than ending them. A scale step is the fourth way: the space entries 13–17 (05 §2) took the protocol to version 6, and are paid for like an event — a session below 6 is sent the older step each one falls back to, so nothing is refused and nothing raises a floor. DefGradient (§5.3), the gradient range of ColorRef (§3.2) and the animation bits pulse and bounce (03 §5) are version 6 too, and are paid for the same way: a session below 6 is sent each gradient's first stop as a solid bg, and records without the two bits. position 3–5 (04 §5) took it to version 7, paid for the same way again: a session below 7 is sent absolute in their place, and its client places the node by its parent's justify, as it placed every absolute child before. Everything users would call a widget — button, dialog, table, date picker — is composed from these on the server; see 03-widgets.md.

Kinds 0x02 text, 0x04 icon, 0x0F audio, 0x10 video and 0x11 scene MUST have no children. Kind 0x0A spacer and 0x0B divider are inert: they MUST have no children, no text, no props and no handlers.

4.2 flags

bitMeaning
0x01a key follows — an atom id: identity for reconciliation, and the name a local handler uses for the node
0x02a TextRef follows
0x04a PropList follows
0x08a handler list follows
0x10–0x80reserved, MUST be zero

4.3 TextRef

TextRef := 0x00 atom:varint      -- interned
         | 0x01 inline:str       -- one-off, ≤ 4 KiB

4.4 PropList

PropList := count:varint  count × ( prop:varint  value:Value )

prop is an atom id naming the property. count MUST NOT exceed 64.

Value := 0x00                       -- null
       | 0x01 b:u8                  -- bool, MUST be 0 or 1
       | 0x02 n:svarint             -- integer
       | 0x03 f:f64                 -- float, MUST NOT be NaN or infinite
       | 0x04 atom:varint           -- interned string
       | 0x05 s:str                 -- inline string, ≤ 4 KiB
       | 0x06 hash:32×u8            -- asset reference (BLAKE3)
       | 0x07 c:ColorRef
       | 0x08 count:varint × Value  -- list, depth ≤ 4, count ≤ 1024

4.5 Handlers

HList   := count:varint  count × ( event:u8  Handler )
Handler := 0x00 name:varint                 -- server round trip, atom names the event
         | 0x01 chunk:varint                -- local bytecode chunk
         | 0x02 chunk:varint  name:varint   -- local first, then notify the server

count MUST NOT exceed 16. Event codes are defined in 06-events.md.

A 0x01 handler runs entirely on the client and produces no network traffic. A server MUST NOT place authorisation-relevant logic in a local handler: the effect of every local handler is advisory and MUST be revalidated server-side before it is trusted. See 07-bytecode.md §5.

5. Ops

A Batch frame is:

Batch := seq:varint  op_count:varint  op_count × Op
Op    := opcode:u8  payload
opcodeOpPayload
0x10DefAtomid:varint value:str
0x11DefStyleid:varint record:64×u8
0x12DefColorid:varint rgba:u32
0x13DefChunkid:varint hash:32×u8 — fetched as an asset
0x14DefChunkBytesid:varint bytes:bytes — inline, ≤ 64 KiB (07-bytecode.md)
0x15DefFontrole:u8 count:varint count × 32×u8 — bind a font role to its faces, fetched as assets
0x16DefGradientid:varint angle:u16 count:u8 count × (color:ColorRef at:u8) — a linear gradient a bg may name (§5.3); version 6
0x20Mountroot:Node — replaces the whole tree; tables persist
0x21Replacenode:varint subtree:Node
0x22SetStylenode:varint style:varint
0x23SetTextnode:varint text:TextRef
0x24SetPropnode:varint prop:varint value:Value
0x25InsertChildparent:varint index:varint subtree:Node
0x26RemoveChildparent:varint index:varint count:varint — the root cannot be removed
0x27MoveChildparent:varint from:varint to:varint
0x28SetHandlernode:varint event:u8 handler:Handler
0x29ClearHandlernode:varint event:u8
0x2AFocusnode:varint
0x2BScrollTonode:varint x:svarint y:svarint
0x2CNotifytitle:str body:str tag:str — §5.2

Definition ops (0x1x) within a batch MUST precede any op that references what they define. A decoder MAY rely on this and MUST reject a forward reference.

MoveChild removes the child at from and re-inserts it at to, where to indexes the list after the removal. Moving [a, b, c] with from=0, to=2 gives [b, c, a].

MoveChild is what makes keyed list reconciliation cheap: reordering a thousand-row table is n moves, not a rebuild. A server SHOULD emit MoveChild whenever the keys of a child list are a permutation of the previous keys.

5.1 Font roles

DefFont binds a font_family role to the faces that draw it. role MUST be at most 9; count MUST be at least 1 and at most 8. Each face is the BLAKE3 hash of an asset, fetched and verified like any other (01-transport.md §2.2); font_weight selects among the faces of a role, and nothing else does.

Roles 0 and 1 are sans and mono, and the client MUST have faces for them before any op arrives. A DefFont on 0 or 1 replaces the client's own face for that session only — which is what a theme's font_sans asset means (05-theme.md §3). Roles 2..9 begin bound to nothing.

Unlike the other definition tables a role MAY be bound again: a role is a slot the protocol already names, not an id a server hands out, so rebinding is a change of mind rather than a redefinition. A client MUST discard what it shaped under the old binding.

A style MAY name a role no DefFont bound, and a client MUST NOT fail the session for it: it draws the run in sans and carries on. The same applies to a role whose faces have not arrived yet, or whose bytes the client could not read as a face. Text is never not drawn because a font is missing — the only thing a server can do by naming a face badly is choose the wrong typography for its own application.

A client MUST NOT resolve a face by name, by URL, or from the machine it runs on. The faces a session shapes with are exactly the embedded ones and the assets its own origin served (08-security.md §8).

5.2 Saying something to the person

Notify is the one op that names no node. What it changes is not the document but what somebody is told: a line raised by the machine's own notifier, which outlives the batch, sits outside the window, and is read by a person who may not be looking at the application at all.

Field
titleAt most MAX_NOTIFY_TITLE bytes. Required in practice: a notification with no title is a blank rectangle, and a client SHOULD show nothing rather than that.
bodyAt most MAX_NOTIFY_BODY bytes; may be empty.
tagAt most MAX_NOTIFY_TAG bytes; may be empty. An identity, not prose: a notification carrying the tag of one still on screen replaces it rather than stacking beside it, so ten replies to one thread are one notification.

A client MUST NOT show one without the notifications capability (01-transport.md §2.1), and MUST NOT fail the session for it either: the op is decoded, counted against the limit below, and dropped. A batch carrying one is a batch like any other — applied whole, acked once — and a batch whose tree is refused shows nothing, because the op never applied.

At most MAX_NOTIFY_PER_BATCH of them may appear in one batch, and a batch carrying more MUST be refused. This is the only limit in §6 that bounds something other than the client's memory: what it bounds is how often one batch may interrupt somebody, which nothing else in this protocol measures. A client MAY drop the oldest of several it has not shown yet.

Nothing is reported back. There is no event for a notification shown, clicked, replaced or dismissed, and none for a machine that has no notifier at all. An application learns exactly as much by sending one as it does by sending an address to open (03 §3.5), and for the same reason: an answer would be a probe for what this machine is and who is at it, with a clock beside it (08-security.md §8). What a client MAY do with a click is bring its own window forward.

A client MUST NOT hand any part of a Notify to a shell, and a title carrying control characters MUST be cleaned or refused before it reaches a platform notifier — a notification's text is a server's string, and every platform has an argument parser somewhere behind it.

5.3 Gradients

DefGradient defines a linear gradient, and a record's bg names it by the ColorRef 0x4000 | id (§3.2). It is a table like the colours: define-once, per session and per island (01 §2.7), ids from 1, at most MAX_GRADIENTS of them, and a definition MUST precede the first record that names it.

Field
angle0..=359: CSS's <angle> in whole degrees — 0 points to the top, and it turns clockwise, so 90 is to right and 180 to bottom. 360..=363: CSS's corner keywords, to top right, to bottom right, to bottom left and to top left, whose angle depends on the box (below). Anything above 363 MUST be rejected.
count2..=3 (MAX_GRADIENT_STOPS); anything else MUST be rejected.
colorA role or a literal (§3.2). None, and a gradient, MUST be rejected; a literal MUST already be defined, which is session state and the tree's to check.
atWhere the stop sits along the gradient line, in 255ths: 0 its start, 255 its end. Each stop's at MUST be at least the one before it.

A client paints it exactly as CSS paints linear-gradient() over the border box. The gradient line passes through the box's centre in the direction of the angle, and is |w·sin θ| + |h·cos θ| long, so that its two ends are where the perpendiculars through the far corners cross it. For a corner keyword the direction is the one perpendicular to the diagonal that does not touch that corner — the 50 % line runs corner to corner — so to top right on a wide banner rises steeply rather than at 45°. A point takes the colour of its projection on the line: before the first stop the first stop's colour, after the last the last's, and between two stops the mix of the two in premultiplied sRGB, which is CSS's default interpolation. Colours are resolved as any ColorRef is — a role against the viewer's theme — so a gradient of roles follows dark mode for nothing.

The corners, the border, the shadow, the opacity, the clip and a blur behave over a gradient exactly as over a solid bg: the gradient is the fill and nothing else changes. A change of bg to or from a gradient does not ease (03 §5).

Version 6. A server MUST NOT send DefGradient, or a ColorRef in the gradient range, to a session negotiated below 6. The reference server sends the gradient's first stop as the solid bg instead, so an older client draws the colour the gradient starts from rather than refusing the batch or drawing nothing (lang/src/serve/eui/tree.rs, the encoder's color).

6. Limits

A conforming client MUST enforce all of these and MUST fail the session, not truncate, when one is exceeded.

LimitValue
MAX_FRAME_BYTES8 MiB
MAX_TREE_DEPTH256
MAX_NODES1 000 000
MAX_ATOMS65 535
MAX_ATOM_BYTES64 KiB each
MAX_ATOM_TOTAL_BYTES8 MiB per session
MAX_STYLES65 535
MAX_COLORS4 095
MAX_CHUNKS4 095
MAX_CHUNK_TOTAL_BYTES8 MiB per session, of inline chunks (DefChunkBytes), the page and its islands (01 §2.7) together
MAX_GRADIENTS1 023 per session and per island (§5.3)
MAX_GRADIENT_STOPS3
MAX_FONT_ROLE9 (roles 0–9)
MAX_FACES_PER_ROLE8
MAX_CHILDREN65 535 per node
MAX_PROPS64 per node
MAX_HANDLERS16 per node
MAX_OPS_PER_BATCH65 535
MAX_INLINE_STR4 KiB
MAX_VALUE_DEPTH4
MAX_VALUE_LIST1 000 000 elements (a windowed list's heights, 04 §7.1; bounded in bytes by the frame)
MAX_NOTIFY_TITLE256 bytes
MAX_NOTIFY_BODY1 KiB
MAX_NOTIFY_TAG64 bytes
MAX_NOTIFY_PER_BATCH4 (§5.2)

MAX_GRADIENTS is a quarter of MAX_COLORS because a gradient is a decoration a page has a handful of, not a value a chart plots: the ceiling is there for the view that derives one from data, which would otherwise mint a permanent entry per reading exactly as a derived colour does.

MAX_ATOM_TOTAL_BYTES, MAX_CHUNK_TOTAL_BYTES, and the rule that an id is defined once and referenced only after, are session state and belong to the tree layer, not the decoder. Enforced: eui-tree::Session::apply.

The chunk total is one figure for the page and every island open on it, where the atom total is per namespace: an island's DefChunkBytes is budgeted against what the page already holds. Without it the ceiling was 64 KiB times 4 095 ids — 256 MiB a namespace, and a page with its islands has nine. Every other limit is enforced by eui-proto alone.

MAX_TREE_DEPTH is enforced during decoding, before any recursion, so that a hostile tree cannot exhaust the stack. An implementation SHOULD decode iteratively with an explicit work stack; eui-proto does.

7. Records

The manifest (§01 2.1) and the theme document (05-theme.md) use one shared shape:

Record := magic:4×u8  version:u8  field_count:varint
          field_count × ( key:varint  value:Value )

Magic is EUIM for a manifest, EUIT for a theme. Keys are indices into a fixed key table defined by each document type, not atoms — a record stands alone and has no session to intern against.

8. Worked example

A two-node tree: a column containing the text "Hi".

op 0x10  DefAtom   id=1  "Hi"
op 0x11  DefStyle  id=1  ⟨display=column, padding=[4,4,4,4], bg=role 1⟩
op 0x11  DefStyle  id=2  ⟨font_size=3, fg=role 8⟩
op 0x20  Mount
         node kind=0x01 box    flags=0x00 id=1 style=1  children=1
           node kind=0x02 text flags=0x02 id=2 style=2 text=atom(1) children=0

Encoded:

10 01 02 48 69                    DefAtom  1 "Hi"
11 01 <64 bytes>                  DefStyle 1
11 02 <64 bytes>                  DefStyle 2
20 01 00 01 01 01                 Mount, box id=1 style=1, 1 child
      02 02 02 02 00 01 00        text id=2 style=2 text=atom 1, 0 children

150 bytes, of which 132 are the two style records. Those are sent once for the whole session no matter how many nodes come to use them, which is the whole point: the marginal cost of the next node is 5 to 9 bytes, not another copy of its styling.

The measured comparison on a realistic table is in 10-budgets.md §2, and the test that produces it is crates/eui-proto/tests/size_budget.rs.

Byte-level vectors — this example, the default StyleRecord, varints, frame envelopes — are pinned in crates/eui-proto/tests/vectors.rs, so a second implementation written from this document alone can check itself against the same numbers. 64 rejection cases live in crates/eui-proto/tests/reject.rs.

Rendered from spec/02-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