dommy 0.6.0 → 0.8.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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -38
  3. data/lib/dommy/animation.rb +10 -2
  4. data/lib/dommy/attr.rb +197 -32
  5. data/lib/dommy/backend/nokogiri_adapter.rb +127 -0
  6. data/lib/dommy/backend/nokolexbor_adapter.rb +117 -0
  7. data/lib/dommy/backend.rb +175 -0
  8. data/lib/dommy/blob.rb +30 -11
  9. data/lib/dommy/bridge/constructor_registry.rb +28 -0
  10. data/lib/dommy/bridge/methods.rb +57 -0
  11. data/lib/dommy/bridge.rb +97 -0
  12. data/lib/dommy/callable_invoker.rb +36 -0
  13. data/lib/dommy/compression_streams.rb +4 -4
  14. data/lib/dommy/cookie_store.rb +4 -2
  15. data/lib/dommy/crypto.rb +16 -9
  16. data/lib/dommy/css.rb +53 -7
  17. data/lib/dommy/custom_elements.rb +33 -9
  18. data/lib/dommy/data_transfer.rb +4 -0
  19. data/lib/dommy/document.rb +693 -60
  20. data/lib/dommy/dom_parser.rb +29 -15
  21. data/lib/dommy/element.rb +1147 -438
  22. data/lib/dommy/event.rb +279 -79
  23. data/lib/dommy/event_source.rb +14 -10
  24. data/lib/dommy/fetch.rb +509 -39
  25. data/lib/dommy/file_reader.rb +14 -6
  26. data/lib/dommy/form_data.rb +3 -3
  27. data/lib/dommy/history.rb +46 -8
  28. data/lib/dommy/html_collection.rb +59 -6
  29. data/lib/dommy/html_elements.rb +153 -1502
  30. data/lib/dommy/internal/css_pseudo_handlers.rb +137 -0
  31. data/lib/dommy/internal/dom_matching.rb +3 -3
  32. data/lib/dommy/internal/global_functions.rb +26 -0
  33. data/lib/dommy/internal/idna.rb +16 -7
  34. data/lib/dommy/internal/ipv4_parser.rb +22 -7
  35. data/lib/dommy/internal/mutation_coordinator.rb +11 -2
  36. data/lib/dommy/internal/namespaces.rb +70 -0
  37. data/lib/dommy/internal/node_equality.rb +86 -0
  38. data/lib/dommy/internal/node_traversal.rb +1 -1
  39. data/lib/dommy/internal/node_wrapper_cache.rb +77 -31
  40. data/lib/dommy/internal/observable_callback.rb +1 -5
  41. data/lib/dommy/internal/parent_node.rb +126 -0
  42. data/lib/dommy/internal/reflected_attributes.rb +103 -13
  43. data/lib/dommy/internal/selector_parser.rb +664 -0
  44. data/lib/dommy/internal/template_content_registry.rb +6 -6
  45. data/lib/dommy/internal/url_parser.rb +677 -0
  46. data/lib/dommy/intersection_observer.rb +4 -2
  47. data/lib/dommy/location.rb +10 -4
  48. data/lib/dommy/media_query_list.rb +10 -4
  49. data/lib/dommy/message_channel.rb +41 -11
  50. data/lib/dommy/mutation_observer.rb +76 -23
  51. data/lib/dommy/navigator.rb +38 -24
  52. data/lib/dommy/node.rb +158 -16
  53. data/lib/dommy/notification.rb +6 -4
  54. data/lib/dommy/parser.rb +13 -13
  55. data/lib/dommy/performance.rb +4 -0
  56. data/lib/dommy/performance_observer.rb +4 -2
  57. data/lib/dommy/promise.rb +14 -14
  58. data/lib/dommy/range.rb +74 -5
  59. data/lib/dommy/resize_observer.rb +4 -2
  60. data/lib/dommy/scheduler.rb +34 -13
  61. data/lib/dommy/shadow_root.rb +31 -60
  62. data/lib/dommy/storage.rb +2 -0
  63. data/lib/dommy/streams.rb +40 -49
  64. data/lib/dommy/svg_elements.rb +204 -3606
  65. data/lib/dommy/text_codec.rb +178 -25
  66. data/lib/dommy/tree_walker.rb +270 -81
  67. data/lib/dommy/url.rb +305 -450
  68. data/lib/dommy/url_pattern.rb +2 -0
  69. data/lib/dommy/version.rb +1 -1
  70. data/lib/dommy/web_socket.rb +49 -19
  71. data/lib/dommy/window.rb +205 -203
  72. data/lib/dommy/worker.rb +12 -12
  73. data/lib/dommy/xml_http_request.rb +32 -7
  74. data/lib/dommy.rb +19 -2
  75. metadata +22 -27
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokolexbor"
4
+
5
+ module Dommy
6
+ module Backend
7
+ # Nokolexbor (Lexbor-based) backend. 3-7× faster CSS selectors,
8
+ # 6× faster HTML5 parsing. HTML-only — XML namespaces are not
9
+ # tracked, so SVG detection falls back to ancestor walking.
10
+ module Nokolexbor
11
+ # Class references for `is_a?` / type-checking.
12
+ Element = ::Nokolexbor::Element
13
+ Document = ::Nokolexbor::Document
14
+ Text = ::Nokolexbor::Text
15
+ Comment = ::Nokolexbor::Comment
16
+ DocumentFragment = ::Nokolexbor::DocumentFragment
17
+ Node = ::Nokolexbor::Node
18
+
19
+ # Fake "namespace" wrapper returned for nodes inside <svg> subtrees.
20
+ # Provides the same `href` API that Nokogiri's Namespace object has,
21
+ # so calling code can treat them uniformly.
22
+ SvgNamespace = Struct.new(:href) do
23
+ def initialize
24
+ super("http://www.w3.org/2000/svg")
25
+ end
26
+ end
27
+
28
+ SVG_NAMESPACE = SvgNamespace.new.freeze
29
+
30
+ module_function
31
+
32
+ def parse(html)
33
+ ::Nokolexbor.parse(html.to_s)
34
+ end
35
+
36
+ def fragment(html, owner_doc:)
37
+ ::Nokolexbor::DocumentFragment.parse(html.to_s)
38
+ end
39
+
40
+ def create_element(name, doc)
41
+ ::Nokolexbor::Node.new(name, doc)
42
+ end
43
+
44
+ def create_text(content, doc)
45
+ ::Nokolexbor::Text.new(content, doc)
46
+ end
47
+
48
+ def create_comment(content, doc)
49
+ # Nokolexbor's argument order is (content, doc).
50
+ ::Nokolexbor::Comment.new(content, doc)
51
+ end
52
+
53
+ # Nokolexbor doesn't track XML namespaces. We synthesize one for
54
+ # SVG by walking ancestors — necessary so `element_class_for`
55
+ # routes SVG tags to their specialized classes.
56
+ def namespace_of(node)
57
+ return nil unless node.respond_to?(:name)
58
+
59
+ in_svg_subtree?(node) ? SVG_NAMESPACE : nil
60
+ end
61
+
62
+ def add_namespace_definition(_node, _prefix, _href)
63
+ # No-op: Nokolexbor doesn't support XML namespaces.
64
+ end
65
+
66
+ # ----- Namespaced attributes (degraded) -----
67
+ # Nokolexbor has no namespace model, so *AttributeNS collapses to the
68
+ # qualified name in the null namespace. Fine for HTML (all attributes are
69
+ # null-namespace); foreign-content (SVG/MathML) fidelity is lost.
70
+
71
+ def get_attribute_ns(node, _namespace, local_name)
72
+ node[local_name.to_s]
73
+ end
74
+
75
+ def has_attribute_ns?(node, _namespace, local_name)
76
+ node.key?(local_name.to_s)
77
+ end
78
+
79
+ def set_attribute_ns(node, _namespace, _prefix, _local_name, qualified_name, value)
80
+ node[qualified_name] = value.to_s
81
+ value.to_s
82
+ end
83
+
84
+ def remove_attribute_ns(node, _namespace, local_name)
85
+ node.remove_attribute(local_name.to_s) if node.key?(local_name.to_s)
86
+ nil
87
+ end
88
+
89
+ def attribute_ns_info(attr_node)
90
+ {
91
+ namespace_uri: nil,
92
+ prefix: nil,
93
+ local_name: attr_node.name,
94
+ qualified_name: attr_node.name,
95
+ value: attr_node.value,
96
+ }
97
+ end
98
+
99
+ def attribute_nodes(node)
100
+ node.attribute_nodes
101
+ end
102
+
103
+ # Internal helper — visible to allow testing.
104
+ def in_svg_subtree?(node)
105
+ return true if node.name.to_s.downcase == "svg"
106
+
107
+ current = node.parent
108
+ while current
109
+ return true if current.respond_to?(:name) && current.name.to_s.downcase == "svg"
110
+ current = current.respond_to?(:parent) ? current.parent : nil
111
+ end
112
+
113
+ false
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ # `Dommy::Backend` — pluggable HTML parser abstraction. Lets Dommy
5
+ # work with either Nokogiri (mature, full namespace support) or
6
+ # Nokolexbor (faster, HTML5-only). Internally, all DOM library
7
+ # code goes through this facade rather than referencing the parser
8
+ # directly.
9
+ #
10
+ # Defaults to Nokogiri if available, else Nokolexbor.
11
+ #
12
+ # Switching backends:
13
+ #
14
+ # require "dommy"
15
+ # Dommy::Backend.use(:nokolexbor)
16
+ #
17
+ # Or set directly:
18
+ #
19
+ # Dommy::Backend.current = Dommy::Backend::Nokolexbor
20
+ #
21
+ # All adapters must implement the same interface — see
22
+ # `Backend::Nokogiri` for the canonical reference.
23
+ module Backend
24
+ class BackendNotAvailable < StandardError
25
+ end
26
+
27
+ class << self
28
+ def current
29
+ @current ||= detect_default
30
+ end
31
+
32
+ attr_writer :current
33
+
34
+ def use(name)
35
+ @current = case name.to_sym
36
+ when :nokogiri
37
+ require_relative "backend/nokogiri_adapter"
38
+ Nokogiri
39
+ when :nokolexbor
40
+ require_relative "backend/nokolexbor_adapter"
41
+ Nokolexbor
42
+ else
43
+ raise ArgumentError, "Unknown backend: #{name.inspect}. Use :nokogiri or :nokolexbor."
44
+ end
45
+ end
46
+
47
+ # Delegate calls so internal code can use `Backend.parse(...)`.
48
+ def parse(html)
49
+ current.parse(html)
50
+ end
51
+
52
+ # Parse XML input into an XML document. Backends without a real XML parser
53
+ # (HTML-only, e.g. Nokolexbor) fall back to the HTML parser.
54
+ def parse_xml(xml)
55
+ current.respond_to?(:parse_xml) ? current.parse_xml(xml) : current.parse(xml)
56
+ end
57
+
58
+ def fragment(html, owner_doc:)
59
+ current.fragment(html, owner_doc: owner_doc)
60
+ end
61
+
62
+ def create_element(name, doc)
63
+ current.create_element(name, doc)
64
+ end
65
+
66
+ def create_text(content, doc)
67
+ current.create_text(content, doc)
68
+ end
69
+
70
+ def create_comment(content, doc)
71
+ current.create_comment(content, doc)
72
+ end
73
+
74
+ # CDATA section node (XML documents). Backends without CDATA fall back to a
75
+ # text node.
76
+ def create_cdata(content, doc)
77
+ current.respond_to?(:create_cdata) ? current.create_cdata(content, doc) : current.create_text(content, doc)
78
+ end
79
+
80
+ def cdata_class
81
+ current.respond_to?(:cdata_class) ? current.cdata_class : nil
82
+ end
83
+
84
+ def namespace_of(node)
85
+ current.namespace_of(node)
86
+ end
87
+
88
+ def add_namespace_definition(node, prefix, href)
89
+ current.add_namespace_definition(node, prefix, href)
90
+ end
91
+
92
+ # Namespaced attribute access (DOM *AttributeNS). `namespace` is an href
93
+ # String or nil. Nokolexbor degrades to qualified-name (null-namespace).
94
+ def get_attribute_ns(node, namespace, local_name)
95
+ current.get_attribute_ns(node, namespace, local_name)
96
+ end
97
+
98
+ def set_attribute_ns(node, namespace, prefix, local_name, qualified_name, value)
99
+ current.set_attribute_ns(node, namespace, prefix, local_name, qualified_name, value)
100
+ end
101
+
102
+ def remove_attribute_ns(node, namespace, local_name)
103
+ current.remove_attribute_ns(node, namespace, local_name)
104
+ end
105
+
106
+ def has_attribute_ns?(node, namespace, local_name)
107
+ current.has_attribute_ns?(node, namespace, local_name)
108
+ end
109
+
110
+ # Reads a backend attribute node into {namespace_uri:, prefix:,
111
+ # local_name:, qualified_name:, value:} (namespace-aware).
112
+ def attribute_ns_info(attr_node)
113
+ current.attribute_ns_info(attr_node)
114
+ end
115
+
116
+ # The element's attribute nodes (each readable via attribute_ns_info).
117
+ # The single choke point so DOM code doesn't touch parser internals.
118
+ def attribute_nodes(node)
119
+ current.attribute_nodes(node)
120
+ end
121
+
122
+ # Type constants — proxy through to the current backend so
123
+ # `node.is_a?(Backend::Element)` resolves dynamically.
124
+ def element_class
125
+ current::Element
126
+ end
127
+
128
+ def document_class
129
+ current::Document
130
+ end
131
+
132
+ def text_class
133
+ current::Text
134
+ end
135
+
136
+ def comment_class
137
+ current::Comment
138
+ end
139
+
140
+ def document_fragment_class
141
+ current::DocumentFragment
142
+ end
143
+
144
+ def node_class
145
+ current::Node
146
+ end
147
+
148
+ private
149
+
150
+ def detect_default
151
+ try_nokogiri ||
152
+ try_nokolexbor ||
153
+ raise(BackendNotAvailable, "Dommy requires either 'nokogiri' or 'nokolexbor' gem to be installed.")
154
+ end
155
+
156
+ def try_nokogiri
157
+ require "nokogiri"
158
+
159
+ require_relative "backend/nokogiri_adapter"
160
+ Nokogiri
161
+ rescue LoadError
162
+ nil
163
+ end
164
+
165
+ def try_nokolexbor
166
+ require "nokolexbor"
167
+
168
+ require_relative "backend/nokolexbor_adapter"
169
+ Nokolexbor
170
+ rescue LoadError
171
+ nil
172
+ end
173
+ end
174
+ end
175
+ end
data/lib/dommy/blob.rb CHANGED
@@ -17,12 +17,16 @@ module Dommy
17
17
  # - anything else: coerced via to_s
