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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +6 -0
- data/LICENSE.txt +21 -0
- data/README.md +150 -0
- data/Rakefile +49 -0
- data/docs/bridge-redesign.md +559 -0
- data/docs/wpt-conformance.md +752 -0
- data/lib/dommy/js/constructor_registry.rb +40 -0
- data/lib/dommy/js/custom_elements.rb +55 -0
- data/lib/dommy/js/dom_interfaces.rb +139 -0
- data/lib/dommy/js/handle_table.rb +52 -0
- data/lib/dommy/js/host_bridge.rb +400 -0
- data/lib/dommy/js/host_runtime.js +922 -0
- data/lib/dommy/js/observable_runtime.js +728 -0
- data/lib/dommy/js/quickjs/backend.rb +64 -0
- data/lib/dommy/js/quickjs/capybara.rb +80 -0
- data/lib/dommy/js/quickjs/runtime.rb +210 -0
- data/lib/dommy/js/quickjs/version.rb +9 -0
- data/lib/dommy/js/quickjs/wasm_bridge.rb +151 -0
- data/lib/dommy/js/quickjs.rb +20 -0
- data/sig/dommy/js/quickjs.rbs +8 -0
- metadata +95 -0
|
@@ -0,0 +1,922 @@
|
|
|
1
|
+
// JS half of the Dommy <-> Ruby DOM bridge. Loaded once per backend and
|
|
2
|
+
// eval'd into the VM. Defines globalThis.__rbHost.{makeProxy, invokeCallback,
|
|
3
|
+
// tag, interfaceOf, seedInterfaces}.
|
|
4
|
+
//
|
|
5
|
+
// Values crossing the boundary are tagged: a bridge-able Ruby object is
|
|
6
|
+
// `{ __rb_handle: id }`, a JS function passed to Ruby is `{ __rb_callback: id }`.
|
|
7
|
+
globalThis.__rbHost = (function () {
|
|
8
|
+
const HKEY = Symbol("rbHandle");
|
|
9
|
+
const cache = new Map(); // handle -> WeakRef(proxy)
|
|
10
|
+
const callbacks = new Map();
|
|
11
|
+
const callbackIds = new WeakMap();
|
|
12
|
+
let nextCb = 1;
|
|
13
|
+
|
|
14
|
+
// 2a: array-like DOM collections that cross as proxies (not as JS arrays the
|
|
15
|
+
// way NodeList does) need Symbol.iterator so for-of / spread work. They expose
|
|
16
|
+
// length + integer indices through the ABI, so the iterator walks those.
|
|
17
|
+
const ARRAY_LIKE_COLLECTIONS = new Set([
|
|
18
|
+
"HTMLCollection", "NodeList", "DOMTokenList", "NamedNodeMap", "DOMStringList",
|
|
19
|
+
"FileList", "CSSRuleList", "StyleSheetList", "DataTransferItemList"
|
|
20
|
+
]);
|
|
21
|
+
// Map-like collections iterated as [key, value] pairs via .entries().
|
|
22
|
+
const ENTRIES_ITERABLES = new Set(["URLSearchParams", "FormData", "Headers"]);
|
|
23
|
+
|
|
24
|
+
// Array-like collections that are iterable ONLY via @@iterator (their IDL is
|
|
25
|
+
// not declared `iterable<>`, so they lack keys()/values()/entries()/forEach()).
|
|
26
|
+
const INDEXED_ONLY_ITERABLE = new Set(["HTMLCollection", "HTMLOptionsCollection"]);
|
|
27
|
+
|
|
28
|
+
// WebIDL legacy platform objects with a named property getter, and whether
|
|
29
|
+
// their named properties are enumerable (DOMStringMap) and writable/deletable
|
|
30
|
+
// (DOMStringMap has a named setter/deleter; HTMLCollection/NamedNodeMap are
|
|
31
|
+
// read-only — `coll[name] = x` / `delete coll[name]` reject in strict mode).
|
|
32
|
+
const NAMED_PROP_COLLECTIONS = new Map([
|
|
33
|
+
["HTMLCollection", { enumerable: false, writable: false }],
|
|
34
|
+
["HTMLOptionsCollection", { enumerable: false, writable: false }],
|
|
35
|
+
["NamedNodeMap", { enumerable: false, writable: false }],
|
|
36
|
+
["DOMStringMap", { enumerable: true, writable: true }],
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
// [LegacyNullToEmptyString] DOMString setters: null becomes "", any other
|
|
40
|
+
// value is ToString-coerced JS-side before crossing into Ruby.
|
|
41
|
+
const NULL_TO_EMPTY_STRING_SETTERS = new Set(["innerHTML", "outerHTML"]);
|
|
42
|
+
|
|
43
|
+
// WebIDL [Constant]s exposed on Node (and inherited by every node interface):
|
|
44
|
+
// the nodeType values plus the compareDocumentPosition bit flags.
|
|
45
|
+
const NODE_CONSTANTS = {
|
|
46
|
+
ELEMENT_NODE: 1, ATTRIBUTE_NODE: 2, TEXT_NODE: 3, CDATA_SECTION_NODE: 4,
|
|
47
|
+
ENTITY_REFERENCE_NODE: 5, ENTITY_NODE: 6, PROCESSING_INSTRUCTION_NODE: 7,
|
|
48
|
+
COMMENT_NODE: 8, DOCUMENT_NODE: 9, DOCUMENT_TYPE_NODE: 10,
|
|
49
|
+
DOCUMENT_FRAGMENT_NODE: 11, NOTATION_NODE: 12,
|
|
50
|
+
DOCUMENT_POSITION_DISCONNECTED: 1, DOCUMENT_POSITION_PRECEDING: 2,
|
|
51
|
+
DOCUMENT_POSITION_FOLLOWING: 4, DOCUMENT_POSITION_CONTAINS: 8,
|
|
52
|
+
DOCUMENT_POSITION_CONTAINED_BY: 16, DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC: 32
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// WebIDL [Constant]s exposed on the Event interface object + prototype.
|
|
56
|
+
const EVENT_CONSTANTS = {
|
|
57
|
+
NONE: 0, CAPTURING_PHASE: 1, AT_TARGET: 2, BUBBLING_PHASE: 3
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// NodeFilter whatToShow bitmasks + filter return values (TreeWalker/NodeIterator).
|
|
61
|
+
const NODEFILTER_CONSTANTS = {
|
|
62
|
+
FILTER_ACCEPT: 1, FILTER_REJECT: 2, FILTER_SKIP: 3,
|
|
63
|
+
SHOW_ALL: 0xffffffff, SHOW_ELEMENT: 0x1, SHOW_ATTRIBUTE: 0x2, SHOW_TEXT: 0x4,
|
|
64
|
+
SHOW_CDATA_SECTION: 0x8, SHOW_ENTITY_REFERENCE: 0x10, SHOW_ENTITY: 0x20,
|
|
65
|
+
SHOW_PROCESSING_INSTRUCTION: 0x40, SHOW_COMMENT: 0x80, SHOW_DOCUMENT: 0x100,
|
|
66
|
+
SHOW_DOCUMENT_TYPE: 0x200, SHOW_DOCUMENT_FRAGMENT: 0x400, SHOW_NOTATION: 0x800
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// WebSocket ready-state [Constant]s (on the interface object + prototype, so
|
|
70
|
+
// `WebSocket.OPEN` and `ws.OPEN` both resolve).
|
|
71
|
+
const WEBSOCKET_CONSTANTS = { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 };
|
|
72
|
+
|
|
73
|
+
// Interface name -> its [Constant]s (placed on both the interface object and
|
|
74
|
+
// its prototype; instances inherit via the proxy get `prop in target` path).
|
|
75
|
+
const INTERFACE_CONSTANTS = {
|
|
76
|
+
Node: NODE_CONSTANTS, Event: EVENT_CONSTANTS, NodeFilter: NODEFILTER_CONSTANTS,
|
|
77
|
+
WebSocket: WEBSOCKET_CONSTANTS
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// 1d: custom elements. ceRegistry maps a tag name to its JS constructor;
|
|
81
|
+
// constructionStack carries the element being upgraded so the interface base
|
|
82
|
+
// constructor (see protoForChain) adopts it when `super()` runs; cePending
|
|
83
|
+
// holds whenDefined() resolvers waiting for a name to be defined.
|
|
84
|
+
const ceRegistry = new Map();
|
|
85
|
+
const constructionStack = [];
|
|
86
|
+
const cePending = new Map();
|
|
87
|
+
|
|
88
|
+
// When a proxy is garbage-collected, drop the Ruby-side handle entry
|
|
89
|
+
// (unless a live re-proxy for the same handle exists). Keeps the
|
|
90
|
+
// registry bounded on long-lived VMs. Handles are monotonic on the
|
|
91
|
+
// Ruby side, so a handle never refers to two different objects.
|
|
92
|
+
const finalizers = new FinalizationRegistry((handle) => {
|
|
93
|
+
const ref = cache.get(handle);
|
|
94
|
+
if (!ref || ref.deref() === undefined) {
|
|
95
|
+
cache.delete(handle);
|
|
96
|
+
__rb_release_handle(handle);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
function isProxy(v) {
|
|
101
|
+
return v !== null && typeof v === "object" && v[HKEY] !== undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The set of property names a prototype chain exposes via accessor setters
|
|
105
|
+
// (a framework's reactive properties, e.g. Lit), computed once per prototype
|
|
106
|
+
// and cached — so the set trap doesn't walk the chain on every write.
|
|
107
|
+
const setterPropsCache = new WeakMap();
|
|
108
|
+
function settersOf(proto) {
|
|
109
|
+
let names = setterPropsCache.get(proto);
|
|
110
|
+
if (names) return names;
|
|
111
|
+
names = new Set();
|
|
112
|
+
for (let o = proto; o && o !== Object.prototype; o = Object.getPrototypeOf(o)) {
|
|
113
|
+
const descs = Object.getOwnPropertyDescriptors(o);
|
|
114
|
+
for (const k of Object.keys(descs)) {
|
|
115
|
+
if (typeof descs[k].set === "function") names.add(k);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
setterPropsCache.set(proto, names);
|
|
119
|
+
return names;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Same function -> same id, so addEventListener / removeEventListener
|
|
123
|
+
// round-trip to the same Ruby HostCallback (Dommy matches by identity).
|
|
124
|
+
function registerCallback(fn) {
|
|
125
|
+
if (callbackIds.has(fn)) return callbackIds.get(fn);
|
|
126
|
+
const id = nextCb++;
|
|
127
|
+
callbacks.set(id, fn);
|
|
128
|
+
callbackIds.set(fn, id);
|
|
129
|
+
return id;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Called from Ruby when a host event dispatch reaches a JS-registered
|
|
133
|
+
// listener. The live function (closure intact) is invoked; tagged args
|
|
134
|
+
// (e.g. an Event handle) are rehydrated to proxies first.
|
|
135
|
+
function invokeCallback(id, args, thisArg) {
|
|
136
|
+
const fn = callbacks.get(id);
|
|
137
|
+
if (!fn) return undefined;
|
|
138
|
+
// A null/absent thisArg keeps the historical undefined receiver; a tagged
|
|
139
|
+
// value (e.g. a MutationObserver handle) sets the callback's `this`.
|
|
140
|
+
const receiver = thisArg == null ? undefined : rehydrate(thisArg);
|
|
141
|
+
return dehydrate(fn.apply(receiver, rehydrate(args || [])));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Enqueue a host-side microtask (by id) onto the engine's native promise-job
|
|
145
|
+
// queue, so a Dommy Ruby microtask (e.g. MutationObserver delivery) runs in
|
|
146
|
+
// FIFO order with JS `await`/Promise reactions rather than on a separate pass.
|
|
147
|
+
function scheduleMicrotask(id) {
|
|
148
|
+
Promise.resolve().then(() => { __rb_run_microtask(id); });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Replace unpaired UTF-16 surrogates with U+FFFD. Ruby strings can't hold lone
|
|
152
|
+
// surrogates, so any string crossing into Ruby loses them regardless; doing the
|
|
153
|
+
// scalar-value substitution here (what the spec's USVString conversion mandates,
|
|
154
|
+
// e.g. for TextEncoder) yields a single U+FFFD rather than invalid bytes.
|
|
155
|
+
function scrubLoneSurrogates(s) {
|
|
156
|
+
let out = "";
|
|
157
|
+
for (let i = 0; i < s.length; i++) {
|
|
158
|
+
const c = s.charCodeAt(i);
|
|
159
|
+
if (c >= 0xd800 && c <= 0xdbff) {
|
|
160
|
+
const next = s.charCodeAt(i + 1);
|
|
161
|
+
if (next >= 0xdc00 && next <= 0xdfff) { out += s[i] + s[i + 1]; i++; }
|
|
162
|
+
else out += "�";
|
|
163
|
+
} else if (c >= 0xdc00 && c <= 0xdfff) {
|
|
164
|
+
out += "�";
|
|
165
|
+
} else {
|
|
166
|
+
out += s[i];
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function dehydrate(v, seen) {
|
|
173
|
+
if (typeof v === "string") return /[\ud800-\udfff]/.test(v) ? scrubLoneSurrogates(v) : v;
|
|
174
|
+
if (typeof v === "function") return { __rb_callback: registerCallback(v) };
|
|
175
|
+
if (isProxy(v)) return { __rb_handle: v[HKEY] };
|
|
176
|
+
// A BufferSource (ArrayBuffer or any typed-array/DataView view) crosses as
|
|
177
|
+
// its raw bytes, so host code gets a uniform byte buffer (TextDecoder.decode,
|
|
178
|
+
// Blob, …) rather than a key→value object from Object.keys.
|
|
179
|
+
if (typeof ArrayBuffer !== "undefined") {
|
|
180
|
+
if (v instanceof ArrayBuffer) return { __rb_bytes: Array.from(new Uint8Array(v)) };
|
|
181
|
+
if (ArrayBuffer.isView(v)) return { __rb_bytes: Array.from(new Uint8Array(v.buffer, v.byteOffset, v.byteLength)) };
|
|
182
|
+
}
|
|
183
|
+
// SharedArrayBuffer is a separate type (not an ArrayBuffer subclass), but a
|
|
184
|
+
// BufferSource all the same — cross it as raw bytes too.
|
|
185
|
+
if (typeof SharedArrayBuffer !== "undefined" && v instanceof SharedArrayBuffer) {
|
|
186
|
+
return { __rb_bytes: Array.from(new Uint8Array(v)) };
|
|
187
|
+
}
|
|
188
|
+
if (v !== null && typeof v === "object") {
|
|
189
|
+
seen = seen || new WeakSet();
|
|
190
|
+
if (seen.has(v)) return undefined; // break reference cycles
|
|
191
|
+
seen.add(v);
|
|
192
|
+
if (Array.isArray(v)) return v.map((e) => dehydrate(e, seen));
|
|
193
|
+
// An "exotic" object — anything that is NOT a plain data object (Error,
|
|
194
|
+
// DOMException, Map, a class instance, …) — crosses as an opaque JS-side
|
|
195
|
+
// reference, so a value Ruby merely stores and hands back (an
|
|
196
|
+
// AbortSignal's reason, a CustomEvent detail) round-trips with IDENTITY
|
|
197
|
+
// rather than being flattened to a key→value map (which also loses an
|
|
198
|
+
// Error's non-enumerable message/stack). Plain `{}` objects stay maps so
|
|
199
|
+
// option bags keep behaving like Ruby Hashes.
|
|
200
|
+
const proto = Object.getPrototypeOf(v);
|
|
201
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
202
|
+
return { __rb_js_ref: registerJsRef(v) };
|
|
203
|
+
}
|
|
204
|
+
const out = {};
|
|
205
|
+
for (const k of Object.keys(v)) out[k] = dehydrate(v[k], seen);
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
return v;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Opaque JS-value registry: lets a non-plain JS object survive a round trip
|
|
212
|
+
// through Ruby with identity preserved (keyed by the value so the same object
|
|
213
|
+
// reuses its id). Entries are retained for the VM's lifetime.
|
|
214
|
+
const jsRefs = new Map();
|
|
215
|
+
const jsRefIds = new Map();
|
|
216
|
+
let jsRefSeq = 0;
|
|
217
|
+
function registerJsRef(v) {
|
|
218
|
+
let id = jsRefIds.get(v);
|
|
219
|
+
if (id === undefined) {
|
|
220
|
+
id = ++jsRefSeq;
|
|
221
|
+
jsRefs.set(id, v);
|
|
222
|
+
jsRefIds.set(v, id);
|
|
223
|
+
}
|
|
224
|
+
return id;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Dehydrate a top-level call/constructor argument list, tagging an explicit
|
|
228
|
+
// `undefined` so it crosses as Dommy::Bridge::UNDEFINED (distinct from the
|
|
229
|
+
// `nil` a JS `null` becomes) — letting WebIDL-style dispatch tell an omitted
|
|
230
|
+
// optional argument from an explicit null. Only top-level args are tagged;
|
|
231
|
+
// `undefined` nested inside an object still dehydrates to null, preserving
|
|
232
|
+
// existing option-bag behavior.
|
|
233
|
+
function dehydrateArgs(args) {
|
|
234
|
+
return Array.prototype.map.call(args, (a) => (a === undefined ? { __rb_undefined: true } : dehydrate(a)));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// A host call that raised a Dommy::DOMException comes back tagged so it can be
|
|
238
|
+
// re-thrown JS-side as a real DOMException (name + legacy code, and
|
|
239
|
+
// `instanceof DOMException`). Without this the quickjs gem flattens it to a
|
|
240
|
+
// plain Error, breaking assert_throws_dom and the DOM's error contracts.
|
|
241
|
+
function makeHostError(info) {
|
|
242
|
+
const G = globalThis;
|
|
243
|
+
// A deliberate JS-native error (TypeError, RangeError, …): build the real
|
|
244
|
+
// constructor so `instanceof` holds. URL construction failures arrive here
|
|
245
|
+
// as TypeError (per the URL Standard), not as a DOMException.
|
|
246
|
+
if (info.js_native && typeof G[info.name] === "function") {
|
|
247
|
+
return new G[info.name](info.message);
|
|
248
|
+
}
|
|
249
|
+
if (typeof G.DOMException === "function") {
|
|
250
|
+
try {
|
|
251
|
+
return new G.DOMException(info.message, info.name);
|
|
252
|
+
} catch (_) {
|
|
253
|
+
/* fall through to a plain Error */
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const e = new Error(info.message);
|
|
257
|
+
if (info.name) e.name = info.name;
|
|
258
|
+
if (info.code !== undefined && info.code !== null) e.code = info.code;
|
|
259
|
+
return e;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function rehydrate(v) {
|
|
263
|
+
if (Array.isArray(v)) return v.map(rehydrate);
|
|
264
|
+
if (v !== null && typeof v === "object") {
|
|
265
|
+
if (v.__rb_exception__) throw makeHostError(v.__rb_exception__);
|
|
266
|
+
// A host method that threw an arbitrary value (throwIfAborted's reason):
|
|
267
|
+
// re-throw the rehydrated value verbatim.
|
|
268
|
+
if ("__rb_throw__" in v) throw rehydrate(v.__rb_throw__);
|
|
269
|
+
// A void DOM op marshals as this marker so it becomes `undefined`, not the
|
|
270
|
+
// `null` a bare Ruby nil would (e.g. DOMTokenList add/remove return undefined).
|
|
271
|
+
if (v.__rb_undefined) return undefined;
|
|
272
|
+
// A host byte buffer (TextEncoder.encode, …) rehydrates to a Uint8Array.
|
|
273
|
+
if (v.__rb_bytes) return new Uint8Array(v.__rb_bytes);
|
|
274
|
+
// A host byte buffer tagged as an ArrayBuffer (Response/Blob/FileReader/
|
|
275
|
+
// XHR arrayBuffer) rehydrates to a bare ArrayBuffer.
|
|
276
|
+
if (v.__rb_arraybuffer) return new Uint8Array(v.__rb_arraybuffer).buffer;
|
|
277
|
+
if ("__rb_handle" in v) return makeProxy(v.__rb_handle);
|
|
278
|
+
// An opaque JS-value reference round-tripping back from Ruby — restore the
|
|
279
|
+
// exact original object (identity-preserving).
|
|
280
|
+
if ("__rb_js_ref" in v) return jsRefs.get(v.__rb_js_ref);
|
|
281
|
+
// Symmetric with dehydrate: a tagged callback restores to the live JS
|
|
282
|
+
// function it was registered from (so functions nested in objects — e.g.
|
|
283
|
+
// an event's detail — survive a round trip through Ruby).
|
|
284
|
+
if ("__rb_callback" in v) {
|
|
285
|
+
const fn = callbacks.get(v.__rb_callback);
|
|
286
|
+
if (fn) return fn;
|
|
287
|
+
}
|
|
288
|
+
const out = {};
|
|
289
|
+
for (const k of Object.keys(v)) out[k] = rehydrate(v[k]);
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
return v;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ===== wasm host bridge (handle-oriented JS access) =====
|
|
296
|
+
//
|
|
297
|
+
// A second embedding model, distinct from the Proxy-based one above: a wasm
|
|
298
|
+
// guest (e.g. mruby-in-wasm under wasmtime-rb) drives JS through a small set
|
|
299
|
+
// of imports — js_eval / js_global / js_get / js_set / js_call / js_new /
|
|
300
|
+
// js_make_callback — that operate on opaque JS *handles*, not on Ruby objects
|
|
301
|
+
// exposed as proxies. So the guest needs the inverse of makeProxy: any JS
|
|
302
|
+
// value referenced by an integer ref it can get/set/call/new on.
|
|
303
|
+
//
|
|
304
|
+
// The marshalling is uniform: every non-primitive (object, function, DOM
|
|
305
|
+
// proxy, exotic) crosses as `{ __rb_js_ref: id }` via the shared jsRefs table
|
|
306
|
+
// (so a function can be the receiver of `new` or sit in a `.then(...)` arg
|
|
307
|
+
// list — unlike dehydrate, which would flatten it to `{ __rb_callback }`).
|
|
308
|
+
// Primitives cross as themselves. This pair (wasmTag/wasmUntag) is used only
|
|
309
|
+
// by the wasm* entry points; the Proxy model's dehydrate/rehydrate are
|
|
310
|
+
// untouched.
|
|
311
|
+
function wasmTag(v) {
|
|
312
|
+
if (v === undefined) return { __rb_undefined: true };
|
|
313
|
+
if (v === null) return null;
|
|
314
|
+
const t = typeof v;
|
|
315
|
+
if (t === "string") return /[\ud800-\udfff]/.test(v) ? scrubLoneSurrogates(v) : v;
|
|
316
|
+
if (t === "number" || t === "boolean") return v;
|
|
317
|
+
if (t === "bigint") return Number(v);
|
|
318
|
+
// object / function / symbol — keep identity behind a stable ref.
|
|
319
|
+
return { __rb_js_ref: registerJsRef(v) };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function wasmUntag(v) {
|
|
323
|
+
if (Array.isArray(v)) return v.map(wasmUntag);
|
|
324
|
+
if (v !== null && typeof v === "object") {
|
|
325
|
+
if (v.__rb_undefined) return undefined;
|
|
326
|
+
if ("__rb_js_ref" in v) return jsRefs.get(v.__rb_js_ref);
|
|
327
|
+
if (v.__rb_bytes) return new Uint8Array(v.__rb_bytes);
|
|
328
|
+
if (v.__rb_arraybuffer) return new Uint8Array(v.__rb_arraybuffer).buffer;
|
|
329
|
+
if ("__rb_handle" in v) return makeProxy(v.__rb_handle);
|
|
330
|
+
const out = {};
|
|
331
|
+
for (const k of Object.keys(v)) out[k] = wasmUntag(v[k]);
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
return v;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function wasmDeref(ref) {
|
|
338
|
+
const v = jsRefs.get(ref);
|
|
339
|
+
if (v === undefined && !jsRefs.has(ref)) {
|
|
340
|
+
throw new Error("wasm bridge: stale or unknown JS ref " + ref);
|
|
341
|
+
}
|
|
342
|
+
return v;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// globalThis as a (tagged) ref, so the guest's `js_global` has a handle to
|
|
346
|
+
// operate on.
|
|
347
|
+
function wasmGlobalRef() { return wasmTag(globalThis); }
|
|
348
|
+
|
|
349
|
+
// Indirect eval runs in global scope: `globalThis.fetch = …` and top-level
|
|
350
|
+
// var/function declarations land on the global, matching a browser's
|
|
351
|
+
// host-eval escape hatch (JS.eval_javascript).
|
|
352
|
+
const indirectEval = eval;
|
|
353
|
+
function wasmEval(src) { return wasmTag(indirectEval(src)); }
|
|
354
|
+
|
|
355
|
+
function wasmGet(ref, prop) { return wasmTag(wasmDeref(ref)[prop]); }
|
|
356
|
+
|
|
357
|
+
function wasmSet(ref, prop, value) { wasmDeref(ref)[prop] = wasmUntag(value); }
|
|
358
|
+
|
|
359
|
+
function wasmCall(ref, method, args) {
|
|
360
|
+
const recv = wasmDeref(ref);
|
|
361
|
+
const fn = recv[method];
|
|
362
|
+
if (typeof fn !== "function") {
|
|
363
|
+
throw new TypeError("wasm bridge: " + String(method) + " is not a function");
|
|
364
|
+
}
|
|
365
|
+
return wasmTag(fn.apply(recv, args.map(wasmUntag)));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Apply a function ref directly (optionally with an explicit `this` ref).
|
|
369
|
+
function wasmApply(ref, thisRef, args) {
|
|
370
|
+
const fn = wasmDeref(ref);
|
|
371
|
+
const thisArg = thisRef == null ? undefined : wasmDeref(thisRef);
|
|
372
|
+
return wasmTag(fn.apply(thisArg, args.map(wasmUntag)));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function wasmNew(ref, args) {
|
|
376
|
+
const ctor = wasmDeref(ref);
|
|
377
|
+
return wasmTag(Reflect.construct(ctor, args.map(wasmUntag)));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function wasmTypeof(ref) { return typeof wasmDeref(ref); }
|
|
381
|
+
function wasmToString(ref) { return String(wasmDeref(ref)); }
|
|
382
|
+
function wasmStrictEqual(a, b) { return wasmDeref(a) === wasmDeref(b); }
|
|
383
|
+
function wasmIsNull(ref) {
|
|
384
|
+
const v = jsRefs.get(ref);
|
|
385
|
+
return v === null || v === undefined;
|
|
386
|
+
}
|
|
387
|
+
function wasmInstanceof(ref, ctorRef) {
|
|
388
|
+
const ctor = wasmDeref(ctorRef);
|
|
389
|
+
return typeof ctor === "function" && wasmDeref(ref) instanceof ctor;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Create a JS function that calls back into the wasm guest by invoke-id.
|
|
393
|
+
// Returned as a ref so it can be passed to Promise.then / setTimeout / etc.
|
|
394
|
+
// `globalThis.__rbWasmInvoke(id, taggedArgs)` is installed by the embedder
|
|
395
|
+
// (Runtime#enable_wasm_bridge!) and routes into the guest's js_invoke_proc.
|
|
396
|
+
function wasmMakeCallback(invokeId) {
|
|
397
|
+
const fn = function (...args) {
|
|
398
|
+
const result = globalThis.__rbWasmInvoke(invokeId, args.map(wasmTag));
|
|
399
|
+
return wasmUntag(result);
|
|
400
|
+
};
|
|
401
|
+
return wasmTag(fn);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function wasmReleaseRef(ref) {
|
|
405
|
+
const v = jsRefs.get(ref);
|
|
406
|
+
if (v !== undefined || jsRefs.has(ref)) {
|
|
407
|
+
jsRefs.delete(ref);
|
|
408
|
+
jsRefIds.delete(v);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ===== DOM interface prototypes & constructors (1a/1b/1c) =====
|
|
413
|
+
|
|
414
|
+
// 1c: build a host object from a bare interface constructor
|
|
415
|
+
// (new Event(...) / new DOMException(...)). Ruby resolves the named
|
|
416
|
+
// constructor by interface name; null means "not constructable" so we throw.
|
|
417
|
+
// WebIDL dictionary members for the constructors that take an init dictionary,
|
|
418
|
+
// in the order the spec reads them (inherited members first, then own, each
|
|
419
|
+
// group lexicographic). "boolean" members are coerced with JS ToBoolean; "any"
|
|
420
|
+
// is passed through. Only interfaces with a COMPLETE member list belong here —
|
|
421
|
+
// a partial list would silently drop members.
|
|
422
|
+
const CONSTRUCTOR_DICTS = {
|
|
423
|
+
Event: { bubbles: "boolean", cancelable: "boolean", composed: "boolean" },
|
|
424
|
+
CustomEvent: { bubbles: "boolean", cancelable: "boolean", composed: "boolean", detail: "any" },
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
// WebIDL argument coercion for a constructor that takes `(DOMString type,
|
|
428
|
+
// optional XInit dict)`: the required `type` is ToString-coerced (so a throwing
|
|
429
|
+
// `toString` propagates, and a missing argument is a TypeError), and the dict
|
|
430
|
+
// is rebuilt by reading ONLY its declared members, in declaration order — so
|
|
431
|
+
// unrelated getters (a stray `sweet`/`dummy`) are never invoked and a member's
|
|
432
|
+
// boolean coercion follows JS, not Ruby, truthiness. Other interfaces pass
|
|
433
|
+
// through untouched.
|
|
434
|
+
function coerceConstructorArgs(name, args) {
|
|
435
|
+
const members = CONSTRUCTOR_DICTS[name];
|
|
436
|
+
if (!members) return args;
|
|
437
|
+
if (args.length < 1) {
|
|
438
|
+
throw new TypeError("Failed to construct '" + name + "': 1 argument required, but only 0 present.");
|
|
439
|
+
}
|
|
440
|
+
const type = String(args[0]);
|
|
441
|
+
const init = args[1];
|
|
442
|
+
const dict = {};
|
|
443
|
+
if (init !== undefined && init !== null) {
|
|
444
|
+
for (const member in members) {
|
|
445
|
+
const value = init[member];
|
|
446
|
+
if (value === undefined) continue;
|
|
447
|
+
dict[member] = members[member] === "boolean" ? !!value : value;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return [type, dict];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function constructInterface(name, args) {
|
|
454
|
+
const r = rehydrate(__rb_construct(name, dehydrateArgs(coerceConstructorArgs(name, args))));
|
|
455
|
+
if (r == null) throw new TypeError("Illegal constructor");
|
|
456
|
+
return r;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// 1b: lazily build a JS prototype chain + constructor per DOM interface,
|
|
460
|
+
// mirroring the chain Ruby reports (most-derived first). Cached by name so the
|
|
461
|
+
// shared tail (…Element→Node→EventTarget) is built once and every node links
|
|
462
|
+
// into the same prototypes — making `instanceof` and Object.prototype.toString
|
|
463
|
+
// (via Symbol.toStringTag) work. Constructable interfaces (Event, DOMException,
|
|
464
|
+
// …) build via Ruby; the rest throw Illegal constructor (HTMLElement until 1d).
|
|
465
|
+
const protos = new Map();
|
|
466
|
+
// 2d: method name sets are per-interface (class), so cache them by interface
|
|
467
|
+
// name and reuse across every proxy of that interface instead of rebuilding.
|
|
468
|
+
const methodsByInterface = new Map();
|
|
469
|
+
function protoForChain(chain, i) {
|
|
470
|
+
const name = chain[i];
|
|
471
|
+
const cached = protos.get(name);
|
|
472
|
+
if (cached) return cached;
|
|
473
|
+
const parent = (i + 1 < chain.length) ? protoForChain(chain, i + 1) : Object.prototype;
|
|
474
|
+
const proto = Object.create(parent);
|
|
475
|
+
Object.defineProperty(proto, Symbol.toStringTag, { value: name, configurable: true });
|
|
476
|
+
// Only node/element constructors adopt an element being upgraded. Otherwise
|
|
477
|
+
// a non-element `new` (e.g. `new IntersectionObserver()` inside a custom
|
|
478
|
+
// element's constructor) would greedily adopt the queued element off the
|
|
479
|
+
// shared construction stack and hijack its prototype.
|
|
480
|
+
const consultsStack = chain.includes("Node");
|
|
481
|
+
const ctor = function (...args) {
|
|
482
|
+
const nt = new.target;
|
|
483
|
+
if (nt === undefined) throw new TypeError(name + " requires 'new'");
|
|
484
|
+
// 1d: custom element upgrade — when a construction is queued, `super()`
|
|
485
|
+
// adopts the element being upgraded (its proxy) and stamps it with the
|
|
486
|
+
// derived class's prototype, rather than minting a new backing object.
|
|
487
|
+
if (consultsStack && constructionStack.length > 0) {
|
|
488
|
+
const el = constructionStack[constructionStack.length - 1];
|
|
489
|
+
Object.setPrototypeOf(el, nt.prototype);
|
|
490
|
+
return el;
|
|
491
|
+
}
|
|
492
|
+
return constructInterface(name, args);
|
|
493
|
+
};
|
|
494
|
+
Object.defineProperty(ctor, "name", { value: name, configurable: true });
|
|
495
|
+
ctor.prototype = proto;
|
|
496
|
+
Object.defineProperty(proto, "constructor", { value: ctor, configurable: true, writable: true });
|
|
497
|
+
// WebIDL [Constant]s live on both the interface object and its prototype
|
|
498
|
+
// (so `Node.ELEMENT_NODE`, `el.ELEMENT_NODE`, `Event.CAPTURING_PHASE`, …
|
|
499
|
+
// all === the numeric value). Instances reach the prototype copy via the
|
|
500
|
+
// proxy get trap's `prop in target` fallback.
|
|
501
|
+
const constants = INTERFACE_CONSTANTS[name];
|
|
502
|
+
if (constants) {
|
|
503
|
+
for (const [k, val] of Object.entries(constants)) {
|
|
504
|
+
const desc = { value: val, enumerable: true, writable: false, configurable: false };
|
|
505
|
+
Object.defineProperty(proto, k, desc);
|
|
506
|
+
Object.defineProperty(ctor, k, desc);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (ARRAY_LIKE_COLLECTIONS.has(name)) {
|
|
510
|
+
// WebIDL: a value-iterator interface (indexed getter + `iterable<>`) gets
|
|
511
|
+
// keys()/values()/entries()/forEach()/@@iterator whose values ARE the
|
|
512
|
+
// %Array.prototype% functions — so `list.values === Array.prototype.values`.
|
|
513
|
+
// They operate on the proxy via its live length + indexed getter, and each
|
|
514
|
+
// returns a real Array Iterator (so `list.keys() instanceof Array` is false).
|
|
515
|
+
const A = Array.prototype;
|
|
516
|
+
const define = (key, fn) => Object.defineProperty(proto, key, { value: fn, configurable: true, writable: true });
|
|
517
|
+
define(Symbol.iterator, A[Symbol.iterator]);
|
|
518
|
+
// HTMLCollection is iterable only via @@iterator (its IDL is NOT declared
|
|
519
|
+
// `iterable<>`); the keys()/values()/entries()/forEach() pair methods are
|
|
520
|
+
// exclusive to interfaces that ARE (NodeList, DOMTokenList, …).
|
|
521
|
+
if (!INDEXED_ONLY_ITERABLE.has(name)) {
|
|
522
|
+
define("values", A.values);
|
|
523
|
+
define("keys", A.keys);
|
|
524
|
+
define("entries", A.entries);
|
|
525
|
+
define("forEach", A.forEach);
|
|
526
|
+
}
|
|
527
|
+
} else if (ENTRIES_ITERABLES.has(name)) {
|
|
528
|
+
// A LIVE entries iterator: re-read entries() at each step (indexed by a
|
|
529
|
+
// running cursor) so a mutation mid-loop is observed — e.g. URLSearchParams
|
|
530
|
+
// `for (const e of params) { params.delete(...) }` must see the new state.
|
|
531
|
+
Object.defineProperty(proto, Symbol.iterator, {
|
|
532
|
+
value: function () {
|
|
533
|
+
let i = 0;
|
|
534
|
+
const self = this;
|
|
535
|
+
const it = {
|
|
536
|
+
next() {
|
|
537
|
+
const entries = self.entries();
|
|
538
|
+
if (i >= entries.length) return { value: undefined, done: true };
|
|
539
|
+
return { value: entries[i++], done: false };
|
|
540
|
+
},
|
|
541
|
+
};
|
|
542
|
+
it[Symbol.iterator] = function () { return this; };
|
|
543
|
+
return it;
|
|
544
|
+
},
|
|
545
|
+
configurable: true, writable: true
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
if (name === "TextEncoder") {
|
|
549
|
+
// encodeInto mutates the destination Uint8Array in place, so it must run
|
|
550
|
+
// JS-side (a host round trip would only see a copy). Encodes scalar values
|
|
551
|
+
// to UTF-8, stops before a code point that wouldn't fit, and returns
|
|
552
|
+
// {read (source UTF-16 units), written (bytes)}.
|
|
553
|
+
Object.defineProperty(proto, "encodeInto", {
|
|
554
|
+
value: function (source, destination) {
|
|
555
|
+
if (!(destination instanceof Uint8Array)) {
|
|
556
|
+
throw new TypeError("encodeInto's destination must be a Uint8Array");
|
|
557
|
+
}
|
|
558
|
+
source = String(source);
|
|
559
|
+
const cap = destination.length;
|
|
560
|
+
let read = 0, written = 0;
|
|
561
|
+
for (let i = 0; i < source.length;) {
|
|
562
|
+
let cp = source.codePointAt(i);
|
|
563
|
+
let units = cp > 0xffff ? 2 : 1;
|
|
564
|
+
if (cp >= 0xd800 && cp <= 0xdfff) { cp = 0xfffd; units = 1; } // lone surrogate
|
|
565
|
+
const need = cp <= 0x7f ? 1 : cp <= 0x7ff ? 2 : cp <= 0xffff ? 3 : 4;
|
|
566
|
+
if (written + need > cap) break;
|
|
567
|
+
if (need === 1) {
|
|
568
|
+
destination[written++] = cp;
|
|
569
|
+
} else if (need === 2) {
|
|
570
|
+
destination[written++] = 0xc0 | (cp >> 6);
|
|
571
|
+
destination[written++] = 0x80 | (cp & 0x3f);
|
|
572
|
+
} else if (need === 3) {
|
|
573
|
+
destination[written++] = 0xe0 | (cp >> 12);
|
|
574
|
+
destination[written++] = 0x80 | ((cp >> 6) & 0x3f);
|
|
575
|
+
destination[written++] = 0x80 | (cp & 0x3f);
|
|
576
|
+
} else {
|
|
577
|
+
destination[written++] = 0xf0 | (cp >> 18);
|
|
578
|
+
destination[written++] = 0x80 | ((cp >> 12) & 0x3f);
|
|
579
|
+
destination[written++] = 0x80 | ((cp >> 6) & 0x3f);
|
|
580
|
+
destination[written++] = 0x80 | (cp & 0x3f);
|
|
581
|
+
}
|
|
582
|
+
read += units;
|
|
583
|
+
i += units;
|
|
584
|
+
}
|
|
585
|
+
return { read, written };
|
|
586
|
+
},
|
|
587
|
+
configurable: true, writable: true,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
if (!(name in globalThis)) globalThis[name] = ctor;
|
|
591
|
+
protos.set(name, proto);
|
|
592
|
+
return proto;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Eagerly build the base interfaces (chains supplied by Ruby, the single
|
|
596
|
+
// source of hierarchy knowledge) so `instanceof Node` / `typeof HTMLElement`
|
|
597
|
+
// resolve before an instance of that exact type has crossed.
|
|
598
|
+
function seedInterfaces(chains) {
|
|
599
|
+
chains.forEach((c) => protoForChain(c, 0));
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// 1c: expose an interface constructor's static/class methods (URL.createObjectURL,
|
|
603
|
+
// URL.parse, …) on the seeded global, delegating to the window's constructor.
|
|
604
|
+
// Called once the window is bound (statics live on the window's constructors).
|
|
605
|
+
function attachStatics() {
|
|
606
|
+
for (const name of protos.keys()) {
|
|
607
|
+
const ctor = globalThis[name];
|
|
608
|
+
if (typeof ctor !== "function") continue;
|
|
609
|
+
for (const m of __rb_static_names(name)) {
|
|
610
|
+
if (m in ctor) continue;
|
|
611
|
+
ctor[m] = (...args) => rehydrate(__rb_static_call(name, m, dehydrateArgs(args)));
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Expose the interface constructors as own properties of the `window` proxy
|
|
617
|
+
// so `window.Node` / `document.defaultView.DOMException` / … resolve to the
|
|
618
|
+
// same constructor functions as the bare globals. In a browser window IS the
|
|
619
|
+
// global object; here it's a separate host proxy whose host get returns null
|
|
620
|
+
// for these, which broke e.g. assert_throws_dom(type, doc.defaultView.DOMException, …)
|
|
621
|
+
// (it read `.name` off null). Defining them on the proxy target means the get
|
|
622
|
+
// trap's own-property fast path returns the real function with no round trip.
|
|
623
|
+
function exposeConstructorsOnWindow(target) {
|
|
624
|
+
// Defaults to the top window, but a secondary window (an iframe's
|
|
625
|
+
// contentWindow) can be passed so `subWin.Element` / `subWin.DOMException`
|
|
626
|
+
// resolve to the same seeded constructors — needed for cross-window
|
|
627
|
+
// `instanceof` and `doc.defaultView.X` in iframe documents.
|
|
628
|
+
const w = target || globalThis.window;
|
|
629
|
+
if (!w) return;
|
|
630
|
+
const names = [...protos.keys()];
|
|
631
|
+
if (typeof globalThis.DOMException === "function") names.push("DOMException");
|
|
632
|
+
// Mirror the JS built-in constructors too, so an iframe's contentWindow
|
|
633
|
+
// resolves `defaultView.TypeError` / `defaultView.Array` like a real window
|
|
634
|
+
// (WPT reaches for `(root.ownerDocument).defaultView.TypeError`).
|
|
635
|
+
names.push(
|
|
636
|
+
"Object", "Array", "Function", "String", "Boolean", "Number", "BigInt",
|
|
637
|
+
"Symbol", "Date", "RegExp", "Promise", "Map", "Set", "WeakMap", "WeakSet",
|
|
638
|
+
"Error", "TypeError", "RangeError", "SyntaxError", "ReferenceError",
|
|
639
|
+
"Proxy", "Reflect", "JSON", "Math"
|
|
640
|
+
);
|
|
641
|
+
const interfaceNames = new Set(protos.keys());
|
|
642
|
+
for (const name of names) {
|
|
643
|
+
const ctor = globalThis[name];
|
|
644
|
+
if (typeof ctor !== "function") continue;
|
|
645
|
+
try {
|
|
646
|
+
const current = w[name];
|
|
647
|
+
// Fill in names the window doesn't resolve at all; AND replace a
|
|
648
|
+
// host-backed interface object (e.g. window.Event / window.MutationObserver
|
|
649
|
+
// crossing as a non-constructable Dommy proxy) with the constructable
|
|
650
|
+
// seeded constructor, so `new document.defaultView.MutationObserver(cb)`
|
|
651
|
+
// works — in a real window, window.X IS the constructor function X.
|
|
652
|
+
if (current == null || (typeof current !== "function" && interfaceNames.has(name))) {
|
|
653
|
+
Object.defineProperty(w, name, { value: ctor, configurable: true, writable: true });
|
|
654
|
+
}
|
|
655
|
+
} catch (e) { /* non-configurable / frozen — leave as-is */ }
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// ===== Host object proxy =====
|
|
660
|
+
|
|
661
|
+
// The proxy traps route each access to one of the bridge's layers. The order
|
|
662
|
+
// is deliberate — changing it breaks subtle cases, so it's spelled out here:
|
|
663
|
+
//
|
|
664
|
+
// get(prop):
|
|
665
|
+
// 1. HKEY symbol -> the Ruby handle (identity tag)
|
|
666
|
+
// 2. any other symbol -> target/prototype (Symbol.toStringTag/iterator)
|
|
667
|
+
// 3. own property on target -> a JS-side expando (object identity intact)
|
|
668
|
+
// 4. ABI method name -> a per-proxy memoized fn (__rb_host_call)
|
|
669
|
+
// 5. ABI property (non-null) -> the __rb_host_get value
|
|
670
|
+
// 6. prototype member -> constructor / connectedCallback / etc.
|
|
671
|
+
//
|
|
672
|
+
// set(prop, value):
|
|
673
|
+
// 1. symbol -> store on the target
|
|
674
|
+
// 2. prototype setter -> run it (framework reactive props, e.g. Lit)
|
|
675
|
+
// 3. Dommy handled it -> a DOM property write
|
|
676
|
+
// 4. otherwise -> a JS-side expando on the target
|
|
677
|
+
// An array index property name: "0", "1", … (canonical, no leading zeros).
|
|
678
|
+
function isArrayIndex(prop) {
|
|
679
|
+
return typeof prop === "string" && /^(0|[1-9][0-9]*)$/.test(prop);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function makeHandler(handle, methods, methodCache, arrayLike, named) {
|
|
683
|
+
// The live length of an array-like collection (NodeList/HTMLCollection/…),
|
|
684
|
+
// so indexed own-property reflection (hasOwnProperty / Object.keys / spread)
|
|
685
|
+
// tracks the current children. 0 for non-collections.
|
|
686
|
+
const liveLength = () => {
|
|
687
|
+
if (!arrayLike) return 0;
|
|
688
|
+
const n = rehydrate(__rb_host_get(handle, "length"));
|
|
689
|
+
return typeof n === "number" && n >= 0 ? n : 0;
|
|
690
|
+
};
|
|
691
|
+
// The live WebIDL "supported property names" (named getter keys), re-queried
|
|
692
|
+
// each call so it tracks DOM mutations; [] when there is no named getter.
|
|
693
|
+
const namedKeys = () => {
|
|
694
|
+
if (!named) return [];
|
|
695
|
+
const r = rehydrate(__rb_named_props(handle));
|
|
696
|
+
return Array.isArray(r) ? r : [];
|
|
697
|
+
};
|
|
698
|
+
const isIndexInRange = (prop) => arrayLike && isArrayIndex(prop) && Number(prop) < liveLength();
|
|
699
|
+
const isNamedKey = (prop) => named && typeof prop === "string" && namedKeys().indexOf(prop) !== -1;
|
|
700
|
+
return {
|
|
701
|
+
get(t, prop, receiver) {
|
|
702
|
+
if (prop === HKEY) return handle;
|
|
703
|
+
if (typeof prop === "symbol") return Reflect.get(t, prop, receiver);
|
|
704
|
+
if (Object.hasOwn(t, prop)) return Reflect.get(t, prop, receiver);
|
|
705
|
+
if (methods.has(prop)) {
|
|
706
|
+
let fn = methodCache.get(prop);
|
|
707
|
+
if (!fn) {
|
|
708
|
+
fn = (...args) => rehydrate(__rb_host_call(handle, prop, dehydrateArgs(args)));
|
|
709
|
+
methodCache.set(prop, fn);
|
|
710
|
+
}
|
|
711
|
+
return fn;
|
|
712
|
+
}
|
|
713
|
+
const v = rehydrate(__rb_host_get(handle, prop));
|
|
714
|
+
if (v == null && (prop in t)) return Reflect.get(t, prop, receiver);
|
|
715
|
+
// A legacy platform collection returns `undefined` (not the host's null)
|
|
716
|
+
// for a string property that resolves to no value. An out-of-range array
|
|
717
|
+
// index is `undefined` and does NOT fall back to a named lookup (so
|
|
718
|
+
// `coll[2147483648]` is undefined even if an element's id is that digit
|
|
719
|
+
// string); other unsupported strings (`coll[""]`, `coll["x"]`) too.
|
|
720
|
+
if (v === null && (arrayLike || named) && typeof prop === "string" && prop !== "length") {
|
|
721
|
+
if (arrayLike && isArrayIndex(prop)) return undefined;
|
|
722
|
+
if (!isNamedKey(prop)) return undefined;
|
|
723
|
+
}
|
|
724
|
+
return v;
|
|
725
|
+
},
|
|
726
|
+
set(t, prop, value, receiver) {
|
|
727
|
+
if (typeof prop === "symbol") { t[prop] = value; return true; }
|
|
728
|
+
if (settersOf(Object.getPrototypeOf(t)).has(prop)) {
|
|
729
|
+
Reflect.set(t, prop, value, receiver);
|
|
730
|
+
return true;
|
|
731
|
+
}
|
|
732
|
+
// Legacy platform object with NO indexed setter: an array-index
|
|
733
|
+
// assignment never becomes an expando — it is a no-op (sloppy) /
|
|
734
|
+
// TypeError (strict), so the trap returns false.
|
|
735
|
+
if (arrayLike && isArrayIndex(prop)) return false;
|
|
736
|
+
// A read-only named property (HTMLCollection/NamedNodeMap) likewise
|
|
737
|
+
// rejects — unless an own expando already shadows it (then update it).
|
|
738
|
+
if (named && !named.writable && !Object.hasOwn(t, prop) && isNamedKey(prop)) return false;
|
|
739
|
+
// WebIDL [LegacyNullToEmptyString] DOMString setters coerce JS-side
|
|
740
|
+
// (null → "", else ToString — so `innerHTML = 42` / `{toString…}` work and
|
|
741
|
+
// a toString that throws propagates) before the value crosses into Ruby.
|
|
742
|
+
if (NULL_TO_EMPTY_STRING_SETTERS.has(prop)) value = value === null ? "" : String(value);
|
|
743
|
+
const handled = __rb_host_set(handle, prop, dehydrate(value));
|
|
744
|
+
// A throwing setter comes back as a tagged exception — re-throw it.
|
|
745
|
+
if (handled && typeof handled === "object" && handled.__rb_exception__) {
|
|
746
|
+
throw makeHostError(handled.__rb_exception__);
|
|
747
|
+
}
|
|
748
|
+
if (!handled) t[prop] = value;
|
|
749
|
+
return true;
|
|
750
|
+
},
|
|
751
|
+
// Array-like collections reflect their indices as own enumerable
|
|
752
|
+
// properties so `hasOwnProperty(i)` / `Object.keys` / `{...spread}` see the
|
|
753
|
+
// live children (testharness's assert_array_equals checks hasOwnProperty).
|
|
754
|
+
// Named properties (HTMLCollection ids/names, dataset keys, attr names)
|
|
755
|
+
// are reflected too — non-enumerable for [LegacyUnenumerableNamedProperties].
|
|
756
|
+
getOwnPropertyDescriptor(t, prop) {
|
|
757
|
+
if (typeof prop !== "symbol" && Object.hasOwn(t, prop)) return Reflect.getOwnPropertyDescriptor(t, prop);
|
|
758
|
+
if (isIndexInRange(prop)) {
|
|
759
|
+
// Indexed properties are enumerable + configurable but NOT writable
|
|
760
|
+
// (these collections have no indexed property setter).
|
|
761
|
+
return {
|
|
762
|
+
value: rehydrate(__rb_host_get(handle, prop)),
|
|
763
|
+
writable: false, enumerable: true, configurable: true,
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
if (isNamedKey(prop)) {
|
|
767
|
+
return {
|
|
768
|
+
value: rehydrate(__rb_host_get(handle, prop)),
|
|
769
|
+
writable: named.writable, enumerable: named.enumerable, configurable: true,
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
return Reflect.getOwnPropertyDescriptor(t, prop);
|
|
773
|
+
},
|
|
774
|
+
defineProperty(t, prop, desc) {
|
|
775
|
+
// Cannot redefine a live indexed or read-only named property.
|
|
776
|
+
if (arrayLike && isArrayIndex(prop)) return false;
|
|
777
|
+
if (named && !named.writable && !Object.hasOwn(t, prop) && isNamedKey(prop)) return false;
|
|
778
|
+
return Reflect.defineProperty(t, prop, desc);
|
|
779
|
+
},
|
|
780
|
+
deleteProperty(t, prop) {
|
|
781
|
+
if (typeof prop !== "symbol" && Object.hasOwn(t, prop)) return Reflect.deleteProperty(t, prop);
|
|
782
|
+
if (isIndexInRange(prop)) return false;
|
|
783
|
+
if (named && typeof prop === "string") {
|
|
784
|
+
if (named.writable) {
|
|
785
|
+
// Named deleter (dataset): remove the backing attribute.
|
|
786
|
+
if (rehydrate(__rb_host_delete(handle, prop))) return true;
|
|
787
|
+
} else if (isNamedKey(prop)) {
|
|
788
|
+
return false; // read-only named property cannot be deleted
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
return Reflect.deleteProperty(t, prop);
|
|
792
|
+
},
|
|
793
|
+
ownKeys(t) {
|
|
794
|
+
const keys = Reflect.ownKeys(t);
|
|
795
|
+
if (!arrayLike && !named) return keys;
|
|
796
|
+
const n = arrayLike ? liveLength() : 0;
|
|
797
|
+
const result = [];
|
|
798
|
+
for (let i = 0; i < n; i++) result.push(String(i));
|
|
799
|
+
for (const nm of namedKeys()) if (result.indexOf(nm) === -1) result.push(nm);
|
|
800
|
+
// Then expandos / symbols that don't collide with an index or named key.
|
|
801
|
+
for (const k of keys) {
|
|
802
|
+
if (typeof k !== "symbol" && isArrayIndex(k) && Number(k) < n) continue;
|
|
803
|
+
if (result.indexOf(k) !== -1) continue;
|
|
804
|
+
result.push(k);
|
|
805
|
+
}
|
|
806
|
+
return result;
|
|
807
|
+
},
|
|
808
|
+
has(t, prop) {
|
|
809
|
+
// An out-of-range index on an array-like is genuinely absent (`2 in
|
|
810
|
+
// nodeList` is false past its length). A supported named key is present.
|
|
811
|
+
if (arrayLike && isArrayIndex(prop)) return Number(prop) < liveLength() || Reflect.has(t, prop);
|
|
812
|
+
if (isNamedKey(prop)) return true;
|
|
813
|
+
// For a legacy platform collection, an unsupported string key (`"" in
|
|
814
|
+
// coll`, `"foo" in coll`) is genuinely absent — only real props (length,
|
|
815
|
+
// item/namedItem, expandos, Symbol.iterator, …) are present. Other host
|
|
816
|
+
// objects stay permissive (frameworks probe arbitrary `x in obj`).
|
|
817
|
+
if ((arrayLike || named) && typeof prop === "string"
|
|
818
|
+
&& !Object.hasOwn(t, prop) && !methods.has(prop) && !(prop in t) && prop !== "length") {
|
|
819
|
+
return false;
|
|
820
|
+
}
|
|
821
|
+
return true;
|
|
822
|
+
}
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function makeProxy(handle) {
|
|
827
|
+
const ref = cache.get(handle);
|
|
828
|
+
if (ref) {
|
|
829
|
+
const existing = ref.deref();
|
|
830
|
+
if (existing) return existing;
|
|
831
|
+
}
|
|
832
|
+
// 2d: one host round trip describes the node (interface + methods + ce).
|
|
833
|
+
const desc = __rb_host_describe(handle);
|
|
834
|
+
// 2d: method-name sets are per-interface; reuse across proxies of that type.
|
|
835
|
+
let methods = methodsByInterface.get(desc.name);
|
|
836
|
+
if (!methods) {
|
|
837
|
+
methods = new Set(desc.methods);
|
|
838
|
+
methodsByInterface.set(desc.name, methods);
|
|
839
|
+
}
|
|
840
|
+
const target = (desc.chain && desc.chain.length)
|
|
841
|
+
? Object.create(protoForChain(desc.chain, 0))
|
|
842
|
+
: {};
|
|
843
|
+
// 2c: memoize method functions per proxy so `el.foo === el.foo`.
|
|
844
|
+
const p = new Proxy(target, makeHandler(handle, methods, new Map(),
|
|
845
|
+
ARRAY_LIKE_COLLECTIONS.has(desc.name), NAMED_PROP_COLLECTIONS.get(desc.name) || null));
|
|
846
|
+
cache.set(handle, new WeakRef(p));
|
|
847
|
+
finalizers.register(p, handle);
|
|
848
|
+
// 1d: a Dommy-registered custom element node is upgraded to its JS class on
|
|
849
|
+
// first crossing — so the constructor runs before any lifecycle callback.
|
|
850
|
+
if (desc.ce) upgradeElement(p, desc.ce);
|
|
851
|
+
return p;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// ===== Custom elements (1d) =====
|
|
855
|
+
|
|
856
|
+
// Run a JS custom element's constructor against an existing Dommy-backed proxy
|
|
857
|
+
// (the construction-stack adoption proven by the Step 0 spike), making the
|
|
858
|
+
// proxy an instance of the registered class with its constructor side effects.
|
|
859
|
+
function upgradeElement(proxy, name) {
|
|
860
|
+
const ctor = ceRegistry.get(name);
|
|
861
|
+
if (!ctor) return;
|
|
862
|
+
constructionStack.push(proxy);
|
|
863
|
+
try { Reflect.construct(ctor, [], ctor); }
|
|
864
|
+
finally { constructionStack.pop(); }
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// Ruby calls this when a registered custom element fires a lifecycle reaction.
|
|
868
|
+
// makeProxy upgrades on first crossing, so the constructor has already run.
|
|
869
|
+
function invokeLifecycle(handle, callback, args) {
|
|
870
|
+
const p = makeProxy(handle);
|
|
871
|
+
const fn = p[callback];
|
|
872
|
+
if (typeof fn !== "function") return undefined;
|
|
873
|
+
return dehydrate(fn.apply(p, rehydrate(args || [])));
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// customElements.define(name, JSClass): register JS-side and ask Ruby to wire
|
|
877
|
+
// a Dommy custom element whose reactions route back through invokeLifecycle.
|
|
878
|
+
function defineCustomElement(name, ctor) {
|
|
879
|
+
ceRegistry.set(name, ctor);
|
|
880
|
+
const observed = Array.isArray(ctor.observedAttributes) ? ctor.observedAttributes : [];
|
|
881
|
+
__rb_define_custom_element(name, observed);
|
|
882
|
+
const waiters = cePending.get(name);
|
|
883
|
+
if (waiters) { cePending.delete(name); waiters.forEach((resolve) => resolve(ctor)); }
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// whenDefined stays pending until the name is defined (spec semantics), so
|
|
887
|
+
// `await customElements.whenDefined(x)` before define() doesn't resolve early.
|
|
888
|
+
function whenDefinedCustomElement(name) {
|
|
889
|
+
const ctor = ceRegistry.get(name);
|
|
890
|
+
if (ctor) return Promise.resolve(ctor);
|
|
891
|
+
return new Promise((resolve) => {
|
|
892
|
+
if (!cePending.has(name)) cePending.set(name, []);
|
|
893
|
+
cePending.get(name).push(resolve);
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
globalThis.customElements = {
|
|
898
|
+
define: (name, ctor) => defineCustomElement(name, ctor),
|
|
899
|
+
get: (name) => ceRegistry.get(name),
|
|
900
|
+
whenDefined: (name) => whenDefinedCustomElement(name),
|
|
901
|
+
// Delegate manual upgrades to Dommy's registry (define() already upgrades
|
|
902
|
+
// existing nodes; this covers subtrees attached without reactions).
|
|
903
|
+
upgrade: (root) => { if (isProxy(root)) __rb_upgrade_custom_elements(root[HKEY]); }
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
// 1a: report the DOM interface chain of a host proxy, most-derived first
|
|
907
|
+
// (e.g. ["HTMLDivElement","HTMLElement","Element","Node","EventTarget"]).
|
|
908
|
+
// Returns null for non-proxies.
|
|
909
|
+
function interfaceOf(proxy) {
|
|
910
|
+
if (!isProxy(proxy)) return null;
|
|
911
|
+
return __rb_host_describe(proxy[HKEY]);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
return {
|
|
915
|
+
makeProxy, invokeCallback, scheduleMicrotask, tag: dehydrate, interfaceOf,
|
|
916
|
+
seedInterfaces, invokeLifecycle, attachStatics, exposeConstructorsOnWindow,
|
|
917
|
+
// wasm host bridge (handle-oriented access for a wasm guest)
|
|
918
|
+
wasmGlobalRef, wasmEval, wasmGet, wasmSet, wasmCall, wasmApply, wasmNew,
|
|
919
|
+
wasmTypeof, wasmToString, wasmStrictEqual, wasmIsNull, wasmInstanceof,
|
|
920
|
+
wasmMakeCallback, wasmReleaseRef,
|
|
921
|
+
};
|
|
922
|
+
})();
|