dommy-js-quickjs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,400 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Dommy
6
+ module Js
7
+ # Engine-agnostic core of the JS<->Ruby DOM bridge. Given a `backend` that
8
+ # can evaluate JS, register Ruby host functions, and call back into JS,
9
+ # HostBridge exposes a Ruby object to the JS side as an ES Proxy whose
10
+ # property/method access routes into the bridge ABI:
11
+ # __js_get__(name) / __js_set__(name, value) / __js_call__(method, args)
12
+ #
13
+ # Nothing here is QuickJS-specific; this layer is intended to move into a
14
+ # future `dommy-js` gem with QuickJS/wasm backends plugged in underneath.
15
+ #
16
+ # Two collaborators keep the marshalling core free of DOM specifics:
17
+ # DomInterfaces — interface name/chain derivation (instanceof support)
18
+ # ConstructorRegistry — `new Event(...)` style reverse construction
19
+ #
20
+ # Backend contract:
21
+ # backend.eval(js) -> evaluate top-level JS
22
+ # backend.define_host_function(name) { } -> expose a Ruby block as a JS global
23
+ # backend.call_js(path, *args) -> invoke a JS global function by path
24
+ #
25
+ # The host object must implement __js_get__/__js_set__/__js_call__, and the
26
+ # bridge needs to know which names are methods (callable via __js_call__)
27
+ # vs. properties (read via __js_get__) — see #method_names.
28
+ class HostBridge
29
+ # JS half of the bridge (globalThis.__rbHost). Read from a companion file
30
+ # so it stays lintable/highlightable rather than buried in a heredoc.
31
+ # ::File — inside module Dommy, bare `File` resolves to Dommy::File (the
32
+ # File API class), not Ruby's file class.
33
+ HOST_RUNTIME_JS = ::File.read(::File.join(__dir__, "host_runtime.js")).freeze
34
+ # The WICG Observable polyfill (Observable/Subscriber + EventTarget.when),
35
+ # evaluated after the DOM interface prototypes are seeded.
36
+ OBSERVABLE_RUNTIME_JS = ::File.read(::File.join(__dir__, "observable_runtime.js")).freeze
37
+
38
+ def initialize(backend)
39
+ @backend = backend
40
+ @handles = HandleTable.new
41
+ @callback_objects = {}
42
+ @constructors = ConstructorRegistry.new
43
+ @custom_elements = CustomElements.new(self)
44
+ @microtask_procs = {}
45
+ @microtask_seq = 0
46
+ install!
47
+ end
48
+
49
+ # Bind a Ruby object to a JS global of the given name.
50
+ def define_host_object(name, obj)
51
+ handle = @handles.register(obj)
52
+ @backend.eval("globalThis[#{name.to_s.to_json}] = __rbHost.makeProxy(#{handle}); undefined;")
53
+ obj
54
+ end
55
+
56
+ # Bind the window the bridge draws on for JS constructors (new Event(...))
57
+ # and custom element registration. Called by Runtime#install_window — kept
58
+ # distinct from define_host_object so the generic binder has no hidden
59
+ # side effects.
60
+ def window=(win)
61
+ @constructors.source = win
62
+ @custom_elements.window = win
63
+ # Now that constructors are resolvable, expose their static methods
64
+ # (URL.createObjectURL, …) on the seeded interface globals, and expose
65
+ # the constructors themselves on the window proxy (window.Node,
66
+ # document.defaultView.DOMException, …).
67
+ @backend.call_js("__rbHost.attachStatics")
68
+ @backend.call_js("__rbHost.exposeConstructorsOnWindow")
69
+ # Route Dommy's host-side microtasks (MutationObserver delivery, …) onto
70
+ # the engine's native promise-job queue, so they interleave FIFO with JS
71
+ # `await`/Promise reactions instead of draining on a separate pass (which
72
+ # would deliver e.g. MutationObserver records only after `await
73
+ # Promise.resolve()`, batching several mutations into one callback).
74
+ if win.respond_to?(:scheduler) && win.scheduler.respond_to?(:native_microtask_scheduler=)
75
+ win.scheduler.native_microtask_scheduler = ->(callback) { schedule_native_microtask(callback) }
76
+ end
77
+ end
78
+
79
+ # Enqueue a Ruby callback as a NATIVE microtask (a resolved-promise job), so
80
+ # it runs in FIFO order with the engine's other promise jobs.
81
+ def schedule_native_microtask(callback)
82
+ id = (@microtask_seq += 1)
83
+ @microtask_procs[id] = callback
84
+ @backend.call_js("__rbHost.scheduleMicrotask", id)
85
+ nil
86
+ end
87
+
88
+ # Expose the seeded interface constructors (Element, Node, DOMException, …)
89
+ # on a secondary window object — an iframe's contentWindow — so cross-window
90
+ # `instanceof subWin.Element` and `subDoc.defaultView.DOMException` resolve
91
+ # to the same constructors the top window uses. Idempotent per window.
92
+ def expose_constructors_on(window_obj)
93
+ handle = @handles.register(window_obj)
94
+ # Retain the proxy in a JS-side registry: the constructors are defined as
95
+ # own properties on the proxy's target, so the proxy must stay alive (and
96
+ # keep its handle) — otherwise GC releases it and a later
97
+ # `iframe.contentWindow` rebuilds a fresh, constructor-less proxy.
98
+ @backend.eval(<<~JS)
99
+ (globalThis.__rbSubWindows ||= []).push(__rbHost.makeProxy(#{handle}));
100
+ __rbHost.exposeConstructorsOnWindow(globalThis.__rbSubWindows.at(-1));
101
+ JS
102
+ window_obj
103
+ end
104
+
105
+ # Invoke a JS custom element lifecycle callback (connectedCallback etc.) for
106
+ # a Dommy node. Called by the bridged custom element class (see CustomElements).
107
+ def invoke_lifecycle(node, callback, args)
108
+ handle = @handles.register(node)
109
+ unwrap(@backend.call_js("__rbHost.invokeLifecycle", handle, callback, wrap(Array(args))))
110
+ end
111
+
112
+ # Invoke a retained live JS function by id (used by HostCallback). The JS
113
+ # side returns a `dehydrate`d (tagged) value, so unwrap it back to Ruby:
114
+ # a callback that returns e.g. a Promise proxy must come back as the live
115
+ # PromiseValue, otherwise Dommy can't adopt it (breaking
116
+ # `fetch().then(r => r.json()).then(…)` chains).
117
+ def invoke_callback(id, args, this_arg = nil)
118
+ unwrap(@backend.call_js("__rbHost.invokeCallback", id, wrap(Array(args)), wrap(this_arg)))
119
+ end
120
+
121
+ # Turn a JS-side tagged value (produced by __rbHost.tag) back into Ruby:
122
+ # tagged handles become the original Ruby DOM objects. Used for return
123
+ # values that may contain DOM nodes (e.g. evaluate_script).
124
+ def decode(tagged)
125
+ unwrap(tagged)
126
+ end
127
+
128
+ # Number of live handle entries. Introspection for lifetime tests.
129
+ def registered_count
130
+ @handles.size
131
+ end
132
+
133
+ private
134
+
135
+ def install!
136
+ @backend.define_host_function("__rb_host_get") do |handle, prop|
137
+ dom_guard do
138
+ obj = host(handle)
139
+ wrap(obj.respond_to?(:__js_get__) ? obj.__js_get__(prop) : nil)
140
+ end
141
+ end
142
+ @backend.define_host_function("__rb_host_set") do |handle, prop, value|
143
+ # Returns whether Dommy handled the write as a DOM property. When it
144
+ # didn't (or the object has no __js_set__), the JS side keeps the value
145
+ # as an expando (preserving object/instance field identity). Wrapped in
146
+ # dom_guard so a throwing setter (e.g. `documentElement.outerHTML = …` →
147
+ # NoModificationAllowedError) crosses as a tagged exception the JS set
148
+ # trap re-throws, rather than escaping as a raw Ruby error.
149
+ dom_guard do
150
+ obj = host(handle)
151
+ obj.respond_to?(:__js_set__) ? dommy_handled?(obj.__js_set__(prop, unwrap(value))) : false
152
+ end
153
+ end
154
+ @backend.define_host_function("__rb_host_call") do |handle, method, args|
155
+ dom_guard do
156
+ obj = host(handle)
157
+ obj.respond_to?(:__js_call__) ? wrap(obj.__js_call__(method, unwrap(args))) : nil
158
+ end
159
+ end
160
+ # 2d: one call returns everything makeProxy needs — interface name +
161
+ # chain, method names, and the custom element tag (if any).
162
+ @backend.define_host_function("__rb_host_describe") do |handle|
163
+ obj = host(handle)
164
+ info = DomInterfaces.info(obj)
165
+ info["methods"] = method_names(obj)
166
+ # Mark JS-defined custom elements so makeProxy upgrades them on crossing.
167
+ info["ce"] = obj.__js_custom_element_name__ if obj.respond_to?(:__js_custom_element_name__)
168
+ info
169
+ end
170
+ @backend.define_host_function("__rb_release_handle") do |handle|
171
+ @handles.release(handle)
172
+ nil
173
+ end
174
+ # Run a Ruby microtask previously registered by schedule_native_microtask,
175
+ # invoked from the resolved-promise job scheduleMicrotask queued.
176
+ @backend.define_host_function("__rb_run_microtask") do |id|
177
+ callback = @microtask_procs.delete(id)
178
+ callback&.call
179
+ nil
180
+ end
181
+ # WebIDL "supported property names" for a legacy platform object (a live
182
+ # array-like/maplike collection): the current ordered named-property
183
+ # keys. Queried per ownKeys / getOwnPropertyDescriptor so it tracks DOM
184
+ # mutations. Nil when the object has no named getter.
185
+ @backend.define_host_function("__rb_named_props") do |handle|
186
+ obj = host(handle)
187
+ obj.respond_to?(:__js_named_props__) ? Array(obj.__js_named_props__).map(&:to_s) : nil
188
+ end
189
+ # Named deleter (`delete el.dataset.foo`): true when the object handled
190
+ # the delete, false/UNHANDLED when the JS side should fall back to its
191
+ # own (expando) delete.
192
+ @backend.define_host_function("__rb_host_delete") do |handle, prop|
193
+ dom_guard do
194
+ obj = host(handle)
195
+ obj.respond_to?(:__js_delete__) ? dommy_handled?(obj.__js_delete__(prop)) : false
196
+ end
197
+ end
198
+ # `new Event(...)` / `new DOMException(...)` from a bare interface
199
+ # constructor — resolve the named constructor and build. Returns nil when
200
+ # the interface isn't constructable, so the JS side throws.
201
+ @backend.define_host_function("__rb_construct") do |name, args|
202
+ dom_guard do
203
+ ctor = @constructors.resolve(name)
204
+ ctor ? wrap(ctor.__js_new__(unwrap(args))) : nil
205
+ end
206
+ end
207
+ # Static/class methods on an interface constructor (URL.createObjectURL,
208
+ # URL.parse, …): names to expose, and the dispatch.
209
+ @backend.define_host_function("__rb_static_names") do |name|
210
+ ctor = @constructors.resolve(name)
211
+ ctor.respond_to?(:__js_class_method_names__) ? ctor.__js_class_method_names__ : []
212
+ end
213
+ @backend.define_host_function("__rb_static_call") do |name, method, args|
214
+ dom_guard do
215
+ ctor = @constructors.resolve(name)
216
+ ctor.respond_to?(:__js_call__) ? wrap(ctor.__js_call__(method, unwrap(args))) : nil
217
+ end
218
+ end
219
+ # 1d: customElements.define(name, JSClass) wires a Dommy custom element.
220
+ @backend.define_host_function("__rb_define_custom_element") do |name, observed|
221
+ @custom_elements.define(name, Array(observed))
222
+ nil
223
+ end
224
+ # 1d: customElements.upgrade(root) — delegate to Dommy's registry.
225
+ @backend.define_host_function("__rb_upgrade_custom_elements") do |handle|
226
+ @custom_elements.upgrade(host(handle))
227
+ nil
228
+ end
229
+ @backend.eval(HOST_RUNTIME_JS)
230
+ # Seed base interface prototypes from the single Ruby-side hierarchy.
231
+ @backend.eval("__rbHost.seedInterfaces(#{JSON.generate(DomInterfaces::BASE_CHAINS)});")
232
+ # Observable depends on EventTarget.prototype existing (seeded above).
233
+ @backend.eval(OBSERVABLE_RUNTIME_JS)
234
+ end
235
+
236
+ def host(handle)
237
+ @handles.fetch(handle)
238
+ end
239
+
240
+ # Run a host-function body, converting a raised Dommy::DOMException into a
241
+ # tagged marker that the JS side (rehydrate) re-throws as a real
242
+ # DOMException (name + legacy code, `instanceof DOMException`). Otherwise
243
+ # the quickjs gem flattens it to a plain Error — no name/code — which
244
+ # breaks `assert_throws_dom` and every DOM error contract (removeChild
245
+ # NotFoundError, classList SyntaxError/InvalidCharacterError, …).
246
+ def dom_guard
247
+ yield
248
+ rescue Dommy::Bridge::ThrowValue => e
249
+ # A host method threw an arbitrary value (e.g. throwIfAborted's reason);
250
+ # re-throw it verbatim JS-side, identity preserved.
251
+ {"__rb_throw__" => wrap(e.value)}
252
+ rescue Dommy::DOMException => e
253
+ {"__rb_exception__" => {"name" => e.name, "message" => e.message, "code" => e.code}}
254
+ rescue Dommy::Bridge::TypeError => e
255
+ # A deliberate, spec-mandated JS TypeError (e.g. `new URL(bad)`). Tagged
256
+ # so rehydrate rethrows a real `TypeError` — `assert_throws_js(TypeError,
257
+ # …)` checks `instanceof TypeError`, which a DOMException/Error fails.
258
+ {"__rb_exception__" => {"name" => "TypeError", "message" => e.message, "js_native" => true}}
259
+ rescue Dommy::Bridge::RangeError => e
260
+ # A spec-mandated JS RangeError (e.g. `new Response(b, {status: 42})`).
261
+ {"__rb_exception__" => {"name" => "RangeError", "message" => e.message, "js_native" => true}}
262
+ end
263
+
264
+ # Ruby -> JS: tag bridge-able objects so the JS side can proxy them.
265
+ # Recurses Array and Hash so nested DOM nodes are tagged too (symmetric
266
+ # with #unwrap).
267
+ def wrap(value)
268
+ # A `__js_call__` may return the UNDEFINED sentinel for a void op; marshal
269
+ # it so the JS side yields `undefined` rather than `null`.
270
+ if defined?(Dommy::Bridge::UNDEFINED) && value.equal?(Dommy::Bridge::UNDEFINED)
271
+ return {"__rb_undefined" => true}
272
+ end
273
+ # A byte buffer tagged ArrayBuffer crosses back as a bare ArrayBuffer
274
+ # (checked before Bytes, since ArrayBuffer < Bytes).
275
+ if defined?(Dommy::Bridge::ArrayBuffer) && value.is_a?(Dommy::Bridge::ArrayBuffer)
276
+ return {"__rb_arraybuffer" => value.to_a}
277
+ end
278
+ # A byte buffer crosses back as a JS Uint8Array.
279
+ if defined?(Dommy::Bridge::Bytes) && value.is_a?(Dommy::Bridge::Bytes)
280
+ return {"__rb_bytes" => value.to_a}
281
+ end
282
+ # An opaque JS value returns as its original JS object (identity kept).
283
+ if defined?(Dommy::Bridge::JSValue) && value.is_a?(Dommy::Bridge::JSValue)
284
+ return {"__rb_js_ref" => value.ref}
285
+ end
286
+
287
+ case value
288
+ when Array
289
+ value.map { |element| wrap(element) }
290
+ when Hash
291
+ value.transform_values { |element| wrap(element) }
292
+ when HostCallback
293
+ # A JS function that crossed into Ruby returns as the same live JS
294
+ # function (not a proxy), so callbacks nested in objects round-trip.
295
+ {"__rb_callback" => value.id}
296
+ else
297
+ if bridgeable?(value)
298
+ {"__rb_handle" => @handles.register(value)}
299
+ else
300
+ value
301
+ end
302
+ end
303
+ end
304
+
305
+ # A value crosses as a proxy if it implements any of the bridge ABI — not
306
+ # only __js_get__: method-only objects (observers) and constructors expose
307
+ # __js_call__ / __js_new__ without properties.
308
+ def bridgeable?(value)
309
+ value.respond_to?(:__js_get__) ||
310
+ value.respond_to?(:__js_call__) ||
311
+ value.respond_to?(:__js_new__)
312
+ end
313
+
314
+ # JS -> Ruby: rebuild tagged handles / callbacks into Ruby objects.
315
+ def unwrap(value)
316
+ case value
317
+ when Array
318
+ value.map { |element| unwrap(element) }
319
+ when Hash
320
+ if value.key?("__rb_handle")
321
+ host(value["__rb_handle"])
322
+ elsif value.key?("__rb_callback")
323
+ id = value["__rb_callback"]
324
+ @callback_objects[id] ||= HostCallback.new(self, id)
325
+ elsif value.key?("__rb_js_ref")
326
+ # An opaque JS value (a non-plain object Ruby just stores and returns,
327
+ # e.g. an abort reason) — kept as a handle so it round-trips with
328
+ # identity rather than being flattened to a Hash.
329
+ if defined?(Dommy::Bridge::JSValue)
330
+ Dommy::Bridge::JSValue.new(value["__rb_js_ref"], value["__rb_js_label"])
331
+ else
332
+ value
333
+ end
334
+ elsif value.key?("__rb_undefined")
335
+ # A top-level JS `undefined` argument — distinct from JS null (nil).
336
+ defined?(Dommy::Bridge::UNDEFINED) ? Dommy::Bridge::UNDEFINED : nil
337
+ elsif value.key?("__rb_bytes")
338
+ # A JS ArrayBuffer / TypedArray argument arrives as a byte buffer.
339
+ defined?(Dommy::Bridge::Bytes) ? Dommy::Bridge::Bytes.new(value["__rb_bytes"]) : value["__rb_bytes"]
340
+ else
341
+ value.transform_values { |element| unwrap(element) }
342
+ end
343
+ when :undefined
344
+ # A raw JS `undefined` (e.g. a property-set value, which crosses via
345
+ # `dehydrate` rather than the tagged `dehydrateArgs`) reaches Ruby as
346
+ # the gem's `:undefined` symbol. Normalize it to the same sentinel a
347
+ # tagged top-level undefined produces, so setters can distinguish it
348
+ # from `null` (e.g. `el.ariaLabel = undefined` removes the attribute).
349
+ defined?(Dommy::Bridge::UNDEFINED) ? Dommy::Bridge::UNDEFINED : nil
350
+ else
351
+ value
352
+ end
353
+ end
354
+
355
+ # Which property names should be treated as callable methods. The ABI
356
+ # keeps properties (__js_get__) and methods (__js_call__) in disjoint
357
+ # namespaces, so the proxy asks the object to self-describe via the bridge
358
+ # ABI method __js_method_names__. method_defined? (not respond_to?) avoids
359
+ # classes whose respond_to_missing? answers true for arbitrary names (e.g.
360
+ # StyleDeclaration's CSS-property accessors).
361
+ def method_names(obj)
362
+ return [] unless obj.class.method_defined?(:__js_method_names__)
363
+
364
+ Array(obj.__js_method_names__).map(&:to_s)
365
+ end
366
+
367
+ # Did Dommy treat a __js_set__ as a real DOM property? A returned UNHANDLED
368
+ # sentinel means "no" (the JS side then keeps it as an expando). Tolerant of
369
+ # older Dommy without the sentinel (treats everything as handled).
370
+ def dommy_handled?(result)
371
+ !(defined?(Dommy::Bridge::UNHANDLED) && result == Dommy::Bridge::UNHANDLED)
372
+ end
373
+ end
374
+
375
+ # An event listener backed by a live JS function. Implements only the bridge
376
+ # ABI (__js_call__) — not #call/#handle_event — so Dommy's invoke_listener
377
+ # routes through the __js_call__("call", [event]) branch.
378
+ class HostCallback
379
+ attr_reader :id
380
+
381
+ def initialize(bridge, id)
382
+ @bridge = bridge
383
+ @id = id
384
+ end
385
+
386
+ def __js_call__(method, args)
387
+ return nil unless method == "call"
388
+
389
+ @bridge.invoke_callback(@id, args)
390
+ end
391
+
392
+ # Invoke with an explicit `this` receiver — e.g. a MutationObserver
393
+ # callback whose `this` must be the observer, or an event listener whose
394
+ # `this` is the currentTarget.
395
+ def __js_call_with_this__(args, this_arg)
396
+ @bridge.invoke_callback(@id, args, this_arg)
397
+ end
398
+ end
399
+ end
400
+ end