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.
@@ -1,68 +1,164 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "capybara/dommy"
4
+ require "dommy/rack"
4
5
  require_relative "../quickjs"
5
6
 
6
7
  module Dommy
7
8
  module Js
8
9
  module Quickjs
10
+ # evaluate_async_script's callback was not called within the wait budget.
11
+ class ScriptTimeoutError < Error; end
12
+
9
13
  # Opt-in Capybara integration. Requiring this file enables JS execution on
10
14
  # Capybara::Dommy::Driver (via install_capybara! below), so execute_script /
11
15
  # evaluate_script run against the current Dommy document through a QuickJS
12
16
  # Runtime. Without this require, capybara-dommy stays JS-free (its default).
17
+ #
18
+ # The realm-per-document machinery lives in SessionRuntime (shared with
19
+ # Dommy::Rack::Session's `javascript: true`); the driver wires it to the
20
+ # session and the Capybara polling loop, and wraps results as Capybara
21
+ # nodes.
13
22
  module CapybaraDriver
14
- def execute_script(script, *_args)
15
- dommy_js_runtime.execute(script)
23
+ # The in-realm slot an async script's callback reports to. Each call
24
+ # installs a fresh state object, so a late callback from an earlier call
25
+ # writes into its own, now-orphaned one.
26
+ ASYNC_SCRIPT_STATE = "__rbAsyncScriptState"
27
+
28
+ def rack_session
29
+ session = super
30
+ dommy_js_attach(session)
31
+ session
32
+ end
33
+
34
+ def execute_script(script, *args)
35
+ host = dommy_js_host
36
+ if args.empty?
37
+ host.execute(script)
38
+ else
39
+ host.execute_with_args(script, capybara_script_args(args))
40
+ end
16
41
  nil
17
42
  end
18
43
 
19
- def evaluate_script(script, *_args)
20
- decode_for_capybara(dommy_js_runtime.evaluate(script))
44
+ def evaluate_script(script, *args)
45
+ host = dommy_js_host
46
+ value = args.empty? ? host.evaluate(script) : host.evaluate_with_args(script, capybara_script_args(args))
47
+ decode_for_capybara(value)
21
48
  end
22
49
 
23
- # No real async loop; evaluate synchronously. Sufficient for scripts
24
- # that resolve immediately (the common Capybara case).
50
+ # WebDriver's executeAsyncScript: the script gets a callback as its last
51
+ # argument, and its result is whatever that callback is called with.
52
+ # Virtual time is pumped until it is, for at most
53
+ # Capybara.default_max_wait_time of VIRTUAL time — so a timer the script
54
+ # waits on fires, while the budget stays deterministic.
25
55
  def evaluate_async_script(script, *args)
26
- evaluate_script(script, *args)
56
+ host = dommy_js_host
57
+ host.execute_with_args(<<~JS, capybara_script_args(args))
58
+ var state = { settled: false, value: undefined };
59
+ globalThis.#{ASYNC_SCRIPT_STATE} = state;
60
+ var args = Array.prototype.slice.call(arguments);
61
+ args.push(function (value) {
62
+ if (state.settled) return;
63
+ state.settled = true;
64
+ state.value = value;
65
+ });
66
+ (function () {
67
+ #{script}
68
+ }).apply(this, args);
69
+ JS
70
+ await_async_script(host)
71
+ decode_for_capybara(host.evaluate("globalThis.#{ASYNC_SCRIPT_STATE}.value"))
27
72
  end
28
73
 
29
74
  private
30
75
 