18
18
  #
19
19
  # `options["type"]` sets the MIME type (lowercased per spec).
20
- def initialize(parts = [], options = {})
20
+ # `window` (optional) lets the JS-facing `text()`/`arrayBuffer()` return real
21
+ # Promises (they need a scheduler). A window-less Blob falls back to a
22
+ # synchronous result, which `await` still handles.
23
+ def initialize(parts = [], options = {}, window = nil)
21
24
  parts = [parts] unless parts.is_a?(Array)
22
25
  @data = collect_bytes(parts)
23
26
  @size = @data.bytesize
24
27
  raw_type = options["type"] || options[:type] || ""
25
28
  @type = raw_type.to_s.downcase
29
+ @window = window
26
30
  end
27
31
 
28
32
  # Return a new Blob over a byte range of this one.
@@ -31,7 +35,7 @@ module Dommy
31
35
  s = clamp_index(start.to_i, @size)
32
36
  e = clamp_index(last.to_i, @size)
33
37
  e = s if e < s
34
- Blob.new([@data.byteslice(s, e - s) || ""], "type" => content_type.to_s)
38
+ Blob.new([@data.byteslice(s, e - s) || ""], {"type" => content_type.to_s}, @window)
35
39
  end
36
40
 
37
41
  # Read the bytes as UTF-8 text. The DOM spec returns a Promise,
