Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

jco is a fully native tool for working with WebAssembly Components in JavaScript.

Features

  • Transpiling Wasm Component binaries into ECMAScript modules that can run in any JavaScript environment.
  • WASI Preview2 support in Node.js & browsers.
  • Component builds of Wasm Tools helpers, available for use as a library or CLI commands for use in native JS environments
  • Optimization helper for Components via Binaryen.
  • componentize command to easily create components written in JavaScript (wrapper of ComponentizeJS).

Note: This is an experimental project. No guarantees are provided for stability, security or support and breaking changes may be made without notice.

Contributing

To contribute to the codebase of the project, refer to the Contributor guide.

To contribute to the documentation, refer to the Contributor guide.

If you find a mistake, omission, ambiguity, or other problem, please let us know via GitHub issues.

Creating new JavaScript and TypeScript components

Jco exposes componentize-js and componentize-qjs to make it easy to build components from JavaScript or TypeScript ES module source code.

Scaffold a project from WIT

The quickest way to start a new component is with jco scaffold.

jco scaffold uses an existing WIT package file or directory to produce a JS component project that builds for NodeJS or the Web, including typescript declarations and an implementation skeleton.

To use jco scaffold, simply name the folder and point at the WIT directory:

jco scaffold hello-component --wit path/to/wit

Jco also bundles official WASI WIT packages for three common starting points:

jco scaffold my-command --wit builtin:wasi-command
jco scaffold my-http-service --wit builtin:wasi-proxy
jco scaffold my-reactor --wit builtin:wasi-reactor

Without a version suffix these select WASI 0.3.0. To target the latest bundled WASI 0.2 release instead, use @0.2.x (currently 0.2.12):

jco scaffold my-command --wit builtin:wasi-command@0.2.x

The command and reactor aliases select the wasi:cli/command and wasi:cli/imports worlds. The proxy alias selects wasi:http/service in WASI 0.3 and wasi:http/proxy in WASI 0.2. Jco copies the selected snapshot into the generated project’s wit/ directory, so this workflow does not require a registry client or network access.

After runnig this command you can enter the folder and build the project:

cd hello-component
pnpm install
pnpm check
pnpm test
pnpm build

By default Typescript and pnpm are used, but you may use JS and other package managers if desired:

jco scaffold hello-js \
    --wit path/to/world.wit \
    --language javascript \
    --package-manager npm

If your WIT package contains one world, Jco selects it automatically. If it contains multiple worlds, use the --world option to specify which one you’d like to target:

jco scaffold hello-component \
    --wit path/to/wit \
    --world example:hello/app

By default, the scaffold checks and builds both Node.js and web targets. To build for only one target, you can use the --target option. Note that the option can be repeated (this has the same effect as not specifying it):

jco scaffold hello-node --wit path/to/wit --target nodejs
jco scaffold hello-web --wit path/to/wit --target web
jco scaffold hello-both --wit path/to/wit --target nodejs --target web

Multi-target projects have some shared files (tsconfig.json) but also platform specific files (e.g. tsconfig.nodejs.json), with differing Rolldown configurations and scripts available in package.json for building (e.g. build:nodejs vs build:web).

A README.md will be generated which records the selected world and package-manager commands.

To scaffold the other side of the component boundary, use --host. This generates a host plugin whose default export is an imports object ready to pass to the component’s instantiate function:

jco scaffold hello-host --wit path/to/wit --world example:hello/app --host

The regular scaffold implements the world’s exports in src/component.ts; the host scaffold implements its imports in src/plugin.ts. This makes it possible to generate both sides from the same WIT world.

Replace TODO bodies in generated files (src/component.{js,ts}), run pnpm types (or npm run types) after changing the copied wit/ package, and use pnpm check, pnpm test, and pnpm build throughout development.

Build source directly

Building a JavaScript component is as easy as calling jco componentize, with a few options:

jco componentize -w wit -o dist/component.wasm component.js

StarlingMonkey is the default backend, but you can use [componentize-qjs][qjs] which is powered by quickjs-ng by passing --backend quickjs or --backend qjs:

jco componentize --backend qjs -w wit -o dist/component.wasm component.js

The StarlingMonkey backend also accepts the aliases starlingmonkey and sm. Note that --engine <path> allows supplying a a custom StarlingMonkey build and cannot be combined with the componentize-qjs backend.

TypeScript entry modules are transformed and bundled automatically:

jco componentize -w wit -o dist/component.wasm component.ts

Jco uses Rolldown’s native TypeScript support to erase type syntax. This does not perform semantic type checking; run tsc --noEmit separately when type checking is part of your build. TypeScript component projects can import local modules and npm dependencies, and Jco discovers the nearest tsconfig.json from the entry project.

There are many examples in the Jco component examples folder

Bundling

By default, Jco passes a JavaScript source module directly to the selected componentization backend with no intermediate processing. TypeScript entry modules are always bundled so the backend receives generated JavaScript.

Use --bundle to bundle the entry module and its local or npm package dependencies before componentization. Package resolution starts from the entry module’s project, and wasi:* imports remain external so they can be matched to component capabilities.

Passing --bundle for a TypeScript entry is supported but unnecessary.

Note

Rolldown automatically treats unresolved imports (e.g. wasi:http, which is not a traditional import) as external, and prints warnings for imports it deems missing. By default we mark wasi:* imports as external, but in a future release automatic detection of import/export interfaces will mark all expected imports as well.

The bundle itself is generated in memory as a single ES module:

jco componentize app.js --bundle --wit wit -o component.wasm

Customizing bundle configuration

If you need to configure the Rolldown-generated bundle and do some processing on top of the default configuration, use --bundle-config <path> to merge a Rolldown configuration module. JavaScript entries must also specify --bundle; TypeScript entries bundle automatically and do not need the redundant flag.

The module can export a configuration object created with Rolldown’s defineConfig helper:

// rolldown.config.mjs
import { defineConfig } from 'rolldown';

export default defineConfig({
    resolve: {
        alias: {
            // For example, if you wanted to hard-code/mock a certain import
            'virtual:config': './src/config.js',
        },
    },
    transform: {
        define: {
            // For example, if you wanted to specify a build-time transform
            __BUILD_MODE__: JSON.stringify('component'),
        },
    },
});
jco componentize app.js --bundle --bundle-config rolldown.config.mjs --wit wit -o component.wasm

jco componentize will merge the following configurations:

  • plugins
  • aliases
  • external rules
  • transforms
  • output customization

Other settings will remain fixed/overriden by the built-in configuration to Jco where necessary to ensure a component is built properly.

Providing configuration functions in your supplemental config files is supported ({ bundle: true } will be provided as an input). Configuration arrays and configuration files that produce multiple outputs will be rejected.

Rolldown uses the nearest tsconfig.json for TypeScript entries unless the supplemental configuration provides an explicit tsconfig setting. TSX follows the JSX mode and runtime configured by that project. Any JSX runtime introduced by the transform must be resolvable and compatible with the component environment.

Transpiling

Components can be transpiled in two separate modes:

When using the default direct ESM transpilation mode, the output file is a JavaScript module, which imports the component imports, and exports the component exports.

Instantiation mode allows dynamically providing the imports for the component instantiation, as well as for instantiating a component multiple times.

For the default output, you will likely want to ensure there is a package.json file with a { "type": "module" } set for Node.js ES module support (although this is not needed for browser module loading or JS build tooling).

Usage

To transpile a component into JS:

jco transpile component.wasm -o out-dir

The resultant file can be imported providing the bindings of the component as if it were imported directly:

app.js

import { fn } from './out-dir/component.js';

fn();

Imports can be remapped using the --map flag, or to provide imports as an argument use the --instantiation option.

Components relying on WASI bindings will contain external WASI imports, which are automatically updated to the @bytecodealliance/preview2-shim package. This package can be installed from npm separately for runtime usage. This shim layer supports both Node.js and browsers.

Options

Options include:

  • --name: Give a custom name for the component JS file in out-dir/[name].js

  • --minify: Minify the component JS

  • --optimize: Runs the internal core Wasm files through Binaryen for optimization. Optimization options can be passed with a -- <binaryen options> flag separator.

  • --tla-compat: Instead of relying on top-level-await, requires an $init promise to be imported and awaited first.

  • --js: Converts core Wasm files to JavaScript for environments that don’t even support core Wasm.

  • --base64-cutoff=<number>: Sets the maximum number of bytes for inlining Wasm files into the JS using base64 encoding. Set to zero to disable base64 inlining entirely.

  • --no-wasi-shim: Disable the WASI shim mapping to @bytecodealliance/preview2-shim.

  • --map: Provide custom mappings for world imports. Supports both wildcard mappings (* similarly as in the package.json “exports” field) as well as # mappings for targetting exported interfaces. For example, the WASI mappings are internally defined with mappings like --map wasi:filesystem/*=@bytecodealliance/preview2-shim/filesystem#* to map import as * filesystem from 'wasi:filesystem/types' to import { types } from '@bytecodealliance/preview2-shim/filesystem.

  • --no-nodejs-compat: Disables Node.js compat in the output to load core Wasm with FS methods.

  • --instantiation [mode]: Instead of a direct ES module, export an instantiate function which can take the imports as an argument instead of implicit imports. The instantiate function can be async (with --instantiation or --instantiation async), or sync (with --instantiation sync).

  • --valid-lifting-optimization: Internal validations are removed assuming that core Wasm binaries are valid components, providing a minor output size saving.

  • --flags-as-bigint: Represents WIT flags as bigint values and exports named flag constants. By default, flags remain objects of booleans for backwards compatibility.

  • --variants-inline-cases: Inlines WIT variant cases in their discriminated unions instead of exporting a named interface for every case. The named interfaces remain the default for backwards compatibility.

  • --use-namespace-objects: Exports namespace objects for WIT enums, flags, and variants. This implies --flags-as-bigint and cannot be combined with --variants-inline-cases.

  • --enum-values-screaming-snake-case: Represents WIT enum values as SCREAMING_SNAKE_CASE strings instead of their original kebab-case names.

  • --tracing: Emit tracing calls for all function entry and exits.

  • --no-component-error-wrapping: Throw the lifted error payload directly for a top-level result::err instead of wrapping it in a generated ComponentError. Jco preserves wrapping by default for compatibility.

  • --no-namespaced-exports: Removes exports of the type test as "test:flavorful/test" which are not compatible with typescript

  • --async-mode [mode]: EXPERIMENTAL: For the component imports and exports, functions and methods on resources can be specified as async. The only option is jspi (JavaScript Promise Integration).

  • --async-imports <imports...>: EXPERIMENTAL: Specify the component imports as async. Used with --async-mode.

  • --async-exports <exports...>: EXPERIMENTAL: Specify the component exports as async. Used with --async-mode.

Browser Support

Jco itself can be used in the browser, which provides the simpler Jco API that is just exactly the same as the internal Jco component Jco uses to self-host.

To use this browser-supported internal component build, import the /component subpath directly:

import { transpile } from '@bytecodealliance/jco/component';

Most JS build tools should then correctly work with such code bundled for the browser.

Experimental WebIDL Imports

Jco has experimental support for zero-runtime and zero-configuration WEbIDL bindings, when using the webidl: interface.

A canonical WebIDL resource is not yet available, but some examples of these IDLs and WITs can be found in the IDL fixtures directory.

Whenever the webidl: namespace is used, Jco will automatically bind such imports to the global object.

Two top-level conventions are then provided for global access:

  1. A top-level getWindow function can be used (or for any singleton global name) to obtain the global object.
  2. If the imported interface name starts with global- such as global-console, then the interface is bound to that object name on the global object, with dashes replaced with . access, ie globalThis.console.

Under these conventions, many WebIDL files can be directly supported for components without any additional runtime configuration needed. A WebIDL to WIT converter is in development at https://github.com/wasi-gfx/webidl2wit.

This work is highly experimental, and contributors and improvements would be welcome to help steer this feature to stability.

Transpilation Semantics

Export Conventions

Components can represent both bundles of modules and individual modules. Compponents export the direct export interface as well as the canonical named interface for the implementation to represent both of these cases.

For example a component that imports an interface will be output as:

export { interface, interface as 'my:pkg/interface@version' }

The exact version allows for disambiguation when a component exports multiple interfaces with the same name but different versions.

If not needing this disambiguation feature, and since support for string exports in JS can be limited, this feature can be disabled with the --no-namespaced-exports flag to instead output only:

export { interface }

Import Conventions

When using the ESM integration default transpilation output bindings are output directly in the registry:name/interface form, but with versions removed.

For example an import to my:pkg/interface@1.2.3 will become an import to import { fn } from 'my:pkg/interface';.

Map Configuration

To customize the import specifiers used in JS, a --map configuration can be provided to the transpilation operation to convert the imports.

For example, jco transpile component.wasm --map my:pkg/interface@1.2.3=./myinterface.js will instead output import { fn } from './myinterface.js'.

Where the file myinterface.js would contain the function that is being imported from the interface:

export function fn () {
  // .. function implementation ..
}

Map configuration also supports # targets, which means that the interface can be read off of a nested JS object export.

For example with a JS file written:

export const interface = {
  fn () {
    // exported function to be imported from my:pkg/interface
  }
}

We can map the interface directly to this object instead of the entire module using the map configuration:

jco transpile component.wasm --map my:pkg/interface@1.2.3=./mypkg.js#interface

This way a single JS file can define multiple interfaces together.

Furthermore, wildcard mappings are also supported so that using (and quoting for bash compatibility):

jco transpile component.wasm --map 'my:pkg/*@1.2.3=./mypkg.js#*'

we can map all interfaces into a single JS file reading them off of exported objects for those interfaces.

WASI Shims

WASI is given special treatment and is automatically mapped to the @bytecodealliance/preview2-shim npm package, with interfaces imported off of the relevant subsystem.

Using the above rules, this is effectively provided by the default map configuration which is always automatically provided:

jco transpile component.wasm --map wasi:cli/*@0.2.0=@bytecodealliance/preview2-shim/cli#*

For all subsystems - cli, clocks, filesystem, http, io, random and sockets.

To disable this automatic WASI handling the --no-wasi-shim flag can be provided and WASI will be treated like any other import without special handling.

Browser WASI support is subject to web-platform capability limitations; some interfaces require application-provided adapters.

Interface Implementation Example

Here’s an example of implementing a custom WIT interface in JavaScript:

example.wit

package test:pkg;
interface interface-types {
  type some-type = list<u32>;
  record some-record {
    some-field: some-type
  }
}
interface iface {
  use interface-types.{some-record};
  interface-fn: func(%record: some-record) -> result<string, string>;
}
world myworld {
  import iface;
  export test: func() -> string;
}

When transpiling, we can use the map rules as described in the previous section to implement all interfaces from a single JS file.

Given a component compiled for this world, we could transpile it, but given this is only an example, we can use the --stub feature of transpile to inspect the bindings:

jco transpile example.wit --stub -o output --map 'test:pkg/*=./imports.js#*'

The output/example.js file contains the generated bindgen:

import { iface } from './imports.js';
const { interfaceFn } = iface;

// ... bindings ...

function test () {
  // ...
}

export { test }

Therefore, we can implement this mapping of the world with the following JS file:

imports.js

export const iface = {
  interfaceFn (record) {
    return 'string';
  }
};

Note: Top-level results are turned into JS exceptions, all other results are treated as tagged objects { tag: 'ok' | 'err', val }.

WASI Proposals

Jco will always take PRs to support all open WASI proposals.

These PRs can be implemented by extending the default map configuration provided by Jco to support the new --map wasi:subsytem/*=shimpkg/subsystem#* for the WASI subsystem being implemented.

shimpkg in the above refers to a published npm package implementation to install per JS ecosystem conventions. This way, polyfill packages can be published to npm.

Upstreaming into the @bytecodealliance/preview2-shim package is also possible for WASI proposals that have progressed to Phase 1 in the WASI proposal stage process.

Instantiation

Instantiation output is enabled via jco transpile component.wasm --instantiation sync|async.

When using instantiation mode, the output is a JS module with a single instantiate() function.

For async instantiation, the instantiate function takes the following signature:

export async function instantiate(
  getCoreModule: (path: string) => Promise<WebAssembly.Module>,
  imports: {
    [importName: string]: any
  },
  instantiateCore?: (module: WebAssembly.Module, imports: Record<string, any>) => Promise<WebAssembly.Instance>
): Promise<{ [exportName: string]: any }>;

imports allows customizing the imports provided for instantiation.

instantiateCore defaults to WebAssembly.instantiate.

getCoreModule can typically be implemented as:

export async function getCoreModule(path: string) {
  return await WebAssembly.compile(await readFile(new URL(`./${path}`, import.meta.url)));
}

For synchronous instantiation, the instantiate function has the following signature:

export function instantiate(
  getCoreModule: (path: string) => WebAssembly.Module,
  imports: {
    [importName: string]: any
  },
  instantiateCore?: (module: WebAssembly.Module, imports: Record<string, any>) => WebAssembly.Instance
): Promise<{ [exportName: string]: any }>;

Where instead of promises, all functions are synchronous.

Example Workflow

Jco Example Workflow

Given an existing Wasm Component, jco provides the tooling necessary to work with this Component fully natively in JS.

Jco also provides an experimental feature for generating components from JavaScript by wrapping ComponentizeJS in the jco componentize command.

To demonstrate a full end-to-end component, we can create a JavaScript component embedding Spidermnokey then run it in JavaScript.

Installing Jco

Either install Jco globally:

$ pnpm install -g @bytecodealliance/jco
$ jco --version
1.0.3

Or install it locally and use pnpx to run it:

$ pnpm install @bytecodealliance/jco
$ pnpm exec jco --version
1.0.3

Local usage can be preferable to ensure the project is reproducible and self-contained, but requires replacing all jco shell calls in the following example with either ./node_modules/.bin/jco or npx jco.

Installing ComponentizeJS

To use ComponentizeJS, it must be separately installed, globally or locally depending on whether Jco was installed globally or locally. Globally:

$ pnpm install -g @bytecodealliance/componentize-js

Or locally:

$ pnpm install @bytecodealliance/componentize-js

Now the jco componentize command will be ready to use.

Creating a Component with ComponentizeJS

This Cowsay component uses the following WIT file (WIT is the typing language used for defining Components):

cowsay.wit

package local:cowsay;
world cowsay {
  export cow: interface {
    enum cows {
      default,
      owl
    }
    say: func(text: string, cow: option<cows>) -> string;
  }
}

We can implement this with the following JS:

cowsay.js

export const cow = {
  say (text, cow = 'default') {
    switch (cow) {
      case 'default':
return `${text}
  \\   ^__^
    \\  (oo)\\______
      (__)\\      )\/\\
          ||----w |
          ||     ||
`;
      case 'owl':
return `${text}
   ___
  (o o)
 (  V  )
/--m-m-
`;
    }
  }
};

To turn this into a component run:

$ jco componentize cowsay.js --wit cowsay.wit -o cowsay.wasm

OK Successfully written cowsay.wasm with imports ().

Inspecting Component WIT

As a first step, we might like to look instead this binary black box of a Component and see what it actually does.

$ jco wit cowsay.wasm
package root:component;

world root {
  export cow: interface {
    enum cows {
      default,
      owl,
    }

    say: func(text: string, cow: option<cows>) -> string;
  }
}

Transpiling to JS

To execute the Component in a JS environment, use the jco transpile command to generate the JS for the Component:

$ jco transpile cowsay.wasm -o cowsay

Transpiled JS Component Files:

 - cowsay/cowsay.core.wasm     7.61 MiB
 - cowsay/cowsay.d.ts          0.07 KiB
 - cowsay/cowsay.js            2.62 KiB
 - cowsay/interfaces/cow.d.ts  0.21 KiB

Now the Component can be directly imported and used as an ES module:

test.js

import { cow } from './cowsay/cowsay.js';

console.log(cow.say('Hello Wasm Components!'));

For Node.js to allow us to run native ES modules, we must first create or edit the local package.json file to include a "type": "module" field:

package.json

{
  "type": "module"
}

The above JavaScript can now be executed in Node.js:

$ node test.js

 Hello Wasm Components!
  \   ^__^
    \  (oo)\______
      (__)\      )/\
          ||----w |
          ||     ||

Passing in the optional second parameter, we can change the cow:

test.js

import { cow } from './cowsay/cowsay.js';

console.log(cow.say('Hello Wasm Components!', 'owl'));
$ node test.js

 Hello Wasm Components!
   ___
  (o o)
 (  V  )
/--m-m-

It can also be executed in a browser via a module script:

<script type="module" src="test.js"></script>

There are a number of custom transpilation options available, detailed in the API section.

Development-use jco serve

jco serve is a convenient development utility for running Preview 2 HTTP components (in particular components that export the wasi:http/incoming-handler interface) in Node.js.

Warning

jco serve is intended for development and testing only.

It is not production ready, and these benchmarks should not be interpreted as production deployment guidance.

jco serve does not yet support Preview 3 HTTP components.

Caveats

Given the “for development” development nature of jco serve, this benchmark measures Jco’s Node.js development server, not the general performance of Preview 2 HTTP components.

A production deployment would typically use Wasmtime or another production-oriented component runtime, which is substantially more efficient than jco serve.

Performance depends on the component, generated bindings, JavaScript runtime, hardware, and workload. Measurements from one application or machine should not be treated as production capacity estimates. Use this benchmark to compare development-server configurations rather than to estimate production capacity.

Request isolation

By default, jco serve reuses a single instantiated component across requests.

The --isolate-requests=instance argument introduces a light (and permeable) layer of isolation: a fresh component instance while retaining the same JavaScript isolate and module cache.

The stronger --isolate-requests=worker mode creates a worker thread with a separate V8 isolate and module cache for every request.

Note

The --isolate-requests option selects worker isolation by default

Worker mode efficiency

Workers are one-shot: each handles exactly one request and is then terminated and replaced, so prewarming does not reuse JavaScript globals or module caches across requests.

As the cost of isolated workers is quite high, Worker mode maintains a pool of 50 prewarmed workers by default.

The capacity can be adjusted with --isolate-worker-pool-size. Larger pools can hide worker startup latency during bursts, at the cost of higher server startup time and memory use.

Running the benchmark

Jco includes an opt-in end-to-end benchmark that compares both isolation modes with a shared component and a native Node.js HTTP handler:

pnpm --filter @bytecodealliance/jco bench:serve-isolation

Note

The benchmark is not part of the normal test suite.

The serve-isolation benchmark suite builds a minimal HTTP component, starts a real NodeJS server for each mode, and makes sequential requests to expose per-request overhead. It reports requests per second and mean, median, and p95 latency.

By default, each mode receives 100 warmup requests followed by 10,000 measured requests.

Worker isolation intentionally creates a worker for every request, so a complete default run can take a long time.

Based on the reference result below, benchmarking all three worker pool sizes may take a while. A smaller smoke run can be requested before committing to the full benchmark:

JCO_SERVE_BENCH_WARMUP=5 \
JCO_SERVE_BENCH_REQUESTS=30 \
pnpm --filter @bytecodealliance/jco bench:serve-isolation

The reference result recorded below can be reproduced with its original sample sizes:

JCO_SERVE_BENCH_WARMUP=10 \
JCO_SERVE_BENCH_REQUESTS=100 \
pnpm --filter @bytecodealliance/jco bench:serve-isolation

Componentization is deliberately excluded from the timed measurements. Because building the fixture can still make repeated benchmark runs inconvenient, the generated component can be retained and reused:

JCO_SERVE_BENCH_COMPONENT_OUT=/tmp/jco-serve-benchmark.wasm \
pnpm --filter @bytecodealliance/jco bench:serve-isolation

JCO_SERVE_BENCH_COMPONENT=/tmp/jco-serve-benchmark.wasm \
pnpm --filter @bytecodealliance/jco bench:serve-isolation

Reference result

Using Node.js 24.19.0 on a 6-vCPU Intel Xeon 2.60 GHz virtual machine, the benchmark made 10 warmup requests followed by 100 measured sequential requests per mode:

ModeRequests/sMean latencyMedian latencyp95 latency
Native node:http598.131.67 ms1.69 ms2.03 ms
Shared component225.074.44 ms4.31 ms5.40 ms
Instance isolation22.8843.71 ms43.24 ms47.59 ms
Worker, pool size 14.15240.95 ms239.36 ms274.28 ms
Worker, pool size 5013.5573.82 ms70.14 ms100.00 ms
Worker, pool size 10014.7867.66 ms65.14 ms95.25 ms

Note

The performance ratios here are likely more improtant than the absoltue numbers.

This result illustrates the relative cost of the isolation mechanisms for a minimal component.

A larger component, concurrent traffic, different response sizes, pool utilization, or another Node.js version can change both the absolute results and the ratios between modes.

Host Bindings

The default mode for host bindings in JS hosts in Jco is through the high-level JS-based bindgen.

The benefit of this approach is that all host bindings are available as normal JS imports. For example, JavaScript developers can directly import a function like import { getRandomBytes } from 'wasi:random/random', and directly interact with the bindings at a high level.

This also makes it easy to provide custom or virtual implementations for bindings using the same host semantic conventions.

But for performance-sensitive applications, host bindings still need to have a fast path for optimized bindgen.

Using Native Host Bindings

Given a JS host that implements such a binding, the --import-bindings flag may be used to customize which host bindings mode to use:

  • The default bindgen mode is --import-bindings=js using high-level JS bindings for all imports.
  • When generating --import-bindings=hybrid, Jco will still generate the high-level bindgen for all imports, but check for a Symbol.for('cabiLower') and use this optimized bindgen when available on a function-by-function basis.
  • For --import-bindings=optimized, Jco will omit outputting the high-level JS bindgen for imports, and instead use the low-level bindgen function directly, assuming Symbol.for('cabiLower') is defined on all imports.
  • For --import-bindings=direct-optimized, instead of reading a Symbol.for('cabiLower'), Jco will assume that imports are all these lower functions instead (useful in instantiatio mode).

This scheme implies instantiation mode to provide the host bindings, or for the host to support providing the imports as a host ESM import scheme such as import { getRandomBytes } from 'wasi:random/random'.

Optimized Host Bindings Spec

fn[Symbol.for('cabiLower')](canonOpts) -> coreFn

A function that has a native optimized implementation, can expose its native optimized bindgen through a Symbol.for('cabiLower') method, taking a canonOpts object.

The following canonOpts fields may be defined as needed:

  • memory: The WebAssembly memory object for the component to which we are binding, if needed.
  • realloc: The realloc function inside of the component we are binding, per component model semantics, if needed.
  • postReturn: The post-return function for the call, if needed.
  • stringEncoding: If needed, with 'utf8' as the default.
  • resourceTables: If needed, an ordered list of resource tables in which they uniquely appear in the function parameters and results of type ResourceTable[].

The return value of this function is then a new function, coreFn, which represents an optimized native function which can be provided as a direct core function import to the WebAssembly.instantiate operation of the core binary for the component being linked, providing a direct host-native binding to the inner core binary of the component without needing an intermediate lowering operation in the component model semantics.

ResourceTable: number[]

Resource handles are tracked in handle tables, a set of shared slab data structures primarily relating handles to resource ids (reps) for the particular table. Each resource usually has a unique handle table assign for every component it is used in.

When handles are passed between component functions, resource state needs to be maintained between these tables, therefore in optimized bindgen, this shared state needs to be operated on. For example, resource creation creates an own handle in the table for that resource of the component caller, requiring the creator to populate a table of the caller.

In optimized bindgen, this is acheived by mutating the data structure accordingly. Great care needs to be taken to ensure the full component model semantics are followed in this process.

The implementation here is based on a JS array of integers. This is done instead of using typed arrays because we need resizability without reserving a large buffer like resizable typed arrays might for the same use case (and unless that changes in future).

The number bits are the lowest 29 bits, while the flag bit for all data values is 1 << 30. We avoid the use of the highest bit entirely to not trigger SMI deoptimization.

Each entry consists of a pair of u32s, with each pair either a free list entry, or a data entry.

Free List Entries:

index (x, u30)unused
32 bits32 bits
01xxxxxxxxxxxxxxxxx###################

Free list entries use only the first value in the pair, with the high bit always set to indicate that the pair is part of the free list. The first entry pair at indices 0 and 1 is the free list head, with the initial values of 1 << 30 and 0 respectively. Removing the 1 << 30 flag gives 0, which indicates the end of the free list.

Data Entries:

scope (x, u30)own(o), rep(x, u30)
32 bits32 bits
00xxxxxxxxxxxxxxxxx0oxxxxxxxxxxxxxxxxx

Data entry pairs consist of a first u30 scope value and a second rep value. The field is only called the scope for interface shape consistency, but is actually used for the ref count for own handles and the scope id for borrow handles. The high bit is never set for this first entry to distinguish the pair from the free list. The second value in the pair is the rep for the resource, with the high bit in this entry indicating if it is an own handle.

The free list numbering and the handle numbering are the same, indexing by pair, so to get from a handle or free list numbering to an index, we multiply by two.

For example, to access a handle n, we read the pair of values n * 2 and n * 2 + 1 in the array to get the context and rep respectively. If the high bit is set on the context, we throw for an invalid handle. The rep value is masked out from the ownership high bit, also throwing for an invalid zero rep.

resourceInstance[Symbol.for('cabiRep')]

Normally imported resource classes do not have to define any special symbols, as they are assigned rep numbers when passed in.

When using hybrid or optimized bindgen, high-level functions may still return and take high-level resource classes as parameters. For example, a resource type used optimized in import bindgen might still be constructible elsewhere to be passed in as a parameter to an exported function of a component attached to that optimized low-level import bindgen.

As a result, when using low-level bindgen, any high-level resource instances MUST define a Symbol.for('cabiRep') symbol in order for these resources to correctly interact with low-level bindgen functions referring to those same resources.

ResourceClass[Symbol.for('cabiDispose')](rep) -> void

Just like Symbol.dispose is used in high-level bindgen for imported resources to provide a destructor for when an own handle to a resource is dropped, low-level bindgen provides this hook for imported resources through the cabiDispose function.

The Symbol.for('cabiDispose') function is an optional destructor which is available as a direct static method on the imported resource class.

Unlike the other low-level functions, this one does not need to be bound and is called directly, as it takes the rep directly to handle internal destructor mechanisms.

Imported resources created externally are always “captured” explicitly when passed in to high-level functions, even when defining Symbol.for('cabiRep'), so any GC is implicitly averted. In these capture cases their resourceInstance[Symbol.dispose]() disposal will always be called instead of cabiDispose, even if they do not define a Symbol.dispose. This allows any custom GC hooks to apply correctly.

WIT Type Representations

Similar to any other guest langauge, there are multiple type systems in play when dealing with JS WebAssembly components.

Types represented in WebAssembly Interface Types (“WIT”) must be converted down to types that are familiar for Javascript, and Typescript (if dealing with jco types or jco guest-types subcommands).

This document details the type representations and usage for types that are defined in WIT and built into components.

Basic types

Here is a basic table of conversions between WIT types and JS types:

More complicated types that are built into WIT but require more work to translate are explained below.

WIT typeJS Type
u8number
u16number
u32number
u64BigInt
s8number
s16number
s32number
s64BigInt
f32number
f64number
boolboolean
charstring
stringstring

Enums (enum)

Jco represents a WIT enum as a union of string literals. By default, the strings preserve the WIT case names:

enum status { request-pending, request-complete }
export type Status = 'request-pending' | 'request-complete';

Pass --enum-values-screaming-snake-case to use SCREAMING_SNAKE_CASE values at the JavaScript boundary and in generated declarations:

export type Status = 'REQUEST_PENDING' | 'REQUEST_COMPLETE';

The option affects both values passed into a component and values returned from it. It also works with --use-namespace-objects; in that mode, for example, Status.RequestPending has the value 'REQUEST_PENDING'.

Namespace objects

WIT enums and variants are represented as TypeScript types by default, so they do not provide JavaScript values for constructing cases. Pass --use-namespace-objects to jco transpile to generate namespace objects for enums, flags, and variants. The same option on jco types and jco guest-types generates matching declarations.

For example, given these WIT types:

enum direction { north, south }
flags permissions { read, write }
variant request { open(string), close }

an exported interface provides values like:

api.Direction.North; // 'north'
api.Permissions.Read | api.Permissions.Write; // 3n
api.Request.Open('/tmp/file'); // { tag: 'open', val: '/tmp/file' }
api.Request.Close(); // { tag: 'close' }

The namespace objects are frozen. Aliases of the same WIT type share the same object. Since flag namespace values are bit masks, this option implies --flags-as-bigint. It cannot be combined with --variants-inline-cases, and remains disabled by default to preserve existing generated APIs.

Variants (variant)

Note

See the Variant section of the WIT IDL for more information on Variants

Variants are like basic enums in most languages with one exception; members of the variant can hold a single data type. Alternative variant members may hold different types to represent different cases. For example:

variant exit-code {
  success,
  failure-code(u32),
  failure-msg(string),
}

WIT syntax

variant filter {
    all,
    none,
    some(list<string>),
}

Jco Representation

Jco represents variants as objects with a tag that represents the variant, and val that represents the content:

For example, pseudo Typescript for the of the above filter variant would look like the following:

// Filter with all
{
  tag: 'all';
}

// Filter with None
{
  tag: 'none';
}

// Filter with some and a list of strings
{
  tag: 'some';
  val: string[];
}

Note

WIT variant’s options may only contain one piece of data.

You can work around this limitation of variants by having the contained type be a tuple, (e.g. tuple<string, u32, string>), or using a named record as the related data.

By default, generated TypeScript declarations export a named interface for each variant case. Pass --variants-inline-cases to jco transpile, jco types, or jco guest-types to emit the cases directly in the discriminated union instead:

export type Filter = { tag: 'all' } | { tag: 'none' } | { tag: 'some'; val: string[] };

Inlining prevents a generated case name such as OperationCreate from colliding with a WIT record named operation-create. The option is disabled by default because existing code may import the named case interfaces.

Records (record)

WIT Syntax

record person {
    name: string,
    age: u32,
    favorite-color: option<string>,
}

Jco Representation

Jco represents records as the Javascript Object basic data type:

Given the WIT record above, you can expect to deal with an object similar to the following Typescript:

interface Person {
    person: string;
    age: number;
    favoriteColor?: number;
}

Note

If using jco guest-types or jco types, you will be able to use Typescript types that properly constrain the Typescript code you write.

Options (option)

WIT Syntax

option<u32, u32>
option<string, u32>

Jco Representation

Jco represents options as an optional value or undefined, so some examples:

TypeRepresentation (TS)Example
option<u32>number | undefinedoption<u32> -> number | undefined
option<option<u32>>{ tag: "some" | "none", val: number }option<u32> -> number | undefined

Warning

“single level” options are easy to reason about, but the doubly nested case (option<option<_>>) is more complex.

Due to the important distinction between a missing optional versus an option that contains an empty value, doubly-nested (or more) options are encoded with the object encoding described above, rather than as an optional value.

options in context: Records

When used in the context of a record (which becomes a JS Object), optional values are represented as optional properties (i.e in TS a propName?: value).

options in context: Function arguments/return values

When used in the context of arguments or return to a function, single level options are represented as optional values:

Consider the following interface:

interface optional {
    f: func(n: option<u32>) -> string;
}

An implementation of the function optional.f would look like the following Typescript:

function f(n?: number): string {
    if (n === undefined) {
        return 'no n provided';
    }
    return 'n was provided';
}

Result (result)

Result types, as a general concept represent a result that may or may not be present, due to a failure. A result value either contains a value that represents a completed computation (SuccessType), or some “error” that indicates a failure (ErrorType).

You can think of the type of a Result as:

Result<SuccessType, ErrorType>

The value you ultimately deal with is one or the other – either the successful result or the error that represents the failure.

WIT Syntax

result<_, string>
result<, string>
result<t,e>

Jco representation

In Javsacript, computation that fails or errors are often represented as exceptions – and depending on how the result is used, Jco adheres to that representations.

When used as an output to a function, throwing an error will suffice. Given the following WIT interface:

add-overflow: func(lhs: u32, rhs: u32) -> result<u32, string>;

The following JS function would satistfy the WIT interface:

function addOverflow(lhs, rhs) {
    let sum = lhs + rhs;
    if (Nan.isNan(sum)) {
        throw 'ERROR: addition produced non-number value';
    } else if (sum > 4294967295) {
        throw 'ERROR: u32 overflow';
    }
    return sum;
}

While JS automatically converts numbers, we must be careful to not attempt passing a number that would not fit in a u32 (unsigned 32 bit integer) via WebAssembly.

Note

How JS treats large numbers is not in focus here, but it is worth noting that Number.MAX_VALUE + Number.MAX_VALUE === Infinity.

Typescript Schema

type Result<T,E> = { tag: 'ok', val: T } | { tag: 'err', val: E };

results in context: Function return values

When a result is returned directly from a function, any thrown error of the function is treated as the result error type, while any direct return value is treated as the result success type.

For guest exports, @bytecodealliance/jco-transpile throws the lifted error payload directly by default. The jco API and CLI preserve the historical behavior of wrapping that value in an Error with a payload property. Pass noComponentErrorWrapping: true to the API, or --no-component-error-wrapping to the CLI, to make jco throw the payload directly. Pass noComponentErrorWrapping: false to jco-transpile to request the compatibility wrapper.

Consider the following interface:

interface fallible {
    f: func(n: u32) -> result<string, string>;
}

An implementation of the function fallible.f would look like the following Typescript:

function f(n: number): string {
    if (n == 42) {
        return 'correct';
    }
    throw 'not correct';
}

results in context: Container types (record, optional, etc)

A result stored inside a container type or in non-function argument/return contexts will look like a variant type of the form { tag: 'ok', val: SuccessType } | { tag: 'err', val: ErrorType }.

For example, consider the following WIT interface:

interface fallible-reaction {
    r: func(r: result<string, string>) -> string;
}

An implementation of the function fallible-reaction.r would look like the following Typescript:

type Result<T,E> = { tag: 'ok', val: T } | { tag: 'err', val: E };

function f(input: Result<string, string>): string {
  switch (input.tag) {
    case 'ok': return `SUCCESS, returned: [${input.val}]";
    case 'err': return `ERROR, returned: [${input.val}]";
    // We we should never reach the case below
    default: throw Error("something has gone seriously wrong");
  }
}

result considerations: Idiomatic JS errors for Host implementations

When running a component in a JS host, it is likely for host functions to throw real JS errors (objects which are descendants of the Error global object), rather than the exact type expected by Jco.

This means that the default conversion mechanism for Jco would be a JS anti-pattern (i.e. throw 12345 versus throw new Error("error code 12345")).

To ensure smooth use of Jco-generated code from hosts, Error objects with a payload property will have the payload extracted as the result error type.

Consider the following WIT:

type error-code = u32;

interface only-throws {
    just-throw: func() -> result<string, error-code>;
}

Consider the following host function adhering to the interface, and making use of idiomatic JS errors:

// The below code assumes interaction with a WIT which looks like a
function justThrow() {
    const plainError = new Error('Error for JS users');
    const errorWithPayload = Object.assign(plainError, { payload: 1111 });
    throw errorWithPayload;
}

Tuples (tuple)

Tuples are a container type that has a fixed size, types somewhat analogous to a fixed size list.

Tuples can be combined with type renaming to produce types that carry some semantic meaning. For example:

type point = tuple<u32,u32>

Note that tuples can be combined with custom user-defined types like records and variants, options and results. For example:

variant example-var {
    nothing,
    value(u64),
}

record example-rec {
    fst: string,
    snd: u32,
}

type maybe-num = option<u32>;

type num-or-err-str = result<u32, string>;

type examples = tuple<example-rec, example-var, maybe-num, num-or-err-str>;

WIT Syntax

tuple<u32, u32>
tuple<string, u32>

Jco Representation

Jco represents tuples as lists (arrays), so some examples:

TypeRepresentation (TS)Example
tuple<u32, u32>[number, number]tuple<u32, u32> -> [number, number]
tuple<string, u32>[string, number]tuple<string, u32> -> [string, number]

List (list)

WIT Syntax

list<u8>
list<string>

Jco Representation

Jco represents lists with native Javscript Arrays, with the exception of a list<u8>:

TypeRepresentation (TS)Example
list<u8>Uint8Arraylist<u8> -> Uint8Array
list<t>T[]list<string> -> string[]

Resources (resource)

Note

See the WIT IDL description of Resources for more information

Resources represent values that cannot be copied across a component boundary. A resource handle refers to state owned by the resource’s provider, without exposing that state to its consumer. In JavaScript, Jco represents the handle as an object whose methods call back into the provider.

Resources are useful for stateful or platform-specific values such as files, sockets, and HTTP bodies. Unlike a WIT record, passing a resource does not serialize all of its fields.

The examples below use a blob resource:

WIT Syntax

package docs:resources;

interface blobs {
    resource blob {
        constructor(init: list<u8>);
        write: func(bytes: list<u8>);
        read: func() -> list<u8>;
        merge: static func(lhs: borrow<blob>, rhs: borrow<blob>) -> blob;
    }
}

world imports-blobs {
    import blobs;
}

world exports-blobs {
    export blobs;
}

Jco representation

The resource is represented as a class. WIT kebab-case names become JavaScript camelCase for functions and PascalCase for classes. The blob resource above therefore has the following approximate TypeScript shape:

class Blob implements Disposable {
    constructor(init: Uint8Array) {}

    write(bytes: Uint8Array): void {}

    read(): Uint8Array {}

    static merge(lhs: Blob, rhs: Blob): Blob {}

    [Symbol.dispose](): void {}
}

The exact declaration depends on whether the resource is imported or exported. Run jco guest-types when implementing a JavaScript guest, or inspect the declarations emitted by jco transpile when using a component from a JavaScript host. The generated types are the source of truth for the binding being used.

Imports and exports are from the guest’s perspective

It is useful to identify the provider and consumer before writing any JavaScript:

WIT world itemResource providerResource consumer
import blobsHostGuest component
export blobsGuest componentHost

An imported resource is implemented by the host and passed to the component during instantiation. An exported resource is implemented by the guest and returned to the host as part of the component’s exports.

Ownership and borrowing

A resource value is a handle with an associated lifetime. WIT uses two handle modes:

  • own<blob> transfers ownership to the callee. The receiving side is then responsible for eventually dropping the handle.
  • borrow<blob> temporarily makes the handle available to the callee. Ownership remains with the caller, and the callee must not keep using the handle after the call returns.

Writing blob in a result or parameter is shorthand for an owned handle where WIT permits that shorthand. Resource methods implicitly borrow self, so calling blob.read() does not consume blob. In the example, merge borrows both arguments and returns a new owned resource.

When a generated resource class implements Disposable, release it deterministically with a using declaration:

{
    using blob = new Blob(new Uint8Array([1, 2, 3]));
    blob.write(new Uint8Array([4]));
    console.log(blob.read());
} // blob[Symbol.dispose]() is called here

The equivalent JavaScript without a using declaration is:

const blob = new Blob(new Uint8Array([1, 2, 3]));
try {
    console.log(blob.read());
} finally {
    blob[Symbol.dispose]();
}

Warning

Do not use a resource after disposing it or after passing it to a parameter that takes own<blob>. JavaScript garbage collection is not a substitute for deterministic cleanup when the underlying resource holds files, sockets, or other limited host state.

Whether [Symbol.dispose]() appears on a particular binding is recorded in its generated declaration. A provider may also implement [Symbol.dispose]() as a cleanup hook; Jco calls that hook when the corresponding owned handle is dropped.

Importing a host resource into a guest

The imports-blobs world declares that the guest needs the host to provide the blobs interface. Generate types for the guest implementation with:

jco guest-types wit --world-name imports-blobs -o generated

The generated ambient module uses the WIT package and interface name. Guest TypeScript can import the resource class, construct it, and call its methods:

/// <reference path="./generated/imports-blobs.d.ts" />
import { Blob } from 'docs:resources/blobs';

export function processBytes(): Uint8Array {
    using left = new Blob(new Uint8Array([1, 2]));
    using right = new Blob(new Uint8Array([3, 4]));
    using merged = Blob.merge(left, right);

    merged.write(new Uint8Array([5]));
    return merged.read();
}

Blob is supplied by the host even though it looks like a normal class to the guest. The guest can only observe the operations described by WIT.

Providing the imported resource from the host

The host implements the resource with a JavaScript class. Its private fields remain entirely on the host:

class HostBlob {
    #bytes: number[];

    constructor(init: Uint8Array) {
        this.#bytes = Array.from(init);
    }

    write(bytes: Uint8Array): void {
        this.#bytes.push(...bytes);
    }

    read(): Uint8Array {
        return Uint8Array.from(this.#bytes);
    }

    static merge(lhs: HostBlob, rhs: HostBlob): HostBlob {
        return new HostBlob(Uint8Array.from([...lhs.#bytes, ...rhs.#bytes]));
    }

    [Symbol.dispose](): void {
        this.#bytes.length = 0;
    }
}

Transpile the component with explicit instantiation support:

jco transpile component.wasm -o transpiled --instantiation=async

Then provide the class under the WIT interface’s fully qualified name:

import { readFile } from 'node:fs/promises';
import { instantiate } from './transpiled/component.js';

const loader = async (path: string) => WebAssembly.compile(await readFile(new URL(path, import.meta.url)));

const instance = await instantiate(loader, {
    'docs:resources/blobs': {
        Blob: HostBlob,
    },
});

The property names match the generated JavaScript names: the WIT resource blob becomes Blob. Jco invokes methods with the original resource object as this, so private and per-instance state work as they do on an ordinary class.

Real components often have additional imports, such as WASI interfaces. Those implementations must be included in the same import object; they are omitted here to keep the resource wiring visible.

Exporting a guest resource to the host

The exports-blobs world reverses the direction: the guest provides the implementation and the host consumes it. First generate the guest declarations:

jco guest-types wit --world-name exports-blobs -o generated

Use the generated resource type as the contract for the guest class, then export the class inside an object named after the WIT interface:

/// <reference path="./generated/exports-blobs.d.ts" />
import type { Blob } from 'docs:resources/blobs';

class GuestBlob implements Blob {
    #bytes: number[];

    constructor(init: Uint8Array) {
        this.#bytes = Array.from(init);
    }

    write(bytes: Uint8Array): void {
        this.#bytes.push(...bytes);
    }

    read(): Uint8Array {
        return Uint8Array.from(this.#bytes);
    }

    static merge(lhs: GuestBlob, rhs: GuestBlob): GuestBlob {
        return new GuestBlob(Uint8Array.from([...lhs.#bytes, ...rhs.#bytes]));
    }

    [Symbol.dispose](): void {
        this.#bytes.length = 0;
    }
}

export const blobs = {
    Blob: GuestBlob,
};

The interface export is an object rather than a top-level Blob export because the WIT world exports the complete blobs interface. The shape of this object can be checked against the world module emitted by jco guest-types.

Using the exported resource from the host

After componentizing and transpiling the guest, instantiate it from the host:

jco componentize guest.js --wit wit --world-name exports-blobs \
    -o component.wasm
jco transpile component.wasm -o transpiled --instantiation=async

The instantiated component returns the exported interface. Its resource constructor and methods can be used like an ordinary JavaScript class:

import { readFile } from 'node:fs/promises';
import { instantiate } from './transpiled/component.js';

const loader = async (path: string) => WebAssembly.compile(await readFile(new URL(path, import.meta.url)));

const { blobs } = await instantiate(loader, {});

const left = new blobs.Blob(new Uint8Array([1, 2]));
const right = new blobs.Blob(new Uint8Array([3, 4]));
const merged = blobs.Blob.merge(left, right);

console.log(merged.read());

The host owns all three handles returned by their constructors and merge. If their generated declarations expose [Symbol.dispose](), the host should dispose each handle when it is no longer needed, preferably with using. Check the declarations generated by the Jco version in use: disposal support can differ between binding directions and component-model features.

The important distinction is where the classes originate:

Use caseClass implementationConnection at instantiationCalls the resource
Guest imports resourceHostHost passes { Blob: HostBlob }Guest
Guest exports resourceGuestHost receives { blobs }Host

These are the same WIT resource semantics in opposite directions. In both cases, Jco preserves the resource’s identity and routes method calls to the side that owns its underlying state.

Manual Wasm instantiation with WASI Overrides

When a Wasm component depends on functionality provided by WASI, the jco transpile produces a WebAssembly module that can be loaded from NodeJS or the Browser that includes usages of unresolved imports like wasi:random/random.

Note

Normally, WASI imports that need to be sourced from elsewhere would be mapped, using the --map option to jco transpile.

These instructions are for when mapping is insufficient or implementations must be redirected or changed at instantiation time.

A common usage of transpilation is to map the imports to a known package, like @bytecodealliance/preview2-shim:

jco transpile \
    component.wasm \
    --output dist/transpiled \
    --map wasi:cli/*@0.2.0=@bytecodealliance/preview2-shim/cli#*

Note

For more information, see the Map Configuration section of the Transpiling documentation

Sometimes you may want to use your own implementation of WASI interfaces (whether partial or complete), known/resolved only at instantiation time.

Manual instantiation of a transpiled component with no overrides

To use custom instantiations, in NodeJS we must build with the async instantiation mode:

jco transpile \
    component.wasm \
    --instantiation async \
    --output dist/transpiled

We can instantiate the WebAssembly component for use with no custom overrides (i.e. the default WASI implementations provided by preview2-shim):

import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation";

async function main() {
    const wasmESModule = await import("path/to/transpiled/component.js");
    const loader = async (path) => {
      const buf = await readFile(`./dist/transpiled/${path}`);
      return await WebAssembly.compile(buf.buffer);
    };
    const component = wasmESModule.instantiate(loader, new WASIShim().getImportObject());
    // TODO: add code that utilizes the component's exports
}

await main();

Note

When dealing with browser environments, the loader function is not necessary, and null/undefined can be used.

This is identical to mapping all imports to those provided by @bytecodealliance/preview2-shim.

Manual instantiation of a transpiled component with custom overrides

To use custom component overrides in addition to the WASI imports provided by preview2-shim, as before build the component with the async instantiation mode:

jco transpile \
    component.wasm \
    --instsantiation async \
    --output dist/transpiled \
    --map wasi:cli/*@0.2.0=@bytecodealliance/preview2-shim/cli#*

Then write an ES Module like the following:

import { readFile } from "node:fs/promises";

import { random } from "@bytecodealliance/preview2-shim";
import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation";

async function main() {
  /// Load the ES module generated by `jco transpile`
  const wasmESModule = await import("./dist/transpiled/component.js");

  // Build a customized WASI shim by mizing custom implementations
  // and the provided implementation
  const customShim = new WASIShim({
    random: {
      // For these two interfaces we re-use the default provided shim
      random: random.random,
      "insecure-seed": random.insecureSeed,
      // For insecure, we can supply our own custom implementation
      // (in this case, one that is *VERY* insecure)
      insecure: {
        getInsecureRandomBytes: (len) => {
          return new Uint8Array(Number(len)).fill(0);
        },
        getInsecureRandomU64: () => 42n,
      },
    },
  });

  const loader = async (path) => {
    const buf = await readFile(`./dist/transpiled/${path}`);
    return await WebAssembly.compile(buf.buffer);
  };

  // Instantiate the Wasm component's ES module
  const component = await wasmESModule.instantiate(
    loader,
    customShim.getImportObject(),
  );

  // TODO: add code to utilize the component exports
}

await main();

Using WASIShim, you can generate your own custom implementations of WASI, making use of the published shims where necessary.

Versioned imports with WASIShim

You can also use versions with the import objects produced by WASIShim:

import { WASIShim } from '@bytecodealliance/preview2-shim/instantiation';
import type {
    VersionedWASIImportObject,
    WASIImportObject,
} from '@bytecodealliance/preview2-shim/instantiation';

const shim = new WASIShim();

const unversioned: WASIImportObject = shim.getImportObject();
// console.log('unversioned', unversioned);
unversioned satisfies WASIImportObject;
unversioned satisfies VersionedWASIImportObject<''>;

const versioned: VersionedWASIImportObject<'0.2.3'> = shim.getImportObject({
    asVersion: '0.2.3',
});
//console.log('versioned', versioned);
versioned satisfies VersionedWASIImportObject<'0.2.3'>;

Sandboxing with WASIShim

By default, the preview2-shim provides full access to the host filesystem, environment variables, and network - matching the default behavior of Node.js libraries. You can use the sandbox option to restrict what guests can access.

Each WASIShim instance has its own isolated preopens, environment variables, and arguments. Multiple instances with different configurations will not affect each other:

Fully sandboxed instance

To create a fully sandboxed instance with no filesystem, network, or environment access:

import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation";

const sandboxedShim = new WASIShim({
  sandbox: {
    preopens: {},           // No filesystem access
    env: {},                // No environment variables
    args: ['my-program'],   // Custom arguments
    enableNetwork: false,   // Disable network access
  }
});

const component = await wasmESModule.instantiate(
  loader,
  sandboxedShim.getImportObject(),
);

Limited filesystem access

To provide limited filesystem access by mapping virtual paths to host paths:

import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation";

const limitedShim = new WASIShim({
  sandbox: {
    preopens: {
      '/data': '/tmp/guest-data',  // Guest sees /data, maps to /tmp/guest-data
      '/config': '/etc/app'        // Guest sees /config, maps to /etc/app
    },
    env: { 'ENV1': '42' },         // Only expose specific env vars
  }
});

const component = await wasmESModule.instantiate(
  loader,
  limitedShim.getImportObject(),
);

Sandbox options

The sandbox configuration object supports the following options:

OptionTypeDefaultDescription
preopensRecord<string, string>Full filesystemMap of virtual paths to host paths. Use {} for no filesystem access.
envRecord<string, string>process.envEnvironment variables visible to the guest. Use {} for no env access.
argsstring[]process.argvCommand-line arguments visible to the guest.
enableNetworkbooleantrueWhether to enable network access (sockets, HTTP).

Detecting Traps

While some errors are represented at the WIT type level and expected, some internal errors may cause a component instance to trap. Jco-generated bindings report the component-model traps they detect with the built-in WebAssembly.RuntimeError class.

After an instance has trapped, Jco marks the component instance as unusable. Any subsequent call throws the original trap without re-entering the component.

To detect these traps, use the built-in WebAssembly.RuntimeError class:

import { instantiate } from "./dist/transpiled/component.js";
import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation";

const shim = new WASIShim().getImportObject();
const instance = await instantiate(undefined, shim);

try {
    instance['ns:pkg/iface'].someFunction();
} catch (err) {
    if (err instanceof WebAssembly.RuntimeError) {
        console.error(`TRAP: ${err}`);
        // The instance is now disabled and cannot be called again.
    } else {
        // Other exceptions are unexpected and should be handled separately.
        throw err;
    }
}

WIT result<T, E> values remain part of the component’s normal return contract and are not thrown as WebAssembly.RuntimeError instances.

Preview 3 Streams and Backpressure

Preview 3 WIT stream<T> values are asynchronous sequences of elements.

Jco accepts JS AsyncIterables for host-provided readable streams, including ReadableStream and async generators (which may be manually constructed). Numeric chunks such as Uint8Array are expanded into their individual elements.

Items passed through iterables are elements, not chunks.

A component may do many things that are not quite similar to how async iterables work natively in JS:

  • Consume part of a chunk
  • Combine several chunks in one read
  • Issue a zero-length readiness read
  • Cancel an in-progress read.

Code that depends on one JavaScript chunk corresponding to one component read has to be written with this in mind.

Backpressure is not an acknowledgement

Backpressure limits how far a producer should run ahead of a consumer. It does not prove that the component has processed a value (i.e. if you are performing an operation on stream elements, it does not mean that the component has performed the relevant operation).

In particular:

  • ReadableStreamDefaultController.enqueue() returns immediately.
  • WritableStreamDefaultWriter.ready means the stream can accept more data.
  • A resolved WritableStreamDefaultWriter.write() means the Web Stream accepted the chunk according to its queuing strategy; it is not an application-level acknowledgement from the component.
  • Runtime readiness probes may request data before the component performs its next non-empty read.

If the producer needs delivery confirmation, this should be expressed in WIT, rather than inferring it from Stream object backpressure.

Use a bounded producer and wait for backpressure before producing more data:

const transform = new TransformStream(
    undefined,
    { highWaterMark: 1 },
    { highWaterMark: 1 },
);
const writer = transform.writable.getWriter();

async function produce(source) {
    try {
        for await (const chunk of source) {
            if (writer.desiredSize !== null && writer.desiredSize <= 0) {
                await writer.ready;
            }
            await writer.write(chunk);
        }
        await writer.close();
    } catch (error) {
        await writer.abort(error);
        throw error;
    }
}

// Pass transform.readable as the JS representation of stream<u8>.

Try to avoid enqueuing unbounded inputs eagerly, and treat an enqueued typed array as immutable; mutating or reusing its storage while it is buffered can change data that has not crossed the component boundary yet.

Cancellation can race completion

When stream<t>s are cancelled, in-progress copies are automatically resolved. Data can become available between the cancellation request and cancellation completion, which means the result may therefore report either:

  1. cancelled with no progress; or
  2. completed with the number of elements copied before cancellation ocurred

Regardless of which occurs, the stream remains usable, it’s up to the producer to retain retain values that were fetched from its source but were not copied, preserve their order, and offer them to the next read.

The producer must not start a second write against a stream end that already has a copy in progress.

This means that for custom async iterators, you should likely implement cleanup with return() or a generator finally block.

Also, cancellation of one component read does not necessarily mean that the entire WIT stream was dropped:

async function* byteStream(source) {
    try {
        for await (const chunk of source) {
            yield chunk;
        }
    } finally {
        await source.close();
    }
}

Zero-length reads are readiness probes

A component can read zero elements to wait until data or end-of-stream is observable without consuming an element.

Jco may pull one host chunk to answer that readiness query, but the values must remain available for a later non-empty read.

This has two practical consequences:

  1. Producing a chunk does not imply that any of its elements were consumed.
  2. A source with side effects should perform them when producing the value, not when assuming the component received it.

When testing a host stream you should generally include:

  • Delayed production
  • Chunks larger than the guest read buffer
  • Repeated read cancellation
  • Zero-length readiness reads
  • An end-to-end ordering check.

Timing-only assertions are not a good idea, and you should generally make sure to interact with the stream directly.

jco-std

@bytecodealliance/jco-std is Jco’s library of reusable JavaScript and TypeScript building blocks for WebAssembly components.

The goal of jco-std is to provide component authors portable implementations and ecosystem adapters that are useful across projects but do not belong in generated WIT bindings or in a particular application.

Relationship to componentization

jco-std is best used with jco componentize:

  • jco componentize builds JS code into a WebAssembly component matching a WIT world.
  • jco-std supplies guest-side library code that the application can bundle, including selected Node.js compatibility implementations and WASI HTTP adapters.

Note that the WIT world remains the source of truth for host capabilities. Importing a helper does not implicitly grant filesystem, network, environment, or other host access.

Generally, helpers will be specific about the version of underlying dependencies (WASI, NodeJS) in use, and force users to pick a pinned version where appropriate.

Installing jco-std

Install jco-std when application source directly uses a package export such as a WASI HTTP framework adapter:

pnpm add @bytecodealliance/jco-std

jco-std is ordinary ESM, so Jco bundles those explicit imports with the rest of the component.

Use normal imports for Node.js built-ins

Warning

Jco’s Node.js built-in compatibility for components is experimental and subject to change. APIs, behavior, and generated component interfaces may change incompatibly without a semver-major release.

Application code should import supported Node.js APIs exactly as it would under Node.js. Do not rewrite a Node.js built-in import to an internal jco-std export:

import assert from 'node:assert/strict';

assert.equal(1 + 1, 2);

Componentizing JS code

Componentize the source with Jco’s Node.js built-in support. TypeScript entry points are bundled automatically; JavaScript entry points need --bundle:

jco componentize app.ts --wit wit -o app.wasm
jco componentize app.js --bundle --wit wit -o app.wasm

Jco consumes selected jco-std implementations internally and redirects the supported node: specifier while bundling.

The application retains normal Node.js source code and does not need jco-std as a direct dependency solely for built-in compatibility.

Note

See Node.js built-in compatibility for the resolution model and current support matrix.

Using jco-std and Node built-ins together

jco-std imports and automatic Node built-in compatibility (e.g. node:path) are complementary – you can use both in the same source graph and bundled into the same component.

Enabling a supported node: import does not disable or replace jco-std adapters, and importing a jco-std adapter does not disable Node compatibility.

Example

For example, a Hono component can use the jco-std server adapter while ordinary application modules use Node’s assert and Buffer APIs:

import assert from 'node:assert/strict';
import { Buffer } from 'node:buffer';
import { Hono } from 'hono';

import { fire, incomingHandler } from '@bytecodealliance/jco-std/wasi/0.2.x/http/adapters/hono/server';

const app = new Hono();
app.get('/', (context) => {
    const body = Buffer.from('Hello from a component');
    assert(body.byteLength > 0);
    return context.body(body);
});

fire(app);

export { incomingHandler };

Jco bundles the explicit package import and independently rewrites the supported node: imports. The component’s WIT world still needs the WASI HTTP capabilities used by the Hono adapter; assert and Buffer do not add further capabilities.

Node.js compatibility implementations

jco-std currently owns the implementations for:

  • node:assert and node:assert/strict, adapted from Node.js 24 for portable execution without a host capability; and
  • node:path, node:path/posix, and node:path/win32, implemented with portable path algorithms and a wasi:cli/environment provider for operations that need the guest working directory; and
  • the synchronous node:child_process API, bridged through the explicit jco:node/child-process@0.1.0 host capability. Its default provider denies access, while an opt-in Node host provider delegates to the real node:child_process implementation; and
  • node:fs and node:fs/promises, sharing synchronous, callback, and promise facades over the explicit jco:node/fs@0.1.0 capability. The default provider denies access, while an opt-in Node provider delegates filesystem operations to the host; and
  • the node:http client and server APIs, implemented over a selectable direct jco:node/http@0.1.0, Preview 2 wasi:sockets, or Preview 2 wasi:http implementation. The direct provider denies access by default, direct and wasi:sockets support servers, and wasi:http rejects server construction;
  • the node:http2 settings API plus typed session, stream, and server resources. Its direct provider delegates to real Node HTTP/2 after explicit host mapping; wasi-sockets implements cleartext prior-knowledge HTTP/2 clients and TCP servers in the guest, while wasi-http rejects session and server operations whose semantics an individual-request interface cannot preserve;
  • node:stream/consumers, implemented as portable iterable collection over the engine’s Blob, typed-array, and text-codec globals; and
  • the experimental Node 24.20 node:stream/iter API, including portable sources, transforms, consumers, writers, push streams, duplex pairs, and multicast streams. It requires no WIT capability.

Iterable-stream adapters that consume duck-typed classic Readable and Writable objects are available. The reverse adapters that must construct a real classic Node stream fail explicitly until Jco has a faithful node:stream implementation; jco-std does not substitute unenv’s nonfunctional constructor mocks.

Not every Node compatibility module lives in jco-std. Jco can also bundle an audited upstream implementation directly when that is the better fit. For example, the current Buffer and querystring cores come from unenv and are wrapped by Jco during bundling – this allows Jco to use mature upstream work and sprinkle in WASI support where necessary.

Hono and WASI HTTP

The Hono adapter connects a normal Hono application to a wasi:http/incoming-handler component export:

import { Hono } from 'hono';

import { fire, incomingHandler } from '@bytecodealliance/jco-std/wasi/0.2.x/http/adapters/hono/server';

const app = new Hono();
app.get('/', (context) => context.text('Hello from a component'));

fire(app);

export { incomingHandler };

The package also exports Hono middleware adapters for WASI configuration and environment access. These helpers translate between familiar JavaScript framework conventions and the corresponding component interfaces; the target WIT world must still import and export the required WASI interfaces.

WASI version selection

The wasi/0.2.x package path selects the newest WASI 0.2 adapter verified with the ComponentizeJS version used by Jco. It can advance in a jco-std release.

Use a fully versioned export such as wasi/0.2.12, wasi/0.2.6, or wasi/0.2.3 when the component must stay aligned with a particular WIT world. The package export and the versions imported by that world must agree.

Design expectations

Code in jco-std is intended to be:

  • portable across the JavaScript engines supported by Jco;
  • explicit about every WASI capability it consumes;
  • fully typed at its public boundaries;
  • tested from guest code after componentization; and
  • clear about code adapted from upstream projects and any intentional behavioral differences.

This makes jco-std a focused interoperability library rather than an attempt to recreate every browser, Node.js, or framework API. New adapters belong here when they can provide a reusable, well-defined bridge between ordinary JavaScript code and WebAssembly component interfaces.

NodeJS built-in compatibility

Jco’s long-term goal is to let existing Node.js programs become WebAssembly components with as few source changes as possible.

Warning

Jco’s Node.js built-in compatibility for components is experimental and subject to change. APIs, behavior, and generated component interfaces may change incompatibly without a semver-major release.

In the ideal case, application code can keep an ordinary import such as import { Buffer } from "node:buffer", and jco componentize supplies a portable implementation while producing the component.

Note

In the future, NodeJS compatibility will likely be built into the layer below Jco – ComponentizeJS. When that day comes, the NodeJS compatibility layer in Jco will likely be deprecated.

Compatibility boundaries

Note that this is a best-ieffort compatibility layer, not a Node.js process inside WebAssembly. Node APIs often assume access to an operating system, threads, subprocesses, native addons, or Node’s event loop.

JS WebAssembly components can only use capabilities declared by WIT worlds, given that the built-in JavaScript engine does not automatically provide Node internals, in Jco we support NodeJS compatibility API by API, with explicit behavior and tests for each supported module.

Enabling Node.js built-ins

Node built-ins are replaced while Jco bundles component source. JavaScript entry points must pass --bundle; TypeScript entry points are bundled automatically:

jco componentize app.js --bundle --wit wit -o app.wasm
jco componentize app.ts --wit wit -o app.wasm

Bundling behavior

During bundling, Jco’s Node built-in plugin resolves supported node: imports to virtual ES modules.

Virtual modules and their portable dependencies are included in the guest JavaScript before ComponentizeJS or componentize-qjs embeds it in a WebAssembly component.

Application source should keep its normal node: imports. Direct imports of the underlying jco-std implementation are not the recommended application-facing interface for Node.js compatibility.

Implementation selection

Resolution follows a deliberate quality order:

  1. A Jco or jco-std implementation wins when it has better Node compatibility or needs a WASI-aware design.
  2. An audited unenv implementation is used when its complete public surface and dependency graph work in a component.
  3. An admitted module can expose an explicit unsupported stub for an unavailable API. Deprecated APIs always fail immediately rather than running a deprecated implementation.
  4. Everything else remains unresolved. Jco never enables unenv’s entire alias map merely because an alias exists.

Only node: specifiers participate in this mechanism. Legacy bare specifiers such as buffer, path, and querystring are not rewritten.

Combining built-ins with jco-std

Node built-in compatibility can be mixed freely with direct imports from @bytecodealliance/jco-std and other portable packages.

They are resolved as separate parts of the same bundle, not selected as alternative componentization modes. For example, a component can use jco-std’s Hono adapter while its application code imports node:assert and node:buffer.

Currently supported modules

The current compatibility target is Node.js 24.20.0. Implementations that predate that patch retain their pinned Node 24 provenance; node:stream/iter specifically targets the release where it was introduced. The unenv-backed modules are audited against unenv@2.0.0-rc.24; upgrading unenv requires rerunning the compatibility suites.

These entry points are namespaced by two independent versions: the WASI version they adapt Node to, and the Node major they implement. The modules below live under wasi/0.2.x/node/<major>.x.x, where 0.2.x means the latest WASI p2 release and <major>.x.x means any release of that Node major – most modules under 24.x.x, and node:ffi, which does not exist before Node 26, under 26.x.x. Both axes move on their own – Node’s builtin semantics change across majors, and the same module adapted to WASI p3 is a different implementation – so a new Node major or a p3 adaptation is added alongside rather than replacing what is there.

Downstream projects should use explicit versions matching what they target, and Jco pins both when it bundles, so what is being built for is always explicit.

Automatically selecting the right WASI and NodeJS versions at build time, or detecting them, is planned.

Note

To support node/path remains as an alias for wasi/0.2.x/node/24.x.x/path, so imports written before the entry points were versioned keep resolving.

It is the only such alias: modules added after the split, including node:assert, are available only under a versioned entry point.

ImportsImplementationNotes
node:assert, node:assert/strict@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/assertAdapted from the MIT-licensed Node.js 24 implementation. Requires no WIT capability.
node:path, node:path/posix, node:path/win32@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/pathJco’s portable path implementation, connected to wasi:cli/environment for the guest working directory and environment.
node:string_decoder@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/string-decoderGuest-local streaming decoder for Node 24. Requires no WIT capability.
node:domain(refused)Deprecated upstream in its entirety. Resolves so the failure explains itself; every use throws ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API.
node:ffi@bytecodealliance/jco-std/wasi/0.2.x/node/26.x.x/ffiNode 26 only. Native calls and host memory over an explicit host capability; denied by default. Callbacks and guest-buffer addresses are refused – see below.
node:module@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/moduleClassification, source maps and require.resolve are exact. Everything that loads throws ERR_JCO_UNSUPPORTED_NODE_API – see below. Requires no WIT capability.
node:async_hooks@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/async-hooksSynchronous scopes only. Requires no WIT capability. Asynchronous use is refused rather than silently losing the store – see below.
node:diagnostics_channel@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/diagnostics-channelChannels and tracing channels. Requires no WIT capability. Bound stores are scoped synchronously.
node:child_process@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/child-processSynchronous APIs over an explicit application-provided host capability; denied by default.
node:cluster@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/clusterPrimary/worker control over an explicit host capability. Partly unsupported – see below.
node:console@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/consoleGuest console over an explicit application-provided host capability; denied by default, so every call throws until the application maps a provider.
node:dns, node:dns/promises@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/dnsName resolution over an explicit host capability; denied by default.
node:fs, node:fs/promises@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/fsSynchronous, callback, and promise facades over an explicit filesystem capability; denied by default.
node:http@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/httpOutbound client API over a selectable direct, Preview 2 sockets, or Preview 2 WASI HTTP transport. Server listening is explicitly unsupported.
node:inspector, node:inspector/promises@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/inspectorSession, console, and broadcast surface over an explicit host capability; denied by default. The host calls back through a guest-exported interface – see below.
node:os@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/osMachine and user information over an explicit host capability; denied by default. Static POSIX constants resolve without a provider – see below.
node:bufferunenv’s portable Buffer core with a Jco public adapterCovers the commonly used modern Buffer operations. Jco controls deprecated and runtime-dependent exports.
node:eventsunenv’s EventEmitter with a Jco layer from @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/eventsCovers the complete Node 24 module surface, including the on() async iterator and EventEmitterAsyncResource. Requires no WIT capability.
node:querystringunenv’s Node-derived querystring implementationCovers the complete Node 24 module surface and shares the audited Buffer core used by node:buffer.
node:stream/consumers@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/stream/consumersPortable Node 24 collection helpers over async iterables and engine globals. Requires no WIT capability.
node:stream/iter@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/stream/iterExperimental Node 24.20 iterable streams. Requires no WIT capability. Classic output adapters are explicitly unsupported.

Stream consumers and iterable streams

node:stream/consumers supports Node 24 applications written before or after the 24.20 iterable-stream addition. node:stream/iter exposes the experimental 24.20 batch-oriented API. Both execute entirely inside the guest and share byte normalization, limits, text decoding, and collection behavior. Importing either specifier adds no host or WIT capability.

For example, ordinary Node application source can use both entry points:

import { text as consumeText } from 'node:stream/consumers';
import { from, pull, text } from 'node:stream/iter';

export async function run() {
    const upper = (batch) =>
        batch?.map((chunk) => chunk.map((byte) => (byte >= 97 && byte <= 122 ? byte - 32 : byte))) ?? null;
    return {
        consumed: await consumeText(['consumer']),
        transformed: await text(pull(from('iterable'), upper)),
    };
}

Bundle that source normally; the WIT world only needs to describe the component’s own imports and exports:

jco componentize app.js --bundle --backend starlingmonkey -w app.wit -o app.wasm

The implementation uses engine-provided iterable, typed-array, Blob, text-codec, and abort globals. fromReadable() and fromWritable() work with duck-typed classic streams. toReadable(), toReadableSync(), and toWritable() need real classic Node stream constructors, which neither the component engine nor the audited unenv release provides. Those functions remain present but immediately throw ERR_JCO_UNSUPPORTED_NODE_API; they do not inspect their arguments first.

Warning

Node marks node:stream/iter experimental. Its Jco implementation is likewise experimental and may change incompatibly without a semver-major release as the upstream Node 24 API evolves.

Globals

Node’s Globals API is a catalog of runtime bindings, not a node:globals module. Jco therefore does not resolve that specifier. Bundled code can use Buffer without importing node:buffer; Rolldown injects Jco’s existing audited Buffer adapter only when a free Buffer identifier is referenced. A source graph that never uses it pays no bundle-size or initialization cost.

The component engine already supplies the portable Web globals shared with Node, so Jco leaves their identities and behavior untouched. With ComponentizeJS 0.22.0’s pinned StarlingMonkey runtime, this includes:

  • AbortController, AbortSignal, atob, btoa, Blob, and File;
  • ByteLengthQueuingStrategy, CountQueuingStrategy, ReadableStream and its exposed reader/controller classes, WritableStream, TransformStream, CompressionStream, and DecompressionStream;
  • console, Crypto, CryptoKey, SubtleCrypto, crypto, CustomEvent, DOMException, Event, and EventTarget;
  • fetch, FormData, Headers, Request, and Response;
  • Performance, performance, queueMicrotask, timeout/interval functions, structuredClone, TextEncoder, TextDecoder, URL, URLSearchParams, and WebAssembly.

Some of these retain StarlingMonkey’s existing WASI feature requirements, such as clocks for timers, random for WebCrypto, stdio for console, and HTTP for network fetches. Jco does not add a Node-specific WIT capability for globals.

Errors globals

The Node Errors API is cross-cutting behavior rather than a node:errors module. Standard error constructors are globals, while individual Node APIs create coded and system errors. Jco therefore does not resolve node:errors; Node 24 rejects that specifier as well.

Bundled code can use Error, AggregateError, DOMException, EvalError, RangeError, ReferenceError, SuppressedError, SyntaxError, TypeError, and URIError without an import. Rolldown injects @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/errors only for constructors actually referenced by the source graph. A graph that uses none of them contains none of the adapter after bundling.

The adapter preserves the guest engine’s constructor identities, supplies portable fallbacks for missing newer constructors and V8 Error extensions, and provides the common coded/system-error core used by other jco-std Node shims. No WIT capability is required. Error classes, codes, and documented system fields are compatibility targets; exact stack frames and source positions remain engine-specific.

Assert

Jco keeps its own assert implementation because the assertion namespace is a coherent system: comparison semantics, callable/default/strict identities, AssertionError, and error matching must work together (and can change across versions).

The implementation covers Node 24’s public module surface and comparison of cycles, Maps, Sets, typed arrays, errors, symbols, and other built-in families. The deprecated CallTracker API throws immediately. The deprecated multi-argument form of assert.fail() also throws immediately, while its current zero- and one-argument forms remain available.

Path and WASI capabilities

Path manipulation is portable, but path.resolve() and related operations need a current working directory.

Jco obtains that value through wasi:cli/environment@0.2.x, so the selected WIT world must import exactly one compatible version when it uses node:path:

world app {
  import wasi:cli/environment@0.2.6;
  // component imports and exports...
}

Jco selects the adapter matching the version in the world. A component that only uses capability-free built-ins such as assert, Buffer, or querystring does not need this import.

String decoder

Bundled source can use the documented Node 24 streaming decoder directly:

import { Buffer } from "node:buffer";
import { StringDecoder } from "node:string_decoder";

const decoder = new StringDecoder("utf8");

export function decode() {
  return decoder.write(Buffer.from([0xf0, 0x9f])) +
    decoder.end(Buffer.from([0x8c, 0x8d]));
}

Jco maps the import to a guest-local implementation based on Node 24.20.0. It retains incomplete UTF-8, UTF-16LE, base64, and base64url groups between calls, supports Node’s encoding aliases, and accepts strings or any ArrayBufferView. It reuses the audited Buffer core already used by node:buffer; it does not add a WIT import, callback export, host adapter, or JSPI operation.

Because the adapter is selected only when bundled code resolves node:string_decoder, source graphs that do not import it pay no decoder code or initialization cost. The legacy bare string_decoder specifier is deliberately not intercepted.

Child processes and host capabilities

A WebAssembly guest cannot spawn a process itself. When bundled source imports node:child_process, Jco ensures that the selected world declares the dedicated interface:

world app {
  import jco:node/child-process@0.1.0;
  // component imports and exports...
}

If the selected world does not already contain the import, Jco edits its .wit file in place, adds a comment identifying the generated line, installs the interface definition under deps/jco-node-0.1.0, and prints a CLI warning naming the changed files. This makes the capability change visible in the application’s source control. --world is honored when a package defines multiple worlds, and repeated componentization does not add duplicate imports or dependencies.

The interface definition also ships in jco-std under wit/node-0.1.0. Declaring or generating the import does not grant host access: Jco’s default transpilation map uses a provider that throws ERR_JCO_CHILD_PROCESS_ADAPTER_REQUIRED. An application must make the security decision explicitly, for example by mapping the Node host provider:

jco transpile component.wasm \
  --map 'jco:node/child-process@0.1.0=@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/child-process/host/node'

That produces the call path guest node:child_process → WIT capability → host adapter → Node node:child_process.

The current interface supports spawnSync, execFileSync, and execSync, including buffered input/output, encoding, cwd, environment, shell, stdio, timeout, signal, identity, and Windows options. spawn, callback-based exec and execFile, ChildProcess, and fork/IPC are present but throw ERR_JCO_UNSUPPORTED_NODE_API. A synchronous WIT function cannot faithfully carry Node callbacks, lifecycle events, or interactive streams; those APIs stay explicitly unavailable until the capability grows an asynchronous resource and stream model.

Clusters and host capabilities

A guest has no process model, so node:cluster follows the same pattern as node:child_process. When bundled source imports it, Jco ensures the selected world declares the interface, editing the .wit file in place, installing the definition under deps/jco-node-0.1.0, and printing a CLI warning naming the changed files:

world app {
  import jco:node/cluster@0.1.0;
  // component imports and exports...
}

Declaring or generating the import does not grant host access. Jco’s default transpilation map uses a provider that throws ERR_JCO_CLUSTER_ADAPTER_REQUIRED, so an application must make the security decision explicitly, for example by mapping the Node host provider:

jco transpile component.wasm \
  --map 'jco:node/cluster@0.1.0=@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/cluster/host/node'

That produces the call path guest node:cluster → WIT capability → host adapter → Node node:cluster. Because a transpiled component is itself a Node process, cluster.fork() re-executes the entry, so a forked worker runs the component again and observes itself as a worker.

Two differences from Node are unavoidable:

  • Event timing. Node delivers cluster events on its event loop. A guest cannot be called back across the host boundary, so events are queued by the host and emitted when the guest next touches the module; cluster.pump() drains them on demand.
  • Messages cross as JSON. WIT has no dynamic value type, so values JSON cannot represent – functions, symbols, cycles, BigInt – are rejected rather than silently altered.

These throw ERR_JCO_UNSUPPORTED_NODE_API rather than failing quietly:

APIWhy
worker.processA ChildProcess handle cannot cross the component boundary.
listening event, handle sharingCluster distributes Node net handles; guest servers are wasi:sockets, so nothing hooks them. SCHED_RR is accepted but does not distribute guest connections.
setupPrimary({ exec, execArgv, stdio, uid, gid, inspectPort, serialization })These configure the host runner executing the component, not a guest file.

cluster.isMaster and cluster.setupMaster() are deprecated in Node, so they throw ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API and point at isPrimary/setupPrimary.

Console and host capabilities

Writing to a console is a host capability, not a portable one: a component has no stdout of its own. node:console therefore resolves against jco:node/console@0.1.0, which an application must provide.

It is denied by default. Transpiling maps the capability to jco-std’s deny host unless told otherwise, and every call – write, isTerminal, colorDepth – throws ERR_JCO_CONSOLE_ADAPTER_REQUIRED. That is deliberate: a component that silently discarded its output would be harder to diagnose than one that says the capability is missing.

To grant it, map the interface to a provider. jco-std ships one for Node, which writes through to the real process.stdout/process.stderr and reports their TTY status and color depth:

jco transpile component.wasm \
  --map 'jco:node/console@0.1.0=@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/console/host/node'

Formatting is done in the guest – Console, the log/warn/error family, group indentation, count, time, table and dir all run guest-side, and only the finished string crosses the boundary.

Filesystem and host capabilities

node:fs and node:fs/promises use one jco:node/fs@0.1.0 host capability. When either specifier occurs in bundled source, Jco adds a missing import to the selected world, installs fs.wit under deps/jco-node-0.1.0, and prints a CLI warning to alert to the fact that a WIT dependency has been added.

The default filesystem host provider always throws ERR_JCO_FS_ADAPTER_REQUIRED. To use the passthrough NodeJS host provider you can map it in:

jco transpile component.wasm \
  --map 'jco:node/fs@0.1.0=@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/fs/host/node'

The resulting call path for filesystem function is:

  1. guest node:fs
  2. WIT capability
  3. host adapter
  4. NodeJS builtins

The Node provider delegates to Node 24’s synchronous operations; guest callback APIs queue their callbacks on a microtask, and promise APIs share the same descriptor state through promise FileHandles.

Common file and directory operations, metadata, directory entries, scalar and vector descriptor I/O, and their callback/promise facades are supported.

Warning

APIs whose contract requires long-lived streams or event sources are not yet supported – including ReadStream, WriteStream, Utf8Stream, watch, watchFile, and openAsBlob.

These functions currently throw ERR_JCO_UNSUPPORTED_NODE_API because the typed WIT interface does not model those resources.

OS and host capabilities

A WebAssembly guest has no view of the machine it runs on. When bundled source imports node:os, Jco ensures that the selected world declares the dedicated interface, following the same in-place WIT editing described for node:child_process:

world app {
  import jco:node/os@0.1.0;
  // component imports and exports...
}

Declaring or generating the import does not grant host access: Jco’s default transpilation map uses a provider that fails every inspecting or mutating call with ERR_JCO_OS_ADAPTER_REQUIRED. Static POSIX values that reveal no machine state – EOL, devNull, and constants – resolve without a provider, so the module can be imported and used for its constants even when access is denied. An application must make the security decision explicitly, for example by mapping the Node host provider:

jco transpile component.wasm \
  --map 'jco:node/os@0.1.0=@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/os/host/node'

That produces the call path guest node:os → WIT capability → host adapter → Node node:os.

The interface supports arch, availableParallelism, cpus, endianness, freemem, getPriority, homedir, hostname, loadavg, machine, networkInterfaces, platform, release, setPriority, tmpdir, totalmem, type, uptime, userInfo, and version, with Node’s argument validation and ERR_SYSTEM_ERROR reconstruction for failing calls.

Async hooks and synchronous scopes

AsyncLocalStorage works within a synchronous scope: run, getStore, exit, enterWith, nesting, snapshot and bind all behave as Node does, and AsyncResource binds to the context it was constructed in.

What it cannot do is carry a store across an asynchronous boundary. await resolves through the engine’s internal PerformPromiseThen, which JavaScript cannot intercept – patching Promise.prototype.then does not see it – and StarlingMonkey exposes no TC39 AsyncContext to carry the value instead.

Rather than return an empty store after an await, Jco refuses at the call site: any callback given to run, exit, withScope or a snapshot that returns a promise throws ERR_JCO_UNSUPPORTED_NODE_API, naming the reason. A failure at the call site is easier to act on than a store that silently disappears somewhere else.

createHook, executionAsyncId, triggerAsyncId and executionAsyncResource describe the async resource graph and always throw: nothing tracks that graph in a component.

Domains

node:domain is Stability 0 – deprecated in its entirety – and Jco implements none of it. Its purpose is routing errors across asynchronous boundaries, which a component cannot do in any case (see the async hooks section).

It still resolves rather than failing as an unknown import, so the error names the reason and a way forward instead of reading Could not resolve 'node:domain'. Importing is fine; every use – create(), createDomain(), new Domain(), and reading active or _stack – throws ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API, pointing at AsyncLocalStorage for carrying context.

active and _stack are reachable on the default import only. An ES module binding cannot throw on read, so import { active } from "node:domain" fails at build time instead.

Foreign function interface (NodeJS v26+)

node:ffi lets a component call native code on the host.

As WASI has no dynamic loader and a component has no host address space, this is host-backed, like node:child_process, and is denied by default.

To use the NodeJS passthrough version, you can map it in:

jco transpile component.wasm \
  --map 'jco:node/ffi@0.1.0=@bytecodealliance/jco-std/wasi/0.2.x/node/26.x.x/ffi/host/node'

Note

The host adapter forwards to the runtime’s real node:ffi, so the runtime must itself be Node 26 started with --experimental-ffi.

Without it, calls fail with a message naming the version and the flag rather than a missing-module error.

using the load-call-read-write cycle would look something liek this:

import { DynamicLibrary, exportString, getInt32, setInt32, toString } from 'node:ffi';

// null resolves symbols from the host process image, which links libc.
const lib = new DynamicLibrary(null);
const malloc = lib.getFunction('malloc', { arguments: ['uint64'], return: 'pointer' });
const strlen = lib.getFunction('strlen', { arguments: ['pointer'], return: 'uint64' });

const pointer = malloc(64n);
setInt32(pointer, 0, 123456);
getInt32(pointer, 0); // 123456, read back out of host memory
exportString('hello ffi', pointer, 64);
strlen(pointer); // 9n -- native code reading what the guest wrote
toString(pointer); // "hello ffi"

Pointers cross as bigint, matching NodeJS.

Errors keep NodeJS’s own codes, so ERR_FFI_LIBRARY_CLOSED and friends behave as they would on Node.

node:ffi incompatibilities

These are refused guest-side, before the host is reached, because a WebAssembly component cannot express them:

SurfaceWhy
getRawPointer(buffer)Guest memory is not mapped into the host address space, so a component’s buffer has no host address. Any number returned would be a lie native code then dereferences.
registerCallback(), unregisterCallback(), refCallback(), unrefCallback()A native callback is a function pointer the host would call back into the guest through, which the component boundary cannot carry.
toBuffer(p, n, false), toArrayBuffer(p, n, false)copy: false asks for a live view into host memory. Omit the argument for the copy Node returns by default.
A buffer, arraybuffer, or function argument typeA buffer argument would be copied, so native code writing through the pointer would write into a copy the guest never sees – silently. Declare a pointer and use toBuffer/exportBuffer, which copy explicitly. Refused when the signature is declared, so the message names the type.

node:ffi’s use of suffix

suffix comes from the host, but not at module load: a component’s top-level code runs under Wizer, which refuses imported calls outright (“You cannot call arbitrary imported functions during Wizer initialization”).

suffix is seeded with "so" and replaced the first time the guest touches the host – or on the first read of ffi.suffix, which syncs before answering.

The one stale window is a destructured import { suffix } read before any FFI call, which is also the documented dlopen(`./lib.${suffix}`) idiom.

The host adapter therefore lets the application set it, which is the reliable way to serve a guest that names .dylib or .dll files:

import { setSuffix } from '@bytecodealliance/jco-std/wasi/0.2.x/node/26.x.x/ffi/host/node';

setSuffix('dylib'); // before instantiating the component

Note that you may not need to change the suffix if the runtime already has it set properly, as suffix defaults to the runtime’s own ffi.suffix.

Inspector

node:inspector (and node:inspector/promises) exposes the V8 inspector: a Session speaking the Chrome DevTools Protocol, the inspector console, and the experimental Network/DOMStorage broadcast namespaces. The inspector is host machinery – a WebSocket server and a protocol dispatcher wired into the running isolate – so WASI cannot express it. Like node:child_process, it is host-backed and denied by default.

Map it to the Node passthrough to grant it:

jco transpile component.wasm \
  --map 'jco:node/inspector@0.1.0=@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/inspector/host/node'

The guest code is ordinary Node:

import { Session } from "node:inspector/promises";

const session = new Session();
session.connect();
const { result } = await session.post("Runtime.evaluate", { expression: "6 * 7" });
result.value;   // 42, evaluated in the host isolate

Argument validation, session state, the EventEmitter surface, and error reconstruction all run guest-side, so ERR_INSPECTOR_NOT_CONNECTED, ERR_INSPECTOR_ALREADY_CONNECTED, ERR_INVALID_ARG_TYPE, and the protocol’s ERR_INSPECTOR_COMMAND all match Node exactly. CDP payloads cross the boundary as JSON; the inspector console forwards its arguments as JSON too, which is best-effort for functions, symbols, and cycles.

The host calls back into the component

The inspector’s two callbacks – a post response and a session notification – run the other way, from host to guest. A component cannot implement a resource declared on an imported interface (its methods would run host-side), so the callbacks are a guest-exported interface, jco:node/inspector-callbacks@0.1.0, holding one resource per callback kind: a one-shot post-callback and a long-lived notification-listener. When bundled source imports node:inspector, Jco adds both the import jco:node/inspector@0.1.0; and the matching export jco:node/inspector-callbacks@0.1.0; to the selected world, and bundles the JS export alongside the entry – neither is written by hand.

The embedder wires the exported interface to the host adapter after instantiation:

import * as inspectorHost from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/inspector/host/node";
import * as component from "./transpiled/component.js";

inspectorHost.attachCallbacks(component.inspectorCallbacks);

Two timing rules follow from the component model, which forbids calling into a component while a task is already active in it:

  • In-isolate post responses are synchronous. Runtime.evaluate, Debugger.*, and the other in-isolate methods resolve during the post call, so the host returns the response directly and an awaited Session.post never needs a re-entrant callback. This is the same behavior as Node, which also fires those callbacks synchronously.
  • Notifications arrive between guest tasks, like node:cluster’s events: the host queues each notification and delivers it once no exported call is in flight, never into a suspended await.

Modules

node:module splits cleanly in two, and the split is not about effort.

There is no module loader in a component. jco componentize bundles the whole graph ahead of time, and StarlingMonkey cannot compile or link a module that was not present at build time – no dlopen, no filesystem, no loader to hook. No host capability would fix this: the missing piece is the guest engine’s ability to instantiate new code. So every entry point whose job is to load something throws ERR_JCO_UNSUPPORTED_NODE_API and says why:

register · registerHooks · runMain · findPackageJSON · stripTypeScriptTypes · setSourceMapsSupport · Module.prototype.require / load / _compile · and the _* loader internals (_load, _resolveFilename, _findPath, _nodeModulePaths, and the rest).

Everything else is real, because it is classification or arithmetic:

SurfaceBehavior
builtinModules, isBuiltinNode 24’s list, verbatim. isBuiltin agrees with Node on every builtin in every spelling, including prefix-only ones – isBuiltin("node:test") is true and isBuiltin("test") is false
SourceMapImplemented in full: VLQ decoding, findEntry, findOrigin, payload, lineLengths
wrap, wrapperDeprecated upstream but pure string work, so they behave as Node’s do, including wrap reading a mutated wrapper live
constants, findSourceMap, getSourceMapsSupport, getCompileCacheDir, flushCompileCache, syncBuiltinESMExportsExact, down to Node’s null-prototype return objects
globalPaths[] – a true statement, not a refusal: there is no $HOME/.node_modules to search
enableCompileCacheReports { status: FAILED, message }. Node’s own protocol for “could not”, so callers that branch on status keep working instead of catching
new Module(id)Constructs, with Node’s own-property shape. Its methods are what need a loader

createRequire

createRequire() succeeds. Code routinely writes const require = createRequire(import.meta.url) at module top level and only calls it on some paths; refusing at creation would break modules that require nothing.

Calling the returned require() refuses and points at static import. But require.resolve is not a refusal – it answers truthfully:

const require = createRequire(import.meta.url);
require.resolve("node:path");   // "node:path", exactly as Node answers
require.resolve("lodash");      // throws MODULE_NOT_FOUND -- which is the truth here
require.cache;                  // genuinely empty
require.main;                   // genuinely undefined

A caveat on builtinModules

It reports Node’s list, not the modules Jco resolves. isBuiltin asks “is this a Node builtin?”, which is a classification question, so answering for Node is the faithful thing. A guest that writes if (isBuiltin(x)) require(x) therefore gets a true answer followed by a refusal. The table at the top of this page is what says which builtins a component can actually import.

Diagnostics channels

Publish/subscribe for instrumentation, entirely in-process, so it needs no WIT capability. Channels are interned by name: a publisher and a subscriber that never share a reference still meet on the same object.

TracingChannel is implemented in full – traceSync, tracePromise and traceCallback, with the start/end/asyncStart/asyncEnd/error sub-channels emitted in Node’s order.

Channel.bindStore accepts anything offering run(value, fn), which includes jco-std’s AsyncLocalStorage. Stores are therefore scoped synchronously: a bound store is visible while subscribers run and does not follow an await. See the async hooks section above for why.

Events

node:events is two pieces. The EventEmitter itself comes from unenv, audited against Node 24: on/emit, one-shot once, listener ordering under prependListener, eventNames, removeAllListeners, the per-emitter max-listener methods, and an unhandled error throwing all match Node, as do once(), on()’s async iterator, getEventListeners, addAbortListener and EventEmitterAsyncResource.

Three module-level functions do not, and Jco implements them in jco-std rather than exporting something that fails when called:

Entry pointunenvJco
events.listenerCount(emitter, eventName)throws [unenv] node:events.listenerCount is not implemented yet!delegates to the emitter’s own listenerCount, as Node does, so a subclass that overrides it is honored
events.setMaxListeners(n[, ...targets])throws [unenv] node:events.setMaxListeners is not implemented yet!sets the limit on EventEmitters and EventTargets, or the process-wide default when given no targets
events.getMaxListeners(target)throws for an EventTarget; only handles emittersreads either, falling back to the current default

Argument validation matches Node’s, ERR_INVALID_ARG_TYPE and ERR_OUT_OF_RANGE messages included.

Node’s module object is the EventEmitter class, so events === events.EventEmitter holds here too: the adapter keeps the class as the default export, and installs the three functions above as statics on it so both access paths reach the working versions.

Note for anyone reading jco-std: it carries a separate, deliberately minimal EventEmitter of its own for shims such as node:cluster. jco-std does not depend on unenv, and shim code importing a node:* builtin would rely on a bundler rewriting it, which is not true of every way jco-std is consumed. The two are independent by design.

DNS and host capabilities

node:dns and node:dns/promises share one guest implementation and the jco:node/dns@0.1.0 capability. Jco adds that import and its dns.wit dependency when bundled source uses either specifier. The default provider throws ERR_JCO_DNS_ADAPTER_REQUIRED; applications opt into Node name resolution with:

jco transpile component.wasm \
  --map 'jco:node/dns@0.1.0=@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/dns/host/node'

The WIT interface represents each DNS operation as a named, typed function; it does not tunnel requests through a serialized dispatcher. The Node provider calls the real asynchronous node:dns/promises operations directly. When an application supplies a DNS host map, Jco automatically enables JSPI for every function in jco:node/dns@0.1.0. The Preview 2 WIT calls therefore remain synchronous from the guest’s perspective without blocking Node’s event loop or creating a worker for each query. Because any component export may transitively call DNS, mapped components expose promise-returning exports that JavaScript hosts must await. Callback APIs retain callback delivery in the guest, and the promises subpath shares server and default-result-order state with the main module.

Resolver.cancel() throws ERR_JCO_UNSUPPORTED_NODE_API. The synchronous WIT boundary does not expose an outstanding c-ares request that a later guest call could cancel. The provider boundary otherwise remains Node-independent, leaving room for a future browser implementation.

HTTP and selectable implementations

The node:http adapter implements both client and server NodeJS HTTP APIs, with outbound request() and get() calls with Node-style ClientRequest and buffered IncomingMessage objects along with http.Server.

As this API obviously requires access to the outside world of some sort, and there are actually many ways to achieve that on the host side, you must select the host implementation during componentization:

jco componentize component.js --wit wit --bundle \
  --with-nodejs-http-via wasi-sockets -o component.wasm
ValueComponent boundary
direct (default)Typed jco:node/http@0.1.0; denied by default, with an opt-in Node node:http provider.
wasi-socketsPreview 2 DNS lookup, TCP sockets, streams, and pollables; HTTP/1.1 framing and parsing live in the guest.
wasi-httpPreview 2 wasi:http/outgoing-handler and wasi:http/types.

Jco injects only the selected mode’s missing imports into the selected world. In direct mode it also injects the jco:node/http-callbacks@0.1.0 export and re-bundles the component entry with the matching guest callback implementation. Generated declarations include comments, pinned dependencies are installed under wit/deps, and Jco warns about the visible WIT changes. Existing declarations, including aliases, are preserved and repeated componentization is idempotent.

The direct implementation is asynchronous; Jco configures its typed request, listen, close, and connection-count imports for JSPI so they appear synchronous to the Preview 2 guest without a worker. Direct and wasi-sockets implement clients and servers. wasi-http implements clients and rejects server construction immediately because outgoing-handler cannot listen for arbitrary connections.

Warning

All modes currently buffer complete request and response bodies.

Connection pooling, upgrades, CONNECT tunnels, and HTTPS are explicit gaps. Unavailable operations throw ERR_JCO_UNSUPPORTED_NODE_API rather than silently doing nothing.

HTTP/2

Client and server code uses Node’s normal session and stream APIs:

import { connect, createServer } from 'node:http2';

export function requestStatus(authority) {
    const session = connect(authority);
    const stream = session.request({ ':path': '/status' });
    stream.end();
    return session;
}

export const server = createServer((request, response) => {
    response.writeHead(200, { 'content-type': 'text/plain' });
    response.end(`received ${request.url}`);
});

Select its implementation independently:

jco componentize component.js --wit wit --bundle \
  --with-nodejs-http2-via direct -o component.wasm
ValueBehavior
direct (default)Typed jco:node/http2@0.1.0, denied by default; an opt-in Node host uses real h2c and TLS/ALPN clients and servers.
wasi-socketsCleartext prior-knowledge HTTP/2 (h2c) clients and TCP servers, with guest-side framing, HPACK, settings, ping, reset, and stream/connection flow control.
wasi-httpRejects sessions and servers: outgoing-handler cannot expose observable Node sessions, stream control, or arbitrary inbound listeners.

By default, the provider rejects both connect() and server construction with ERR_JCO_HTTP2_ADAPTER_REQUIRED.

direct mode models sessions, streams, and servers as typed host-owned WIT resources, with a passthrough implementation to NodeJS underneath. The WIT interface used is jco:node/http2-callbacks@0.1.0.

Note

Under WASI p2, Bodies are currently buffered; low-level sockets, priority, push, flow-control windows, and operations that cannot cross the boundary throw explicit errors.

The wasi:sockets implementation also deliberately omits server push, HTTP/1.1 Upgrade: h2c, Unix-domain sockets, and Node’s arbitrary createConnection/custom duplex transport hooks.

Buffer

The Buffer core comes from unenv’s wrapper around the MIT-licensed Feross buffer implementation. Jco adds the Node-facing module shape, one shared globalThis.Buffer, and policy for exports that cannot be faithfully provided in the guest.

Supported behavior includes common text and binary encodings, allocation and filling, concatenation, comparison, integer and floating-point IO, searching, slicing, copying, swapping, and JSON conversion. atob() and btoa() use runtime globals when available and portable Buffer fallbacks otherwise.

Behavioral limits

There are intentional limits:

  • Buffer() and new Buffer() are deprecated in Node and throw ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API; use Buffer.from(), Buffer.alloc(), or Buffer.allocUnsafe() instead.
  • SlowBuffer is deprecated and throws the same error.
  • isAscii, isUtf8, resolveObjectURL, and transcode currently throw ERR_JCO_UNSUPPORTED_NODE_API.
  • Blob and File use engine globals when those globals exist; otherwise their fallback constructors throw an unsupported-API error.
  • The current portable core does not support the base64url encoding.

Querystring

unenv’s querystring implementation is adapted from Node’s MIT-licensed implementation and is a strong fit for a component: it is deterministic, mostly algorithmic, and needs no operating-system capability. Jco exposes its default namespace and the named decode, encode, escape, parse, stringify, unescape, and unescapeBuffer exports with Node-compatible alias identities.

The adapter initializes the same Buffer core as node:buffer. That matters for malformed-percent fallback and unescapeBuffer, which use Buffer internally.

How Jco evaluates unenv modules

Different compatibility goals

unenv provides a valuable cross-runtime foundation used by browsers, edge workers, server frameworks, and other non-Node environments. Its scope is broader than Jco’s: for many consumers, preserving an import and providing a conservative fallback or no-op is preferable to making a bundle impossible.

A WebAssembly component has a different contract: Jco must know whether an API is algorithmic, backed by a declared WASI capability, dependent on missing Node internals, or intentionally mocked.

Consequently, an unenv compatibility marker or alias is a starting point for review rather than an automatic promise of full Node behavior.

Audit criteria

For each candidate, Jco checks:

  • Node 24 export names, aliases, descriptors, types, and deprecations;
  • transitive imports and assumptions about process, globals, the event loop, or the host platform;
  • placeholders, mocks, no-ops, and notImplemented paths;
  • differential behavior against Node 24; and
  • execution through an actual guest component, not only source inspection or generated-bundle string checks.

Upstream improvements

General correctness improvements should be contributed upstream when practical. Until an improvement is in the pinned unenv release and passes Jco’s guest tests, Jco keeps a stronger local implementation or leaves the module disabled.

Reviewed modules that are not enabled

The pinned unenv release currently supplies 55 public node: aliases. Jco exposes a reviewed subset through Jco implementations and audited unenv cores. node:ffi is not among them at all – it is a Node 26 module, newer than the release unenv targets. The other aliases were reviewed but are not automatically resolved.

The following grouping describes the main blocker, not a permanent judgment about the module or upstream project.

More semantic or dependency work needed

ModulesWhy they are not enabled yet
node:readline, node:readline/promisesInteractive terminal behavior needs real guest streams and input handling; current fallbacks cannot reproduce it.
node:timers/promisesA component-aware timer/event-loop integration is needed for delays, cancellation, and abort signals.
node:trace_events, node:ttyThe fallbacks preserve useful shapes, but tracing and terminal detection are synthetic or no-op without runtime integration.
node:urlThere is substantial Node-derived code, but its eager node:path dependency adds a WASI environment requirement even for global-only URL use, and its namespace combines modern and legacy APIs that need separate policy.

Host-backed or broad subsystems

These modules contain useful portable pieces, but their complete public surfaces also require operating-system access, Node internals, an event loop, or a larger set of coordinated shims:

node:crypto, node:dgram, node:http2, node:https, node:net, node:perf_hooks, node:process, node:repl, node:sqlite, node:stream, node:stream/promises, node:stream/web, node:timers, node:tls, node:util, node:util/types, node:v8, node:vm, node:wasi, node:worker_threads, and node:zlib.

Future composition

This group is not all-or-nothing. A future implementation can combine portable upstream algorithms with explicit host capabilities, just as Jco’s path implementation combines portable path logic with a WASI environment provider.

Legacy or deprecated modules

node:constants, node:punycode, and node:sys are legacy or deprecated surfaces. Jco does not enable their functional fallbacks by default. When a deprecated API is added for import compatibility, Jco’s policy is to expose an immediate, explicit unsupported stub rather than execute the deprecated API.

node:domain is the worked example of that policy: it resolves, matches Node’s module shape, and throws from every entry point. See the domain section above.

What happens for an unsupported import

An unsupported node: import is left unresolved during bundling. This makes the missing compatibility visible instead of silently substituting a mock.

An explicit unsupported function inside an admitted module can be imported, but calling that particular function throws a stable Jco error.

Expanding support

This distinction lets applications use well-supported portions of modules such as Buffer while keeping unavailable behavior easy to diagnose.

Jco has a clear path to expand support: add or connect a faithful implementation, test it against Node and inside both JavaScript component backends, then add the specifier to the audited allowlist.

Common issues

componentize-js 0.19.3 fallback

Symptom

While running jco componentize, you may see a warning like this:

warning Falling back to componentize-js 0.19.3 because this component requests Preview 2 WASI packages older than 0.2.10.

If that component is then run on newer Wasmtime releases, especially Wasmtime 42 or newer, you may hit the same isolate crash that was fixed upstream in componentize-js 0.20.0.

Root cause

jco normally uses componentize-js 0.20.0 or newer, which includes the upstream fix for ComponentizeJS issue #224.

When the component’s WIT still depends on Preview 2 WASI packages older than 0.2.10, jco must fall back to componentize-js 0.19.3 for compatibility. In practice this usually shows up as wasi:http older than 0.2.10, but related dependencies such as wasi:clocks, wasi:random, and wasi:io can force the same fallback. The older componentize-js version does not include the upstream fix, so the crash can reappear.

For background, see jco issue #1415.

Solution

Update wasi:http and any related Preview 2 WASI dependencies to 0.2.10 or newer, then re-run jco componentize.

If you manage your WIT dependencies with wkg, fetching the updated packages can look like this:

wkg get --format wit wasi:http@0.2.10
wkg get --format wit wasi:clocks@0.2.10
wkg get --format wit wasi:random@0.2.10
wkg get --format wit wasi:io@0.2.10

After updating the packages, make sure your entry WIT file and any vendored dependency files reference the newer versions before componentizing again.

Contributing to the Codebase

Development is based on a standard NodeJS workflow, with pnpm as the package manager, i.e.:

pnpm install
pnpm run build
pnpm run test

Prerequisites

Required prerequisites for building jco include:

Rust Toolchain

The latest Rust stable toolchain can be installed using rustup.

Specifically:

rustup toolchain install stable
rustup target add wasm32-wasi

In case you do not have rustup installed on your system, please follow the installation instructions on the official Rust website based on your operating system

Project Structure

jco is effectively a monorepo consisting of the following projects:

  • crates/js-component-bindgen: Rust crate for creating JS component bindgen, published under https://crates.io/crates/js-component-bindgen.
  • crates/js-component-bindgen-component: Component wrapper crate for the component bindgen. This allows bindgen to be self-hosted in JS.
  • crates/wasm-tools-component: Component wrapper crate for wasm-tools, allowing jco to invoke various Wasm toolchain functionality and also make it available through the jco API.
  • src/api.ts: The jco API which can be used as a library dependency. It is compiled to dist/api.js and published as @bytecodealliance/jco.
  • src/jco.ts: The jco CLI. It is compiled to dist/jco.js and published as @bytecodealliance/jco.
  • packages/preview2-shim: The WASI Preview2 host implementations for Node.js & browsers. Published as @bytecodealliance/preview2-shim.
  • packages/preview3-shim: The WASI Preview3 host implementations for Node.js
  • packages/jco-node-fs: Native Node.js filesystem helpers used by the WASI shims.
  • packages/rolldown-plugin-jco: Rolldown and Rollup plugin for importing WebAssembly Components through Jco.

Files that should be checked in

The repository is for project related code only – avoid checking in files related to specific platforms or IDEs. One off configuration and/or secrets should of course not be checked in either.

If there is information/configuration that is important for users or developers to see, include them in documentation and/or examples with appropriate context/explanation.

Building

To build jco, run:

pnpm install
pnpm run build

Testing

There are three test suites in jco:

  • pnpm run test: Project-level transpilation, CLI & API tests.
  • pnpm run --filter 'packages/preview2-shim' test: preview2-shim unit tests.
  • pnpm run --filter 'packages/preview3-shim' test: preview3-shim unit tests.
  • pnpm run --filter 'packages/jco-node-fs' test: Native filesystem helper tests.
  • pnpm run --filter 'packages/rolldown-plugin-jco' test: Rolldown and Rollup plugin tests.
  • test/browser.html: Bare-minimum browser validation test.
  • cargo test: Wasmtime preview2 conformance tests (not currently passing).

Warning

Generally, when running jco tests, you will want to build the project first during a test run, to ensure the latest version of Rust code (i.e. js-component-bindgen is in use):

cd packages/jco
pnpm run build
pnpm run test api.js

The command above runs a single test file – the name of the file is relative to packages/jco/test.

Testing Rust-side bindgen changes

When working on the Rust bindgen crate (crates/js-component-bindgen), you generally must build jco-transpile to see changes appear in Jco, along with configuring Jco to use the workspace’s version of jco-transpile rather than a released one:

In packages/jco/package.json:

-    "jco-transpile": "X.X.X",
+    "jco-transpile": "workspace:*",

After this has been set, you might run a commands like the following from packages/jco:

pnpm --filter '@bytecodealliance/jco-transpile' build
pnpm run test future-lifts.js

Ensure to reset the jco-transpile dependency to a published version once you’ve finsihed testing locally

Running specific tests

JS tests are powered by vitest, and a specific test suite can be run by passing the filename to pnpm run test:

cd packages/jco
pnpm run test runtime.js

For example, to run multiple tests in a given folder:

cd packages/jco
pnpm run test test/p3/*.js

Commits

Jco and related subprojects use Conventional Commits. Using Conventional Commits helps the project maintain consistency in commit messages, and powers release automation.

CI enforces that commits are structured in a conventional commit style (see commitlint.config.mjs. Special care must also be taken to ensure PR titles are formatted in a way that matches conventional commits as well, when performing squash merges.

The following types are valid:

  • build
  • chore
  • ci
  • debug
  • docs
  • feat
  • fix
  • perf
  • refactor
  • release
  • revert
  • style
  • test
  • sec (security)

The following project scopes are valid:

  • jco
  • p2-shim
  • p3-shim
  • bindgen
  • transpile
  • std

For changes made to projects in the repository to be included in releases, the appropriate project scope must be applied.

Since changes that should be made to the repo may not always have a project-specific scope, the following scopes can be used as well:

  • deps
  • ci
  • ops

Here are a few example commit messages:

chore(jco): update componentize-js dependency to X.X.X
feat(ci): add commitlint to actions workflows

Contributing to Docs

jco is a Bytecode Alliance project and follows the Bytecode Alliance’s Code of Conduct and Organizational Code of Conduct.

Using this repository

You can run the website locally using the mdBook command line tool.

Prerequisites

To use this repository, you need mdBook installed on your workstation.

Running the website locally

After installing mdBook, on GitHub, click the Fork button in the upper-right area of the screen to create a copy of the jco repository in your account. This copy is called a fork.

Next, clone it locally by executing the command below.

git clone https://github.com/bytecodealliance/jco/
cd docs

To build and test the site locally, run:

mdbook serve --open

Submitting Changes

  • Follow the instructions above to make changes to your website locally.
  • When you are ready to submit those changes, go to your fork and create a new pull request to let us know about it.

Everyone is welcome to submit a pull request! Once your pull request is created, we’ll try to get to reviewing it or responding to it in at most a few days. As the owner of the pull request, it is your responsibility to modify your pull request to address the feedback that has been provided to you by the reviewer.