31
- # One Runtime per document. Rebuilt when navigation swaps the document
32
- # so JS always sees the current page (and the old VM is released).
33
- def dommy_js_runtime
34
- doc = document
35
- unless defined?(@dommy_js_doc) && @dommy_js_doc.equal?(doc)
36
- @dommy_js_runtime&.dispose
37
- @dommy_js_runtime = Runtime.new
38
- @dommy_js_runtime.define_host_object("document", doc)
39
- view = doc.default_view
40
- @dommy_js_runtime.install_window(view) if view
41
- @dommy_js_doc = doc
76
+ def await_async_script(host)
77
+ budget_ms = ::Capybara.default_max_wait_time * 1000
78
+ pumps = (budget_ms / ::Dommy::Rack::SessionRuntime::PUMP_SLICE_MS).ceil
79
+ settled = "(globalThis.#{ASYNC_SCRIPT_STATE} || {}).settled === true"
80
+ pumps.times do
81
+ return if host.evaluate(settled)
82
+
83
+ host.pump
84
+ end
85
+ return if host.evaluate(settled)
86
+
87
+ raise ScriptTimeoutError,
88
+ "evaluate_async_script did not call its callback within #{budget_ms.round}ms of virtual time"
89
+ end
90
+
91
+ # A Capybara node argument becomes the Dommy element it wraps (so it
92
+ # crosses to JS as a proxy), at any depth inside arrays and hashes.
93
+ # Capybara::Session unwraps only top-level elements, so a nested
94
+ # Capybara::Node::Element is unwrapped here too.
95
+ def capybara_script_args(args)
96
+ args.map { |arg| capybara_script_arg(arg) }
97
+ end
98
+
99
+ def capybara_script_arg(arg)
100
+ case arg
101
+ when ::Capybara::Node::Element then capybara_script_arg(arg.base)
102
+ when ::Capybara::Dommy::Node then arg.native
103
+ when Array then capybara_script_args(arg)
104
+ when Hash then arg.transform_values { |value| capybara_script_arg(value) }
105
+ else arg
42
106
  end
43
- @dommy_js_runtime
107
+ end
108
+
109
+ # Bind a SessionRuntime to the session (rebuilt when reset!/app_host
110
+ # swaps the session). The driver's frame-aware `document` is the realm
111
+ # target, and Capybara's retry loop pumps virtual time via time_pump.
112
+ def dommy_js_attach(session)
113
+ return if defined?(@dommy_js_session) && @dommy_js_session.equal?(session)
114
+
115
+ @dommy_js_session = session
116
+ @dommy_js_host = ::Dommy::Rack::SessionRuntime.new(session) { document }
117
+ self.time_pump = -> { @dommy_js_host.pump }
118
+ end
119
+
120
+ # The JS host bound to the CURRENT session. Reading `rack_session` is what
121
+ # (re)binds one, because the attach lives in that override — so this is a
122
+ # fetch with a deliberate side effect, not a plain reader. Capybara swaps
123
+ # the session on reset! / app_host, and the host has to follow.
124
+ def dommy_js_host
125
+ rack_session
126
+ @dommy_js_host
44
127
  end
45
128
 
46
129
  # Map an evaluate() result to what Capybara expects:
47
130
  # - Array -> recurse (so element collections wrap per item)
48
131
  # - JS undefined -> nil
49
132
  # - Dommy::Element -> Capybara::Dommy::Node (covers HTML/SVG subclasses)
50
- # - other bridge obj -> nil (Document/Text/Comment/Fragment/NodeList/
51
- # Window have no Capybara representation; a browser
52
- # likewise returns non-serializable values as null)
133
+ # - other host object -> nil (Document/Text/Comment/Fragment/NodeList/
134
+ # Window/Blob/FormData have no Capybara
135
+ # representation)
53
136
  # - primitive/Hash -> as-is
54
137
  def decode_for_capybara(value)
138
+ return nil if js_undefined?(value)
139
+
55
140
  case value
56
- when Array
57
- value.map { |element| decode_for_capybara(element) }
58
- when ::Quickjs::Value::UNDEFINED
59
- nil
60
- when ::Dommy::Element
61
- ::Capybara::Dommy::Node.new(self, value)
62
- else
63
- value.respond_to?(:__js_get__) ? nil : value
141
+ when Array then value.map { |element| decode_for_capybara(element) }
142
+ when ::Dommy::Element then ::Capybara::Dommy::Node.new(self, value)
143
+ else host_object?(value) ? nil : value
64
144
  end
65
145
  end
