dommy 0.12.0 → 0.13.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,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "encodings/tables"
4
+
5
+ module Dommy
6
+ # The Encoding Standard's encodings: every label it knows, and a decoder for
7
+ # each encoding that turns bytes into code points, streaming, with the
8
+ # replacement-or-throw error mode TextDecoder exposes as `fatal`.
9
+ #
10
+ # UTF-8, UTF-16 and the legacy single-byte encodings are decoded here, from
11
+ # the spec's own algorithms and index tables. The legacy multi-byte
12
+ # encodings (Big5, EUC-JP, ISO-2022-JP, Shift_JIS, EUC-KR, GBK, gb18030) go
13
+ # through Ruby's converters for the encoding closest to the spec's index,
14
+ # which differ from it in a few code points and in how many U+FFFD a broken
15
+ # sequence yields.
16
+ #
17
+ # Spec: https://encoding.spec.whatwg.org/
18
+ module Encodings
19
+ # The Ruby encoding whose mapping is closest to the spec's index for each
20
+ # legacy multi-byte encoding.
21
+ RUBY_ENCODINGS = {
22
+ "Big5" => "Big5-HKSCS",
23
+ "EUC-JP" => "EUC-JP",
24
+ "ISO-2022-JP" => "ISO-2022-JP",
25
+ "Shift_JIS" => "Windows-31J",
26
+ "EUC-KR" => "CP949",
27
+ "GBK" => "GBK",
28
+ "gb18030" => "GB18030"
29
+ }.freeze
30
+
31
+ REPLACEMENT_CHARACTER = 0xFFFD
32
+
33
+ # The name of the encoding `label` stands for, or nil. Only ASCII
34
+ # whitespace around the label is ignored.
35
+ #
36
+ # Spec: https://encoding.spec.whatwg.org/#concept-encoding-get
37
+ def self.get(label)
38
+ LABELS[label.gsub(/\A[\t\n\f\r ]+|[\t\n\f\r ]+\z/, "").downcase]
39
+ end
40
+
41
+ # The charset parameter of a MIME type string, or nil.
42
+ def self.charset_of(mime_type)
43
+ match = mime_type.to_s.match(/;\s*charset\s*=\s*(?:"([^"]*)"|([^\s;]*))/i)
44
+ match && (match[1] || match[2])
45
+ end
46
+
47
+ # Bytes to a String in `fallback` (an encoding name), unless they start
48
+ # with a byte-order mark, which names the encoding itself and is dropped.
49
+ # Errors are U+FFFD.
50
+ #
51
+ # Spec: https://encoding.spec.whatwg.org/#decode
52
+ def self.decode(bytes, fallback = "UTF-8")
53
+ bytes = bytes.b
54
+ name = fallback
55
+ if bytes.start_with?("\xEF\xBB\xBF".b)
56
+ name = "UTF-8"
57
+ bytes = bytes.byteslice(3..)
58
+ elsif bytes.start_with?("\xFE\xFF".b)
59
+ name = "UTF-16BE"
60
+ bytes = bytes.byteslice(2..)
61
+ elsif bytes.start_with?("\xFF\xFE".b)
62
+ name = "UTF-16LE"
63
+ bytes = bytes.byteslice(2..)
64
+ end
65
+ decoder_for(name).decode(bytes, flush: true).pack("U*")
66
+ end
67
+
68
+ # A fresh decoder for the encoding named `name` (as `get` returns it).
69
+ def self.decoder_for(name, fatal: false)
70
+ case name
71
+ when "UTF-8" then Utf8Decoder.new(fatal)
72
+ when "UTF-16LE" then Utf16Decoder.new(fatal, big_endian: false)
73
+ when "UTF-16BE" then Utf16Decoder.new(fatal, big_endian: true)
74
+ when "x-user-defined" then XUserDefinedDecoder.new(fatal)
75
+ when "replacement" then ReplacementDecoder.new(fatal)
76
+ else
77
+ index = SINGLE_BYTE_INDEXES[name]
78
+ return SingleByteDecoder.new(fatal, index) if index
79
+
80
+ ConverterDecoder.new(fatal, RUBY_ENCODINGS.fetch(name))
81
+ end
82
+ end
83
+
84
+ # What every decoder shares: `decode(bytes, flush:)` takes a binary String
85
+ # and returns the code points it yields, keeping whatever partial sequence
86
+ # is left over for the next call unless `flush` is true. An error is
87
+ # U+FFFD, or a TypeError in fatal mode.
88
+ class Decoder
89
+ def initialize(fatal)
90
+ @fatal = fatal
91
+ end
92
+
93
+ private
94
+
95
+ def error
96
+ raise Bridge::TypeError, "The encoded data was not valid" if @fatal
97
+
98
+ REPLACEMENT_CHARACTER
99
+ end
100
+ end
101
+
102
+ # Spec: https://encoding.spec.whatwg.org/#utf-8-decoder
103
+ class Utf8Decoder < Decoder
104
+ def initialize(fatal)
105
+ super
106
+ reset
107
+ end
108
+
109
+ def decode(bytes, flush:)
110
+ out = []
111
+ queue = bytes.bytes
112
+ until queue.empty?
113
+ byte = queue.shift
114
+ if @needed.zero?
115
+ if byte <= 0x7F
116
+ out << byte
117
+ elsif byte.between?(0xC2, 0xDF)
118
+ @needed = 1
119
+ @code_point = byte & 0x1F
120
+ elsif byte.between?(0xE0, 0xEF)
121
+ @lower = 0xA0 if byte == 0xE0
122
+ @upper = 0x9F if byte == 0xED
123
+ @needed = 2
124
+ @code_point = byte & 0x0F
125
+ elsif byte.between?(0xF0, 0xF4)
126
+ @lower = 0x90 if byte == 0xF0
127
+ @upper = 0x8F if byte == 0xF4
128
+ @needed = 3
129
+ @code_point = byte & 0x07
130
+ else
131
+ out << error
132
+ end
133
+ next
134
+ end
135
+
136
+ unless byte.between?(@lower, @upper)
137
+ # Not a continuation byte: an error, and the byte is read again.
138
+ reset
139
+ out << error
140
+ queue.unshift(byte)
141
+ next
142
+ end
143
+
144
+ @lower = 0x80
145
+ @upper = 0xBF
146
+ @code_point = (@code_point << 6) | (byte & 0x3F)
147
+ @seen += 1
148
+ next unless @seen == @needed
149
+
150
+ out << @code_point
151
+ reset
152
+ end
153
+
154
+ if flush && @needed != 0
155
+ reset
156
+ out << error
157
+ end
158
+ out
159
+ end
160
+
161
+ private
162
+
163
+ def reset
164
+ @needed = 0
165
+ @seen = 0
166
+ @code_point = 0
167
+ @lower = 0x80
168
+ @upper = 0xBF
169
+ end
170
+ end
171
+
172
+ # Spec: https://encoding.spec.whatwg.org/#shared-utf-16-decoder
173
+ class Utf16Decoder < Decoder
174
+ def initialize(fatal, big_endian:)
175
+ super(fatal)
176
+ @big_endian = big_endian
177
+ @lead_byte = nil
178
+ @lead_surrogate = nil
179
+ end
180
+
181
+ def decode(bytes, flush:)
182
+ out = []
183
+ bytes.each_byte do |byte|
184
+ if @lead_byte.nil?
185
+ @lead_byte = byte
186
+ next
187
+ end
188
+
189
+ code_unit = @big_endian ? ((@lead_byte << 8) + byte) : ((byte << 8) + @lead_byte)
190
+ @lead_byte = nil
191
+
192
+ if @lead_surrogate
193
+ lead_surrogate = @lead_surrogate
194
+ @lead_surrogate = nil
195
+ if code_unit.between?(0xDC00, 0xDFFF)
196
+ out << (0x10000 + ((lead_surrogate - 0xD800) << 10) + (code_unit - 0xDC00))
197
+ next
198
+ end
199
+ # The lead surrogate had no trail: an error, and the code unit's
200
+ # bytes are read again.
201
+ out << error
202
+ out.concat(decode_code_unit(code_unit))
203
+ next
204
+ end
205
+
206
+ out.concat(decode_code_unit(code_unit))
207
+ end
208
+
209
+ if flush && (@lead_byte || @lead_surrogate)
210
+ @lead_byte = nil
211
+ @lead_surrogate = nil
212
+ out << error
213
+ end
214
+ out
215
+ end
216
+
217
+ private
218
+
219
+ def decode_code_unit(code_unit)
220
+ if code_unit.between?(0xD800, 0xDBFF)
221
+ @lead_surrogate = code_unit
222
+ []
223
+ elsif code_unit.between?(0xDC00, 0xDFFF)
224
+ [error]
225
+ else
226
+ [code_unit]
227
+ end
228
+ end
229
+ end
230
+
231
+ # Spec: https://encoding.spec.whatwg.org/#single-byte-decoder
232
+ class SingleByteDecoder < Decoder
233
+ def initialize(fatal, index)
234
+ super(fatal)
235
+ @index = index
236
+ end
237
+
238
+ def decode(bytes, flush:)
239
+ bytes.each_byte.map do |byte|
240
+ if byte <= 0x7F
241
+ byte
242
+ else
243
+ @index[byte - 0x80] || error
244
+ end
245
+ end
246
+ end
247
+ end
248
+
249
+ # Spec: https://encoding.spec.whatwg.org/#x-user-defined-decoder
250
+ class XUserDefinedDecoder < Decoder
251
+ def decode(bytes, flush:)
252
+ bytes.each_byte.map { |byte| byte <= 0x7F ? byte : 0xF780 + byte - 0x80 }
253
+ end
254
+ end
255
+
256
+ # The whole stream is one error, reported once.
257
+ #
258
+ # Spec: https://encoding.spec.whatwg.org/#replacement-decoder
259
+ class ReplacementDecoder < Decoder
260
+ def initialize(fatal)
261
+ super
262
+ @reported = false
263
+ end
264
+
265
+ def decode(bytes, flush:)
266
+ return [] if bytes.empty? || @reported
267
+
268
+ @reported = true
269
+ [error]
270
+ end
271
+ end
272
+
273
+ # A legacy multi-byte encoding, through Ruby's converter. Partial input is
274
+ # held between calls; at a flush what is left over is an error.
275
+ class ConverterDecoder < Decoder
276
+ def initialize(fatal, ruby_encoding)
277
+ super(fatal)
278
+ options = fatal ? {} : {invalid: :replace, undef: :replace, replace: "\uFFFD"}
279
+ @converter = ::Encoding::Converter.new(ruby_encoding, "UTF-8", **options)
280
+ end
281
+
282
+ def decode(bytes, flush:)
283
+ output = +""
284
+ result = @converter.primitive_convert(bytes.dup, output, nil, nil, partial_input: !flush)
285
+ code_points = output.codepoints
286
+ case result
287
+ when :finished, :source_buffer_empty
288
+ code_points
289
+ when :incomplete_input, :invalid_byte_sequence, :undefined_conversion
290
+ # Only reached in fatal mode, or by an incomplete sequence at a
291
+ # flush; either way the stream is over.
292
+ code_points << error
293
+ else
294
+ code_points
295
+ end
296
+ end
297
+ end
298
+ end
299
+ end
data/lib/dommy/event.rb CHANGED
@@ -473,15 +473,14 @@ module Dommy
473
473
  # exception reported as an `error` event at the relevant global (the Window),
