Class: Dommy::Js::Quickjs::Runtime

Inherits:
Object
  • Object
show all
Defined in:
lib/dommy/js/quickjs/runtime.rb

Overview

Public entry point: a JS runtime that can drive a Dommy DOM.

rt = Dommy::Js::Quickjs::Runtime.new
rt.define_host_object("document", win.document)
rt.evaluate('document.querySelector("h1").textContent')  #=> "..."

Wires the QuickJS Backend to the engine-agnostic HostBridge, seeded with the Dommy method manifest.

Constant Summary collapse

WASM_STUB_JS =
<<~'JS'
  if (typeof globalThis.WebAssembly === "undefined") {
    var unsupported = function () { return Promise.reject(new Error("WebAssembly is not supported")); };
    var throwUnsupported = function () { throw new Error("WebAssembly is not supported"); };
    globalThis.WebAssembly = {
      instantiate: unsupported, instantiateStreaming: unsupported,
      compile: unsupported, compileStreaming: unsupported,
      validate: function () { return false; },
      Module: throwUnsupported, Instance: throwUnsupported,
      Memory: function (opts) {
        var bytes = ((opts && opts.initial) || 0) * 65536;
        this.buffer = (opts && opts.shared && typeof SharedArrayBuffer === "function")
          ? new SharedArrayBuffer(bytes) : new ArrayBuffer(bytes);
      },
      Table: function () {}, Global: function () {},
      CompileError: Error, LinkError: Error, RuntimeError: Error,
    };
  }
