dommy-js-quickjs 0.9.0 → 0.10.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.
@@ -9,18 +9,29 @@ module Dommy
9
9
  # rt.define_host_object("document", win.document)
10
10
  # rt.evaluate('document.querySelector("h1").textContent') #=> "..."
11
11
  #
12
- # Wires the QuickJS Backend to the engine-agnostic HostBridge, seeded with
13
- # the Dommy method manifest.
12
+ # Implements the Dommy::Js::Runtime port (see that module for the contract)
13
+ # by wiring four collaborators together and delegating to them:
14
+ #
15
+ # Backend the `quickjs` gem — eval, compile, the VM's state
16
+ # HostBridge the engine-agnostic JS<->Ruby DOM bridge (core)
17
+ # BrowserEnvironment the globals and polyfills a page expects to find
18
+ # EventLoop microtasks + Dommy's scheduler, as one loop
19
+ # ErrorTranslator an engine exception, as what the page should see
20
+ #
21
+ # What stays here is the port itself: script evaluation, and the wiring
22
+ # that decides which collaborator answers a host's call.
14
23
  class Runtime
15
24
  def initialize(**vm_opts)
16
25
  @backend = Backend.new(**vm_opts)
17
26
  @bridge = Dommy::Js::HostBridge.new(@backend)
27
+ @environment = BrowserEnvironment.new(@backend)
28
+ @errors = ErrorTranslator.new(@backend, @bridge)
29
+ @environment.install_error_rebuilder
30
+ @loop = EventLoop.new(@backend, scheduler: -> { @window&.scheduler }) do |error|
31
+ @callback_error_listener&.call(error)
32
+ end
18
33
  @callback_error_listener = nil
19
- @js_halted = false
20
- # Opt-in diagnostics: the engine stringifies a non-Error rejection reason
21
- # to "[object Object]". Install a JS-side recorder so on_unhandled_rejection
22
- # can surface the real cause (DOMMY_JS_DEBUG_REJECTIONS=1).
23
- @track_rejections = !ENV["DOMMY_JS_DEBUG_REJECTIONS"].to_s.empty?
34
+ @track_rejections = Config.track_rejections?
24
35
  # Install the JS-side Promise rejection recorder (HostBridge registers
25
36
  # the __rb_record_rejection_detail sink it pushes to). The detail then
26
37
  # backfills the engine's detail-less report in #enrich_rejection.
@@ -31,74 +42,18 @@ module Dommy
31
42
  @bridge.define_host_object(name, obj)
32
43
  end
33
44
 
34
- # Inject the Dommy window and alias the bare browser timer globals to it,
35
- # so `setTimeout(fn, ms)` routes into Dommy's deterministic scheduler.
36
- # Drive callbacks with `win.scheduler.advance_time(ms)`. `window.setTimeout`
37
- # already works via the Window manifest; this also wires the unqualified
38
- # globals browsers expose.
45
+ # Inject the Dommy window: seed it as the JS global, give the bridge the
46
+ # realm it belongs to, route rejections and runaway callbacks back here,
47
+ # and alias the bare timer globals onto it (so `setTimeout(fn, ms)` runs
48
+ # through Dommy's deterministic scheduler — drive it with
49
+ # `win.scheduler.advance_time(ms)`).
39
50
  def install_window(win)
40
51
  @window = win
41
52
  define_host_object("window", win)
42
53
  @bridge.window = win
43
- # A runaway timer/rAF callback (busy loop) is force-killed by the gem's
44
- # eval timeout, surfacing as a Quickjs::InterruptedError out of the host
45
- # call. Route it through the scheduler's error hook so it is recorded as
46
- # a js_error and dropped, not propagated as a fatal crash (browsing must
47
- # never crash). Genuine host bugs (any other error) still propagate.
48
- if win.respond_to?(:scheduler) && win.scheduler
49
- win.scheduler.timer_error_handler = method(:handle_timer_error)
50
- # The other half of a WHATWG microtask checkpoint: the engine's
51
- # promise-job queue. Wiring this lets the scheduler drain microtasks
52
- # after EACH task (not once per batch of due timers), as the event
53
- # loop processing model requires.
54
- win.scheduler.microtask_checkpoint = method(:drain_microtasks)
55
- end
56
- @backend.eval(<<~JS)
57
- // Remember where each timer was scheduled, so a throwing callback can
58
- // be traced back to the code that set it up. This matters most for
59
- // minified SPA bundles, where the thrown value is often a bare `null`
60
- // with no stack of its own — the only locatable stack is the
61
- // scheduling site. The origin Error is kept JS-side and only
62
- // stringified if the callback actually throws (see
63
- // __rbFetchTimerOrigin + handle_timer_error); a successful callback
64
- // forgets its origin and the map is size-capped, so this stays cheap.
65
- globalThis.__rbTimerOrigins = new Map();
66
- const __rbDefer = (schedule, fn, delay) => {
67
- if (typeof fn !== "function") return schedule(fn, delay);
68
- const origin = new Error();
69
- let id;
70
- const wrapped = function () {
71
- const result = fn.apply(this, arguments);
72
- __rbTimerOrigins.delete(id); // ran cleanly — no need to keep it
73
- return result;
74
- };
75
- id = schedule(wrapped, delay);
76
- __rbTimerOrigins.set(id, origin);
77
- if (__rbTimerOrigins.size > 4096) __rbTimerOrigins.delete(__rbTimerOrigins.keys().next().value);
78
- return id;
79
- };
80
- globalThis.__rbFetchTimerOrigin = (id) => {
81
- const origin = __rbTimerOrigins.get(id);
82
- if (!origin) return "";
83
- __rbTimerOrigins.delete(id);
84
- return origin.stack || "";
85
- };
86
- globalThis.setTimeout = (fn, delay) => __rbDefer((f, d) => window.setTimeout(f, d), fn, delay);
87
- globalThis.clearTimeout = (id) => window.clearTimeout(id);
88
- globalThis.setInterval = (fn, delay) => __rbDefer((f, d) => window.setInterval(f, d), fn, delay);
89
- globalThis.clearInterval = (id) => window.clearInterval(id);
90
- globalThis.requestAnimationFrame = (fn) => __rbDefer((f) => window.requestAnimationFrame(f), fn);
91
- globalThis.cancelAnimationFrame = (id) => window.cancelAnimationFrame(id);
92
- // queueMicrotask must share the engine's promise-job (microtask)
93
- // queue so its callbacks are FIFO-ordered with Promise reactions
94
- // (the WHATWG single-microtask-queue model). Routing through the
95
- // Ruby scheduler instead would drain on a separate pass, reordering
96
- // it after all native promise jobs.
97
- globalThis.queueMicrotask = (fn) => {
98
- if (typeof fn !== "function") throw new TypeError("queueMicrotask requires a function");
99
- Promise.resolve().then(() => { fn(); });
100
- };
101
- JS
54
+ install_promise_rejection_hook
55
+ wire_scheduler(win)
56
+ @environment.install_timers
102
57
  win
