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
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|