474
474
  # so `window.onerror` / an "error" listener sees it. The error value is the
475
475
  # thrown value itself — a JS value keeps its identity (via ThrowValue), so
476
- # `event.error === thrown`. No window (a node in a windowless document, or a
477
- # target that has none) → nothing to report to.
476
+ # `event.error === thrown`; the Ruby exception rides along as `host_error` so
477
+ # an unhandled report reaches the host with its backtrace. No window (a node
478
+ # in a windowless document, or a target that has none) → nothing to report to.
478
479
  def __internal_report_listener_exception__(error)
479
480
  win = window_of(self)
480
481
  return unless win.respond_to?(:__internal_report_exception__)
481
482
 
482
- value = error.is_a?(Bridge::ThrowValue) ? error.value : error
483
- message = value.respond_to?(:message) ? value.message.to_s : value.to_s
484
- win.__internal_report_exception__(value, message)
483
+ Internal::ExceptionReport.report_at(win, error)
485
484
  end
486
485
 
487
486
  # Diagnostic only (DOMMY_EVENT_DEBUG=<file>): when a listener throws, append
data/lib/dommy/fetch.rb CHANGED
@@ -557,6 +557,60 @@ module Dommy
557
557
  end
558
558
  end
559
559
 
560
+ # The `formData()` half of the Body mixin, shared by Request and Response:
561
+ # the body parsed as a FormData by its Content-Type. The including class
562
+ # provides `@headers`, `@window`, `immediate` and `rejected`.
563
+ module FormDataBody
564
+ private
565
+
566
+ # WHATWG: parse the body as a FormData based on Content-Type —
567
+ # application/x-www-form-urlencoded or multipart/form-data. Any other type
568
+ # rejects with a TypeError.
569
+ def consume_form_data(body)
570
+ content_type = (@headers.__js_call__("get", ["content-type"]) || "").to_s
571
+ if content_type.start_with?("application/x-www-form-urlencoded")
572
+ immediate(parse_urlencoded_form(body))
573
+ elsif (match = content_type.match(/\bmultipart\/form-data\b.*?boundary=("?)([^";]+)\1/i))
574
+ immediate(parse_multipart_form(body, match[2]))
575
+ else
576
+ rejected(ErrorValue.new("Failed to read body as FormData: unsupported Content-Type", name: "TypeError"))
577
+ end
578
+ end
579
+
580
+ def parse_urlencoded_form(body)
581
+ form = FormData.new
582
+ URLSearchParams.new(body).__js_call__("entries", []).each { |name, value| form.append(name, value) }
583
+ form
584
+ end
585
+
586
+ # Parse a multipart/form-data body (the inverse of Response.multipart_body):
587
+ # a part with a `filename` becomes a File entry, otherwise a string entry.
588
+ def parse_multipart_form(body, boundary)
589
+ form = FormData.new
590
+ body.split("--#{boundary}").each do |section|
591
+ next if section.empty? || section.start_with?("--") # preamble / closing
592
+
593
+ section = section.sub(/\A\r\n/, "")
594
+ head, content = section.split("\r\n\r\n", 2)
595
+ next unless content
596
+
597
+ content = content.sub(/\r\n\z/, "")
598
+ disposition = head[/Content-Disposition:\s*form-data;([^\r\n]*)/i, 1].to_s
599
+ name = disposition[/name="([^"]*)"/i, 1]
600
+ next unless name
601
+
602
+ filename = disposition[/filename="([^"]*)"/i, 1]
603
+ if filename
604
+ type = head[/Content-Type:\s*([^\r\n]+)/i, 1].to_s.strip
605
+ form.append(name, File.new([content], filename, {"type" => type}, @window))
606
+ else
607
+ form.append(name, content)
608
+ end
609
+ end
610
+ form
611
+ end
612
+ end
613
+
560
614
  # `Request` polyfill — minimal Fetch API Request object so callers