@@ -40,15 +44,16 @@ module Dommy
40
44
  @data.dup.force_encoding(Encoding::UTF_8)
41
45
  end
42
46
 
43
- # Read the bytes as an Array<Integer>. The DOM spec returns a
44
- # Promise<ArrayBuffer>; Dommy is synchronous.
47
+ # Read the bytes as a real ArrayBuffer (the spec return type, wrapped so it
48
+ # crosses the JS boundary as a bare ArrayBuffer rather than an Array/typed
49
+ # array). The DOM spec returns a Promise<ArrayBuffer>; Dommy is synchronous.
45
50
  def array_buffer
46
- @data.bytes
51
+ Bridge::ArrayBuffer.new(@data.bytes)
47
52
  end
48
53
 
49
54
  # Raw binary bytes (Ruby ASCII-8BIT string). Used by FormData /
50
55
  # fetch when serializing multipart bodies.
51
- def __bytes__
56
+ def __dommy_bytes__
52
57
  @data
53
58
  end
54
59
 
@@ -61,25 +66,37 @@ module Dommy
61
66
  end
62
67
  end
63
68
 
69
+ # Methods routed through __js_call__ (keep in sync with its when-arms).
70
+ # File < Blob inherits these (it adds only properties).
71
+ include Bridge::Methods
72
+ js_methods %w[slice text arrayBuffer]
64
73
  def __js_call__(method, args)