103
58
  end
104
59
 
@@ -109,14 +64,34 @@ module Dommy
109
64
  @bridge.expose_constructors_on(window_obj)
110
65
  end
111
66
 
67
+ # Wire the bare browser globals frameworks reach for, aliased onto the
68
+ # installed window: self / location / history / navigator / storages /
69
+ # CSS / fetch / addEventListener / .... Call after install_window. This
70
+ # is what lets real frontend bundles (Turbo, …) run unmodified.
71
+ def install_browser_globals
72
+ @environment.install_globals
73
+ self
74
+ end
75
+
76
+ # WPT-only scaffolding, part of the port's optional surface: the
77
+ # harness's common/sab.js derives the SharedArrayBuffer constructor from
78
+ # `new WebAssembly.Memory({shared:true}).buffer.constructor`. That is
79
+ # what the WebAssembly stub's Memory already yields, so this is the same
80
+ # install — named separately because a host asks for it explicitly, and a
81
+ # no-op when #install_browser_globals already ran.
82
+ def install_wasm_memory_shim
83
+ @environment.install_wasm_stub
84
+ self
85
+ end
86
+
87
+ # --- Running scripts ---
88
+
112
89
  # Run a script for side effects (no return value). Wrapped in an IIFE so
113
90
  # statements are allowed and the completion value is voided — otherwise a
114
91
  # trailing Promise expression would trip the gem's "unawaited Promise"
115
92
  # guard. Drains microtasks so queued .then work lands before returning.
116
93
  def execute(js)
117
- @backend.eval("(function () {\n#{js}\n})();")
118
- drain_microtasks
119
- nil
94
+ in_page_turn { @backend.eval("(function () {\n#{js}\n})();") }
120
95
  end
121
96
 
122
97
  # Load a script the way a browser <script> does: in GLOBAL scope, so its
@@ -125,18 +100,16 @@ module Dommy
125
100
  # `var Vue = (function(){…})({})`, which an IIFE wrapper (execute) would
126
101
  # trap in function scope. Drains microtasks afterward.
127
102
  def load_script(js)
128
- @backend.eval(js)
129
- drain_microtasks
130
- nil
103
+ in_page_turn { @backend.eval(discard_completion_value(js)) }
131
104
  end
132
105
 
133
106
  # Like #load_script, but compiles the source to bytecode once per
134
107
  # `cache_key` (an external script's URL) and reuses it across VMs —
135
108
  # avoiding a re-parse of large vendored bundles on every page load.
136
109
  def load_script_cached(js, cache_key:)
137
- @backend.run_compiled(ScriptCache.compiled(cache_key, js))
138
- drain_microtasks
139
- nil
110
+ in_page_turn do
111
+ @backend.run_compiled(ScriptCache.compiled(cache_key, discard_completion_value(js)))
112
+ end
140
113
  end
141
114
 
142
115
  # Install the ESM module resolver (see Backend#module_loader=). A
@@ -145,79 +118,43 @@ module Dommy
145
118
  @backend.module_loader = callable
146
119
  end
147
120
 
148
- # Evaluate an inline `<script type="module">` body as an ES module (run
149
- # for side effects). Bare specifiers / absolute paths in its imports
150
- # resolve through the module loader. Drains microtasks afterward.
151
- def load_module(source)
152
- @backend.import_module(source)
153
- drain_microtasks
154
- nil
155
- end
156
-
157
121
  # Evaluate an external module by URL (the loader fetches it); its
158
- # relative imports resolve against that URL. Drains microtasks.
122
+ # relative imports resolve against that URL. An inline module arrives
123
+ # here too, seeded under a page URL by ScriptBoot so `import.meta.url`
124
+ # resolves. Drains microtasks.
159
125
  def load_module_url(url)
160
- @backend.import_module_url(url)
161
- drain_microtasks
162
- nil
126
+ in_page_turn { @backend.import_module_url(url) }
163
127
  end
164
128
 
165
129
  # Evaluate JS and return its value, with DOM nodes decoded to Dommy
166
130
  # objects (rather than the empty Hash a raw proxy becomes crossing to
