Running Wado on Node via jco
jco (bytecodealliance) transpiles a Wasm component into JS + core Wasm so it
runs on a plain Wasm engine (V8/Node, browsers) instead of a Component Model
runtime. Wado targets WASI P3; this doc covers what the released jco does for
Wado today and what is still blocked.
TL;DR
- Use the released npm jco as a library (
scripts/jco, mise run jco-*).
- Compile Wado with
-f no-wide-arithmetic — V8 has no wide-arithmetic
proposal, and float formatting / i128 emit it.
- Run on Node 26+ — stable JSPI, no flag.
- Compute and filesystem programs work (including float formatting). A
filesystem program needs its preopen set with
_setPreopens (see below).
- Quick check:
mise run jco-hello-released. Benchmark:
mise run jco-bench <program.wado>.
Environment
- Node 26+ required. Node 26 (V8 14.6) ships stable JSPI
(
WebAssembly.Suspending), so no flag is needed; the repo pins node = "26"
in mise.toml. Node 24 needs --experimental-wasm-jspi; Node 22's older JSPI
fails (WebAssembly.Suspending is not a constructor).
/tmp pitfall: outside the repo, node may resolve to a system Node 22
(mise activation is path-scoped). Run inside the repo, or use the pinned
binary's absolute path.
- V8 has no wide-arithmetic in any version (no flag exists). This is a V8
gap, not a jco one — handled by
-f no-wide-arithmetic (see below).
Vendor-free pipeline (scripts/jco)
Released @bytecodealliance/jco as a library. transpile-released.mjs is a
plain transpile() — jco's own preview3-shim serves every import a Wado
program makes, and the output links to it through a node_modules symlink the
script writes beside the files.
The shim writes stdout from a worker that is torn down once the event loop
empties, so lines still queued when run() resolves are lost, and a runner that
calls process.exit on that promise loses more. Hold the loop open for a
second after run() resolves. The lines of one run can also arrive out of
order.
A program that reads files needs a preopen. Set it before importing the
transpiled module, mapping the guest's . to a host directory as
wado run --dir <dir>::. does:
import { _setPreopens } from "@bytecodealliance/preview3-shim/filesystem";
_setPreopens({ ".": "/abs/host/dir" });
const m = await import("./out/prog.js");
await m.run.run();
await new Promise((r) => setTimeout(r, 1000));
Import the shim the output links to through its node_modules symlink. A
second copy of the shim holds its own preopen table, which the program never
reads.
mise tasks:
mise run jco-deps # npm install released jco under scripts/jco
mise run jco-transpile-released foo.wasm [out-dir]
mise run jco-hello-released # compile + transpile + run hello on Node
mise run jco-bench <program.wado> [runs] # compile -f no-wide-arithmetic, transpile, self-time
Released jco status (verified at 1.30.0)
| Capability | Status |
|---|
| Transpile (GC component) | ✅ works, wasi:http/service included |
| JSPI | ✅ native (Node 26 no flag; Node 24 needs the flag) |
| Wide-arithmetic component | ❌ transpile rejects it (wide arithmetic support is not enabled); even if forced, V8 rejects the opcode at runtime → use -f no-wide-arithmetic |
| Stdout via stream | ✅ jco's own shim delivers it, if the runner holds the event loop open after run() resolves |
| Filesystem read stream | ✅ reads through a preopen set with _setPreopens (zlib benchmark verified) |
wide-arithmetic (-f no-wide-arithmetic)
Wado emits the Wasm wide-arithmetic proposal (i64.mul_wide_u/s, i64.add128,
i64.sub128) for float formatting (core:prelude/fpfmt.wado) and i128. No
V8 implements it (checked through Node 26; no flag, --wasm-staging no help),
so any component containing those opcodes fails WebAssembly.compile with
invalid numeric opcode: 0xfc16.
-f no-wide-arithmetic rewrites each such builtin call, before NIR, to a
32-bit-limb software form in core:rt (i64_mul_wide_u_soft and so on;
wado-compiler/src/lower/wide_arith.rs). NIR and WIR then show ordinary calls,
which the optimizer inlines. Compile every Node-bound Wado program with this
flag — a bare println of a float needs it.
WASI shims
BA ships a preview3-shim implementing P3 cli / clocks / filesystem /
http, with a browser build beside the Node one. A plain jco transpile wires
it, and stdout, float formatting, wasi:random, MonotonicClock and an HTTP
handle all run through it unaided. Its browser cli is unimplemented
(throw new Todo()), which is why the playground keeps a hand-written one.
A hand-written clock is where precision goes missing: system-clock.now is an
instant carrying sub-second nanoseconds, and get-resolution a bare duration.
Benchmarking on Node
mise run jco-bench <program.wado> [runs] compiles with -f no-wide-arithmetic,
transpiles via the released pipeline, and runs the program self-timed runs
times (default 3; keep the best). The benchmark programs already self-time via
core:benchmark + MonotonicClock and print their own
throughput line, so no host timing is needed.
JCO_PREOPEN names the host directory granted as the program's . (default:
the repository root). The benchmarks read their data relative to benchmark/:
mise run jco-bench benchmark/mandelbrot/mandelbrot.wado
JCO_PREOPEN=benchmark mise run jco-bench benchmark/zlib/zlib_bench.wado
| Benchmark | Wado on Node (jco) | Wado on wasmtime |
|---|
| mandelbrot | ~4.0 M px/s | ~4.2 M |
| sieve | ~150 M numbers/s | ~64 M (V8 ~2.3× faster) |
| fts | ~12 M conv/s | — |
| zlib compress | ~65 MB/s | — |
| zlib decompress | ~220 MB/s | — |
The Node column is mise run jco-bench <program.wado> as above; the wasmtime
column is wado run -O2 <program.wado> from benchmark/, as
benchmark/mise.toml runs it.
Compute throughput on V8 lands within ~5–10% of wasmtime (sieve is much faster
on V8). Numbers are indicative on a noisy cloud VM; keep best-of-3.
The other filesystem benchmarks (json-{twitter,canada,catalog}, sqlite-parse,
syntax-highlight, cbor) load their data the same way, outside the timed loop,
but have not been run on Node yet.
Known blockers (jco / V8 gaps)
wide-arithmetic (V8)
Not jco. Handled by -f no-wide-arithmetic.
Reusing an instance (jco)
An instance serves a couple of calls, then the next suspends on a stream read
whose host injection is never driven (JCO_DEBUG=1 ends at
[StreamEnd#copy()] blocked). cloudflare-worker/ builds one per request.
Debugging jco runtime errors
Transpiled output is one large JS file. Useful canonical-builtin → JS mappings:
| Wasm builtin | jco JS function | Notes |
|---|
stream.write | streamWrite() | JSPI Suspending |
stream.read | streamRead() | JSPI Suspending |
canon lower (async) | _lowerImportBackwardsCompat() | JSPI Suspending |
task.return | taskReturn() | |
future.new (lift) | _genStreamHostInjectFn / createReadableStreamEnd | host→guest wiring |
Techniques
-
Catch swallowed errors — jco's async machinery loses errors as unhandled
rejections:
process.on('unhandledRejection', e => { console.error('UNHANDLED:', e); process.exit(1); });
-
JCO_DEBUG=1 node run.mjs — verbose trace of every instruction/trampoline.
A trailing [ComponentAsyncState#suspendTask()] with no progress = a
rendezvous deadlock.
-
Inject logging by string-replacing a function header in the transpiled JS
(e.g. add console.error(...) to streamRead/generatedStreamHostInject).
-
Timeout hangs: timeout 12 node run.mjs so a deadlock doesn't wedge.
Common error patterns
| Error | Likely cause |
|---|
invalid numeric opcode: 0xfc16 | Wide-arithmetic — recompile with -f no-wide-arithmetic |
WebAssembly.Suspending is not a constructor | Node < 24, or Node 22 picked up outside the repo (use Node 26) |
FutureReadableEnd is not defined | Future-end classes not injected (run via transpile-released.mjs) |
| stdout empty or missing lines | The runner exits or empties the event loop before the shim's worker flushes (wait after run()) |
Bare unreachable from a program that reads files | No preopen set (_setPreopens), or a path outside it; the panic message is lost with the exit |
wide arithmetic support is not enabled (transpile) | Compile with -f no-wide-arithmetic; V8 cannot run the opcodes either |
| Hang / timeout | JSPI Suspending missing on a trampoline, or a stream rendezvous deadlock |