65
74
  case method
66
75
  when "slice"
67
76
  slice(args[0] || 0, args[1] || @size, args[2] || "")
68
77
  when "text"
69
- text
78
+ # WHATWG: Blob.text() returns a Promise<string>.
79
+ promise_or_value(text)
70
80
  when "arrayBuffer"
71
- array_buffer
81
+ # WHATWG: Blob.arrayBuffer() returns a Promise<ArrayBuffer>.
82
+ promise_or_value(array_buffer)
72
83
  end
73
84
  end
74
85
 
75
86
  private
76
87
 
88
+ # Wrap a consumed value in a resolved Promise when a window is available;
89
+ # otherwise return it directly (a window-less Blob — `await` copes either way).
90
+ def promise_or_value(value)
91
+ @window ? PromiseValue.resolve(@window, value) : value
92
+ end
93
+
77
94
  def collect_bytes(parts)
78
95
  buf = String.new(encoding: Encoding::ASCII_8BIT)
79
96
  parts.each do |part|
80
97
  case part
81
98
  when Blob
82
- buf << part.__bytes__
99
+ buf << part.__dommy_bytes__
83
100
  when String
84
101
  buf << part.dup.force_encoding(Encoding::ASCII_8BIT)
85
102
  when Array
@@ -106,8 +123,8 @@ module Dommy
106
123
  class File < Blob
