---
name: jco
description: Transpile Wado Wasm components to JS with jco, then run, debug, and benchmark them on Node. Use for anything that involves jco, Node, or a browser as the host.
---

# 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:

```js
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:

```sh
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/`:

```sh
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:

  ```js
  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                         |
