EUI applications, written in Ruby.
EUI delivers an application interface over HTTPS without HTML, CSS or JavaScript. The server sends a tree that is already resolved, in a compact binary encoding; a native client applies it, lays it out and draws it on the GPU. There is no tolerant parse at the other end, no cascade to resolve, no script to run.
This gem is the server half: the wire format, the view encoder and its diff, the session, the content-addressed asset store, the signed manifest, and a component model in which a view is a hash and a handler changes state.
require "eui"
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)EUI_ALLOW_INSECURE_LOOPBACK=1 eui ws://127.0.0.1:5099/_eui/session/counter
Pressing the button sends one SetText. Not a page, not a diffed DOM,
not a frame of JSON: the style records went once, at mount, and every later
render names them by id.
A hash, and a pure function of the state. EUI::DSL — included in every
component — builds them, but there is nothing behind the helpers: print one
and you have the whole document.
{"k" => "box", "s" => {"display" => "column", "gap" => 4},
"c" => [{"k" => "text", "t" => "Hi", "s" => {"size" => "lg"}}]}kone of the seventeen primitive kinds. Everything a person would call a widget — button, dialog, table, date picker — is composed from these on the server, which is why the catalogue grows without shipping a new client.sa flat style hash in the spec's own vocabulary. An unknown key is an error, not a key that does nothing.cchildren,ttext,pprops,onhandlers,keyidentity for reconciliation.
eui.solisoft.net/components is the reference for all of it — every node key, all seventeen kinds, every style key and all thirty-three colour roles, the event names, and the catalogue of composed widgets, each one shown with the hash it returns. It is written against Soli, and the vocabulary is the protocol's, so a style hash or a node on that page is the same style hash and the same node here. The rest of the site is worth the visit too: the running demo, the controls every widget is built from, and what is not there yet.
Colour is a role — surface.raised, text.muted, danger.base — never
a literal. The client resolves it against the viewer's theme, so the page is
right in dark mode without this server ever learning which mode they are
in. Sizes are scale indices, by index or by name: size: "lg", gap: 4,
radius: "md".
What this gem ships is the primitives plus a few composed widgets — button,
card, field, divider, spacer. Anything else in the catalogue is a
function that returns a hash, so it ports to Ruby by writing the same hash.
column(gap: 4, pad: 6, bg: "surface.base") do
[ text("Total", size: "sm", fg: "text.muted"),
text(format("%.2f", @total), size: "2xl", font: "mono"),
row(gap: 3) { [button("Save", "save"), button("Delete", "delete", tone: "danger")] },
divider,
keyed("row-#{id}", card { [text(name)] }) ]
endA block, or a method, named by the view rather than by the event kind —
{"on" => {"wake" => "tick"}} arrives as tick. It changes state and
returns; it never touches the tree.
on("pick") { |params| @selected = params["props"]["id"] }params carries node, kind, payload and props — the node's props as
the server last rendered them. That last one is what lets one handler serve
ten thousand rows: put the identifying value on the node, not in the
handler's name.
A handler that raises leaves the state unchanged and the screen right; it is
a line in the log, not the end of somebody's session. A view that cannot
be encoded is the other way round: it fails identically on every later
render, so the session ends with Error 400 and the reason.
app.run(port: 5099) |
plain ws:// on loopback, for development |
app.run(host: "0.0.0.0", port: 443, tls: {cert:, key:}) |
TLS 1.3, which is the protocol's floor |
app.run_pipe |
no server at all: starts eui --pipe and speaks over its stdin and stdout |
A release client refuses ws:// outright; a debug one takes
EUI_ALLOW_INSECURE_LOOPBACK=1. EUI_TRACE=1 on the server prints every
frame and every event a session sees, which is the first thing to reach for
when a click does nothing.
Three endpoints, and nothing else: /.well-known/eui (the signed manifest),
/_eui/asset/<blake3-hex> (content-addressed, immutable, served to anyone),
and the session itself.
app = EUI::App.new(name: "Books", app_id: "books.example",
key_path: "config/eui_publisher.pem",
capabilities: %w[net.open])
app.font("Space Grotesk", ["public/fonts/space-grotesk-400.ttf",
"public/fonts/space-grotesk-700.ttf"])The publisher key is generated on first use and kept: a client pins it
against app_id on first run and refuses a different one later. It belongs
to the application, not to a deployment, and it never belongs in a
repository.
An application that runs on the person's own machine does not need a port.
app.run_pipe starts the client itself — eui --pipe, version 0.8 or later
(EUI=/path/to/eui to name another) — and runs one session of the first
mounted component over the client's standard input and output, until the
window closes (spec/01-transport.md §7):
app = EUI::App.new(name: "Counter")
app.mount("counter", Counter)
app.run_pipe(allow: %w[clipboard.write]) # returns the client's exit statusThere is no manifest, no TLS and no pin: the window is a child of this
process, and allow is the whole of the grant, because a program the person
started can already do everything a capability names. Assets travel in the
session — the client asks with Fetch, this gem answers with Asset
chunks — since there is no origin to fetch them from. examples/pipe.rb is
the counter this way.
Implemented: the whole wire format of spec/02 (checked against the byte
vectors that document pins), the session frames of spec/01, the view
encoder with its append-only tables, a keyed diff that reorders by
MoveChild, assets, fonts, notifications, scroll_to and focus_to, the
manifest, and BLAKE3 in Ruby because an asset is named by the hash of its
content.
Not yet:
- Local handlers (
spec/07). A handler is a server round trip; the bytecode a client runs for itself — the hover that answers without a packet — is the next milestone. - File transfers (
spec/01§6):UploadandBlobare decoded and dropped. - Session resume (
spec/01§4.1): a socket that breaks gets a fresh session and a freshMount, which is a conforming answer and a worse one. - The windowed
list'swindowevent, andsceneuniforms.
rake test
# or, with nothing installed but Ruby itself:
ruby -Ilib -Itest -e 'Dir["test/**/*_test.rb"].each { |f| require File.expand_path(f) }'
102 of them, and the ones worth reading are test/proto_test.rb — the spec's
own §8 example, 150 bytes, byte for byte — and test/session_test.rb, which
runs a real application on a real socket and counts the ops a click costs.
test/pipe_test.rb does the same over a pair of pipes, serves an asset in
Asset chunks, and starts a stand-in for eui --pipe through run_pipe.
The same application, written seven times — in Soli, Ruby, Python, PHP,
JavaScript, Go and Rust, node for node. bench/ holds all seven and
the one driver that measures them, each phase timed until the server goes
quiet. At 500 rows, one number changed: 9 bytes out of every one of them,
13.8 ms in Soli, 22.9 ms here, 8.3 ms in Node, 2.4 ms in Rust. The bytes being
identical is the protocol's claim; the rest ranks by runtime, and the memory
column ranks what else is in the process. bench/README.md
has the tables, the method and the caveats.
MIT.