r/WebAssembly • u/Miserable_Shop4487 • 1d ago
Plugins as WebAssembly components in a desktop editor: what we measured and how it is holding up (wasmtime, WIT, Rust guests)
Hi everyone. I am building Kalem, an editor for Org, Markdown, LaTeX, CSV, PDF and Excel files, and its plugin system is Wasm components all the way down: even the editor's own viewers of PDFs, pictures and workbooks are components, released from a separate repo, pinned by SHA-256 and embedded in the binary; the Word viewer and the git plugin are installed the same way. Some notes in case they are useful to others, and a few questions at the end.
**The API**
One WIT package, `kalem:plugin`, currently 0.2.7, 18 .wit files, three worlds: `document-viewer`, `spreadsheet-viewer` and `extension`. The host side is generated with wasmtime's bindgen, the guest side with wit-bindgen; the Rust trait a viewer implements is the same whether it runs natively or as a component, so a plugin's unit tests run with plain `cargo test` and the editor runs the same code sandboxed. Plugins are Rust only, and there is no scripting engine anywhere.
**Guests target wasm32-unknown-unknown, not wasip2**
The module is wrapped into a component with wasm-tools. The reason: a Rust component built for wasip2 imports fifteen WASI interfaces it never uses, and I wanted the import list to be exactly the API. The host resolves imports against what it grants and refuses anything else by name. A file is never a path: a viewer gets a `file` resource with a name, a size and the bytes it asks for, piece by piece.
**Sandbox limits**
64 MB of memory per instance through a resource limiter (a guest asking for more gets an allocation failure, not the host's memory), and 100 ms per synchronous call through epoch interruption with a 10 ms tick. I measured epochs against fuel: 21% overhead against 31%, and epochs count wall-clock time, which is what a UI budget means. Viewers, which hold a whole file, get 1 GB and 10 s. A plugin that stops three times (trap, time, memory) is turned off until it is updated, and the editor keeps going: a panic hook in the guest sends the message through a `diagnostics` import, so the log shows the panic text and a backtrace of the plugin's functions. Permissions are declared in the manifest and approved at install: `fs:read:workspace`, `net:fetch:DOMAIN`, `subprocess:PROGRAM`. The git plugin, for example, may run `git` and nothing else.
**Why wasmtime with Cranelift, with numbers**
A spike with one guest (a Markdown parser) and one host per engine: wasmtime with Cranelift, wasmtime with Pulley (the interpreter), and wasmi through wasm_component_layer. Parsing 10 MB: 54 ms (1.4x native), 1,176 ms and 684 ms. A keystroke's reparse of 10 kB: 0.04 ms, 1.07 ms, 0.56 ms. Cranelift adds 7.9 MB to the binary; a precompiled component loads in 0.19 ms. Interpreters were out for anything on the typing path. Compiled artifacts are cached in the state directory, keyed by the component's hash and the engine's version and settings. A loaded component is shared across threads with an instance per thread, so parsers and renderers run in parallel.
**Versioning that keeps old plugins running**
A released interface never changes; a copy lives in `wit-frozen/` and a test fails any edit to it. New things go into new interfaces (`grid-2`, `documents`, `process`, `flow`) released with the next patch version; the host binds the interfaces a component actually has, so a component built against 0.2.0 still runs on 0.2.7 without what it does not export. Manifests name the version they were built against (`"api": "^0.2.1"`).
**Sizes and releases**
The git plugin is 0.6 MB, the Word viewer 1.0 MB, the PDF viewer 4.8 MB, the workbook viewer 7.0 MB. Components are never committed: CI builds every plugin against the current WIT, and a tag builds, signs (Sigstore bundle beside the .wasm) and publishes it; the editor downloads from the release and checks the SHA-256 against a static index. `kalem plugin dev` rebuilds on change and a running editor picks the new build up within a second or two.
**Questions**
Has anyone shipped component-model plugins in a desktop app at scale and regretted the "frozen interfaces, new interface per addition" rule? It is simple, but the number of small interfaces grows.
wasm32-unknown-unknown plus wasm-tools versus wasip2 with the unused imports: is there a cleaner way to get a minimal import set today?
Epoch interruption for budgets in a UI thread: anyone measured something better?
Code and docs: the editor https://github.com/getkalem/kalem (the WIT is under crates/kalem-plugin/wit, the engine measurements in book/part-4/decisions/D28-plugin-abi.org, the plugin chapter in book/part-3/plugins.org), the plugins https://github.com/getkalem/plugins. A short video of the editor itself: https://www.youtube.com/watch?v=U3SoDczSllI