107
124
  attr_reader :name, :last_modified
108
125
 
109
- def initialize(parts, name, options = {})
110
- super(parts, options)
126
+ def initialize(parts, name, options = {}, window = nil)
127
+ super(parts, options, window)
111
128
  @name = name.to_s
112
129
  raw_lm = options["lastModified"] || options[:lastModified]
113
130
  @last_modified = (raw_lm || (Time.now.to_f * 1000)).to_i
@@ -172,6 +189,8 @@ module Dommy
172
189
  end
173
190
  end
174
191
 
192
+ include Bridge::Methods
193
+ js_methods %w[item]
175
194
  def __js_call__(method, args)
176
195
  case method
177
196
  when "item"
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Bridge
5
+ # Maps JS global constructor names (e.g. "Event", "URL", "XMLHttpRequest")
6
+ # to their `Bridge::Constructor` instances. Window builds one and routes
7
+ # `__js_get__` name lookups through it, instead of carrying one ivar plus
8
+ # one `when` arm per constructor.
9
+ class ConstructorRegistry
10
+ def initialize(map)
11
+ @map = map.freeze
12
+ end
13
+
14
+ def [](name)
15
+ @map[name]
16
+ end
17
+
18
+ def key?(name)
19
+ @map.key?(name)
20
+ end
21
+
22
+ # The set of constructor names, e.g. for host-side enumeration / tests.
23
+ def names
24
+ @map.keys
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Bridge
5
+ # Declares, in one line, the set of JS-callable method names a bridge class
6
+ # routes through `__js_call__` (as opposed to data properties read via
7
+ # `__js_get__`). The QuickJS host reads `__js_method_names__` once per
8
+ # interface to decide which property names to expose as callable functions.
9
+ #
10
+ # class Blob
11
+ # include Bridge::Methods
12
+ # js_methods %w[slice text arrayBuffer]
13
+ # def __js_call__(method, args) = ...
14
+ # end
15
+ #
16
+ # Subclasses compose automatically: a subclass's own `js_methods` are merged
17
+ # with its ancestors' (ancestors first), so `__js_call__ ... else super`
18
+ # chains stay in sync with the exposed names without a manual `super + own`.
19
+ #
20
+ # The per-class `JS_METHOD_NAMES` constant holds the class's OWN names; the
21
+ # suite asserts it matches the class's own `__js_call__` `when` arms — see
22
+ # test/test_js_call_dispatch_invariant.rb.
23
+ module Methods
24
+ def self.included(base)
25
+ base.extend(ClassMethods)
26
+ end
27
+
28
+ module ClassMethods
29
+ # `extend` is per-singleton, so a subclass of an includer would not
30
+ # inherit `js_methods`. Re-extend each subclass as it is defined.
31
+ def inherited(subclass)
32
+ super
33
+ subclass.extend(ClassMethods)
34
+ end
35
+
36
+ def js_methods(names)
37
+ own = names.map(&:to_s).freeze
38
+ const_set(:JS_METHOD_NAMES, own) unless const_defined?(:JS_METHOD_NAMES, false)
39
+
40
+ # Capture the ancestor's __js_method_names__ as a real method (if any)
41
+ # at definition time. We can't use `super` here: classes like
42
+ # StyleDeclaration define `method_missing`, so `super` would fall
43
+ # through to it and return a CSS-property String instead of raising.
44
+ parent =
45
+ if superclass.method_defined?(:__js_method_names__)
46
+ superclass.instance_method(:__js_method_names__)
47
+ end
48
+
49
+ define_method(:__js_method_names__) do
50
+ base = parent ? parent.bind(self).call : []
51
+ (base + own).uniq.freeze
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
data/lib/dommy/bridge.rb CHANGED
@@ -18,6 +18,97 @@ module Dommy
18
18
  # args (Array)
