Class: Dommy::Js::Marshaller
- Inherits:
-
Object
- Object
- Dommy::Js::Marshaller
- Defined in:
- lib/dommy/js/marshaller.rb
Overview
The Ruby<->JS value marshaller + cross-boundary identity tables. Given a
bridge (used only to back the live-callback adapters), it converts Ruby
values to the WireTags-tagged shapes the JS side proxies (#wrap) and
rebuilds tagged JS values back into Ruby (#unwrap), and owns the handle /
callback / listener / filter caches that keep identity stable across the
boundary.
Extracted from HostBridge so the marshalling concern is separate from the host-function ABI registration — and so a second bridge (a future wasm guest bridge, see WireTags) can reuse the exact same logic rather than re-deriving the tag shapes. Engine-agnostic.
Instance Method Summary collapse
-
#bridgeable?(value) ⇒ Boolean
A value crosses as a proxy if it implements any of the bridge ABI — not only js_get: method-only objects (observers) and constructors expose js_call / js_new without properties.
-
#callback_result(raw, raising) ⇒ Object
A callback's return value, or — when the JS side tagged the result as a throw ("rb_cb_threw") — the thrown value re-raised (raising) or swallowed (the default, returning nil).
-
#dom_guard ⇒ Object
Run a host-function body, converting a raised Dommy::DOMException into a tagged marker that the JS side (rehydrate) re-throws as a real DOMException (name + legacy code,
instanceof DOMException). - #host(handle) ⇒ Object
-
#initialize(bridge) ⇒ Marshaller
constructor
A new instance of Marshaller.
-
#interface_name(value) ⇒ Object
The host object's interface name (chain.first), cached by class — the name->descriptor mapping is per-interface, so this lookup is the cheap half of avoiding the describe crossing.
-
#register(obj) ⇒ Object
---- handle table (cross-boundary object identity) ----.
- #release(handle) ⇒ Object
- #size ⇒ Object
-
#unwrap(value) ⇒ Object
JS -> Ruby: rebuild tagged handles / callbacks into Ruby objects.
-
#wrap(value) ⇒ Object
Ruby -> JS: tag bridge-able objects so the JS side can proxy them.
-
#wrap_handle(value) ⇒ Object
A handle wire value, tagged with the host object's interface name (and custom-element tag when applicable) so makeProxy can build the proxy from a cached per-interface descriptor and skip a
__rb_host_describeround trip — the bridge's biggest avoidable cost when JS walks/creates many nodes (each new proxy otherwise describes, even for a shared interface).
Constructor Details
#initialize(bridge) ⇒ Marshaller
Returns a new instance of Marshaller.
17 18 19 20 21 22 23 |
# File 'lib/dommy/js/marshaller.rb', line 17 def initialize(bridge) @bridge = bridge @handles = HandleTable.new @callback_objects = {} @listener_objects = {} @filter_objects = {} end |
Instance Method Details
#bridgeable?(value) ⇒ Boolean
A value crosses as a proxy if it implements any of the bridge ABI — not only js_get: method-only objects (observers) and constructors expose js_call / js_new without properties.
137 138 139 140 141 |
# File 'lib/dommy/js/marshaller.rb', line 137 def bridgeable?(value) value.respond_to?(:__js_get__) || value.respond_to?(:__js_call__) || value.respond_to?(:__js_new__) end |
#callback_result(raw, raising) ⇒ Object
A callback's return value, or — when the JS side tagged the result as a throw ("rb_cb_threw") — the thrown value re-raised (raising) or swallowed (the default, returning nil).
230 231 232 233 234 235 236 237 |
# File 'lib/dommy/js/marshaller.rb', line 230 def callback_result(raw, raising) if raw.is_a?(Hash) && raw.key?(WireTags::CALLBACK_THREW) raise Dommy::Bridge::ThrowValue.new(unwrap(raw[WireTags::CALLBACK_THREW])) if raising return nil end unwrap(raw) end |
#dom_guard ⇒ Object
Run a host-function body, converting a raised Dommy::DOMException into a
tagged marker that the JS side (rehydrate) re-throws as a real
DOMException (name + legacy code, instanceof DOMException). Otherwise
the quickjs gem flattens it to a plain Error — no name/code — which
breaks assert_throws_dom and every DOM error contract (removeChild
NotFoundError, classList SyntaxError/InvalidCharacterError, …).
209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 |
# File 'lib/dommy/js/marshaller.rb', line 209 def dom_guard yield rescue Dommy::Bridge::ThrowValue => e # A host method threw an arbitrary value (e.g. throwIfAborted's reason); # re-throw it verbatim JS-side, identity preserved. {WireTags::THROW => wrap(e.value)} rescue Dommy::DOMException => e {WireTags::EXCEPTION => {"name" => e.name, "message" => e., "code" => e.code}} rescue Dommy::Bridge::TypeError => e # A deliberate, spec-mandated JS TypeError (e.g. `new URL(bad)`). Tagged # so rehydrate rethrows a real `TypeError` — `assert_throws_js(TypeError, # …)` checks `instanceof TypeError`, which a DOMException/Error fails. {WireTags::EXCEPTION => {"name" => "TypeError", "message" => e., "js_native" => true}} rescue Dommy::Bridge::RangeError => e # A spec-mandated JS RangeError (e.g. `new Response(b, {status: 42})`). {WireTags::EXCEPTION => {"name" => "RangeError", "message" => e., "js_native" => true}} end |
#host(handle) ⇒ Object
28 |
# File 'lib/dommy/js/marshaller.rb', line 28 def host(handle) = @handles.fetch(handle) |
#interface_name(value) ⇒ Object
The host object's interface name (chain.first), cached by class — the name->descriptor mapping is per-interface, so this lookup is the cheap half of avoiding the describe crossing.
121 122 123 124 125 126 127 128 129 130 131 132 |
# File 'lib/dommy/js/marshaller.rb', line 121 def interface_name(value) @interface_name_cache ||= {} klass = value.class return @interface_name_cache[klass] if @interface_name_cache.key?(klass) @interface_name_cache[klass] = begin DomInterfaces.info(value)["name"] rescue StandardError nil end end |
#register(obj) ⇒ Object
---- handle table (cross-boundary object identity) ----
27 |
# File 'lib/dommy/js/marshaller.rb', line 27 def register(obj) = @handles.register(obj) |
#release(handle) ⇒ Object
29 |
# File 'lib/dommy/js/marshaller.rb', line 29 def release(handle) = @handles.release(handle) |
#size ⇒ Object
30 |
# File 'lib/dommy/js/marshaller.rb', line 30 def size = @handles.size |
#unwrap(value) ⇒ Object
JS -> Ruby: rebuild tagged handles / callbacks into Ruby objects.
144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 |
# File 'lib/dommy/js/marshaller.rb', line 144 def unwrap(value) case value when Array value.map { |element| unwrap(element) } when Hash if value.key?(WireTags::HANDLE) # Tolerant: an argument referencing a released/invalid node resolves # to nil rather than crashing (e.g. Vue passes a transient handle # during v-model setup). A receiver handle still uses strict #host. @handles.lookup(value[WireTags::HANDLE]) elsif value.key?(WireTags::CALLBACK) id = value[WireTags::CALLBACK] @callback_objects[id] ||= HostCallback.new(@bridge, id) elsif value.key?(WireTags::JS_REF) ref = value[WireTags::JS_REF] if value[WireTags::HANDLE_EVENT] # A JS object implementing EventListener (handleEvent). Wrap it as a # Ruby listener whose #handle_event routes back to its handleEvent. # Memoized by ref so the same JS object yields the same wrapper, # letting removeEventListener match the listener by identity. @listener_objects[ref] ||= HostEventListener.new(@bridge, ref, value[WireTags::JS_LABEL]) elsif value[WireTags::ACCEPT_NODE] # A NodeFilter callback-interface object. Wrap it so a traversal # invokes acceptNode on the live JS object (fresh getter, this = # object, exceptions propagated). @filter_objects[ref] ||= HostNodeFilter.new(@bridge, ref) else # An opaque JS value (a non-plain object Ruby just stores and # returns, e.g. an abort reason) — kept as a handle so it # round-trips with identity rather than being flattened to a Hash. Dommy::Bridge::JSValue.new(ref, value[WireTags::JS_LABEL]) end elsif value.key?(WireTags::UNDEFINED) # A top-level JS `undefined` argument — distinct from JS null (nil). Dommy::Bridge::UNDEFINED elsif value.key?(WireTags::ABSENT) # Symmetry with #wrap; an absent marker crossing back is the sentinel. Dommy::Bridge::ABSENT elsif value.key?(WireTags::BYTES) # A JS ArrayBuffer / TypedArray argument arrives as a byte buffer. Dommy::Bridge::Bytes.new(value[WireTags::BYTES]) else value.transform_values { |element| unwrap(element) } end when :undefined # A bare JS `undefined` (e.g. a property-set value, marshalled # directly rather than through the tagged-args path) arrives as the # `:undefined` symbol — see the HostBridge backend contract. Normalize # it to the same sentinel a tagged top-level undefined produces, so # setters can distinguish it from `null` (e.g. `el.ariaLabel = # undefined` removes the attribute). Dommy::Bridge::UNDEFINED else value end end |
#wrap(value) ⇒ Object
Ruby -> JS: tag bridge-able objects so the JS side can proxy them. Recurses Array and Hash so nested DOM nodes are tagged too (symmetric with #unwrap).
37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 |
# File 'lib/dommy/js/marshaller.rb', line 37 def wrap(value) # A `__js_call__` may return the UNDEFINED sentinel for a void op; marshal # it so the JS side yields `undefined` rather than `null`. if value.equal?(Dommy::Bridge::UNDEFINED) return {WireTags::UNDEFINED => true} end # A `__js_get__` returns the ABSENT sentinel for a genuinely-missing # property: the JS value is `undefined`, but the proxy reports it absent # for `in` (see host_runtime.js get/has traps). if value.equal?(Dommy::Bridge::ABSENT) return {WireTags::ABSENT => true} end # A byte buffer tagged ArrayBuffer crosses back as a bare ArrayBuffer # (checked before Bytes, since ArrayBuffer < Bytes). if value.is_a?(Dommy::Bridge::ArrayBuffer) return {WireTags::ARRAY_BUFFER => value.to_a} end # A byte buffer crosses back as a JS Uint8Array. if value.is_a?(Dommy::Bridge::Bytes) return {WireTags::BYTES => value.to_a} end # An opaque JS value returns as its original JS object (identity kept). if value.is_a?(Dommy::Bridge::JSValue) return {WireTags::JS_REF => value.ref} end # A host-created native error crossing as a VALUE (e.g. a promise's # rejection reason that must be `instanceof TypeError`): rebuild the real # JS error on the other side rather than flattening it to a plain object. if value.is_a?(Dommy::Bridge::TypeError) || value.is_a?(Dommy::Bridge::RangeError) name = value.is_a?(Dommy::Bridge::RangeError) ? "RangeError" : "TypeError" return {WireTags::ERROR_VALUE => {"name" => name, "message" => value., "js_native" => true}} end # A JS EventListener object wrapped on the way in returns as that same JS # object (so removeEventListener(el, this) reaches the right listener). if value.is_a?(HostEventListener) return {WireTags::JS_REF => value.ref} end # A host collection that subclasses Array (e.g. Dommy::NodeList < Array) # must cross as a proxy carrying its DOM interface — so `instanceof # NodeList`, `.item()` and the NodeList iterator work — rather than being # flattened to a plain JS array by the `when Array` branch below. Plain # Arrays (not bridgeable) still map element-wise. if value.is_a?(Array) && bridgeable?(value) return wrap_handle(value) end case value when Array value.map { |element| wrap(element) } when Hash value.transform_values { |element| wrap(element) } when HostCallback # A JS function that crossed into Ruby returns as the same live JS # function (not a proxy), so callbacks nested in objects round-trip. {WireTags::CALLBACK => value.id} else if bridgeable?(value) wrap_handle(value) else value end end end |
#wrap_handle(value) ⇒ Object
A handle wire value, tagged with the host object's interface name (and
custom-element tag when applicable) so makeProxy can build the proxy from
a cached per-interface descriptor and skip a __rb_host_describe round
trip — the bridge's biggest avoidable cost when JS walks/creates many
nodes (each new proxy otherwise describes, even for a shared interface).
107 108 109 110 111 112 113 114 115 116 |
# File 'lib/dommy/js/marshaller.rb', line 107 def wrap_handle(value) ref = {WireTags::HANDLE => @handles.register(value)} if (name = interface_name(value)) ref[WireTags::INTERFACE] = name end if value.respond_to?(:__js_custom_element_name__) && (ce = value.__js_custom_element_name__) ref[WireTags::CUSTOM_ELEMENT] = ce end ref end |