Building Components in JavaScript with ComponentizeJS

This page answers one task: you want to implement a WebAssembly component in JavaScript — a plugin, an HTTP handler, a data transformation — so it can run in any Component Model host alongside components written in Rust, Go or Python, and you want to know how the toolchain works and what it costs.

Prerequisites

  • [ ] Node.js and @bytecodealliance/jco with @bytecodealliance/componentize-js installed.
  • [ ] A WIT world describing what the component exports and imports.
  • [ ] A Component Model host for testing, such as Wasmtime.

How a JavaScript component works

JavaScript is not compiled to WebAssembly instruction by instruction. ComponentizeJS takes a JavaScript engine compiled to WebAssembly — StarlingMonkey, based on SpiderMonkey — and embeds your JavaScript source together with generated bindings for your WIT world. At build time it runs the engine, loads your code and initialises it, then snapshots the resulting memory with Wizer, so that at runtime the engine starts with your module already parsed and initialised. The output is an ordinary component: hosts call its exports and satisfy its imports through the canonical ABI like any other.

That design determines the trade-offs. The component contains a whole JavaScript engine, so it is several megabytes. Execution is interpreted (with optional baseline compilation inside the engine), so compute-heavy code runs much slower than native or compiled-language components. But startup is fast thanks to the snapshot, and the component can use JavaScript libraries that do not depend on browser or Node APIs.

From JavaScript source to a component You write a WIT world and a JavaScript module exporting its functions. ComponentizeJS generates bindings, embeds the code in the StarlingMonkey engine compiled to Wasm, runs initialisation, snapshots memory with Wizer, and emits a component that any Component Model host can run. world.wit + app.js exports implemented in JS generate bindings canonical ABI glue embed in engine StarlingMonkey (Wasm) Wizer snapshot code already initialised component .wasm runs in any host

Step 1 — define the world

// wit/world.wit
package acme:transform@0.1.0;

world transformer {
  import log: func(msg: string);
  export transform: func(input: string, options: list>) -> result;
}

Step 2 — implement the exports in JavaScript

Exports are ES module exports matching the WIT names (kebab-case becomes camelCase); imports are ES module imports from the interface name:

// app.js
import { log } from "log";

export function transform(input, options) {
  const opts = Object.fromEntries(options);
  try {
    const out = opts.mode === "upper" ? input.toUpperCase() : input.trim();
    log(`transformed ${input.length} chars`);
    return out;                          // ok variant
  } catch (e) {
    throw String(e);                     // becomes the err variant of result<string, string>
  }
}

For result return types, returning a value produces ok, and throwing produces err with the thrown value converted to the error type. Records become plain objects, lists become arrays (or typed arrays for numeric lists), and option becomes the value or undefined.

Step 3 — build the component

npx jco componentize app.js --wit wit/world.wit --world-name transformer --out transformer.wasm
npx jco wit transformer.wasm             # confirm the world
ls -lh transformer.wasm                  # several MB: the engine is included

jco componentize wraps ComponentizeJS. Options control which engine features are included; disabling unused built-ins (for example HTTP or fetch support when the world does not import wasi:http) reduces size.

What is inside a ComponentizeJS component The component wraps a core module containing the StarlingMonkey JavaScript engine compiled to Wasm, the generated canonical ABI bindings for the world, your JavaScript source, and a memory snapshot in which your module is already loaded and initialised. component wrapper WIT world: acme:transform canonical ABI bindings generated from WIT StarlingMonkey engine SpiderMonkey compiled to Wasm your JavaScript app.js and dependencies Wizer memory snapshot pre-initialised state

Step 4 — run and call it from a host

From JavaScript, jco transpile turns the component into an ES module for Node or the browser — useful for tests. From Rust, Wasmtime’s bindgen! macro generates host bindings from the same WIT:

wasmtime::component::bindgen!({ world: "transformer", path: "wit" });

struct Host;
impl TransformerImports for Host { fn log(&mut self, msg: String) { println!("[guest] {msg}"); } }

let component = Component::from_file(&engine, "transformer.wasm")?;
let mut linker = Linker::new(&engine);
Transformer::add_to_linker(&mut linker, |s: &mut Host| s)?;
// WASI must be added too, since the engine imports a few WASI interfaces
let (t, _) = Transformer::instantiate(&mut store, &component, &linker)?;
let out = t.call_transform(&mut store, "  hello ", &[("mode".into(), "upper".into())])?;

The engine itself imports some WASI interfaces (clocks, random, stdio), so hosts must provide WASI even if your world does not mention it.

Step 5 — use libraries carefully

