Running QIP In Ruby
Ruby can run a QIP component with the Wasmtime Ruby gem and a small wrapper around the QIP memory contract. The app loads the .wasm file from disk, writes UTF-8 input into its memory, calls render, and copies the UTF-8 output back into a Ruby String.
QIP uses component to mean a small unit that follows a QIP contract. A QIP component is currently a WebAssembly Core module, not a WebAssembly Component Model component. Use Wasmtime::Module and Wasmtime::Instance. QIP components also have no WASI imports, so this example does not configure WASI or expose host capabilities.
Add Wasmtime #
The current Wasmtime gem requires Ruby 3.1 or newer. Add it to the application's Gemfile:
source "https://rubygems.org"
gem "wasmtime", "~> 46.0"
Install the bundle:
bundle install
Wasmtime publishes precompiled gems for common macOS, Linux, and Windows targets. Add every production target to Gemfile.lock so Bundler resolves the native artifact before deployment:
bundle lock --add-platform arm64-darwin
bundle lock --add-platform x86_64-linux
Use the platforms the application actually deploys to. A platform without a precompiled gem needs a Rust toolchain to build the native extension. Once the example works in your app, keep the tested Wasmtime version and deployment platforms in Gemfile.lock.
Put the downloaded component next to the Ruby script:
markdown-example/
├── Gemfile
├── Gemfile.lock
├── gfm-commonmark.0.31.2.wasm
└── markdown.rb
Render Markdown #
Create markdown.rb:
require "wasmtime"
class MarkdownRenderer
def initialize(wasm_path)
@engine = Wasmtime::Engine.new
@module = Wasmtime::Module.from_file(@engine, wasm_path)
@store = Wasmtime::Store.new(@engine)
@instance = Wasmtime::Instance.new(@store, @module, [])
@memory = export("memory").to_memory
@input_ptr = export("input_ptr").to_func
@input_cap = export("input_utf8_cap").to_func
@output_ptr = export("output_ptr").to_func
@render = export("render").to_func
end
def markdown_to_html(markdown)
source = markdown.encode(Encoding::UTF_8)
capacity = @input_cap.call
if source.bytesize > capacity
raise ArgumentError,
"Markdown input exceeds component capacity: " \
"#{source.bytesize} > #{capacity}"
end
@memory.write(@input_ptr.call, source)
output_size = @render.call(source.bytesize)
@memory.read_utf8(@output_ptr.call, output_size)
end
private
def export(name)
@instance.export(name) ||
raise("Component does not export #{name}")
end
end
wasm_path = File.expand_path(
"gfm-commonmark.0.31.2.wasm",
__dir__
)
renderer = MarkdownRenderer.new(wasm_path)
markdown = <<~MARKDOWN
# Project status
| Feature | Status |
| --- | --- |
| Ruby host | Ready |
- [x] Load the component from disk
- [x] Render **GFM**
MARKDOWN
puts renderer.markdown_to_html(markdown)
Run it from the project directory:
bundle exec ruby markdown.rb
The output begins with the rendered HTML:
<h1>Project status</h1>
<table>
<thead>
<tr>
<th>Feature</th>
<th>Status</th>
</tr>
How The Boundary Maps To Ruby #
The loader is runtime-specific; the QIP calls are not:
Wasmtime::Module.from_file validates and compiles the WebAssembly Core module.
Wasmtime::Instance.new instantiates it with no imports.
- Ruby encodes the Markdown as UTF-8 and checks
input_utf8_cap().
Memory#write copies those bytes to input_ptr().
render(input_size) returns the number of output bytes.
Memory#read_utf8 copies and validates that many bytes from output_ptr().
Resolving each export during initialization catches a missing export before the first render. Converting it with to_func or to_memory also rejects an export of the wrong kind.
This wrapper trusts the known-valid GFM component and checks only the caller-controlled input size. A host accepting arbitrary Wasm has a different validation boundary; see Known And Untrusted Components.
Traps, Threads, And Reuse #
If render traps, Wasmtime raises a Wasmtime::Trap. Treat that render as failed and do not read the output buffer; it may contain stale or partial bytes.
The class compiles and instantiates the component once, then reuses it. A MarkdownRenderer owns mutable component memory and is not safe to call concurrently. Give each thread, worker, or pool entry its own renderer instead of sharing one instance.
Wasmtime calls hold Ruby's Global VM Lock by default. The gem can release it with to_func(gvl: false), but that mode requires a separate Wasmtime::Store for every calling thread. Use the default until profiling shows that long WebAssembly calls are blocking useful Ruby work; violating the store-per-thread requirement can cause undefined behavior.
Preloading a renderer before a process server forks can also produce unclear ownership of native runtime state. Prefer constructing renderers in each worker after the fork.
When To Use Something Else #
Keep ordinary Ruby code in charge of database access, HTTP calls, authentication, logging, and application workflow. QIP fits the deterministic Markdown-to-HTML step and gives that code no access to the rest of the application.
Use a normal Ruby gem when the transform intentionally needs Ruby objects, callbacks, or framework services throughout its execution. Use the qip CLI as a subprocess when adding a native extension to the application's bundle or deployment image is a worse tradeoff than process startup and IPC.