Class: Dommy::Js::HostBridge

Inherits:
Object
  • Object
show all
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 File resolves 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

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_countObject

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_countsObject



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_detailObject

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