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
|
@@ -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
|