dommy-js-wasmtime 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5c63d21b5f68aa52f4ac026e1d7a3370c6d4b277c4b78bfd32731b092a0be375
4
+ data.tar.gz: ceb394faaa0b7783e7398ee71d7c1983a7fbbfd9e72d3463cfe5c31070ad1b26
5
+ SHA512:
6
+ metadata.gz: a5e28dd35f810a1d66ec4c67f9de8ba748dc9e8dba0bfeb17bd7f296d869732902e67586ffd12905e040f6ee34b68fef462f4a7d5d3024f7cb8867b4c8d73f0e
7
+ data.tar.gz: 54fc08f9e8ab4e2bf1b78676fb5ac4be72a61b8812619f4c794fca0f688247e0d1d707eba9bb9006277e1b9de218d04189eecfcaabaafd443f67d31163e23574
data/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ - Initial release.
6
+ - `Dommy::Js::Wasmtime::VM` — a wasmtime-rb host for mruby-wasm-js builds (the
7
+ Lilac runtime): implements the 25-function `js.*` handle-table interop ABI and
8
+ the WASI preview1 surface, routing JS interop to a pluggable engine.
9
+ - `Dommy::Js::Wasmtime::Engines::Quickjs` — default engine; a QuickJS VM bound to
10
+ a Dommy DOM (via `dommy-js-quickjs`), giving the wasm a real JS world
11
+ (promises/await/fetch, full marshalling).
12
+ - `Dommy::Js::Wasmtime.boot` — convenience loader: build the VM over a Dommy DOM,
13
+ seed the JS world, load mruby sources, and eval an `entrypoint` (e.g.
14
+ `Lilac.start`).
15
+ - Minitest suite: engine unit tests + VM integration tests (against a vendored
16
+ mruby-wasm-js fixture), plus a cross-check against lilac's own wasm spec suite.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 TAKAHASHI Masayoshi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # dommy-js-wasmtime
2
+
3
+ [![CI](https://github.com/takahashim/dommy-js-wasmtime/actions/workflows/ci.yml/badge.svg)](https://github.com/takahashim/dommy-js-wasmtime/actions/workflows/ci.yml)
4
+
5
+ Run an **mruby-wasm-js** build on
6
+ [wasmtime-rb](https://github.com/bytecodealliance/wasmtime-rb), bridged to a
7
+ [Dommy](https://github.com/takahashim/dommy) DOM — no browser, no Node, no
8
+ happy-dom. The [Lilac](https://github.com/takahashim/lilac) component runtime is
9
+ the primary example, but the gem itself doesn't depend on Lilac: it targets the
10
+ generic mruby-wasm-js host ABI.
11
+
12
+ It is the wasmtime sibling of `dommy-js-quickjs`. Where `dommy-js-quickjs` runs
13
+ *JavaScript* against Dommy, this gem runs *mruby* (inside wasm, on wasmtime) and
14
+ serves its JS interop from a real JS world:
15
+
16
+ ```
17
+ mruby (app.wasm) ──js.* handle ABI──▶ VM (wasmtime host)
18
+
19
+ ▼ Engines::Quickjs
20
+ QuickJS globalThis ──dommy-js-quickjs──▶ Dommy DOM
21
+ ```
22
+
23
+ So `JS.global` is a real `globalThis`, promises / `await` / `fetch` work, and the
24
+ same DOM the wasm mutates is inspectable from Ruby via Dommy's API.
25
+
26
+ ## How it works
27
+
28
+ The wasm imports two modules; this gem implements both in pure Ruby:
29
+
30
+ - **`js`** — the 25-function handle-table interop ABI (`js_global`, `js_get`,
31
+ `js_call`, `js_new`, `js_make_callback`, …). Values cross as small integer
32
+ handles; the VM stores the Ruby/JS objects and routes property/method access
33
+ through the bridge ABI (`__js_get__` / `__js_set__` / `__js_call__` /
34
+ `__js_new__`). The default engine (`Engines::Quickjs`) backs each handle with a
35
+ live QuickJS value (`JsRef`).
36
+ - **`wasi_snapshot_preview1`** — wasmtime's bundled WASI preview1, with `fd_write`
37
+ shadowed to capture mruby stdout/stderr.
38
+
39
+ mruby-wasm-js builds use the WebAssembly exception-handling proposal (mruby's
40
+ longjmp); wasmtime enables it via `Engine.new(wasm_exceptions: true)`.
41
+
42
+ ## Usage
43
+
44
+ ```ruby
45
+ require "dommy"
46
+ require "dommy/js/wasmtime"
47
+
48
+ vm = Dommy::Js::Wasmtime.boot(
49
+ wasm: "build/app.wasm",
50
+ html: File.read("index.html"),
51
+ sources: Dir["lib/*.rb"], # mruby source files, loaded in order
52
+ entrypoint: "Lilac.start", # app boot call after sources load (or nil)
53
+ ) do |engine|
54
+ engine.eval(<<~JS) # seed the JS world (e.g. a fetch fixture)
55
+ globalThis.fetch = async (u) => new Response(DATA_JSON, { status: 200 });
56
+ JS
57
+ end
58
+
59
+ vm.document.query_selector(".app") # the DOM the mruby app rendered
60
+ ```
61
+
62
+ `boot` builds the VM, runs `_initialize`, optionally seeds the JS world, loads the
63
+ mruby sources, and evals `entrypoint` (e.g. `Lilac.start` to mount components).
64
+ After each `eval`, the event loop is driven to quiescence so fibers suspended on
65
+ `.await` settle.
66
+
67
+ For lower-level use, drive the VM directly — it has no app-framework knowledge:
68
+
69
+ ```ruby
70
+ vm = Dommy::Js::Wasmtime::VM.new(wasm: "build/app.wasm")
71
+ vm.eval('JS.global[:document].call(:querySelector, "h1")')
72
+ vm.stdout
73
+ ```
74
+
75
+ ## Development
76
+
77
+ ```bash
78
+ bundle install
79
+ bundle exec rake # runs the test suite + RuboCop
80
+ bundle exec rake test
81
+ ```
82
+
83
+ The suite has two layers: engine unit tests (`Engines::Quickjs`, no wasm —
84
+ marshalling, the JsRef bridge ABI, callbacks, the event-loop drive) and VM
85
+ integration tests that run real mruby through the `js.*` bridge against
86
+ QuickJS+Dommy (DOM read/write, callback round-trips, `await`, JS-error capture).
87
+
88
+ The integration tests need an mruby-wasm-js `.wasm` with the compiler
89
+ (`js_eval_handle`). A representative build (lilac-full) is vendored at
90
+ `test/fixtures/mruby-wasm-js.wasm`; point `DOMMY_JS_WASMTIME_TEST_WASM` at another
91
+ build to override. The tests use only mruby + the `JS` module, not Lilac's
92
+ component API.
93
+
94
+ ## Status
95
+
96
+ Extracted from lilac's reference host (`test/ruby_spec/mruby_wasm.rb` +
97
+ `quickjs_bridge.rb`); additionally cross-checked by running lilac's own wasm spec
98
+ suite through this VM and diffing per-spec results against the reference host
99
+ (identical).
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dommy"
4
+ require "dommy/js/quickjs"
5
+
6
+ module Dommy
7
+ module Js
8
+ module Wasmtime
9
+ module Engines
10
+ # Real-JS engine backing the wasmtime host's `js.*` bridge.
11
+ #
12
+ # mruby-in-wasm reaches the host through ~25 `js_*` imports that operate on
13
+ # opaque JS *handles* (js_eval / js_global / js_get / js_set / js_call /
14
+ # js_new / js_make_callback / …). In the browser those go to V8; here they
15
+ # go to a QuickJS VM bound to a Dommy DOM (dommy-js-quickjs's WasmBridge),
16
+ # so the mruby inner loop runs the *same* JavaScript a browser would.
17
+ #
18
+ # One global JS world: QuickJS owns globalThis, Dommy's window/document are
19
+ # installed into it, and the guest's `JS.global` IS quickjs globalThis — so
20
+ # a fetch stub installed with `engine.eval("globalThis.fetch = …")` and the
21
+ # fetch Fetchy calls are the same function (no split brain).
22
+ #
23
+ # Every JS value crosses as a JsRef implementing the bridge ABI
24
+ # (__js_get__/__js_set__/__js_call__/__js_new__), so the VM's duck-typed
25
+ # dispatch drives it unchanged.
26
+ #
27
+ # Extracted from lilac's reference host (test/ruby_spec/quickjs_bridge.rb).
28
+ class Quickjs
29
+ # A handle to a live JS value in the quickjs VM, exposing the same bridge
30
+ # ABI Dommy objects do.
31
+ class JsRef
32
+ attr_reader :jsvalue
33
+
34
+ def initialize(bridge, jsvalue)
35
+ @bridge = bridge
36
+ @jsvalue = jsvalue
37
+ end
38
+
39
+ def __js_get__(key)
40
+ @bridge.wrap_result(@bridge.wb.get(@jsvalue, key))
41
+ end
42
+
43
+ def __js_set__(key, value)
44
+ @bridge.wb.set(@jsvalue, key, @bridge.unwrap_arg(value))
45
+ value
46
+ end
47
+
48
+ def __js_call__(method, args)
49
+ @bridge.wrap_result(@bridge.wb.call(@jsvalue, method, args.map { |a| @bridge.unwrap_arg(a) }))
50
+ end
51
+
52
+ def __js_new__(args)
53
+ @bridge.wrap_result(@bridge.wb.construct(@jsvalue, args.map { |a| @bridge.unwrap_arg(a) }))
54
+ end
55
+ end
56
+
57
+ JSValue = Dommy::Js::Quickjs::WasmBridge::JSValue
58
+
59
+ attr_reader :wb, :global, :window
60
+
61
+ # @param invoke [#call] invoke.(callback_id, ruby_args) -> guest return
62
+ # value; the VM passes its #invoke_callback so JS callbacks route into
63
+ # the wasm's js_invoke_proc.
64
+ # @param window [Dommy::Window] the DOM to render into (a fresh one by default)
65
+ def initialize(invoke:, window: Dommy.new_window)
66
+ @window = window
67
+ @runtime = Dommy::Js::Quickjs::Runtime.new
68
+ @runtime.install_window(@window)
69
+ @runtime.install_browser_globals
70
+ @runtime.define_host_object("document", @window.document)
71
+ @wb = @runtime.wasm_bridge
72
+ @wb.on_invoke do |callback_id, packed_args|
73
+ ruby_args = packed_args.map { |pa| wrap_result(@wb.unpack(pa)) }
74
+ result = invoke.call(callback_id, ruby_args)
75
+ @wb.pack(unwrap_arg(result))
76
+ end
77
+ @global = wrap_result(@wb.global_ref)
78
+ end
79
+
80
+ def document = @window.document
81
+
82
+ # Evaluate real JS source (the JS.eval_javascript escape hatch + the
83
+ # bridge's js_eval import).
84
+ def eval(src)
85
+ wrap_result(@wb.eval_js(src))
86
+ end
87
+
88
+ # A JS function that calls back into the guest's callback table by id.
89
+ def make_callback(callback_id)
90
+ wrap_result(@wb.make_callback(callback_id))
91
+ end
92
+
93
+ # Drive the event loop to quiescence (WHATWG-ordered): drain microtasks,
94
+ # advance Dommy's deterministic scheduler to its next timer, repeat.
95
+ def run_until_idle
96
+ @runtime.run_until_idle
97
+ end
98
+
99
+ def on_log(&) = @runtime.on_log(&)
100
+
101
+ def typeof(value)
102
+ return @wb.typeof(value.jsvalue) if value.is_a?(JsRef)
103
+
104
+ case value
105
+ when nil then "object" # typeof null === "object"
106
+ when Integer, Float then "number"
107
+ when String then "string"
108
+ when true, false then "boolean"
109
+ else "object"
110
+ end
111
+ end
112
+
113
+ def to_string(value)
114
+ return @wb.to_string(value.jsvalue) if value.is_a?(JsRef)
115
+
116
+ value.to_s
117
+ end
118
+
119
+ def strict_equal(a, b)
120
+ return @wb.strict_equal(a.jsvalue, b.jsvalue) if a.is_a?(JsRef) && b.is_a?(JsRef)
121
+
122
+ a == b
123
+ end
124
+
125
+ def instanceof(value, ctor)
126
+ return false unless value.is_a?(JsRef) && ctor.is_a?(JsRef)
127
+
128
+ @wb.instance_of?(value.jsvalue, ctor.jsvalue)
129
+ end
130
+
131
+ # WasmBridge value (primitive | JSValue) -> handle value (primitive | JsRef).
132
+ def wrap_result(value)
133
+ value.is_a?(JSValue) ? JsRef.new(self, value) : value
134
+ end
135
+
136
+ # Handle value (primitive | JsRef | Array | Hash) -> WasmBridge arg.
137
+ def unwrap_arg(value)
138
+ case value
139
+ when JsRef then value.jsvalue
140
+ when Array then value.map { |e| unwrap_arg(e) }
141
+ when Hash then value.transform_values { |e| unwrap_arg(e) }
142
+ else value
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Js
5
+ module Wasmtime
6
+ VERSION = "0.1.0"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,344 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "wasmtime"
4
+ require "json"
5
+ require "dommy"
6
+
7
+ module Dommy
8
+ module Js
9
+ module Wasmtime
10
+ # Raised when mruby raises an unhandled exception during eval / load_bytecode.
11
+ class RubyError < StandardError; end
12
+
13
+ # A wasmtime-rb host for an mruby-wasm-js build (e.g. the Lilac runtime). It
14
+ # implements the two import modules the wasm needs:
15
+ #
16
+ # - `js` the 25-function handle-table interop ABI,
17
+ # routed to a pluggable JS engine (Engines::Quickjs
18
+ # by default) whose values implement the bridge
19
+ # ABI (__js_get__/__js_set__/__js_call__/__js_new__)
20
+ # - `wasi_snapshot_preview1` wasmtime's bundled WASI preview1, with fd_write
21
+ # shadowed to capture mruby stdout/stderr
22
+ #
23
+ # Extracted from lilac's reference host (test/ruby_spec/mruby_wasm.rb).
24
+ class VM
25
+ attr_reader :engine, :wasm_path
26
+
27
+ # @param wasm [String] path to the mruby-wasm-js .wasm (e.g. lilac.wasm)
28
+ # @param engine [#global,#eval,#make_callback,#run_until_idle,…] the JS
29
+ # engine backing the bridge. Defaults to a QuickJS VM bound to a fresh
30
+ # Dommy window (real JS over a real DOM).
31
+ def initialize(wasm:, engine: nil)
32
+ @wasm_path = wasm.to_s
33
+ @engine = engine || default_engine
34
+ @stdout_buf = String.new(encoding: Encoding::BINARY)
35
+ @stderr_buf = String.new(encoding: Encoding::BINARY)
36
+ wire_console!
37
+
38
+ # Handle table. id 0 = undefined/null sentinel; id 1 = the JS global
39
+ # (engine.global). User values (primitives, JsRefs, Ruby Hash/Array)
40
+ # live at ids >= 100.
41
+ @handles = { 0 => nil, 1 => @engine.global }
42
+ @next_handle = 100
43
+ # A JS-side exception captured during js_call (a host callback can't
44
+ # raise out of a wasmtime host function — it would unwind the wasm
45
+ # runtime), handed back to mruby via the js_take_error import so it
46
+ # surfaces as JS::Error instead of crashing the host.
47
+ @pending_error = 0
48
+ boot!
49
+ end
50
+
51
+ # The Dommy document/window the engine renders into — the same DOM the wasm
52
+ # runtime mutates. Host-side tests drive and inspect it via Dommy's Ruby API.
53
+ def document = @engine.document
54
+ def window = @engine.window
55
+
56
+ # Evaluate mruby source. Returns mruby's exit code (0 on success, 1 on
57
+ # error, 2 if the compiler is absent). After the eval, drives the event
58
+ # loop so fibers suspended on `.await` settle (the Ruby-host equivalent of
59
+ # the browser/Node event loop unwinding the stack).
60
+ def eval(source)
61
+ handle = store_handle(source.b)
62
+ rc = @js_eval_handle.call(handle, 0, 0)
63
+ drain_async!
64
+ rc
65
+ ensure
66
+ @handles.delete(handle) if handle
67
+ end
68
+
69
+ # Like #eval but raises RubyError on a non-zero exit code.
70
+ def eval!(source)
71
+ rc = eval(source)
72
+ raise RubyError, "mruby eval failed (rc=#{rc})#{stderr_tail}" unless rc.zero?
73
+
74
+ rc
75
+ end
76
+
77
+ # Load pre-compiled mrbc bytecode. Bytes flow in via the handle table as an
78
+ # array-like (js_load_irep_handle reads length + indexed bytes).
79
+ def load_bytecode(bytes)
80
+ handle = store_handle(bytes.bytes)
81
+ rc = @js_load_irep_handle.call(handle)
82
+ drain_async!
83
+ rc
84
+ ensure
85
+ @handles.delete(handle) if handle
86
+ end
87
+
88
+ # Drive the engine's event loop to quiescence.
89
+ def drain_async! = @engine.run_until_idle
90
+
91
+ # Fire an mruby block registered via `JS.callback`. `args` are Ruby values
92
+ # (already unwrapped by the engine); they cross as a single handle to the
93
+ # args array.
94
+ def invoke_callback(callback_id, args)
95
+ args_handle = store_handle(args)
96
+ result_handle = @js_invoke_proc.call(callback_id, args_handle)
97
+ @handles[result_handle]
98
+ ensure
99
+ @handles.delete(args_handle) if args_handle
100
+ @handles.delete(result_handle) if result_handle && result_handle >= 100
101
+ end
102
+
103
+ # Captured stdout/stderr since the last read (clears the buffer).
104
+ def stdout
105
+ out = @stdout_buf
106
+ @stdout_buf = String.new(encoding: Encoding::BINARY)
107
+ out
108
+ end
109
+
110
+ def stderr
111
+ out = @stderr_buf
112
+ @stderr_buf = String.new(encoding: Encoding::BINARY)
113
+ out
114
+ end
115
+
116
+ private
117
+
118
+ def default_engine
119
+ require_relative "engines/quickjs"
120
+ Engines::Quickjs.new(invoke: method(:invoke_callback))
121
+ end
122
+
123
+ # mruby routes $stdout/$stderr through the JS console (mruby-wasm-js's
124
+ # z_console_io), so puts/warn reach us via the engine's log hook, not WASI
125
+ # fd_write. Severity error/warning → stderr.
126
+ def wire_console!
127
+ @engine.on_log do |log|
128
+ buf = %i[error warning].include?(log.severity) ? @stderr_buf : @stdout_buf
129
+ buf << log.to_s << "\n"
130
+ end
131
+ end
132
+
133
+ def boot!
134
+ @wt_engine = ::Wasmtime::Engine.new(wasm_exceptions: true)
135
+ @module = ::Wasmtime::Module.from_file(@wt_engine, @wasm_path)
136
+
137
+ linker = ::Wasmtime::Linker.new(@wt_engine)
138
+ register_js!(linker)
139
+ register_wasi!(linker)
140
+
141
+ @store = ::Wasmtime::Store.new(@wt_engine, wasi_p1_config: ::Wasmtime::WasiConfig.new)
142
+ @instance = linker.instantiate(@store, @module)
143
+
144
+ init = @instance.export("_initialize")&.to_func
145
+ init&.call
146
+
147
+ @js_invoke_proc = @instance.export("js_invoke_proc")&.to_func
148
+ @js_eval_handle = @instance.export("js_eval_handle")&.to_func
149
+ @js_load_irep_handle = @instance.export("js_load_irep_handle")&.to_func
150
+ raise "wasm is missing js_eval_handle export" unless @js_eval_handle
151
+ end
152
+
153
+ # ----- handle table --------------------------------------------------
154
+
155
+ def store_handle(value)
156
+ id = @next_handle
157
+ @handles[id] = value
158
+ @next_handle += 1
159
+ id
160
+ end
161
+
162
+ def handle_for(value)
163
+ value.nil? ? 0 : store_handle(value)
164
+ end
165
+
166
+ # ----- js.* bridge (25 functions) ------------------------------------
167
+
168
+ def register_js!(linker)
169
+ define = ->(name, params, results, &body) { linker.func_new("js", name, params, results, &body) }
170
+
171
+ define.call("js_eval", %i[i32 i32], [:i32]) do |c, p, l|
172
+ handle_for(@engine.eval(read_str(c, p, l).strip))
173
+ end
174
+ define.call("js_global", [], [:i32]) { |_c| 1 }
175
+ define.call("js_release", [:i32], []) { |_c, _h| nil } # accumulate; see reference note
176
+
177
+ define.call("js_get", %i[i32 i32 i32], [:i32]) do |c, h, p, l|
178
+ obj = @handles[h]
179
+ key = read_str(c, p, l)
180
+ handle_for(host_get(obj, key))
181
+ end
182
+
183
+ define.call("js_set", %i[i32 i32 i32 i32], []) do |c, h, p, l, v|
184
+ host_set(@handles[h], read_str(c, p, l), @handles[v])
185
+ nil
186
+ end
187
+
188
+ define.call("js_call", %i[i32 i32 i32 i32 i32], [:i32]) do |c, h, mp, ml, ap, ac|
189
+ obj = @handles[h]
190
+ method = read_str(c, mp, ml)
191
+ args = read_handle_args(c, ap, ac)
192
+ begin
193
+ handle_for(host_call(obj, method, args))
194
+ rescue StandardError => e
195
+ # Surface to mruby via js_take_error on the next bridge hit (the
196
+ # wasm throws JS::Error); we can't raise out of a host callback.
197
+ @pending_error = store_handle("#{e.class}: #{e.message}")
198
+ 0
199
+ end
200
+ end
201
+
202
+ define.call("js_new", %i[i32 i32 i32], [:i32]) do |c, ctor, ap, ac|
203
+ obj = @handles[ctor]
204
+ args = read_handle_args(c, ap, ac)
205
+ handle_for(obj.respond_to?(:__js_new__) ? obj.__js_new__(args) : nil)
206
+ end
207
+
208
+ define.call("js_to_string_len", [:i32], [:i32]) { |_c, h| string_value(@handles[h]).bytesize }
209
+ define.call("js_to_string_copy", %i[i32 i32 i32], []) do |c, h, ptr, len|
210
+ value = string_value(@handles[h])
211
+ c.export("memory").to_memory.write(ptr, value.byteslice(0, len)) if len.positive?
212
+ nil
213
+ end
214
+ define.call("js_from_string", %i[i32 i32], [:i32]) { |c, p, l| store_handle(read_str(c, p, l)) }
215
+ define.call("js_to_int", [:i32], [:i32]) { |_c, h| Integer(@handles[h] || 0) }
216
+ define.call("js_from_int", [:i32], [:i32]) { |_c, n| store_handle(n) }
217
+ define.call("js_to_float", [:i32], [:f64]) { |_c, h| Float(@handles[h] || 0.0) }
218
+ define.call("js_from_float", [:f64], [:i32]) { |_c, x| store_handle(x) }
219
+ define.call("js_is_null", [:i32], [:i32]) { |_c, h| @handles[h].nil? ? 1 : 0 }
220
+ define.call("js_strict_equal", %i[i32 i32], [:i32]) do |_c, a, b|
221
+ @engine.strict_equal(@handles[a], @handles[b]) ? 1 : 0
222
+ end
223
+ define.call("js_typeof_len", [:i32], [:i32]) { |_c, h| @engine.typeof(@handles[h]).bytesize }
224
+ define.call("js_typeof_copy", %i[i32 i32 i32], []) do |c, h, p, l|
225
+ c.export("memory").to_memory.write(p, @engine.typeof(@handles[h]).byteslice(0, l))
226
+ nil
227
+ end
228
+ define.call("js_inspect_len", [:i32], [:i32]) { |_c, h| inspect_value(@handles[h]).bytesize }
229
+ define.call("js_inspect_copy", %i[i32 i32 i32], []) do |c, h, p, l|
230
+ c.export("memory").to_memory.write(p, inspect_value(@handles[h]).byteslice(0, l))
231
+ nil
232
+ end
233
+ define.call("js_instanceof", %i[i32 i32], [:i32]) do |_c, h, ctor|
234
+ @engine.instanceof(@handles[h], @handles[ctor]) ? 1 : 0
235
+ end
236
+ define.call("js_make_callback", [:i32], [:i32]) do |_c, callback_id|
237
+ store_handle(@engine.make_callback(callback_id))
238
+ end
239
+ define.call("js_handle_count", [], [:i32]) { |_c| @handles.size }
240
+ define.call("js_clone", [:i32], [:i32]) { |_c, h| h }
241
+ define.call("js_take_error", [], [:i32]) do |_c|
242
+ err = @pending_error
243
+ @pending_error = 0
244
+ err
245
+ end
246
+ end
247
+
248
+ # Property/method routing. JS values cross as engine refs (or Dommy
249
+ # objects) implementing the bridge ABI; plain Ruby Array/Hash that cross
250
+ # (callback args, host results) get array-like / hash-like access.
251
+ def host_get(obj, key)
252
+ return obj.__js_get__(key) if obj.respond_to?(:__js_get__)
253
+
254
+ case obj
255
+ when Array
256
+ return obj.size if key == "length"
257
+
258
+ (idx = Integer(key, exception: false)) ? obj[idx] : nil
259
+ when Hash
260
+ obj[key]
261
+ end
262
+ end
263
+
264
+ def host_set(obj, key, value)
265
+ return obj.__js_set__(key, value) if obj.respond_to?(:__js_set__)
266
+
267
+ case obj
268
+ when Array then (idx = Integer(key, exception: false)) && (obj[idx] = value)
269
+ when Hash then obj[key] = value
270
+ end
271
+ nil
272
+ end
273
+
274
+ def host_call(obj, method, args)
275
+ return obj.__js_call__(method, args) if obj.respond_to?(:__js_call__)
276
+ if obj.is_a?(Array) && method == "push"
277
+ return (args.each { |a| obj.push(a) }
278
+ obj.size)
279
+ end
280
+
281
+ nil
282
+ end
283
+
284
+ # ----- WASI ----------------------------------------------------------
285
+
286
+ def register_wasi!(linker)
287
+ ::Wasmtime::WASI::P1.add_to_linker_sync(linker)
288
+ linker.allow_shadowing = true
289
+ linker.func_new("wasi_snapshot_preview1", "fd_write", %i[i32 i32 i32 i32],
290
+ [:i32]) do |c, fd, iovs, n, nwritten|
291
+ mem = c.export("memory").to_memory
292
+ buf = fd == 2 ? @stderr_buf : @stdout_buf
293
+ total = 0
294
+ n.times do |i|
295
+ base = iovs + (i * 8)
296
+ ptr = mem.read(base, 4).unpack1("l<")
297
+ len = mem.read(base + 4, 4).unpack1("l<")
298
+ buf << mem.read(ptr, len) if len.positive?
299
+ total += len
300
+ end
301
+ mem.write(nwritten, [total].pack("l<"))
302
+ 0
303
+ end
304
+ end
305
+
306
+ # ----- memory + value helpers ----------------------------------------
307
+
308
+ def read_str(caller, ptr, len)
309
+ return "" if len <= 0
310
+
311
+ caller.export("memory").to_memory.read(ptr, len).force_encoding("UTF-8")
312
+ end
313
+
314
+ def read_handle_args(caller, ptr, count)
315
+ return [] if count.zero?
316
+
317
+ caller.export("memory").to_memory.read(ptr, count * 4).unpack("l<*").map { |h| @handles[h] }
318
+ end
319
+
320
+ def string_value(value)
321
+ case value
322
+ when String then value
323
+ when true then "true"
324
+ when false then "false"
325
+ when Integer, Float then value.to_s
326
+ when nil then ""
327
+ else @engine.to_string(value)
328
+ end
329
+ end
330
+
331
+ def inspect_value(value)
332
+ @engine.to_string(value)
333
+ rescue StandardError
334
+ value.inspect
335
+ end
336
+
337
+ def stderr_tail
338
+ tail = @stderr_buf.dup.force_encoding("UTF-8")
339
+ tail.empty? ? "" : "\n#{tail}"
340
+ end
341
+ end
342
+ end
343
+ end
344
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dommy"
4
+
5
+ require_relative "wasmtime/version"
6
+ require_relative "wasmtime/vm"
7
+
8
+ module Dommy
9
+ module Js
10
+ # A wasmtime-rb host for mruby-wasm-js builds, bridged to a Dommy DOM. The
11
+ # wasmtime sibling of dommy-js-quickjs: it runs *mruby* (inside wasm on
12
+ # wasmtime) and serves its `js.*` interop from a real JS world — a QuickJS VM
13
+ # bound to Dommy (dommy-js-quickjs) — so `JS.global` is real globalThis,
14
+ # promises/await/fetch work, and the same DOM the wasm mutates is inspectable
15
+ # from Ruby. Any mruby-wasm-js build works; the Lilac component runtime is the
16
+ # primary example.
17
+ #
18
+ # require "dommy"
19
+ # require "dommy/js/wasmtime"
20
+ #
21
+ # vm = Dommy::Js::Wasmtime.boot(
22
+ # wasm: "build/app.wasm",
23
+ # html: File.read("index.html"),
24
+ # sources: %w[lib/foo.rb lib/bar.rb …], # mruby source files
25
+ # entrypoint: "Lilac.start", # app boot call (or nil)
26
+ # ) do |engine|
27
+ # engine.eval(<<~JS) # seed the JS world
28
+ # globalThis.fetch = async (u) => new Response(DATA_JSON, {…});
29
+ # JS
30
+ # end
31
+ # vm.document.query_selector(".app") # driven by the mruby app
32
+ module Wasmtime
33
+ class Error < StandardError; end
34
+
35
+ # Build a VM over a Dommy DOM, optionally seed the JS world (fetch stub,
36
+ # globals), load the mruby source files, and run an app boot call — the Ruby
37
+ # equivalent of a browser bootstrap script.
38
+ #
39
+ # @param wasm [String] path to the mruby-wasm-js .wasm
40
+ # @param html [String, nil] HTML to parse into the window's document
41
+ # @param window [Dommy::Window, nil] explicit window (overrides html)
42
+ # @param sources [Array<String>] mruby source file paths (loaded in order)
43
+ # @param entrypoint [String, nil] mruby expression to eval after sources
44
+ # load (e.g. "Lilac.start" to mount components); nil to skip
45
+ # @yield [engine] optional hook to seed the JS world before sources load
46
+ # @return [VM]
47
+ def self.boot(wasm:, html: nil, window: nil, sources: [], entrypoint: nil)
48
+ require_relative "wasmtime/engines/quickjs"
49
+ win = window || Dommy.parse(html.to_s)
50
+ vm = nil
51
+ engine = Engines::Quickjs.new(invoke: ->(id, args) { vm.invoke_callback(id, args) }, window: win)
52
+ vm = VM.new(wasm: wasm, engine: engine)
53
+ yield engine if block_given?
54
+ Array(sources).each { |path| vm.eval!(::File.read(path.to_s)) }
55
+ vm.eval!(entrypoint) if entrypoint
56
+ vm
57
+ end
58
+ end
59
+ end
60
+ end
metadata ADDED
@@ -0,0 +1,96 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dommy-js-wasmtime
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - TAKAHASHI Masayoshi
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: dommy
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.9'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.9'
26
+ - !ruby/object:Gem::Dependency
27
+ name: dommy-js-quickjs
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.9'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.9'
40
+ - !ruby/object:Gem::Dependency
41
+ name: wasmtime
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 45.0.0
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: 45.0.0
54
+ description: A wasmtime-rb host for mruby-wasm-js builds (the Lilac component runtime
55
+ is the primary example). It reimplements the mruby-wasm-js `js.*` handle-table ABI
56
+ and the WASI preview1 surface in pure Ruby, routing JS interop into Dommy's `__js_get__/__js_set__/__js_call__/__js_new__`
57
+ bridge protocol. The wasmtime sibling of dommy-js-quickjs — but instead of running
58
+ JavaScript it drives Dommy from mruby running inside wasm.
59
+ email:
60
+ - takahashimm@gmail.com
61
+ executables: []
62
+ extensions: []
63
+ extra_rdoc_files: []
64
+ files:
65
+ - CHANGELOG.md
66
+ - LICENSE.txt
67
+ - README.md
68
+ - lib/dommy/js/wasmtime.rb
69
+ - lib/dommy/js/wasmtime/engines/quickjs.rb
70
+ - lib/dommy/js/wasmtime/version.rb
71
+ - lib/dommy/js/wasmtime/vm.rb
72
+ homepage: https://github.com/takahashim/dommy-js-wasmtime
73
+ licenses:
74
+ - MIT
75
+ metadata:
76
+ source_code_uri: https://github.com/takahashim/dommy-js-wasmtime
77
+ changelog_uri: https://github.com/takahashim/dommy-js-wasmtime/blob/main/CHANGELOG.md
78
+ rubygems_mfa_required: 'true'
79
+ rdoc_options: []
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: 3.2.0
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ requirements: []
93
+ rubygems_version: 4.0.10
94
+ specification_version: 4
95
+ summary: Run mruby-wasm-js builds on wasmtime-rb, bridged to a Dommy DOM
96
+ test_files: []