EUI applications without a browser

Serving EUI from Ruby, Python, PHP, Node, Go or Rust

Soli is the reference server, not the only one. clients/ holds the protocol's server half in six more languages, each its own repository, each with no runtime dependencies to speak of:

repositoryinstallwhat it needs
Rubysolisoft/eui-rubygem eui-rubynothing; Ruby ≥ 3.2
Pythonsolisoft/eui-pythoneui-pythonnothing; Python ≥ 3.11
PHPsolisoft/eui-phpsolisoft/eui-phpext-openssl; PHP ≥ 8.2
Nodesolisoft/eui-nodeeui-nodenothing; Node ≥ 20 or Bun ≥ 1.1
Gosolisoft/eui-gogithub.com/solisoft/eui-gonothing; Go ≥ 1.23
Rustsolisoft/eui-rustcrate eui-rustnothing; Rust ≥ 1.75

They exist for the obvious reason. An application whose data already lives in a Rails codebase, a Django one, a Laravel one, an Express one or a Go service should not have to move to another language to get a window; it should be able to serve one from where its models already are.

class Counter < EUI::Component
  def mount(params)
    super          # the window, as it is right now
    @count = 0
  end

  on("increment") { @count += 1 }

  def render
    column(gap: 5, align: "center", justify: "center",
           width: "100%", height: "100%", bg: "surface.base") do
      [text(@count.to_s, size: "4xl", weight: "bold"),
       button("Increment", "increment")]
    end
  end
end

app = EUI::App.new(name: "Counter", app_id: "counter.example")
app.mount("counter", Counter)
app.run(port: 5099)

The contract is the one Soli uses, because it is the protocol's: a view is a hash and a pure function of the state, a handler changes state and returns, and what reaches the client is the difference the change made. Pressing that button sends one SetText — nine bytes. The same program in Python, PHP, JavaScript, Go and Rust is the same program: a mount, a handler named by the view, and a render that returns the same tree.

What they implement

The whole of the wire format — every frame, all nineteen ops, the 64-byte style record, flat subtrees, values and handlers — checked in each language against the byte vectors that document pins, including the §8 worked example at exactly 150 bytes.

The session of the transport: Hello and Welcome with the version negotiated, batches with their sequence numbers, events, acks, pings, a resync answered with a fresh Mount that does not repeat the tables, the manifest at /.well-known/eui signed with an Ed25519 publisher key, and assets at /_eui/asset/<blake3-hex>.

BLAKE3 is written out in each of them, because an asset is named by the hash of its content and none of those six standard libraries ships one; each is checked against the official vectors, the multi-chunk ones included. Ed25519 comes from the runtime where there is one (Ruby's OpenSSL, PHP's, Node's crypto, Go's crypto/ed25519) and is written out in Python and Rust, where there is not — and both of those were compared byte for byte against OpenSSL's signatures and against RFC 8032 §7.1. The Rust crate writes out SHA-1 and base64 for the WebSocket handshake too, because it takes no dependencies at all.

The publisher key is one file for all six: a PKCS#8 PEM. An application that changes language keeps its identity, and nobody's pin breaks — checked, not assumed: the same key file produces the same manifest bytes, signature included, out of every one of them.

The view layer interns exactly as Soli's does — the same style vocabulary, the same enumeration names, the same append-only tables — and diffs against the tree the client holds. Keyed children reconcile through a Fenwick tree, so a reordered table is n moves and not a scan per row.

Not yet, in any of them: local handlers (the bytecode a client runs for itself), file transfers, session resume, and the windowed list's window event. A socket that breaks gets a fresh session and a fresh Mount, which is a conforming answer and a worse one. And one thing in one of them: the Rust crate has no TLS, because Rust's standard library has none and the crate takes no dependencies — it answers ws:// and belongs behind a terminator. The other five terminate TLS 1.3 themselves.

What they cost

The same application written seven times — the same node hash, the same keys, the same strings — an invoice table with a tick that changes one number and a sort that reverses every row. A tick is the floor: the handler adds 1, the view re-renders in full — fifty thousand nodes at ten thousand rows — the diff finds one changed text node, and nine bytes go out. One session at a time, over loopback; 20 ticks and 5 sorts, each phase measured until the server has been silent for a second.

500 rows — 2 511 nodes, which is what an application looks like

SoliRubyPythonPHPNodeGoRust
Mount50 ms82 ms61 ms78 ms59 ms49 ms37 ms
Tick — one number13.1 ms22.9 ms23.3 ms27.0 ms8.8 ms4.4 ms2.3 ms
Sort — 500 reversed16 ms25 ms27 ms35 ms9 ms8 ms5 ms
Memory, idle → after99 → 98 MB24 → 34 MB22 → 26 MB30 → 49 MB84 → 143 MB9 → 16 MB2.5 → 5 MB
CPU, 25 events0.38 s0.63 s0.65 s0.75 s0.45 s0.17 s0.06 s

