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,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'limits'
4
+ require_relative 'reader'
5
+ require_relative 'writer'
6
+
7
+ module EUI
8
+ module Proto
9
+ # A length, in one of the five forms the layout algorithm understands
10
+ # (`spec/02-wire-format.md` §3.1). There is no `calc()`: a server that
11
+ # wants a computed length computes it, and the wire carries the answer.
12
+ Dim = Struct.new(:tag, :value) do
13
+ AUTO = 0
14
+ PX = 1
15
+ PERCENT = 2
16
+ FR = 3
17
+ SPACE = 4
18
+
19
+ def self.auto = new(AUTO, 0)
20
+ def self.px(value) = new(PX, Integer(value))
21
+ def self.percent(hundredths) = new(PERCENT, Integer(hundredths))
22
+ def self.fr(hundredths) = new(FR, Integer(hundredths))
23
+ def self.space(index) = new(SPACE, Integer(index))
24
+
25
+ def self.decode(reader)
26
+ tag = reader.u8
27
+ value = reader.u16
28
+ raise DecodeError, 'Dim::Auto carries a value' if tag == AUTO && value != 0
29
+ raise DecodeError, "unknown Dim tag #{tag}" if tag > SPACE
30
+ raise DecodeError, 'space index above 255' if tag == SPACE && value > 255
31
+
32
+ new(tag, value)
33
+ end
34
+
35
+ def encode(writer)
36
+ writer.u8(tag).u16(value)
37
+ end
38
+ end
39
+
40
+ # A colour: a theme role, a literal from the session's table, or
41
+ # nothing. Roles are strongly preferred — only a role follows the
42
+ # viewer's mode, contrast and density.
43
+ class ColorRef
44
+ LITERAL_BIT = 0x8000
45
+
46
+ attr_reader :bits
47
+
48
+ def initialize(bits)
49
+ @bits = bits & 0xFFFF
50
+ end
51
+
52
+ NONE = new(0)
53
+
54
+ def self.role(id) = new(id & 0x7FFF)
55
+ def self.literal(index) = new((index & 0x7FFF) | LITERAL_BIT)
56
+
57
+ def literal? = (@bits & LITERAL_BIT) != 0
58
+ def none? = @bits.zero?
59
+ def index = @bits & 0x7FFF
60
+
61
+ def ==(other) = other.is_a?(ColorRef) && other.bits == @bits
62
+ alias eql? ==
63
+ def hash = @bits.hash
64
+ def to_s = "ColorRef(#{literal? ? "literal #{index}" : "role #{index}"})"
65
+ end
66
+
67
+ # The enumerated style fields. Each one rejects a value it does not
68
+ # define rather than clamping it: clamping is how two implementations
69
+ # quietly disagree about a layout for a year.
70
+ module Enum
71
+ DISPLAY = { 'row' => 0, 'column' => 1, 'stack' => 2, 'grid' => 3, 'none' => 4 }.freeze
72
+ WRAP = { 'nowrap' => 0, 'wrap' => 1, 'wrap_reverse' => 2 }.freeze
73
+ JUSTIFY = { 'start' => 0, 'center' => 1, 'end' => 2, 'between' => 3, 'around' => 4, 'evenly' => 5 }.freeze
74
+ ALIGN_ITEMS = { 'start' => 0, 'center' => 1, 'end' => 2, 'stretch' => 3, 'baseline' => 4 }.freeze
75
+ ALIGN_SELF = ALIGN_ITEMS.merge('auto' => 5).freeze
76
+ FONT_WEIGHT = { 'regular' => 0, 'medium' => 1, 'semibold' => 2, 'bold' => 3 }.freeze
77
+ TEXT_ALIGN = { 'start' => 0, 'center' => 1, 'end' => 2, 'justify' => 3 }.freeze
78
+ OVERFLOW = { 'visible' => 0, 'clip' => 1, 'scroll' => 2 }.freeze
79
+ POSITION = { 'flow' => 0, 'absolute' => 1, 'pointer' => 2 }.freeze
80
+ CURSOR = {
81
+ 'default' => 0, 'pointer' => 1, 'text' => 2, 'grab' => 3, 'grabbing' => 4,
82
+ 'resize_h' => 5, 'resize_v' => 6, 'wait' => 7, 'not_allowed' => 8
83
+ }.freeze
84
+ TRANSITION = { 'none' => 0, 'fast' => 1, 'base' => 2, 'slow' => 3, 'slower' => 4, 'slowest' => 5 }.freeze
85
+ MOTION = {
86
+ 'fade' => 0, 'leading' => 1, 'trailing' => 2, 'top' => 3,
87
+ 'bottom' => 4, 'scale' => 5, 'paired' => 6
88
+ }.freeze
89
+ # `animation` is a bit set rather than one name: a node has to say how
90
+ # it arrives *and* how it leaves while it is still there to say it.
91
+ ANIMATION = { 'none' => 0, 'spin' => 1, 'enter' => 2, 'exit' => 4 }.freeze
92
+
93
+ ANIMATION_SPIN = 1
94
+ ANIMATION_ENTER = 2
95
+ ANIMATION_EXIT = 4
96
+ ANIMATION_MASK = ANIMATION_SPIN | ANIMATION_ENTER | ANIMATION_EXIT
97
+
98
+ def self.check!(table, value, what)
99
+ raise DecodeError, "#{what} is outside the range this revision defines: #{value}" unless table.value?(value)
100
+
101
+ value
102
+ end
103
+ end
104
+
105
+ # The 64-byte computed style record (`spec/02-wire-format.md` §3).
106
+ #
107
+ # Everything in it is already resolved. There is no cascade, no
108
+ # specificity, no inheritance to walk: a client's whole styling cost is
109
+ # one indexed lookup, and a thousand table rows share three ids.
110
+ class StyleRecord
111
+ FIELDS = %i[
112
+ display wrap justify align_items align_self grow shrink gap
113
+ basis width height min_width min_height max_width max_height
114
+ padding margin bg fg border_color border_width
115
+ radius shadow opacity font_family font_size font_weight text_align
116
+ line_clamp text_decoration overflow position z cursor
117
+ transition animation blur motion
118
+ ].freeze
119
+
120
+ attr_accessor(*FIELDS)
121
+
122
+ # The neutral record: a transparent row that inherits what it can.
123
+ def initialize
124
+ @display = 0 # row
125
+ @wrap = 0
126
+ @justify = 0
127
+ @align_items = 3 # stretch
128
+ @align_self = 5 # auto
129
+ @grow = 0
130
+ @shrink = 1
131
+ @gap = 0
132
+ @basis = Dim.auto
133
+ @width = Dim.auto
134
+ @height = Dim.auto
135
+ @min_width = Dim.auto
136
+ @min_height = Dim.auto
137
+ @max_width = Dim.auto
138
+ @max_height = Dim.auto
139
+ @padding = [0, 0, 0, 0]
140
+ @margin = [0, 0, 0, 0]
141
+ @bg = ColorRef::NONE
142
+ @fg = ColorRef::NONE
143
+ @border_color = ColorRef::NONE
144
+ @border_width = [0, 0, 0, 0]
145
+ @radius = 0
146
+ @shadow = 0
147
+ @opacity = 255
148
+ @font_family = 0 # sans
149
+ @font_size = 2 # `base` on the text scale; 0 would be `xs`
150
+ @font_weight = 0
151
+ @text_align = 0
152
+ @line_clamp = 0
153
+ @text_decoration = 0
154
+ @overflow = 0
155
+ @position = 0
156
+ @z = 0
157
+ @cursor = 0
158
+ @transition = 0
159
+ @animation = 0
160
+ @blur = 0
161
+ @motion = 0
162
+ end
163
+
164
+ def self.decode(reader)
165
+ raw = Reader.new(reader.take(Limits::STYLE_RECORD_BYTES))
166
+ r = new
167
+ r.display = Enum.check!(Enum::DISPLAY, raw.u8, 'display')
168
+ r.wrap = Enum.check!(Enum::WRAP, raw.u8, 'wrap')
169
+ r.justify = Enum.check!(Enum::JUSTIFY, raw.u8, 'justify')
170
+ r.align_items = Enum.check!(Enum::ALIGN_ITEMS, raw.u8, 'align_items')
171
+ r.align_self = Enum.check!(Enum::ALIGN_SELF, raw.u8, 'align_self')
172
+ r.grow = raw.u8
173
+ r.shrink = raw.u8
174
+ r.gap = raw.u8
175
+ r.basis = Dim.decode(raw)
176
+ r.width = Dim.decode(raw)
177
+ r.height = Dim.decode(raw)
178
+ r.min_width = Dim.decode(raw)
179
+ r.min_height = Dim.decode(raw)
180
+ r.max_width = Dim.decode(raw)
181
+ r.max_height = Dim.decode(raw)
182
+ r.padding = raw.take(4).bytes
183
+ r.margin = raw.take(4).bytes
184
+ r.bg = ColorRef.new(raw.u16)
185
+ r.fg = ColorRef.new(raw.u16)
186
+ r.border_color = ColorRef.new(raw.u16)
187
+ r.border_width = raw.take(4).bytes
188
+ r.radius = raw.u8
189
+ r.shadow = raw.u8
190
+ r.opacity = raw.u8
191
+ r.font_family = raw.u8
192
+ r.font_size = raw.u8
193
+ r.font_weight = Enum.check!(Enum::FONT_WEIGHT, raw.u8, 'font_weight')
194
+ r.text_align = Enum.check!(Enum::TEXT_ALIGN, raw.u8, 'text_align')
195
+ r.line_clamp = raw.u8
196
+ r.text_decoration = raw.u8
197
+ r.overflow = Enum.check!(Enum::OVERFLOW, raw.u8, 'overflow')
198
+ r.position = Enum.check!(Enum::POSITION, raw.u8, 'position')
199
+ r.z = raw.u8
200
+ r.cursor = Enum.check!(Enum::CURSOR, raw.u8, 'cursor')
201
+ r.transition = raw.u8
202
+ r.animation = raw.u8
203
+ r.blur = raw.u8
204
+ r.motion = Enum.check!(Enum::MOTION, raw.u8, 'motion')
205
+ raw.finish!
206
+ r.validate!
207
+ r
208
+ end
209
+
210
+ def validate!
211
+ raise DecodeError, 'transition is a motion index + 1, at most 5' if @transition > 5
212
+ raise DecodeError, 'animation is a bit set of 1, 2 and 4' if (@animation & ~Enum::ANIMATION_MASK) != 0
213
+ raise DecodeError, 'text_decoration has unknown bits' if (@text_decoration & ~0b11) != 0
214
+ # A direction with nothing going that way.
215
+ if @motion != 0 && (@animation & (Enum::ANIMATION_ENTER | Enum::ANIMATION_EXIT)).zero?
216
+ raise DecodeError, 'motion needs an entrance or an exit to belong to'
217
+ end
218
+
219
+ self
220
+ end
221
+
222
+ def encode(writer)
223
+ writer.u8(@display).u8(@wrap).u8(@justify).u8(@align_items).u8(@align_self)
224
+ writer.u8(@grow).u8(@shrink).u8(@gap)
225
+ [@basis, @width, @height, @min_width, @min_height, @max_width, @max_height].each { |d| d.encode(writer) }
226
+ writer.raw(@padding.pack('C4')).raw(@margin.pack('C4'))
227
+ writer.u16(@bg.bits).u16(@fg.bits).u16(@border_color.bits)
228
+ writer.raw(@border_width.pack('C4'))
229
+ writer.u8(@radius).u8(@shadow).u8(@opacity)
230
+ writer.u8(@font_family).u8(@font_size).u8(@font_weight).u8(@text_align)
231
+ writer.u8(@line_clamp).u8(@text_decoration).u8(@overflow).u8(@position).u8(@z).u8(@cursor)
232
+ writer.u8(@transition).u8(@animation).u8(@blur).u8(@motion)
233
+ writer
234
+ end
235
+
236
+ def to_bytes
237
+ w = Writer.new
238
+ encode(w)
239
+ w.to_s
240
+ end
241
+
242
+ def ==(other)
243
+ other.is_a?(StyleRecord) && other.to_bytes == to_bytes
244
+ end
245
+ alias eql? ==
246
+
247
+ def hash = to_bytes.hash
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EUI
4
+ module Proto
5
+ # An append-only byte buffer with the protocol's primitives on it
6
+ # (`spec/02-wire-format.md` §1). Every method returns `self`, so an
7
+ # encoder reads as one sentence.
8
+ class Writer
9
+ attr_reader :buffer
10
+
11
+ def initialize
12
+ @buffer = +''
13
+ @buffer.force_encoding(Encoding::BINARY)
14
+ end
15
+
16
+ def u8(value)
17
+ @buffer << (value & 0xFF).chr
18
+ self
19
+ end
20
+
21
+ def u16(value)
22
+ @buffer << [value & 0xFFFF].pack('v')
23
+ self
24
+ end
25
+
26
+ def u32(value)
27
+ @buffer << [value & 0xFFFF_FFFF].pack('V')
28
+ self
29
+ end
30
+
31
+ def u64(value)
32
+ @buffer << [value & 0xFFFF_FFFF_FFFF_FFFF].pack('Q<')
33
+ self
34
+ end
35
+
36
+ def f64(value)
37
+ @buffer << [value].pack('E')
38
+ self
39
+ end
40
+
41
+ # LEB128, minimally encoded. A decoder rejects any other spelling of
42
+ # the same number, so there is only ever one.
43
+ def varint(value)
44
+ raise Error, "varint cannot carry #{value}" if value.negative?
45
+
46
+ loop do
47
+ byte = value & 0x7F
48
+ value >>= 7
49
+ if value.zero?
50
+ @buffer << byte.chr
51
+ break
52
+ end
53
+ @buffer << (byte | 0x80).chr
54
+ end
55
+ self
56
+ end
57
+
58
+ # LEB128 over zigzag: the sign rides in the low bit, so a small
59
+ # negative number costs one byte like a small positive one.
60
+ def svarint(value)
61
+ varint((value << 1) ^ (value >> 63))
62
+ end
63
+
64
+ def bytes(str)
65
+ str = str.b
66
+ varint(str.bytesize)
67
+ @buffer << str
68
+ self
69
+ end
70
+
71
+ def str(value)
72
+ bytes(value.to_s.encode(Encoding::UTF_8).b)
73
+ end
74
+
75
+ def raw(str)
76
+ @buffer << str.b
77
+ self
78
+ end
79
+
80
+ def size
81
+ @buffer.bytesize
82
+ end
83
+
84
+ def to_s
85
+ @buffer
86
+ end
87
+ end
88
+ end
89
+ end
data/lib/eui/proto.rb ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+ require_relative 'proto/limits'
5
+ require_relative 'proto/reader'
6
+ require_relative 'proto/writer'
7
+ require_relative 'proto/style'
8
+ require_relative 'proto/node'
9
+ require_relative 'proto/op'
10
+ require_relative 'proto/frame'
11
+
12
+ module EUI
13
+ module Proto
14
+ # The version this library speaks. Four: `DefFont` and the font roles it
15
+ # binds took the protocol there (`spec/02-wire-format.md` §4.1).
16
+ #
17
+ # A session speaks `min(client, server)`, negotiated in the Welcome —
18
+ # the number a client names in its Hello is not a field id and not a
19
+ # capability set, it is this.
20
+ PROTOCOL_VERSION = 4
21
+ end
22
+ end
data/lib/eui/server.rb ADDED
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require_relative 'errors'
5
+ require_relative 'websocket'
6
+ require_relative 'session'
7
+
8
+ module EUI
9
+ # The three endpoints an EUI application answers on
10
+ # (`spec/01-transport.md` §2), and nothing else.
11
+ #
12
+ # GET /.well-known/eui the signed manifest
13
+ # GET /_eui/asset/<blake3-hex> a content-addressed asset
14
+ # WSS /_eui/session[/<name>] the session
15
+ #
16
+ # TLS 1.3 is the protocol's floor, and a release client refuses `ws://`
17
+ # outright. Pass `tls:` in production; over loopback a debug client can
18
+ # be told to come in the front door with `EUI_ALLOW_INSECURE_LOOPBACK=1`.
19
+ class Server
20
+ HEADER_LIMIT = 16 * 1024
21
+
22
+ def initialize(app, host: '127.0.0.1', port: 5012, tls: nil, logger: nil)
23
+ @app = app
24
+ @host = host
25
+ @port = port
26
+ @tls = tls
27
+ @logger = logger || ->(line) { warn line }
28
+ @connections = []
29
+ end
30
+
31
+ def start
32
+ server = TCPServer.new(@host, @port)
33
+ server = wrap_tls(server) if @tls
34
+ @server = server
35
+ log("listening on #{scheme}://#{@host}:#{@port}")
36
+ log(" manifest #{scheme}://#{@host}:#{@port}/.well-known/eui")
37
+ @app.components.each_key do |name|
38
+ log(" session #{scheme == 'https' ? 'wss' : 'ws'}://#{@host}:#{@port}/_eui/session/#{name}")
39
+ end
40
+
41
+ loop do
42
+ socket = begin
43
+ server.accept
44
+ rescue OpenSSL::SSL::SSLError => e
45
+ log("TLS handshake: #{e.message}")
46
+ next
47
+ rescue IOError, Errno::EBADF
48
+ break
49
+ end
50
+ Thread.new(socket) { |s| serve(s) }
51
+ end
52
+ end
53
+
54
+ def stop
55
+ @server&.close
56
+ rescue IOError
57
+ nil
58
+ end
59
+
60
+ private
61
+
62
+ def scheme = @tls ? 'https' : 'http'
63
+
64
+ def wrap_tls(server)
65
+ require 'openssl'
66
+ ctx = OpenSSL::SSL::SSLContext.new
67
+ ctx.cert = OpenSSL::X509::Certificate.new(File.read(@tls.fetch(:cert)))
68
+ ctx.key = OpenSSL::PKey.read(File.read(@tls.fetch(:key)))
69
+ # The protocol's floor, not a preference: a server answering `wss://`
70
+ # with TLS 1.2 is refused by a conforming client rather than
71
+ # accommodated, so there is nothing to gain by offering it.
72
+ ctx.min_version = OpenSSL::SSL::TLS1_3_VERSION
73
+ OpenSSL::SSL::SSLServer.new(server, ctx)
74
+ end
75
+
76
+ def serve(socket)
77
+ request = read_request(socket)
78
+ return socket.close if request.nil?
79
+
80
+ method, path, headers = request
81
+ if websocket?(headers) && method == 'GET'
82
+ session(socket, path, headers)
83
+ else
84
+ http(socket, method, path)
85
+ socket.close
86
+ end
87
+ rescue WebSocket::ProtocolError => e
88
+ log("websocket: #{e.message}")
89
+ socket.close
90
+ rescue StandardError => e
91
+ log("#{e.class}: #{e.message}")
92
+ log(e.backtrace.first(5).join("\n")) if e.backtrace
93
+ socket.close
94
+ end
95
+
96
+ def websocket?(headers)
97
+ headers['upgrade']&.downcase == 'websocket'
98
+ end
99
+
100
+ def session(socket, path, headers)
101
+ name = path.sub(%r{\A/_eui/session/?}, '')
102
+ component = @app.component_for(name.empty? ? nil : name)
103
+ unless path.start_with?('/_eui/session') && component
104
+ respond(socket, 404, 'text/plain', "no session at #{path}\n")
105
+ return socket.close
106
+ end
107
+
108
+ ws = WebSocket.accept(socket, path: path, headers: headers)
109
+ Session.new(ws, component_class: component, app: @app, logger: @logger).run
110
+ end
111
+
112
+ def http(socket, method, path)
113
+ return respond(socket, 405, 'text/plain', "GET only\n") unless method == 'GET'
114
+
115
+ case path
116
+ when '/.well-known/eui'
117
+ manifest = @app.manifest
118
+ return respond(socket, 404, 'text/plain', "this application has no manifest\n") unless manifest
119
+
120
+ respond(socket, 200, 'application/vnd.eui.manifest', manifest.encode)
121
+ when %r{\A/_eui/asset/([0-9a-f]{64})\z}
122
+ entry = @app.assets.fetch(Regexp.last_match(1))
123
+ # A hash this server does not hold is a 404 and never a redirect:
124
+ # the name is the content, so there is nowhere else it could be.
125
+ return respond(socket, 404, 'text/plain', "no such asset\n") unless entry
126
+
127
+ respond(socket, 200, entry.content_type, entry.bytes,
128
+ 'Cache-Control' => 'public, max-age=31536000, immutable')
129
+ when '/health'
130
+ respond(socket, 200, 'text/plain', "ok\n")
131
+ else
132
+ respond(socket, 404, 'text/plain', "not found\n")
133
+ end
134
+ end
135
+
136
+ def respond(socket, status, type, body, extra = {})
137
+ body = body.to_s.b
138
+ reason = { 200 => 'OK', 404 => 'Not Found', 405 => 'Method Not Allowed', 400 => 'Bad Request' }.fetch(status, 'OK')
139
+ head = +"HTTP/1.1 #{status} #{reason}\r\n"
140
+ head << "Content-Type: #{type}\r\n"
141
+ head << "Content-Length: #{body.bytesize}\r\n"
142
+ extra.each { |k, v| head << "#{k}: #{v}\r\n" }
143
+ head << "Connection: close\r\n\r\n"
144
+ socket.write(head, body)
145
+ rescue SystemCallError, IOError
146
+ nil
147
+ end
148
+
149
+ def read_request(socket)
150
+ line = socket.gets("\r\n")
151
+ return nil if line.nil?
152
+
153
+ method, path, = line.split
154
+ return nil if method.nil? || path.nil?
155
+
156
+ headers = {}
157
+ read = line.bytesize
158
+ while (header = socket.gets("\r\n"))
159
+ read += header.bytesize
160
+ raise ProtocolError, 'headers too long' if read > HEADER_LIMIT
161
+ break if header == "\r\n"
162
+
163
+ name, value = header.split(':', 2)
164
+ next unless value
165
+
166
+ headers[name.strip.downcase] = value.strip
167
+ end
168
+ [method, path, headers]
169
+ rescue SystemCallError, IOError
170
+ nil
171
+ end
172
+
173
+ def log(line) = @logger.call("[EUI] #{line}")
174
+ end
175
+ end