19
19
  # - `__js_new__(args)` invokes the value as a JS constructor
20
20
  module Bridge
21
+ # Sentinel returned by `__js_set__` when a key is not a known DOM property
22
+ # (so the JS host can keep it as a JS-side expando, preserving identity)
23
+ # rather than silently dropping it.
24
+ UNHANDLED = :__js_unhandled__
25
+
26
+ # Sentinel for the JS `undefined` value, used in both directions:
27
+ # - a `__js_call__` returns it for a void (undefined-returning) op, so the
28
+ # host marshals JS `undefined` rather than the `null` a bare Ruby `nil`
29
+ # would (e.g. DOMTokenList add/remove return undefined);
30
+ # - a top-level JS `undefined` *argument* arrives as it (whereas JS `null`
31
+ # arrives as `nil`), so WebIDL-style dispatch can tell an omitted optional
32
+ # argument from an explicit null.
33
+ # Its `to_s` is "undefined" so a DOMString coercion of a stray undefined is
34
+ # still spec-faithful.
35
+ UNDEFINED = Object.new
36
+ def UNDEFINED.to_s = "undefined"
37
+ def UNDEFINED.inspect = "#<Dommy::Bridge::UNDEFINED>"
38
+ UNDEFINED.freeze
39
+
40
+ # An opaque handle to a JS-side value that Ruby only stores and hands back
41
+ # (an AbortSignal's reason, a CustomEvent's detail). A non-plain JS object
42
+ # (Error, class instance, …) crosses as one of these instead of being
43
+ # flattened to a Hash, so it round-trips with IDENTITY preserved. `to_s`
44
+ # exposes the captured JS string form for the rare Ruby consumer that needs
45
+ # text (e.g. building a message).
46
+ class JSValue
47
+ attr_reader :ref
48
+
49
+ def initialize(ref, label = nil)
50
+ @ref = ref
51
+ @label = label
52
+ end
53
+
54
+ def to_s = (@label || "[object]").to_s
55
+ def inspect = "#<Dommy::Bridge::JSValue #{to_s}>"
56
+ end
57
+
58
+ # Raised by a host method that must throw an ARBITRARY value back to JS —
59
+ # not a DOMException/Error, but e.g. `signal.throwIfAborted()` throwing the
60
+ # exact abort reason (a string, number, or opaque JSValue). The bridge
61
+ # re-throws the wrapped value verbatim (identity preserved). Subclasses
62
+ # RuntimeError (with the value's string form as the message) so standalone
63
+ # CRuby callers still see a normal `raise`-able error.
64
+ class ThrowValue < RuntimeError
65
+ attr_reader :value
66
+
67
+ def initialize(value)
68
+ @value = value
69
+ super(value.to_s)
70
+ end
71
+ end
72
+
73
+ # A byte buffer that crosses the JS boundary as a `Uint8Array` (rather than a
74
+ # plain Array). Wrap a host method's byte-array result in this so JS sees a
75
+ # real typed array — e.g. `TextEncoder#encode`, `Blob#arrayBuffer`. The
76
+ # reverse direction (a JS ArrayBuffer/TypedArray argument) arrives as a
77
+ # `Bytes` too. It subclasses Array so plain-Array callers (and `== [..]`
78
+ # comparisons) keep working; only the bridge treats it specially.
79
+ class Bytes < ::Array
80
+ def initialize(bytes = [])
81
+ super()
82
+ concat(Array(bytes).map { |b| b.to_i & 0xFF })
83
+ end
84
+
85
+ alias bytes to_a
86
+ def pack_bytes = pack("C*")
87
+ end
88
+
89
+ # Like `Bytes`, but crosses the JS boundary as a bare `ArrayBuffer` rather
90
+ # than a `Uint8Array` view. Use this for the spec methods whose return type
91
+ # is `ArrayBuffer` — `Response`/`Blob`/`FileReader`/`XMLHttpRequest`'s
92
+ # `arrayBuffer`. Subclasses `Bytes` so Ruby-level `== [..]` comparisons still
93
+ # hold; only the bridge distinguishes it (and must check it before `Bytes`).
94
+ class ArrayBuffer < Bytes
95
+ end
96
+
97
+ # A Ruby-side signal that the JS boundary should surface a JS `TypeError`
98
+ # (not a `DOMException`). Some WebIDL operations — notably the `URL`
99
+ # constructor and its `href` setter — throw `TypeError` on failure rather
100
+ # than a DOMException; raising this lets a host bridge rethrow the correct
101
+ # JS error type, while Ruby callers can still rescue it like any other
102
+ # error. Kept distinct from Ruby's built-in `::TypeError` so a host can map
103
+ # only deliberate, spec-mandated TypeErrors (and not mask genuine Ruby type
104
+ # bugs) across the boundary.
105
+ class TypeError < ::StandardError; end
106
+
107
+ # Like `Bridge::TypeError`, but for spec-mandated `RangeError`s (e.g. the
108
+ # `Response` constructor rejecting a status outside 200–599). A host bridge
109
+ # rethrows a real JS `RangeError`; kept distinct from Ruby's `::RangeError`.
110
+ class RangeError < ::StandardError; end
111
+
21
112
  # Wraps an external callback handle (registered in a host-side
