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.
@@ -0,0 +1,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require_relative 'proto'
5
+ require_relative 'view/tree'
6
+ require_relative 'view/diff'
7
+ require_relative 'websocket'
8
+
9
+ module EUI
10
+ # One socket, one component instance, one tree.
11
+ #
12
+ # The shape is the whole protocol in twenty lines: the client says Hello,
13
+ # the server answers Welcome and mounts a tree, and from then on every
14
+ # event is a handler, a render, and the *difference* between what the
15
+ # client holds and what the view now says.
16
+ class Session
17
+ # A client that connects and says nothing is not a client.
18
+ HELLO_TIMEOUT = 10
19
+ # Whichever side has been silent for this long sends a Ping. Nothing
20
+ # else wakes: the zero-wakeup idle budget is a property of that rule.
21
+ IDLE_PING = 30
22
+ # Two unanswered pings and the socket is gone, whatever it still says.
23
+ MAX_UNANSWERED_PINGS = 2
24
+ # The code on the `Error` that ends a session the application itself
25
+ # closed. Codes 1–8 are the decoder's and 100–104 the client's; this is
26
+ # a server's, and it says the session ended on purpose.
27
+ CLOSED_BY_APPLICATION = 200
28
+
29
+ attr_reader :id, :component, :viewport, :granted, :protocol
30
+
31
+ def initialize(socket, component_class:, app:, logger: nil)
32
+ @ws = socket
33
+ @component_class = component_class
34
+ @app = app
35
+ @logger = logger
36
+ @id = SecureRandom.bytes(16)
37
+ @encoder = View::Encoder.new(assets: app&.assets)
38
+ @inbox = Thread::Queue.new
39
+ @seq = 0
40
+ @acked = 0
41
+ @protocol = Proto::PROTOCOL_VERSION
42
+ @granted = 0
43
+ @viewport = Proto::Viewport.default
44
+ @open = true
45
+ end
46
+
47
+ def run
48
+ hello = handshake or return
49
+
50
+ @protocol = [hello.version, Proto::PROTOCOL_VERSION].min
51
+ @granted = hello.granted
52
+ @viewport = hello.viewport
53
+ @encoder.protocol = @protocol
54
+ # A session that starts empty, which is every first Hello's answer.
55
+ # Resuming one whose socket broke is the server's to offer, and this
56
+ # one does not yet: a client that reconnects gets a fresh Mount.
57
+ send_frame(Proto::Frame.welcome(Proto::Welcome.new(@protocol, @id, false)))
58
+
59
+ # The faces this application draws in, bound to their roles before
60
+ # any view names one.
61
+ @app&.fonts&.each { |family, hashes| @encoder.font(family, hashes) }
62
+
63
+ @component = @component_class.new(session: self)
64
+ @component.mount({ 'viewport' => @viewport.to_h })
65
+ render!
66
+
67
+ reader = Thread.new { read_loop }
68
+ pump
69
+ reader.kill
70
+ @component.unmount
71
+ rescue WebSocket::ClosedError, WebSocket::ProtocolError => e
72
+ log("session ended: #{e.message}")
73
+ ensure
74
+ @open = false
75
+ @ws.close
76
+ end
77
+
78
+ # Render again although nothing arrived: a timer in the application, a
79
+ # message from another session, anything this process knows and the
80
+ # client does not.
81
+ def refresh!
82
+ @inbox << [:render] if @open
83
+ end
84
+
85
+ # Say one line to the person through the machine they are using. Shown
86
+ # only if they granted `notifications`, and nothing comes back either
87
+ # way — not that it was shown, not that it was not.
88
+ def notify(title, body: '', tag: '')
89
+ @inbox << [:notify, [title.to_s, body.to_s, tag.to_s]] if @open
90
+ end
91
+
92
+ def close(reason = 'the application closed the session')
93
+ @inbox << [:close, reason] if @open
94
+ end
95
+
96
+ def granted?(capability) = (@granted & Proto::Caps.bit(capability)) != 0
97
+
98
+ private
99
+
100
+ def handshake
101
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + HELLO_TIMEOUT
102
+ kind, bytes = @ws.recv
103
+ return nil if kind.nil?
104
+ if kind == :text
105
+ # A text frame is not an extension point; it is something that is
106
+ # not an EUI client.
107
+ @ws.send_close(1003, 'binary frames only')
108
+ return nil
109
+ end
110
+ return nil if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
111
+
112
+ frame = Proto::Frame.decode(bytes)
113
+ unless frame.kind == Proto::Frame::HELLO && frame.body.version >= 1
114
+ fail_session(400, 'the first frame is a Hello')
115
+ return nil
116
+ end
117
+ frame.body
118
+ rescue DecodeError => e
119
+ fail_session(400, e.message)
120
+ nil
121
+ end
122
+
123
+ def read_loop
124
+ while (message = @ws.recv)
125
+ kind, bytes = message
126
+ if kind == :text
127
+ @inbox << [:close, 'binary frames only']
128
+ break
129
+ end
130
+ @inbox << [:frame, bytes]
131
+ end
132
+ @inbox << [:eof]
133
+ rescue WebSocket::ProtocolError => e
134
+ @inbox << [:close, e.message]
135
+ rescue StandardError => e
136
+ log("read: #{e.class}: #{e.message}")
137
+ @inbox << [:eof]
138
+ end
139
+
140
+ # The one thread that writes. Everything that changes the tree comes
141
+ # through here, in the order it arrived, so two events never render on
142
+ # top of each other.
143
+ def pump
144
+ unanswered = 0
145
+ loop do
146
+ item = @inbox.pop(timeout: IDLE_PING)
147
+ if item.nil?
148
+ unanswered += 1
149
+ return if unanswered > MAX_UNANSWERED_PINGS
150
+
151
+ send_frame(Proto::Frame.ping(SecureRandom.bytes(8)))
152
+ next
153
+ end
154
+
155
+ what, payload = item
156
+ case what
157
+ when :eof then return
158
+ when :close
159
+ fail_session(CLOSED_BY_APPLICATION, payload.to_s)
160
+ return
161
+ when :render then render!
162
+ when :notify
163
+ send_batch([Proto::Op.notify(*payload)])
164
+ when :frame
165
+ unanswered = 0
166
+ return unless handle(payload)
167
+ end
168
+ end
169
+ end
170
+
171
+ def handle(bytes)
172
+ frame = Proto::Frame.decode(bytes)
173
+ trace { "frame kind 0x#{frame.kind.to_s(16)}" }
174
+ case frame.kind
175
+ when Proto::Frame::EVENT then dispatch(frame.body)
176
+ when Proto::Frame::ACK then @acked = frame.body
177
+ when Proto::Frame::PING then send_frame(Proto::Frame.pong(frame.body))
178
+ when Proto::Frame::PONG then nil
179
+ when Proto::Frame::VIEWPORT
180
+ @viewport = frame.body
181
+ post('viewport', { 'viewport' => @viewport.to_h })
182
+ when Proto::Frame::RESYNC
183
+ # Not an error, and never answered with one: the client's tree is
184
+ # unrecoverable and it wants the document again. The tables it
185
+ # already holds are not repeated — they were never cleared.
186
+ @encoder.forget_tree!
187
+ render!
188
+ when Proto::Frame::ERROR
189
+ log("client error #{frame.body[0]}: #{frame.body[1]}")
190
+ return false
191
+ when Proto::Frame::UPLOAD, Proto::Frame::BLOB
192
+ log('file transfers are not implemented yet; the chunk was dropped')
193
+ else
194
+ fail_session(400, 'that frame is the server\'s to send')
195
+ return false
196
+ end
197
+ true
198
+ rescue DecodeError => e
199
+ fail_session(400, e.message)
200
+ false
201
+ end
202
+
203
+ # An event on a node that carries no handler for it *now* is dropped.
204
+ # Usually that is a race rather than an attack — a handler a render
205
+ # removed is still in the client's tree for the one round trip it takes
206
+ # the new one to arrive — and nothing is looked up for it either way.
207
+ def dispatch(event)
208
+ target = @encoder.event_target(event.node, event.event)
209
+ unless target
210
+ trace { "event on node #{event.node} (#{Proto::EventKind.name(event.event)}) names no handler in the tree we last sent" }
211
+ return
212
+ end
213
+
214
+ name, props = target
215
+ trace { "event #{Proto::EventKind.name(event.event)} on node #{event.node} -> #{name}" }
216
+ post(name, {
217
+ 'node' => event.node,
218
+ 'kind' => Proto::EventKind.name(event.event),
219
+ 'payload' => resolve(event.payload),
220
+ 'props' => props
221
+ })
222
+ end
223
+
224
+ def post(name, params)
225
+ @component.handle(name, params)
226
+ render!
227
+ rescue ViewError
228
+ raise
229
+ rescue StandardError => e
230
+ # A handler that raised leaves the state unchanged and the screen
231
+ # right; the next click still works. It is a log line, not the end of
232
+ # somebody's session.
233
+ log("#{name}: #{e.class}: #{e.message}")
234
+ log(e.backtrace.first(3).join("\n")) if e.backtrace
235
+ end
236
+
237
+ def render!
238
+ view = @component.render
239
+ ops = @encoder.render(view)
240
+ send_batch(ops) unless ops.empty?
241
+ rescue ViewError => e
242
+ # A view that cannot be encoded fails the same way on every later
243
+ # render, and a server that only logged it would leave a window that
244
+ # looks alive and answers nothing.
245
+ log("view: #{e.message}")
246
+ fail_session(400, e.message)
247
+ @open = false
248
+ end
249
+
250
+ def send_batch(ops)
251
+ trace { "batch of #{ops.length}: #{ops.map { |o| format('0x%02X', o.opcode) }.join(' ')}" }
252
+ @seq += 1
253
+ send_frame(Proto::Frame.batch(Proto::Batch.new(@seq, ops)))
254
+ end
255
+
256
+ def send_frame(frame)
257
+ @ws.send_binary(frame.encode)
258
+ end
259
+
260
+ def fail_session(code, message)
261
+ send_frame(Proto::Frame.error(code, message))
262
+ @ws.send_close(1000, 'session ended')
263
+ rescue WebSocket::ClosedError
264
+ nil
265
+ end
266
+
267
+ # Atoms the client sent back are ids in *this* session's table, so they
268
+ # are resolved here rather than handed to a view as numbers.
269
+ def resolve(value)
270
+ case value.tag
271
+ when Proto::Value::ATOM then @encoder.atom_value(value.value)
272
+ when Proto::Value::LIST then value.value.map { |v| resolve(v) }
273
+ else value.to_ruby
274
+ end
275
+ end
276
+
277
+ def log(message)
278
+ @logger&.call("[EUI] #{message}")
279
+ end
280
+
281
+ # `EUI_TRACE=1` prints every frame and every event this session sees.
282
+ # The one thing worth watching when a click does nothing.
283
+ def trace
284
+ return unless ENV['EUI_TRACE']
285
+
286
+ log("trace: #{yield}")
287
+ end
288
+ end
289
+ end
data/lib/eui/theme.rb ADDED
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+
5
+ module EUI
6
+ # What a server is allowed to say about colour and size
7
+ # (`spec/05-theme.md`).
8
+ #
9
+ # The server never sends a colour. It sends a *role* and a *scale index*,
10
+ # and the client resolves both against the active theme and the viewer's
11
+ # own mode, density and font scale. Dark mode costs zero bytes, and this
12
+ # process never learns which one somebody is in.
13
+ module Theme
14
+ # The 33 colour roles, numbered once and for good. Ids 34.. are reserved
15
+ # and a client rejects them.
16
+ ROLES = {
17
+ 'surface.base' => 1, 'surface.raised' => 2, 'surface.sunken' => 3, 'surface.overlay' => 4,
18
+ 'text.default' => 5, 'text.muted' => 6, 'text.inverted' => 7, 'text.disabled' => 8,
19
+ 'accent.base' => 9, 'accent.hover' => 10, 'accent.active' => 11, 'accent.on' => 12,
20
+ 'success.base' => 13, 'success.subtle' => 14, 'success.on' => 15,
21
+ 'warning.base' => 16, 'warning.subtle' => 17, 'warning.on' => 18,
22
+ 'danger.base' => 19, 'danger.subtle' => 20, 'danger.on' => 21,
23
+ 'info.base' => 22, 'info.subtle' => 23, 'info.on' => 24,
24
+ 'border.subtle' => 25, 'border.default' => 26, 'border.strong' => 27,
25
+ 'focus.ring' => 28,
26
+ 'series.1' => 29, 'series.2' => 30, 'series.3' => 31, 'series.4' => 32, 'series.5' => 33
27
+ }.freeze
28
+
29
+ # `space`, in device-independent pixels at cozy density. What a style
30
+ # carries is the index; these are here so a view can reason about a
31
+ # measure it is computing itself.
32
+ SPACE = [0, 2, 4, 8, 12, 16, 20, 24, 32, 40, 48, 64, 96].freeze
33
+
34
+ # `text`, as `[size, line height]`. The scale stops at index 7, 38 px,
35
+ # and the theme cannot move it: there is no hero type in this protocol.
36
+ TEXT = {
37
+ 'xs' => 0, 'sm' => 1, 'base' => 2, 'lg' => 3,
38
+ 'xl' => 4, '2xl' => 5, '3xl' => 6, '4xl' => 7
39
+ }.freeze
40
+ TEXT_PX = [[11, 16], [13, 18], [15, 22], [17, 24], [20, 28], [24, 32], [30, 38], [38, 46]].freeze
41
+
42
+ RADIUS = { 'none' => 0, 'sm' => 1, 'md' => 2, 'lg' => 3, 'full' => 4 }.freeze
43
+ SHADOW = { 'none' => 0, 'sm' => 1, 'md' => 2, 'lg' => 3 }.freeze
44
+
45
+ # `motion`, in milliseconds. `transition` carries the index + 1, which
46
+ # is why `none` is a name here rather than a hole in the scale.
47
+ MOTION_MS = { 'fast' => 100, 'base' => 180, 'slow' => 320, 'slower' => 560, 'slowest' => 1000 }.freeze
48
+
49
+ # Control heights, for the widgets composed on top of the primitives.
50
+ CONTROL = { 'sm' => 28, 'md' => 36, 'lg' => 44 }.freeze
51
+
52
+ def self.role(name)
53
+ ROLES.fetch(name.to_s) { raise ViewError, "unknown colour role '#{name}'" }
54
+ end
55
+
56
+ def self.role?(name) = ROLES.key?(name.to_s)
57
+
58
+ # The pixel value of a `space` index, for a view doing its own
59
+ # arithmetic — a measure derived from the viewport, say.
60
+ def self.space(index)
61
+ SPACE.fetch(index) { raise ViewError, "space index #{index} is past the end of the scale" }
62
+ end
63
+
64
+ def self.text_size(name_or_index)
65
+ index = name_or_index.is_a?(Integer) ? name_or_index : TEXT.fetch(name_or_index.to_s) do
66
+ raise ViewError, "unknown text scale '#{name_or_index}'"
67
+ end
68
+ raise ViewError, "text index #{index} is past the end of the scale" if index > 7
69
+
70
+ index
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EUI
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../proto/op'
4
+
5
+ module EUI
6
+ module View
7
+ # What it costs to go from the tree the client holds to the one the view
8
+ # just returned.
9
+ #
10
+ # The point of the whole exercise: a click that changes one number is
11
+ # one `SetText`, not a page. Keyed children reconcile by `MoveChild`, so
12
+ # reordering a thousand-row table is *n* moves rather than a rebuild.
13
+ class Diff
14
+ def initialize(encoder)
15
+ @encoder = encoder
16
+ @ops = []
17
+ end
18
+
19
+ def ops(old_tree, new_tree)
20
+ @ops = []
21
+ if replaceable?(old_tree, new_tree)
22
+ node(old_tree, new_tree)
23
+ else
24
+ # The root changed kind: there is no parent to patch it in, so the
25
+ # whole document is replaced. Tables are not cleared with it.
26
+ @encoder.assign_ids(new_tree)
27
+ @ops << Proto::Op.mount(@encoder.subtree_of(new_tree))
28
+ end
29
+ @ops
30
+ end
31
+
32
+ private
33
+
34
+ def replaceable?(old, new)
35
+ old.kind == new.kind && old.key == new.key
36
+ end
37
+
38
+ # One node against its counterpart, then its children.
39
+ def node(old, new)
40
+ new.id = old.id
41
+ @ops << Proto::Op.set_style(new.id, new.style) if old.style != new.style
42
+ @ops << Proto::Op.set_text(new.id, new.text) if old.text != new.text
43
+ props(old, new)
44
+ handlers(old, new)
45
+ children(old, new)
46
+ # Instructions rather than state: a node is scrolled, or focused,
47
+ # once — so what triggers the op is the view asking again, not the
48
+ # client's own offset, which this server never learns.
49
+ @ops << Proto::Op.scroll_to(new.id, new.scroll_to[0], new.scroll_to[1]) if new.scroll_to && new.scroll_to != old.scroll_to
50
+ @ops << Proto::Op.focus(new.id) if new.focus_to && !old.focus_to
51
+ end
52
+
53
+ def props(old, new)
54
+ before = old.props.to_h
55
+ after = new.props.to_h
56
+ after.each do |atom, value|
57
+ @ops << Proto::Op.set_prop(new.id, atom, value) if before[atom] != value
58
+ end
59
+ # There is no op that removes a property, and a client that kept one
60
+ # the view stopped sending would answer for a state nothing holds.
61
+ # Null is how a prop goes away.
62
+ (before.keys - after.keys).each do |atom|
63
+ @ops << Proto::Op.set_prop(new.id, atom, Proto::Value.null)
64
+ end
65
+ end
66
+
67
+ def handlers(old, new)
68
+ before = old.handlers.to_h
69
+ after = new.handlers.to_h
70
+ after.each do |event, handler|
71
+ @ops << Proto::Op.set_handler(new.id, event, handler) if before[event] != handler
72
+ end
73
+ (before.keys - after.keys).each do |event|
74
+ @ops << Proto::Op.clear_handler(new.id, event)
75
+ end
76
+ end
77
+
78
+ def children(old, new)
79
+ return if old.children.empty? && new.children.empty?
80
+
81
+ if keyed?(old.children) && keyed?(new.children)
82
+ keyed_children(old, new)
83
+ else
84
+ positional_children(old, new)
85
+ end
86
+ end
87
+
88
+ def keyed?(children)
89
+ !children.empty? && children.all?(&:key)
90
+ end
91
+
92
+ # Position is identity: child *i* on one side is child *i* on the
93
+ # other. Right for a view whose shape is fixed, wrong for a list —
94
+ # which is what keys are for.
95
+ def positional_children(old, new)
96
+ shared = [old.children.length, new.children.length].min
97
+ shared.times do |i|
98
+ before = old.children[i]
99
+ after = new.children[i]
100
+ if replaceable?(before, after)
101
+ node(before, after)
102
+ else
103
+ @encoder.assign_ids(after)
104
+ @ops << Proto::Op.replace(before.id, @encoder.subtree_of(after))
105
+ end
106
+ end
107
+
108
+ if old.children.length > shared
109
+ @ops << Proto::Op.remove_child(new.id, shared, old.children.length - shared)
110
+ elsif new.children.length > shared
111
+ new.children[shared..].each_with_index do |child, offset|
112
+ @encoder.assign_ids(child)
113
+ @ops << Proto::Op.insert_child(new.id, shared + offset, @encoder.subtree_of(child))
114
+ end
115
+ end
116
+ end
117
+
118
+ # Identity is the key, so a row that moved is a row that moved rather
119
+ # than every row below it having changed.
120
+ #
121
+ # The obvious way to write this is quadratic — scan the old children
122
+ # for each new one — and a ten-thousand-row sort then costs fifty
123
+ # million comparisons before a single byte is sent. What makes it
124
+ # `n log n` instead is the observation that a `MoveChild` only ever
125
+ # pulls a row *forward*: everything before `index` is already final,
126
+ # and the rest keep their relative order. So a row's current position
127
+ # is `index` plus however many rows ahead of it are still waiting,
128
+ # and a Fenwick tree answers that in fourteen steps rather than ten
129
+ # thousand.
130
+ def keyed_children(old, new)
131
+ parent = new.id
132
+ wanted = new.children.map(&:key)
133
+ wanted_set = {}
134
+ wanted.each { |k| wanted_set[k] = true }
135
+
136
+ cur = old.children.dup
137
+ i = 0
138
+ while i < cur.length
139
+ if wanted_set[cur[i].key]
140
+ i += 1
141
+ next
142
+ end
143
+ run = 1
144
+ run += 1 while i + run < cur.length && !wanted_set[cur[i + run].key]
145
+ @ops << Proto::Op.remove_child(parent, i, run)
146
+ cur.slice!(i, run)
147
+ end
148
+
149
+ at = {}
150
+ cur.each_with_index { |child, slot| at[child.key] = slot }
151
+ waiting = Fenwick.new(cur.length)
152
+
153
+ new.children.each_with_index do |after, index|
154
+ slot = at[after.key]
155
+ if slot.nil?
156
+ @encoder.assign_ids(after)
157
+ @ops << Proto::Op.insert_child(parent, index, @encoder.subtree_of(after))
158
+ next
159
+ end
160
+
161
+ from = index + waiting.count_before(slot)
162
+ @ops << Proto::Op.move_child(parent, from, index) if from != index
163
+ waiting.place(slot)
164
+ reconcile_kept(cur[slot], after)
165
+ end
166
+ end
167
+
168
+ # How many of the rows still waiting sit ahead of this one. A plain
169
+ # array of counts would answer it in a scan; this answers it, and
170
+ # takes a row out of the running, in log n.
171
+ class Fenwick
172
+ def initialize(size)
173
+ @size = size
174
+ @tree = Array.new(size + 1, 0)
175
+ (1..size).each do |i|
176
+ @tree[i] += 1
177
+ parent = i + (i & -i)
178
+ @tree[parent] += @tree[i] if parent <= size
179
+ end
180
+ end
181
+
182
+ # Rows still waiting at slots `0...slot`.
183
+ def count_before(slot)
184
+ total = 0
185
+ i = slot
186
+ while i.positive?
187
+ total += @tree[i]
188
+ i -= i & -i
189
+ end
190
+ total
191
+ end
192
+
193
+ def place(slot)
194
+ i = slot + 1
195
+ while i <= @size
196
+ @tree[i] -= 1
197
+ i += i & -i
198
+ end
199
+ end
200
+ end
201
+
202
+ def reconcile_kept(before, after)
203
+ if before.kind == after.kind
204
+ node(before, after)
205
+ else
206
+ @encoder.assign_ids(after)
207
+ @ops << Proto::Op.replace(before.id, @encoder.subtree_of(after))
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end