Transport
EUI runs over HTTPS. There is no EUI port, no new TLS profile, and no new certificate story: an EUI application is served from an ordinary origin, behind ordinary proxies and CDNs.
Requirements
-
TLS 1.3, no exceptions. A client refuses
http://origins, loopback included, in any release build — and offers no earlier version, so a server answering with TLS 1.2 is refused rather than accommodated. -
The roots your machine trusts. Verification is against the public web's roots and the platform trust store, so a development proxy under a
.testname works with no configuration at all —mkcert -installput its CA where the browser beside you reads it, and the client reads the same place:eui wss://app.example.test/_eui/session/galleryEUI_CA_SYSTEM=0drops back to the public roots alone. A root that is in neither — one carried with a deployment rather than installed — goes inEUI_CA_FILE, one PEM bundle or several separated by:. All three sets cover the manifest, the assets and the session alike.There is no flag that turns verification off. A client that would accept any certificate on request is a client whose TLS means nothing.
-
No cross-origin redirect during discovery.
-
No user-agent string and no client identifier, ever.
In a browser, none of that is ours
The WebAssembly build of the client (Overview) meets almost none of the requirements above itself, and it is worth saying which and why rather than leaving it implied.
TLS is the tab's. The chain was verified before a byte reached the module,
by code we did not write and cannot inspect from inside; EUI_CA_SYSTEM and
EUI_CA_FILE have no meaning there because there is no trust store to
point at. The Origin and User-Agent headers on the upgrade are the
browser's and cannot be suppressed from a page, so the last line above is
one a page simply cannot keep. And the publisher key is not pinned at all —
there is nowhere durable to pin it that the reader cannot clear, so that
build reports its session as unverified rather than implying a pin it
never made.
What it does keep is the part that does not need a platform: wss:// only,
binary frames only, one origin for the page and the session — the server
refuses an upgrade whose Origin is not its own, which is why an embed is
served by the application it embeds.
The loopback exception of Security §1 survives in a better
form there than the environment variable it replaces. EUI_ALLOW_INSECURE_LOOPBACK
cannot be set in a page, so ws:// is permitted only when the document
itself came from localhost — a question the browser answers, not the
document. A page served from anywhere else cannot ask for it however it is
written.
Endpoints
GET /.well-known/eui | The application manifest |
GET /_eui/asset/<blake3-hex> | A content-addressed asset |
wss://host/_eui/session | The interactive session |
GET /_eui/view/<component> | One render, cacheable, with no session behind it |
One render, and no session
A socket costs the server a session per reader — the interned tables, the previous tree, an instance to hang them on. Measured against the EUI site that is 50–60 kB for somebody who is only reading, which is the wrong shape for a page whose readers mostly read, and the socket buys nothing there: nothing on such a page changes unless the reader changes it.
GET /_eui/view/<component>?v=4 answers application/vnd.eui.frames — the
exact frames a fresh socket would have sent, a Welcome and the batches
through the first Mount. No second encoding: a client feeds the body through
the same decoder it uses on the socket. The body carries a strong ETag, so a
CDN or a browser cache answers the second reader and the origin renders once
per revalidation rather than once per reader. Six hundred distinct renders of
the EUI site's page cost the server 4 kB, against 50–60 kB apiece for the
sockets they replace.
A component opts in, because its first render has to be the same for everybody:
router_eui("site", "site#site", "site#site_view", {"static": "public, max-age=60"})
{"static": true} means no-cache, which still saves the session and still
revalidates against the ETag. It cannot be combined with
{"session": "required"} — a static view is rendered for nobody — and saying
both is refused where it is written rather than at request time.
Two things follow from "rendered for nobody". The render sees no session, no
cookie and no locale, so a view that greets somebody by name does not belong
here. And a GET has no same-origin check — a resource a CDN is meant to hold
cannot have one — so declaring a component static is promising that its
connect handler is a read: an <img src> on any page anywhere reaches it.
What the shell shows while there is no session
The reference shell normalises a typed address at one door: https://host
becomes wss://host, because somebody copying their browser's address bar
writes the first and it names the same origin. The manifest's entry then
completes it, so an address with no path lands on
wss://host/_eui/session/<component> — the application's own address, and
the right thing to show.
Which leaves a wss:// in the bar over a tab that may have no socket at all.
So the address row carries a word for it, beside the trust chip and where
reconnecting and offline go: page. It is not a fault and not a
degraded state — the tree is drawn, its pictures are fetched, its local(…)
handlers run, and the server is holding nothing whatever for this reader —
but it is a state, and the one this endpoint exists to produce. The word goes
when a socket is dialled.
When a page needs the server after all
A page fetched that way opens no socket until something happens that only the server can answer. When one is finally dialled, the naive thing is to mount the page again — and that discards the tree, the layout, focus, every scroll offset and anything half-typed, so a reader partway down is returned to the top with nothing focused.
So the client offers what it has. Its Hello carries the BLAKE3 of the
batches it holds, as a third resume tag beside "nothing" and "a session"; the
server renders connect as it would have anyway, compares, and answers in
Welcome.start:
- adopted — the hash matched. No
Mountfollows, sequence numbers carry on, and the reader keeps scroll, focus and caret by nothing being torn down. - fresh — it did not, and the server sends the frames it just rendered. Not a second render: the comparison is of bytes it produced once.
The hash covers the batches and not the body they arrived in. A one-shot
render's Welcome carries sixteen zero bytes and a socket's carries a session
handle, so a server hashing the whole body would find no match ever and the
only symptom would be that adoption silently never happened.
A server may decline for any reason; fresh is always a correct answer.
Islands
A whole page becoming a session because one part of it must be live is the
wrong trade — every reader then pays a session's memory for a comment count
that changes twice a day. A node may instead carry an island prop, an
absolute path on the same origin, and take its content from a session of
its own. The name is the web's own: a page that is mostly still, with islands
in it that are not.
{"k": "slot", "island": "/_eui/session/comments?for=" + page["id"], "c": [
comment_count_as_rendered(page)
]}
The node's own children are what shows until that session speaks, and they came from the cached render — so an older client that ignores the prop, a session that cannot be opened, and a page served from a cache all end in the same place: content that is out of date rather than missing.
The query is how two islands of one component tell the application which of
them is being rendered; it reaches connect alongside viewport. Islands
naming the same path share one session, at most eight are opened for a page,
and an island naming another origin is refused — a tree that could open a
socket elsewhere would make every page a way to reach any host its reader can.
The manifest
The manifest carries the application id, the protocol range it supports, an Ed25519 public key, a signature over everything else, the capabilities it wants, and the hash of its default theme.
A client verifies the signature before acting on any other field. On first run it pins the publisher key for that application id at that origin. A different key on a later run is refused unless the manifest also carries a rotation record signed by the previously pinned key.
The manifest, checked
Before a session opens, the client fetches /.well-known/eui, verifies the
publisher's Ed25519 signature, checks the protocol range, and pins the key
under the origin it fetched from and the application's app_id together —
trust on first use. A later manifest with a different key is refused unless
it carries a rotation signed by the pinned key.
Why the origin as well: the signed record names none, and a manifest is
public. Anybody can serve a byte-for-byte copy of a real application's
/.well-known/eui, and the signature verifies there too. Pinned by app_id
alone, that copy wore the real application's padlock and inherited the
camera and microphone the person had granted it. Keyed by both, it is a new
application at a new origin, trusted on first use and asked for everything.
The remembered answers to the consent sheet are kept the same way. Pins and
answers written by a client from before this was so are not carried over —
which origin they came from is exactly what they never recorded — so every
application is trusted on first use, and asks, once more. Soli serves the manifest for any app with the eui feature, signing
with a key it generates on first use into config/eui_publisher.pkcs8; an
app asks for capabilities with eui_capabilities("clipboard.read") and the
person grants them with eui <url> --allow clipboard.read. Only the debug
loopback may connect without a manifest, and says so.
Assets
An asset is named by the BLAKE3 hash of its content. The client recomputes the
hash and discards a mismatch. Because the name is the content,
Cache-Control: immutable is always correct, and a hostile CDN cannot
substitute anything.
The session
A WebSocket over TLS, carrying binary frames only. A text frame closes the session.
WebSocket rather than HTTP/3 for version 1 because it traverses every proxy in existence today and Soli already speaks it. The framing layer is specified independently of the transport, so HTTP/3 with WebTransport can be substituted later without changing a single message byte.
Framing
frame := kind:u8 len:varint payload
| kind | Name | Direction |
|---|---|---|
0x01 | Hello | client to server |
0x02 | Welcome | server to client |
0x03 | Batch | server to client |
0x04 | Event | client to server |
0x05 | Ack | client to server |
0x06 / 0x07 | Ping / Pong | either |
0x08 | Error | either |
0x09 | Resync | client to server |
0x0A | Viewport | client to server |
0x0B | Upload | client to server |
0x0C | Blob | server to client |
Any other kind is rejected. Unknown kinds are not reserved for forward
compatibility: version negotiation in Hello and Welcome is the only
extension mechanism, so a client never has to guess at semantics.
A frame's declared length must account for every byte of the message. Trailing bytes are an error, not padding.
Ordering
Batches carry a monotonically increasing sequence number and apply in order,
all or nothing. If one fails — an op naming a node that does not exist, an atom
id that was never defined — the client does not attempt partial application. It
discards the tree, sends Resync, and waits for a fresh Mount.
When the socket breaks
A session belongs to the server; the socket under it does not. A wifi hop, a VPN reconnect, a laptop lid and a proxy's idle timeout all end a socket while both ends are still willing, and an application that treated those as its own end would lose a half-filled form to a change of network.
So a client whose socket closed without an Error opens another one — after
300 ms, doubling to 30 s and no further — and says so meanwhile rather than
leaving a window that looks alive and answers nothing. Its Hello offers the
session back: the id the server named, and the last batch it applied. The
server answers in Welcome:
- resumed — the session is still here and nothing else is on it. The client keeps its tree, its tables, its focus and what was typed into it, and the server sends the batches it missed.
- not resumed — a session that starts empty. A client holding a tree
discards it before the
Mountthat follows.
The client believes that answer over its own memory: a tree kept against a
server that has forgotten the session would answer clicks the server cannot
place. Because a replay may repeat a batch the client already applied, a
batch whose sequence has been applied is acked again and otherwise ignored —
a SetText would survive being applied twice, an InsertChild would not.
The reference server keeps a session for two minutes and the last 64 unacked batches, and refuses the resume rather than half-serving it when either runs out.
Files
A file is not an asset. An asset is named by its content, is the same for everyone, and anything in between may serve it to anyone — exactly wrong for the invoice one person attached and the export another asked for. So files travel in the session: authenticated by it, scoped to it, gone with it, and cacheable by nothing.
One shape, both directions: an id, a chunk index, a flag (more, last,
aborted), and at most 256 KiB of bytes. Upload carries a file the person
picked, against the id announced in the file_pick event that opened it.
Blob carries what a node's save offers, addressed to that node — and a
client refuses one for a node it has no open save for, because that is a
server trying to write a file nobody offered it.
What opens either is in widgets: a pick or save prop, a
handler for the event that answers it, the capability, and a person actually
clicking.
Idle
Whichever side has been silent for 30 seconds sends a ping. A client does not poll, does not keep a timer that fires when nothing has changed, and does not redraw unless a frame or an input event asked it to. The zero-wakeup idle budget is a property of that rule, not of a setting.
Rendered from doc/docs/eui/transport.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