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.
@@ -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
- globalThis.setTimeout = (fn, delay) => window.setTimeout(fn, delay);
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
- @bridge.decode(eval_tagged("await (#{js.strip.sub(/;\s*\z/, "")})"))
174
+ evaluate_settled("(#{js.strip.sub(/;\s*\z/, "")})")
80
175
  rescue ::Quickjs::SyntaxError
81
- @bridge.decode(eval_tagged("await (async () => {\n#{js}\n})()"))
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
- @backend.on_unhandled_rejection(&block)
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