dommy-js-quickjs 0.1.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.
@@ -0,0 +1,46 @@
1
+ // The bare timer globals a browser exposes, routed into Dommy's deterministic
2
+ // scheduler through the window (`window.setTimeout` already works via the
3
+ // Window manifest; these are the unqualified ones).
4
+ //
5
+ // Remember where each timer was scheduled, so a throwing callback can be traced
6
+ // back to the code that set it up. This matters most for minified SPA bundles,
7
+ // where the thrown value is often a bare `null` with no stack of its own — the
8
+ // only locatable stack is the scheduling site. The origin Error is kept JS-side
9
+ // and only stringified if the callback actually throws (see
10
+ // __rbFetchTimerOrigin + Runtime#handle_timer_error); a successful callback
11
+ // forgets its origin and the map is size-capped, so this stays cheap.
12
+ globalThis.__rbTimerOrigins = new Map();
13
+ const __rbDefer = (schedule, fn, delay) => {
14
+ if (typeof fn !== "function") return schedule(fn, delay);
15
+ const origin = new Error();
16
+ let id;
17
+ const wrapped = function () {
18
+ const result = fn.apply(this, arguments);
19
+ __rbTimerOrigins.delete(id); // ran cleanly — no need to keep it
20
+ return result;
21
+ };
22
+ id = schedule(wrapped, delay);
23
+ __rbTimerOrigins.set(id, origin);
24
+ if (__rbTimerOrigins.size > 4096) __rbTimerOrigins.delete(__rbTimerOrigins.keys().next().value);
25
+ return id;
26
+ };
27
+ globalThis.__rbFetchTimerOrigin = (id) => {
28
+ const origin = __rbTimerOrigins.get(id);
29
+ if (!origin) return "";
30
+ __rbTimerOrigins.delete(id);
31
+ return origin.stack || "";
32
+ };
33
+ globalThis.setTimeout = (fn, delay) => __rbDefer((f, d) => window.setTimeout(f, d), fn, delay);
34
+ globalThis.clearTimeout = (id) => window.clearTimeout(id);
35
+ globalThis.setInterval = (fn, delay) => __rbDefer((f, d) => window.setInterval(f, d), fn, delay);
36
+ globalThis.clearInterval = (id) => window.clearInterval(id);
37
+ globalThis.requestAnimationFrame = (fn) => __rbDefer((f) => window.requestAnimationFrame(f), fn);
38
+ globalThis.cancelAnimationFrame = (id) => window.cancelAnimationFrame(id);
39
+ // queueMicrotask must share the engine's promise-job (microtask) queue so its
40
+ // callbacks are FIFO-ordered with Promise reactions (the WHATWG
41
+ // single-microtask-queue model). Routing through the Ruby scheduler instead
42
+ // would drain on a separate pass, reordering it after all native promise jobs.
43
+ globalThis.queueMicrotask = (fn) => {
44
+ if (typeof fn !== "function") throw new TypeError("queueMicrotask requires a function");
45
+ Promise.resolve().then(() => { fn(); });
46
+ };
@@ -0,0 +1,26 @@
1
+ // This QuickJS build has no WebAssembly, so a bare `WebAssembly.foo` reference
2
+ // throws `'WebAssembly' is not defined` (nuxt.com via Shiki, many bundlers'
3
+ // feature probes). Define a stub: compile/instantiate reject and validate()
4
+ // returns false, so WASM-loading code takes its JS fallback instead of
5
+ // crashing.
6
+ //
7
+ // `Memory` honors `{shared:true}` with a SharedArrayBuffer, which the engine
8
+ // does ship. That is also what WPT's common/sab.js needs: it derives the SAB
9
+ // constructor from `new WebAssembly.Memory({shared:true}).buffer.constructor`.
10
+ if (typeof globalThis.WebAssembly === "undefined") {
11
+ var unsupported = function () { return Promise.reject(new Error("WebAssembly is not supported")); };
12
+ var throwUnsupported = function () { throw new Error("WebAssembly is not supported"); };
13
+ globalThis.WebAssembly = {
14
+ instantiate: unsupported, instantiateStreaming: unsupported,
15
+ compile: unsupported, compileStreaming: unsupported,
16
+ validate: function () { return false; },
17
+ Module: throwUnsupported, Instance: throwUnsupported,
18
+ Memory: function (opts) {
19
+ var bytes = ((opts && opts.initial) || 0) * 65536;
20
+ this.buffer = (opts && opts.shared && typeof SharedArrayBuffer === "function")
21
+ ? new SharedArrayBuffer(bytes) : new ArrayBuffer(bytes);
22
+ },
23
+ Table: function () {}, Global: function () {},
24
+ CompileError: Error, LinkError: Error, RuntimeError: Error,
25
+ };
26
+ }
@@ -0,0 +1,14 @@
1
+ // The window IS the global object, so JS built-in constructors and namespaces
2
+ // are also `window` properties (`window.String`, `window.Number`, …). Mirror
3
+ // them as own props on the window proxy so code that reads constructors off
4
+ // `window` (e.g. the WPT reflection harness's `window[type]` casts) resolves
5
+ // them.
6
+ for (const __n of [
7
+ "String", "Boolean", "Number", "BigInt", "Symbol", "Object", "Array",
8
+ "Function", "Date", "RegExp", "Promise", "Map", "Set", "WeakMap",
9
+ "WeakSet", "Math", "JSON", "Reflect", "Proxy", "Error", "TypeError",
10
+ "RangeError", "SyntaxError", "Infinity", "NaN", "undefined",
11
+ "parseInt", "parseFloat", "isNaN", "isFinite", "globalThis",
12
+ ]) {
13
+ try { window[__n] = globalThis[__n]; } catch (__e) {}
14
+ }
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Wires QuickJS as the JS runtime for `Dommy::Rack::Session.new(app,
4
+ # javascript: true)`. Requiring this file (directly, or lazily by the session
5
+ # when javascript is requested) registers the :quickjs backend and points the
6
+ # session's runtime factory at the realm manager.
7
+ #
8
+ # The realm manager (Dommy::Rack::SessionRuntime) lives in dommy-rack and is
9
+ # backend-agnostic; this gem only supplies the QuickJS engine.
10
+ require "dommy/rack"
11
+ require_relative "../quickjs"
12
+
13
+ Dommy::Rack::Session.javascript_runtime_factory = lambda do |session|
14
+ Dommy::Rack::SessionRuntime.new(session)
15
+ end
@@ -9,44 +9,51 @@ 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
33
+ @callback_error_listener = nil
34
+ @track_rejections = Config.track_rejections?
35
+ # Install the JS-side Promise rejection recorder (HostBridge registers
36
+ # the __rb_record_rejection_detail sink it pushes to). The detail then
37
+ # backfills the engine's detail-less report in #enrich_rejection.
38
+ @backend.call_js("__rbHost.installRejectionTracker") if @track_rejections
18
39
  end
19
40
 
20
41
  def define_host_object(name, obj)
21
42
  @bridge.define_host_object(name, obj)
22
43
  end
23
44
 
24
- # Inject the Dommy window and alias the bare browser timer globals to it,
25
- # so `setTimeout(fn, ms)` routes into Dommy's deterministic scheduler.
26
- # Drive callbacks with `win.scheduler.advance_time(ms)`. `window.setTimeout`
27
- # already works via the Window manifest; this also wires the unqualified
28
- # 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)`).
29
50
  def install_window(win)
30
51
  @window = win
31
52
  define_host_object("window", win)
32
53
  @bridge.window = win
33
- @backend.eval(<<~JS)
34
- globalThis.setTimeout = (fn, delay) => window.setTimeout(fn, delay);
35
- globalThis.clearTimeout = (id) => window.clearTimeout(id);
36
- globalThis.setInterval = (fn, delay) => window.setInterval(fn, delay);
37
- globalThis.clearInterval = (id) => window.clearInterval(id);
38
- globalThis.requestAnimationFrame = (fn) => window.requestAnimationFrame(fn);
39
- globalThis.cancelAnimationFrame = (id) => window.cancelAnimationFrame(id);
40
- // queueMicrotask must share the engine's promise-job (microtask)
41
- // queue so its callbacks are FIFO-ordered with Promise reactions
42
- // (the WHATWG single-microtask-queue model). Routing through the
43
- // Ruby scheduler instead would drain on a separate pass, reordering
44
- // it after all native promise jobs.
45
- globalThis.queueMicrotask = (fn) => {
46
- if (typeof fn !== "function") throw new TypeError("queueMicrotask requires a function");
47
- Promise.resolve().then(() => { fn(); });
48
- };
49
- JS
54
+ install_promise_rejection_hook
55
+ wire_scheduler(win)
56
+ @environment.install_timers
50
57
  win
51
58
  end
52
59
 
@@ -57,67 +64,178 @@ module Dommy
57
64
  @bridge.expose_constructors_on(window_obj)
58
65
  end
59
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
+
60
89
  # Run a script for side effects (no return value). Wrapped in an IIFE so
61
90
  # statements are allowed and the completion value is voided — otherwise a
62
91
  # trailing Promise expression would trip the gem's "unawaited Promise"
63
92
  # guard. Drains microtasks so queued .then work lands before returning.
64
93
  def execute(js)
65
- @backend.eval("(function () {\n#{js}\n})();")
66
- drain_microtasks
67
- nil
94
+ in_page_turn { @backend.eval("(function () {\n#{js}\n})();") }
95
+ end
96
+
97
+ # Load a script the way a browser <script> does: in GLOBAL scope, so its
98
+ # top-level `var` / `function` / `let` declarations become globals. UMD /
99
+ # "global" bundles rely on this — e.g. Vue's global build is literally
100
+ # `var Vue = (function(){…})({})`, which an IIFE wrapper (execute) would
101
+ # trap in function scope. Drains microtasks afterward.
102
+ def load_script(js)
103
+ in_page_turn { @backend.eval(discard_completion_value(js)) }
104
+ end
105
+
106
+ # Like #load_script, but compiles the source to bytecode once per
107
+ # `cache_key` (an external script's URL) and reuses it across VMs —
108
+ # avoiding a re-parse of large vendored bundles on every page load.
109
+ def load_script_cached(js, cache_key:)
110
+ in_page_turn do
111
+ @backend.run_compiled(ScriptCache.compiled(cache_key, discard_completion_value(js)))
112
+ end
113
+ end
114
+
115
+ # Install the ESM module resolver (see Backend#module_loader=). A
116
+ # callable `(specifier, importer) -> source | {code:, as:} | nil`.
117
+ def module_loader=(callable)
118
+ @backend.module_loader = callable
119
+ end
120
+
121
+ # Evaluate an external module by URL (the loader fetches it); its
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.
125
+ def load_module_url(url)
126
+ in_page_turn { @backend.import_module_url(url) }
68
127
  end
69
128
 
70
129
  # Evaluate JS and return its value, with DOM nodes decoded to Dommy
71
130
  # objects (rather than the empty Hash a raw proxy becomes crossing to
72
131
  # Ruby). Accepts either an expression (`document.title`) or a statement
73
- # body that uses `return` (`const x = ...; return x;`): the expression
74
- # form is tried first and, on a syntax error, retried as an async
75
- # function body. Syntax errors are compile-time so the failed first
76
- # attempt runs nothing. The result is awaited, so a Promise resolves
77
- # 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.
78
136
  def evaluate(js)
79
- @bridge.decode(eval_tagged("await (#{js.strip.sub(/;\s*\z/, "")})"))
80
- rescue ::Quickjs::SyntaxError
81
- @bridge.decode(eval_tagged("await (async () => {\n#{js}\n})()"))
137
+ bump_dom_epoch
138
+ evaluate_settled(expression?(js) ? parenthesized(js) : "(async () => {\n#{js}\n})()")
82
139
  end
83
140
 
84
- def drain_microtasks
85
- @backend.drain_microtasks
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})();") }
86
149
  end