167
131
  # Ruby). Accepts either an expression (`document.title`) or a statement
168
- # body that uses `return` (`const x = ...; return x;`): the expression
169
- # form is tried first and, on a syntax error, retried as an async
170
- # function body. Syntax errors are compile-time so the failed first
171
- # attempt runs nothing. The result is awaited, so a Promise resolves
172
- # before returning.
132
+ # body that uses `return` (`const x = ...; return x;`), run as an async
133
+ # function body. Which one it is gets decided by compiling, not by
134
+ # running and retrying (see #expression?). The result is awaited, so a
135
+ # Promise resolves before returning.
173
136
  def evaluate(js)
174
- evaluate_settled("(#{js.strip.sub(/;\s*\z/, "")})")
175
- rescue ::Quickjs::SyntaxError
176
- evaluate_settled("(async () => {\n#{js}\n})()")
137
+ bump_dom_epoch
138
+ evaluate_settled(expression?(js) ? parenthesized(js) : "(async () => {\n#{js}\n})()")
177
139
  end
178
140
 
179
- # Evaluate `expr` to its awaited value, driving the event loop so a result
180
- # that depends on a TASK (a fetch's setTimeout(0) delivery, a setTimeout)
181
- # settles first. The gem's top-level await (js_std_await) only drains the
182
- # engine's job queue, never Dommy's scheduler — so awaiting a task-resolved
183
- # promise directly deadlocks in C. Instead: store the result, run the
184
- # event loop over everything due NOW (microtask checkpoint + due tasks and
185
- # the tasks they chain), then await the now-settled promise (which returns
186
- # immediately and still surfaces a rejection as a Ruby raise, as before).
187
- def evaluate_settled(expr)
188
- # `void 0` keeps the completion value off the promise, so the eval
189
- # doesn't trip the gem's "unawaited Promise at top-level" guard.
190
- @backend.eval("globalThis.__rbEvalP = Promise.resolve(#{expr}); void 0;")
191
- drive_due_now
192
- @bridge.decode(eval_tagged("await globalThis.__rbEvalP"))
141
+ # Optional Runtime API: run a script with Ruby arguments as its
142
+ # `arguments`. DOM objects cross as JS proxies (the same wire the bridge
143
+ # uses for any Ruby->JS value), so `arguments[0].scrollIntoView()` works.
144
+ # The wire payload is JSON spliced into the source and rehydrated
145
+ # in-realm (`__rbHost.rehydrateArgs`), then spread into the script.
146
+ def execute_with_args(js, args)
147
+ wire = encode_args(args)
148
+ in_page_turn { @backend.eval("(function () {\n#{with_arguments_js(js, wire)}\n})();") }
193
149
  end
194
150
 
195
- # Run the event loop over the work ready at the current virtual time —
196
- # the microtask checkpoint plus every due task and anything it queues at
197
- # the same instant — WITHOUT advancing the clock to a future timer (a
198
- # result waiting on a real delay is left for the await to surface). The
199
- # nested-timer 4ms clamp guarantees due-now work drains in finite turns.
200
- def drive_due_now
201
- sched = @window&.scheduler
202
- return drain_microtasks unless sched
203
-
204
- 64.times do
205
- sched.advance_time(0)
206
- break unless sched.next_due_timer_at == sched.now_ms
207
- end
208
- nil
209
- end
210
-
211
- def drain_microtasks
212
- @backend.drain_microtasks
213
- rescue ::Quickjs::RuntimeError => e
214
- # The microtask checkpoint hit out-of-memory and poisoned the VM. Per
215
- # the "browsing never crashes" contract, don't let it escape the event
216
- # loop: record it once (so it shows in js_errors / the activity log) and
217
- # then no-op — the page's JS is dead, but the browser stays alive.
218
- raise unless @backend.poisoned?
219
-
220
- note_js_halted(e)
151
+ # Args-aware evaluate. Like #evaluate, the script is an expression
152
+ # (`arguments[0].value`, the form Capybara's evaluate_script passes) or a
153
+ # body that uses `return`; the decoded result is awaited.
154
+ def evaluate_with_args(js, args)
155
+ bump_dom_epoch
156
+ body = expression?(js) ? "return #{parenthesized(js)};" : js
157
+ evaluate_settled("(async function () {\n#{with_arguments_js(body, encode_args(args))}\n})()")
221
158
  end
222
159
 
223
160
  # Drive the document lifecycle: set `document.readyState` and fire the
@@ -234,96 +171,69 @@ module Dommy
234
171
  self
235
172
  end
236
173
 
237
- # Handle-oriented JS access for a wasm guest (see WasmBridge). Memoized
238
- # so the guest's `__rbWasmInvoke` dispatcher (installed via #on_invoke)
239
- # stays registered for the VM's lifetime.
240
- def wasm_bridge
241
- @wasm_bridge ||= WasmBridge.new(@backend)
242
- end
174
+ # --- Driving the event loop (see EventLoop for what each one means) ---
243
175
 
