Class: Dommy::Js::Quickjs::Backend

Inherits:
Object
  • Object
show all
Defined in:
lib/dommy/js/quickjs/backend.rb

Overview

Binds HostBridge’s abstract backend contract to the ‘quickjs` gem.

Value-representation conformance: host_runtime.js now tags a top-level JS ‘undefined` itself (`dehydrateTop` -> `__rb_undefined:true`) at the JS->Ruby crossings, so the protocol no longer relies on the backend to marshal a bare `undefined` to a sentinel — keeping it engine-neutral. The `quickjs` gem happens to also deliver a bare `undefined` as the Ruby symbol `:undefined`, which HostBridge#unwrap still accepts as a defensive fallback (e.g. the `evaluate`/`tag` return path, which dehydrates without the top-level tag); either way it maps to Dommy::Bridge::UNDEFINED. No normalization is needed here.

Constant Summary collapse

DEFAULT_TIMEOUT_MSEC =

The gem’s default eval timeout is 100ms, which interrupts large synchronous bridge loops (every property crossing is a Ruby call).

60_000
DEFAULT_MEMORY_LIMIT =

The gem’s default memory ceiling is 128 MB. A real-site SPA (note.com’s Apollo/React bundle, hydration, the whole DOM mirrored as host proxies) blows past that and the VM hits out-of-memory, which poisons it. Give a browser-grade VM more headroom so heavy pages actually finish rendering; the OOM is also now survivable (see #poisoned?), not a crash.

512 * 1024 * 1024

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(**vm_opts) ⇒ Backend

Returns a new instance of Backend.



64
65
66
67
# File 'lib/dommy/js/quickjs/backend.rb', line 64

def initialize(**vm_opts)
  vm_opts = {timeout_msec: self.class.default_timeout_msec, memory_limit: DEFAULT_MEMORY_LIMIT}.merge(vm_opts)
  @vm = ::Quickjs::VM.new(**vm_opts)
end

Class Method Details

.compile(source, filename: "<compiled>") ⇒ Object

Compile JS source to reusable bytecode (parsed once, via a throwaway VM). Run it on any number of fresh VMs with #run_compiled — far cheaper than re-parsing the source per VM (the large host runtime / vendored bundles are identical across VMs).



98
99
100
101
102
103
104
105
106
107
108
# File 'lib/dommy/js/quickjs/backend.rb', line 98

def self.compile(source, filename: "<compiled>")
  ::Quickjs.compile(source, filename: filename)
rescue ::Quickjs::RuntimeError => e
  # See #eval: work around the for-of/yield-in-iterable codegen bug.
  raise unless SourceGuard.relevant_error?(e)

  guarded = SourceGuard.fix_for_of_yield(source)
  raise if guarded.equal?(source) || guarded == source

  ::Quickjs.compile(guarded, filename: filename)
end

.compiled_bundle(cache_key, source) ⇒ Object



117
118
119
# File 'lib/dommy/js/quickjs/backend.rb', line 117

def self.compiled_bundle(cache_key, source)
  @bundle_mutex.synchronize { @bundle_cache[cache_key] ||= compile(source, filename: cache_key.to_s) }
end

.default_timeout_msecObject

The per-eval timeout, in ms. A single JS eval — a script, or one timer/rAF callback — is force-aborted (Quickjs::InterruptedError, caught and logged) after this long. It is ALSO the ceiling on how long QuickJS holds the thread in C: while it runs, a Ctrl-C (delivered as a deferred SIGINT) can’t be serviced, so an interactive host (dommynx) sets a lower value via DOMMY_JS_TIMEOUT_MSEC so a heavy/runaway burst can’t freeze the UI for the full library default.



52
53
54
55
# File 'lib/dommy/js/quickjs/backend.rb', line 52

def self.default_timeout_msec
  env = ENV["DOMMY_JS_TIMEOUT_MSEC"].to_i
  env.positive? ? env : DEFAULT_TIMEOUT_MSEC
end

Instance Method Details

#call_js(path, *args) ⇒ Object



166
167
168
169
170
# File 'lib/dommy/js/quickjs/backend.rb', line 166

def call_js(path, *args)
  return if poisoned?

  @vm.call(path, *args)
end

#define_host_function(name, &block) ⇒ Object



162
163
164
# File 'lib/dommy/js/quickjs/backend.rb', line 162

def define_host_function(name, &block)
  @vm.define_function(name, &block)
end

#disposeObject



195
196
197
# File 'lib/dommy/js/quickjs/backend.rb', line 195

def dispose
  @vm.dispose!
end

#drain_microtasksObject



172
173
174
175
176
# File 'lib/dommy/js/quickjs/backend.rb', line 172

def drain_microtasks
  return if poisoned?

  @vm.drain_jobs!
end

#eval(js) ⇒ Object



79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/dommy/js/quickjs/backend.rb', line 79

def eval(js)
  return if poisoned?

  @vm.eval_code(js, async: false)
rescue ::Quickjs::RuntimeError => e
  # A QuickJS codegen bug rejects `for-of` with a `yield` in the iterable
  # ("stack underflow") — rewrite that construct and retry once.
  raise unless SourceGuard.relevant_error?(e)

  guarded = SourceGuard.fix_for_of_yield(js)
  raise if guarded.equal?(js) || guarded == js

  @vm.eval_code(guarded, async: false)
end

#eval_awaited(js) ⇒ Object

Async eval: the gem awaits the top-level result and drains the microtask queue, so JS ‘await`/Promises resolve before returning.



138
139
140
# File 'lib/dommy/js/quickjs/backend.rb', line 138

def eval_awaited(js)
  @vm.eval_code(js, async: true)
end

#import_module(source) ⇒ Object

Evaluate ‘source` as an ES module (its `import`s resolved through the module loader). `* as` with no globalization runs it for side effects.



151
152
153
# File 'lib/dommy/js/quickjs/backend.rb', line 151

def import_module(source)
  @vm.import("* as __dommy_mod", from: source, code_to_expose: "")
end

#import_module_url(url) ⇒ Object

Evaluate the module at ‘url` (resolved + fetched by the module loader). The importer of its relative imports is `url`, so they resolve correctly — unlike an inline module’s synthetic filename.



158
159
160
# File 'lib/dommy/js/quickjs/backend.rb', line 158

def import_module_url(url)
  @vm.import("* as __dommy_mod", filename: url, code_to_expose: "")
end

#module_loader=(callable) ⇒ Object

Install the ESM module resolver: a callable ‘(specifier, importer) -> source String | { code:, as: } | nil` the engine consults for every static/dynamic `import`. nil clears it (engine default loader).



145
146
147
# File 'lib/dommy/js/quickjs/backend.rb', line 145

def module_loader=(callable)
  @vm.module_loader = callable
end

#on_log(&block) ⇒ Object

Register a handler for console.(log|info|debug|warn|error). The block receives a log object (#severity / #to_s / #raw).



187
188
189
# File 'lib/dommy/js/quickjs/backend.rb', line 187

def on_log(&block)
  @vm.on_log(&block)
end

#on_unhandled_rejection(&block) ⇒ Object

Register a handler for promise rejections that reach the microtask queue with no ‘.catch` — frameworks (Turbo, …) often swallow these, so surfacing them is essential for diagnosing failures.



181
182
183
# File 'lib/dommy/js/quickjs/backend.rb', line 181

def on_unhandled_rejection(&block)
  @vm.on_unhandled_rejection(&block)
end

#poisoned?Boolean

True once the VM can no longer run JS safely: it hit out-of-memory (the gem flags it “poisoned” — further eval may segfault) or was disposed. Callers stop driving a poisoned VM (the page’s JS is dead) instead of letting the error crash the whole browser — browsing survives a page whose JS ran out of memory, showing whatever rendered before it died.

Returns:

  • (Boolean)


74
75
76
77
# File 'lib/dommy/js/quickjs/backend.rb', line 74

def poisoned?
  (@vm.respond_to?(:memory_poisoned?) && @vm.memory_poisoned?) ||
    (@vm.respond_to?(:disposed?) && @vm.disposed?)
end

#run_bundle(cache_key, source) ⇒ Object

Run a source bundle that is identical across VMs (the bridge’s host runtime, the Observable polyfill): compile it to bytecode once per process — keyed by ‘cache_key` — and run that on this VM. Lets the engine-agnostic bridge reuse big bundles without knowing about bytecode; the compile-once optimization stays here in the engine layer.



132
133
134
# File 'lib/dommy/js/quickjs/backend.rb', line 132

def run_bundle(cache_key, source)
  run_compiled(self.class.compiled_bundle(cache_key, source))
end

#run_compiled(runnable) ⇒ Object

Execute precompiled bytecode (a Quickjs::Runnable) on this VM in global scope — equivalent to #eval of its source, without the parse cost.



123
124
125
# File 'lib/dommy/js/quickjs/backend.rb', line 123

def run_compiled(runnable)
  runnable.run(on: @vm)
end

#run_gcObject



191
192
193
# File 'lib/dommy/js/quickjs/backend.rb', line 191

def run_gc
  @vm.gc!
end