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 +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +99 -0
- data/lib/dommy/js/wasmtime/engines/quickjs.rb +149 -0
- data/lib/dommy/js/wasmtime/version.rb +9 -0
- data/lib/dommy/js/wasmtime/vm.rb +344 -0
- data/lib/dommy/js/wasmtime.rb +60 -0
- metadata +96 -0
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
|
+
[](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,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: []
|