22
113
  # callback table) so the JS bridge can resolve / invoke it. The
23
114
  # external host that creates these is responsible for honoring
@@ -83,6 +174,12 @@ module Dommy
83
174
  handler = @class_methods[method.to_s]
84
175
  handler&.call(args)
85
176
  end
177
+
178
+ # Names of the registered class-level (static) methods, so a JS host can
179
+ # expose them on the constructor function (e.g. `URL.createObjectURL`).
180
+ def __js_class_method_names__
181
+ @class_methods.keys
182
+ end
86
183
  end
87
184
 
88
185
  # `JS.global[:Promise]` view. Implements the `resolve` / `reject`
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ # Invokes a callback that may be a JS-bridged object (responds to `__js_call__`)
5
+ # or a plain Ruby callable (responds to `call`). Centralizes the dispatch used
6
+ # by promises, the scheduler, and streams so the JS/Ruby fork lives in one place.
7
+ module CallableInvoker
8
+ module_function
9
+
10
+ # Invoke `callback` with `args`. A JS-bridged callable goes through
11
+ # `__js_call__("call", args)`; a Ruby callable through `call(*args)`. A nil
12
+ # or non-callable callback is a no-op (returns nil).
13
+ def invoke(callback, *args)
14
+ return if callback.nil?
15
+
16
+ if callback.respond_to?(:__js_call__)
17
+ callback.__js_call__("call", args)
18
+ elsif callback.respond_to?(:call)
19
+ callback.call(*args)
20
+ end
21
+ end
22
+
23
+ # Invoke a DOM event listener per the EventTarget rule: an object with
24
+ # `handle_event`, else a Ruby callable, else a JS-bridged callable (tried in
25
+ # that order).
26
+ def invoke_listener(listener, event)
27
+ if listener.respond_to?(:handle_event)
28
+ listener.handle_event(event)
29
+ elsif listener.respond_to?(:call) && !listener.is_a?(Module)
30
+ listener.call(event)
31
+ elsif listener.respond_to?(:__js_call__)
32
+ listener.__js_call__("call", [event])
33
+ end
34
+ end
35
+ end
36
+ end
@@ -31,9 +31,9 @@ module Dommy
31
31
  "close" => proc do