561
615
  # constructing `new Request(url, init)` get a value with `.url`,
562
616
  # `.method`, `.headers`, `.body`. The stub-based `fetch` doesn't
@@ -626,7 +680,8 @@ module Dommy
626
680
  end
627
681
 
628
682
  include Bridge::Methods
629
- js_methods %w[clone text json arrayBuffer blob bytes]
683
+ include FormDataBody
684
+ js_methods %w[clone text json arrayBuffer blob bytes formData]
630
685
  def __js_call__(method, _args)
631
686
  case method
632
687
  when "clone"
@@ -659,6 +714,8 @@ module Dommy
659
714
  when "blob"
660
715
  ct = @headers.__js_call__("get", ["content-type"]) || ""
661
716
  consume_body { immediate(Blob.new([@body_bytes], {"type" => ct}, @window)) }
717
+ when "formData"
718
+ consume_body { consume_form_data(@body_bytes) }
662
719
  end
663
720
  end
664
721
 
@@ -946,6 +1003,7 @@ module Dommy
946
1003
  end
947
1004
 
948
1005
  include Bridge::Methods
1006
+ include FormDataBody
949
1007
  js_methods %w[text json arrayBuffer blob formData clone]
950
1008
  def __js_call__(method, _args)
951
1009
  case method
@@ -966,7 +1024,7 @@ module Dommy
966
1024
  immediate(Blob.new([@body], {"type" => @headers.__js_call__("get", ["content-type"]) || ""}, @window))