10 000 rows — 50 011 nodes

SoliRubyPythonPHPNodeGoRust
Mount1 175 ms1 424 ms1 245 ms1 528 ms875 ms842 ms811 ms
Tick227 ms503 ms572 ms545 ms110 ms88 ms45 ms
Sort — 10 000 reversed358 ms581 ms663 ms651 ms169 ms149 ms101 ms
Memory, after229 MB125 MB93 MB153 MB264 MB94 MB54 MB
CPU, 25 events6.75 s13.58 s15.30 s14.84 s5.01 s3.73 s1.24 s

The JavaScript one runs unchanged on Bun, where the same 98 tests pass and the client cannot tell the difference. Bun is slower on this work by a half — 174 ms against Node's 110 for a tick at ten thousand rows — and holds about half the memory, 33 MB idle against 84.

On the wire, all seven are identical: 9 bytes for the tick, 2.8 KB for the 500-row sort, 58.5 KB and ten thousand MoveChild for the 10 000-row one. The one difference at the mount — 885 KB against 846 KB — is Soli interning a row's cell strings the other six carry inline.

Three things are worth reading out of that.

The bytes are the protocol's, not the implementation's. Seven servers that share nothing but a specification produce the same frames to the byte. That is the claim this whole project rests on, and it is the one thing here that is not a matter of degree.

The spread is about 12× at the tick, and it ranks by runtime. Rust and Go compile to machine code and land where you would expect; V8 is within a factor of two of Go; Soli's interpreter is next; CRuby, CPython and PHP land within a sixth of one another. Every one of these servers is doing the same work, which is exactly why the wire is identical.

Soli's own half of that is worth a line, because it is two halves: the view, which its interpreter runs, and the convert-diff-encode, which is Rust. EUI_TRACE=1 prints the split — at 500 rows, 7.6 ms and 3.4 ms. Half of the first is rebuilding style hashes that are all identical, and hoisting them out of the loop takes the tick to 6.2 ms without moving a byte. The floor under that is the interpreter building five thousand hashes, which no work on the Rust side reaches.

The memory column is not a language ranking. Soli's idle is a whole application server — worker pool, HTTP stack, database driver, LiveView registry, a compiler for local handlers — and it swings by sixty megabytes between runs as that pool warms, which is why its 500-row idle above reads higher than its figure after the run. Node's is V8. Rust's 2.5 MB and Go's 8 MB are what a process is when nothing else is in it. Compare what the processes do before comparing what they hold.

The mount column is a single sample. A tick is a median of twenty and a sort of five; a mount happens once per session, with a connection and a handshake inside it, so it is one measurement. Across runs it moves by forty per cent — Rust's 500-row mount has read 68 ms and 37 ms on the same binary — while the tick beside it moves by a tenth of a millisecond. The 10 000-row mounts are large enough to drown that and can be read as they stand.

And these are single runs on a machine that is not idle. An earlier pass put Ruby's 500-row tick at 36.9 ms against the 22.9 above and Soli's at 17.0 against 13.8, while Rust moved by six hundredths of a millisecond. The compiled servers barely notice a busy machine; the allocating ones notice it a great deal. Read the ranking, not the third digit.

And nothing here says anything about concurrency: one session at a time is one session at a time. A hundred at once would rank Soli's worker pool, Go's and Rust's threads, Node's event loop, PHP's process per connection and Ruby's and Python's threads under a global lock quite differently, and that is unmeasured.

The first run of that benchmark reported the Ruby sort at 17 690 ms. That was not Ruby: it was a quadratic keyed reconciliation, and a style record compiled once per node instead of once per distinct style. A Fenwick tree and a cache took it to 789 ms, and the five implementations written afterwards had both from the start. A benchmark between languages measures the implementations first.

Where it lives

clients/ in this repository holds all six, each its own git repository — cloned and pushed on its own, and this tree only ever has a copy sitting there. clients/eui-ruby/bench holds the seven applications and the one driver that measures them, and each library's own tests run in its own way:

rake test                                        # Ruby, 97
python3 -m unittest discover -s tests -t tests   # Python, 103
php tests/run.php                                # PHP, 96
npm test                                         # Node, 98
go test ./...                                    # Go, 98
cargo test                                       # Rust, 102

Among them, in each language, a client of forty lines that applies the server's ops and compares the tree it ends up holding against the server's own — over every permutation of five keyed rows and four hundred and eighty random edits. That is the test a diff is actually worth: a MoveChild off by one encodes, decodes and applies without complaint.

Rendered from doc/docs/eui/clients.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