JS
INTL_POLYFILL_JS =
<<~'JS'
  if (typeof globalThis.Intl === "undefined") {
    var I = {};
    var group = function (s) {
      var p = String(s).split(".");
      p[0] = p[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
      return p.join(".");
    };
    function NumberFormat(l, o) { this.o = o || {}; }
    NumberFormat.prototype.format = function (n) {
      n = Number(n); var o = this.o;
      if (o.style === "percent") n *= 100;
      var max = o.maximumFractionDigits;
      if (max == null && o.style === "currency") max = 2;
      var s = group(max == null ? String(n) : n.toFixed(max));
      if (o.style === "percent") s += "%";
      if (o.style === "currency" && o.currency) s = o.currency + " " + s;
      return s;
    };
    NumberFormat.prototype.formatToParts = function (n) { return [{ type: "literal", value: this.format(n) }]; };
    NumberFormat.prototype.resolvedOptions = function () { return Object.assign({ locale: "en", numberingSystem: "latn", style: "decimal" }, this.o); };
    function DateTimeFormat(l, o) { this.o = o || {}; }
    DateTimeFormat.prototype.format = function (d) {
      d = d == null ? new Date() : new Date(d);
      if (isNaN(d.getTime())) return "";
      try { return d.toLocaleString(); } catch (e) { return d.toString(); }
    };
    DateTimeFormat.prototype.formatToParts = function (d) { return [{ type: "literal", value: this.format(d) }]; };
    DateTimeFormat.prototype.formatRange = function (a, b) { return this.format(a) + " – " + this.format(b); };
    DateTimeFormat.prototype.resolvedOptions = function () { return Object.assign({ locale: "en", calendar: "gregory", numberingSystem: "latn", timeZone: "UTC" }, this.o); };
    function Collator(l, o) { this.o = o || {}; }
    Collator.prototype.compare = function (a, b) { a = String(a); b = String(b); return a < b ? -1 : a > b ? 1 : 0; };
    Collator.prototype.resolvedOptions = function () { return Object.assign({ locale: "en" }, this.o); };
    function PluralRules(l, o) { this.o = o || {}; }
    PluralRules.prototype.select = function (n) { return Number(n) === 1 ? "one" : "other"; };
    PluralRules.prototype.resolvedOptions = function () { return Object.assign({ locale: "en", type: "cardinal" }, this.o); };
    function RelativeTimeFormat(l, o) { this.o = o || {}; }
    RelativeTimeFormat.prototype.format = function (v, u) { return v + " " + u + (Math.abs(v) === 1 ? "" : "s"); };
    RelativeTimeFormat.prototype.formatToParts = function (v, u) { return [{ type: "literal", value: this.format(v, u) }]; };
    RelativeTimeFormat.prototype.resolvedOptions = function () { return Object.assign({ locale: "en", numeric: "always", style: "long" }, this.o); };
    function ListFormat(l, o) { this.o = o || {}; }
    ListFormat.prototype.format = function (a) { return Array.from(a || []).join(", "); };
    ListFormat.prototype.formatToParts = function (a) { return [{ type: "element", value: this.format(a) }]; };
    ListFormat.prototype.resolvedOptions = function () { return Object.assign({ locale: "en", type: "conjunction", style: "long" }, this.o); };
    I.NumberFormat = NumberFormat; I.DateTimeFormat = DateTimeFormat; I.Collator = Collator;
    I.PluralRules = PluralRules; I.RelativeTimeFormat = RelativeTimeFormat; I.ListFormat = ListFormat;
    ["NumberFormat", "DateTimeFormat", "Collator", "PluralRules", "RelativeTimeFormat", "ListFormat"].forEach(function (k) {
      I[k].supportedLocalesOf = function (locs) { return Array.isArray(locs) ? locs.slice() : locs ? [locs] : []; };
    });
    I.getCanonicalLocales = function (locs) { return Array.isArray(locs) ? locs.slice() : locs ? [String(locs)] : []; };
    globalThis.Intl = I;
  }
JS

Instance Method Summary collapse

Constructor Details

#initialize(**vm_opts) ⇒ Runtime

Returns a new instance of Runtime.



15
16
17
18
19
20
21
22
23
24
25
26
27
28
# File 'lib/dommy/js/quickjs/runtime.rb', line 15

def initialize(**vm_opts)
  @backend = Backend.new(**vm_opts)
  @bridge = Dommy::Js::HostBridge.new(@backend)
  @callback_error_listener = nil
  @js_halted = false
  # Opt-in diagnostics: the engine stringifies a non-Error rejection reason
  # to "[object Object]". Install a JS-side recorder so on_unhandled_rejection
  # can surface the real cause (DOMMY_JS_DEBUG_REJECTIONS=1).
  @track_rejections = !ENV["DOMMY_JS_DEBUG_REJECTIONS"].to_s.empty?
  # Install the JS-side Promise rejection recorder (HostBridge registers
  # the __rb_record_rejection_detail sink it pushes to). The detail then
  # backfills the engine's detail-less report in #enrich_rejection.
  @backend.call_js("__rbHost.installRejectionTracker") if @track_rejections
end

Instance Method Details

#bridge_crossing_counts(limit: nil) ⇒ Object

Snapshot bridge crossing counts when DOMMY_JS_BRIDGE_PROFILE=1.



480
481
482
# File 'lib/dommy/js/quickjs/runtime.rb', line 480

def bridge_crossing_counts(limit: nil)
  @bridge.crossing_counts(limit: limit)
end

#collect_garbageObject

Run JS GC then drain, so FinalizationRegistry cleanup callbacks fire and release handles for proxies that are no longer referenced.



469
470
471
472
# File 'lib/dommy/js/quickjs/runtime.rb', line 469

def collect_garbage
  @backend.run_gc
  @backend.drain_microtasks
end

#define_host_object(name, obj) ⇒ Object



30
31
32
# File 'lib/dommy/js/quickjs/runtime.rb', line 30

def define_host_object(name, obj)
  @bridge.define_host_object(name, obj)
end

#disposeObject



489
490
491
# File 'lib/dommy/js/quickjs/runtime.rb', line 489

def dispose
  @backend.dispose
end

#drain_microtasksObject



211
212
213
214
215
216
217
218
219
220
221
# File 'lib/dommy/js/quickjs/runtime.rb', line 211

def drain_microtasks
  @backend.drain_microtasks
rescue ::Quickjs::RuntimeError => e
  # The microtask checkpoint hit out-of-memory and poisoned the VM. Per
  # the "browsing never crashes" contract, don't let it escape the event
  # loop: record it once (so it shows in js_errors / the activity log) and
  # then no-op — the page's JS is dead, but the browser stays alive.
  raise unless @backend.poisoned?

  note_js_halted(e)
end

#drive_due_nowObject

Run the event loop over the work ready at the current virtual time —the microtask checkpoint plus every due task and anything it queues at the same instant — WITHOUT advancing the clock to a future timer (a result waiting on a real delay is left for the await to surface). The nested-timer 4ms clamp guarantees due-now work drains in finite turns.



200
201
202
203
204
205
206
207
208
209
# File 'lib/dommy/js/quickjs/runtime.rb', line 200

def drive_due_now
  sched = @window&.scheduler
  return drain_microtasks unless sched

  64.times do
    sched.advance_time(0)
    break unless sched.next_due_timer_at == sched.now_ms
  end
  nil
end

#enrich_rejection(err) ⇒ Object

In rejection-debug mode, replace the engine’s detail-less “[object Object]” message (a non-Error reason the engine could only toString) with the rich detail recorded JS-side at rejection time, paired by recency. A no-op for errors that already carry a real message.



310
311
312
313
314
315
316
317
318
319
320
321
# File 'lib/dommy/js/quickjs/runtime.rb', line 310

def enrich_rejection(err)
  return err unless err.respond_to?(:message) && err.message.to_s.strip == "[object Object]"

  detail = @bridge.take_rejection_detail
  return err if detail.nil? || detail.to_s.empty?

  enriched = ::Quickjs::RuntimeError.new(detail.to_s, "UnhandledRejection")
  enriched.set_backtrace(err.backtrace) if err.backtrace
  enriched
rescue StandardError
  err
end

#evaluate(js) ⇒ Object

Evaluate JS and return its value, with DOM nodes decoded to Dommy objects (rather than the empty Hash a raw proxy becomes crossing to Ruby). Accepts either an expression (‘document.title`) or a statement body that uses `return` (`const x = …; return x;`): the expression form is tried first and, on a syntax error, retried as an async function body. Syntax errors are compile-time so the failed first attempt runs nothing. The result is awaited, so a Promise resolves before returning.



173
174
175
176
177
# File 'lib/dommy/js/quickjs/runtime.rb', line 173

def evaluate(js)
  evaluate_settled("(#{js.strip.sub(/;\s*\z/, "")})")
rescue ::Quickjs::SyntaxError
  evaluate_settled("(async () => {\n#{js}\n})()")
end

#evaluate_settled(expr) ⇒ Object

Evaluate ‘expr` to its awaited value, driving the event loop so a result that depends on a TASK (a fetch’s setTimeout(0) delivery, a setTimeout) settles first. The gem’s top-level await (js_std_await) only drains the engine’s job queue, never Dommy’s scheduler — so awaiting a task-resolved promise directly deadlocks in C. Instead: store the result, run the event loop over everything due NOW (microtask checkpoint + due tasks and the tasks they chain), then await the now-settled promise (which returns immediately and still surfaces a rejection as a Ruby raise, as before).



187
188
189
190
191
192
193
# File 'lib/dommy/js/quickjs/runtime.rb', line 187

def evaluate_settled(expr)
  # `void 0` keeps the completion value off the promise, so the eval
  # doesn't trip the gem's "unawaited Promise at top-level" guard.
  @backend.eval("globalThis.__rbEvalP = Promise.resolve(#{expr}); void 0;")
  drive_due_now
  @bridge.decode(eval_tagged("await globalThis.__rbEvalP"))
end

#execute(js) ⇒ Object

Run a script for side effects (no return value). Wrapped in an IIFE so statements are allowed and the completion value is voided — otherwise a trailing Promise expression would trip the gem’s “unawaited Promise” guard. Drains microtasks so queued .then work lands before returning.



116
117
118
119
120
# File 'lib/dommy/js/quickjs/runtime.rb', line 116

def execute(js)
  @backend.eval("(function () {\n#{js}\n})();")
  drain_microtasks
  nil
end

#expose_constructors_on(window_obj) ⇒ Object

Expose the seeded interface constructors on a secondary window (an iframe’s contentWindow), so cross-window instanceof / defaultView work. Call after install_window (the constructors must already be seeded).



108
109
110
# File 'lib/dommy/js/quickjs/runtime.rb', line 108

def expose_constructors_on(window_obj)
  @bridge.expose_constructors_on(window_obj)
end

#install_browser_globalsObject

Wire the bare browser globals frameworks reach for, aliased onto the installed window: self / location / history / navigator / storages / CSS / fetch / addEventListener / .… Call after install_window. This is what lets real frontend bundles (Turbo, …) run unmodified.



342
343
344
345
346
347
348
# File 'lib/dommy/js/quickjs/runtime.rb', line 342

def install_browser_globals
  alias_browser_globals
  install_intl_polyfill
  install_wasm_stub
  mirror_builtins_on_window
  self
end

#install_intl_polyfillObject

This QuickJS build ships without ICU, so ‘Intl` is undefined and any page touching `Intl.NumberFormat` / `DateTimeFormat` / … throws `’Intl’ is not defined` (nuxt.com, i18n libraries, …). Install a small locale-naive polyfill: it formats reasonably (grouped numbers, ISO-ish dates) so pages run instead of crashing, without pulling in full ICU.



386
387
388
389
# File 'lib/dommy/js/quickjs/runtime.rb', line 386

def install_intl_polyfill
  @backend.eval(INTL_POLYFILL_JS)
  self
end

#install_wasm_memory_shimObject

WPT-only scaffolding: a minimal ‘WebAssembly.Memory` whose `.buffer` is a SharedArrayBuffer. The engine ships a real SharedArrayBuffer but no WebAssembly, and WPT’s ‘common/sab.js` derives the SAB constructor from `new WebAssembly.Memory(shared:true).buffer.constructor`. This is test-harness-only (real pages never need it), so it is opt-in and NOT part of #install_browser_globals.



451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
# File 'lib/dommy/js/quickjs/runtime.rb', line 451

def install_wasm_memory_shim
  @backend.eval(<<~JS)
    if (typeof globalThis.WebAssembly === "undefined" && typeof globalThis.SharedArrayBuffer === "function") {
      globalThis.WebAssembly = {
        Memory: function (opts) {
          const bytes = ((opts && opts.initial) || 0) * 65536;
          this.buffer = (opts && opts.shared)
            ? new SharedArrayBuffer(bytes)
            : new ArrayBuffer(bytes);
        },
      };
    }
  JS
  self
end

#install_wasm_stubObject

This QuickJS build has no WebAssembly, so a bare ‘WebAssembly.foo` reference throws `’WebAssembly’ is not defined` (nuxt.com via Shiki, many bundlers’ feature probes). Define a stub: compile/instantiate reject and validate() returns false, so WASM-loading code takes its JS fallback instead of crashing. ‘Memory` honors `shared:true` (a SharedArrayBuffer) so WPT’s common/sab.js keeps working.



356
357
358
359
# File 'lib/dommy/js/quickjs/runtime.rb', line 356

def install_wasm_stub
  @backend.eval(WASM_STUB_JS)
  self
end

#install_window(win) ⇒ Object

Inject the Dommy window and alias the bare browser timer globals to it, so ‘setTimeout(fn, ms)` routes into Dommy’s deterministic scheduler. Drive callbacks with ‘win.scheduler.advance_time(ms)`. `window.setTimeout` already works via the Window manifest; this also wires the unqualified globals browsers expose.



39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'lib/dommy/js/quickjs/runtime.rb', line 39

def install_window(win)
  @window = win
  define_host_object("window", win)
  @bridge.window = win
  # A runaway timer/rAF callback (busy loop) is force-killed by the gem's
  # eval timeout, surfacing as a Quickjs::InterruptedError out of the host
  # call. Route it through the scheduler's error hook so it is recorded as
  # a js_error and dropped, not propagated as a fatal crash (browsing must
  # never crash). Genuine host bugs (any other error) still propagate.
  if win.respond_to?(:scheduler) && win.scheduler
    win.scheduler.timer_error_handler = method(:handle_timer_error)
    # The other half of a WHATWG microtask checkpoint: the engine's
    # promise-job queue. Wiring this lets the scheduler drain microtasks
    # after EACH task (not once per batch of due timers), as the event
    # loop processing model requires.
    win.scheduler.microtask_checkpoint = method(:drain_microtasks)
  end
  @backend.eval(<<~JS)
    // Remember where each timer was scheduled, so a throwing callback can
    // be traced back to the code that set it up. This matters most for
    // minified SPA bundles, where the thrown value is often a bare `null`
    // with no stack of its own — the only locatable stack is the
    // scheduling site. The origin Error is kept JS-side and only
    // stringified if the callback actually throws (see
    // __rbFetchTimerOrigin + handle_timer_error); a successful callback
    // forgets its origin and the map is size-capped, so this stays cheap.
    globalThis.__rbTimerOrigins = new Map();
    const __rbDefer = (schedule, fn, delay) => {
      if (typeof fn !== "function") return schedule(fn, delay);
      const origin = new Error();
      let id;
      const wrapped = function () {
        const result = fn.apply(this, arguments);
        __rbTimerOrigins.delete(id); // ran cleanly — no need to keep it
        return result;
      };
      id = schedule(wrapped, delay);
      __rbTimerOrigins.set(id, origin);
      if (__rbTimerOrigins.size > 4096) __rbTimerOrigins.delete(__rbTimerOrigins.keys().next().value);
      return id;
    };
    globalThis.__rbFetchTimerOrigin = (id) => {
      const origin = __rbTimerOrigins.get(id);
      if (!origin) return "";
      __rbTimerOrigins.delete(id);
      return origin.stack || "";
    };
    globalThis.setTimeout = (fn, delay) => __rbDefer((f, d) => window.setTimeout(f, d), fn, delay);
    globalThis.clearTimeout = (id) => window.clearTimeout(id);
    globalThis.setInterval = (fn, delay) => __rbDefer((f, d) => window.setInterval(f, d), fn, delay);
    globalThis.clearInterval = (id) => window.clearInterval(id);
    globalThis.requestAnimationFrame = (fn) => __rbDefer((f) => window.requestAnimationFrame(f), fn);
    globalThis.cancelAnimationFrame = (id) => window.cancelAnimationFrame(id);
    // queueMicrotask must share the engine's promise-job (microtask)
    // queue so its callbacks are FIFO-ordered with Promise reactions
    // (the WHATWG single-microtask-queue model). Routing through the
    // Ruby scheduler instead would drain on a separate pass, reordering
    // it after all native promise jobs.
    globalThis.queueMicrotask = (fn) => {
      if (typeof fn !== "function") throw new TypeError("queueMicrotask requires a function");
      Promise.resolve().then(() => { fn(); });
    };
  JS
  win
end

#load_module(source) ⇒ Object

Evaluate an inline ‘<script type=“module”>` body as an ES module (run for side effects). Bare specifiers / absolute paths in its imports resolve through the module loader. Drains microtasks afterward.



151
152
153
154
155
# File 'lib/dommy/js/quickjs/runtime.rb', line 151

def load_module(source)
  @backend.import_module(source)
  drain_microtasks
  nil
end

#load_module_url(url) ⇒ Object

Evaluate an external module by URL (the loader fetches it); its relative imports resolve against that URL. Drains microtasks.



159
160
161
162
163
# File 'lib/dommy/js/quickjs/runtime.rb', line 159

def load_module_url(url)
  @backend.import_module_url(url)
  drain_microtasks
  nil
end

#load_script(js) ⇒ Object

Load a script the way a browser <script> does: in GLOBAL scope, so its top-level ‘var` / `function` / `let` declarations become globals. UMD / “global” bundles rely on this — e.g. Vue’s global build is literally ‘var Vue = (function()…)({})`, which an IIFE wrapper (execute) would trap in function scope. Drains microtasks afterward.



127
128
129
130
131
# File 'lib/dommy/js/quickjs/runtime.rb', line 127

def load_script(js)
  @backend.eval(js)
  drain_microtasks
  nil
end

#load_script_cached(js, cache_key:) ⇒ Object

Like #load_script, but compiles the source to bytecode once per ‘cache_key` (an external script’s URL) and reuses it across VMs —avoiding a re-parse of large vendored bundles on every page load.



136
137
138
139
140
# File 'lib/dommy/js/quickjs/runtime.rb', line 136

def load_script_cached(js, cache_key:)
  @backend.run_compiled(ScriptCache.compiled(cache_key, js))
  drain_microtasks
  nil
end

#module_loader=(callable) ⇒ Object

Install the ESM module resolver (see Backend#module_loader=). A callable ‘(specifier, importer) -> source | as: | nil`.



144
145
146
# File 'lib/dommy/js/quickjs/runtime.rb', line 144

def module_loader=(callable)
  @backend.module_loader = callable
end

#on_callback_error(&block) ⇒ Object

Observe a timer/rAF callback that was force-killed by the execution timeout (a runaway busy loop). The host records it as a js_error; the offending timer is already dropped by the scheduler so it cannot re-stall. Optional in the Runtime contract (guard with respond_to?).



327
328
329
330
# File 'lib/dommy/js/quickjs/runtime.rb', line 327

def on_callback_error(&block)
  @callback_error_listener = block
  self
end

#on_log(&block) ⇒ Object

Observe console.* output (see Backend).



333
334
335
336
# File 'lib/dommy/js/quickjs/runtime.rb', line 333

def on_log(&block)
  @backend.on_log(&block)
  self
end

#on_unhandled_rejection(&block) ⇒ Object

Surface otherwise-swallowed JS promise rejections (see Backend).



297
298
299
300
301
302
303
304
# File 'lib/dommy/js/quickjs/runtime.rb', line 297

def on_unhandled_rejection(&block)
  if @track_rejections
    @backend.on_unhandled_rejection { |err| block.call(enrich_rejection(err)) }
  else
    @backend.on_unhandled_rejection(&block)
  end
  self
end

#registered_countObject

Live handle count (introspection for lifetime tests).



475
476
477
# File 'lib/dommy/js/quickjs/runtime.rb', line 475

def registered_count
  @bridge.registered_count
end

#reset_bridge_crossing_countsObject



484
485
486
487
# File 'lib/dommy/js/quickjs/runtime.rb', line 484

def reset_bridge_crossing_counts
  @bridge.reset_crossing_counts
  self
end

#run_until_idle(max_iterations: 1000) ⇒ Object

Drive the event loop to quiescence: drain the native microtask queue, then advance the deterministic scheduler to its next due timer and drain again, repeating until no timer is pending. This is the single deterministic “settle everything” entry point a host uses after an eval (mirroring a ‘drain_async!`): every queued microtask runs and every scheduled timer fires, in WHATWG order (microtasks before each timer). `max_iterations` bounds runaway timer loops (e.g. a self-rescheduling setInterval).



252
253
254
255
256
257
258
259
260
261
262
263
264
265
# File 'lib/dommy/js/quickjs/runtime.rb', line 252

def run_until_idle(max_iterations: 1000)
  sched = @window&.scheduler
  max_iterations.times do
    drain_microtasks
    break unless sched

    next_at = sched.next_due_timer_at
    break unless next_at

    sched.advance_time(next_at - sched.now_ms)
    drain_microtasks
  end
  self
end

#set_document_ready_state(state) ⇒ Object

Drive the document lifecycle: set ‘document.readyState` and fire the milestone events (`readystatechange`, then `DOMContentLoaded` on “interactive” / `load` on “complete”), then drain microtasks so the listeners settle. Lets a host replay the real load sequence so code that waits on document readiness (framework startup, `ready` handlers) runs the deferred path. The document defaults to “complete”, so call `set_document_ready_state(“loading”)` BEFORE loading such code to exercise the waiting path.



231
232
233
234
235
# File 'lib/dommy/js/quickjs/runtime.rb', line 231

def set_document_ready_state(state)
  @window&.document&.__internal_set_ready_state__(state)
  drain_microtasks
  self
end

#settle(max_iterations: 1000) ⇒ Object

Settle the work that is READY at the current virtual time: drain microtasks, run timers already due now (‘setTimeout(0)` chains), and flush pending `requestAnimationFrame` callbacks by advancing to their frame boundary — but do NOT jump the clock to a not-yet-due `setTimeout(300)` (that needs an explicit `advance_time(300)`). This is the “let promises and animation frames resolve” entry point; `bound` caps a self-rescheduling rAF loop.



274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
# File 'lib/dommy/js/quickjs/runtime.rb', line 274

def settle(max_iterations: 1000)
  sched = @window&.scheduler
  max_iterations.times do
    drain_microtasks
    break unless sched

    before = sched.now_ms
    sched.advance_time(0) # run due-now timers + microtasks, no clock jump
    drain_microtasks

    raf_at = sched.next_animation_frame_at
    if raf_at && raf_at > sched.now_ms
      sched.advance_time(raf_at - sched.now_ms) # advance to the frame, run rAF
      drain_microtasks
      next
    end

    break if sched.now_ms == before
  end
  self
end

#wasm_bridgeObject

Handle-oriented JS access for a wasm guest (see WasmBridge). Memoized so the guest’s ‘__rbWasmInvoke` dispatcher (installed via #on_invoke) stays registered for the VM’s lifetime.



240
241
242
# File 'lib/dommy/js/quickjs/runtime.rb', line 240

def wasm_bridge
  @wasm_bridge ||= WasmBridge.new(@backend)
end