dommy-js-quickjs 0.1.0 → 0.9.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.
- checksums.yaml +4 -4
- data/.github/workflows/test.yml +28 -0
- data/CHANGELOG.md +84 -1
- data/README.md +31 -9
- data/Rakefile +70 -2
- data/lib/dommy/js/quickjs/backend.rb +138 -1
- data/lib/dommy/js/quickjs/capybara.rb +31 -17
- data/lib/dommy/js/quickjs/rack.rb +15 -0
- data/lib/dommy/js/quickjs/runtime.rb +450 -42
- data/lib/dommy/js/quickjs/script_cache.rb +37 -0
- data/lib/dommy/js/quickjs/source_guard.rb +304 -0
- data/lib/dommy/js/quickjs/version.rb +1 -1
- data/lib/dommy/js/quickjs/wasm_bridge.rb +15 -15
- data/lib/dommy/js/quickjs.rb +13 -5
- data/script/build_jsx_transform.sh +27 -0
- data/script/build_stimulus_tests.sh +52 -0
- metadata +13 -14
- data/lib/dommy/js/constructor_registry.rb +0 -40
- data/lib/dommy/js/custom_elements.rb +0 -55
- data/lib/dommy/js/dom_interfaces.rb +0 -139
- data/lib/dommy/js/handle_table.rb +0 -52
- data/lib/dommy/js/host_bridge.rb +0 -400
- data/lib/dommy/js/host_runtime.js +0 -922
- data/lib/dommy/js/observable_runtime.js +0 -728
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Dommy
|
|
4
|
-
module Js
|
|
5
|
-
# Derives WebIDL interface metadata for a Dommy DOM object: the most-derived
|
|
6
|
-
# interface name and the single-inheritance chain up to the root
|
|
7
|
-
# (EventTarget for nodes). This mirrors the JS prototype chain the bridge
|
|
8
|
-
# builds so `instanceof` / Object.prototype.toString resolve correctly.
|
|
9
|
-
#
|
|
10
|
-
# Engine-agnostic, and the single home for DOM interface hierarchy knowledge:
|
|
11
|
-
# BASE_CHAINS (seeded eagerly on the JS side) must stay consistent with what
|
|
12
|
-
# #chain_for derives from real objects.
|
|
13
|
-
module DomInterfaces
|
|
14
|
-
# Dommy class basename -> WebIDL interface name, where they diverge.
|
|
15
|
-
# Anything not listed uses the class basename verbatim (HTMLDivElement, …).
|
|
16
|
-
NAME_OVERRIDES = {
|
|
17
|
-
"TextNode" => "Text",
|
|
18
|
-
"CommentNode" => "Comment",
|
|
19
|
-
"CharacterDataNode" => "CharacterData",
|
|
20
|
-
"Fragment" => "DocumentFragment",
|
|
21
|
-
"ClassList" => "DOMTokenList",
|
|
22
|
-
"DatasetMap" => "DOMStringMap",
|
|
23
|
-
"StyleDeclaration" => "CSSStyleDeclaration",
|
|
24
|
-
"LiveNodeList" => "NodeList",
|
|
25
|
-
"StandaloneEventTarget" => "EventTarget"
|
|
26
|
-
}.freeze
|
|
27
|
-
|
|
28
|
-
# Concrete HTML element interfaces. A browser exposes every one of these as
|
|
29
|
-
# a global constructor whether or not an instance exists, so a framework's
|
|
30
|
-
# bare `instanceof HTMLInputElement` feature check resolves regardless of
|
|
31
|
-
# page content (idiomorph, Turbo's morph engine, probes `instanceof
|
|
32
|
-
# HTMLInputElement`/`HTMLTextAreaElement` during focus restoration even when
|
|
33
|
-
# the page has no such element). Each is a direct HTMLElement subclass
|
|
34
|
-
# except the two media leaves, appended with their chains below. Mirrors the
|
|
35
|
-
# `class HTMLxxxElement < HTMLElement` set in the dommy gem's html_elements.
|
|
36
|
-
HTML_LEAF_INTERFACES = %w[
|
|
37
|
-
HTMLAnchorElement HTMLAreaElement HTMLBaseElement HTMLBodyElement
|
|
38
|
-
HTMLBRElement HTMLButtonElement HTMLDataElement HTMLDetailsElement
|
|
39
|
-
HTMLDialogElement HTMLDivElement HTMLEmbedElement HTMLFieldsetElement
|
|
40
|
-
HTMLFormElement HTMLHeadElement HTMLHeadingElement HTMLHRElement
|
|
41
|
-
HTMLHtmlElement HTMLIFrameElement HTMLImageElement HTMLInputElement
|
|
42
|
-
HTMLLabelElement HTMLLegendElement HTMLLIElement HTMLLinkElement
|
|
43
|
-
HTMLMapElement HTMLMetaElement HTMLMeterElement HTMLModElement
|
|
44
|
-
HTMLObjectElement HTMLOListElement HTMLOptGroupElement HTMLOptionElement
|
|
45
|
-
HTMLOutputElement HTMLParagraphElement HTMLPictureElement HTMLPreElement
|
|
46
|
-
HTMLProgressElement HTMLQuoteElement HTMLScriptElement HTMLSelectElement
|
|
47
|
-
HTMLSlotElement HTMLSourceElement HTMLSpanElement HTMLStyleElement
|
|
48
|
-
HTMLTableCaptionElement HTMLTableCellElement HTMLTableElement
|
|
49
|
-
HTMLTableRowElement HTMLTableSectionElement HTMLTemplateElement
|
|
50
|
-
HTMLTextAreaElement HTMLTimeElement HTMLTitleElement HTMLTrackElement
|
|
51
|
-
HTMLUListElement
|
|
52
|
-
].freeze
|
|
53
|
-
|
|
54
|
-
# Base interface chains seeded eagerly on the JS side so `instanceof Node`
|
|
55
|
-
# / `typeof HTMLElement` resolve before an instance of that exact type has
|
|
56
|
-
# crossed. Concrete leaves (HTMLButtonElement, …) are built lazily from
|
|
57
|
-
# #chain_for when an instance crosses. Keep consistent with #chain_for.
|
|
58
|
-
BASE_CHAINS = [
|
|
59
|
-
%w[Node EventTarget],
|
|
60
|
-
%w[Element Node EventTarget],
|
|
61
|
-
%w[HTMLElement Element Node EventTarget],
|
|
62
|
-
%w[SVGElement Element Node EventTarget],
|
|
63
|
-
%w[CharacterData Node EventTarget],
|
|
64
|
-
%w[Text CharacterData Node EventTarget],
|
|
65
|
-
%w[Comment CharacterData Node EventTarget],
|
|
66
|
-
%w[Document Node EventTarget],
|
|
67
|
-
%w[DocumentFragment Node EventTarget],
|
|
68
|
-
%w[DocumentType Node EventTarget],
|
|
69
|
-
%w[Attr Node EventTarget],
|
|
70
|
-
%w[Event],
|
|
71
|
-
%w[CustomEvent Event],
|
|
72
|
-
%w[MessageEvent Event],
|
|
73
|
-
%w[PopStateEvent Event],
|
|
74
|
-
%w[CloseEvent Event],
|
|
75
|
-
%w[MouseEvent Event],
|
|
76
|
-
%w[KeyboardEvent Event],
|
|
77
|
-
%w[DOMException],
|
|
78
|
-
# Window-exposed constructors that frameworks call bare (new X(...)).
|
|
79
|
-
# Seeding them creates the global; construction routes to the window.
|
|
80
|
-
%w[MutationObserver], %w[IntersectionObserver], %w[ResizeObserver],
|
|
81
|
-
%w[PerformanceObserver], %w[AbortController], %w[AbortSignal EventTarget],
|
|
82
|
-
%w[FormData], %w[URL], %w[URLSearchParams], %w[Headers], %w[Request], %w[Response],
|
|
83
|
-
%w[Blob], %w[File], %w[FileList], %w[FileReader], %w[XMLHttpRequest],
|
|
84
|
-
%w[TextEncoder], %w[TextDecoder], %w[DOMParser], %w[XMLSerializer],
|
|
85
|
-
%w[MessageChannel], %w[BroadcastChannel], %w[WebSocket], %w[EventSource],
|
|
86
|
-
%w[Notification], %w[Worker], %w[DataTransfer],
|
|
87
|
-
%w[ReadableStream], %w[WritableStream], %w[TransformStream],
|
|
88
|
-
%w[Range],
|
|
89
|
-
# Collection interfaces, seeded so `result instanceof NodeList` /
|
|
90
|
-
# `instanceof HTMLCollection` resolve (querySelectorAll, children, …).
|
|
91
|
-
%w[NodeList], %w[HTMLCollection],
|
|
92
|
-
# Traversal: NodeFilter exposes only [Constant]s (NodeFilter.SHOW_ELEMENT,
|
|
93
|
-
# .FILTER_ACCEPT, …); TreeWalker/NodeIterator are instances.
|
|
94
|
-
%w[NodeFilter], %w[TreeWalker], %w[NodeIterator],
|
|
95
|
-
# Concrete HTML element interfaces (see HTML_LEAF_INTERFACES) + the media
|
|
96
|
-
# subtree, so bare `instanceof HTMLInputElement` always resolves.
|
|
97
|
-
*HTML_LEAF_INTERFACES.map { |n| [n, "HTMLElement", "Element", "Node", "EventTarget"] },
|
|
98
|
-
%w[HTMLMediaElement HTMLElement Element Node EventTarget],
|
|
99
|
-
%w[HTMLAudioElement HTMLMediaElement HTMLElement Element Node EventTarget],
|
|
100
|
-
%w[HTMLVideoElement HTMLMediaElement HTMLElement Element Node EventTarget]
|
|
101
|
-
].freeze
|
|
102
|
-
|
|
103
|
-
module_function
|
|
104
|
-
|
|
105
|
-
# { "name" => most-derived interface, "chain" => [...] } for a host object.
|
|
106
|
-
def info(obj)
|
|
107
|
-
chain = chain_for(obj)
|
|
108
|
-
{"name" => chain.first, "chain" => chain}
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
# Walk the Dommy class superclass chain (HTMLDivElement < HTMLElement <
|
|
112
|
-
# Element), then append the module-provided base interfaces (Node ->
|
|
113
|
-
# EventTarget) for nodes, since Dommy models Node as a mixin rather than a
|
|
114
|
-
# superclass. Stops at the first foreign superclass (Object, or
|
|
115
|
-
# StandardError for DOMException) so non-DOM ancestors stay out.
|
|
116
|
-
def chain_for(obj)
|
|
117
|
-
names = []
|
|
118
|
-
klass = obj.class
|
|
119
|
-
while klass && klass.name&.start_with?("Dommy::")
|
|
120
|
-
name = name_for(klass)
|
|
121
|
-
names << name if name && !names.include?(name)
|
|
122
|
-
klass = klass.superclass
|
|
123
|
-
end
|
|
124
|
-
if defined?(Dommy::Node) && obj.is_a?(Dommy::Node)
|
|
125
|
-
names << "Node" unless names.include?("Node")
|
|
126
|
-
names << "EventTarget" unless names.include?("EventTarget")
|
|
127
|
-
end
|
|
128
|
-
names
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def name_for(klass)
|
|
132
|
-
base = klass.name&.split("::")&.last
|
|
133
|
-
return nil unless base
|
|
134
|
-
|
|
135
|
-
NAME_OVERRIDES.fetch(base, base)
|
|
136
|
-
end
|
|
137
|
-
end
|
|
138
|
-
end
|
|
139
|
-
end
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Dommy
|
|
4
|
-
module Js
|
|
5
|
-
# Maps JS-facing integer handles to live Ruby objects. Engine-agnostic.
|
|
6
|
-
#
|
|
7
|
-
# Handles are monotonic — never reused — so a handle can never refer to two
|
|
8
|
-
# different objects over the table's lifetime. That invariant is what makes
|
|
9
|
-
# GC-driven #release race-free: a finalizer releasing handle N can't clobber
|
|
10
|
-
# a different object that happened to get the same id.
|
|
11
|
-
#
|
|
12
|
-
# The same Ruby object reuses its handle while still registered (keyed by
|
|
13
|
-
# object_id), so it maps to a single JS proxy (stable identity). A registry
|
|
14
|
-
# entry also keeps the object reachable while JS holds a proxy for it.
|
|
15
|
-
class HandleTable
|
|
16
|
-
def initialize
|
|
17
|
-
@by_handle = {} # handle (Integer) -> object
|
|
18
|
-
@handle_by_oid = {} # object_id -> handle (for identity reuse)
|
|
19
|
-
@next_handle = 0
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def register(obj)
|
|
23
|
-
oid = obj.object_id
|
|
24
|
-
existing = @handle_by_oid[oid]
|
|
25
|
-
return existing if existing && @by_handle[existing].equal?(obj)
|
|
26
|
-
|
|
27
|
-
handle = (@next_handle += 1)
|
|
28
|
-
@by_handle[handle] = obj
|
|
29
|
-
@handle_by_oid[oid] = handle
|
|
30
|
-
handle
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def fetch(handle)
|
|
34
|
-
@by_handle.fetch(handle.to_i)
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
# Forget a handle (called when its JS proxy is garbage-collected). Only
|
|
38
|
-
# trims the mapping; the object itself lives on via its other references.
|
|
39
|
-
def release(handle)
|
|
40
|
-
obj = @by_handle.delete(handle.to_i)
|
|
41
|
-
return unless obj
|
|
42
|
-
|
|
43
|
-
oid = obj.object_id
|
|
44
|
-
@handle_by_oid.delete(oid) if @handle_by_oid[oid] == handle.to_i
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def size
|
|
48
|
-
@by_handle.size
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
end
|
data/lib/dommy/js/host_bridge.rb
DELETED
|
@@ -1,400 +0,0 @@
|
|
|
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
|