146
+
147
+ # JS `undefined` arrives as either the engine-neutral sentinel the bridge
148
+ # decodes to, or — on the paths that skip that decode — the gem's own.
149
+ def js_undefined?(value)
150
+ value.equal?(::Dommy::Bridge::UNDEFINED) || value.equal?(::Quickjs::Value::UNDEFINED)
151
+ end
152
+
153
+ # Whether `value` is a Dommy object the page can read properties off
154
+ # (`__js_get__` is what the bridge calls to do that). Everything that
155
+ # answers to it is a DOM-side object, and the ones Capybara can represent
156
+ # were already taken by the branches above — so what is left has no
157
+ # representation and becomes nil rather than leaking a host object into a
158
+ # Capybara assertion.
159
+ def host_object?(value)
160
+ value.respond_to?(:__js_get__)
161
+ end
66
162
  end
67
163
 
68
164
  # Idempotently prepend JS-execution support onto Capybara::Dommy::Driver.
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Js
5
+ module Quickjs
6
+ # Every environment variable this backend reads, in one place. They used to
7
+ # be read where they were needed — three files, three layers — so the only
8
+ # way to learn which knobs exist was to grep for `ENV`.
9
+ #
10
+ # Read at the point of use rather than memoized: a test (and dommynx)
11
+ # changes these between runtimes in the same process.
12
+ module Config
13
+ # The gem's default eval timeout is 100ms, which interrupts large
14
+ # synchronous bridge loops (every property crossing is a Ruby call).
15
+ DEFAULT_TIMEOUT_MSEC = 60_000
16
+
17
+ # The gem's default memory ceiling is 128 MB. A real-site SPA (note.com's
18
+ # Apollo/React bundle, hydration, the whole DOM mirrored as host proxies)
19
+ # blows past that and the VM hits out-of-memory, which poisons it. Give a
20
+ # browser-grade VM more headroom so heavy pages actually finish
21
+ # rendering; the OOM is also survivable (see Backend#poisoned?).
22
+ DEFAULT_MEMORY_LIMIT = 512 * 1024 * 1024
23
+
24
+ module_function
25
+
26
+ # The per-eval timeout, in ms. A single JS eval — a script, or one
27
+ # timer/rAF callback — is force-aborted (Quickjs::InterruptedError,
28
+ # caught and logged) after this long. It is ALSO the ceiling on how long
29
+ # QuickJS holds the thread in C: while it runs, a Ctrl-C (delivered as a
30
+ # deferred SIGINT) can't be serviced, so an interactive host (dommynx)
31
+ # sets a lower value so a heavy/runaway burst can't freeze the UI for the
32
+ # full library default.
33
+ def timeout_msec
34
+ env = ENV["DOMMY_JS_TIMEOUT_MSEC"].to_i
35
+ env.positive? ? env : DEFAULT_TIMEOUT_MSEC
36
+ end
37
+
38
+ # The VM's memory ceiling, in bytes. Overridable like the timeout, in MB
39
+ # for legibility: an out-of-memory poisons the VM and stops the page's
40
+ # JavaScript for good, so being able to lower it is how that path gets
41
+ # exercised deliberately (and to raise it for a heavier page than the
42
+ # default was sized for).
43
+ def memory_limit
44
+ mb = ENV["DOMMY_JS_MEMORY_LIMIT_MB"].to_i
45
+ mb.positive? ? mb * 1024 * 1024 : DEFAULT_MEMORY_LIMIT
46
+ end
47
+
48
+ # Whether to keep the gem's per-crossing Ruby `Timeout.timeout` (see
49
+ # Backend's SkipCrossingTimeout). Off by default: it costs ~4us on every
50
+ # DOM crossing, and QuickJS's own interrupt handler already bounds a
51
+ # runaway execution.
52
+ def crossing_timeout? = !ENV["DOMMY_JS_CROSSING_TIMEOUT"].to_s.empty?
53
+
54
+ # Whether to record the JS-side detail of a promise rejection, so a
55
+ # reason the engine could only stringify to "[object Object]" can still
56
+ # be diagnosed (see ErrorTranslator#enrich_rejection). Opt-in: it makes
57
+ # every rejection cross the bridge twice.
58
+ def track_rejections? = !ENV["DOMMY_JS_DEBUG_REJECTIONS"].to_s.empty?
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Js
5
+ module Quickjs
6
+ # Turns what the ENGINE raised into what the page (or the host) should see.
7
+ #
8
+ # QuickJS converts a JS throw into a Ruby exception before the host hears
9
+ # about it, which loses the thrown object, and a rejection reason the
10
+ # engine could only stringify arrives as "[object Object]". Each of those
11
+ # needs a different repair, and none of them is about running scripts or
12
+ # driving the loop, so they live here rather than in Runtime.
13
+ class ErrorTranslator
14
+ # An engine frame in a converted exception's backtrace (`at f
15
+ # (<code>:1:34)`). A host-raised one carries Ruby frames instead
16
+ # (`/…/lib/dommy/js/quickjs/backend.rb:82:in '…'`).
17
+ #
18
+ # Core has a stricter counterpart (Internal::ExceptionReport::STACK_FRAME)
19
+ # that PARSES a frame into file/line/column for ErrorEvent. This one only
20
+ # asks whether a frame came from JS at all, and stays here because the
21
+ # answer is engine-specific: what a frame looks like is QuickJS's
22
+ # business, and another backend's would differ.
23
+ JS_FRAME = /\A\s*at\s/
24
+ private_constant :JS_FRAME
25
+
26
+ def initialize(backend, bridge)
27
+ @backend = backend
28
+ @bridge = bridge
29
+ end
30
+
31
+ # Rebuild a script's thrown value as a real Error inside the realm.
32
+ #
33
+ # QuickJS raises a HOST exception when an evaluated script throws, so by
34
+ # the time we see it the JS value is gone: its message and frames survive
35
+ # as a Ruby exception, the object itself does not. Handing that husk to
36
+ # the page gives a handler an object with no `message` and no `stack`,
37
+ # which is worse than useless — reading either throws, so the handler
38
+ # dies before it can cancel the report.
39
+ #
40
+ # An equivalent Error is built in the realm instead, carrying the same
41
+ # name, message and frames. Everything a handler observes matches what it
42
+ # threw; what it cannot do is compare identity (`e.error === thrown`),
43
+ # since the original was freed before we were told about it. Preserving
44
+ # that needs the engine to hand the value over instead of converting it.
45
+ #
46
+ # Returns nil for anything that did not come from JS (a host bug) and for
47
+ # any failure to rebuild, so the caller falls back to what it caught.
48
+ def rebuild_error(error)
49
+ return nil unless error.is_a?(::Quickjs::RuntimeError)
50
+
51
+ # Deliberately NOT through an evaluate that drives the event loop: this
52
+ # runs while the page is still booting its scripts, and a due-now timer
53
+ # from an earlier script would fire before the next `<script>`, which
54
+ # no browser does. Calling the realm's helper (see js/error_rebuild.js)
55
+ # only builds the object and marshals it.
56
+ @bridge.decode(
57
+ @backend.call_js("__rbDommyRebuildError",
58
+ js_error_name(error), error.message.to_s, js_frames(error))
59
+ )
60
+ rescue ::StandardError
61
+ nil
62
+ end
63
+
64
+ # The JS half of a converted exception's backtrace, as the page's own
65
+ # `stack` string.
66
+ #
67
+ # Handing the host frames to the page would publish this gem's file paths
68
+ # to anything that reads `error.stack` — a page can log or upload it.
69
+ # Keep the JS frames, drop the rest, and let an all-host backtrace
70
+ # produce an empty stack rather than a plausible-looking lie about where
71
+ # the page failed.
72
+ def js_frames(error)
73
+ Array(error.backtrace).grep(JS_FRAME).join("\n")
74
+ end
75
+
76
+ # In rejection-debug mode, replace the engine's detail-less "[object
77
+ # Object]" message (a non-Error reason the engine could only toString)
78
+ # with the rich detail recorded JS-side at rejection time, paired by
79
+ # recency. A no-op for errors that already carry a real message.
80
+ def enrich_rejection(error)
81
+ return error unless error.respond_to?(:message) && error.message.to_s.strip == "[object Object]"
82
+
83
+ detail = @bridge.take_rejection_detail
84
+ return error if detail.nil? || detail.to_s.empty?
85
+
86
+ enriched = ::Quickjs::RuntimeError.new(detail.to_s, "UnhandledRejection")
87
+ enriched.set_backtrace(error.backtrace) if error.backtrace
88
+ enriched
89
+ rescue ::StandardError
90
+ error
91
+ end
92
+
93
+ # Attach the timer's scheduling stack (where the page set the timer up)
94
+ # to the error, so a callback that throws a stackless value — a bare
95
+ # `null`, common in minified bundles — is still traceable to the code
96
+ # that scheduled it. The error's class (and message, the dedup key) is
97
+ # kept; only its backtrace is replaced with the JS frames, which the
98
+ # diagnostics UI reads in place of the host scheduler internals. A no-op
99
+ # when no origin was captured (a timer not created through the
100
+ # instrumented globals, or a unit test driving the scheduler directly).
101
+ def with_timer_origin(error, timer)
102
+ frames = timer_origin(timer)
103
+ error.set_backtrace(frames) unless frames.empty?
104
+ error
105
+ rescue ::StandardError
106
+ error
107
+ end
108
+
109
+ private
110
+
111
+ # The JS constructor name behind a converted exception. quickjs.rb maps
112
+ # each standard error to its own subclass (`Quickjs::TypeError` for a JS
113
+ # `TypeError`), and uses the generic `RuntimeError` for a plain `Error`
114
+ # and for a thrown non-Error.
115
+ def js_error_name(error)
116
+ name = error.class.name.to_s.split("::").last.to_s
117
+ name == "RuntimeError" ? "Error" : name
118
+ end
119
+
120
+ # The scheduling stack for `timer`, as cleaned frame strings (or []). The
121
+ # origin Error lives JS-side (see js/timer_globals.js) until now;
122
+ # fetching it also clears it.
123
+ def timer_origin(timer)
124
+ return [] unless timer.respond_to?(:id)
125
+
126
+ stack = @backend.call_js("__rbFetchTimerOrigin", timer.id).to_s
127
+ # Drop the shim's own frame (the `new Error()` in __rbDefer) so the top
128
+ # frame is the page code that called setTimeout/setInterval.
129
+ stack.split("\n").map(&:strip).reject(&:empty?)
130
+ .reject { |line| line.include?("__rbDefer") }
131
+ rescue ::StandardError
132
+ []
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Js
5
+ module Quickjs
6
+ # The WHATWG event loop over two queues that live in different places: the
7
+ # engine's promise-job queue (drained through the Backend) and Dommy's
8
+ # deterministic scheduler (the window's, driven by virtual time).
9
+ #
10
+ # The three entry points differ ONLY in how far they are willing to move
11
+ # the clock, so they share one pump and supply that policy as a block:
12
+ #
13
+ # #drive_due_now not at all — whatever is due at this instant
14
+ # #settle to the next animation frame, but not to a later timer
15
+ # #run_until_idle to the next due timer, repeatedly, until none is left
16
+ #
17
+ # Also the place where a dead VM stops the loop: an out-of-memory poisons
18
+ # the VM, and per the "browsing never crashes" contract the failure is
19
+ # reported once and then swallowed, rather than escaping into a host that
20
+ # is only asking for microtasks to be drained.
21
+ class EventLoop
22
+ # How many due-now turns #drive_due_now takes before giving up. The
23
+ # nested-timer 4ms clamp guarantees due-now work drains in finite turns.
24
+ DUE_NOW_TURNS = 64
25
+
26
+ # Bounds a runaway timer loop (a self-rescheduling setInterval, an rAF
27
+ # that always asks for another frame).
28
+ DEFAULT_MAX_ITERATIONS = 1000
29
+
30
+ # `scheduler` is asked for Dommy's scheduler each time the loop runs,
31
+ # rather than handed one at construction: a Runtime is built before its
32
+ # window exists, and the window is the scheduler's owner. Keeping it a
33
+ # question avoids a second copy of the window living here.
34
+ #
35
+ # `on_halt` is called with the exception the first time the VM is found
36
+ # poisoned, and never again.
37
+ def initialize(backend, scheduler:, &on_halt)
38
+ @backend = backend
39
+ @scheduler = scheduler
40
+ @on_halt = on_halt
41
+ @halted = false
42
+ end
43
+
44
+ # Drain the engine's microtask queue. The one operation every policy
45
+ # starts from, and the one a host can ask for on its own.
46
+ def drain_microtasks
47
+ @backend.drain_microtasks
48
+ rescue ::Quickjs::RuntimeError => e
49
+ # The checkpoint hit out-of-memory and poisoned the VM. Record it once
50
+ # (so it shows in js_errors / the activity log) and then no-op — the
51
+ # page's JS is dead, but the browser stays alive.
52
+ raise unless @backend.poisoned?
53
+
54
+ note_halted(e)
55
+ end
56
+
57
+ # Everything ready at the current virtual time: the microtask checkpoint
58
+ # plus every due task and anything it queues at the same instant, WITHOUT
59
+ # advancing the clock to a future timer (a result waiting on a real delay
60
+ # is left for the caller's await to surface).
61
+ def drive_due_now
62
+ pump(DUE_NOW_TURNS) do |scheduler|
63
+ scheduler.advance_time(0)
64
+ scheduler.next_due_timer_at == scheduler.now_ms
65
+ end
66
+ nil
67
+ end
68
+
69
+ # The work that is READY now: microtasks, timers already due
70
+ # (`setTimeout(0)` chains), and pending `requestAnimationFrame`
71
+ # callbacks, reached by advancing to their frame boundary. Does NOT jump
72
+ # the clock to a not-yet-due `setTimeout(300)` — that needs an explicit
73
+ # `advance_time(300)`. The "let promises and animation frames resolve"
74
+ # entry point.
75
+ def settle(max_iterations: DEFAULT_MAX_ITERATIONS)
76
+ pump(max_iterations) do |scheduler|
77
+ before = scheduler.now_ms
78
+ scheduler.advance_time(0) # due-now timers + microtasks, no clock jump
79
+ # Not redundant with the pump's own drains: a timer that just ran may
80
+ # have queued the microtask that schedules the next animation frame,
81
+ # and the check below has to see it.
82
+ drain_microtasks
83
+
84
+ frame_at = scheduler.next_animation_frame_at
85
+ if frame_at && frame_at > scheduler.now_ms
86
+ scheduler.advance_time(frame_at - scheduler.now_ms) # advance to the frame, run rAF
87
+ next true
88
+ end
89
+
90
+ scheduler.now_ms != before # quiescent once the clock stops moving
91
+ end
92
+ end
93
+
94
+ # Quiescence: drain, advance to the next due timer, drain again, until no
95
+ # timer is pending. The deterministic "settle everything" a host uses
96
+ # after an eval — every queued microtask runs and every scheduled timer
97
+ # fires, in WHATWG order (microtasks before each timer).
98
+ def run_until_idle(max_iterations: DEFAULT_MAX_ITERATIONS)
99
+ pump(max_iterations) do |scheduler|
100
+ next_at = scheduler.next_due_timer_at
101
+ next false unless next_at
102
+
103
+ scheduler.advance_time(next_at - scheduler.now_ms)
104
+ true
105
+ end
106
+ end
107
+
108
+ # Record a halt if the VM is in fact dead, and say whether it was. The
109
+ # caller (a timer callback that raised) does not have to ask the backend
110
+ # itself: an out-of-memory there poisons the whole VM, not just that one
111
+ # callback.
112
+ def note_halted_if_poisoned(error)
113
+ return false unless @backend.poisoned?
114
+
115
+ note_halted(error)
116
+ true
117
+ end
118
+
119
+ private
120
+
121
+ # The VM is poisoned. Surface the failure ONCE (a repeated drain would
122
+ # otherwise report it every tick) so the host sees that the page's
123
+ # JavaScript stopped, then leave it to the no-op guards.
124
+ def note_halted(error)
125
+ return if @halted
126
+
127
+ @halted = true
128
+ @on_halt&.call(error)
129
+ nil
130
+ end
131
+
132
+ # Drain, then let the policy move the clock; stop when it reports there
133
+ # is nothing left to do, when there is no scheduler to drive, or when the
134
+ # iteration bound runs out.
135
+ def pump(max_iterations)
136
+ scheduler = @scheduler.call
137
+ max_iterations.times do
138
+ drain_microtasks
139
+ break unless scheduler
140
+ break unless yield(scheduler)
141
+
142
+ drain_microtasks
143
+ end
144
+ self
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,36 @@
1
+ // The bare browser globals frameworks reach for, aliased onto the installed
2
+ // window. This is what lets real frontend bundles (Turbo, …) run unmodified.
3
+ globalThis.self = globalThis;
4
+ // Top-level window: parent/top are the window itself (spec), so frame-walking
5
+ // loops terminate instead of dereferencing undefined.
6
+ globalThis.parent = globalThis;
7
+ globalThis.top = globalThis;
8
+ // `frames` is the window itself, indexable by child-frame number
9
+ // (`frames[0]` === the first <iframe>'s contentWindow).
10
+ globalThis.frames = window;
11
+ globalThis.location = window.location;
12
+ globalThis.history = window.history;
13
+ globalThis.navigator = window.navigator;
14
+ globalThis.sessionStorage = window.sessionStorage;
15
+ globalThis.localStorage = window.localStorage;
16
+ globalThis.CSS = window.CSS;
17
+ globalThis.getComputedStyle = (...args) => window.getComputedStyle(...args);
18
+ globalThis.matchMedia = (...args) => window.matchMedia(...args);
19
+ globalThis.fetch = (...args) => window.fetch(...args);
20
+ globalThis.addEventListener = (...args) => window.addEventListener(...args);
21
+ globalThis.removeEventListener = (...args) => window.removeEventListener(...args);
22
+ globalThis.dispatchEvent = (event) => window.dispatchEvent(event);
23
+
24
+ // More bare globals frameworks read directly (e.g. performance.now(), crypto,
25
+ // screen). Objects/values are aliased by reference; methods are wrapped so
26
+ // `this` binds to the window. All already exist on window.
27
+ for (const __n of ["performance", "crypto", "screen", "visualViewport",
28
+ "indexedDB", "caches", "devicePixelRatio",
29
+ "innerWidth", "innerHeight", "scrollX", "scrollY", "pageXOffset"]) {
30
+ try { globalThis[__n] = window[__n]; } catch (__e) {}
31
+ }
32
+ for (const __m of ["scrollTo", "scrollBy", "requestIdleCallback", "cancelIdleCallback",
33
+ "getSelection", "structuredClone", "reportError", "btoa", "atob",
34
+ "alert", "confirm", "prompt", "open", "postMessage"]) {
35
+ try { globalThis[__m] = (...args) => window[__m](...args); } catch (__e) {}
36
+ }
@@ -0,0 +1,22 @@
1
+ // Rebuild a thrown value as a real Error inside the realm.
2
+ //
3
+ // QuickJS raises a HOST exception when an evaluated script throws, so by the
4
+ // time Ruby sees it the JS value is gone: its name, message and frames survive
5
+ // as a Ruby exception, the object itself does not. The page is handed an
6
+ // equivalent built here instead, so a handler reading `.message` / `.stack`
7
+ // gets what it threw rather than an object with no properties.
8
+ //
9
+ // `name` is the constructor to use (`TypeError`, …), falling back to `Error`
10
+ // for anything the realm does not define. `stack` is already filtered to the
11
+ // frames that came from JS — the host's own are not the page's business.
12
+ //
13
+ // The result is tagged for the bridge here rather than at the call site, so
14
+ // crossing back to Ruby carries the object rather than flattening it.
15
+ globalThis.__rbDommyRebuildError = function (name, message, stack) {
16
+ var Ctor = globalThis[name];
17
+ var error = new (typeof Ctor === "function" ? Ctor : Error)(message);
18
+ if (stack) {
19
+ try { error.stack = stack; } catch (_) {}
20
+ }
21
+ return __rbHost.tag(error);
22
+ };
@@ -0,0 +1,56 @@
1
+ // This QuickJS build ships without ICU, so `Intl` is undefined and any page
2
+ // touching `Intl.NumberFormat` / `DateTimeFormat` / … throws `'Intl' is not
3
+ // defined` (nuxt.com, i18n libraries, …). Install a small locale-naive
4
+ // polyfill: it formats reasonably (grouped numbers, ISO-ish dates) so pages run
5
+ // instead of crashing, without pulling in full ICU.
6
+ if (typeof globalThis.Intl === "undefined") {
7
+ var I = {};
8
+ var group = function (s) {
9
+ var p = String(s).split(".");
10
+ p[0] = p[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
11
+ return p.join(".");
12
+ };
13
+ function NumberFormat(l, o) { this.o = o || {}; }
14
+ NumberFormat.prototype.format = function (n) {
15
+ n = Number(n); var o = this.o;
16
+ if (o.style === "percent") n *= 100;
17
+ var max = o.maximumFractionDigits;
18
+ if (max == null && o.style === "currency") max = 2;
19
+ var s = group(max == null ? String(n) : n.toFixed(max));
20
+ if (o.style === "percent") s += "%";
21
+ if (o.style === "currency" && o.currency) s = o.currency + " " + s;
22
+ return s;
23
+ };
24
+ NumberFormat.prototype.formatToParts = function (n) { return [{ type: "literal", value: this.format(n) }]; };
25
+ NumberFormat.prototype.resolvedOptions = function () { return Object.assign({ locale: "en", numberingSystem: "latn", style: "decimal" }, this.o); };
26
+ function DateTimeFormat(l, o) { this.o = o || {}; }
27
+ DateTimeFormat.prototype.format = function (d) {
28
+ d = d == null ? new Date() : new Date(d);
29
+ if (isNaN(d.getTime())) return "";
30
+ try { return d.toLocaleString(); } catch (e) { return d.toString(); }
31
+ };
32
+ DateTimeFormat.prototype.formatToParts = function (d) { return [{ type: "literal", value: this.format(d) }]; };
33
+ DateTimeFormat.prototype.formatRange = function (a, b) { return this.format(a) + " – " + this.format(b); };
34
+ DateTimeFormat.prototype.resolvedOptions = function () { return Object.assign({ locale: "en", calendar: "gregory", numberingSystem: "latn", timeZone: "UTC" }, this.o); };
35
+ function Collator(l, o) { this.o = o || {}; }
36
+ Collator.prototype.compare = function (a, b) { a = String(a); b = String(b); return a < b ? -1 : a > b ? 1 : 0; };
37
+ Collator.prototype.resolvedOptions = function () { return Object.assign({ locale: "en" }, this.o); };
38
+ function PluralRules(l, o) { this.o = o || {}; }
39
+ PluralRules.prototype.select = function (n) { return Number(n) === 1 ? "one" : "other"; };
40
+ PluralRules.prototype.resolvedOptions = function () { return Object.assign({ locale: "en", type: "cardinal" }, this.o); };
41
+ function RelativeTimeFormat(l, o) { this.o = o || {}; }
42
+ RelativeTimeFormat.prototype.format = function (v, u) { return v + " " + u + (Math.abs(v) === 1 ? "" : "s"); };
43
+ RelativeTimeFormat.prototype.formatToParts = function (v, u) { return [{ type: "literal", value: this.format(v, u) }]; };
44
+ RelativeTimeFormat.prototype.resolvedOptions = function () { return Object.assign({ locale: "en", numeric: "always", style: "long" }, this.o); };
45
+ function ListFormat(l, o) { this.o = o || {}; }
46
+ ListFormat.prototype.format = function (a) { return Array.from(a || []).join(", "); };
47
+ ListFormat.prototype.formatToParts = function (a) { return [{ type: "element", value: this.format(a) }]; };
48
+ ListFormat.prototype.resolvedOptions = function () { return Object.assign({ locale: "en", type: "conjunction", style: "long" }, this.o); };
49
+ I.NumberFormat = NumberFormat; I.DateTimeFormat = DateTimeFormat; I.Collator = Collator;
50
+ I.PluralRules = PluralRules; I.RelativeTimeFormat = RelativeTimeFormat; I.ListFormat = ListFormat;
51
+ ["NumberFormat", "DateTimeFormat", "Collator", "PluralRules", "RelativeTimeFormat", "ListFormat"].forEach(function (k) {
52
+ I[k].supportedLocalesOf = function (locs) { return Array.isArray(locs) ? locs.slice() : locs ? [locs] : []; };
53
+ });
54
+ I.getCanonicalLocales = function (locs) { return Array.isArray(locs) ? locs.slice() : locs ? [String(locs)] : []; };
55
+ globalThis.Intl = I;
56
+ }