Background
Typst provides a WebAssembly (Wasm) plugin protocol to incorporate packages from other programming languages. That’s fantastic!
However, if the package author has not handled errors properly, you may encounter the notorious error:
error: plugin panicked: wasm `unreachable` instruction executed
┌─ hello.typ:17:21
│
17 │ let decoded = cbor(p.complex_data(encoded))
│ ^^^^^^^^^^^^^^^^^^^
The error message itself is cryptic. What’s worse is that Typst does not show the error’s backtrace inside the plugin. You can’t know whether the error originates from your code, one of your dependencies, or the compiler. It could make debugging extremely difficult.
Fortunately, that’s a limitation of wasmi, not an intrinsic limitation of Wasm. (wasmi is the Wasm interpreter used by Typst.)
We can use other interpreters that show backtraces to call our plugins. Plugin functions are pure (as required by Typst’s protocol), so it’s usually easy to reproduce the error with other interpreters. In this post, we’ll use Node.js to call plugins.
Result
If you follow the steps below, you’ll see the error’s backtrace like this:
Loaded hello.wasm.
Input: <Buffer a2 61 78 01 61 79 f9 40 00>
wasm://wasm/main-007ba6f2:1
RuntimeError: unreachable
at main.runtime.runtimePanicAt (wasm://wasm/main-007ba6f2:wasm-function[176]:0x16261)
at main.runtime.nilPanic (wasm://wasm/main-007ba6f2:wasm-function[53]:0x3dbb)
at main.runtime.setupDeferFrame (wasm://wasm/main-007ba6f2:wasm-function[154]:0x144cd)
at main.(*sync.Map).Load (wasm://wasm/main-007ba6f2:wasm-function[153]:0x14201)
at main.github.com/fxamacker/cbor/v2.getTypeInfo (wasm://wasm/main-007ba6f2:wasm-function[696]:0x6cfc2)
at main.complex_data (wasm://wasm/main-007ba6f2:wasm-function[1051]:0xa95d8)
at file:///***/run_typst_plugin.ts:48:21
Node.js v24.14.1
runtime.*are the codes generated by the compiler (TinyGo in this case).github.com/fxamacker/cbor/v2is one of your dependencies.complex_datais the function you implemented in the plugin.
Prerequisite
Before proceeding, you have to figure out the exact call to your plugin (function name and arguments) that triggers the error. This post will only cover the backtrace inside the plugin.
Fortunately, Typst shows the error backtrace outside the plugin (i.e. among the Typst codes), so that shouldn’t be so hard. By the way, Tips on debugging Typst code, including the dark magic might help.
Ideally you will simplify the error as a small self-constrained Typst document like the following. We’ll take it as an example in the steps below.
#{
let p = plugin("hello.wasm")
let input = cbor.encode((x: 1, y: 0.2))
p.complex_data(input) // Plugin panicked here
}
Steps to call the plugin in Node.js
1. Export the input bytes from Typst
At present, you can use the new bundle feature to export bytes:
#asset("input.cbor", cbor.encode((x: 1, y: 2.0)))
typst compile input.typ . --format=bundle --features=bundle
Thanks to Xiretza, when Typst v0.16.0 is released in the future, you can also use typst eval --format raw and pipe the bytes directly to the later steps:
typst eval 'cbor.encode((x: 1, y: 2.0))' --format raw | node …
(You may also download a dev build to use --format raw. Check the latest build in Build typst · GitHub)
2. Save the Node.js script
Save this as run_typst_plugin.ts.
run_typst_plugin.ts
/**
* Simulate Typst's Wasm plugin protocol.
*
* Example usage:
* $ cat input.cbor \
* | node --experimental-strip-types run_typst_plugin.ts hello.wasm complex_data \
* > output.cbor
* Loaded hello.wasm.
* Input: <Buffer a2 61 78 01 61 79 f9 40 00>
* complex_data returned 0
* Output: <Buffer fb 40 08 00 00 00 00 00 00>
*
* Note: If this script doesn't work anymore, check https://forum.typst.app/t/show-backtrace-for-plugin-panicked-wasm-unreachable-instruction-executed-using-node-js/10130 for updates.
*/
import { readFile } from "node:fs/promises"
import process from "node:process"
import { buffer } from 'node:stream/consumers'
const [pluginPath, funcName] = process.argv.slice(2)
const input = await buffer(process.stdin)
let output: Buffer | null = null
let memory: WebAssembly.Memory
const imports = {
typst_env: {
wasm_minimal_protocol_write_args_to_buffer(ptr: number): void {
new Uint8Array(memory.buffer, ptr, input.length).set(input)
},
wasm_minimal_protocol_send_result_to_host(ptr: number, len: number): void {
output = Buffer.from(new Uint8Array(memory.buffer, ptr, len))
},
},
}
const { instance: plugin } = await WebAssembly.instantiate(await readFile(pluginPath), imports)
memory = plugin.exports.memory as WebAssembly.Memory
console.error(`Loaded ${pluginPath}.`)
console.error("Input:", input)
const func = plugin.exports[funcName] as (...arg: number[]) => number
const returnValue = func(input.length)
console.error(`${funcName} returned`, returnValue)
console.error('Output:', output)
process.stdout.write(output!)
3. Call the plugin in Node.js
Run:
cat input.cbor \
| node --experimental-strip-types run_typst_plugin.ts hello.wasm complex_data \
> output.cbor
input.cboris the file you generated in the first step.hello.wasmis your Wasm plugin.complex_datais the problematic function in the plugin.
If the plugin panics, you’ll see the backtrace. See the Result section above for an example.
If it exits successfully, you’ll get the output bytes saved in output.cbor, and you can inspect it further with typst eval:
$ cat … | node … > output.cbor
Loaded hello.wasm.
Input: <Buffer a2 61 78 01 61 79 f9 40 00>
complex_data returned 0
Output: <Buffer fb 40 08 00 00 00 00 00 00>
$ typst eval 'cbor("output.cbor")'
3.0