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.

Two collaborators keep the marshalling core free of DOM specifics:

DomInterfaces       — interface name/chain derivation (instanceof support)
ConstructorRegistry — `new Event(...)` style reverse construction

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

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.

::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.



38
39
40
41
42
43
44
45
46
47
# File 'lib/dommy/js/host_bridge.rb', line 38

def initialize(backend)
  @backend = backend
  @handles = HandleTable.new
  @callback_objects = {}
  @constructors = ConstructorRegistry.new
  @custom_elements = CustomElements.new(self)
  @microtask_procs = {}
  @microtask_seq = 0
  install!
end

Instance Method Details

#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).



124
125
126
# File 'lib/dommy/js/host_bridge.rb', line 124

def decode(tagged)
  unwrap(tagged)
end

#define_host_object(name, obj) ⇒ Object

Bind a Ruby object to a JS global of the given name.



50
51
52
53
54
# File 'lib/dommy/js/host_bridge.rb', line 50

def define_host_object(name, obj)
  handle = @handles.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.



92
93
94
95
96
97
98
99
100
101
102
103
# File 'lib/dommy/js/host_bridge.rb', line 92

def expose_constructors_on(window_obj)
  handle = @handles.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) ⇒ Object

Invoke a retained live JS function by id (used by HostCallback). The JS side returns a ‘dehydrate`d (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).



117
118
119
# File 'lib/dommy/js/host_bridge.rb', line 117

def invoke_callback(id, args, this_arg = nil)
  unwrap(@backend.call_js("__rbHost.invokeCallback", id, wrap(Array(args)), wrap(this_arg)))
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 CustomElements).



107
108
109
110
# File 'lib/dommy/js/host_bridge.rb', line 107

def invoke_lifecycle(node, callback, args)
  handle = @handles.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.



129
130
131
# File 'lib/dommy/js/host_bridge.rb', line 129

def registered_count
  @handles.size
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.



81
82
83
84
85
86
# File 'lib/dommy/js/host_bridge.rb', line 81

def schedule_native_microtask(callback)
  id = (@microtask_seq += 1)
  @microtask_procs[id] = callback
  @backend.call_js("__rbHost.scheduleMicrotask", id)
  nil
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.



60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/dommy/js/host_bridge.rb', line 60

def window=(win)
  @constructors.source = win
  @custom_elements.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")
  # Route Dommy's host-side microtasks (MutationObserver delivery, …) onto
  # the engine's native promise-job queue, so they interleave FIFO with JS
  # `await`/Promise reactions instead of draining on a separate pass (which
  # would deliver e.g. MutationObserver records only after `await
  # Promise.resolve()`, batching several mutations into one callback).
  if win.respond_to?(:scheduler) && win.scheduler.respond_to?(:native_microtask_scheduler=)
    win.scheduler.native_microtask_scheduler = ->(callback) { schedule_native_microtask(callback) }
  end
end