87
150
 
88
- # Handle-oriented JS access for a wasm guest (see WasmBridge). Memoized
89
- # so the guest's `__rbWasmInvoke` dispatcher (installed via #on_invoke)
90
- # stays registered for the VM's lifetime.
91
- def wasm_bridge
92
- @wasm_bridge ||= WasmBridge.new(@backend)
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})()")
93
158
  end
94
159
 
95
- # Drive the event loop to quiescence: drain the native microtask queue,
96
- # then advance the deterministic scheduler to its next due timer and drain
97
- # again, repeating until no timer is pending. This is the single
98
- # deterministic "settle everything" entry point a host uses after an eval
99
- # (mirroring a `drain_async!`): every queued microtask runs and every
100
- # scheduled timer fires, in WHATWG order (microtasks before each timer).
101
- # `max_iterations` bounds runaway timer loops (e.g. a self-rescheduling
102
- # setInterval).
103
- def run_until_idle(max_iterations: 1000)
104
- sched = @window&.scheduler
105
- max_iterations.times do
106
- drain_microtasks
107
- break unless sched
108
-
109
- next_at = sched.next_due_timer_at
110
- break unless next_at
111
-
112
- sched.advance_time(next_at - sched.now_ms)
113
- drain_microtasks
114
- end
160
+ # Drive the document lifecycle: set `document.readyState` and fire the
161
+ # milestone events (`readystatechange`, then `DOMContentLoaded` on
162
+ # "interactive" / `load` on "complete"), then drain microtasks so the
163
+ # listeners settle. Lets a host replay the real load sequence so code
164
+ # that waits on document readiness (framework startup, `ready` handlers)
165
+ # runs the deferred path. The document defaults to "complete", so call
166
+ # `set_document_ready_state("loading")` BEFORE loading such code to
167
+ # exercise the waiting path.
168
+ def set_document_ready_state(state)
169
+ @window&.document&.__internal_set_ready_state__(state)
170
+ drain_microtasks
171
+ self
172
+ end
173
+
174
+ # --- Driving the event loop (see EventLoop for what each one means) ---
175
+
176
+ def drain_microtasks = @loop.drain_microtasks
177
+
178
+ def settle(max_iterations: EventLoop::DEFAULT_MAX_ITERATIONS)
179
+ @loop.settle(max_iterations: max_iterations)
180
+ self
181
+ end
182
+
183
+ def run_until_idle(max_iterations: EventLoop::DEFAULT_MAX_ITERATIONS)
184
+ @loop.run_until_idle(max_iterations: max_iterations)
115
185
  self
