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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +7 -0
- data/lib/dommy/js/quickjs/backend.rb +100 -93
- data/lib/dommy/js/quickjs/browser_environment.rb +93 -0
- data/lib/dommy/js/quickjs/capybara.rb +101 -19
- data/lib/dommy/js/quickjs/config.rb +62 -0
- data/lib/dommy/js/quickjs/error_translator.rb +137 -0
- data/lib/dommy/js/quickjs/event_loop.rb +149 -0
- data/lib/dommy/js/quickjs/js/browser_globals.js +36 -0
- data/lib/dommy/js/quickjs/js/error_rebuild.js +22 -0
- data/lib/dommy/js/quickjs/js/intl_polyfill.js +56 -0
- data/lib/dommy/js/quickjs/js/timer_globals.js +46 -0
- data/lib/dommy/js/quickjs/js/wasm_stub.js +26 -0
- data/lib/dommy/js/quickjs/js/window_builtins.js +14 -0
- data/lib/dommy/js/quickjs/runtime.rb +243 -442
- data/lib/dommy/js/quickjs/script_cache.rb +4 -1
- data/lib/dommy/js/quickjs/version.rb +1 -1
- data/lib/dommy/js/quickjs/wasm_bridge.rb +14 -8
- data/lib/dommy/js/quickjs.rb +5 -1
- metadata +22 -13
- data/.github/workflows/test.yml +0 -28
- data/Rakefile +0 -117
- data/docs/bridge-redesign.md +0 -559
- data/docs/wpt-conformance.md +0 -752
- data/lib/dommy/js/quickjs/source_guard.rb +0 -304
- data/script/build_jsx_transform.sh +0 -27
- data/script/build_stimulus_tests.sh +0 -52
|
@@ -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
|
-
#
|
|
13
|
-
#
|
|
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
|
-
@
|
|
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
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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.
|
|
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;`)
|
|
169
|
-
#
|
|
170
|
-
#
|
|
171
|
-
#
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
#
|
|
180
|
-
#
|
|
181
|
-
#
|
|
182
|
-
#
|
|
183
|
-
#
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
#
|
|
196
|
-
# the
|
|
197
|
-
#
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
#
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
351
|
-
#
|
|
352
|
-
#
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
509
|
-
@callback_error_listener&.call(
|
|
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
|
-
#
|
|
514
|
-
#
|
|
515
|
-
#
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
520
|
-
|
|
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
|
-
#
|
|
525
|
-
#
|
|
526
|
-
#
|
|
527
|
-
#
|
|
528
|
-
#
|
|
529
|
-
#
|
|
530
|
-
#
|
|
531
|
-
#
|
|
532
|
-
def
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|