tungsten-runtime 0.1.2
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/LICENSE +55 -0
- data/README.md +23 -0
- data/lib/tungsten/runtime/auth.rb +258 -0
- data/lib/tungsten/runtime/classify.rb +248 -0
- data/lib/tungsten/runtime/client_core.rb +1147 -0
- data/lib/tungsten/runtime/descriptors.rb +173 -0
- data/lib/tungsten/runtime/dispatcher.rb +83 -0
- data/lib/tungsten/runtime/envelope.rb +230 -0
- data/lib/tungsten/runtime/expr.rb +186 -0
- data/lib/tungsten/runtime/helpers.rb +185 -0
- data/lib/tungsten/runtime/idempotency.rb +110 -0
- data/lib/tungsten/runtime/json.rb +346 -0
- data/lib/tungsten/runtime/macros.rb +343 -0
- data/lib/tungsten/runtime/oauth.rb +329 -0
- data/lib/tungsten/runtime/options.rb +142 -0
- data/lib/tungsten/runtime/pages.rb +173 -0
- data/lib/tungsten/runtime/schema.rb +320 -0
- data/lib/tungsten/runtime/serialize.rb +353 -0
- data/lib/tungsten/runtime/sse.rb +148 -0
- data/lib/tungsten/runtime/stream.rb +500 -0
- data/lib/tungsten/runtime/transport.rb +386 -0
- data/lib/tungsten/runtime/typed.rb +107 -0
- data/lib/tungsten/runtime/util.rb +254 -0
- data/lib/tungsten/runtime/version.rb +9 -0
- data/lib/tungsten/runtime.rb +49 -0
- metadata +82 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
module Tungsten
|
|
5
|
+
module Runtime
|
|
6
|
+
# Parameter and body serialization, and the request's headers.
|
|
7
|
+
module Serialize
|
|
8
|
+
# A value that cannot be encoded becomes VALIDATION_FAILED.
|
|
9
|
+
SerializationError = Data.define(:parameter, :expected, :value)
|
|
10
|
+
|
|
11
|
+
# A body ready for the wire: `bytes` (a binary String) or `parts`
|
|
12
|
+
# (multipart), `content_type`, the redacted `display` and the bytes the
|
|
13
|
+
# content hash is computed over (`hashed`, nil without a body).
|
|
14
|
+
Encoded = Data.define(:empty, :bytes, :parts, :content_type, :display, :hashed)
|
|
15
|
+
EMPTY = Encoded.new(empty: true, bytes: nil, parts: nil, content_type: nil, display: nil, hashed: nil)
|
|
16
|
+
|
|
17
|
+
# One multipart part: `kind` :text, :json or :file.
|
|
18
|
+
Part = Data.define(:name, :kind, :text, :data, :filename, :content_type)
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# String(value) for a scalar, JSON text for anything else.
|
|
23
|
+
def scalar(value)
|
|
24
|
+
case value
|
|
25
|
+
when String then value
|
|
26
|
+
when Integer, Float then Json.number_text(value)
|
|
27
|
+
when true, false then value.to_s
|
|
28
|
+
else Json.text(value)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def escape(text, keep)
|
|
33
|
+
text.to_s.b.each_byte.map do |b|
|
|
34
|
+
c = b.chr
|
|
35
|
+
c.match?(/[A-Za-z0-9]/) || keep.include?(c) ? c : format("%%%02X", b)
|
|
36
|
+
end.join
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# encodeURIComponent.
|
|
40
|
+
def encode_component(text) = escape(text, "-_.!~*'()")
|
|
41
|
+
|
|
42
|
+
# One component of a form body (URLSearchParams): spaces as `+`.
|
|
43
|
+
def encode_form_component(text)
|
|
44
|
+
text.to_s.b.each_byte.map do |b|
|
|
45
|
+
c = b.chr
|
|
46
|
+
if c.match?(/[A-Za-z0-9]/) || "*-._".include?(c) then c
|
|
47
|
+
elsif c == " " then "+"
|
|
48
|
+
else format("%%%02X", b)
|
|
49
|
+
end
|
|
50
|
+
end.join
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# RFC 3986 percent-encoding.
|
|
54
|
+
def percent_encode(text) = escape(text, "-._~")
|
|
55
|
+
|
|
56
|
+
def encoded_items(items) = items.reject(&:nil?).map { |v| encode_component(scalar(v)) }
|
|
57
|
+
|
|
58
|
+
def encoded_pairs(object)
|
|
59
|
+
object.reject { |_, v| v.nil? }.map { |k, v| [encode_component(k), encode_component(scalar(v))] }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# One path parameter value, percent-encoded.
|
|
63
|
+
def path_param(p, value)
|
|
64
|
+
name = encode_component(p.wire)
|
|
65
|
+
style = %w[label matrix].include?(p.style) ? p.style : "simple"
|
|
66
|
+
case value
|
|
67
|
+
when Array
|
|
68
|
+
items = encoded_items(value)
|
|
69
|
+
return ".#{items.join(p.explode ? '.' : ',')}" if style == "label"
|
|
70
|
+
return items.map { |i| ";#{name}=#{i}" }.join if style == "matrix" && p.explode
|
|
71
|
+
return ";#{name}=#{items.join(',')}" if style == "matrix"
|
|
72
|
+
|
|
73
|
+
items.join(",")
|
|
74
|
+
when Hash
|
|
75
|
+
pairs = encoded_pairs(value)
|
|
76
|
+
exploded = pairs.map { |k, v| "#{k}=#{v}" }
|
|
77
|
+
flat = pairs.flatten
|
|
78
|
+
return ".#{exploded.join('.')}" if style == "label" && p.explode
|
|
79
|
+
return ".#{flat.join(',')}" if style == "label"
|
|
80
|
+
return exploded.map { |e| ";#{e}" }.join if style == "matrix" && p.explode
|
|
81
|
+
return ";#{name}=#{flat.join(',')}" if style == "matrix"
|
|
82
|
+
|
|
83
|
+
p.explode ? exploded.join(",") : flat.join(",")
|
|
84
|
+
else
|
|
85
|
+
val = encode_component(scalar(value))
|
|
86
|
+
case style
|
|
87
|
+
when "label" then ".#{val}"
|
|
88
|
+
when "matrix" then ";#{name}=#{val}"
|
|
89
|
+
else val
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def deep_object_pairs(prefix, value, out, depth)
|
|
95
|
+
return if depth > 16 || value.nil?
|
|
96
|
+
|
|
97
|
+
case value
|
|
98
|
+
when Array then value.each { |item| deep_object_pairs(prefix, item, out, depth + 1) }
|
|
99
|
+
when Hash
|
|
100
|
+
value.each do |k, v|
|
|
101
|
+
deep_object_pairs("#{prefix}[#{encode_component(k)}]", v, out, depth + 1) unless v.nil?
|
|
102
|
+
end
|
|
103
|
+
else out << "#{prefix}=#{encode_component(scalar(value))}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The query parts (name=value, encoded) of one parameter.
|
|
108
|
+
def query_param(p, value)
|
|
109
|
+
return [] if value.nil?
|
|
110
|
+
|
|
111
|
+
name = encode_component(p.wire)
|
|
112
|
+
delimiter = { "space_delimited" => "%20", "pipe_delimited" => "|" }.fetch(p.style, ",")
|
|
113
|
+
if value.is_a?(Hash) && p.style == "deep_object"
|
|
114
|
+
out = []
|
|
115
|
+
deep_object_pairs(name, value, out, 0)
|
|
116
|
+
return out
|
|
117
|
+
end
|
|
118
|
+
case value
|
|
119
|
+
when Array
|
|
120
|
+
items = encoded_items(value)
|
|
121
|
+
return [] if items.empty?
|
|
122
|
+
return items.map { |i| "#{name}=#{i}" } if p.explode || p.style == "deep_object"
|
|
123
|
+
|
|
124
|
+
["#{name}=#{items.join(delimiter)}"]
|
|
125
|
+
when Hash
|
|
126
|
+
pairs = encoded_pairs(value)
|
|
127
|
+
return pairs.map { |k, v| "#{k}=#{v}" } if p.explode
|
|
128
|
+
|
|
129
|
+
["#{name}=#{pairs.flatten.join(delimiter)}"]
|
|
130
|
+
else ["#{name}=#{encode_component(scalar(value))}"]
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# A header parameter (simple style, not encoded).
|
|
135
|
+
def header_param(p, value)
|
|
136
|
+
case value
|
|
137
|
+
when Array then value.reject(&:nil?).map { |v| scalar(v) }.join(",")
|
|
138
|
+
when Hash
|
|
139
|
+
value.reject { |_, v| v.nil? }.flat_map { |k, v| p.explode ? ["#{k}=#{scalar(v)}"] : [k, scalar(v)] }.join(",")
|
|
140
|
+
else scalar(value)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# name=value of a cookie parameter.
|
|
145
|
+
def cookie_param(p, value)
|
|
146
|
+
case value
|
|
147
|
+
when Array then "#{p.wire}=#{encoded_items(value).join(',')}"
|
|
148
|
+
when Hash then "#{p.wire}=#{encoded_pairs(value).flatten.join(',')}"
|
|
149
|
+
else "#{p.wire}=#{encode_component(scalar(value))}"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The body before encoding: merged fields picked from the arguments
|
|
154
|
+
# (under their wire names) or the body argument; wrapped in the rpc
|
|
155
|
+
# envelope when the operation has one. UNSET without a body.
|
|
156
|
+
def body_value(op, args, param_names)
|
|
157
|
+
value = UNSET
|
|
158
|
+
if (b = op.body)
|
|
159
|
+
if b.arg
|
|
160
|
+
value = args.fetch(b.arg, UNSET)
|
|
161
|
+
else
|
|
162
|
+
picked = {}
|
|
163
|
+
b.fields.each { |name, wire| picked[wire] = args[name] if args.key?(name) }
|
|
164
|
+
value = picked if !picked.empty? || b.required || op.rpc
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
rpc = op.rpc
|
|
168
|
+
return value unless rpc
|
|
169
|
+
|
|
170
|
+
params =
|
|
171
|
+
if op.body.nil? then args.reject { |k, _| param_names.include?(k) }
|
|
172
|
+
elsif !UNSET.equal?(value) then value
|
|
173
|
+
else {}
|
|
174
|
+
end
|
|
175
|
+
envelope = {}
|
|
176
|
+
(rpc["constants"] || {}).each { |k, v| envelope[k] = v }
|
|
177
|
+
envelope[rpc["field"]] = rpc["value"]
|
|
178
|
+
envelope[rpc["params_field"]] = params
|
|
179
|
+
envelope
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def binary_display(n, media) = media.to_s.empty? ? "<#{n} bytes>" : "<#{n} bytes of #{media}>"
|
|
183
|
+
|
|
184
|
+
def form_pairs(value)
|
|
185
|
+
return SerializationError.new(parameter: "body", expected: "an object of form fields", value: value) unless value.is_a?(Hash)
|
|
186
|
+
|
|
187
|
+
out = []
|
|
188
|
+
value.each do |k, item|
|
|
189
|
+
next if item.nil?
|
|
190
|
+
|
|
191
|
+
if item.is_a?(Array)
|
|
192
|
+
item.each { |x| out << [k, scalar(x)] unless x.nil? }
|
|
193
|
+
else
|
|
194
|
+
out << [k, scalar(item)]
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
out
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Encode a body per its descriptor; a SerializationError when the value
|
|
201
|
+
# does not fit. `redact` renders the shown body.
|
|
202
|
+
def encode_body(desc, value, redact)
|
|
203
|
+
return EMPTY if UNSET.equal?(value)
|
|
204
|
+
|
|
205
|
+
encoding = desc ? desc.encoding : "json"
|
|
206
|
+
media = desc ? desc.media_type : "application/json"
|
|
207
|
+
case encoding
|
|
208
|
+
when "form"
|
|
209
|
+
pairs = form_pairs(value)
|
|
210
|
+
return pairs if pairs.is_a?(SerializationError)
|
|
211
|
+
|
|
212
|
+
text = pairs.map { |k, v| "#{encode_form_component(k)}=#{encode_form_component(v)}" }.join("&")
|
|
213
|
+
Encoded.new(empty: false, bytes: text.b, parts: nil, content_type: media, display: redact.call(pairs.to_h),
|
|
214
|
+
hashed: text.b)
|
|
215
|
+
when "multipart" then multipart(value, redact)
|
|
216
|
+
when "bytes"
|
|
217
|
+
bin = Json.binary_of(value)
|
|
218
|
+
data =
|
|
219
|
+
if value.is_a?(String) then value.b
|
|
220
|
+
elsif !UNSET.equal?(bin) then bin.data
|
|
221
|
+
else
|
|
222
|
+
return SerializationError.new(parameter: "body", expected: "binary data (a Binary value)", value: value)
|
|
223
|
+
end
|
|
224
|
+
Encoded.new(empty: false, bytes: data, parts: nil, content_type: media,
|
|
225
|
+
display: binary_display(data.bytesize, media), hashed: data)
|
|
226
|
+
when "text"
|
|
227
|
+
unless value.is_a?(String) || value.is_a?(Numeric) || [true, false].include?(value)
|
|
228
|
+
return SerializationError.new(parameter: "body", expected: "a string", value: value)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
text = scalar(value)
|
|
232
|
+
Encoded.new(empty: false, bytes: text.b, parts: nil, content_type: media, display: text, hashed: text.b)
|
|
233
|
+
else
|
|
234
|
+
text = Json.text(value)
|
|
235
|
+
raw = Json.try_parse(text)
|
|
236
|
+
if UNSET.equal?(raw)
|
|
237
|
+
return SerializationError.new(parameter: "body", expected: "a JSON-serializable value", value: value)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
Encoded.new(empty: false, bytes: text.b, parts: nil, content_type: media, display: redact.call(raw),
|
|
241
|
+
hashed: Json.canonical(raw).b)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def multipart(value, redact)
|
|
246
|
+
unless value.is_a?(Hash)
|
|
247
|
+
return SerializationError.new(parameter: "body", expected: "an object of multipart fields", value: value)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
parts = []
|
|
251
|
+
display = {}
|
|
252
|
+
value.each do |k, item|
|
|
253
|
+
next if item.nil?
|
|
254
|
+
|
|
255
|
+
list = item.is_a?(Array) ? item.reject(&:nil?) : [item]
|
|
256
|
+
shown = []
|
|
257
|
+
list.each do |x|
|
|
258
|
+
bin = Json.binary_of(x)
|
|
259
|
+
if !UNSET.equal?(bin)
|
|
260
|
+
content_type = bin.content_type || "application/octet-stream"
|
|
261
|
+
unless valid_header_value?(content_type)
|
|
262
|
+
return SerializationError.new(parameter: "body", expected: "multipart file content types without header control characters",
|
|
263
|
+
value: value)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Keep the checked header value stable until the body is sent.
|
|
267
|
+
content_type = content_type.dup.freeze
|
|
268
|
+
shown << binary_display(bin.bytesize, bin.content_type)
|
|
269
|
+
parts << Part.new(name: k, kind: :file, text: nil, data: bin.data, filename: bin.filename || k,
|
|
270
|
+
content_type: content_type)
|
|
271
|
+
elsif x.is_a?(Hash)
|
|
272
|
+
parts << Part.new(name: k, kind: :json, text: Json.text(x), data: nil, filename: nil, content_type: nil)
|
|
273
|
+
shown << x
|
|
274
|
+
else
|
|
275
|
+
parts << Part.new(name: k, kind: :text, text: scalar(x), data: nil, filename: nil, content_type: nil)
|
|
276
|
+
shown << scalar(x)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
display[k] = item.is_a?(Array) ? shown : shown.first
|
|
280
|
+
end
|
|
281
|
+
Encoded.new(empty: false, bytes: nil, parts: parts, content_type: nil, display: redact.call(display),
|
|
282
|
+
hashed: Json.canonical(value).b)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# The bytes of a multipart body and its content type.
|
|
286
|
+
def multipart_body(parts)
|
|
287
|
+
boundary = SecureRandom.hex(16)
|
|
288
|
+
out = +"".b
|
|
289
|
+
quote = ->(s) { s.to_s.gsub("\\", "\\\\\\\\").gsub('"', '\\"').gsub("\r", "%0D").gsub("\n", "%0A") }
|
|
290
|
+
parts.each do |p|
|
|
291
|
+
out << "--#{boundary}\r\n".b
|
|
292
|
+
case p.kind
|
|
293
|
+
when :file
|
|
294
|
+
out << %(Content-Disposition: form-data; name="#{quote.call(p.name)}"; filename="#{quote.call(p.filename)}"\r\n).b
|
|
295
|
+
out << "Content-Type: #{p.content_type}\r\n\r\n".b << p.data.b
|
|
296
|
+
when :json
|
|
297
|
+
out << %(Content-Disposition: form-data; name="#{quote.call(p.name)}"\r\n).b
|
|
298
|
+
out << "Content-Type: application/json\r\n\r\n".b << p.text.b
|
|
299
|
+
else
|
|
300
|
+
out << %(Content-Disposition: form-data; name="#{quote.call(p.name)}"\r\n\r\n).b << p.text.b
|
|
301
|
+
end
|
|
302
|
+
out << "\r\n".b
|
|
303
|
+
end
|
|
304
|
+
out << "--#{boundary}--\r\n".b
|
|
305
|
+
[out, "multipart/form-data; boundary=#{boundary}"]
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# An RFC 9110 token.
|
|
309
|
+
def valid_header_name?(name) = name.is_a?(String) && name.match?(/\A[A-Za-z0-9!#$%&'*+\-.^_`|~]+\z/)
|
|
310
|
+
|
|
311
|
+
# Visible ASCII, tab, space and Latin-1 characters.
|
|
312
|
+
def valid_header_value?(value)
|
|
313
|
+
value.is_a?(String) && value.each_char.all? { |c| c == "\t" || (c.ord >= 0x20 && c.ord <= 0x7e) || (c.ord >= 0x80 && c.ord <= 0xff) }
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# The Latin-1 bytes of a valid header value.
|
|
317
|
+
def header_bytes(value) = value.each_char.map { |c| c.ord.chr }.join.b
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# A case-insensitive header collection that remembers which values are
|
|
321
|
+
# secrets, in insertion order.
|
|
322
|
+
class HeaderBag
|
|
323
|
+
Entry = Struct.new(:name, :value, :secret)
|
|
324
|
+
|
|
325
|
+
attr_reader :entries
|
|
326
|
+
|
|
327
|
+
def initialize(entries = [])
|
|
328
|
+
@entries = entries.map(&:dup)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def set(name, value, secret)
|
|
332
|
+
existing = @entries.find { |e| e.name.casecmp?(name) }
|
|
333
|
+
if existing
|
|
334
|
+
existing.name = name
|
|
335
|
+
existing.value = value
|
|
336
|
+
existing.secret = secret
|
|
337
|
+
else
|
|
338
|
+
@entries << Entry.new(name, value, secret)
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def get(name) = @entries.find { |e| e.name.casecmp?(name) }&.value
|
|
343
|
+
|
|
344
|
+
def delete(name) = @entries.reject! { |e| e.name.casecmp?(name) }
|
|
345
|
+
|
|
346
|
+
def to_h(redact) = @entries.to_h { |e| [e.name, redact && e.secret ? REDACTED : e.value] }
|
|
347
|
+
|
|
348
|
+
def secrets = @entries.select(&:secret).map(&:value)
|
|
349
|
+
|
|
350
|
+
def dup = HeaderBag.new(@entries)
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
module Tungsten
|
|
5
|
+
module Runtime
|
|
6
|
+
# A server-sent events parser (WHATWG HTML 9.2): lines end with CRLF, LF
|
|
7
|
+
# or CR; a line is a comment (":"), a field (event, data, id, retry; the
|
|
8
|
+
# value loses one leading space) or the blank line that dispatches the
|
|
9
|
+
# event. Data lines are joined with LF; an event without data lines is
|
|
10
|
+
# not dispatched; an event still open when the stream ends is
|
|
11
|
+
# discarded. An event may hold at most `max` UTF-8 bytes (its field
|
|
12
|
+
# lines, each with a one-byte terminator, plus the line being read);
|
|
13
|
+
# past it the parser stops (`exceeded?`).
|
|
14
|
+
class SseParser
|
|
15
|
+
# One dispatched event; `id` and `retry_ms` persist across events
|
|
16
|
+
# (nil before any).
|
|
17
|
+
Event = Data.define(:event, :data, :id, :retry_ms)
|
|
18
|
+
|
|
19
|
+
def initialize(max)
|
|
20
|
+
@max = max
|
|
21
|
+
@buffer = +""
|
|
22
|
+
@started = false
|
|
23
|
+
reset_event
|
|
24
|
+
@id = nil
|
|
25
|
+
@retry_ms = nil
|
|
26
|
+
@exceeded = false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def exceeded? = @exceeded
|
|
30
|
+
|
|
31
|
+
# Feed decoded text; the events it completes. A CR at the end is held
|
|
32
|
+
# back until the next text shows whether an LF follows.
|
|
33
|
+
def push(chunk)
|
|
34
|
+
return [] if @exceeded
|
|
35
|
+
|
|
36
|
+
text = @buffer + chunk
|
|
37
|
+
@buffer = +""
|
|
38
|
+
if !@started && !text.empty?
|
|
39
|
+
@started = true
|
|
40
|
+
text = text.delete_prefix("\uFEFF")
|
|
41
|
+
end
|
|
42
|
+
out = []
|
|
43
|
+
bytes = text.b
|
|
44
|
+
start = 0
|
|
45
|
+
while (i = bytes.byteindex(/[\r\n]/n, start))
|
|
46
|
+
cr = bytes.getbyte(i) == 13
|
|
47
|
+
if cr && i + 1 == bytes.bytesize
|
|
48
|
+
@buffer = utf8(bytes.byteslice(start..))
|
|
49
|
+
return out
|
|
50
|
+
end
|
|
51
|
+
line(utf8(bytes.byteslice(start, i - start)), out)
|
|
52
|
+
start = cr && bytes.getbyte(i + 1) == 10 ? i + 2 : i + 1
|
|
53
|
+
return out if @exceeded
|
|
54
|
+
end
|
|
55
|
+
@buffer = utf8(bytes.byteslice(start..) || "")
|
|
56
|
+
if @event_bytes + @buffer.bytesize > @max
|
|
57
|
+
@exceeded = true
|
|
58
|
+
@buffer = +""
|
|
59
|
+
end
|
|
60
|
+
out
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The stream ended: a held-back CR ends its line, the rest is
|
|
64
|
+
# discarded.
|
|
65
|
+
def finish
|
|
66
|
+
out = []
|
|
67
|
+
line(@buffer.delete_suffix("\r"), out) if !@exceeded && @buffer.end_with?("\r")
|
|
68
|
+
@buffer = +""
|
|
69
|
+
reset_event
|
|
70
|
+
out
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def utf8(bytes) = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
76
|
+
|
|
77
|
+
def reset_event
|
|
78
|
+
@event = ""
|
|
79
|
+
@data = +""
|
|
80
|
+
@has_data = false
|
|
81
|
+
@event_bytes = 0
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def line(text, out)
|
|
85
|
+
if text.empty?
|
|
86
|
+
if @has_data
|
|
87
|
+
out << Event.new(event: @event.empty? ? "message" : @event, data: @data.delete_suffix("\n"), id: @id, retry_ms: @retry_ms)
|
|
88
|
+
end
|
|
89
|
+
reset_event
|
|
90
|
+
return
|
|
91
|
+
end
|
|
92
|
+
return if text.start_with?(":")
|
|
93
|
+
|
|
94
|
+
@event_bytes += text.bytesize + 1
|
|
95
|
+
if @event_bytes > @max
|
|
96
|
+
@exceeded = true
|
|
97
|
+
return
|
|
98
|
+
end
|
|
99
|
+
name, value = text.split(":", 2)
|
|
100
|
+
value = value.to_s.delete_prefix(" ")
|
|
101
|
+
case name
|
|
102
|
+
when "event" then @event = value
|
|
103
|
+
when "data"
|
|
104
|
+
@data << value << "\n"
|
|
105
|
+
@has_data = true
|
|
106
|
+
when "id" then @id = value unless value.include?("\u0000")
|
|
107
|
+
when "retry"
|
|
108
|
+
@retry_ms = value.to_i if value.match?(/\A\d+\z/) && value.to_i <= Util::MAX_SAFE_INTEGER
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Joins UTF-8 sequences split across chunks; invalid bytes become U+FFFD.
|
|
114
|
+
class Utf8Decoder
|
|
115
|
+
def initialize
|
|
116
|
+
@pending = "".b
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def decode(data)
|
|
120
|
+
buf = @pending + data.b
|
|
121
|
+
cut = buf.bytesize
|
|
122
|
+
[3, buf.bytesize].min.times do |back|
|
|
123
|
+
i = back + 1
|
|
124
|
+
b = buf.getbyte(buf.bytesize - i)
|
|
125
|
+
next if (b & 0xC0) == 0x80
|
|
126
|
+
|
|
127
|
+
if b.between?(0xC2, 0xF4)
|
|
128
|
+
need = if b >= 0xF0 then 4
|
|
129
|
+
elsif b >= 0xE0 then 3
|
|
130
|
+
else 2
|
|
131
|
+
end
|
|
132
|
+
cut = buf.bytesize - i if need > i
|
|
133
|
+
end
|
|
134
|
+
break
|
|
135
|
+
end
|
|
136
|
+
@pending = buf.byteslice(cut..) || "".b
|
|
137
|
+
buf.byteslice(0, cut).force_encoding(Encoding::UTF_8).scrub("\uFFFD")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def finish
|
|
141
|
+
return "" if @pending.empty?
|
|
142
|
+
|
|
143
|
+
@pending = "".b
|
|
144
|
+
"\uFFFD"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|