32
32
  compressed = compressor.call(@buffer)
33
33
  controller.enqueue(compressed)
34
- @readable.__close__
34
+ @readable.__internal_close__
35
35
  end,
36
- "abort" => proc { |r| @readable.__error__(r) }
36
+ "abort" => proc { |r| @readable.__internal_error__(r) }
37
37
  }
38
38
  )
39
39
  end
@@ -98,9 +98,9 @@ module Dommy
98
98
  "close" => proc do
99
99
  plain = decompressor.call(@buffer)
100
100
  controller.enqueue(plain)
101
- @readable.__close__
101
+ @readable.__internal_close__
102
102
  end,
103
- "abort" => proc { |r| @readable.__error__(r) }
103
+ "abort" => proc { |r| @readable.__internal_error__(r) }
104
104
  }
105
105
  )
106
106
  end
@@ -60,6 +60,8 @@ module Dommy
60
60
  PromiseValue.resolve(@window, nil)
61
61
  end
62
62
 
63
+ include Bridge::Methods
64
+ js_methods %w[get getAll set delete addEventListener removeEventListener dispatchEvent]
63
65
  def __js_call__(method, args)
64
66
  case method
65
67
  when "get"
@@ -73,13 +75,13 @@ module Dommy
73
75
  when "addEventListener"
74
76
  add_event_listener(args[0], args[1], args[2])
75
77
  when "removeEventListener"
76
- remove_event_listener(args[0], args[1])
78
+ remove_event_listener(args[0], args[1], args[2])
77
79
  when "dispatchEvent"
78
80
  dispatch_event(args[0])
79
81
  end
80
82
  end
81
83
 
82
- def __event_parent__
84
+ def __internal_event_parent__
83
85
  nil
84
86
  end
85
87