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.

Instance Method Summary collapse

Constructor Details

#initialize(**vm_opts) ⇒ Runtime

Returns a new instance of Runtime.



15
16
17
18
# File 'lib/dommy/js/quickjs/runtime.rb', line 15

def initialize(**vm_opts)
  @backend = Backend.new(**vm_opts)
  @bridge = Dommy::Js::HostBridge.new(@backend)
end

Instance Method Details

#collect_garbageObject

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



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

def collect_garbage
  @backend.run_gc
  @backend.drain_microtasks
end

#define_host_object(name, obj) ⇒ Object



20
21
22
# File 'lib/dommy/js/quickjs/runtime.rb', line 20

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

#disposeObject



198
199
200
# File 'lib/dommy/js/quickjs/runtime.rb', line 198

def dispose
  @backend.dispose
end

#drain_microtasksObject



84
85
86
# File 'lib/dommy/js/quickjs/runtime.rb', line 84

def drain_microtasks
  @backend.drain_microtasks
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.



78
79
80
81
82
# File 'lib/dommy/js/quickjs/runtime.rb', line 78

def evaluate(js)
  @bridge.decode(eval_tagged("await (#{js.strip.sub(/;\s*\z/, "")})"))
rescue ::Quickjs::SyntaxError
  @bridge.decode(eval_tagged("await (async () => {\n#{js}\n})()"))
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.



64
65
66
67
68
# File 'lib/dommy/js/quickjs/runtime.rb', line 64

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).



56
57
58
# File 'lib/dommy/js/quickjs/runtime.rb', line 56

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.



134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
# File 'lib/dommy/js/quickjs/runtime.rb', line 134

def install_browser_globals
  @backend.eval(<<~JS)
    globalThis.self = globalThis;
    // Top-level window: parent/top are the window itself (spec), so
    // frame-walking loops terminate instead of dereferencing undefined.
    globalThis.parent = globalThis;
    globalThis.top = globalThis;
    globalThis.location = window.location;
    globalThis.history = window.history;
    globalThis.navigator = window.navigator;
    globalThis.sessionStorage = window.sessionStorage;
    globalThis.localStorage = window.localStorage;
    globalThis.CSS = window.CSS;
    globalThis.fetch = (...args) => window.fetch(...args);
    globalThis.addEventListener = (...args) => window.addEventListener(...args);
    globalThis.removeEventListener = (...args) => window.removeEventListener(...args);
    globalThis.dispatchEvent = (event) => window.dispatchEvent(event);
    // The window IS the global object, so JS built-in constructors and
    // namespaces are also `window` properties (`window.String`,
    // `window.Number`, …). Mirror them as own props on the window proxy
    // so code that reads constructors off `window` (e.g. the WPT
    // reflection harness's `window[type]` casts) resolves them.
    for (const __n of [
      "String", "Boolean", "Number", "BigInt", "Symbol", "Object", "Array",
      "Function", "Date", "RegExp", "Promise", "Map", "Set", "WeakMap",
      "WeakSet", "Math", "JSON", "Reflect", "Proxy", "Error", "TypeError",
      "RangeError", "SyntaxError", "Infinity", "NaN", "undefined",
      "parseInt", "parseFloat", "isNaN", "isFinite", "globalThis",
    ]) {
      try { window[__n] = globalThis[__n]; } catch (__e) {}
    }
    // Minimal WebAssembly.Memory: the engine provides a real
    // SharedArrayBuffer but no WebAssembly, and WPT's `common/sab.js`
    // derives the SAB constructor from
    // `new WebAssembly.Memory({shared:true}).buffer.constructor`. A
    // Memory whose `.buffer` is a SharedArrayBuffer is enough to let
    // those tests (encodeInto, TextDecoder copy, …) exercise shared
    // buffers with the real codec logic.
    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_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.



29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# File 'lib/dommy/js/quickjs/runtime.rb', line 29

def install_window(win)
  @window = win
  define_host_object("window", win)
  @bridge.window = win
  @backend.eval(<<~JS)
    globalThis.setTimeout = (fn, delay) => window.setTimeout(fn, delay);
    globalThis.clearTimeout = (id) => window.clearTimeout(id);
    globalThis.setInterval = (fn, delay) => window.setInterval(fn, delay);
    globalThis.clearInterval = (id) => window.clearInterval(id);
    globalThis.requestAnimationFrame = (fn) => window.requestAnimationFrame(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

#on_log(&block) ⇒ Object

Observe console.* output (see Backend).



125
126
127
128
# File 'lib/dommy/js/quickjs/runtime.rb', line 125

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

#on_unhandled_rejection(&block) ⇒ Object

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



119
120
121
122
# File 'lib/dommy/js/quickjs/runtime.rb', line 119

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

#registered_countObject

Live handle count (introspection for lifetime tests).



194
195
196
# File 'lib/dommy/js/quickjs/runtime.rb', line 194

def registered_count
  @bridge.registered_count
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).



103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/dommy/js/quickjs/runtime.rb', line 103

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

#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.



91
92
93
# File 'lib/dommy/js/quickjs/runtime.rb', line 91

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