eui-ruby 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +31 -0
- data/LICENSE +21 -0
- data/README.md +192 -0
- data/examples/counter.rb +65 -0
- data/lib/eui/app.rb +83 -0
- data/lib/eui/assets.rb +76 -0
- data/lib/eui/blake3.rb +216 -0
- data/lib/eui/component.rb +105 -0
- data/lib/eui/dsl.rb +166 -0
- data/lib/eui/errors.rb +19 -0
- data/lib/eui/manifest.rb +109 -0
- data/lib/eui/proto/frame.rb +243 -0
- data/lib/eui/proto/limits.rb +47 -0
- data/lib/eui/proto/node.rb +385 -0
- data/lib/eui/proto/op.rb +196 -0
- data/lib/eui/proto/reader.rb +125 -0
- data/lib/eui/proto/style.rb +250 -0
- data/lib/eui/proto/writer.rb +89 -0
- data/lib/eui/proto.rb +22 -0
- data/lib/eui/server.rb +175 -0
- data/lib/eui/session.rb +289 -0
- data/lib/eui/theme.rb +73 -0
- data/lib/eui/version.rb +5 -0
- data/lib/eui/view/diff.rb +212 -0
- data/lib/eui/view/style.rb +198 -0
- data/lib/eui/view/tree.rb +378 -0
- data/lib/eui/websocket.rb +195 -0
- data/lib/eui-ruby.rb +4 -0
- data/lib/eui.rb +32 -0
- metadata +80 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 59195020fcb69b8505cacd7ea0e69a2b6e1b62aee5d643d12b23cf5e28d59c44
|
|
4
|
+
data.tar.gz: 4d5f6ab4418724e05f811cf421face93e14f80771d18d2b0489d40b3b1552610
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c916bad7e89041aa084bde702f29e5c53752b71d2ba6293dad7097b9017c1ef1ffadc4ad87f4303e705b297628bbc2de3b53203c4b1cd4963e6b14bcd853e912
|
|
7
|
+
data.tar.gz: 224ee331e613b7d5435f51c4a87b91f46295f1bbb82babb0ca9ef9b0b3d0513f8a02a3a97a8e79594666d8d7573175dbd3e03c7b9bb2f880f6b0f5c34b1a28de
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
The first cut: enough to write an EUI application in Ruby and have the
|
|
6
|
+
reference client draw it.
|
|
7
|
+
|
|
8
|
+
- `EUI::Proto` — the wire format of `spec/02`: varints, the 64-byte style
|
|
9
|
+
record, nodes, values, handlers, ops, batches and every session frame,
|
|
10
|
+
checked against the byte vectors the spec pins.
|
|
11
|
+
- `EUI::View` — a view hash compiled into interned atoms, styles and
|
|
12
|
+
colours, and diffed against the tree the client holds: keyed children
|
|
13
|
+
reconcile by `MoveChild`, and one changed word is one `SetText`.
|
|
14
|
+
- `EUI::Session` / `EUI::Server` — the HTTP and WebSocket halves of
|
|
15
|
+
`spec/01`: manifest, content-addressed assets, and a session that
|
|
16
|
+
welcomes, mounts, patches, answers a ping and rebuilds on a resync.
|
|
17
|
+
- `EUI::Component` — state, handlers, and a view that is a function of the
|
|
18
|
+
state.
|
|
19
|
+
- `EUI::Blake3` — BLAKE3 in Ruby, because an asset is named by the hash of
|
|
20
|
+
its content.
|
|
21
|
+
- `EUI::Manifest` — the signed `EUIM` record, with an Ed25519 publisher key
|
|
22
|
+
kept on disk.
|
|
23
|
+
- `bench/` — the same application in Ruby and in Soli, and a driver that
|
|
24
|
+
measures both until the server goes quiet. Writing it found two of this
|
|
25
|
+
gem's own faults: a quadratic keyed reconciliation (a 10 000-row sort took
|
|
26
|
+
17.7 s; a Fenwick tree made it 0.8 s) and a style record compiled once per
|
|
27
|
+
node rather than once per distinct style (a 50 000-node render went from
|
|
28
|
+
2.9 s to 0.6 s).
|
|
29
|
+
|
|
30
|
+
Not yet: local handlers (`spec/07` bytecode), file transfers (`spec/01`
|
|
31
|
+
§6), session resume, and the windowed `list`'s `window` event.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Olivier Bonnaure
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# eui-ruby
|
|
2
|
+
|
|
3
|
+
EUI applications, written in Ruby.
|
|
4
|
+
|
|
5
|
+
[EUI](https://github.com/solisoft/eui) delivers an application interface over
|
|
6
|
+
HTTPS without HTML, CSS or JavaScript. The server sends a tree that is
|
|
7
|
+
**already resolved**, in a compact binary encoding; a native client applies
|
|
8
|
+
it, lays it out and draws it on the GPU. There is no tolerant parse at the
|
|
9
|
+
other end, no cascade to resolve, no script to run.
|
|
10
|
+
|
|
11
|
+
This gem is the server half: the wire format, the view encoder and its diff,
|
|
12
|
+
the session, the content-addressed asset store, the signed manifest, and a
|
|
13
|
+
component model in which a view is a hash and a handler changes state.
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
require "eui"
|
|
17
|
+
|
|
18
|
+
class Counter < EUI::Component
|
|
19
|
+
def mount(params)
|
|
20
|
+
super # the window, as it is right now
|
|
21
|
+
@count = 0
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
on("increment") { @count += 1 }
|
|
25
|
+
|
|
26
|
+
def render
|
|
27
|
+
column(gap: 5, align: "center", justify: "center",
|
|
28
|
+
width: "100%", height: "100%", bg: "surface.base") do
|
|
29
|
+
[text(@count.to_s, size: "4xl", weight: "bold"),
|
|
30
|
+
button("Increment", "increment")]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
app = EUI::App.new(name: "Counter", app_id: "counter.example")
|
|
36
|
+
app.mount("counter", Counter)
|
|
37
|
+
app.run(port: 5099)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
EUI_ALLOW_INSECURE_LOOPBACK=1 eui ws://127.0.0.1:5099/_eui/session/counter
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Pressing the button sends **one** `SetText`. Not a page, not a diffed DOM,
|
|
45
|
+
not a frame of JSON: the style records went once, at mount, and every later
|
|
46
|
+
render names them by id.
|
|
47
|
+
|
|
48
|
+
## What a view is
|
|
49
|
+
|
|
50
|
+
A hash, and a pure function of the state. `EUI::DSL` — included in every
|
|
51
|
+
component — builds them, but there is nothing behind the helpers: print one
|
|
52
|
+
and you have the whole document.
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
{"k" => "box", "s" => {"display" => "column", "gap" => 4},
|
|
56
|
+
"c" => [{"k" => "text", "t" => "Hi", "s" => {"size" => "lg"}}]}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
- **`k`** one of the seventeen primitive kinds. Everything a person would
|
|
60
|
+
call a widget — button, dialog, table, date picker — is composed from
|
|
61
|
+
these on the server, which is why the catalogue grows without shipping a
|
|
62
|
+
new client.
|
|
63
|
+
- **`s`** a flat style hash in the spec's own vocabulary. An unknown key is
|
|
64
|
+
an error, not a key that does nothing.
|
|
65
|
+
- **`c`** children, **`t`** text, **`p`** props, **`on`** handlers,
|
|
66
|
+
**`key`** identity for reconciliation.
|
|
67
|
+
|
|
68
|
+
**[eui.solisoft.net/components](https://eui.solisoft.net/components) is the
|
|
69
|
+
reference for all of it** — every node key, all seventeen kinds, every style
|
|
70
|
+
key and all thirty-three colour roles, the event names, and the catalogue of
|
|
71
|
+
composed widgets, each one shown with the hash it returns. It is written
|
|
72
|
+
against Soli, and the vocabulary is the protocol's, so a style hash or a node
|
|
73
|
+
on that page is the same style hash and the same node here. The rest of the
|
|
74
|
+
site is worth the visit too: [the running demo](https://eui.solisoft.net/demo),
|
|
75
|
+
[the controls](https://eui.solisoft.net/controls) every widget is built from,
|
|
76
|
+
and [what is not there yet](https://eui.solisoft.net/gaps).
|
|
77
|
+
|
|
78
|
+
Colour is a **role** — `surface.raised`, `text.muted`, `danger.base` — never
|
|
79
|
+
a literal. The client resolves it against the viewer's theme, so the page is
|
|
80
|
+
right in dark mode *without this server ever learning which mode they are
|
|
81
|
+
in*. Sizes are scale indices, by index or by name: `size: "lg"`, `gap: 4`,
|
|
82
|
+
`radius: "md"`.
|
|
83
|
+
|
|
84
|
+
What this gem ships is the primitives plus a few composed widgets — `button`,
|
|
85
|
+
`card`, `field`, `divider`, `spacer`. Anything else in the catalogue is a
|
|
86
|
+
function that returns a hash, so it ports to Ruby by writing the same hash.
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
column(gap: 4, pad: 6, bg: "surface.base") do
|
|
90
|
+
[ text("Total", size: "sm", fg: "text.muted"),
|
|
91
|
+
text(format("%.2f", @total), size: "2xl", font: "mono"),
|
|
92
|
+
row(gap: 3) { [button("Save", "save"), button("Delete", "delete", tone: "danger")] },
|
|
93
|
+
divider,
|
|
94
|
+
keyed("row-#{id}", card { [text(name)] }) ]
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## What a handler is
|
|
99
|
+
|
|
100
|
+
A block, or a method, named by the **view** rather than by the event kind —
|
|
101
|
+
`{"on" => {"wake" => "tick"}}` arrives as `tick`. It changes state and
|
|
102
|
+
returns; it never touches the tree.
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
on("pick") { |params| @selected = params["props"]["id"] }
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`params` carries `node`, `kind`, `payload` and `props` — the node's props as
|
|
109
|
+
the server last rendered them. That last one is what lets one handler serve
|
|
110
|
+
ten thousand rows: put the identifying value on the node, not in the
|
|
111
|
+
handler's name.
|
|
112
|
+
|
|
113
|
+
A handler that raises leaves the state unchanged and the screen right; it is
|
|
114
|
+
a line in the log, not the end of somebody's session. A **view** that cannot
|
|
115
|
+
be encoded is the other way round: it fails identically on every later
|
|
116
|
+
render, so the session ends with `Error 400` and the reason.
|
|
117
|
+
|
|
118
|
+
## Running it
|
|
119
|
+
|
|
120
|
+
| | |
|
|
121
|
+
|---|---|
|
|
122
|
+
| `app.run(port: 5099)` | plain `ws://` on loopback, for development |
|
|
123
|
+
| `app.run(host: "0.0.0.0", port: 443, tls: {cert:, key:})` | TLS 1.3, which is the protocol's floor |
|
|
124
|
+
|
|
125
|
+
A release client refuses `ws://` outright; a debug one takes
|
|
126
|
+
`EUI_ALLOW_INSECURE_LOOPBACK=1`. `EUI_TRACE=1` on the server prints every
|
|
127
|
+
frame and every event a session sees, which is the first thing to reach for
|
|
128
|
+
when a click does nothing.
|
|
129
|
+
|
|
130
|
+
Three endpoints, and nothing else: `/.well-known/eui` (the signed manifest),
|
|
131
|
+
`/_eui/asset/<blake3-hex>` (content-addressed, immutable, served to anyone),
|
|
132
|
+
and the session itself.
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
app = EUI::App.new(name: "Books", app_id: "books.example",
|
|
136
|
+
key_path: "config/eui_publisher.pem",
|
|
137
|
+
capabilities: %w[net.open])
|
|
138
|
+
app.font("Space Grotesk", ["public/fonts/space-grotesk-400.ttf",
|
|
139
|
+
"public/fonts/space-grotesk-700.ttf"])
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The publisher key is generated on first use and kept: a client pins it
|
|
143
|
+
against `app_id` on first run and refuses a different one later. It belongs
|
|
144
|
+
to the application, not to a deployment, and it never belongs in a
|
|
145
|
+
repository.
|
|
146
|
+
|
|
147
|
+
## What is here, and what is not
|
|
148
|
+
|
|
149
|
+
Implemented: the whole wire format of `spec/02` (checked against the byte
|
|
150
|
+
vectors that document pins), the session frames of `spec/01`, the view
|
|
151
|
+
encoder with its append-only tables, a keyed diff that reorders by
|
|
152
|
+
`MoveChild`, assets, fonts, notifications, `scroll_to` and `focus_to`, the
|
|
153
|
+
manifest, and BLAKE3 in Ruby because an asset is named by the hash of its
|
|
154
|
+
content.
|
|
155
|
+
|
|
156
|
+
Not yet:
|
|
157
|
+
|
|
158
|
+
- **Local handlers** (`spec/07`). A handler is a server round trip; the
|
|
159
|
+
bytecode a client runs for itself — the hover that answers without a
|
|
160
|
+
packet — is the next milestone.
|
|
161
|
+
- **File transfers** (`spec/01` §6): `Upload` and `Blob` are decoded and
|
|
162
|
+
dropped.
|
|
163
|
+
- **Session resume** (`spec/01` §4.1): a socket that breaks gets a fresh
|
|
164
|
+
session and a fresh `Mount`, which is a conforming answer and a worse one.
|
|
165
|
+
- The windowed `list`'s `window` event, and `scene` uniforms.
|
|
166
|
+
|
|
167
|
+
## Tests
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
rake test
|
|
171
|
+
# or, with nothing installed but Ruby itself:
|
|
172
|
+
ruby -Ilib -Itest -e 'Dir["test/**/*_test.rb"].each { |f| require File.expand_path(f) }'
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
61 of them, and the ones worth reading are `test/proto_test.rb` — the spec's
|
|
176
|
+
own §8 example, 150 bytes, byte for byte — and `test/session_test.rb`, which
|
|
177
|
+
runs a real application on a real socket and counts the ops a click costs.
|
|
178
|
+
|
|
179
|
+
## Against the other four
|
|
180
|
+
|
|
181
|
+
The same application, written five times — in Soli, Ruby, Python, PHP and
|
|
182
|
+
JavaScript, node for node. [`bench/`](bench/) holds all five and the one
|
|
183
|
+
driver that measures them, each phase timed until the server goes quiet. At
|
|
184
|
+
500 rows, one number changed: **9 bytes** out of every one of them, 12 ms in
|
|
185
|
+
Soli, 24 ms here, 9 ms in Node. The bytes being identical is the protocol's
|
|
186
|
+
claim; the rest ranks by JIT, and the memory column ranks what else is in the
|
|
187
|
+
process. [`bench/README.md`](bench/README.md) has the tables, the method and
|
|
188
|
+
the caveats.
|
|
189
|
+
|
|
190
|
+
## Licence
|
|
191
|
+
|
|
192
|
+
MIT.
|
data/examples/counter.rb
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The counter, as an EUI application in Ruby.
|
|
4
|
+
#
|
|
5
|
+
# ruby -Ilib examples/counter.rb
|
|
6
|
+
# EUI_ALLOW_INSECURE_LOOPBACK=1 eui ws://127.0.0.1:5099/_eui/session/counter
|
|
7
|
+
#
|
|
8
|
+
# What to look at: `render` is a pure function of `@count`, and pressing a
|
|
9
|
+
# button sends one `SetText` — not a page, not a diffed DOM, not a frame of
|
|
10
|
+
# JSON. The style records were sent once, at mount, and every later render
|
|
11
|
+
# references them by id.
|
|
12
|
+
|
|
13
|
+
require_relative '../lib/eui'
|
|
14
|
+
|
|
15
|
+
class Counter < EUI::Component
|
|
16
|
+
def mount(params)
|
|
17
|
+
super
|
|
18
|
+
@count = 0
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
on('increment') { @count += 1 }
|
|
22
|
+
on('decrement') { @count -= 1 }
|
|
23
|
+
on('reset') { @count = 0 }
|
|
24
|
+
|
|
25
|
+
def render
|
|
26
|
+
column(
|
|
27
|
+
display: 'column', justify: 'center', align: 'center', gap: 7,
|
|
28
|
+
bg: 'surface.base', width: '100%', height: '100%', pad: 8
|
|
29
|
+
) do
|
|
30
|
+
[
|
|
31
|
+
text('COUNTER', size: 'sm', weight: 'semibold', fg: 'text.muted', font: 'mono'),
|
|
32
|
+
text(@count.to_s, size: '4xl', weight: 'bold', fg: tone),
|
|
33
|
+
row(gap: 4) do
|
|
34
|
+
[
|
|
35
|
+
button('−', 'decrement', tone: 'quiet', size: 'lg'),
|
|
36
|
+
button('Reset', 'reset', tone: 'quiet'),
|
|
37
|
+
button('+', 'increment', size: 'lg')
|
|
38
|
+
]
|
|
39
|
+
end,
|
|
40
|
+
divider(width: 320),
|
|
41
|
+
text(footnote, size: 'xs', fg: 'text.muted', font: 'mono')
|
|
42
|
+
]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Colour by what the number *is*, so it reads as a legend rather than
|
|
49
|
+
# decoration — and reads right in either theme, because the client
|
|
50
|
+
# resolves the role and this server never learns which one they are in.
|
|
51
|
+
def tone
|
|
52
|
+
return 'danger.base' if @count.negative?
|
|
53
|
+
return 'text.muted' if @count.zero?
|
|
54
|
+
|
|
55
|
+
'success.base'
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def footnote
|
|
59
|
+
"#{width} × #{height} · #{@viewport['mode'] || 'light'}"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
app = EUI::App.new(name: 'Counter', app_id: 'counter.eui-ruby')
|
|
64
|
+
app.mount('counter', Counter)
|
|
65
|
+
app.run(port: Integer(ENV.fetch('PORT', '5099')))
|
data/lib/eui/app.rb
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'assets'
|
|
4
|
+
require_relative 'manifest'
|
|
5
|
+
require_relative 'server'
|
|
6
|
+
|
|
7
|
+
module EUI
|
|
8
|
+
# An application: the components it serves, the assets they name, and the
|
|
9
|
+
# manifest that says who published it.
|
|
10
|
+
#
|
|
11
|
+
# app = EUI::App.new(name: "Counter", app_id: "counter.example")
|
|
12
|
+
# app.mount("counter", Counter)
|
|
13
|
+
# app.run(port: 5012)
|
|
14
|
+
#
|
|
15
|
+
# The first component mounted is the one a bare origin opens, because the
|
|
16
|
+
# protocol has one entry and a server here has many: `wss://host` has to
|
|
17
|
+
# mean something, and the first one in the file is what a person reading
|
|
18
|
+
# it top to bottom would say.
|
|
19
|
+
class App
|
|
20
|
+
attr_reader :components, :assets, :name, :app_id
|
|
21
|
+
|
|
22
|
+
def initialize(name:, app_id: nil, version: '0.1.0', root: Dir.pwd,
|
|
23
|
+
key_path: nil, capabilities: [], logger: nil)
|
|
24
|
+
@name = name
|
|
25
|
+
@app_id = app_id || name.downcase.gsub(/[^a-z0-9]+/, '-')
|
|
26
|
+
@version = version
|
|
27
|
+
@root = root
|
|
28
|
+
@assets = Assets.new(root: root)
|
|
29
|
+
@components = {}
|
|
30
|
+
@capabilities = capabilities
|
|
31
|
+
@key_path = key_path
|
|
32
|
+
@logger = logger
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def mount(path, component_class)
|
|
36
|
+
name = path.to_s.delete_prefix('/')
|
|
37
|
+
@components[name] = component_class
|
|
38
|
+
@default ||= name
|
|
39
|
+
self
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def component_for(name)
|
|
43
|
+
return @components[@default] if name.nil?
|
|
44
|
+
|
|
45
|
+
@components[name]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The face an application draws in, bound to a font role and sent as
|
|
49
|
+
# content-addressed assets — so the window talks to no font service and
|
|
50
|
+
# opens no connection the session did not.
|
|
51
|
+
#
|
|
52
|
+
# Declaring one at boot is what takes the manifest's floor to EUI 4;
|
|
53
|
+
# calling it later falls back to `sans` for the sessions already open
|
|
54
|
+
# rather than ending them.
|
|
55
|
+
def font(family, paths)
|
|
56
|
+
hashes = Array(paths).map { |path| @assets.add_file(path) }
|
|
57
|
+
@fonts ||= {}
|
|
58
|
+
@fonts[family] = hashes
|
|
59
|
+
family
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def fonts = @fonts || {}
|
|
63
|
+
|
|
64
|
+
# The signed record at `/.well-known/eui`. Without a key path there is
|
|
65
|
+
# no manifest, which a debug client on loopback accepts and a release
|
|
66
|
+
# client does not.
|
|
67
|
+
def manifest
|
|
68
|
+
return nil unless @key_path
|
|
69
|
+
|
|
70
|
+
@manifest ||= Manifest.new(
|
|
71
|
+
app_id: @app_id, name: @name, version: @version,
|
|
72
|
+
key: Manifest.publisher_key(@key_path),
|
|
73
|
+
entry: "/_eui/session/#{@default}",
|
|
74
|
+
capabilities: @capabilities,
|
|
75
|
+
protocol_min: 1, protocol_max: Proto::PROTOCOL_VERSION
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def run(host: '127.0.0.1', port: 5012, tls: nil)
|
|
80
|
+
Server.new(self, host: host, port: port, tls: tls, logger: @logger).start
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
data/lib/eui/assets.rb
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'blake3'
|
|
4
|
+
require_relative 'errors'
|
|
5
|
+
|
|
6
|
+
module EUI
|
|
7
|
+
# The content-addressed store behind `/_eui/asset/<blake3-hex>`.
|
|
8
|
+
#
|
|
9
|
+
# An asset is named by the hash of its bytes, so the name *is* the
|
|
10
|
+
# content: the client recomputes it and discards a mismatch, a proxy may
|
|
11
|
+
# serve it to anyone, and `immutable` is always the right cache header. A
|
|
12
|
+
# file somebody uploaded is not an asset — that travels in the session
|
|
13
|
+
# (`spec/01-transport.md` §6), because it is one person's and not the
|
|
14
|
+
# same for everyone.
|
|
15
|
+
class Assets
|
|
16
|
+
TYPES = {
|
|
17
|
+
'.png' => 'image/png', '.jpg' => 'image/jpeg', '.jpeg' => 'image/jpeg',
|
|
18
|
+
'.gif' => 'image/gif', '.webp' => 'image/webp', '.svg' => 'image/svg+xml',
|
|
19
|
+
'.ttf' => 'font/ttf', '.otf' => 'font/otf', '.woff2' => 'font/woff2',
|
|
20
|
+
'.wav' => 'audio/wav', '.mp3' => 'audio/mpeg', '.ogg' => 'audio/ogg',
|
|
21
|
+
'.mp4' => 'video/mp4', '.wgsl' => 'text/wgsl'
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
Entry = Struct.new(:bytes, :content_type)
|
|
25
|
+
|
|
26
|
+
def initialize(root: Dir.pwd)
|
|
27
|
+
@root = File.expand_path(root)
|
|
28
|
+
@by_hash = {}
|
|
29
|
+
@by_path = {}
|
|
30
|
+
@lock = Mutex.new
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Take a file into the store and answer its hash. Cheap to call on
|
|
34
|
+
# every render: a path whose mtime and size have not moved is not read
|
|
35
|
+
# again.
|
|
36
|
+
def add_file(path)
|
|
37
|
+
full = File.expand_path(path, @root)
|
|
38
|
+
unless full.start_with?(@root + File::SEPARATOR) || full == @root
|
|
39
|
+
raise ViewError, "an asset must live under #{@root}, got #{path}"
|
|
40
|
+
end
|
|
41
|
+
raise ViewError, "no such asset: #{path}" unless File.file?(full)
|
|
42
|
+
|
|
43
|
+
stat = File.stat(full)
|
|
44
|
+
stamp = [stat.mtime.to_f, stat.size]
|
|
45
|
+
@lock.synchronize do
|
|
46
|
+
cached = @by_path[full]
|
|
47
|
+
return cached[1] if cached && cached[0] == stamp
|
|
48
|
+
|
|
49
|
+
bytes = File.binread(full)
|
|
50
|
+
hash = Blake3.digest(bytes)
|
|
51
|
+
@by_hash[hash] = Entry.new(bytes, TYPES.fetch(File.extname(full).downcase, 'application/octet-stream'))
|
|
52
|
+
@by_path[full] = [stamp, hash]
|
|
53
|
+
hash
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Bytes that have no file — a picture out of a database, a chart this
|
|
58
|
+
# process drew — reach a window the same way.
|
|
59
|
+
def add_bytes(bytes, content_type: 'application/octet-stream')
|
|
60
|
+
bytes = bytes.b
|
|
61
|
+
hash = Blake3.digest(bytes)
|
|
62
|
+
@lock.synchronize { @by_hash[hash] = Entry.new(bytes, content_type) }
|
|
63
|
+
hash
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def fetch(hex)
|
|
67
|
+
return nil unless /\A[0-9a-f]{64}\z/.match?(hex)
|
|
68
|
+
|
|
69
|
+
@lock.synchronize { @by_hash[[hex].pack('H*')] }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def size = @lock.synchronize { @by_hash.size }
|
|
73
|
+
|
|
74
|
+
def self.hex(hash) = hash.unpack1('H*')
|
|
75
|
+
end
|
|
76
|
+
end
|
data/lib/eui/blake3.rb
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EUI
|
|
4
|
+
# BLAKE3, in Ruby, because an asset is named by the hash of its content
|
|
5
|
+
# and the client recomputes it (`spec/01-transport.md` §2.2).
|
|
6
|
+
#
|
|
7
|
+
# Only the plain hash: no keyed mode, no key derivation, no extendable
|
|
8
|
+
# output past 32 bytes. That is every use the protocol has for it — an
|
|
9
|
+
# asset's name — and each of the others is a footgun this library would
|
|
10
|
+
# rather not carry.
|
|
11
|
+
module Blake3
|
|
12
|
+
OUT_LEN = 32
|
|
13
|
+
BLOCK_LEN = 64
|
|
14
|
+
CHUNK_LEN = 1024
|
|
15
|
+
|
|
16
|
+
CHUNK_START = 1 << 0
|
|
17
|
+
CHUNK_END = 1 << 1
|
|
18
|
+
PARENT = 1 << 2
|
|
19
|
+
ROOT = 1 << 3
|
|
20
|
+
|
|
21
|
+
IV = [
|
|
22
|
+
0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A,
|
|
23
|
+
0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
MSG_PERMUTATION = [2, 6, 3, 10, 7, 0, 4, 13, 1, 11, 12, 5, 9, 14, 15, 8].freeze
|
|
27
|
+
|
|
28
|
+
MASK = 0xFFFF_FFFF
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
def hexdigest(data) = digest(data).unpack1('H*')
|
|
32
|
+
|
|
33
|
+
def digest(data)
|
|
34
|
+
hasher = Hasher.new
|
|
35
|
+
hasher.update(data)
|
|
36
|
+
hasher.digest
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def file(path)
|
|
40
|
+
hasher = Hasher.new
|
|
41
|
+
File.open(path, 'rb') do |io|
|
|
42
|
+
while (block = io.read(65_536))
|
|
43
|
+
hasher.update(block)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
hasher.digest
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def rotr(x, n) = ((x >> n) | (x << (32 - n))) & MASK
|
|
50
|
+
|
|
51
|
+
def g(state, a, b, c, d, mx, my)
|
|
52
|
+
state[a] = (state[a] + state[b] + mx) & MASK
|
|
53
|
+
state[d] = rotr(state[d] ^ state[a], 16)
|
|
54
|
+
state[c] = (state[c] + state[d]) & MASK
|
|
55
|
+
state[b] = rotr(state[b] ^ state[c], 12)
|
|
56
|
+
state[a] = (state[a] + state[b] + my) & MASK
|
|
57
|
+
state[d] = rotr(state[d] ^ state[a], 8)
|
|
58
|
+
state[c] = (state[c] + state[d]) & MASK
|
|
59
|
+
state[b] = rotr(state[b] ^ state[c], 7)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def round(state, m)
|
|
63
|
+
g(state, 0, 4, 8, 12, m[0], m[1])
|
|
64
|
+
g(state, 1, 5, 9, 13, m[2], m[3])
|
|
65
|
+
g(state, 2, 6, 10, 14, m[4], m[5])
|
|
66
|
+
g(state, 3, 7, 11, 15, m[6], m[7])
|
|
67
|
+
g(state, 0, 5, 10, 15, m[8], m[9])
|
|
68
|
+
g(state, 1, 6, 11, 12, m[10], m[11])
|
|
69
|
+
g(state, 2, 7, 8, 13, m[12], m[13])
|
|
70
|
+
g(state, 3, 4, 9, 14, m[14], m[15])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The one primitive: a chaining value and a block in, sixteen words
|
|
74
|
+
# out. The first eight are the next chaining value; all sixteen are
|
|
75
|
+
# the root's output.
|
|
76
|
+
def compress(cv, block, counter, block_len, flags)
|
|
77
|
+
state = [
|
|
78
|
+
cv[0], cv[1], cv[2], cv[3], cv[4], cv[5], cv[6], cv[7],
|
|
79
|
+
IV[0], IV[1], IV[2], IV[3],
|
|
80
|
+
counter & MASK, (counter >> 32) & MASK, block_len, flags
|
|
81
|
+
]
|
|
82
|
+
m = block
|
|
83
|
+
7.times do |r|
|
|
84
|
+
round(state, m)
|
|
85
|
+
m = MSG_PERMUTATION.map { |i| m[i] } if r < 6
|
|
86
|
+
end
|
|
87
|
+
8.times do |i|
|
|
88
|
+
state[i] ^= state[i + 8]
|
|
89
|
+
state[i + 8] ^= cv[i]
|
|
90
|
+
end
|
|
91
|
+
state
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def words_of(block)
|
|
95
|
+
block = block.ljust(BLOCK_LEN, "\x00") if block.bytesize < BLOCK_LEN
|
|
96
|
+
block.unpack('V16')
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def parent_output(left, right, flags)
|
|
100
|
+
Output.new(IV.dup, left + right, 0, BLOCK_LEN, PARENT | flags)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def parent_cv(left, right, flags) = parent_output(left, right, flags).chaining_value
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# A node's output, before anybody has decided whether it is the root.
|
|
107
|
+
# Which it is changes the flags, and so changes the bytes: that is what
|
|
108
|
+
# keeps a chunk's hash from being a tree's hash.
|
|
109
|
+
Output = Struct.new(:input_cv, :block_words, :counter, :block_len, :flags) do
|
|
110
|
+
def chaining_value
|
|
111
|
+
Blake3.compress(input_cv, block_words, counter, block_len, flags)[0, 8]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def root_bytes(length = OUT_LEN)
|
|
115
|
+
out = +''
|
|
116
|
+
counter = 0
|
|
117
|
+
while out.bytesize < length
|
|
118
|
+
words = Blake3.compress(input_cv, block_words, counter, block_len, flags | ROOT)
|
|
119
|
+
out << words.pack('V16')
|
|
120
|
+
counter += 1
|
|
121
|
+
end
|
|
122
|
+
out.byteslice(0, length)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# One chunk of at most 1024 bytes, compressed a block at a time.
|
|
127
|
+
class ChunkState
|
|
128
|
+
attr_reader :chunk_counter
|
|
129
|
+
|
|
130
|
+
def initialize(key, chunk_counter, flags)
|
|
131
|
+
@cv = key.dup
|
|
132
|
+
@chunk_counter = chunk_counter
|
|
133
|
+
@block = +''
|
|
134
|
+
@block.force_encoding(Encoding::BINARY)
|
|
135
|
+
@blocks_compressed = 0
|
|
136
|
+
@flags = flags
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def length = (BLOCK_LEN * @blocks_compressed) + @block.bytesize
|
|
140
|
+
|
|
141
|
+
def start_flag = @blocks_compressed.zero? ? CHUNK_START : 0
|
|
142
|
+
|
|
143
|
+
def update(input)
|
|
144
|
+
offset = 0
|
|
145
|
+
while offset < input.bytesize
|
|
146
|
+
if @block.bytesize == BLOCK_LEN
|
|
147
|
+
@cv = Blake3.compress(@cv, Blake3.words_of(@block), @chunk_counter, BLOCK_LEN, @flags | start_flag)[0, 8]
|
|
148
|
+
@blocks_compressed += 1
|
|
149
|
+
@block = +''
|
|
150
|
+
@block.force_encoding(Encoding::BINARY)
|
|
151
|
+
end
|
|
152
|
+
want = BLOCK_LEN - @block.bytesize
|
|
153
|
+
take = [want, input.bytesize - offset].min
|
|
154
|
+
@block << input.byteslice(offset, take)
|
|
155
|
+
offset += take
|
|
156
|
+
end
|
|
157
|
+
self
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def output
|
|
161
|
+
Output.new(@cv, Blake3.words_of(@block), @chunk_counter, @block.bytesize, @flags | start_flag | CHUNK_END)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# The streaming hasher. Chunks are merged into a binary tree as they
|
|
166
|
+
# complete, so hashing a 200 MB file costs a stack of at most 54
|
|
167
|
+
# chaining values.
|
|
168
|
+
class Hasher
|
|
169
|
+
def initialize(key: IV.dup, flags: 0)
|
|
170
|
+
@key = key
|
|
171
|
+
@flags = flags
|
|
172
|
+
@chunk = ChunkState.new(key, 0, flags)
|
|
173
|
+
@stack = []
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def update(input)
|
|
177
|
+
input = input.b
|
|
178
|
+
offset = 0
|
|
179
|
+
while offset < input.bytesize
|
|
180
|
+
if @chunk.length == CHUNK_LEN
|
|
181
|
+
add_chunk(@chunk.output.chaining_value, @chunk.chunk_counter + 1)
|
|
182
|
+
@chunk = ChunkState.new(@key, @chunk.chunk_counter + 1, @flags)
|
|
183
|
+
end
|
|
184
|
+
want = CHUNK_LEN - @chunk.length
|
|
185
|
+
take = [want, input.bytesize - offset].min
|
|
186
|
+
@chunk.update(input.byteslice(offset, take))
|
|
187
|
+
offset += take
|
|
188
|
+
end
|
|
189
|
+
self
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def digest(length = OUT_LEN)
|
|
193
|
+
output = @chunk.output
|
|
194
|
+
@stack.reverse_each do |left|
|
|
195
|
+
output = Blake3.parent_output(left, output.chaining_value, @flags)
|
|
196
|
+
end
|
|
197
|
+
output.root_bytes(length)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def hexdigest(length = OUT_LEN) = digest(length).unpack1('H*')
|
|
201
|
+
|
|
202
|
+
private
|
|
203
|
+
|
|
204
|
+
# A chunk's chaining value joins the tree, merging with everything to
|
|
205
|
+
# its left that is now complete — which is what the low bits of the
|
|
206
|
+
# chunk count say.
|
|
207
|
+
def add_chunk(cv, total_chunks)
|
|
208
|
+
while (total_chunks & 1).zero?
|
|
209
|
+
cv = Blake3.parent_cv(@stack.pop, cv, @flags)
|
|
210
|
+
total_chunks >>= 1
|
|
211
|
+
end
|
|
212
|
+
@stack.push(cv)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|