244
- # Drive the event loop to quiescence: drain the native microtask queue,
245
- # then advance the deterministic scheduler to its next due timer and drain
246
- # again, repeating until no timer is pending. This is the single
247
- # deterministic "settle everything" entry point a host uses after an eval
248
- # (mirroring a `drain_async!`): every queued microtask runs and every
249
- # scheduled timer fires, in WHATWG order (microtasks before each timer).
250
- # `max_iterations` bounds runaway timer loops (e.g. a self-rescheduling
251
- # setInterval).
252
- def run_until_idle(max_iterations: 1000)
253
- sched = @window&.scheduler
254
- max_iterations.times do
255
- drain_microtasks
256
- break unless sched
257
-
258
- next_at = sched.next_due_timer_at
259
- break unless next_at
260
-
261
- sched.advance_time(next_at - sched.now_ms)
262
- drain_microtasks
263
- end
176
+ def drain_microtasks = @loop.drain_microtasks
177
+
178
+ def settle(max_iterations: EventLoop::DEFAULT_MAX_ITERATIONS)
179
+ @loop.settle(max_iterations: max_iterations)
264
180
  self
265
181
  end
266
182
 
267
- # Settle the work that is READY at the current virtual time: drain
268
- # microtasks, run timers already due now (`setTimeout(0)` chains), and
269
- # flush pending `requestAnimationFrame` callbacks by advancing to their
270
- # frame boundary — but do NOT jump the clock to a not-yet-due
271
- # `setTimeout(300)` (that needs an explicit `advance_time(300)`). This is
272
- # the "let promises and animation frames resolve" entry point; `bound`
273
- # caps a self-rescheduling rAF loop.
274
- def settle(max_iterations: 1000)
275
- sched = @window&.scheduler
276
- max_iterations.times do
277
- drain_microtasks
278
- break unless sched
279
-
280
- before = sched.now_ms
281
- sched.advance_time(0) # run due-now timers + microtasks, no clock jump
282
- drain_microtasks
283
-
284
- raf_at = sched.next_animation_frame_at
285
- if raf_at && raf_at > sched.now_ms
286
- sched.advance_time(raf_at - sched.now_ms) # advance to the frame, run rAF
287
- drain_microtasks
288
- next
289
- end
290
-
291
- break if sched.now_ms == before
292
- end
183
+ def run_until_idle(max_iterations: EventLoop::DEFAULT_MAX_ITERATIONS)
184
+ @loop.run_until_idle(max_iterations: max_iterations)
293
185
  self
294
186
  end
295
187
 
188
+ # --- Errors ---
189
+
190
+ # An engine exception for a script's throw, rebuilt as a real Error
191
+ # inside the realm so `event.error` is something the page can read.
192
+ # Optional in the port contract; returns nil when it cannot be done.
193
+ def rebuild_error(error) = @errors.rebuild_error(error)
194
+
296
195
  # Surface otherwise-swallowed JS promise rejections (see Backend).
196
+ # Goes quiet once the JS hook is installed: both would fire for the same
197
+ # rejection, and the hook carries strictly more.
198
+ #
199
+ # Goes quiet, too, on an engine that reports a rejection before a
200
+ # handler could attach (Backend.premature_rejection_reports?): there
201
+ # every `.catch` and `try { await … } catch` would surface as an
202
+ # unhandled rejection and fail a strict host on correct code. Reporting
203
+ # none is the lesser loss — a rejection the page truly leaves unhandled
204
+ # goes unreported until the engine is fixed.
297
205
  def on_unhandled_rejection(&block)
298
- if @track_rejections
299
- @backend.on_unhandled_rejection { |err| block.call(enrich_rejection(err)) }
300
- else
301
- @backend.on_unhandled_rejection(&block)
206
+ # Decided when a rejection actually arrives, not now: a host registers
207
+ # this before #install_window, which is where the hook goes in.
208
+ relay = lambda do |error|
209
+ next if js_rejection_hook?
210
+ next self.class.warn_premature_rejection_reports if Backend.premature_rejection_reports?
211
+
212
+ block.call(@track_rejections ? @errors.enrich_rejection(error) : error)
302
213
  end
214
+ @backend.on_unhandled_rejection(&relay)
303
215
  self
304
216
  end
305
217
 
306
- # In rejection-debug mode, replace the engine's detail-less "[object
307
- # Object]" message (a non-Error reason the engine could only toString)
308
- # with the rich detail recorded JS-side at rejection time, paired by
309
- # recency. A no-op for errors that already carry a real message.
310
- def enrich_rejection(err)
311
- return err unless err.respond_to?(:message) && err.message.to_s.strip == "[object Object]"
312
-
313
- detail = @bridge.take_rejection_detail
314
- return err if detail.nil? || detail.to_s.empty?
218
+ # Said once per process, on the first rejection left unreported.
219
+ def self.warn_premature_rejection_reports
220
+ return if @warned_premature_rejection_reports
315
221
 
316
- enriched = ::Quickjs::RuntimeError.new(detail.to_s, "UnhandledRejection")
317
- enriched.set_backtrace(err.backtrace) if err.backtrace
318
- enriched
319
- rescue StandardError
320
- err
222
+ @warned_premature_rejection_reports = true
223
+ warn "dommy-js-quickjs: this quickjs (#{::Quickjs::VERSION}) reports a promise rejection before " \
224
+ "a handler can attach, so unhandled rejections are not reported. A quickjs with " \
225
+ "hmsk/quickjs.rb#141 (unhandled-rejection timing) reports them."
226
+ nil
321
227
  end
322
228
 
229
+ # Whether rejections arrive through the JS hook. A host that also relays
230
+ # #on_unhandled_rejection would otherwise report each rejection twice.
231
+ def js_rejection_hook? = !!@js_rejection_hook
232
+
323
233
  # Observe a timer/rAF callback that was force-killed by the execution
324
234
  # timeout (a runaway busy loop). The host records it as a js_error; the
325
235
  # offending timer is already dropped by the scheduler so it cannot
