Skip to content
solisoftPublic

About

EUI applications in Ruby: the wire format, the views, and the server that speaks them. No runtime dependencies.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

13 Commits

Folders and files

Repository files navigation

eui-ruby

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.

What a view is

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"}}]}
  • k one 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.
  • s a flat style hash in the spec's own vocabulary. An unknown key is an error, not a key that does nothing.
  • c children, t text, p props, on handlers, key identity 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)] }) ]
end

What a handler is

A 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.

Running it

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.

Over a pipe, with no server

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 status

There 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.

What is here, and what is not

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): Upload and Blob are decoded and dropped.
  • Session resume (spec/01 §4.1): a socket that breaks gets a fresh session and a fresh Mount, which is a conforming answer and a worse one.
  • The windowed list's window event, and scene uniforms.

Tests

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.

Against the other six

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.

Licence

MIT.

About

EUI applications in Ruby: the wire format, the views, and the server that speaks them. No runtime dependencies.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages