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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -1
- data/README.md +38 -9
- data/lib/dommy/js/quickjs/backend.rb +161 -17
- data/lib/dommy/js/quickjs/browser_environment.rb +93 -0
- data/lib/dommy/js/quickjs/capybara.rb +126 -30
- 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/rack.rb +15 -0
- data/lib/dommy/js/quickjs/runtime.rb +331 -122
- data/lib/dommy/js/quickjs/script_cache.rb +40 -0
- data/lib/dommy/js/quickjs/version.rb +1 -1
- data/lib/dommy/js/quickjs/wasm_bridge.rb +21 -15
- data/lib/dommy/js/quickjs.rb +17 -5
- metadata +27 -19
- data/Rakefile +0 -49
- data/docs/bridge-redesign.md +0 -559
- data/docs/wpt-conformance.md +0 -752
- data/lib/dommy/js/constructor_registry.rb +0 -40
- data/lib/dommy/js/custom_elements.rb +0 -55
- data/lib/dommy/js/dom_interfaces.rb +0 -139
- data/lib/dommy/js/handle_table.rb +0 -52
- data/lib/dommy/js/host_bridge.rb +0 -400
- data/lib/dommy/js/host_runtime.js +0 -922
- data/lib/dommy/js/observable_runtime.js +0 -728
|
@@ -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
|
-
#
|
|
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
|
|
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
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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;`)
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
#
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
def
|
|
92
|
-
|
|
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
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
# `
|
|
102
|
-
#
|
|
103
|
-
def
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
131
|
-
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
|
199
|
-
@
|
|
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
|