EUI applications, written in Node.
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 package 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 an object and a handler changes
state. No dependencies — RFC 6455 and BLAKE3 are in here, because an
asset is named by the hash of its content and node:crypto has no such
digest.
import { App, Component } from 'eui-node';
import { button, column, text } from 'eui-node/dsl';
class Counter extends Component {
mount(params) {
super.mount(params); // the window, as it is right now
this.count = 0;
}
onIncrement() {
this.count += 1;
}
render() {
return column([
text(String(this.count), { size: '4xl', weight: 'bold' }),
button('Increment', 'increment'),
], { gap: 5, align: 'center', justify: 'center',
width: '100%', height: '100%', bg: 'surface.base' });
}
}
const app = new App({ name: 'Counter', appId: 'counter.example' });
app.mount('counter', Counter);
await app.run({ port: 5096 });EUI_ALLOW_INSECURE_LOOPBACK=1 eui ws://127.0.0.1:5096/_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.
An object, and a pure function of the state. eui-node/dsl 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 object 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 shown with the hash it returns. It is written against Soli, and the vocabulary is the protocol's, so a style hash on that page is the same style object here. Also worth the visit: 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'.
This package ships the primitives plus a few composed widgets — button,
card, field, divider, spacer. Anything else in the catalogue is a
function that returns an object, so it ports by writing the same object.
A method, named by the view rather than by the event kind: a node saying
{ on: { wake: 'tick' } } is answered by onTick.
onPick(params) {
this.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 may be async; the session waits for it before it
renders.
A handler that throws 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.
await app.run({ port: 5096 }) |
plain ws:// on loopback, for development |
await app.run({ host: '0.0.0.0', port: 443, tls: { cert, key } }) |
TLS 1.3, the protocol's floor |
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.
const app = new App({
name: 'Books',
appId: 'books.example',
keyPath: 'config/eui_publisher.pem',
capabilities: ['net.open'],
});
app.font('Space Grotesk', ['public/fonts/space-grotesk-400.ttf',
'public/fonts/space-grotesk-700.ttf']);The publisher key is a PKCS#8 PEM — the same file the Ruby, Python and PHP
libraries read, so an application that changes language keeps its identity
and nobody's pin breaks. It is generated on first use and kept: a client pins
it against app_id on first run and refuses a different one later.
Implemented: the whole wire format of spec/02 (checked against the byte
vectors that document pins, the §8 example at exactly 150 bytes included),
the session frames of spec/01, the view encoder with its append-only
tables, a keyed diff that reorders through a Fenwick tree, assets, fonts,
notifications, scroll_to and focus_to, and the manifest.
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.
The same application, written seven times — in Soli, Ruby, Python, PHP,
JavaScript, Go and Rust, node for node. At 500 rows, one number changed:
9 bytes out of every one of them, between 2.4 and 26 ms, and a memory
column that ranks what else is in the process rather than the language.
bench/README.md
has the tables, the method and the caveats; the applications and the one
driver that measures all seven live in clients/eui-ruby/bench of the
EUI repository.
Node and Bun both run this, and the tests say so: the same 98 pass under
node --test and under bun test, and the reference client cannot tell
which one is serving it — twelve nodes, thirty-four quads, either way.
What differs is what they spend. At 10 000 rows, the same application:
| mount | tick | sort | memory, idle → after | CPU, 25 events | |
|---|---|---|---|---|---|
| Node 26 | 896 ms | 116 ms | 179 ms | 84 → 268 MB | 5.09 s |
| Bun 1.4 | 977 ms | 173 ms | 218 ms | 33 → 138 MB | 7.08 s |
At 500 rows they are within a millisecond of each other — 8.3 ms against 9.3 for a tick — and Bun still holds about half the memory. Pick on the axis that matters to you; the bytes on the wire are the same either way.
npm test # node --test
bun test # the same 98
98 of them, and the ones worth reading are test/proto.test.js — the spec's
own §8 example, 150 bytes, byte for byte — test/apply.test.js, which is a
client in forty lines that applies the server's ops and compares the tree it
ends up holding with the server's own, over every permutation of five keyed
rows and four hundred and eighty random edits, and test/session.test.js,
which runs a real application on a real socket and counts the ops a click
costs.
MIT.