dommy-js-quickjs 0.1.0 → 0.9.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/.github/workflows/test.yml +28 -0
- data/CHANGELOG.md +84 -1
- data/README.md +31 -9
- data/Rakefile +70 -2
- data/lib/dommy/js/quickjs/backend.rb +138 -1
- data/lib/dommy/js/quickjs/capybara.rb +31 -17
- data/lib/dommy/js/quickjs/rack.rb +15 -0
- data/lib/dommy/js/quickjs/runtime.rb +450 -42
- data/lib/dommy/js/quickjs/script_cache.rb +37 -0
- data/lib/dommy/js/quickjs/source_guard.rb +304 -0
- data/lib/dommy/js/quickjs/version.rb +1 -1
- data/lib/dommy/js/quickjs/wasm_bridge.rb +15 -15
- data/lib/dommy/js/quickjs.rb +13 -5
- data/script/build_jsx_transform.sh +27 -0
- data/script/build_stimulus_tests.sh +52 -0
- metadata +13 -14
- 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
|
@@ -15,6 +15,16 @@ module Dommy
|
|
|
15
15
|
def initialize(**vm_opts)
|
|
16
16
|
@backend = Backend.new(**vm_opts)
|
|
17
17
|
@bridge = Dommy::Js::HostBridge.new(@backend)
|
|
18
|
+
@callback_error_listener = nil
|
|
19
|
+
@js_halted = false
|
|
20
|
+
# Opt-in diagnostics: the engine stringifies a non-Error rejection reason
|
|
21
|
+
# to "[object Object]". Install a JS-side recorder so on_unhandled_rejection
|
|
22
|
+
# can surface the real cause (DOMMY_JS_DEBUG_REJECTIONS=1).
|
|
23
|
+
@track_rejections = !ENV["DOMMY_JS_DEBUG_REJECTIONS"].to_s.empty?
|
|
24
|
+
# Install the JS-side Promise rejection recorder (HostBridge registers
|
|
25
|
+
# the __rb_record_rejection_detail sink it pushes to). The detail then
|
|
26
|
+
# backfills the engine's detail-less report in #enrich_rejection.
|
|
27
|
+
@backend.call_js("__rbHost.installRejectionTracker") if @track_rejections
|
|
18
28
|
end
|
|
19
29
|
|
|
20
30
|
def define_host_object(name, obj)
|
|
@@ -30,12 +40,54 @@ module Dommy
|
|
|
30
40
|
@window = win
|
|
31
41
|
define_host_object("window", win)
|
|
32
42
|
@bridge.window = win
|
|
43
|
+
# A runaway timer/rAF callback (busy loop) is force-killed by the gem's
|
|
44
|
+
# eval timeout, surfacing as a Quickjs::InterruptedError out of the host
|
|
45
|
+
# call. Route it through the scheduler's error hook so it is recorded as
|
|
46
|
+
# a js_error and dropped, not propagated as a fatal crash (browsing must
|
|
47
|
+
# never crash). Genuine host bugs (any other error) still propagate.
|
|
48
|
+
if win.respond_to?(:scheduler) && win.scheduler
|
|
49
|
+
win.scheduler.timer_error_handler = method(:handle_timer_error)
|
|
50
|
+
# The other half of a WHATWG microtask checkpoint: the engine's
|
|
51
|
+
# promise-job queue. Wiring this lets the scheduler drain microtasks
|
|
52
|
+
# after EACH task (not once per batch of due timers), as the event
|
|
53
|
+
# loop processing model requires.
|
|
54
|
+
win.scheduler.microtask_checkpoint = method(:drain_microtasks)
|
|
55
|
+
end
|
|
33
56
|
@backend.eval(<<~JS)
|
|
34
|
-
|
|
57
|
+
// Remember where each timer was scheduled, so a throwing callback can
|
|
58
|
+
// be traced back to the code that set it up. This matters most for
|
|
59
|
+
// minified SPA bundles, where the thrown value is often a bare `null`
|
|
60
|
+
// with no stack of its own — the only locatable stack is the
|
|
61
|
+
// scheduling site. The origin Error is kept JS-side and only
|
|
62
|
+
// stringified if the callback actually throws (see
|
|
63
|
+
// __rbFetchTimerOrigin + handle_timer_error); a successful callback
|
|
64
|
+
// forgets its origin and the map is size-capped, so this stays cheap.
|
|
65
|
+
globalThis.__rbTimerOrigins = new Map();
|
|
66
|
+
const __rbDefer = (schedule, fn, delay) => {
|
|
67
|
+
if (typeof fn !== "function") return schedule(fn, delay);
|
|
68
|
+
const origin = new Error();
|
|
69
|
+
let id;
|
|
70
|
+
const wrapped = function () {
|
|
71
|
+
const result = fn.apply(this, arguments);
|
|
72
|
+
__rbTimerOrigins.delete(id); // ran cleanly — no need to keep it
|
|
73
|
+
return result;
|
|
74
|
+
};
|
|
75
|
+
id = schedule(wrapped, delay);
|
|
76
|
+
__rbTimerOrigins.set(id, origin);
|
|
77
|
+
if (__rbTimerOrigins.size > 4096) __rbTimerOrigins.delete(__rbTimerOrigins.keys().next().value);
|
|
78
|
+
return id;
|
|
79
|
+
};
|
|
80
|
+
globalThis.__rbFetchTimerOrigin = (id) => {
|
|
81
|
+
const origin = __rbTimerOrigins.get(id);
|
|
82
|
+
if (!origin) return "";
|
|
83
|
+
__rbTimerOrigins.delete(id);
|
|
84
|
+
return origin.stack || "";
|
|
85
|
+
};
|
|
86
|
+
globalThis.setTimeout = (fn, delay) => __rbDefer((f, d) => window.setTimeout(f, d), fn, delay);
|
|
35
87
|
globalThis.clearTimeout = (id) => window.clearTimeout(id);
|
|
36
|
-
globalThis.setInterval = (fn, delay) => window.setInterval(fn, delay);
|
|
88
|
+
globalThis.setInterval = (fn, delay) => __rbDefer((f, d) => window.setInterval(f, d), fn, delay);
|
|
37
89
|
globalThis.clearInterval = (id) => window.clearInterval(id);
|
|
38
|
-
globalThis.requestAnimationFrame = (fn) => window.requestAnimationFrame(fn);
|
|
90
|
+
globalThis.requestAnimationFrame = (fn) => __rbDefer((f) => window.requestAnimationFrame(f), fn);
|
|
39
91
|
globalThis.cancelAnimationFrame = (id) => window.cancelAnimationFrame(id);
|
|
40
92
|
// queueMicrotask must share the engine's promise-job (microtask)
|
|
41
93
|
// queue so its callbacks are FIFO-ordered with Promise reactions
|
|
@@ -67,6 +119,49 @@ module Dommy
|
|
|
67
119
|
nil
|
|
68
120
|
end
|
|
69
121
|
|
|
122
|
+
# Load a script the way a browser <script> does: in GLOBAL scope, so its
|
|
123
|
+
# top-level `var` / `function` / `let` declarations become globals. UMD /
|
|
124
|
+
# "global" bundles rely on this — e.g. Vue's global build is literally
|
|
125
|
+
# `var Vue = (function(){…})({})`, which an IIFE wrapper (execute) would
|
|
126
|
+
# trap in function scope. Drains microtasks afterward.
|
|
127
|
+
def load_script(js)
|
|
128
|
+
@backend.eval(js)
|
|
129
|
+
drain_microtasks
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Like #load_script, but compiles the source to bytecode once per
|
|
134
|
+
# `cache_key` (an external script's URL) and reuses it across VMs —
|
|
135
|
+
# avoiding a re-parse of large vendored bundles on every page load.
|
|
136
|
+
def load_script_cached(js, cache_key:)
|
|
137
|
+
@backend.run_compiled(ScriptCache.compiled(cache_key, js))
|
|
138
|
+
drain_microtasks
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Install the ESM module resolver (see Backend#module_loader=). A
|
|
143
|
+
# callable `(specifier, importer) -> source | {code:, as:} | nil`.
|
|
144
|
+
def module_loader=(callable)
|
|
145
|
+
@backend.module_loader = callable
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Evaluate an inline `<script type="module">` body as an ES module (run
|
|
149
|
+
# for side effects). Bare specifiers / absolute paths in its imports
|
|
150
|
+
# resolve through the module loader. Drains microtasks afterward.
|
|
151
|
+
def load_module(source)
|
|
152
|
+
@backend.import_module(source)
|
|
153
|
+
drain_microtasks
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Evaluate an external module by URL (the loader fetches it); its
|
|
158
|
+
# relative imports resolve against that URL. Drains microtasks.
|
|
159
|
+
def load_module_url(url)
|
|
160
|
+
@backend.import_module_url(url)
|
|
161
|
+
drain_microtasks
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
|
|
70
165
|
# Evaluate JS and return its value, with DOM nodes decoded to Dommy
|
|
71
166
|
# objects (rather than the empty Hash a raw proxy becomes crossing to
|
|
72
167
|
# Ruby). Accepts either an expression (`document.title`) or a statement
|
|
@@ -76,13 +171,67 @@ module Dommy
|
|
|
76
171
|
# attempt runs nothing. The result is awaited, so a Promise resolves
|
|
77
172
|
# before returning.
|
|
78
173
|
def evaluate(js)
|
|
79
|
-
|
|
174
|
+
evaluate_settled("(#{js.strip.sub(/;\s*\z/, "")})")
|
|
80
175
|
rescue ::Quickjs::SyntaxError
|
|
81
|
-
|
|
176
|
+
evaluate_settled("(async () => {\n#{js}\n})()")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Evaluate `expr` to its awaited value, driving the event loop so a result
|
|
180
|
+
# that depends on a TASK (a fetch's setTimeout(0) delivery, a setTimeout)
|
|
181
|
+
# settles first. The gem's top-level await (js_std_await) only drains the
|
|
182
|
+
# engine's job queue, never Dommy's scheduler — so awaiting a task-resolved
|
|
183
|
+
# promise directly deadlocks in C. Instead: store the result, run the
|
|
184
|
+
# event loop over everything due NOW (microtask checkpoint + due tasks and
|
|
185
|
+
# the tasks they chain), then await the now-settled promise (which returns
|
|
186
|
+
# immediately and still surfaces a rejection as a Ruby raise, as before).
|
|
187
|
+
def evaluate_settled(expr)
|
|
188
|
+
# `void 0` keeps the completion value off the promise, so the eval
|
|
189
|
+
# doesn't trip the gem's "unawaited Promise at top-level" guard.
|
|
190
|
+
@backend.eval("globalThis.__rbEvalP = Promise.resolve(#{expr}); void 0;")
|
|
191
|
+
drive_due_now
|
|
192
|
+
@bridge.decode(eval_tagged("await globalThis.__rbEvalP"))
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Run the event loop over the work ready at the current virtual time —
|
|
196
|
+
# the microtask checkpoint plus every due task and anything it queues at
|
|
197
|
+
# the same instant — WITHOUT advancing the clock to a future timer (a
|
|
198
|
+
# result waiting on a real delay is left for the await to surface). The
|
|
199
|
+
# nested-timer 4ms clamp guarantees due-now work drains in finite turns.
|
|
200
|
+
def drive_due_now
|
|
201
|
+
sched = @window&.scheduler
|
|
202
|
+
return drain_microtasks unless sched
|
|
203
|
+
|
|
204
|
+
64.times do
|
|
205
|
+
sched.advance_time(0)
|
|
206
|
+
break unless sched.next_due_timer_at == sched.now_ms
|
|
207
|
+
end
|
|
208
|
+
nil
|
|
82
209
|
end
|
|
83
210
|
|
|
84
211
|
def drain_microtasks
|
|
85
212
|
@backend.drain_microtasks
|
|
213
|
+
rescue ::Quickjs::RuntimeError => e
|
|
214
|
+
# The microtask checkpoint hit out-of-memory and poisoned the VM. Per
|
|
215
|
+
# the "browsing never crashes" contract, don't let it escape the event
|
|
216
|
+
# loop: record it once (so it shows in js_errors / the activity log) and
|
|
217
|
+
# then no-op — the page's JS is dead, but the browser stays alive.
|
|
218
|
+
raise unless @backend.poisoned?
|
|
219
|
+
|
|
220
|
+
note_js_halted(e)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Drive the document lifecycle: set `document.readyState` and fire the
|
|
224
|
+
# milestone events (`readystatechange`, then `DOMContentLoaded` on
|
|
225
|
+
# "interactive" / `load` on "complete"), then drain microtasks so the
|
|
226
|
+
# listeners settle. Lets a host replay the real load sequence so code
|
|
227
|
+
# that waits on document readiness (framework startup, `ready` handlers)
|
|
228
|
+
# runs the deferred path. The document defaults to "complete", so call
|
|
229
|
+
# `set_document_ready_state("loading")` BEFORE loading such code to
|
|
230
|
+
# exercise the waiting path.
|
|
231
|
+
def set_document_ready_state(state)
|
|
232
|
+
@window&.document&.__internal_set_ready_state__(state)
|
|
233
|
+
drain_microtasks
|
|
234
|
+
self
|
|
86
235
|
end
|
|
87
236
|
|
|
88
237
|
# Handle-oriented JS access for a wasm guest (see WasmBridge). Memoized
|
|
@@ -115,9 +264,68 @@ module Dommy
|
|
|
115
264
|
self
|
|
116
265
|
end
|
|
117
266
|
|
|
267
|
+
# Settle the work that is READY at the current virtual time: drain
|
|
268
|
+
# microtasks, run timers already due now (`setTimeout(0)` chains), and
|
|
269
|
+
# flush pending `requestAnimationFrame` callbacks by advancing to their
|
|
270
|
+
# frame boundary — but do NOT jump the clock to a not-yet-due
|
|
271
|
+
# `setTimeout(300)` (that needs an explicit `advance_time(300)`). This is
|
|
272
|
+
# the "let promises and animation frames resolve" entry point; `bound`
|
|
273
|
+
# caps a self-rescheduling rAF loop.
|
|
274
|
+
def settle(max_iterations: 1000)
|
|
275
|
+
sched = @window&.scheduler
|
|
276
|
+
max_iterations.times do
|
|
277
|
+
drain_microtasks
|
|
278
|
+
break unless sched
|
|
279
|
+
|
|
280
|
+
before = sched.now_ms
|
|
281
|
+
sched.advance_time(0) # run due-now timers + microtasks, no clock jump
|
|
282
|
+
drain_microtasks
|
|
283
|
+
|
|
284
|
+
raf_at = sched.next_animation_frame_at
|
|
285
|
+
if raf_at && raf_at > sched.now_ms
|
|
286
|
+
sched.advance_time(raf_at - sched.now_ms) # advance to the frame, run rAF
|
|
287
|
+
drain_microtasks
|
|
288
|
+
next
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
break if sched.now_ms == before
|
|
292
|
+
end
|
|
293
|
+
self
|
|
294
|
+
end
|
|
295
|
+
|
|
118
296
|
# Surface otherwise-swallowed JS promise rejections (see Backend).
|
|
119
297
|
def on_unhandled_rejection(&block)
|
|
120
|
-
@
|
|
298
|
+
if @track_rejections
|
|
299
|
+
@backend.on_unhandled_rejection { |err| block.call(enrich_rejection(err)) }
|
|
300
|
+
else
|
|
301
|
+
@backend.on_unhandled_rejection(&block)
|
|
302
|
+
end
|
|
303
|
+
self
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# In rejection-debug mode, replace the engine's detail-less "[object
|
|
307
|
+
# Object]" message (a non-Error reason the engine could only toString)
|
|
308
|
+
# with the rich detail recorded JS-side at rejection time, paired by
|
|
309
|
+
# recency. A no-op for errors that already carry a real message.
|
|
310
|
+
def enrich_rejection(err)
|
|
311
|
+
return err unless err.respond_to?(:message) && err.message.to_s.strip == "[object Object]"
|
|
312
|
+
|
|
313
|
+
detail = @bridge.take_rejection_detail
|
|
314
|
+
return err if detail.nil? || detail.to_s.empty?
|
|
315
|
+
|
|
316
|
+
enriched = ::Quickjs::RuntimeError.new(detail.to_s, "UnhandledRejection")
|
|
317
|
+
enriched.set_backtrace(err.backtrace) if err.backtrace
|
|
318
|
+
enriched
|
|
319
|
+
rescue StandardError
|
|
320
|
+
err
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Observe a timer/rAF callback that was force-killed by the execution
|
|
324
|
+
# timeout (a runaway busy loop). The host records it as a js_error; the
|
|
325
|
+
# offending timer is already dropped by the scheduler so it cannot
|
|
326
|
+
# re-stall. Optional in the Runtime contract (guard with respond_to?).
|
|
327
|
+
def on_callback_error(&block)
|
|
328
|
+
@callback_error_listener = block
|
|
121
329
|
self
|
|
122
330
|
end
|
|
123
331
|
|
|
@@ -132,43 +340,116 @@ module Dommy
|
|
|
132
340
|
# CSS / fetch / addEventListener / .... Call after install_window. This
|
|
133
341
|
# is what lets real frontend bundles (Turbo, …) run unmodified.
|
|
134
342
|
def install_browser_globals
|
|
343
|
+
alias_browser_globals
|
|
344
|
+
install_intl_polyfill
|
|
345
|
+
install_wasm_stub
|
|
346
|
+
mirror_builtins_on_window
|
|
347
|
+
self
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# This QuickJS build has no WebAssembly, so a bare `WebAssembly.foo`
|
|
351
|
+
# reference throws `'WebAssembly' is not defined` (nuxt.com via Shiki,
|
|
352
|
+
# many bundlers' feature probes). Define a stub: compile/instantiate
|
|
353
|
+
# reject and validate() returns false, so WASM-loading code takes its
|
|
354
|
+
# JS fallback instead of crashing. `Memory` honors `{shared:true}` (a
|
|
355
|
+
# SharedArrayBuffer) so WPT's common/sab.js keeps working.
|
|
356
|
+
def install_wasm_stub
|
|
357
|
+
@backend.eval(WASM_STUB_JS)
|
|
358
|
+
self
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
WASM_STUB_JS = <<~'JS'
|
|
362
|
+
if (typeof globalThis.WebAssembly === "undefined") {
|
|
363
|
+
var unsupported = function () { return Promise.reject(new Error("WebAssembly is not supported")); };
|
|
364
|
+
var throwUnsupported = function () { throw new Error("WebAssembly is not supported"); };
|
|
365
|
+
globalThis.WebAssembly = {
|
|
366
|
+
instantiate: unsupported, instantiateStreaming: unsupported,
|
|
367
|
+
compile: unsupported, compileStreaming: unsupported,
|
|
368
|
+
validate: function () { return false; },
|
|
369
|
+
Module: throwUnsupported, Instance: throwUnsupported,
|
|
370
|
+
Memory: function (opts) {
|
|
371
|
+
var bytes = ((opts && opts.initial) || 0) * 65536;
|
|
372
|
+
this.buffer = (opts && opts.shared && typeof SharedArrayBuffer === "function")
|
|
373
|
+
? new SharedArrayBuffer(bytes) : new ArrayBuffer(bytes);
|
|
374
|
+
},
|
|
375
|
+
Table: function () {}, Global: function () {},
|
|
376
|
+
CompileError: Error, LinkError: Error, RuntimeError: Error,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
JS
|
|
380
|
+
|
|
381
|
+
# This QuickJS build ships without ICU, so `Intl` is undefined and any
|
|
382
|
+
# page touching `Intl.NumberFormat` / `DateTimeFormat` / … throws
|
|
383
|
+
# `'Intl' is not defined` (nuxt.com, i18n libraries, …). Install a small
|
|
384
|
+
# locale-naive polyfill: it formats reasonably (grouped numbers, ISO-ish
|
|
385
|
+
# dates) so pages run instead of crashing, without pulling in full ICU.
|
|
386
|
+
def install_intl_polyfill
|
|
387
|
+
@backend.eval(INTL_POLYFILL_JS)
|
|
388
|
+
self
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
INTL_POLYFILL_JS = <<~'JS'
|
|
392
|
+
if (typeof globalThis.Intl === "undefined") {
|
|
393
|
+
var I = {};
|
|
394
|
+
var group = function (s) {
|
|
395
|
+
var p = String(s).split(".");
|
|
396
|
+
p[0] = p[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
397
|
+
return p.join(".");
|
|
398
|
+
};
|
|
399
|
+
function NumberFormat(l, o) { this.o = o || {}; }
|
|
400
|
+
NumberFormat.prototype.format = function (n) {
|
|
401
|
+
n = Number(n); var o = this.o;
|
|
402
|
+
if (o.style === "percent") n *= 100;
|
|
403
|
+
var max = o.maximumFractionDigits;
|
|
404
|
+
if (max == null && o.style === "currency") max = 2;
|
|
405
|
+
var s = group(max == null ? String(n) : n.toFixed(max));
|
|
406
|
+
if (o.style === "percent") s += "%";
|
|
407
|
+
if (o.style === "currency" && o.currency) s = o.currency + " " + s;
|
|
408
|
+
return s;
|
|
409
|
+
};
|
|
410
|
+
NumberFormat.prototype.formatToParts = function (n) { return [{ type: "literal", value: this.format(n) }]; };
|
|
411
|
+
NumberFormat.prototype.resolvedOptions = function () { return Object.assign({ locale: "en", numberingSystem: "latn", style: "decimal" }, this.o); };
|
|
412
|
+
function DateTimeFormat(l, o) { this.o = o || {}; }
|
|
413
|
+
DateTimeFormat.prototype.format = function (d) {
|
|
414
|
+
d = d == null ? new Date() : new Date(d);
|
|
415
|
+
if (isNaN(d.getTime())) return "";
|
|
416
|
+
try { return d.toLocaleString(); } catch (e) { return d.toString(); }
|
|
417
|
+
};
|
|
418
|
+
DateTimeFormat.prototype.formatToParts = function (d) { return [{ type: "literal", value: this.format(d) }]; };
|
|
419
|
+
DateTimeFormat.prototype.formatRange = function (a, b) { return this.format(a) + " – " + this.format(b); };
|
|
420
|
+
DateTimeFormat.prototype.resolvedOptions = function () { return Object.assign({ locale: "en", calendar: "gregory", numberingSystem: "latn", timeZone: "UTC" }, this.o); };
|
|
421
|
+
function Collator(l, o) { this.o = o || {}; }
|
|
422
|
+
Collator.prototype.compare = function (a, b) { a = String(a); b = String(b); return a < b ? -1 : a > b ? 1 : 0; };
|
|
423
|
+
Collator.prototype.resolvedOptions = function () { return Object.assign({ locale: "en" }, this.o); };
|
|
424
|
+
function PluralRules(l, o) { this.o = o || {}; }
|
|
425
|
+
PluralRules.prototype.select = function (n) { return Number(n) === 1 ? "one" : "other"; };
|
|
426
|
+
PluralRules.prototype.resolvedOptions = function () { return Object.assign({ locale: "en", type: "cardinal" }, this.o); };
|
|
427
|
+
function RelativeTimeFormat(l, o) { this.o = o || {}; }
|
|
428
|
+
RelativeTimeFormat.prototype.format = function (v, u) { return v + " " + u + (Math.abs(v) === 1 ? "" : "s"); };
|
|
429
|
+
RelativeTimeFormat.prototype.formatToParts = function (v, u) { return [{ type: "literal", value: this.format(v, u) }]; };
|
|
430
|
+
RelativeTimeFormat.prototype.resolvedOptions = function () { return Object.assign({ locale: "en", numeric: "always", style: "long" }, this.o); };
|
|
431
|
+
function ListFormat(l, o) { this.o = o || {}; }
|
|
432
|
+
ListFormat.prototype.format = function (a) { return Array.from(a || []).join(", "); };
|
|
433
|
+
ListFormat.prototype.formatToParts = function (a) { return [{ type: "element", value: this.format(a) }]; };
|
|
434
|
+
ListFormat.prototype.resolvedOptions = function () { return Object.assign({ locale: "en", type: "conjunction", style: "long" }, this.o); };
|
|
435
|
+
I.NumberFormat = NumberFormat; I.DateTimeFormat = DateTimeFormat; I.Collator = Collator;
|
|
436
|
+
I.PluralRules = PluralRules; I.RelativeTimeFormat = RelativeTimeFormat; I.ListFormat = ListFormat;
|
|
437
|
+
["NumberFormat", "DateTimeFormat", "Collator", "PluralRules", "RelativeTimeFormat", "ListFormat"].forEach(function (k) {
|
|
438
|
+
I[k].supportedLocalesOf = function (locs) { return Array.isArray(locs) ? locs.slice() : locs ? [locs] : []; };
|
|
439
|
+
});
|
|
440
|
+
I.getCanonicalLocales = function (locs) { return Array.isArray(locs) ? locs.slice() : locs ? [String(locs)] : []; };
|
|
441
|
+
globalThis.Intl = I;
|
|
442
|
+
}
|
|
443
|
+
JS
|
|
444
|
+
|
|
445
|
+
# WPT-only scaffolding: a minimal `WebAssembly.Memory` whose `.buffer`
|
|
446
|
+
# is a SharedArrayBuffer. The engine ships a real SharedArrayBuffer but
|
|
447
|
+
# no WebAssembly, and WPT's `common/sab.js` derives the SAB constructor
|
|
448
|
+
# from `new WebAssembly.Memory({shared:true}).buffer.constructor`. This
|
|
449
|
+
# is test-harness-only (real pages never need it), so it is opt-in and
|
|
450
|
+
# NOT part of #install_browser_globals.
|
|
451
|
+
def install_wasm_memory_shim
|
|
135
452
|
@backend.eval(<<~JS)
|
|
136
|
-
globalThis.self = globalThis;
|
|
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
453
|
if (typeof globalThis.WebAssembly === "undefined" && typeof globalThis.SharedArrayBuffer === "function") {
|
|
173
454
|
globalThis.WebAssembly = {
|
|
174
455
|
Memory: function (opts) {
|
|
@@ -195,12 +476,139 @@ module Dommy
|
|
|
195
476
|
@bridge.registered_count
|
|
196
477
|
end
|
|
197
478
|
|
|
479
|
+
# Snapshot bridge crossing counts when DOMMY_JS_BRIDGE_PROFILE=1.
|
|
480
|
+
def bridge_crossing_counts(limit: nil)
|
|
481
|
+
@bridge.crossing_counts(limit: limit)
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def reset_bridge_crossing_counts
|
|
485
|
+
@bridge.reset_crossing_counts
|
|
486
|
+
self
|
|
487
|
+
end
|
|
488
|
+
|
|
198
489
|
def dispose
|
|
199
490
|
@backend.dispose
|
|
200
491
|
end
|
|
201
492
|
|
|
202
493
|
private
|
|
203
494
|
|
|
495
|
+
# Scheduler hook: a timer/rAF callback raised. A JS-execution error —
|
|
496
|
+
# the callback threw (Quickjs::RuntimeError) or was force-killed for
|
|
497
|
+
# running too long (Quickjs::InterruptedError < RuntimeError) — must not
|
|
498
|
+
# escape its dispatch (WHATWG: a timer callback's exception is reported,
|
|
499
|
+
# the event loop keeps running). Record it and return truthy so the
|
|
500
|
+
# scheduler drops the timer and browsing continues. A non-JS error is a
|
|
501
|
+
# genuine host bug: return falsy so it propagates.
|
|
502
|
+
def handle_timer_error(error, timer)
|
|
503
|
+
return false unless error.is_a?(::Quickjs::RuntimeError)
|
|
504
|
+
|
|
505
|
+
# An out-of-memory in a timer callback poisons the whole VM: the page's
|
|
506
|
+
# JS is dead from here on, so flag it (and report once) — not just this
|
|
507
|
+
# one callback.
|
|
508
|
+
note_js_halted(error) if @backend.poisoned?
|
|
509
|
+
@callback_error_listener&.call(enrich_callback_error(error, timer))
|
|
510
|
+
true
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# The VM hit out-of-memory and is poisoned. Surface the failure ONCE (a
|
|
514
|
+
# repeated drain would otherwise report it every tick) so the user sees
|
|
515
|
+
# that the page's JavaScript stopped, then leave it to the no-op guards.
|
|
516
|
+
def note_js_halted(error)
|
|
517
|
+
return if @js_halted
|
|
518
|
+
|
|
519
|
+
@js_halted = true
|
|
520
|
+
@callback_error_listener&.call(error)
|
|
521
|
+
nil
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# Attach the timer's scheduling stack (where the page set the timer up) to
|
|
525
|
+
# the recorded error, so a callback that throws a stackless value — a bare
|
|
526
|
+
# `null`, common in minified bundles — is still traceable to the code that
|
|
527
|
+
# scheduled it. The error's class (and message, the dedup key) is kept;
|
|
528
|
+
# only its backtrace is replaced with the JS frames, which the diagnostics
|
|
529
|
+
# UI reads in place of the host scheduler internals. A no-op when no origin
|
|
530
|
+
# was captured (a timer not created through the instrumented globals, or a
|
|
531
|
+
# unit test driving the scheduler directly).
|
|
532
|
+
def enrich_callback_error(error, timer)
|
|
533
|
+
frames = fetch_timer_origin(timer)
|
|
534
|
+
error.set_backtrace(frames) unless frames.empty?
|
|
535
|
+
error
|
|
536
|
+
rescue StandardError
|
|
537
|
+
error
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# The scheduling stack for `timer`, as cleaned frame strings (or []). The
|
|
541
|
+
# origin Error lives JS-side until now; fetching it also clears it.
|
|
542
|
+
def fetch_timer_origin(timer)
|
|
543
|
+
return [] unless timer.respond_to?(:id)
|
|
544
|
+
|
|
545
|
+
stack = @backend.call_js("__rbFetchTimerOrigin", timer.id).to_s
|
|
546
|
+
# Drop the shim's own frame (the `new Error()` in __rbDefer) so the top
|
|
547
|
+
# frame is the page code that called setTimeout/setInterval.
|
|
548
|
+
stack.split("\n").map(&:strip).reject(&:empty?)
|
|
549
|
+
.reject { |line| line.include?("__rbDefer") }
|
|
550
|
+
rescue StandardError
|
|
551
|
+
[]
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
# Alias the bare browser globals frameworks reach for onto the installed
|
|
555
|
+
# window (self/parent/top/location/history/navigator/storages/CSS/fetch/
|
|
556
|
+
# event methods). This is what lets real frontend bundles run unmodified.
|
|
557
|
+
def alias_browser_globals
|
|
558
|
+
@backend.eval(<<~JS)
|
|
559
|
+
globalThis.self = globalThis;
|
|
560
|
+
// Top-level window: parent/top are the window itself (spec), so
|
|
561
|
+
// frame-walking loops terminate instead of dereferencing undefined.
|
|
562
|
+
globalThis.parent = globalThis;
|
|
563
|
+
globalThis.top = globalThis;
|
|
564
|
+
globalThis.location = window.location;
|
|
565
|
+
globalThis.history = window.history;
|
|
566
|
+
globalThis.navigator = window.navigator;
|
|
567
|
+
globalThis.sessionStorage = window.sessionStorage;
|
|
568
|
+
globalThis.localStorage = window.localStorage;
|
|
569
|
+
globalThis.CSS = window.CSS;
|
|
570
|
+
globalThis.getComputedStyle = (...args) => window.getComputedStyle(...args);
|
|
571
|
+
globalThis.matchMedia = (...args) => window.matchMedia(...args);
|
|
572
|
+
globalThis.fetch = (...args) => window.fetch(...args);
|
|
573
|
+
globalThis.addEventListener = (...args) => window.addEventListener(...args);
|
|
574
|
+
globalThis.removeEventListener = (...args) => window.removeEventListener(...args);
|
|
575
|
+
globalThis.dispatchEvent = (event) => window.dispatchEvent(event);
|
|
576
|
+
|
|
577
|
+
// More bare globals frameworks read directly (e.g. performance.now(),
|
|
578
|
+
// crypto, screen). Objects/values are aliased by reference; methods are
|
|
579
|
+
// wrapped so `this` binds to the window. All already exist on window.
|
|
580
|
+
for (const __n of ["performance", "crypto", "screen", "visualViewport",
|
|
581
|
+
"indexedDB", "caches", "devicePixelRatio",
|
|
582
|
+
"innerWidth", "innerHeight", "scrollX", "scrollY", "pageXOffset"]) {
|
|
583
|
+
try { globalThis[__n] = window[__n]; } catch (__e) {}
|
|
584
|
+
}
|
|
585
|
+
for (const __m of ["scrollTo", "scrollBy", "requestIdleCallback", "cancelIdleCallback",
|
|
586
|
+
"getSelection", "structuredClone", "reportError", "btoa", "atob",
|
|
587
|
+
"alert", "confirm", "prompt", "open", "postMessage"]) {
|
|
588
|
+
try { globalThis[__m] = (...args) => window[__m](...args); } catch (__e) {}
|
|
589
|
+
}
|
|
590
|
+
JS
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
# The window IS the global object, so JS built-in constructors and
|
|
594
|
+
# namespaces are also `window` properties (`window.String`,
|
|
595
|
+
# `window.Number`, …). Mirror them as own props on the window proxy so
|
|
596
|
+
# code that reads constructors off `window` (e.g. the WPT reflection
|
|
597
|
+
# harness's `window[type]` casts) resolves them.
|
|
598
|
+
def mirror_builtins_on_window
|
|
599
|
+
@backend.eval(<<~JS)
|
|
600
|
+
for (const __n of [
|
|
601
|
+
"String", "Boolean", "Number", "BigInt", "Symbol", "Object", "Array",
|
|
602
|
+
"Function", "Date", "RegExp", "Promise", "Map", "Set", "WeakMap",
|
|
603
|
+
"WeakSet", "Math", "JSON", "Reflect", "Proxy", "Error", "TypeError",
|
|
604
|
+
"RangeError", "SyntaxError", "Infinity", "NaN", "undefined",
|
|
605
|
+
"parseInt", "parseFloat", "isNaN", "isFinite", "globalThis",
|
|
606
|
+
]) {
|
|
607
|
+
try { window[__n] = globalThis[__n]; } catch (__e) {}
|
|
608
|
+
}
|
|
609
|
+
JS
|
|
610
|
+
end
|
|
611
|
+
|
|
204
612
|
def eval_tagged(inner_expr)
|
|
205
613
|
@backend.eval_awaited("__rbHost.tag(#{inner_expr});")
|
|
206
614
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dommy
|
|
4
|
+
module Js
|
|
5
|
+
module Quickjs
|
|
6
|
+
# A process-global cache of compiled external-script bytecode, keyed by
|
|
7
|
+
# URL. Vendored bundles (turbo.umd.js, stimulus.umd.js, application.js, …)
|
|
8
|
+
# are identical across page loads but otherwise re-parsed on every fresh
|
|
9
|
+
# VM; compiling each once and running the bytecode per VM removes that
|
|
10
|
+
# repeated parse cost (the bundles are hundreds of KB).
|
|
11
|
+
#
|
|
12
|
+
# Keyed by URL: an asset URL maps 1:1 to its content (Propshaft / Sprockets
|
|
13
|
+
# digest-stamp it; test fixtures are stable within a process).
|
|
14
|
+
#
|
|
15
|
+
# The bridge's built-in runtime bundles (host_runtime.js,
|
|
16
|
+
# observable_runtime.js) use a separate cache in Backend#run_bundle, so an
|
|
17
|
+
# external-script count here stays uncontaminated by engine internals.
|
|
18
|
+
module ScriptCache
|
|
19
|
+
@cache = {}
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
# The compiled bytecode for `url`, compiling `source` on first use.
|
|
24
|
+
def compiled(url, source)
|
|
25
|
+
@mutex.synchronize { @cache[url] ||= Backend.compile(source, filename: url.to_s) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def clear
|
|
29
|
+
@mutex.synchronize { @cache.clear }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def size = @cache.size
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|