Class: Dommy::Js::Quickjs::Backend
- Inherits:
-
Object
- Object
- Dommy::Js::Quickjs::Backend
- 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
-
.compile(source, filename: "<compiled>") ⇒ Object
Compile JS source to reusable bytecode (parsed once, via a throwaway VM).
- .compiled_bundle(cache_key, source) ⇒ Object
-
.default_timeout_msec ⇒ Object
The per-eval timeout, in ms.
Instance Method Summary collapse
- #call_js(path, *args) ⇒ Object
- #define_host_function(name, &block) ⇒ Object
- #dispose ⇒ Object
- #drain_microtasks ⇒ Object
- #eval(js) ⇒ Object
-
#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.
-
#import_module(source) ⇒ Object
Evaluate ‘source` as an ES module (its `import`s resolved through the module loader).
-
#import_module_url(url) ⇒ Object
Evaluate the module at ‘url` (resolved + fetched by the module loader).
-
#initialize(**vm_opts) ⇒ Backend
constructor
A new instance of Backend.
-
#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`.
-
#on_log(&block) ⇒ Object
Register a handler for console.(log|info|debug|warn|error).
-
#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.
-
#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.
-
#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.
-
#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.
- #run_gc ⇒ Object
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_msec ⇒ Object
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 |
#dispose ⇒ Object
195 196 197 |
# File 'lib/dommy/js/quickjs/backend.rb', line 195 def dispose @vm.dispose! end |
#drain_microtasks ⇒ Object
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.
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_gc ⇒ Object
191 192 193 |
# File 'lib/dommy/js/quickjs/backend.rb', line 191 def run_gc @vm.gc! end |