967
1025
  end
968
1026
  when "formData"
969
- consume_body { consume_form_data }
1027
+ consume_body { consume_form_data(@body) }
970
1028
  when "clone"
971
1029
  clone_response
972
1030
  end
@@ -1004,53 +1062,6 @@ module Dommy
1004
1062
  end
1005
1063
  end
1006
1064
 
1007
- # WHATWG: parse the body as a FormData based on Content-Type —
1008
- # application/x-www-form-urlencoded or multipart/form-data. Any other type
1009
- # rejects with a TypeError.
1010
- def consume_form_data
1011
- content_type = (@headers.__js_call__("get", ["content-type"]) || "").to_s
1012
- if content_type.start_with?("application/x-www-form-urlencoded")
1013
- immediate(parse_urlencoded_form(@body))
1014
- elsif (match = content_type.match(/\bmultipart\/form-data\b.*?boundary=("?)([^";]+)\1/i))
1015
- immediate(parse_multipart_form(@body, match[2]))
1016
- else
1017
- rejected(ErrorValue.new("Failed to read body as FormData: unsupported Content-Type", name: "TypeError"))
1018
- end
1019
- end
1020
-
1021
- def parse_urlencoded_form(body)
1022
- form = FormData.new
1023
- URLSearchParams.new(body).__js_call__("entries", []).each { |name, value| form.append(name, value) }
1024
- form
1025
- end
1026
-
1027
- # Parse a multipart/form-data body (the inverse of Response.multipart_body):
1028
- # a part with a `filename` becomes a File entry, otherwise a string entry.
1029
- def parse_multipart_form(body, boundary)
1030
- form = FormData.new
1031
- body.split("--#{boundary}").each do |section|
1032
- next if section.empty? || section.start_with?("--") # preamble / closing
1033
-
1034
- section = section.sub(/\A\r\n/, "")
1035
- head, content = section.split("\r\n\r\n", 2)
1036
- next unless content
1037
-
1038
- content = content.sub(/\r\n\z/, "")
1039
- disposition = head[/Content-Disposition:\s*form-data;([^\r\n]*)/i, 1].to_s
1040
- name = disposition[/name="([^"]*)"/i, 1]
1041
- next unless name
1042
-
1043
- filename = disposition[/filename="([^"]*)"/i, 1]
1044
- if filename
1045
- type = head[/Content-Type:\s*([^\r\n]+)/i, 1].to_s.strip
1046
- form.append(name, File.new([content], filename, {"type" => type}, @window))
1047
- else
1048
- form.append(name, content)
1049
- end
1050
- end
1051
- form
1052
- end
1053
-
1054
1065
  # WHATWG: clone throws if the body is already disturbed/locked; otherwise the
1055
1066
  # clone is an independent Response over the same bytes.
1056
1067
  def clone_response
@@ -15,6 +15,16 @@ module Dommy
15
15
  class FormData
16
16
  include Enumerable
17
17
 
18
+ # `new FormData(form)` from JavaScript: an absent or undefined form means
19
+ # an empty FormData; null or anything but a form element is a TypeError,
20
+ # since the argument is a non-nullable HTMLFormElement.
21
+ def self.from_js(args)
22
+ return new if args.empty? || args[0].equal?(Bridge::UNDEFINED)
23
+ raise Bridge::TypeError, "FormData constructor: argument 1 is not an HTMLFormElement" unless args[0].is_a?(HTMLFormElement)
24
+
25
+ new(args[0])
26
+ end
27
+
18
28
  def initialize(form = nil)
19
29
  @pairs = []
20
30
  collect_from(form) if form