dommy 0.8.1 → 0.10.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 +4 -4
- data/README.md +3 -3
- data/lib/dommy/animation.rb +4 -0
- data/lib/dommy/attr.rb +57 -13
- data/lib/dommy/backend/makiri_adapter.rb +364 -0
- data/lib/dommy/backend.rb +143 -33
- data/lib/dommy/blob.rb +50 -6
- data/lib/dommy/bridge.rb +11 -0
- data/lib/dommy/browser.rb +420 -0
- data/lib/dommy/callable_invoker.rb +6 -2
- data/lib/dommy/compression_streams.rb +4 -0
- data/lib/dommy/crypto.rb +4 -0
- data/lib/dommy/css.rb +487 -50
- data/lib/dommy/custom_elements.rb +2 -2
- data/lib/dommy/data_transfer.rb +2 -0
- data/lib/dommy/data_uri.rb +35 -0
- data/lib/dommy/deferred_response.rb +59 -0
- data/lib/dommy/document.rb +901 -275
- data/lib/dommy/dom_exception.rb +2 -0
- data/lib/dommy/dom_parser.rb +7 -17
- data/lib/dommy/element.rb +940 -353
- data/lib/dommy/event.rb +529 -17
- data/lib/dommy/fetch.rb +577 -53
- data/lib/dommy/form_data.rb +2 -0
- data/lib/dommy/history.rb +26 -3
- data/lib/dommy/html_canvas_element.rb +230 -0
- data/lib/dommy/html_collection.rb +150 -27
- data/lib/dommy/html_elements.rb +1968 -282
- data/lib/dommy/interaction/debug.rb +35 -0
- data/lib/dommy/interaction/dom_summary.rb +131 -0
- data/lib/dommy/interaction/driver.rb +385 -0
- data/lib/dommy/interaction/event_synthesis.rb +164 -0
- data/lib/dommy/interaction/field_interactor.rb +177 -0
- data/lib/dommy/interaction/form_submission.rb +268 -0
- data/lib/dommy/interaction/locator.rb +158 -0
- data/lib/dommy/interaction/role_query.rb +58 -0
- data/lib/dommy/interaction.rb +32 -0
- data/lib/dommy/internal/accessibility_tree.rb +215 -0
- data/lib/dommy/internal/accessible_description.rb +38 -0
- data/lib/dommy/internal/accessible_name.rb +301 -0
- data/lib/dommy/internal/aria_role.rb +252 -0
- data/lib/dommy/internal/aria_snapshot.rb +64 -0
- data/lib/dommy/internal/aria_state.rb +151 -0
- data/lib/dommy/internal/child_node.rb +199 -0
- data/lib/dommy/internal/css/calc.rb +242 -0
- data/lib/dommy/internal/css/cascade.rb +472 -0
- data/lib/dommy/internal/css/color.rb +381 -0
- data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
- data/lib/dommy/internal/css/counters.rb +227 -0
- data/lib/dommy/internal/css/custom_properties.rb +183 -0
- data/lib/dommy/internal/css/media_query.rb +302 -0
- data/lib/dommy/internal/css/parser.rb +265 -0
- data/lib/dommy/internal/css/property_registry.rb +512 -0
- data/lib/dommy/internal/css/rule_index.rb +494 -0
- data/lib/dommy/internal/css/supports.rb +158 -0
- data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
- data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
- data/lib/dommy/internal/css_rule_text.rb +160 -0
- data/lib/dommy/internal/dom_matching.rb +80 -9
- data/lib/dommy/internal/element_matching.rb +109 -0
- data/lib/dommy/internal/global_functions.rb +55 -9
- data/lib/dommy/internal/mutation_coordinator.rb +122 -11
- data/lib/dommy/internal/namespaces.rb +49 -5
- data/lib/dommy/internal/node_wrapper_cache.rb +217 -38
- data/lib/dommy/internal/observer_manager.rb +6 -0
- data/lib/dommy/internal/observer_matcher.rb +6 -8
- data/lib/dommy/internal/parent_node.rb +98 -57
- data/lib/dommy/internal/selector_ast.rb +124 -0
- data/lib/dommy/internal/selector_index.rb +146 -0
- data/lib/dommy/internal/selector_matcher.rb +845 -0
- data/lib/dommy/internal/selector_parser.rb +321 -132
- data/lib/dommy/internal/shadow_root_registry.rb +9 -2
- data/lib/dommy/internal/template_content_registry.rb +26 -18
- data/lib/dommy/internal/xml_serialization.rb +344 -0
- data/lib/dommy/intersection_observer.rb +2 -0
- data/lib/dommy/js/bridge_conformance.rb +80 -0
- data/lib/dommy/js/constructor_resolver.rb +44 -0
- data/lib/dommy/js/custom_element_bridge.rb +101 -0
- data/lib/dommy/js/dom_interfaces.rb +177 -0
- data/lib/dommy/js/handle_table.rb +60 -0
- data/lib/dommy/js/host_bridge.rb +538 -0
- data/lib/dommy/js/host_runtime.js +2357 -0
- data/lib/dommy/js/import_map.rb +58 -0
- data/lib/dommy/js/marshaller.rb +240 -0
- data/lib/dommy/js/module_loader.rb +99 -0
- data/lib/dommy/js/observable_runtime.js +742 -0
- data/lib/dommy/js/runtime.rb +115 -0
- data/lib/dommy/js/script_boot.rb +301 -0
- data/lib/dommy/js/wire_tags.rb +62 -0
- data/lib/dommy/location.rb +78 -13
- data/lib/dommy/media_query_list.rb +50 -14
- data/lib/dommy/message_channel.rb +22 -6
- data/lib/dommy/minitest/assertions.rb +27 -0
- data/lib/dommy/mutation_observer.rb +89 -5
- data/lib/dommy/navigation.rb +263 -0
- data/lib/dommy/navigator.rb +34 -2
- data/lib/dommy/node.rb +198 -35
- data/lib/dommy/notification.rb +2 -0
- data/lib/dommy/parser.rb +1 -1
- data/lib/dommy/performance.rb +21 -1
- data/lib/dommy/promise.rb +94 -10
- data/lib/dommy/range.rb +187 -33
- data/lib/dommy/resources.rb +178 -0
- data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
- data/lib/dommy/scheduler.rb +165 -13
- data/lib/dommy/screen.rb +91 -0
- data/lib/dommy/shadow_root.rb +109 -13
- data/lib/dommy/storage.rb +63 -11
- data/lib/dommy/streams.rb +6 -0
- data/lib/dommy/text_codec.rb +7 -1
- data/lib/dommy/tree_walker.rb +51 -46
- data/lib/dommy/url.rb +17 -3
- data/lib/dommy/version.rb +1 -1
- data/lib/dommy/web_socket.rb +45 -2
- data/lib/dommy/window.rb +322 -15
- data/lib/dommy/worker.rb +8 -4
- data/lib/dommy/xml_http_request.rb +76 -9
- data/lib/dommy.rb +38 -1
- metadata +82 -14
- data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
- data/lib/dommy/backend/nokolexbor_adapter.rb +0 -117
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Dommy
|
|
6
|
+
module Js
|
|
7
|
+
# Engine-agnostic core of the JS<->Ruby DOM bridge. Given a `backend` that
|
|
8
|
+
# can evaluate JS, register Ruby host functions, and call back into JS,
|
|
9
|
+
# HostBridge exposes a Ruby object to the JS side as an ES Proxy whose
|
|
10
|
+
# property/method access routes into the bridge ABI:
|
|
11
|
+
# __js_get__(name) / __js_set__(name, value) / __js_call__(method, args)
|
|
12
|
+
#
|
|
13
|
+
# Nothing here is QuickJS-specific; this layer is intended to move into a
|
|
14
|
+
# future `dommy-js` gem with QuickJS/wasm backends plugged in underneath.
|
|
15
|
+
#
|
|
16
|
+
# Collaborators keep the bridge focused on the engine ABI:
|
|
17
|
+
# Marshaller — Ruby<->JS value conversion + the handle/callback
|
|
18
|
+
# identity tables (#wrap / #unwrap, delegated below)
|
|
19
|
+
# DomInterfaces — interface name/chain derivation (instanceof support)
|
|
20
|
+
# ConstructorResolver — `new Event(...)` style reverse construction
|
|
21
|
+
# CustomElementBridge — JS customElements.define -> Dommy wiring
|
|
22
|
+
#
|
|
23
|
+
# Backend contract:
|
|
24
|
+
# backend.eval(js) -> evaluate top-level JS
|
|
25
|
+
# backend.define_host_function(name) { } -> expose a Ruby block as a JS global
|
|
26
|
+
# backend.call_js(path, *args) -> invoke a JS global function by path
|
|
27
|
+
# backend.run_bundle(cache_key, source) -> run a reused-across-VMs source
|
|
28
|
+
# bundle (engine may compile-cache)
|
|
29
|
+
#
|
|
30
|
+
# Value representation a backend must deliver across the boundary (host
|
|
31
|
+
# function arguments and call_js results): JSON-ish Ruby values
|
|
32
|
+
# (Hash/Array/String/Numeric/true/false/nil) carrying the WireTags protocol.
|
|
33
|
+
# The backend does NOT need to distinguish JS `undefined` from `null` in its
|
|
34
|
+
# value marshalling: host_runtime.js tags every top-level `undefined` crossing
|
|
35
|
+
# to Ruby (dehydrateTop — callback/host returns, property-set values, call
|
|
36
|
+
# args) as `{__rb_undefined: true}`, so it arrives as Dommy::Bridge::UNDEFINED
|
|
37
|
+
# regardless of the engine (V8/mini_racer cannot tell the two apart, and need
|
|
38
|
+
# not). As a defensive fallback, the bridge also normalizes a *bare* JS
|
|
39
|
+
# `undefined` delivered as the Ruby symbol `:undefined` (a backend that can
|
|
40
|
+
# produce it, like QuickJS) to Dommy::Bridge::UNDEFINED, but no backend is
|
|
41
|
+
# required to.
|
|
42
|
+
#
|
|
43
|
+
# The host object must implement __js_get__/__js_set__/__js_call__, and the
|
|
44
|
+
# bridge needs to know which names are methods (callable via __js_call__)
|
|
45
|
+
# vs. properties (read via __js_get__) — see #method_names.
|
|
46
|
+
class HostBridge
|
|
47
|
+
# JS half of the bridge (globalThis.__rbHost). Read from a companion file
|
|
48
|
+
# so it stays lintable/highlightable rather than buried in a heredoc.
|
|
49
|
+
# ::File — inside module Dommy, bare `File` resolves to Dommy::File (the
|
|
50
|
+
# File API class), not Ruby's file class. These bundles are identical
|
|
51
|
+
# across VMs; the backend's #run_bundle keeps them parsed once per process,
|
|
52
|
+
# so the bridge itself stays free of any bytecode/engine knowledge.
|
|
53
|
+
HOST_RUNTIME_JS = ::File.read(::File.join(__dir__, "host_runtime.js")).freeze
|
|
54
|
+
# The WICG Observable polyfill (Observable/Subscriber + EventTarget.when),
|
|
55
|
+
# evaluated after the DOM interface prototypes are seeded.
|
|
56
|
+
OBSERVABLE_RUNTIME_JS = ::File.read(::File.join(__dir__, "observable_runtime.js")).freeze
|
|
57
|
+
|
|
58
|
+
def initialize(backend)
|
|
59
|
+
@backend = backend
|
|
60
|
+
@crossing_counts = crossing_profile_enabled? ? new_crossing_counts : nil
|
|
61
|
+
# The marshaller owns value conversion + the handle/callback identity
|
|
62
|
+
# tables; the bridge keeps the engine ABI and lifecycle wiring.
|
|
63
|
+
@codec = Marshaller.new(self)
|
|
64
|
+
@constructor_resolver = ConstructorResolver.new
|
|
65
|
+
@custom_element_bridge = CustomElementBridge.new(self)
|
|
66
|
+
@microtask_procs = {}
|
|
67
|
+
@microtask_seq = 0
|
|
68
|
+
@rejection_details = [] # opt-in rejection-debug capture (see take_rejection_detail)
|
|
69
|
+
install!
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Bind a Ruby object to a JS global of the given name.
|
|
73
|
+
def define_host_object(name, obj)
|
|
74
|
+
handle = @codec.register(obj)
|
|
75
|
+
@backend.eval("globalThis[#{name.to_s.to_json}] = __rbHost.makeProxy(#{handle}); undefined;")
|
|
76
|
+
obj
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Bind the window the bridge draws on for JS constructors (new Event(...))
|
|
80
|
+
# and custom element registration. Called by Runtime#install_window — kept
|
|
81
|
+
# distinct from define_host_object so the generic binder has no hidden
|
|
82
|
+
# side effects.
|
|
83
|
+
def window=(win)
|
|
84
|
+
@window = win # the window host promises (thenable adoption) schedule on
|
|
85
|
+
@constructor_resolver.source = win
|
|
86
|
+
@custom_element_bridge.window = win
|
|
87
|
+
# Now that constructors are resolvable, expose their static methods
|
|
88
|
+
# (URL.createObjectURL, …) on the seeded interface globals, and expose
|
|
89
|
+
# the constructors themselves on the window proxy (window.Node,
|
|
90
|
+
# document.defaultView.DOMException, …).
|
|
91
|
+
@backend.call_js("__rbHost.attachStatics")
|
|
92
|
+
@backend.call_js("__rbHost.exposeConstructorsOnWindow")
|
|
93
|
+
wire_scheduler!(win)
|
|
94
|
+
wire_script_runner!(win)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Enqueue a Ruby callback as a NATIVE microtask (a resolved-promise job), so
|
|
98
|
+
# it runs in FIFO order with the engine's other promise jobs.
|
|
99
|
+
def schedule_native_microtask(callback)
|
|
100
|
+
id = (@microtask_seq += 1)
|
|
101
|
+
@microtask_procs[id] = callback
|
|
102
|
+
@backend.call_js("__rbHost.scheduleMicrotask", id)
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Expose the seeded interface constructors (Element, Node, DOMException, …)
|
|
107
|
+
# on a secondary window object — an iframe's contentWindow — so cross-window
|
|
108
|
+
# `instanceof subWin.Element` and `subDoc.defaultView.DOMException` resolve
|
|
109
|
+
# to the same constructors the top window uses. Idempotent per window.
|
|
110
|
+
def expose_constructors_on(window_obj)
|
|
111
|
+
handle = @codec.register(window_obj)
|
|
112
|
+
# Retain the proxy in a JS-side registry: the constructors are defined as
|
|
113
|
+
# own properties on the proxy's target, so the proxy must stay alive (and
|
|
114
|
+
# keep its handle) — otherwise GC releases it and a later
|
|
115
|
+
# `iframe.contentWindow` rebuilds a fresh, constructor-less proxy.
|
|
116
|
+
@backend.eval(<<~JS)
|
|
117
|
+
(globalThis.__rbSubWindows ||= []).push(__rbHost.makeProxy(#{handle}));
|
|
118
|
+
__rbHost.exposeConstructorsOnWindow(globalThis.__rbSubWindows.at(-1));
|
|
119
|
+
JS
|
|
120
|
+
window_obj
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Invoke a JS custom element lifecycle callback (connectedCallback etc.) for
|
|
124
|
+
# a Dommy node. Called by the bridged custom element class (see CustomElementBridge).
|
|
125
|
+
def invoke_lifecycle(node, callback, args)
|
|
126
|
+
handle = @codec.register(node)
|
|
127
|
+
unwrap(@backend.call_js("__rbHost.invokeLifecycle", handle, callback, wrap(Array(args))))
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Invoke a retained live JS function by id (used by HostCallback). The JS
|
|
131
|
+
# side returns a `dehydrate`d (tagged) value, so unwrap it back to Ruby:
|
|
132
|
+
# a callback that returns e.g. a Promise proxy must come back as the live
|
|
133
|
+
# PromiseValue, otherwise Dommy can't adopt it (breaking
|
|
134
|
+
# `fetch().then(r => r.json()).then(…)` chains).
|
|
135
|
+
# `raising: true` re-raises a thrown value (as a ThrowValue dom_guard
|
|
136
|
+
# rethrows verbatim) where the spec requires the exception to propagate — a
|
|
137
|
+
# NodeFilter whose error must surface out of the traversal method that ran
|
|
138
|
+
# it. The default swallows it: event listeners / observers / timers must not
|
|
139
|
+
# let a callback error escape their dispatch.
|
|
140
|
+
def invoke_callback(id, args, this_arg = nil, raising: false)
|
|
141
|
+
callback_result(@backend.call_js("__rbHost.invokeCallback", id, wrap(Array(args)), wrap(this_arg)), raising)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Invoke a JS EventListener *object*'s handleEvent (see HostEventListener),
|
|
145
|
+
# passing the dispatched event as a proxy.
|
|
146
|
+
def invoke_js_ref_handle_event(ref, event)
|
|
147
|
+
unwrap(@backend.call_js("__rbHost.invokeJsRefHandleEvent", ref, wrap(event)))
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Invoke a JS NodeFilter object's acceptNode (see HostNodeFilter). `raising`
|
|
151
|
+
# re-raises a thrown value (the traversal must propagate it) rather than
|
|
152
|
+
# swallowing it.
|
|
153
|
+
def invoke_js_ref_accept_node(ref, node, raising: false)
|
|
154
|
+
callback_result(@backend.call_js("__rbHost.invokeJsRefAcceptNode", ref, wrap(node)), raising)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Turn a JS-side tagged value (produced by __rbHost.tag) back into Ruby:
|
|
158
|
+
# tagged handles become the original Ruby DOM objects. Used for return
|
|
159
|
+
# values that may contain DOM nodes (e.g. evaluate_script).
|
|
160
|
+
def decode(tagged)
|
|
161
|
+
unwrap(tagged)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Number of live handle entries. Introspection for lifetime tests.
|
|
165
|
+
def registered_count
|
|
166
|
+
@codec.size
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Snapshot bridge crossing counts, enabled with DOMMY_JS_BRIDGE_PROFILE=1.
|
|
170
|
+
# Counts are grouped by ABI function name, with a nested breakdown of the
|
|
171
|
+
# hottest interface/property or interface/method labels where available.
|
|
172
|
+
def crossing_counts(limit: nil)
|
|
173
|
+
return {} unless @crossing_counts
|
|
174
|
+
|
|
175
|
+
@crossing_counts.transform_values do |counts|
|
|
176
|
+
sorted = counts.sort_by { |(_key, count)| -count }
|
|
177
|
+
sorted = sorted.first(limit) if limit
|
|
178
|
+
sorted.to_h
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def reset_crossing_counts
|
|
183
|
+
@crossing_counts = new_crossing_counts if @crossing_counts
|
|
184
|
+
self
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Drain the most-recent recorded rejection detail (paired by recency with the
|
|
188
|
+
# engine's detail-less "[object Object]" report). Nil when none recorded /
|
|
189
|
+
# the opt-in tracker (installRejectionTracker) isn't installed.
|
|
190
|
+
def take_rejection_detail
|
|
191
|
+
@rejection_details.pop
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
private
|
|
195
|
+
|
|
196
|
+
def crossing_profile_enabled?
|
|
197
|
+
!ENV["DOMMY_JS_BRIDGE_PROFILE"].to_s.empty?
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def new_crossing_counts
|
|
201
|
+
Hash.new { |h, k| h[k] = Hash.new(0) }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def count_crossing(abi_name, obj = nil, member = nil)
|
|
205
|
+
return unless @crossing_counts
|
|
206
|
+
|
|
207
|
+
@crossing_counts[abi_name.to_s]["__total__"] += 1
|
|
208
|
+
return unless member
|
|
209
|
+
|
|
210
|
+
@crossing_counts[abi_name.to_s][crossing_label(obj, member)] += 1
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def crossing_label(obj, member)
|
|
214
|
+
return member.to_s unless obj
|
|
215
|
+
|
|
216
|
+
iface = obj ? DomInterfaces.info(obj)["name"] : nil
|
|
217
|
+
"#{iface || obj.class.name}##{member}"
|
|
218
|
+
rescue StandardError
|
|
219
|
+
"#{obj.class.name}##{member}"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Route Dommy's host-side microtasks (MutationObserver delivery, …) onto
|
|
223
|
+
# the engine's native promise-job queue, so they interleave FIFO with JS
|
|
224
|
+
# `await`/Promise reactions instead of draining on a separate pass (which
|
|
225
|
+
# would deliver e.g. MutationObserver records only after `await
|
|
226
|
+
# Promise.resolve()`, batching several mutations into one callback).
|
|
227
|
+
def wire_scheduler!(win)
|
|
228
|
+
return unless win.respond_to?(:scheduler) && win.scheduler.respond_to?(:native_microtask_scheduler=)
|
|
229
|
+
|
|
230
|
+
win.scheduler.native_microtask_scheduler = ->(callback) { schedule_native_microtask(callback) }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Let a classic <script> inserted into the document execute (Dommy has no
|
|
234
|
+
# JS engine; it calls back here to run the body in global scope).
|
|
235
|
+
def wire_script_runner!(win)
|
|
236
|
+
return unless win.respond_to?(:document) && win.document.respond_to?(:script_runner=)
|
|
237
|
+
|
|
238
|
+
win.document.script_runner = ->(source) { @backend.call_js("__rbHost.runScript", source.to_s) }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Register the host-function ABI in cohesive groups, then run the JS-side
|
|
242
|
+
# runtime that consumes it. Registration order among the groups is
|
|
243
|
+
# irrelevant (they only define functions); seed_runtime! must run last,
|
|
244
|
+
# since the runtime it loads calls back into these functions.
|
|
245
|
+
def install!
|
|
246
|
+
install_object_abi!
|
|
247
|
+
install_lifecycle_abi!
|
|
248
|
+
install_construction_abi!
|
|
249
|
+
install_custom_elements_abi!
|
|
250
|
+
seed_runtime!
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# A host object's property/method ABI, plus the legacy-platform-object
|
|
254
|
+
# named-property protocol (named getter/deleter) and the self-describe call
|
|
255
|
+
# makeProxy uses to build the right JS prototype.
|
|
256
|
+
def install_object_abi!
|
|
257
|
+
@backend.define_host_function("__rb_host_get") do |handle, prop|
|
|
258
|
+
dom_guard do
|
|
259
|
+
obj = host(handle)
|
|
260
|
+
count_crossing(:__rb_host_get, obj, prop)
|
|
261
|
+
wrap(obj.respond_to?(:__js_get__) ? obj.__js_get__(prop) : nil)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
@backend.define_host_function("__rb_host_set") do |handle, prop, value|
|
|
265
|
+
# Returns whether Dommy handled the write as a DOM property. When it
|
|
266
|
+
# didn't (or the object has no __js_set__), the JS side keeps the value
|
|
267
|
+
# as an expando (preserving object/instance field identity). Wrapped in
|
|
268
|
+
# dom_guard so a throwing setter (e.g. `documentElement.outerHTML = …` →
|
|
269
|
+
# NoModificationAllowedError) crosses as a tagged exception the JS set
|
|
270
|
+
# trap re-throws, rather than escaping as a raw Ruby error.
|
|
271
|
+
dom_guard do
|
|
272
|
+
obj = host(handle)
|
|
273
|
+
count_crossing(:__rb_host_set, obj, prop)
|
|
274
|
+
obj.respond_to?(:__js_set__) ? dommy_handled?(obj.__js_set__(prop, unwrap(value))) : false
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
@backend.define_host_function("__rb_host_call") do |handle, method, args|
|
|
278
|
+
dom_guard do
|
|
279
|
+
obj = host(handle)
|
|
280
|
+
count_crossing(:__rb_host_call, obj, method)
|
|
281
|
+
obj.respond_to?(:__js_call__) ? wrap(obj.__js_call__(method, unwrap(args))) : nil
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
# A pending host PromiseValue used to ADOPT a JS thenable returned from a
|
|
285
|
+
# `.then` callback (Promises/A+): the JS side subscribes the thenable to
|
|
286
|
+
# settle this promise, so the host chain WAITS for the thenable instead of
|
|
287
|
+
# resolving immediately with an opaque ref (the HttpLink #95 reorder).
|
|
288
|
+
@backend.define_host_function("__rb_new_host_promise") do
|
|
289
|
+
count_crossing(:__rb_new_host_promise)
|
|
290
|
+
dom_guard { @codec.register(Dommy::PromiseValue.new(@window)) }
|
|
291
|
+
end
|
|
292
|
+
@backend.define_host_function("__rb_settle_host_promise") do |handle, fulfilled, value|
|
|
293
|
+
count_crossing(:__rb_settle_host_promise)
|
|
294
|
+
dom_guard do
|
|
295
|
+
promise = host(handle)
|
|
296
|
+
fulfilled ? promise.fulfill(unwrap(value)) : promise.reject(unwrap(value))
|
|
297
|
+
nil
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
# 2d: one call returns everything makeProxy needs — interface name +
|
|
301
|
+
# chain, method names, and the custom element tag (if any).
|
|
302
|
+
@backend.define_host_function("__rb_host_describe") do |handle|
|
|
303
|
+
obj = host(handle)
|
|
304
|
+
count_crossing(:__rb_host_describe, obj)
|
|
305
|
+
info = DomInterfaces.info(obj)
|
|
306
|
+
info["methods"] = method_names(obj)
|
|
307
|
+
# Mark JS-defined custom elements so makeProxy upgrades them on crossing.
|
|
308
|
+
info["ce"] = obj.__js_custom_element_name__ if obj.respond_to?(:__js_custom_element_name__)
|
|
309
|
+
info
|
|
310
|
+
end
|
|
311
|
+
# One-shot attribute snapshot for the JS-side attribute cache: a plain
|
|
312
|
+
# {qualified_name => value} Hash (an element with case-insensitive
|
|
313
|
+
# attribute lookups), or nil (anything else — the JS side keeps the
|
|
314
|
+
# per-call path). Staleness is the JS side's concern (the DOM epoch).
|
|
315
|
+
@backend.define_host_function("__rb_host_attrs") do |handle|
|
|
316
|
+
dom_guard do
|
|
317
|
+
obj = host(handle)
|
|
318
|
+
count_crossing(:__rb_host_attrs, obj)
|
|
319
|
+
obj.respond_to?(:__js_attribute_snapshot__) ? obj.__js_attribute_snapshot__ : nil
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
# WebIDL "supported property names" for a legacy platform object (a live
|
|
323
|
+
# array-like/maplike collection): the current ordered named-property
|
|
324
|
+
# keys. Queried per ownKeys / getOwnPropertyDescriptor so it tracks DOM
|
|
325
|
+
# mutations. Nil when the object has no named getter.
|
|
326
|
+
@backend.define_host_function("__rb_named_props") do |handle|
|
|
327
|
+
obj = host(handle)
|
|
328
|
+
count_crossing(:__rb_named_props, obj)
|
|
329
|
+
obj.respond_to?(:__js_named_props__) ? Array(obj.__js_named_props__).map(&:to_s) : nil
|
|
330
|
+
end
|
|
331
|
+
# Named deleter (`delete el.dataset.foo`): true when the object handled
|
|
332
|
+
# the delete, false/UNHANDLED when the JS side should fall back to its
|
|
333
|
+
# own (expando) delete.
|
|
334
|
+
@backend.define_host_function("__rb_host_delete") do |handle, prop|
|
|
335
|
+
dom_guard do
|
|
336
|
+
obj = host(handle)
|
|
337
|
+
count_crossing(:__rb_host_delete, obj, prop)
|
|
338
|
+
obj.respond_to?(:__js_delete__) ? dommy_handled?(obj.__js_delete__(prop)) : false
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# VM bookkeeping: proxy-handle release (driven by JS GC) and the native
|
|
344
|
+
# microtask drain hook.
|
|
345
|
+
def install_lifecycle_abi!
|
|
346
|
+
@backend.define_host_function("__rb_release_handle") do |handle|
|
|
347
|
+
count_crossing(:__rb_release_handle)
|
|
348
|
+
@codec.release(handle)
|
|
349
|
+
nil
|
|
350
|
+
end
|
|
351
|
+
# Run a Ruby microtask previously registered by schedule_native_microtask,
|
|
352
|
+
# invoked from the resolved-promise job scheduleMicrotask queued.
|
|
353
|
+
@backend.define_host_function("__rb_run_microtask") do |id|
|
|
354
|
+
count_crossing(:__rb_run_microtask)
|
|
355
|
+
callback = @microtask_procs.delete(id)
|
|
356
|
+
callback&.call
|
|
357
|
+
nil
|
|
358
|
+
end
|
|
359
|
+
# Opt-in rejection diagnostics: __rbHost.installRejectionTracker (only run
|
|
360
|
+
# when asked) records a rich description of each promise rejection here, at
|
|
361
|
+
# reject time, so #take_rejection_detail can replace the engine's
|
|
362
|
+
# detail-less "[object Object]" unhandled-rejection report with the truth.
|
|
363
|
+
@backend.define_host_function("__rb_record_rejection_detail") do |detail|
|
|
364
|
+
@rejection_details.push(detail.to_s)
|
|
365
|
+
@rejection_details.shift if @rejection_details.size > 256
|
|
366
|
+
nil
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Reverse construction (`new Event(...)`) and interface static methods
|
|
371
|
+
# (URL.createObjectURL, …).
|
|
372
|
+
def install_construction_abi!
|
|
373
|
+
# `new Event(...)` / `new DOMException(...)` from a bare interface
|
|
374
|
+
# constructor — resolve the named constructor and build. Returns nil when
|
|
375
|
+
# the interface isn't constructable, so the JS side throws.
|
|
376
|
+
@backend.define_host_function("__rb_construct") do |name, args|
|
|
377
|
+
count_crossing(:__rb_construct, nil, name)
|
|
378
|
+
dom_guard do
|
|
379
|
+
ctor = @constructor_resolver.resolve(name)
|
|
380
|
+
ctor ? wrap(ctor.__js_new__(unwrap(args))) : nil
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
# Static/class methods on an interface constructor (URL.createObjectURL,
|
|
384
|
+
# URL.parse, …): names to expose, and the dispatch.
|
|
385
|
+
@backend.define_host_function("__rb_static_names") do |name|
|
|
386
|
+
count_crossing(:__rb_static_names, nil, name)
|
|
387
|
+
ctor = @constructor_resolver.resolve(name)
|
|
388
|
+
ctor.respond_to?(:__js_class_method_names__) ? ctor.__js_class_method_names__ : []
|
|
389
|
+
end
|
|
390
|
+
@backend.define_host_function("__rb_static_call") do |name, method, args|
|
|
391
|
+
count_crossing(:__rb_static_call, nil, "#{name}.#{method}")
|
|
392
|
+
dom_guard do
|
|
393
|
+
ctor = @constructor_resolver.resolve(name)
|
|
394
|
+
ctor.respond_to?(:__js_call__) ? wrap(ctor.__js_call__(method, unwrap(args))) : nil
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# customElements.define / .upgrade, delegated to Dommy's registry.
|
|
400
|
+
def install_custom_elements_abi!
|
|
401
|
+
# 1d: customElements.define(name, JSClass) wires a Dommy custom element.
|
|
402
|
+
@backend.define_host_function("__rb_define_custom_element") do |name, observed|
|
|
403
|
+
count_crossing(:__rb_define_custom_element, nil, name)
|
|
404
|
+
@custom_element_bridge.define(name, Array(observed))
|
|
405
|
+
nil
|
|
406
|
+
end
|
|
407
|
+
# 1d: customElements.upgrade(root) — delegate to Dommy's registry.
|
|
408
|
+
@backend.define_host_function("__rb_upgrade_custom_elements") do |handle|
|
|
409
|
+
count_crossing(:__rb_upgrade_custom_elements)
|
|
410
|
+
@custom_element_bridge.upgrade(host(handle))
|
|
411
|
+
nil
|
|
412
|
+
end
|
|
413
|
+
# 1d: direct `new MyElement()` — mint the backing Dommy element for a
|
|
414
|
+
# registered tag so the HTMLElement constructor has an element to adopt.
|
|
415
|
+
# Returns nil when the tag isn't defined (JS then throws Illegal constructor).
|
|
416
|
+
@backend.define_host_function("__rb_create_custom_element") do |name|
|
|
417
|
+
count_crossing(:__rb_create_custom_element, nil, name)
|
|
418
|
+
dom_guard do
|
|
419
|
+
el = @custom_element_bridge.create(name)
|
|
420
|
+
el ? wrap(el) : nil
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Run the JS half of the bridge and seed the interface prototypes. Must run
|
|
426
|
+
# after every host function above is registered.
|
|
427
|
+
def seed_runtime!
|
|
428
|
+
@backend.run_bundle("host_runtime.js", HOST_RUNTIME_JS)
|
|
429
|
+
# Seed base interface prototypes from the single Ruby-side hierarchy.
|
|
430
|
+
@backend.eval("__rbHost.seedInterfaces(#{JSON.generate(DomInterfaces::BASE_CHAINS)});")
|
|
431
|
+
# Observable depends on EventTarget.prototype existing (seeded above).
|
|
432
|
+
@backend.run_bundle("observable_runtime.js", OBSERVABLE_RUNTIME_JS)
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# The strict handle lookup for a receiver (raises on a missing handle,
|
|
436
|
+
# unlike the tolerant argument path in Marshaller#unwrap).
|
|
437
|
+
def host(handle)
|
|
438
|
+
@codec.host(handle)
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Marshalling is the Marshaller's job; the bridge calls these from its ABI
|
|
442
|
+
# blocks and invoke_* helpers.
|
|
443
|
+
def wrap(value) = @codec.wrap(value)
|
|
444
|
+
def unwrap(value) = @codec.unwrap(value)
|
|
445
|
+
def dom_guard(&block) = @codec.dom_guard(&block)
|
|
446
|
+
def callback_result(raw, raising) = @codec.callback_result(raw, raising)
|
|
447
|
+
|
|
448
|
+
# Which property names should be treated as callable methods. The ABI
|
|
449
|
+
# keeps properties (__js_get__) and methods (__js_call__) in disjoint
|
|
450
|
+
# namespaces, so the proxy asks the object to self-describe via the bridge
|
|
451
|
+
# ABI method __js_method_names__. method_defined? (not respond_to?) avoids
|
|
452
|
+
# classes whose respond_to_missing? answers true for arbitrary names (e.g.
|
|
453
|
+
# StyleDeclaration's CSS-property accessors).
|
|
454
|
+
def method_names(obj)
|
|
455
|
+
return [] unless obj.class.method_defined?(:__js_method_names__)
|
|
456
|
+
|
|
457
|
+
Array(obj.__js_method_names__).map(&:to_s)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Did Dommy treat a __js_set__ as a real DOM property? A returned UNHANDLED
|
|
461
|
+
# sentinel means "no" (the JS side then keeps it as an expando).
|
|
462
|
+
def dommy_handled?(result)
|
|
463
|
+
result != Dommy::Bridge::UNHANDLED
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# An event listener backed by a live JS function. Implements only the bridge
|
|
468
|
+
# ABI (__js_call__) — not #call/#handle_event — so Dommy's invoke_listener
|
|
469
|
+
# routes through the __js_call__("call", [event]) branch.
|
|
470
|
+
class HostCallback
|
|
471
|
+
attr_reader :id
|
|
472
|
+
|
|
473
|
+
def initialize(bridge, id)
|
|
474
|
+
@bridge = bridge
|
|
475
|
+
@id = id
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def __js_call__(method, args)
|
|
479
|
+
return nil unless method == "call"
|
|
480
|
+
|
|
481
|
+
@bridge.invoke_callback(@id, args)
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# Invoke with an explicit `this` receiver — e.g. a MutationObserver
|
|
485
|
+
# callback whose `this` must be the observer, or an event listener whose
|
|
486
|
+
# `this` is the currentTarget.
|
|
487
|
+
def __js_call_with_this__(args, this_arg)
|
|
488
|
+
@bridge.invoke_callback(@id, args, this_arg)
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# Invoke and re-raise a thrown value instead of swallowing it — for a
|
|
492
|
+
# NodeFilter, whose exception must propagate out of the traversal method.
|
|
493
|
+
def __js_call_with_raise__(args)
|
|
494
|
+
@bridge.invoke_callback(@id, args, raising: true)
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# An event listener backed by a live JS *object* implementing the
|
|
499
|
+
# EventListener interface (a `handleEvent` method, e.g. Stimulus's action
|
|
500
|
+
# listeners). Implements #handle_event so Dommy's invoke_listener routes to
|
|
501
|
+
# the object's handleEvent (with `this` bound to the object). Holds the
|
|
502
|
+
# JS-side ref so it also wraps back to the same JS object (identity kept).
|
|
503
|
+
class HostEventListener
|
|
504
|
+
attr_reader :ref
|
|
505
|
+
|
|
506
|
+
def initialize(bridge, ref, label = nil)
|
|
507
|
+
@bridge = bridge
|
|
508
|
+
@ref = ref
|
|
509
|
+
@label = label
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def handle_event(event)
|
|
513
|
+
@bridge.invoke_js_ref_handle_event(@ref, event)
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
# A NodeFilter backed by a live JS object implementing the callback interface
|
|
518
|
+
# (`{ acceptNode }`). TreeWalker/NodeIterator treat it as the filter callable;
|
|
519
|
+
# each invocation runs acceptNode on the JS object (this = object), and the
|
|
520
|
+
# raising variant lets the filter's exception propagate out of the traversal.
|
|
521
|
+
class HostNodeFilter
|
|
522
|
+
attr_reader :ref
|
|
523
|
+
|
|
524
|
+
def initialize(bridge, ref)
|
|
525
|
+
@bridge = bridge
|
|
526
|
+
@ref = ref
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def __js_call__(_method, args)
|
|
530
|
+
@bridge.invoke_js_ref_accept_node(@ref, args[0])
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def __js_call_with_raise__(args)
|
|
534
|
+
@bridge.invoke_js_ref_accept_node(@ref, args[0], raising: true)
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
end
|