Class: Dommy::Js::HostBridge
- Inherits:
-
Object
- Object
- Dommy::Js::HostBridge
- Defined in:
- lib/dommy/js/host_bridge.rb
Overview
Engine-agnostic core of the JS<->Ruby DOM bridge. Given a backend that
can evaluate JS, register Ruby host functions, and call back into JS,
HostBridge exposes a Ruby object to the JS side as an ES Proxy whose
property/method access routes into the bridge ABI:
__js_get__(name) / __js_set__(name, value) / __js_call__(method, args)
Nothing here is QuickJS-specific; this layer is intended to move into a
future dommy-js gem with QuickJS/wasm backends plugged in underneath.
Collaborators keep the bridge focused on the engine ABI:
Marshaller — Ruby<->JS value conversion + the handle/callback
identity tables (#wrap / #unwrap, delegated below)
DomInterfaces — interface name/chain derivation (instanceof support)
ConstructorResolver — `new Event(...)` style reverse construction
CustomElementBridge — JS customElements.define -> Dommy wiring
Backend contract:
backend.eval(js) -> evaluate top-level JS
backend.define_host_function(name) { } -> expose a Ruby block as a JS global
backend.call_js(path, *args) -> invoke a JS global function by path
backend.run_bundle(cache_key, source) -> run a reused-across-VMs source
bundle (engine may compile-cache)
Value representation a backend must deliver across the boundary (host
function arguments and call_js results): JSON-ish Ruby values
(Hash/Array/String/Numeric/true/false/nil) carrying the WireTags protocol.
The backend does NOT need to distinguish JS undefined from null in its
value marshalling: host_runtime.js tags every top-level undefined crossing
to Ruby (dehydrateTop — callback/host returns, property-set values, call
args) as {__rb_undefined: true}, so it arrives as Dommy::Bridge::UNDEFINED
regardless of the engine (V8/mini_racer cannot tell the two apart, and need
not). As a defensive fallback, the bridge also normalizes a bare JS
undefined delivered as the Ruby symbol :undefined (a backend that can
produce it, like QuickJS) to Dommy::Bridge::UNDEFINED, but no backend is
required to.
The host object must implement js_get/js_set/js_call, and the bridge needs to know which names are methods (callable via js_call) vs. properties (read via js_get) — see #method_names.
Constant Summary collapse
- HOST_RUNTIME_JS =
JS half of the bridge (globalThis.__rbHost). Read from a companion file so it stays lintable/highlightable rather than buried in a heredoc. ::File — inside module Dommy, bare
Fileresolves to Dommy::File (the File API class), not Ruby's file class. These bundles are identical across VMs; the backend's #run_bundle keeps them parsed once per process, so the bridge itself stays free of any bytecode/engine knowledge. ::File.read(::File.join(__dir__, "host_runtime.js")).freeze
- OBSERVABLE_RUNTIME_JS =
The WICG Observable polyfill (Observable/Subscriber + EventTarget.when), evaluated after the DOM interface prototypes are seeded.
::File.read(::File.join(__dir__, "observable_runtime.js")).freeze
Instance Method Summary collapse
-
#crossing_counts(limit: nil) ⇒ Object
Snapshot bridge crossing counts, enabled with DOMMY_JS_BRIDGE_PROFILE=1.
-
#decode(tagged) ⇒ Object
Turn a JS-side tagged value (produced by __rbHost.tag) back into Ruby: tagged handles become the original Ruby DOM objects.
-
#define_host_object(name, obj) ⇒ Object
Bind a Ruby object to a JS global of the given name.
-
#expose_constructors_on(window_obj) ⇒ Object
Expose the seeded interface constructors (Element, Node, DOMException, …) on a secondary window object — an iframe's contentWindow — so cross-window
instanceof subWin.ElementandsubDoc.defaultView.DOMExceptionresolve to the same constructors the top window uses. -
#initialize(backend) ⇒ HostBridge
constructor
A new instance of HostBridge.
-
#invoke_callback(id, args, this_arg = nil, raising: false) ⇒ Object
Invoke a retained live JS function by id (used by HostCallback).
-
#invoke_js_ref_accept_node(ref, node, raising: false) ⇒ Object
Invoke a JS NodeFilter object's acceptNode (see HostNodeFilter).
-
#invoke_js_ref_handle_event(ref, event) ⇒ Object
Invoke a JS EventListener object's handleEvent (see HostEventListener), passing the dispatched event as a proxy.
-
#invoke_lifecycle(node, callback, args) ⇒ Object
Invoke a JS custom element lifecycle callback (connectedCallback etc.) for a Dommy node.
-
#registered_count ⇒ Object
Number of live handle entries.
- #reset_crossing_counts ⇒ Object
-
#schedule_native_microtask(callback) ⇒ Object
Enqueue a Ruby callback as a NATIVE microtask (a resolved-promise job), so it runs in FIFO order with the engine's other promise jobs.
-
#take_rejection_detail ⇒ Object
Drain the most-recent recorded rejection detail (paired by recency with the engine's detail-less "[object Object]" report).
-
#window=(win) ⇒ Object
Bind the window the bridge draws on for JS constructors (new Event(...)) and custom element registration.
Constructor Details
#initialize(backend) ⇒ HostBridge
Returns a new instance of HostBridge.
58 59 60 61 62 63 64 65 66 67 68 69 70 |
# File 'lib/dommy/js/host_bridge.rb', line 58 def initialize(backend) @backend = backend @crossing_counts = crossing_profile_enabled? ? new_crossing_counts : nil # The marshaller owns value conversion + the handle/callback identity # tables; the bridge keeps the engine ABI and lifecycle wiring. @codec = Marshaller.new(self) @constructor_resolver = ConstructorResolver.new @custom_element_bridge = CustomElementBridge.new(self) @microtask_procs = {} @microtask_seq = 0 @rejection_details = [] # opt-in rejection-debug capture (see take_rejection_detail) install! end |
Instance Method Details
#crossing_counts(limit: nil) ⇒ Object
Snapshot bridge crossing counts, enabled with DOMMY_JS_BRIDGE_PROFILE=1. Counts are grouped by ABI function name, with a nested breakdown of the hottest interface/property or interface/method labels where available.
172 173 174 175 176 177 178 179 180 |
# File 'lib/dommy/js/host_bridge.rb', line 172 def crossing_counts(limit: nil) return {} unless @crossing_counts @crossing_counts.transform_values do |counts| sorted = counts.sort_by { |(_key, count)| -count } sorted = sorted.first(limit) if limit sorted.to_h end end |
#decode(tagged) ⇒ Object
Turn a JS-side tagged value (produced by __rbHost.tag) back into Ruby: tagged handles become the original Ruby DOM objects. Used for return values that may contain DOM nodes (e.g. evaluate_script).
160 161 162 |
# File 'lib/dommy/js/host_bridge.rb', line 160 def decode(tagged) unwrap(tagged) end |
#define_host_object(name, obj) ⇒ Object
Bind a Ruby object to a JS global of the given name.
73 74 75 76 77 |
# File 'lib/dommy/js/host_bridge.rb', line 73 def define_host_object(name, obj) handle = @codec.register(obj) @backend.eval("globalThis[#{name.to_s.to_json}] = __rbHost.makeProxy(#{handle}); undefined;") obj end |
#expose_constructors_on(window_obj) ⇒ Object
Expose the seeded interface constructors (Element, Node, DOMException, …)
on a secondary window object — an iframe's contentWindow — so cross-window
instanceof subWin.Element and subDoc.defaultView.DOMException resolve
to the same constructors the top window uses. Idempotent per window.
110 111 112 113 114 115 116 117 118 119 120 121 |
# File 'lib/dommy/js/host_bridge.rb', line 110 def expose_constructors_on(window_obj) handle = @codec.register(window_obj) # Retain the proxy in a JS-side registry: the constructors are defined as # own properties on the proxy's target, so the proxy must stay alive (and # keep its handle) — otherwise GC releases it and a later # `iframe.contentWindow` rebuilds a fresh, constructor-less proxy. @backend.eval(<<~JS) (globalThis.__rbSubWindows ||= []).push(__rbHost.makeProxy(#{handle})); __rbHost.exposeConstructorsOnWindow(globalThis.__rbSubWindows.at(-1)); JS window_obj end |
#invoke_callback(id, args, this_arg = nil, raising: false) ⇒ Object
Invoke a retained live JS function by id (used by HostCallback). The JS
side returns a dehydrated (tagged) value, so unwrap it back to Ruby:
a callback that returns e.g. a Promise proxy must come back as the live
PromiseValue, otherwise Dommy can't adopt it (breaking
fetch().then(r => r.json()).then(…) chains).
raising: true re-raises a thrown value (as a ThrowValue dom_guard
rethrows verbatim) where the spec requires the exception to propagate — a
NodeFilter whose error must surface out of the traversal method that ran
it. The default swallows it: event listeners / observers / timers must not
let a callback error escape their dispatch.
140 141 142 |
# File 'lib/dommy/js/host_bridge.rb', line 140 def invoke_callback(id, args, this_arg = nil, raising: false) callback_result(@backend.call_js("__rbHost.invokeCallback", id, wrap(Array(args)), wrap(this_arg)), raising) end |
#invoke_js_ref_accept_node(ref, node, raising: false) ⇒ Object
Invoke a JS NodeFilter object's acceptNode (see HostNodeFilter). raising
re-raises a thrown value (the traversal must propagate it) rather than
swallowing it.
153 154 155 |
# File 'lib/dommy/js/host_bridge.rb', line 153 def invoke_js_ref_accept_node(ref, node, raising: false) callback_result(@backend.call_js("__rbHost.invokeJsRefAcceptNode", ref, wrap(node)), raising) end |
#invoke_js_ref_handle_event(ref, event) ⇒ Object
Invoke a JS EventListener object's handleEvent (see HostEventListener), passing the dispatched event as a proxy.
146 147 148 |
# File 'lib/dommy/js/host_bridge.rb', line 146 def invoke_js_ref_handle_event(ref, event) unwrap(@backend.call_js("__rbHost.invokeJsRefHandleEvent", ref, wrap(event))) end |
#invoke_lifecycle(node, callback, args) ⇒ Object
Invoke a JS custom element lifecycle callback (connectedCallback etc.) for a Dommy node. Called by the bridged custom element class (see CustomElementBridge).
125 126 127 128 |
# File 'lib/dommy/js/host_bridge.rb', line 125 def invoke_lifecycle(node, callback, args) handle = @codec.register(node) unwrap(@backend.call_js("__rbHost.invokeLifecycle", handle, callback, wrap(Array(args)))) end |
#registered_count ⇒ Object
Number of live handle entries. Introspection for lifetime tests.
165 166 167 |
# File 'lib/dommy/js/host_bridge.rb', line 165 def registered_count @codec.size end |
#reset_crossing_counts ⇒ Object
182 183 184 185 |
# File 'lib/dommy/js/host_bridge.rb', line 182 def reset_crossing_counts @crossing_counts = new_crossing_counts if @crossing_counts self end |
#schedule_native_microtask(callback) ⇒ Object
Enqueue a Ruby callback as a NATIVE microtask (a resolved-promise job), so it runs in FIFO order with the engine's other promise jobs.
99 100 101 102 103 104 |
# File 'lib/dommy/js/host_bridge.rb', line 99 def schedule_native_microtask(callback) id = (@microtask_seq += 1) @microtask_procs[id] = callback @backend.call_js("__rbHost.scheduleMicrotask", id) nil end |
#take_rejection_detail ⇒ Object
Drain the most-recent recorded rejection detail (paired by recency with the engine's detail-less "[object Object]" report). Nil when none recorded / the opt-in tracker (installRejectionTracker) isn't installed.
190 191 192 |
# File 'lib/dommy/js/host_bridge.rb', line 190 def take_rejection_detail @rejection_details.pop end |
#window=(win) ⇒ Object
Bind the window the bridge draws on for JS constructors (new Event(...)) and custom element registration. Called by Runtime#install_window — kept distinct from define_host_object so the generic binder has no hidden side effects.
83 84 85 86 87 88 89 90 91 92 93 94 95 |
# File 'lib/dommy/js/host_bridge.rb', line 83 def window=(win) @window = win # the window host promises (thenable adoption) schedule on @constructor_resolver.source = win @custom_element_bridge.window = win # Now that constructors are resolvable, expose their static methods # (URL.createObjectURL, …) on the seeded interface globals, and expose # the constructors themselves on the window proxy (window.Node, # document.defaultView.DOMException, …). @backend.call_js("__rbHost.attachStatics") @backend.call_js("__rbHost.exposeConstructorsOnWindow") wire_scheduler!(win) wire_script_runner!(win) end |