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,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
+ })();