Class: Dommy::Js::Marshaller

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

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.

Returns:

  • (Boolean)


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_guardObject

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.message, "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.message, "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.message, "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)

#sizeObject



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.message, "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