326
- # re-stall. Optional in the Runtime contract (guard with respond_to?).
236
+ # re-stall. Optional in the port contract (guard with respond_to?).
327
237
  def on_callback_error(&block)
328
238
  @callback_error_listener = block
329
239
  self
@@ -335,133 +245,13 @@ module Dommy
335
245
  self
336
246
  end
337
247
 
338
- # Wire the bare browser globals frameworks reach for, aliased onto the
339
- # installed window: self / location / history / navigator / storages /
340
- # CSS / fetch / addEventListener / .... Call after install_window. This
341
- # is what lets real frontend bundles (Turbo, …) run unmodified.
342
- def install_browser_globals
343
- alias_browser_globals
344
- install_intl_polyfill
345
- install_wasm_stub
346
- mirror_builtins_on_window
347
- self
348
- end
248
+ # --- Introspection and teardown ---
349
249
 
350
- # This QuickJS build has no WebAssembly, so a bare `WebAssembly.foo`
351
- # reference throws `'WebAssembly' is not defined` (nuxt.com via Shiki,
352
- # many bundlers' feature probes). Define a stub: compile/instantiate
353
- # reject and validate() returns false, so WASM-loading code takes its
354
- # JS fallback instead of crashing. `Memory` honors `{shared:true}` (a
355
- # SharedArrayBuffer) so WPT's common/sab.js keeps working.
356
- def install_wasm_stub
357
- @backend.eval(WASM_STUB_JS)
358
- self
359
- end
360
-
361
- WASM_STUB_JS = <<~'JS'
362
- if (typeof globalThis.WebAssembly === "undefined") {
363
- var unsupported = function () { return Promise.reject(new Error("WebAssembly is not supported")); };
364
- var throwUnsupported = function () { throw new Error("WebAssembly is not supported"); };
365
- globalThis.WebAssembly = {
366
- instantiate: unsupported, instantiateStreaming: unsupported,
367
- compile: unsupported, compileStreaming: unsupported,
368
- validate: function () { return false; },
369
- Module: throwUnsupported, Instance: throwUnsupported,
370
- Memory: function (opts) {
371
- var bytes = ((opts && opts.initial) || 0) * 65536;
372
- this.buffer = (opts && opts.shared && typeof SharedArrayBuffer === "function")
373
- ? new SharedArrayBuffer(bytes) : new ArrayBuffer(bytes);
374
- },
375
- Table: function () {}, Global: function () {},
376
- CompileError: Error, LinkError: Error, RuntimeError: Error,
377
- };
378
- }
379
- JS
380
-
381
- # This QuickJS build ships without ICU, so `Intl` is undefined and any
382
- # page touching `Intl.NumberFormat` / `DateTimeFormat` / … throws
383
- # `'Intl' is not defined` (nuxt.com, i18n libraries, …). Install a small
384
- # locale-naive polyfill: it formats reasonably (grouped numbers, ISO-ish
385
- # dates) so pages run instead of crashing, without pulling in full ICU.
386
- def install_intl_polyfill
387
- @backend.eval(INTL_POLYFILL_JS)
388
- self
389
- end
390
-
391
- INTL_POLYFILL_JS = <<~'JS'
392
- if (typeof globalThis.Intl === "undefined") {
393
- var I = {};
394
- var group = function (s) {
395
- var p = String(s).split(".");
396
- p[0] = p[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
397
- return p.join(".");
398
- };
399
- function NumberFormat(l, o) { this.o = o || {}; }
400
- NumberFormat.prototype.format = function (n) {
401
- n = Number(n); var o = this.o;
402
- if (o.style === "percent") n *= 100;
403
- var max = o.maximumFractionDigits;
404
- if (max == null && o.style === "currency") max = 2;
405
- var s = group(max == null ? String(n) : n.toFixed(max));
406
- if (o.style === "percent") s += "%";
407
- if (o.style === "currency" && o.currency) s = o.currency + " " + s;
408
- return s;
409
- };
410
- NumberFormat.prototype.formatToParts = function (n) { return [{ type: "literal", value: this.format(n) }]; };
411
- NumberFormat.prototype.resolvedOptions = function () { return Object.assign({ locale: "en", numberingSystem: "latn", style: "decimal" }, this.o); };
412
- function DateTimeFormat(l, o) { this.o = o || {}; }
413
- DateTimeFormat.prototype.format = function (d) {
414
- d = d == null ? new Date() : new Date(d);
415
- if (isNaN(d.getTime())) return "";
416
- try { return d.toLocaleString(); } catch (e) { return d.toString(); }
417
- };
418
- DateTimeFormat.prototype.formatToParts = function (d) { return [{ type: "literal", value: this.format(d) }]; };
419
- DateTimeFormat.prototype.formatRange = function (a, b) { return this.format(a) + " – " + this.format(b); };
420
- DateTimeFormat.prototype.resolvedOptions = function () { return Object.assign({ locale: "en", calendar: "gregory", numberingSystem: "latn", timeZone: "UTC" }, this.o); };
421
- function Collator(l, o) { this.o = o || {}; }
422
- Collator.prototype.compare = function (a, b) { a = String(a); b = String(b); return a < b ? -1 : a > b ? 1 : 0; };
423
- Collator.prototype.resolvedOptions = function () { return Object.assign({ locale: "en" }, this.o); };
424
- function PluralRules(l, o) { this.o = o || {}; }
425
- PluralRules.prototype.select = function (n) { return Number(n) === 1 ? "one" : "other"; };
426
- PluralRules.prototype.resolvedOptions = function () { return Object.assign({ locale: "en", type: "cardinal" }, this.o); };
427
- function RelativeTimeFormat(l, o) { this.o = o || {}; }
428
- RelativeTimeFormat.prototype.format = function (v, u) { return v + " " + u + (Math.abs(v) === 1 ? "" : "s"); };
429
- RelativeTimeFormat.prototype.formatToParts = function (v, u) { return [{ type: "literal", value: this.format(v, u) }]; };
430
- RelativeTimeFormat.prototype.resolvedOptions = function () { return Object.assign({ locale: "en", numeric: "always", style: "long" }, this.o); };
431
- function ListFormat(l, o) { this.o = o || {}; }
432
- ListFormat.prototype.format = function (a) { return Array.from(a || []).join(", "); };
433
- ListFormat.prototype.formatToParts = function (a) { return [{ type: "element", value: this.format(a) }]; };
434
- ListFormat.prototype.resolvedOptions = function () { return Object.assign({ locale: "en", type: "conjunction", style: "long" }, this.o); };
435
- I.NumberFormat = NumberFormat; I.DateTimeFormat = DateTimeFormat; I.Collator = Collator;
436
- I.PluralRules = PluralRules; I.RelativeTimeFormat = RelativeTimeFormat; I.ListFormat = ListFormat;
437
- ["NumberFormat", "DateTimeFormat", "Collator", "PluralRules", "RelativeTimeFormat", "ListFormat"].forEach(function (k) {
438
- I[k].supportedLocalesOf = function (locs) { return Array.isArray(locs) ? locs.slice() : locs ? [locs] : []; };
439
- });
440
- I.getCanonicalLocales = function (locs) { return Array.isArray(locs) ? locs.slice() : locs ? [String(locs)] : []; };
441
- globalThis.Intl = I;
442
- }
443
- JS
444
-
445
- # WPT-only scaffolding: a minimal `WebAssembly.Memory` whose `.buffer`
446
- # is a SharedArrayBuffer. The engine ships a real SharedArrayBuffer but
447
- # no WebAssembly, and WPT's `common/sab.js` derives the SAB constructor
448
- # from `new WebAssembly.Memory({shared:true}).buffer.constructor`. This
449
- # is test-harness-only (real pages never need it), so it is opt-in and
450
- # NOT part of #install_browser_globals.
451
- def install_wasm_memory_shim
452
- @backend.eval(<<~JS)
453
- if (typeof globalThis.WebAssembly === "undefined" && typeof globalThis.SharedArrayBuffer === "function") {
454
- globalThis.WebAssembly = {
455
- Memory: function (opts) {
456
- const bytes = ((opts && opts.initial) || 0) * 65536;
457
- this.buffer = (opts && opts.shared)
458
- ? new SharedArrayBuffer(bytes)
459
- : new ArrayBuffer(bytes);
460
- },
461
- };
462
- }
463
- JS
464
- self
250
+ # Handle-oriented JS access for a wasm guest (see WasmBridge). Memoized
251
+ # so the guest's `__rbWasmInvoke` dispatcher (installed via #on_invoke)
252
+ # stays registered for the VM's lifetime.
253
+ def wasm_bridge
254
+ @wasm_bridge ||= WasmBridge.new(@backend)
465
255
  end
466
256
 
467
257
  # Run JS GC then drain, so FinalizationRegistry cleanup callbacks fire and
@@ -472,26 +262,54 @@ module Dommy
472
262
  end
473
263
 
474
264
  # Live handle count (introspection for lifetime tests).
475
- def registered_count
476
- @bridge.registered_count
477
- end
265
+ def registered_count = @bridge.registered_count
478
266
 
479
267
  # Snapshot bridge crossing counts when DOMMY_JS_BRIDGE_PROFILE=1.
480
- def bridge_crossing_counts(limit: nil)
481
- @bridge.crossing_counts(limit: limit)
482
- end
268
+ def bridge_crossing_counts(limit: nil) = @bridge.crossing_counts(limit: limit)
483
269
 
484
270
  def reset_bridge_crossing_counts
485
271
  @bridge.reset_crossing_counts
486
272
  self
487
273
  end
488
274
 
489
- def dispose
490
- @backend.dispose
491
- end
275
+ def dispose = @backend.dispose
492
276
 
493
277
  private
494
278
 
279
+ # A runaway timer/rAF callback (busy loop) is force-killed by the gem's
280
+ # eval timeout, surfacing as a Quickjs::InterruptedError out of the host
281
+ # call. Route it through the scheduler's error hook so it is recorded as
282
+ # a js_error and dropped, not propagated as a fatal crash (browsing must
283
+ # never crash). Genuine host bugs (any other error) still propagate.
284
+ #
285
+ # The microtask checkpoint is the other half of the wiring: draining
286
+ # after EACH task (not once per batch of due timers) is what the event
287
+ # loop processing model requires.
288
+ def wire_scheduler(win)
289
+ return unless win.respond_to?(:scheduler) && win.scheduler
290
+
291
+ win.scheduler.timer_error_handler = method(:handle_timer_error)
292
+ win.scheduler.microtask_checkpoint = method(:drain_microtasks)
293
+ end
294
+
295
+ # Hand rejections to the JS hook rather than to a Ruby callback, when the
296
+ # engine has one. It is called with the promise and the reason as the
297
+ # values the page threw, which is the whole point: an exception the
298
+ # engine already converted has lost both, and the page ends up with an
299
+ # object it cannot read. The hook also reports the other half of HTML's
300
+ # tracking, `rejectionhandled`, which a Ruby callback never sees.
301
+ #
302
+ # No-op on an engine without it; #on_unhandled_rejection then stays the
303
+ # route, with the losses that implies.
304
+ def install_promise_rejection_hook
305
+ return unless @backend.respond_to?(:promise_rejection_hook=)
306
+
307
+ @backend.promise_rejection_hook = "__rbHost.onPromiseRejection"
308
+ @js_rejection_hook = true
309
+ rescue ::StandardError
310
+ @js_rejection_hook = false
311
+ end
312
+
495
313
  # Scheduler hook: a timer/rAF callback raised. A JS-execution error —
496
314
  # the callback threw (Quickjs::RuntimeError) or was force-killed for
497
315
  # running too long (Quickjs::InterruptedError < RuntimeError) — must not
@@ -505,113 +323,96 @@ module Dommy
505
323
  # An out-of-memory in a timer callback poisons the whole VM: the page's
506
324
  # JS is dead from here on, so flag it (and report once) — not just this
507
325
  # one callback.
508
- note_js_halted(error) if @backend.poisoned?
509
- @callback_error_listener&.call(enrich_callback_error(error, timer))
326
+ @loop.note_halted_if_poisoned(error)
327
+ @callback_error_listener&.call(@errors.with_timer_origin(error, timer))
510
328
  true
511
329
  end
512
330
 
513
- # The VM hit out-of-memory and is poisoned. Surface the failure ONCE (a
514
- # repeated drain would otherwise report it every tick) so the user sees
515
- # that the page's JavaScript stopped, then leave it to the no-op guards.
516
- def note_js_halted(error)
517
- return if @js_halted
331
+ # A classic script's completion value is discarded by a browser, but the
332
+ # gem converts whatever the eval returned and REFUSES a pending Promise
333
+ # ("An unawaited Promise was returned to the top-level"). A script whose
334
+ # last statement is an assignment of one — `window.p = new Promise(…);`,
335
+ # which is how a page publishes a promise for a later script to await —
336
+ # therefore reached the page as an uncaught error, and a testharness page
337
+ # that saw it reported no results at all.
338
+ #
339
+ # #execute solves this by wrapping in an IIFE, which #load_script cannot
340
+ # do: its declarations have to land in global scope. Append a statement
341
+ # that evaluates to undefined instead. The leading newline is what keeps
342
+ # it out of a trailing line comment, and no declaration moves scope.
343
+ def discard_completion_value(js) = "#{js}\n;void 0;"
344
+
345
+ # One turn of handing the page something to run. Ruby may have mutated the
346
+ # DOM since JS last ran, so the bridge's attribute snapshots are
347
+ # invalidated first; then the work runs; then queued `.then` work lands
348
+ # before the caller gets control back.
349
+ #
350
+ # Every entry point that gives the page code goes through here, so a new
351
+ # one cannot forget either half. Forgetting the first would be the quiet
352
+ # kind of bug: the page reads attribute values that are no longer true,
353
+ # and nothing raises.
354
+ def in_page_turn
355
+ bump_dom_epoch
356
+ yield
357
+ drain_microtasks
358
+ nil
359
+ end
518
360
 
519
- @js_halted = true
520
- @callback_error_listener&.call(error)
361
+ # Ruby -> JS entry: Ruby code (test drivers, script boot) may have
362
+ # mutated the DOM since JS last ran, so invalidate the bridge's
363
+ # attribute snapshots (see host_runtime.js). A no-op before the host
364
+ # runtime is installed.
365
+ def bump_dom_epoch
366
+ @backend.eval("globalThis.__rbHost && globalThis.__rbHost.bumpDomEpoch();")
521
367
  nil
522
368
  end
523
369
 
524
- # Attach the timer's scheduling stack (where the page set the timer up) to
525
- # the recorded error, so a callback that throws a stackless value — a bare
526
- # `null`, common in minified bundles — is still traceable to the code that
527
- # scheduled it. The error's class (and message, the dedup key) is kept;
528
- # only its backtrace is replaced with the JS frames, which the diagnostics
529
- # UI reads in place of the host scheduler internals. A no-op when no origin
530
- # was captured (a timer not created through the instrumented globals, or a
531
- # unit test driving the scheduler directly).
532
- def enrich_callback_error(error, timer)
533
- frames = fetch_timer_origin(timer)
534
- error.set_backtrace(frames) unless frames.empty?
535
- error
536
- rescue StandardError
537
- error
538
- end
539
-
540
- # The scheduling stack for `timer`, as cleaned frame strings (or []). The
541
- # origin Error lives JS-side until now; fetching it also clears it.
542
- def fetch_timer_origin(timer)
543
- return [] unless timer.respond_to?(:id)
544
-
545
- stack = @backend.call_js("__rbFetchTimerOrigin", timer.id).to_s
546
- # Drop the shim's own frame (the `new Error()` in __rbDefer) so the top
547
- # frame is the page code that called setTimeout/setInterval.
548
- stack.split("\n").map(&:strip).reject(&:empty?)
549
- .reject { |line| line.include?("__rbDefer") }
550
- rescue StandardError
551
- []
552
- end
553
-
554
- # Alias the bare browser globals frameworks reach for onto the installed
555
- # window (self/parent/top/location/history/navigator/storages/CSS/fetch/
556
- # event methods). This is what lets real frontend bundles run unmodified.
557
- def alias_browser_globals
558
- @backend.eval(<<~JS)
559
- globalThis.self = globalThis;
560
- // Top-level window: parent/top are the window itself (spec), so
561
- // frame-walking loops terminate instead of dereferencing undefined.
562
- globalThis.parent = globalThis;
563
- globalThis.top = globalThis;
564
- globalThis.location = window.location;
565
- globalThis.history = window.history;
566
- globalThis.navigator = window.navigator;
567
- globalThis.sessionStorage = window.sessionStorage;
568
- globalThis.localStorage = window.localStorage;
569
- globalThis.CSS = window.CSS;
570
- globalThis.getComputedStyle = (...args) => window.getComputedStyle(...args);
571
- globalThis.matchMedia = (...args) => window.matchMedia(...args);
572
- globalThis.fetch = (...args) => window.fetch(...args);
573
- globalThis.addEventListener = (...args) => window.addEventListener(...args);
574
- globalThis.removeEventListener = (...args) => window.removeEventListener(...args);
575
- globalThis.dispatchEvent = (event) => window.dispatchEvent(event);
576
-
577
- // More bare globals frameworks read directly (e.g. performance.now(),
578
- // crypto, screen). Objects/values are aliased by reference; methods are
579
- // wrapped so `this` binds to the window. All already exist on window.
580
- for (const __n of ["performance", "crypto", "screen", "visualViewport",
581
- "indexedDB", "caches", "devicePixelRatio",
582
- "innerWidth", "innerHeight", "scrollX", "scrollY", "pageXOffset"]) {
583
- try { globalThis[__n] = window[__n]; } catch (__e) {}
584
- }
585
- for (const __m of ["scrollTo", "scrollBy", "requestIdleCallback", "cancelIdleCallback",
586
- "getSelection", "structuredClone", "reportError", "btoa", "atob",
587
- "alert", "confirm", "prompt", "open", "postMessage"]) {
588
- try { globalThis[__m] = (...args) => window[__m](...args); } catch (__e) {}
589
- }
590
- JS
591
- end
592
-
593
- # The window IS the global object, so JS built-in constructors and
594
- # namespaces are also `window` properties (`window.String`,
595
- # `window.Number`, …). Mirror them as own props on the window proxy so
596
- # code that reads constructors off `window` (e.g. the WPT reflection
597
- # harness's `window[type]` casts) resolves them.
598
- def mirror_builtins_on_window
599
- @backend.eval(<<~JS)
600
- for (const __n of [
601
- "String", "Boolean", "Number", "BigInt", "Symbol", "Object", "Array",
602
- "Function", "Date", "RegExp", "Promise", "Map", "Set", "WeakMap",
603
- "WeakSet", "Math", "JSON", "Reflect", "Proxy", "Error", "TypeError",
604
- "RangeError", "SyntaxError", "Infinity", "NaN", "undefined",
605
- "parseInt", "parseFloat", "isNaN", "isFinite", "globalThis",
606
- ]) {
607
- try { window[__n] = globalThis[__n]; } catch (__e) {}
608
- }
609
- JS
370
+ # Evaluate `expr` to its awaited value, driving the event loop so a result
371
+ # that depends on a TASK (a fetch's setTimeout(0) delivery, a setTimeout)
372
+ # settles first. The gem's top-level await (js_std_await) only drains the
373
+ # engine's job queue, never Dommy's scheduler — so awaiting a task-resolved
374
+ # promise directly deadlocks in C. Instead: store the result, run the
375
+ # event loop over everything due NOW (microtask checkpoint + due tasks and
376
+ # the tasks they chain), then await the now-settled promise (which returns
377
+ # immediately and still surfaces a rejection as a Ruby raise, as before).
378
+ def evaluate_settled(expr)
379
+ # `void 0` keeps the completion value off the promise, so the eval
380
+ # doesn't trip the gem's "unawaited Promise at top-level" guard.
381
+ @backend.eval("globalThis.__rbEvalP = Promise.resolve(#{expr}); void 0;")
382
+ @loop.drive_due_now
383
+ @bridge.decode(eval_tagged("await globalThis.__rbEvalP"))
610
384
  end
611
385
 
612
386
  def eval_tagged(inner_expr)
613
387
  @backend.eval_awaited("__rbHost.tag(#{inner_expr});")
614
388
  end
389
+
390
+ # Whether `js` is a single expression, found by compiling it — nothing
391
+ # runs. Running it and retrying as a body on SyntaxError could not tell
392
+ # a parse error from one the script throws (`JSON.parse("{")`), and the
393
+ # retry then ran the script's side effects a second time.
394
+ def expression?(js)
395
+ Backend.compile(parenthesized(js))
396
+ true
397
+ rescue ::Quickjs::SyntaxError
398
+ false
399
+ end
400
+
401
+ # `js` as a parenthesized expression, a trailing `;` dropped. The
402
+ # newlines keep a trailing line comment from swallowing the `)`.
403
+ def parenthesized(js) = "(\n#{js.strip.sub(/;\s*\z/, "")}\n)"
404
+
405
+ # Script arguments as the bridge's JSON wire payload.
406
+ def encode_args(args) = JSON.generate(@bridge.encode(Array(args)))
407
+
408
+ # A script body wrapped so `arguments` are the wire payload rehydrated
409
+ # in-realm (DOM handles become JS proxies). The payload is spliced into
410
+ # the source as a JSON literal and held in a function-local `var`, so
411
+ # nested calls can't collide and there is no global to clean up.
412
+ def with_arguments_js(js, wire_json)
413
+ "var __rbScriptArgs = __rbHost.rehydrateArgs(#{wire_json});\n" \
414
+ "return (function () {\n#{js}\n}).apply(this, __rbScriptArgs);"
415
+ end
615
416
  end
616
417
  end
617
418
  end