116
186
  end
117
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
+
118
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.
119
205
  def on_unhandled_rejection(&block)
120
- @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)
213
+ end
214
+ @backend.on_unhandled_rejection(&relay)
215
+ self
216
+ end
217
+
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
221
+
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
227
+ end
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
+
233
+ # Observe a timer/rAF callback that was force-killed by the execution
234
+ # timeout (a runaway busy loop). The host records it as a js_error; the
235
+ # offending timer is already dropped by the scheduler so it cannot
236
+ # re-stall. Optional in the port contract (guard with respond_to?).
237
+ def on_callback_error(&block)
238
+ @callback_error_listener = block
121
239
  self
122
240
  end
123
241
 
@@ -127,60 +245,13 @@ module Dommy
127
245
  self
128
246
  end
129
247
 
130
- # Wire the bare browser globals frameworks reach for, aliased onto the
131
- # installed window: self / location / history / navigator / storages /
132
- # CSS / fetch / addEventListener / .... Call after install_window. This
133
- # is what lets real frontend bundles (Turbo, …) run unmodified.
134
- def install_browser_globals
135
- @backend.eval(<<~JS)
136
- globalThis.self = globalThis;
137
- // Top-level window: parent/top are the window itself (spec), so
138
- // frame-walking loops terminate instead of dereferencing undefined.
139
- globalThis.parent = globalThis;
140
- globalThis.top = globalThis;
141
- globalThis.location = window.location;
142
- globalThis.history = window.history;
143
- globalThis.navigator = window.navigator;
144
- globalThis.sessionStorage = window.sessionStorage;
145
- globalThis.localStorage = window.localStorage;
146
- globalThis.CSS = window.CSS;
147
- globalThis.fetch = (...args) => window.fetch(...args);
148
- globalThis.addEventListener = (...args) => window.addEventListener(...args);
149
- globalThis.removeEventListener = (...args) => window.removeEventListener(...args);
150
- globalThis.dispatchEvent = (event) => window.dispatchEvent(event);
151
- // The window IS the global object, so JS built-in constructors and
152
- // namespaces are also `window` properties (`window.String`,
153
- // `window.Number`, …). Mirror them as own props on the window proxy
154
- // so code that reads constructors off `window` (e.g. the WPT
155
- // reflection harness's `window[type]` casts) resolves them.
156
- for (const __n of [
157
- "String", "Boolean", "Number", "BigInt", "Symbol", "Object", "Array",
158
- "Function", "Date", "RegExp", "Promise", "Map", "Set", "WeakMap",
159
- "WeakSet", "Math", "JSON", "Reflect", "Proxy", "Error", "TypeError",
160
- "RangeError", "SyntaxError", "Infinity", "NaN", "undefined",
161
- "parseInt", "parseFloat", "isNaN", "isFinite", "globalThis",
162
- ]) {
163
- try { window[__n] = globalThis[__n]; } catch (__e) {}
164
- }
165
- // Minimal WebAssembly.Memory: the engine provides a real
166
- // SharedArrayBuffer but no WebAssembly, and WPT's `common/sab.js`
167
- // derives the SAB constructor from
168
- // `new WebAssembly.Memory({shared:true}).buffer.constructor`. A
169
- // Memory whose `.buffer` is a SharedArrayBuffer is enough to let
170
- // those tests (encodeInto, TextDecoder copy, …) exercise shared
171
- // buffers with the real codec logic.
172
- if (typeof globalThis.WebAssembly === "undefined" && typeof globalThis.SharedArrayBuffer === "function") {
173
- globalThis.WebAssembly = {
174
- Memory: function (opts) {
175
- const bytes = ((opts && opts.initial) || 0) * 65536;
176
- this.buffer = (opts && opts.shared)
177
- ? new SharedArrayBuffer(bytes)
178
- : new ArrayBuffer(bytes);
179
- },
180
- };
181
- }
182
- JS
183
- self
248
+ # --- Introspection and teardown ---
249
+
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)
184
255
  end