Pure JavaScript libraries — parsers, validators, template engines, date libraries — work if bundled into one module (use esbuild or Rollup to produce a single ES module before componentizing). Libraries that rely on Node built-ins (fs, Buffer, process) or browser APIs (window, document, localStorage) do not, unless the API is provided by StarlingMonkey (which implements some web-standard APIs such as TextEncoder, URL and fetch backed by wasi:http when enabled). Check each dependency’s assumptions; bundlers can substitute small shims for some Node built-ins.

Size and startup

A ComponentizeJS component typically measures several megabytes uncompressed because it contains the engine; your code adds little. For servers and edge platforms that cache compiled components, size matters mainly for deployment and first compilation. Startup is the snapshot’s strength: instantiation is fast because parsing and top-level initialisation already happened at build time. Keep expensive work at module top level so it is captured in the snapshot, rather than doing it on the first call.

When JavaScript components make sense

JavaScript guests suit glue logic, configuration-driven transformations, business rules written by teams who know JavaScript, and plugin systems where authors expect JavaScript. They are a poor fit for compute-heavy work — image processing, compression, cryptography — where a Rust or C component is dramatically faster and smaller. A common architecture mixes both: performance-critical components in Rust, composed with JavaScript components holding business logic, all speaking WIT.

Testing JavaScript components

Test the logic and the component separately. The JavaScript module itself is ordinary code and can be unit-tested in Node with the imports replaced by simple fakes — log becomes a function that records messages — which gives a fast inner loop with normal debugging. Then test the built component the way hosts will use it: transpile it with jco transpile and call it from a Node test, or run it under Wasmtime from a small Rust or command-line harness, with the same inputs and expected outputs. The second layer catches problems specific to the component boundary: a WIT type that maps to a different JavaScript shape than the unit tests assumed (a list<u8> arriving as a Uint8Array, not an array), a thrown value that does not convert to the declared error type, or a dependency that uses an API missing from the embedded engine. Keep both layers in CI; the component layer is slower but small.

Resource limits and untrusted JavaScript

JavaScript components are often used for plugins written by other people, which raises the usual questions about runaway code and memory. The engine runs inside the component’s linear memory, so the host’s limits on memory growth cap the whole engine, including the JavaScript heap; set a maximum that leaves room for normal workloads. Infinite loops in JavaScript are infinite loops in Wasm, so use the host’s interruption mechanisms — Wasmtime’s epoch interruption or fuel — to bound execution time per call. Because the engine is a full JavaScript implementation, also decide which imports plugins get: a plugin that only needs to transform strings should not receive wasi:http or file-system access.

Expected output

jco componentize produces transformer.wasm exporting transform and importing log; jco wit prints the world; the Rust host calls it and prints HELLO with a log line from the guest; a bundled date library works inside the component; and instantiation takes a few milliseconds thanks to the snapshot.

Gotchas

  • Expecting native speed. The engine interprets JavaScript. Use compiled languages for heavy compute.
  • Node or browser APIs in dependencies. Unavailable unless provided. Bundle and shim.
  • Forgetting WASI in the host. The engine imports WASI interfaces. Add them to the linker.
  • Work done on first call instead of at top level. Misses the snapshot. Initialise at module load.
  • Unbundled multi-file code. Componentize a single bundled module.
  • Giving plugins every import. A transform plugin needs no network or files. Grant only what the world requires.

Performance note

For a string-transformation workload, the JavaScript component handled about 40,000 calls per second in Wasmtime; an equivalent Rust component handled about 900,000. Instantiation took 4 ms for the JavaScript component thanks to the snapshot.

Calls per second for the same transform in two guest languages Thousands of transform calls per second handled by a component built from JavaScript with ComponentizeJS and by an equivalent component built from Rust, both running in Wasmtime. thousand calls per second JavaScript (ComponentizeJS) 40 k/s Rust (cargo component) 900 k/s

Frequently Asked Questions

Is TypeScript supported? Compile TypeScript to JavaScript first, then componentize the bundle.

Can the component use fetch? When built with wasi:http support and run on a host that provides it, yes.

Does it run in the browser? Through jco transpile, yes — though running a JS engine inside Wasm inside a browser’s JS engine is rarely the best choice there.

Can I debug the JavaScript? Logging through an import is the practical approach; source-level debugging inside the embedded engine is limited.

How do I stop a JavaScript plugin that loops forever? Use the host’s interruption mechanism, such as Wasmtime’s epoch interruption or fuel, to bound each call’s execution time.

How should I unit-test the JavaScript before componentizing? Run the module in Node with imports replaced by fakes, then test the built component through jco transpile or a Wasmtime harness.

Can several JavaScript components share one engine? No — each component embeds its own engine instance; composing many JavaScript components multiplies memory use.

← Back to Wasm Component Model & WIT Bindings