185
256
 
186
257
  # Run JS GC then drain, so FinalizationRegistry cleanup callbacks fire and
@@ -191,19 +262,157 @@ module Dommy
191
262
  end
192
263
 
193
264
  # Live handle count (introspection for lifetime tests).
194
- def registered_count
195
- @bridge.registered_count
196
- end
265
+ def registered_count = @bridge.registered_count
266
+
267
+ # Snapshot bridge crossing counts when DOMMY_JS_BRIDGE_PROFILE=1.
268
+ def bridge_crossing_counts(limit: nil) = @bridge.crossing_counts(limit: limit)
197
269
 
198
- def dispose
199
- @backend.dispose
270
+ def reset_bridge_crossing_counts
271
+ @bridge.reset_crossing_counts
272
+ self
200
273
  end
201
274
 
275
+ def dispose = @backend.dispose
276
+
202
277
  private
203
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
+
313
+ # Scheduler hook: a timer/rAF callback raised. A JS-execution error —
314
+ # the callback threw (Quickjs::RuntimeError) or was force-killed for
315
+ # running too long (Quickjs::InterruptedError < RuntimeError) — must not
316
+ # escape its dispatch (WHATWG: a timer callback's exception is reported,
317
+ # the event loop keeps running). Record it and return truthy so the
318
+ # scheduler drops the timer and browsing continues. A non-JS error is a
319
+ # genuine host bug: return falsy so it propagates.
320
+ def handle_timer_error(error, timer)
321
+ return false unless error.is_a?(::Quickjs::RuntimeError)
322
+
323
+ # An out-of-memory in a timer callback poisons the whole VM: the page's
324
+ # JS is dead from here on, so flag it (and report once) — not just this
325
+ # one callback.
326
+ @loop.note_halted_if_poisoned(error)
327
+ @callback_error_listener&.call(@errors.with_timer_origin(error, timer))
328
+ true
329
+ end
330
+
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
360
+
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();")
367
+ nil
368
+ end
369
+
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"))
384
+ end
385
+
204
386
  def eval_tagged(inner_expr)
205
387
  @backend.eval_awaited("__rbHost.tag(#{inner_expr});")
206
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
207
416
  end
208
417
  end
209
418
  end