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.
componentizecommand 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 markwasi:*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:
- ESM Integration (default)
- Instantiation - async or sync
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 inout-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$initpromise 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 mapimport as * filesystem from 'wasi:filesystem/types'toimport { 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 aninstantiatefunction which can take the imports as an argument instead of implicit imports. Theinstantiatefunction can be async (with--instantiationor--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 asbigintvalues 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-bigintand cannot be combined with--variants-inline-cases. -
--enum-values-screaming-snake-case: Represents WIT enum values asSCREAMING_SNAKE_CASEstrings 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-levelresult::errinstead of wrapping it in a generatedComponentError. Jco preserves wrapping by default for compatibility. -
--no-namespaced-exports: Removes exports of the typetest 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 asasync. The only option isjspi(JavaScript Promise Integration). -
--async-imports <imports...>: EXPERIMENTAL: Specify the component imports asasync. Used with--async-mode. -
--async-exports <exports...>: EXPERIMENTAL: Specify the component exports asasync. 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:
- A top-level
getWindowfunction can be used (or for any singleton global name) to obtain the global object. - If the imported interface name starts with
global-such asglobal-console, then the interface is bound to that object name on the global object, with dashes replaced with.access, ieglobalThis.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.
shimpkgin 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. Its keys are the component’s
import names as the WIT spells them, without versions (for example wasi:cli/environment or
jco:node/process). --map rewrites those keys the same way it rewrites ESM import specifiers;
Jco’s default Node capability map is not applied to instantiation output.
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 serveis intended for development and testing only.It is not production ready, and these benchmarks should not be interpreted as production deployment guidance.
jco servedoes 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-requestsoption 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:
| Mode | Requests/s | Mean latency | Median latency | p95 latency |
|---|---|---|---|---|
Native node:http | 598.13 | 1.67 ms | 1.69 ms | 2.03 ms |
| Shared component | 225.07 | 4.44 ms | 4.31 ms | 5.40 ms |
| Instance isolation | 22.88 | 43.71 ms | 43.24 ms | 47.59 ms |
| Worker, pool size 1 | 4.15 | 240.95 ms | 239.36 ms | 274.28 ms |
| Worker, pool size 50 | 13.55 | 73.82 ms | 70.14 ms | 100.00 ms |
| Worker, pool size 100 | 14.78 | 67.66 ms | 65.14 ms | 95.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=jsusing 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 aSymbol.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, assumingSymbol.for('cabiLower')is defined on all imports. - For
--import-bindings=direct-optimized, instead of reading aSymbol.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 typeResourceTable[].
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) | |
|---|---|
| 32 bits | 32 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 bits | 32 bits |
| 00xxxxxxxxxxxxxxxxx | 0oxxxxxxxxxxxxxxxxx |
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 type | JS Type |
|---|---|
u8 | number |
u16 | number |
u32 | number |
u64 | BigInt |
s8 | number |
s16 | number |
s32 | number |
s64 | BigInt |
f32 | number |
f64 | number |
bool | boolean |
char | string |
string | string |
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-typesorjco 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:
| Type | Representation (TS) | Example |
|---|---|---|
option<u32> | number | undefined | option<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
optionthat 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:
| Type | Representation (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>:
| Type | Representation (TS) | Example |
|---|---|---|
list<u8> | Uint8Array | list<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 item | Resource provider | Resource consumer |
|---|---|---|
import blobs | Host | Guest component |
export blobs | Guest component | Host |
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 case | Class implementation | Connection at instantiation | Calls the resource |
|---|---|---|---|
| Guest imports resource | Host | Host passes { Blob: HostBlob } | Guest |
| Guest exports resource | Guest | Host 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
--mapoption tojco 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
loaderfunction is not necessary, andnull/undefinedcan 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:
| Option | Type | Default | Description |
|---|---|---|---|
preopens | Record<string, string> | Full filesystem | Map of virtual paths to host paths. Use {} for no filesystem access. |
env | Record<string, string> | process.env | Environment variables visible to the guest. Use {} for no env access. |
args | string[] | process.argv | Command-line arguments visible to the guest. |
enableNetwork | boolean | true | Whether 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.readymeans 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:
cancelledwith no progress; orcompletedwith 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:
- Producing a chunk does not imply that any of its elements were consumed.
- 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 componentizebuilds JS code into a WebAssembly component matching a WIT world.jco-stdsupplies 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:assertandnode:assert/strict, adapted from Node.js 24 for portable execution without a host capability; andnode:utilandnode:util/types, sharing assertion equality, console formatting, scheduling and validation helpers, with portable parsing and MIME utilities; andnode:path,node:path/posix, andnode:path/win32, implemented with portable path algorithms and awasi:cli/environmentprovider for operations that need the guest working directory; and- the synchronous
node:child_processAPI, bridged through the explicitjco:node/child-process@0.1.0host capability. Its default provider denies access, while an opt-in Node host provider delegates to the realnode:child_processimplementation; and node:fsandnode:fs/promises, sharing synchronous, callback, and promise facades over the explicitjco:node/fs@0.1.0capability. The default provider denies access, while an opt-in Node provider delegates filesystem operations to the host; and- the
node:httpclient and server APIs, implemented over a selectable directjco:node/http@0.1.0, Preview 2wasi:sockets, or Preview 2wasi:httpimplementation. The direct provider denies access by default, direct andwasi:socketssupport servers, andwasi:httprejects server construction; - the
node:http2settings API plus typed session, stream, and server resources. Its direct provider delegates to real Node HTTP/2 after explicit host mapping;wasi-socketsimplements cleartext prior-knowledge HTTP/2 clients and TCP servers in the guest, whilewasi-httprejects session and server operations whose semantics an individual-request interface cannot preserve; node:readlineandnode:readline/promises, ported from Node 24.20 for line parsing, questions, async iteration and terminal editing over supplied streams, with no additional WIT capability;node:repl, ported from Node 24.20 over that readline port for global-scope evaluation, keyword commands, completion and top-levelawait, with no additional WIT capability and acorn bundled only when the REPL is imported;node:tty, ported from Node 24.20 over the explicitjco:node/tty@0.1.0capability, which addresses the host process’s terminals by descriptor and is denied by default; andnode: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/iterAPI, 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.
Component tests
The versioned wasi/0.2.x/node/24.x.x/test and test/reporters entry points reuse
the existing jco-std assertion, error, path and stream implementations. Prefer
ordinary node:test and node:test/reporters imports through jco componentize;
direct jco-std imports can coexist with those builtins in the same component.
See test runner compatibility for serial
execution, engine requirements, output, and unsupported Node process facilities.
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:
- A Jco or jco-std implementation wins when it has better Node compatibility or needs a WASI-aware design.
- An audited unenv implementation is used when its complete public surface and dependency graph work in a component.
- An admitted module can expose an explicit unsupported stub for an unavailable API. Deprecated APIs always fail immediately rather than running a deprecated implementation.
- Everything else remains unresolved. Jco never enables unenv’s entire alias map merely because an alias exists.
Explicit node: imports select builtin adapters. Audited bare names also resolve
when no installed package shadows them. See Express for the
portable globals and dependency compatibility used by ordinary npm libraries.
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.
Supported modules
Browse the supported modules for API-specific examples, capabilities, and compatibility limits. Each API has its own page, with related submodules grouped together.
For whole-application compatibility, see Express.
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
notImplementedpaths; - 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.
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:http2,
node:perf_hooks, node:repl, node:stream,
node:stream/promises, node:stream/web,
node:v8, node:vm, 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 node:domain.
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.
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/pathremains as an alias forwasi/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.
Each API page describes its implementation, host capabilities, examples, and compatibility limits. Related submodules share their parent API page. See the compatibility overview for setup and bundling.
| API | Notes |
|---|---|
node:assert, node:assert/strict | Adapted from the MIT-licensed Node.js 24 implementation. Requires no WIT capability. |
node:async_hooks | Synchronous scopes only. Requires no WIT capability. Asynchronous use is refused rather than silently losing the store. |
node:buffer | Covers the commonly used modern Buffer operations. Jco controls deprecated and runtime-dependent exports. |
node:child_process | Synchronous APIs over an explicit application-provided host capability; denied by default. |
node:cluster | Primary/worker control over an explicit host capability. Partly unsupported. |
node:console | Guest console over an explicit application-provided host capability; denied by default, so every call throws until the application maps a provider. |
node:crypto | Synchronous SHA-1/SHA-256 hash and HMAC helpers; randomness delegates to engine WebCrypto. Other operations have explicit limits. |
node:dgram | UDP sockets over an explicit host capability; denied by default. StarlingMonkey supports the Node passthrough. |
node:diagnostics_channel | Channels and tracing channels. Requires no WIT capability. Bound stores are scoped synchronously. |
node:dns, node:dns/promises | Name resolution over an explicit host capability; denied by default. |
node:domain | Deprecated upstream in its entirety. Resolves so the failure explains itself; every use throws ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API. |
node:events | Covers the complete Node 24 module surface, including the on() async iterator and EventEmitterAsyncResource. Requires no WIT capability. |
node:ffi | Node 26 only. Native calls and host memory over an explicit host capability; denied by default. Callbacks and guest-buffer addresses are refused. |
node:fs, node:fs/promises | Synchronous, callback, and promise facades over an explicit filesystem capability; denied by default. |
node:http | Client and server APIs over a selectable direct, Preview 2 sockets, or Preview 2 WASI HTTP implementation. Servers need direct or wasi-sockets. |
node:http2 | Client and server sessions over selectable direct or WASI socket implementations. |
node:https | The node:http core with the https: profile and a TLS-aware Agent; same implementation selection. TLS uses jco:node/tls, optionally delegating to wasi:tls. |
node:inspector, node:inspector/promises | Session, console, and broadcast surface over an explicit host capability; denied by default. The host calls back through a guest-exported interface. |
node:module | Classification, source maps and require.resolve are exact. Everything that loads throws ERR_JCO_UNSUPPORTED_NODE_API. Requires no WIT capability. |
node:net | TCP clients, servers, and address utilities over Preview 2 wasi:sockets; native handles and IPC are unsupported. |
node:os | Machine and user information over an explicit host capability; denied by default. Static POSIX constants resolve without a provider. |
node:path, node:path/posix, node:path/win32 | Jco’s portable path implementation, connected to wasi:cli/environment for the guest working directory and environment. |
node:perf_hooks | Portable timing and observers; native telemetry throws. See the API page for runtime requirements. |
node:process | Host process state and operations over jco:node/process@0.1.0; lazy default properties, named functions and objects. See the API page for process restrictions. |
node:querystring | Covers the complete Node 24 module surface and shares the audited Buffer core used by node:buffer. |
node:readline, node:readline/promises | Node 24.20 line parsing, questions, terminal editing and cursor actions over supplied streams. No WIT capability. |
node:repl | Node 24.20 REPL over the readline port and supplied streams; useGlobal: true only, bundles acorn. No WIT capability. |
node:sqlite | SQLite over an explicit typed host capability; denied by default. Synchronous SQL callbacks are unsupported. |
node:stream, node:stream/promises | Classic streams, pipelines, operators, disposal, and Web adapters. No WIT capability. |
node:stream/consumers | Portable Node 24 collection helpers over async iterables and engine globals. Requires no WIT capability. |
node:stream/iter | Experimental Node 24.20 iterable streams. Requires no WIT capability. Classic output adapters are explicitly unsupported. |
node:string_decoder | Guest-local streaming decoder for Node 24. Requires no WIT capability. |
node:test, node:test/reporters | Serial component tests, hooks, assertions, mocks, and reporters. No additional WIT imports. Runner requires engine AbortController; see the API page for engine limits. |
node:timers, node:timers/promises | Node 24 timer handles and promise timers over engine task scheduling; see the API page for runtime limits. |
node:tls | Encrypted sockets, contexts, and inspection over jco:node/tls; denied by default. |
node:trace_events | Category control and real host trace capture through an explicitly mapped provider. Denied by default. |
node:tty | Node 24.20 isatty, ReadStream and WriteStream over the host process’s descriptors through an explicit host capability; denied by default. |
node:url | Node 24 URL, URLSearchParams, URLPattern, domain and file conversions; relative file paths use optional WASI environment imports. |
node:util, node:util/types | Portable Node 24 utilities, sharing assertion equality and console formatting. No WIT capability; see the API page for engine and process restrictions. |
node:v8 | Native V8 serialization, host heap diagnostics and profiling through an explicit provider; guest engine hooks are unsupported. |
node:vfs | Node 26 memory VFS, default-denied Node passthrough, and WASI filesystem storage with configurable preopen placement. |
node:vm | Same-context script evaluation and function compilation in the guest. No WIT capability; separate realms, native caches and VM modules are unsupported. |
node:wasi | Node 24.19 WASI construction over an explicit host capability, denied by default; start() and initialize() refuse because a component cannot instantiate a nested module. |
node:worker_threads | Real Node host workers over an explicit capability, with guest-local environment data. Native ports, shared memory and profiling are unsupported. |
node:zlib | Native zlib, Brotli and Zstandard through an explicit compression capability; denied by default. |
Globals and Errors document runtime-wide Node.js
APIs. They are not importable as node:globals or node:errors.
node:assert
| Imports | Implementation |
|---|---|
node:assert, node:assert/strict | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/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.
node:async_hooks
| Imports | Implementation |
|---|---|
node:async_hooks | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/async-hooks |
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.
node:buffer
| Imports | Implementation |
|---|---|
node:buffer | unenv’s portable Buffer core with a Jco public adapter |
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()andnew Buffer()are deprecated in Node and throwERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API; useBuffer.from(),Buffer.alloc(), orBuffer.allocUnsafe()instead.SlowBufferis deprecated and throws the same error.isAscii,isUtf8,resolveObjectURL, andtranscodecurrently throwERR_JCO_UNSUPPORTED_NODE_API.BlobandFileuse engine globals when those globals exist; otherwise their fallback constructors throw an unsupported-API error.- The current portable core does not support the
base64urlencoding.
node:child_process
| Imports | Implementation |
|---|---|
node:child_process | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/child-process |
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.
node:cluster
| Imports | Implementation |
|---|---|
node:cluster | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/cluster |
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:
| API | Why |
|---|---|
worker.process | A ChildProcess handle cannot cross the component boundary. |
listening event, handle sharing | Cluster 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.
node:console
| Imports | Implementation |
|---|---|
node:console | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/console |
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.
Crypto
node:crypto provides synchronous SHA-1 and SHA-256 hashing through createHash,
createHmac, and hash, including the digest operations used by Express ETags
and cookie signatures. Other digest algorithms are unsupported.
Random helpers use the component engine’s WebCrypto implementation, backed by
wasi:random. The webcrypto and subtle exports delegate to that implementation.
Node-shaped cipher, signing, key-object, certificate, and key-derivation operations
throw explicit unsupported errors; this is not full Node crypto compatibility.
import { createHash } from 'node:crypto';
const digest = createHash('sha256').update('hello').digest('hex');
node:dgram
| Imports | Implementation |
|---|---|
node:dgram | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/dgram |
Application code keeps ordinary Node imports:
import { createSocket } from 'node:dgram';
let socket;
export function start() {
socket = createSocket('udp4');
socket.on('message', (message, remote) => {
socket.send(message, remote.port, remote.address);
});
return socket.bindSync({ address: '127.0.0.1', port: 0 }).port;
}
export function stop() { socket.close(); }
Use a world exporting start: func() -> u16 and stop: func(), then build
with jco componentize source.js --bundle --backend starlingmonkey -w wit -o app.wasm.
Jco installs jco:node/dgram@0.1.0 and the guest-exported
jco:node/dgram-callbacks@0.1.0 interface. UDP adds no unrelated WASI imports.
The default provider returns a catchable ERR_JCO_DGRAM_ADAPTER_REQUIRED error
on capability use; importing, constructing, ref/unref, and closing an unused
socket need no host access.
To grant UDP access, transpile for explicit instantiation:
jco transpile app.wasm -o out --instantiation async \
--async-mode jspi --async-exports '*' \
--map 'jco:node/dgram@0.1.0=jco:node/dgram@0.1.0'
This mapping preserves the WIT interface name in the instantiation imports. Wire a separate Node provider to each instance:
import { instantiate } from './out/app.js';
import { WASIShim } from '@bytecodealliance/preview2-shim/instantiation';
import { createDgramHost } from '@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/dgram/host/node';
const imports = new WASIShim().getImportObject();
let instance;
imports['jco:node/dgram@0.1.0'] = createDgramHost(() => instance.dgramCallbacks);
instance = await instantiate(undefined, imports);
const port = await instance.start();
// Send datagrams to 127.0.0.1:port, then call await instance.stop().
The guest implements Node v24.20.0’s socket state, overloads, Buffer messages,
lookup customization, block lists, AbortSignal, events, and disposal. IPv4/IPv6,
bind/connect (including their synchronous forms), sends, address queries,
broadcast, multicast memberships, buffer options, and ref/unref use the typed UDP
provider. Host errors preserve codes, errno, address/port, syscall, and buffer
SystemError details. Native descriptors and shared cluster-handle adoption throw
ERR_JCO_UNSUPPORTED_NODE_API. The deprecated _createSocketHandle,
_handle, _receiving, _bindState, _queue, _reuseAddr,
_healthCheck, and _stopReceiving entries immediately throw
ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API; legacy sendto remains functional.
The Node provider requires native bindSync and connectSync (available in
Node 24.20.0). It queues datagrams, DNS results, and send completions through the
component’s callback exports. Return from exported guest tasks before waiting for
these events on the host; awaiting a future UDP event inside an active guest task
would require component re-entry. Guest-local microtasks replace Node’s nextTick
scheduling, and native async-hooks IDs do not cross the boundary. QuickJS currently
traps on host-invoked exported resource methods, so its tests cover the module,
validation, and denial; full UDP component tests use StarlingMonkey. Multicast and
reuse-port availability depend on the host OS.
The implementation adapts MIT-licensed Node
lib/dgram.js
and its internal handle/lookup flow at v24.20.0. Provenance and the license remain
in the source and emitted JavaScript. Audited unenv 2.0.0-rc.24 dgram is a mock
with no-op network methods and fixed addresses/buffer sizes, so Jco uses its own
adapter and reuses the already-supported Buffer/EventEmitter cores. Only
node:dgram is intercepted; bare dgram is unchanged. Direct jco-std
adapters can coexist with bundled Node builtins.
node:diagnostics_channel
| Imports | Implementation |
|---|---|
node:diagnostics_channel | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/diagnostics-channel |
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 node:async_hooks for why.
node:dns
| Imports | Implementation |
|---|---|
node:dns, node:dns/promises | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/dns |
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.
node:domain
| Imports | Implementation |
|---|---|
node:domain | (refused) |
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 node:async_hooks).
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.
Errors
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.
node:events
| Imports | Implementation |
|---|---|
node:events | unenv’s EventEmitter with a Jco layer from @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/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 point | unenv | Jco |
|---|---|---|
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 emitters | reads 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.
node:ffi (Node.js v26+)
| Imports | Implementation |
|---|---|
node:ffi | @bytecodealliance/jco-std/wasi/0.2.x/node/26.x.x/ffi |
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:
| Surface | Why |
|---|---|
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 type | A 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.
node:fs
| Imports | Implementation |
|---|---|
node:fs, node:fs/promises | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/fs |
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 returns a typed denial result, which the
guest reconstructs as 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:
- guest
node:fs - WIT capability
- host adapter
- 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, andopenAsBlob.These functions currently throw
ERR_JCO_UNSUPPORTED_NODE_APIbecause the typed WIT interface does not model those resources.
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 supplies the portable Web globals shared with Node. With ComponentizeJS 0.22.0’s pinned StarlingMonkey runtime, this includes:
AbortController,AbortSignal,atob,btoa,Blob, andFile;ByteLengthQueuingStrategy,CountQueuingStrategy,ReadableStreamand its exposed reader/controller classes,WritableStream,TransformStream,CompressionStream, andDecompressionStream;console,Crypto,CryptoKey,SubtleCrypto,crypto,CustomEvent,DOMException,Event, andEventTarget;fetch,FormData,Headers,Request, andResponse;Performance,performance,queueMicrotask, timeout/interval functions,structuredClone,TextEncoder,TextDecoder,URL, andURLSearchParams.
When bundled source references AbortController or AbortSignal, Jco loads a
compatibility adapter for the legacy StarlingMonkey abort implementation. It
preserves the native constructors and signal objects while correcting any()’s
array handling, default reason identity, and throwIfAborted(). The adapter
detects the legacy calling convention and leaves conforming engines untouched.
The current embedded runtime does not expose a guest WebAssembly API.
Running the component in a Wasm host does not give its JavaScript code the ability
to compile or instantiate another Wasm module. Guest-side Wasm execution may be
supported in the future; the globals test currently asserts that this API is
absent and should gain execution coverage when the engine provides it.
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.
Bundled dependencies also receive setImmediate and clearImmediate from
the timers adapter. A free process identifier uses the limited
unenv implementation so dependency initialization can run without host calls.
Explicit node:process imports use the host-backed process adapter.
node:http
| Imports | Implementation |
|---|---|
node:http | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http |
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. node:https
is the same core driven with the https: protocol, port 443, and a TLS-aware
Agent, exactly as lib/https.js reuses _http_client and _http_server
upstream; it shares the implementation selection below.
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
| Value | Component boundary |
|---|---|
direct (default) | Typed jco:node/http@0.1.0; denied by default, with an opt-in Node node:http provider. |
wasi-sockets | Preview 2 DNS lookup, TCP sockets, streams, and pollables; HTTP/1.1 framing and parsing live in the guest. |
wasi-http | Preview 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.
For direct servers, instantiate with a provider bound to that component’s callback dispatcher. The provider is asynchronous, so select JSPI and its async imports explicitly; instantiation output keeps the WIT import names and nothing selects them for you. For example, after transpiling with:
jco transpile component.wasm -o out --instantiation async \
--async-mode jspi --async-exports '*' \
--async-imports 'jco:node/http@0.1.0#request' \
'jco:node/http@0.1.0#[method]server.listen' \
'jco:node/http@0.1.0#[method]server.close' \
'jco:node/http@0.1.0#[method]server.get-connections'
import { instantiate } from './component.js';
import { WASIShim } from '@bytecodealliance/preview2-shim/instantiation';
import { createHttpHost } from '@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http/host/node';
let instance;
const imports = new WASIShim().getImportObject();
imports['jco:node/http'] = createHttpHost(() => instance.httpCallbacks);
instance = await instantiate(undefined, imports);
// Await application exports that create or control servers.
await instance.start();
Create a separate provider for each component instance. The server holds a callback registration ID; handlers stay in the guest and run through the exported dispatcher. The provider serializes callback entry and drains accepted callbacks before close completes. Closing releases the guest registration; listening again registers the same server’s handler again. Direct client-only applications can continue mapping the Node provider module without this factory.
Warning
All modes currently buffer complete request and response bodies.
Connection pooling, upgrades, and CONNECT proxy tunnels are explicit gaps.
Unavailable operations throw ERR_JCO_UNSUPPORTED_NODE_API rather than silently
doing nothing.
node:http2
| Imports | Implementation |
|---|---|
node:http2 | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http2 |
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
| Value | Behavior |
|---|---|
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-sockets | Cleartext prior-knowledge HTTP/2 (h2c) clients and TCP servers, with guest-side framing, HPACK, settings, ping, reset, and stream/connection flow control. |
wasi-http | Rejects sessions and servers: outgoing-handler cannot expose observable Node sessions, stream control, or arbitrary inbound listeners. |
By default, cleartext operations fail with ERR_JCO_HTTP2_ADAPTER_REQUIRED;
secure operations first require jco:node/tls and fail with
ERR_JCO_TLS_ADAPTER_REQUIRED when it is denied. Direct secure sessions and
servers obtain one-use configuration handles from the TLS provider. Bind it
with createHttp2Host(() => instance.http2Callbacks, tls).
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.
Servers support response trailers through Http2ServerResponse.addTrailers()
and setTrailer(), or through stream.respond(headers, { waitForTrailers: true })
followed by stream.sendTrailers() in the wantTrailers event. Both direct
and wasi-sockets send the trailers after the buffered body. This supports unary
gRPC calls, including grpc-status and application metadata.
Request trailers and incremental response streaming remain unsupported. The Node gRPC example runs the same server source in native Node and in a transpiled component.
The http2-callbacks.outgoing-response WIT record now includes trailers.
Update checked-in wit/deps/jco-node-0.1.0/http2.wit copies when rebuilding:
Jco adds missing dependency files but does not overwrite existing ones. Custom
callback providers must return the new list, which may be empty.
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.
node:https
| Imports | Implementation |
|---|---|
node:https | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/https |
HTTPS shares the HTTP implementation selection, including the
--with-nodejs-http-via option.
node:https exposes Node 24’s six exports: Agent, globalAgent, Server,
createServer, get, and request. https.Agent subclasses http.Agent on
both prototype chains, keeps Node’s defaultPort/protocol/maxCachedSessions
defaults and its TLS session cache, and produces the same 23-field getName()
key as Node, so option bags pool the way they would natively. Requests reject
non-https: protocols with ERR_INVALID_PROTOCOL and elide :443 from the
authority, and https.get() ends the request itself.
TLS options are configured through jco:node/tls@0.1.0. Direct HTTP requests
and servers carry a one-use configuration handle, which the HTTP host consumes
from the same TLS provider. Certificate, cipher, ALPN, trust, and SNI settings
have one capability boundary instead of separate HTTP and TLS WIT records.
The existing serializable HTTPS option subset remains supported; native objects
and callback options such as checkServerIdentity and SNICallback remain
explicitly unsupported by the buffered HTTP adapter.
| Value | node:https behavior |
|---|---|
direct | Clients and servers using jco:node/http and jco:node/tls. Bind createHttpHost(callbacks, tls) to the same TLS provider imported by the component. |
wasi-sockets | Verified clients call jco:node/tls.start-tls over the existing TCP streams. A provider can delegate to wasi:tls. The pinned draft does not support HTTPS servers. |
wasi-http | HTTPS is rejected because outgoing-handler cannot use the TLS capability. Select direct or wasi-sockets. |
For WASI stream upgrades, bind createWasiTlsBridge(yourWasiTlsProvider) from
@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/wasi as the primary
jco:node/tls import. The bridge reuses the WASI TLS future, connection, and IO
resources. Only servername and rejectUnauthorized: true are supported by
that draft; trust and ALPN are provider policy. The optional native TLS factory
also accepts { wasiTls: yourWasiTlsProvider } to serve both transports.
The existing native WASI TLS provider still awaits publication of the
preview2-shim io-worker export; the new native Node TLS provider does not
have that dependency.
Projects with checked-in http.wit or http2.wit dependencies must update them
together with tls.wit. Injection adds missing files but never overwrites
existing dependency files. Plain HTTP continues to work without granting TLS.
node:inspector
| Imports | Implementation |
|---|---|
node:inspector, node:inspector/promises | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/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
postresponses 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 awaitedSession.postnever 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 suspendedawait.
node:module
| Imports | Implementation |
|---|---|
node:module | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/module |
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:
| Surface | Behavior |
|---|---|
builtinModules, isBuiltin | Node 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 |
SourceMap | Implemented in full: VLQ decoding, findEntry, findOrigin, payload, lineLengths |
wrap, wrapper | Deprecated upstream but pure string work, so they behave as Node’s do, including wrap reading a mutated wrapper live |
constants, findSourceMap, getSourceMapsSupport, getCompileCacheDir, flushCompileCache, syncBuiltinESMExports | Exact, down to Node’s null-prototype return objects |
globalPaths | [] – a true statement, not a refusal: there is no $HOME/.node_modules to search |
enableCompileCache | Reports { 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
supported modules index lists which builtins a component can actually import.
node:net
| Imports | Implementation |
|---|---|
node:net | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/net/core |
node:net implements Node 24.19’s 18-export module surface over Preview 2
wasi:sockets. It includes TCP Socket and Server, BoundSocket,
SocketAddress, BlockList, IP-family predicates, overload normalization, and
the auto-family defaults. connect and createConnection are the same function,
and Socket and Stream are the same constructor, as in Node.
import { connect, createServer } from 'node:net';
createServer((socket) => socket.end('hello')).listen(8080, '127.0.0.1');
connect(8080, '127.0.0.1').setEncoding('utf8').on('data', console.log);
Jco injects only the selected world’s Preview 2 DNS, TCP, stream, and pollable
interfaces and their standard WIT packages. QuickJS worlds use 0.2.12;
StarlingMonkey worlds can use 0.2.10. There is no Jco-specific network host
interface and no bare net alias.
Preview 2 has no Unix-domain sockets, Windows named pipes, OS file descriptors,
libuv handles, TCP reset, IP type-of-service, or custom JavaScript DNS callback.
Those operations throw ERR_JCO_UNSUPPORTED_NODE_API. Address attempts are
sequential rather than reproducing Node’s exact Happy Eyeballs timing. Socket
objects provide the common readable/writable methods and events but do not yet
inherit from classic node:stream.Duplex, because Jco does not have a faithful
classic stream core.
Reads support Node string encodings, buffered read(), and async iteration.
Writable operations complete through blocking WASI writes and do not yet provide
classic stream backpressure. setNoDelay(), ref(), and unref() preserve the
callable surface but cannot control the host’s TCP_NODELAY or event-loop references.
Nonzero socket timeouts require an engine with JavaScript timers; engines without
them reject setTimeout() explicitly. The deprecated bufferSize getter throws
the Jco deprecated-API error; use writableLength instead.
Half-close depends on the host honoring WASI’s directional shutdown. The
Preview 2 Node host shim 0.22.0 currently closes both directions; applications
using that host should let the peer finish its response before closing the
socket’s writable side.
node:os
| Imports | Implementation |
|---|---|
node:os | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/os |
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.
node:path
| Imports | Implementation |
|---|---|
node:path, node:path/posix, node:path/win32 | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/path |
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.
node:perf_hooks
| Imports | Implementation |
|---|---|
node:perf_hooks | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/perf-hooks |
node:perf_hooks targets Node.js 24.20.0. Jco bundles a TypeScript adaptation of
Node’s user timing, resource timing, observer and function timing implementations.
It does not use unenv’s placeholder observers or histograms.
Keep ordinary imports in application code:
import { performance, PerformanceObserver } from "node:perf_hooks";
const observer = new PerformanceObserver((entries) => {
for (const entry of entries.getEntries()) console.log(entry.name, entry.duration);
});
observer.observe({ type: "measure" });
performance.mark("start");
// Application work.
performance.measure("elapsed", "start");
Bundle JavaScript with jco componentize app.js --bundle --wit wit -o app.wasm.
The adapter adds no WIT imports. Clock-based operations use the component engine’s
monotonic performance.now() and timeOrigin; they fail explicitly if the engine
lacks them. Explicit timestamps require no clock. Non-null mark/measure detail
requires the engine’s structuredClone implementation. The module owns its timing
buffers; it does not replace the engine’s global performance object.
Supported timing operations
Marks, measures (including named marks and start/end/duration options), timeline
queries and clearing, explicit resource timings, resource buffer notifications,
observers and timerify are implemented. Observers support mark, measure,
resource and function entries. Function timing includes promise settlement and
constructor calls. Observer and resource-buffer callbacks use component timers;
their ordering relative to other tasks can differ from Node’s setImmediate.
Resource entries describe timings supplied by the application; network operations
are not automatically instrumented.
Native performance telemetry
createHistogram, monitorEventLoopDelay, eventLoopUtilization,
performance.nodeTiming and performance.toJSON() throw
ERR_JCO_UNSUPPORTED_NODE_API. Components do not expose Node’s HDR histogram
binding, libuv event-loop counters or process startup milestones. The histogram
option to timerify is consequently unavailable. GC, HTTP, HTTP/2, DNS and network
observer instrumentation is unavailable. Deprecated node-entry kind and flags
accessors throw ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API, naming the detail replacement.
The public module exports and constants match the pinned Node 24 surface; APIs introduced in Node 26’s rolling documentation are outside this compatibility target. The source files retain Node’s MIT license and pinned source provenance.
QuickJS currently supplies a clock but lacks task timers and Web event targets.
Marks, measures, resource entries within the buffer limit, and synchronous function
timing work there. Observer subscription, event listeners and resource buffer
overflow fail explicitly; PerformanceObserver.supportedEntryTypes is empty.
StarlingMonkey supports these runtime facilities and the observer APIs.
node:process
| Imports | Implementation |
|---|---|
node:process | Jco typed facade and explicit Node passthrough |
As WASI has no concept of processes, node:process uses a Jco facade
over jco:node/process@0.1.0, targeting Node.js v24.20.0. This opt-in Node provider
therefore describes and controls the embedding Node process (via an adapter): its environment,
working directory, PID, resource measurements, diagnostics and credentials.
If using the passthrough NodeJS adapter, process.exit() terminates that
host; kill() sends a real OS signal, and execve() replaces the host
program where Node supports it.
When writing components against this API, you can Use normal Node imports inside a component entry function:
import process, { cwd, cpuUsage, hrtime } from 'node:process';
export function inspect() {
return JSON.stringify({
pid: process.pid,
platform: process.platform,
cwd: cwd(),
cpu: cpuUsage(),
nanoseconds: String(hrtime.bigint()),
});
}
Componentize that component with:
jco componentize app.js --bundle -w wit -o app.wasm
Jco adds the typed jco:node/process WIT import, and jco transpile will map that
to a provider that denies all functionality by default; host-dependent calls
throw ERR_JCO_PROCESS_ADAPTER_REQUIRED.
If you want to use the pass-through provider, you must map it in explicitly:
jco transpile app.wasm \
-o out \
--map 'jco:node/process@0.1.0=@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/process/host/node'
The public facade contains no native Node process objects in its WIT boundary. A different runtime can implement the same typed functions.
Direct jco-std adapters and native Node imports can coexist in a host application.
Supplying your own process provider
Embedders can construct an object satisfying the public ProcessHost type and
pass it directly to the generated instantiate function. Start from the denial
provider and override the operations your application supports. You do not need
to implement every operation or forward anything to Node’s native process.
For example, this TypeScript provider records exit requests and fails the current guest call without exiting the embedding process:
import base from '@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/process/host';
// To forward unoverridden operations to Node, swap the import above for:
// import base from '@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/process/host/node';
import type { ProcessHost } from '@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/process';
export function createProcessHost() {
const exitRequests: number[] = [];
const host = {
...base,
exit(code) {
const status = Number(code?.val ?? 0);
exitRequests.push(status);
throw {
name: 'Error',
code: 'COMPONENT_EXIT',
message: `Component requested exit ${status}`,
};
},
} satisfies ProcessHost;
return { host, exitRequests };
}
The active import denies unoverridden operations. Comment it out and uncomment
the Node provider import to change the base to host passthrough. The custom exit
above still overrides that base, but other operations, including abort, kill,
environment writes and chdir, then affect the embedding Node process.
Generate bindings for explicit instantiation; no custom mapping is needed:
jco transpile app.wasm -o out --instantiation async
Then pass your implementation object directly in the imports, keyed by the WIT interface name without its version. Jco’s deny-by-default module map applies only to ESM output; instantiation output never renames imports. The generated binding types list the expected import keys:
import { instantiate } from './out/app.js';
import { WASIShim } from '@bytecodealliance/preview2-shim/instantiation';
import { createProcessHost } from './my-process-provider.js';
const { host, exitRequests } = createProcessHost();
const component = await instantiate(undefined, {
...new WASIShim().getImportObject(),
'jco:node/process': host,
});
The provider implements the WIT operations underneath the Node facade:
- Functions are synchronous, even with
--instantiation async. Arguments are WIT values:exit(23)receives{ tag: 'number', val: 23 }, a string code uses thetexttag, and an omitted code isundefined. - With JavaScript component bindings, return the successful value directly, or
throw a
ProcessErrorrecord withname,message, and optionalcode,errno,syscall, andpath. The guest receives an Error. Do not return{ tag: 'ok', val: ... }or{ tag: 'err', val: ... }wrappers. Numeric WIT lists may require typed arrays: for example,getgroupsreturnsUint32Array. - Implement related operations consistently: environment access uses
envEntries,envGet, andenvSet;process.exitCodeusesgetStateandsetExitCode. This minimal example supports explicit exit requests only. Reading metadata such aspidorargvrequiresmetadata. - Keep mutable state per component when isolation is desired. Construct a new provider for each instance, as in the example. The facade does not isolate state that your provider shares with other instances or the host.
exit,abort, andexecvemust not return successfully. This example throws an ordinary guest-visible error, which guest code can catch. Enforcing component termination requires the embedder’s own lifecycle policy. Native streams and engine hooks listed under Process restrictions remain unsupported regardless of the provider.
The node-process-custom fixture in packages/jco/test/fixtures/componentize
contains the runnable JavaScript equivalent, checked against ProcessHost.
Its component calls process.exit(23). End-to-end tests bind separate provider
objects in QuickJS and StarlingMonkey, verify the exit requests reach the correct
object, and confirm the host stays alive and other operations remain denied.
Process state and snapshots
Host imports are unavailable while the component engine initializes its snapshot.
Read runtime state inside exported guest functions. Metadata, argument arrays,
versions, configuration and features are obtained lazily at first access. Argument
arrays are guest-local snapshots; environment variables and mutable state such as
title, debugPort, exitCode and report settings remain live on the host.
process.env supports property access, assignment, deletion, enumeration and
ordinary writable data descriptors. Assign strings, numbers or booleans; deprecated
implicit conversions of other values throw before coercion.
Named imports support functions and lazy objects such as env, argv, versions,
report and allowedNodeEnvironmentFlags. Runtime primitive exports (pid,
platform, arch, version, exitCode, and similar properties) are deliberately
absent from the ESM facade: use process.pid, for example. ESM bindings cannot be
lazy getters, and supplying a build-machine PID or a placeholder would be incorrect.
Likewise, obtain the optional permission object through process.permission.
This implementation handles explicit node:process imports; it does not install a
new ambient globalThis.process object or intercept the bare process specifier.
Process operations
The Node provider implements cwd/chdir, environment access and loadEnvFile, CPU,
thread CPU, memory and resource usage, monotonic hrtime, uptime and memory limits,
active resource names, identity and credential operations, kill, the setting
overload of umask, termination and execve, diagnostic reports, allowed Node
flags, permission queries and host source-map settings. Errors cross WIT with their
name, code, errno, syscall and path fields and become guest Error instances.
Diagnostic reports and resource measurements describe the host runtime, including
its JavaScript heap. They are not measurements of just one component.
Ordinary EventEmitter listeners and custom events stay in the guest and use Jco’s
already-audited node:events implementation. nextTick uses the guest microtask
queue; it does not reproduce Node’s separate next-tick phase or its ordering ahead
of promise reactions. ref/unref invoke the guest object’s
Symbol.for('nodejs.ref')/Symbol.for('nodejs.unref') protocols, falling back to
ordinary ref/unref methods. Warnings are
forwarded to Node and scheduled for guest warning listeners. QuickJS currently
rejects Promise-returning exports for synchronous WIT functions; the async
nextTick component test runs on StarlingMonkey, while synchronous passthrough
and denial tests run on both engines.
Process restrictions
Native stream objects (stdin, stdout, stderr), IPC channels and handle transfer,
module loading (dlopen, getBuiltinModule), exception-capture hooks and
finalization callbacks cannot cross this boundary. Their entry points throw
ERR_JCO_UNSUPPORTED_NODE_API. Registering automatic host events (signals,
beforeExit, exit, IPC, rejection/exception and worker events) throws the same
error; the adapter does not silently register listeners that will never run.
These restrictions also apply with the Node provider mapped.
Deprecated binding, assert, mainModule, domain, the no-argument umask()
overload, the multipleResolves event and deprecated feature flags throw
ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API before inspecting arguments or calling
providers. Legacy hrtime() and nextTick() remain functional; legacy status is
not deprecation. Platform-specific credential operations are available only when
the Node host supports them. Node 26 additions are outside the Node 24 contract.
Process implementation sources
unenv@2.0.0-rc.24’s process module was inspected, including its environment,
hrtime, next-tick and TTY dependencies. Its placeholder PIDs, zero memory metrics,
local cwd and unimplemented host operations do not meet this contract. Jco uses
native Node operations behind WIT instead, with a small TypeScript facade.
Tuple clock subtraction and warning normalization follow Node’s MIT-licensed
internal/process/per_thread.js and internal/process/warning.js at commit
71b8b174857e25106d39b61a9e6f30d927da8b01; the source retains attribution.
node:querystring
| Imports | Implementation |
|---|---|
node:querystring | unenv’s Node-derived querystring implementation |
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.
node:readline
| Imports | Implementation |
|---|---|
node:readline, node:readline/promises | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/readline and /readline/promises |
node:readline and node:readline/promises support callback and promise questions,
line events, async iteration, streaming UTF-8/CRLF decoding, prompts, terminal
editing and history, keypress events, and cursor actions. Both share a port of
Node v24.20.0’s readline implementation.
Applications keep ordinary Node imports and supply readable and writable streams:
import * as readline from 'node:readline/promises';
export async function ask(input, output) {
const rl = readline.createInterface({ input, output });
try {
const answer = await rl.question('What do you think of Node.js? ');
output.write(`Thank you for your valuable feedback: ${answer}\n`);
} finally {
rl.close();
}
}
Bundle application code with jco componentize app.js --bundle --wit wit -o app.wasm.
Readline itself requires no WIT imports. The streams determine where input and
output go. node:process resolves inside a component, but its stdin, stdout
and stderr are host stream objects that cannot cross the component boundary and
throw ERR_JCO_UNSUPPORTED_NODE_API (see process restrictions).
The Node documentation’s literal import { stdin, stdout } from 'node:process'
example therefore runs unchanged on Node but not in a component; supply streams
from the component’s own I/O instead. The test fixture runs that literal example
against the shim on Node, and runs the same question/answer flow with supplied
streams in QuickJS and StarlingMonkey.
Terminal and scheduling boundaries
Terminal streams may supply setRawMode, columns, and resize events. Terminal
mode emits ANSI sequences without inspecting a host TERM variable; an application
that wants Node’s TERM=dumb behaviour can read process.env.TERM through
node:process and pass terminal: false itself. Ctrl+Z can be handled with a
SIGTSTP listener; otherwise it throws ERR_JCO_UNSUPPORTED_NODE_API. Node
suspends itself with process.kill(process.pid, 'SIGTSTP') and resumes from a
SIGCONT listener; the node:process facade refuses signal listeners, and
readline deliberately does not import it, so using readline never adds the
process capability to a component.
Cursor widths use Node’s non-ICU tables, with normalization where the engine provides it; some Unicode widths differ from ICU-enabled Node. Deferred callbacks and automatic cursor commits use microtasks rather than Node’s separate next-tick queue. Completion-error text uses portable string formatting.
Timed Escape-key disambiguation requires engine timers; engines without timers
throw an explicit ERR_JCO_UNSUPPORTED_NODE_API for that operation. Cancellation
accepts supplied AbortSignals; readline does not install missing Abort globals.
QuickJS async entry functions must be declared async func in WIT.
node:repl
| Imports | Implementation |
|---|---|
node:repl | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/repl |
node:repl ports Node v24.20.0’s REPL
on top of the readline port: repl.start(), REPLServer, the .break, .clear,
.exit, .help, .editor keywords and defineCommand(), tab completion,
in-memory history and reverse search, top-level await, recoverable multi-line
input, _ and _error, and the 'exit' and 'reset' events. The pinned unenv
repl module is stubs and is not used.
Applications keep the ordinary import and supply the streams:
import repl from "node:repl";
export function attach(input, output) {
const server = repl.start({ prompt: "app> ", input, output, useGlobal: true });
server.context.app = { version: "1.0.0" };
server.on("exit", () => output.write("bye\n"));
return server;
}
Bundle with jco componentize app.js --bundle --wit wit -o app.wasm. The REPL
requires no WIT imports; input and output decide where the session goes.
Without a process global they are required, since there is no stdin or stdout
to fall back to.
Global scope only
Node’s default useGlobal: false runs each line in a separate vm context, a
second realm with its own globals. No component engine can create one, so that
option – given explicitly or omitted – is refused at construction with
ERR_JCO_UNSUPPORTED_NODE_API. With useGlobal: true evaluation is an indirect
eval, which is exactly vm.runInThisContext: replServer.context is
globalThis, .clear is an alias for .break, and assigning to the context
exposes values as documented. Node’s script scope keeps top-level let, const
and class bindings across lines; an eval does not, so the REPL rewrites those
declarations to persist them. The trade is that const is not enforced between
lines and a later redeclaration is accepted – the same trade Node documents for
lines containing await. REPL_MODE_STRICT is refused for the same reason: a
strict-mode eval cannot bind declarations in the global scope at all.
Why acorn
Node’s REPL vendors acorn; this port depends
on the same versions from npm (acorn@8.17.0, acorn-walk@8.3.5). A parser is
needed for what the engine cannot answer: whether a line is incomplete (show the
... prompt) or wrong (print the error) – engine SyntaxError messages differ
between SpiderMonkey and QuickJS and cannot drive that decision – rewriting
top-level await into an async wrapper, and locating the expression to
tab-complete. acorn also serves as the compile step Node performs with
vm.Script, so syntax errors read the same on every engine. It is bundled only
when node:repl is imported; a component without the REPL does not carry it.
Bundle size grows by roughly 1.2 MB (QuickJS) to 1.4 MB (StarlingMonkey).
Boundaries
| Surface | Behavior |
|---|---|
useGlobal: false or omitted, REPL_MODE_STRICT, breakEvalOnSigint | Refused at construction with ERR_JCO_UNSUPPORTED_NODE_API. Ctrl+C still arrives as a keypress and emits 'SIGINT'. |
REPLServer() without new (DEP0185) | Throws ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API before reading any argument. |
preview | Accepted and ignored, as in a Node built without an inspector. |
.save, .load, setupHistory(filePath) | Print Node’s own failure text (Failed to save: …, Could not open history file) and continue; a component has no filesystem unless the application supplies one. |
| Core modules in the context | Not auto-loaded: fs is a ReferenceError unless the application put it on the context. require throws ERR_JCO_UNSUPPORTED_NODE_API; require.resolve answers as Node does. |
| Errors | Synchronous errors print as Uncaught … with the evaluated frames only; errors thrown later by asynchronous work are not routed back, since there is no node:domain. |
writer | Jco’s portable inspector shared with node:console: the same values as util.inspect, without line breaking, showProxy, showHidden, getters or sorted. |
node:sqlite
| Imports | Implementation |
|---|---|
node:sqlite | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/sqlite |
Application code keeps ordinary node:sqlite imports:
import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync(':memory:');
try {
db.exec('CREATE TABLE items(id INTEGER PRIMARY KEY, name TEXT)');
db.prepare('INSERT INTO items(name) VALUES (?)').run('example');
const rows = db.prepare('SELECT * FROM items').all();
} finally {
db.close();
}
When bundling this module, Jco adds jco:node/sqlite@0.1.0 to the selected WIT
world. WASI supplies no database API. This interface models databases, prepared
statements, lazy cursors, sessions, and SQL tag stores as resources; SQL values,
column metadata, and errors use typed records and variants. Providers can use
other SQLite implementations without depending on Node objects in the guest.
The default provider denies database creation, including :memory:, with
ERR_JCO_SQLITE_ADAPTER_REQUIRED. Importing the module and reading constants need
no database authority. Explicitly select the Node passthrough to execute SQL:
jco transpile component.wasm \
--map 'jco:node/sqlite@0.1.0=@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/sqlite/host/node'
Database, backup, attached-database, and extension paths refer to the host
filesystem, independently of the guest’s WASI preopens. The passthrough grants
Node’s SQLite authority; use a restricted custom provider when narrower access
is required. Extension loading still requires the database’s allowExtension
option and Node’s native checks.
The compatibility target is Node 24.20.0, rather than the rolling Node API
page. Supported operations include database open/close and transactions,
prepared statement all/get/run/iterate, named and positional parameters,
blobs, signed 64-bit bigints, array rows, column metadata, SQL tag stores,
sessions and plain changeset application, serialization/deserialization, limits,
defensive mode, extension loading, and promise-based backup. Statements and
sessions retain their owning database; early iterator return releases the active
cursor. SQL execution and SQLite error fields come from the real host engine.
DatabaseSync.function, aggregate, and setAuthorizer, changeset callback
options, and backup’s progress callback throw ERR_JCO_SQLITE_CALLBACK_UNSUPPORTED.
Synchronous callbacks would need to re-enter the guest while its SQL import is
active, which this component interface cannot support. These APIs are present
as explicit stubs; no callback is invoked. APIs added after the pinned release
are outside this compatibility target.
Mapping a custom SQLite provider enables JSPI for the backup import and makes
component exports promising; await calls into the transpiled component. The
synchronous database tests run on both StarlingMonkey and QuickJS. Backup is
also tested end to end on StarlingMonkey; QuickJS currently cannot lower a
Promise returned by an exported guest function. Use StarlingMonkey for that
asynchronous guest flow.
The implementation is available directly at
@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/sqlite, with an injectable factory
at sqlite/core, but Jco’s ordinary node: import handling is the recommended
application entry point. It can be mixed with other supported Node builtins.
node:stream
| Imports | Implementation |
|---|---|
node:stream, node:stream/promises | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/stream and /stream/promises |
node:stream/consumers | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/stream/consumers |
node:stream/iter | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/stream/iter |
Classic streams
node:stream and node:stream/promises provide Readable, Writable, Duplex,
Transform, PassThrough, callback/promise pipelines, finished, readable
operators, cancellation, async disposal, and duplexPair. The implementation
reuses readable-stream 4.7.0,
with typed adaptations targeting Node 24.20, including Web Stream conversions
and support for all typed-array views. Buffer and EventEmitter identities are
shared with the corresponding node: imports. Scheduling uses guest microtasks;
no process, filesystem, network, or other host capability is required.
import { Readable, Transform, Writable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
await pipeline(
Readable.from(['hello']),
new Transform({ transform(chunk, encoding, done) { done(null, chunk.toString().toUpperCase()); } }),
new Writable({ write(chunk, encoding, done) { /* consume chunk */ done(); } }),
);
For Web Streams, use Readable.fromWeb(), Writable.fromWeb(), or
Duplex.fromWeb() before calling finished, addAbortSignal, or the
readable/writable/error/disturbance inspection helpers. Those helpers need private
engine state when used on Web Streams directly and throw
ERR_JCO_UNSUPPORTED_NODE_API. Classic streams and public Web reader/writer
conversions are supported. The deprecated Duplex.toWeb({ type }) alias throws;
use readableType instead.
Note
Classic streams are tested on QuickJS and StarlingMonkey. The current QuickJS backend lacks Web Stream, text-codec, and Abort globals, so Web conversions and operations requiring those globals need an engine that supplies them.
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. The experimental toReadable(), toReadableSync(), and
toWritable() adapters are not yet connected to the classic implementation.
They throw ERR_JCO_UNSUPPORTED_NODE_API without inspecting their arguments;
use the classic constructors directly.
Warning
Node marks
node:stream/iterexperimental. Its Jco implementation is likewise experimental and may change incompatibly without a semver-major release as the upstream Node 24 API evolves.
node:string_decoder
| Imports | Implementation |
|---|---|
node:string_decoder | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/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 bare string_decoder name follows normal package
resolution before falling back to this builtin.
node:test
| Imports | Implementation |
|---|---|
node:test, node:test/reporters | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/test and /test/reporters |
node:test and node:test/reporters target
Node.js 24.20.0.
Application code keeps its ordinary imports:
import test from "node:test";
import assert from "node:assert/strict";
await test("addition", async (t) => {
t.plan(2);
t.assert.strictEqual(2 + 3, 5);
await t.test("nested", () => {
assert.deepStrictEqual([1, 2], [1, 2]);
});
});
export function run() { return "tests completed"; }
Bundle against a world exporting run: func() -> string with
jco componentize app.js --bundle --wit wit -o app.wasm. Top-level tests run when
the engine evaluates the module, which may happen during component initialization
at build time. Tests return promises that resolve even on failure, as in Node.
Failures appear in TAP output; t.passed and t.error are available in cleanup
hooks for applications that need to expose a result through WIT. The runner does
not set the host process’s exit code. A returned “tests completed” string alone
is not evidence that assertions passed.
Tests execute serially. Synchronous, promise and callback test bodies, nested
tests, synchronous suite declarations, describe/it aliases, hooks,
skip/TODO/expected-failure directives, assertion plans, tags, and waitFor are
supported. only/runOnly emit Node’s diagnostic outside test-only mode; Jco has
no Node test-runner CLI mode. Global teardown runs when the registered queue drains,
so suites are the preferred scope for setup and teardown across related tests.
Reuse and engine requirements
The test adapter reuses jco-std’s assertion implementation, error codes, inspection,
path implementation, stream transforms, promise detection, Abort compatibility,
and signal validation. Assertion behavior and error identities therefore agree
with node:assert in the same bundle. Function, method, getter, setter and property
mocks retain the portable proxy and restoration algorithms from Node. There are
no new dependencies.
The port records provenance against Node commit
71b8b174857e25106d39b61a9e6f30d927da8b01 and retains its MIT notice.
The runner requires engine AbortController; StarlingMonkey provides it.
The pinned QuickJS backend does not, so starting a test or suite throws
ERR_JCO_UNSUPPORTED_NODE_API. Importing the module, standalone mocking and
reporting still work there. Timeouts, delayed plans and waitFor additionally use
the engine’s timer functions.
getTestContext() tracks synchronous callbacks. Component engines cannot propagate
implicit test context through await; use the explicit t.test() and t hook
methods after asynchronous boundaries. A global test/hook registration while an
async body is pending throws with that guidance. Async suite declarations and
concurrency: true or numbers greater than one are unsupported. File paths and
worker IDs are undefined; attempts are zero. The adapter does not intercept
unhandled rejections, uncaught exceptions, process signals, or test tracing events.
Mocks, snapshots and reporters
Each test owns a mock tracker that resets after cleanup hooks. Standalone mock
has explicit reset and restoreAll methods. Mock calls retain arguments,
receivers, results, errors and constructor targets. Property mocks retain access
history and one-use replacements. Symbol methods restore correctly, fixing the
pinned upstream implementation’s string-only restoration check.
run() (file discovery, watch mode, isolation and coverage), module loader mocks,
native timer mocking, and snapshot APIs throw ERR_JCO_UNSUPPORTED_NODE_API
without invoking supplied callbacks or reading their options. The deprecated
array form of mock.timers.enable() throws
ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API. These APIs cannot be implemented by
passing guest closures through a host capability.
dot, tap and junit consume event iterables. spec and lcov are callable
and constructible jco-std stream transforms. Reports use no ANSI colors or host
terminal discovery; dot wraps at 20 columns and JUnit leaves hostname empty.
TAP error details and human-readable coverage tables use portable formatting and
omit engine stack frames. LCOV can format supplied coverage events even though
the component runner cannot collect V8 coverage. Importing reporters requires no
filesystem or process provider.
node:timers
| Imports | Implementation |
|---|---|
node:timers, node:timers/promises | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/timers and /timers/promises |
node:timers and node:timers/promises target Node.js 24.20.0. Keep ordinary
imports in application code and bundle them with jco componentize --bundle:
import { setTimeout, clearTimeout } from "node:timers";
import { setTimeout as delay, scheduler } from "node:timers/promises";
const pending = setTimeout(() => console.log("later"), 100);
pending.refresh();
clearTimeout(pending);
await delay(10, "ready");
await scheduler.yield();
Timer handles and promises
The callback module supplies timeout, interval and immediate scheduling and
cancellation. Timeouts support refresh(), numeric/string cancellation IDs,
close() and Symbol.dispose. Immediates support cancellation and disposal.
close() remains functional because Node 24 marks it legacy, not deprecated.
Removed exports such as enroll and active are not reintroduced.
The promise module supplies delays, immediates, interval async iterators and the
scheduler singleton. It shares identity with timers.promises and the callback
functions’ custom promisify hooks. Abort rejects with AbortError, ABORT_ERR
and the signal’s reason as cause; interval iterators retain ticks while the
consumer is busy and release the timer when the loop breaks.
Engine requirements
No additional WIT import or host mapping is required by the adapter. The component
engine supplies the task scheduler and its underlying clocks. StarlingMonkey
supports scheduling; the current QuickJS backend lacks task timers, so scheduling
throws ERR_JCO_UNSUPPORTED_NODE_API (or rejects for promise APIs). Imports and
argument validation remain usable without timers.
setImmediate uses the runtime’s native implementation when present, otherwise
a zero-delay timer task. Nested immediates run in later tasks, but a Web engine
cannot reproduce libuv’s I/O/check phase ordering. The adapter does not replace Web
globals: imported functions return Node-style handles while the engine’s global
timer functions retain their native identities and return types. Cancel imported
timers with the imported cancellation functions or their handle methods.
ref(), unref() and hasRef() track handle state and forward liveness changes
when runtime handles support them. Active unref() and { ref: false } throw or
reject explicitly on engines with numeric Web timer handles, including
StarlingMonkey. A failed promise setup cancels its timer. Native Node timer
handles support these operations when using jco-std directly in Node.
Node’s private async-hook instrumentation, delay warnings, native inspection and
private abort-listener protection against stopImmediatePropagation() are not
ported. Abort handling uses the engine’s public event API. Direct jco-std imports
can coexist with native Node builtins, but their timer handles and cancellation
registries are separate.
Implementation source
The TypeScript adaptation follows Node’s lib/timers.js, lib/internal/timers.js
and lib/timers/promises.js at commit
71b8b174857e25106d39b61a9e6f30d927da8b01, with retained MIT notices. Engine timers
replace Node’s native queue. The audited unenv 2.0.0-rc.24 implementation was not
selected: its promise delays resolve immediately, interval promises yield only
once, and fallback handles lack the required lifecycle semantics.
node:tls
| Imports | Implementation |
|---|---|
node:tls | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls |
node:tls targets Node 24.20.0 and exposes its complete module export list.
An injected jco:node/tls@0.1.0 provider supplies encrypted sockets, listeners,
secure contexts, certificate inspection, cipher/CA queries, and TLS controls.
The guest socket uses the portable classic Duplex implementation for pipes
and backpressure. Importing the module grants no capabilities; the default
provider throws ERR_JCO_TLS_ADAPTER_REQUIRED when used.
Jco replaces ordinary node:tls imports and adds the jco:node/tls@0.1.0
capability plus the tls-callbacks guest export. Cryptography, certificate parsing,
and the TLS protocol run in the injected provider. Importing the module does not
open sockets or read the host’s trust store.
Shared TLS provider
node:tls, direct HTTPS, and secure direct HTTP/2 use jco:node/tls.
HTTP protocols pass one-use TLS configuration handles to their transport host,
so their WIT interfaces no longer duplicate certificate and cipher options.
Bind those HTTP hosts to the same TLS provider instance. The native provider
consumes the handle before starting the HTTP operation; handles from another
provider, or already consumed handles, are rejected.
HTTPS over WASI sockets calls jco:node/tls.start-tls. createWasiTlsBridge
delegates that operation to a supplied wasi:tls/types@0.2.0-draft provider and
reuses its future, connection, and WASI IO resources. The draft only supports
verified client stream upgrades. It cannot supply Node servers, per-connection
trust options, certificate inspection, or cipher controls; the bridge denies
those operations. A native provider can also accept { wasiTls } to provide
both paths. The older /tls/host exports remain WASI providers for existing
embedders; they are not the primary Node capability.
HTTPS via wasi-http is unsupported: outgoing-handler cannot accept the TLS
provider or its configuration. Select direct or wasi-sockets. HTTP/2 over
WASI sockets remains h2c-only.
Binding the native provider
Use one factory per component. With --instantiation, the import keys are the
WIT interface names without their versions; Jco’s deny-by-default module map
applies only to ESM output:
import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation";
import { createTlsHost } from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/node-host/node";
import * as wasiTlsTypes from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host";
import { createHttpHost } from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http/host/node";
import { createHttp2Host } from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http2/host/node";
import { instantiate } from "./component.js";
const tls = createTlsHost({ onCallbackError: (error) => console.error(error) });
let instance;
instance = await instantiate(undefined, {
...new WASIShim().getImportObject(),
"jco:node/tls": tls,
"wasi:tls/types": wasiTlsTypes,
"jco:node/http": createHttpHost(() => instance.httpCallbacks, tls),
"jco:node/http2": createHttp2Host(() => instance.http2Callbacks, tls),
});
if (instance.tlsCallbacks) tls.attachCallbacks(instance.tlsCallbacks);
// Call component exports. When the component is finished:
// tls.dispose();
Transpile with JSPI and promising exports. For direct HTTP, also select async
imports jco:node/http@0.1.0#request, #[method]server.listen,
#[method]server.close, and #[method]server.get-connections. Direct HTTP/2 has
the existing async session/stream selectors. Explicit --map selections for
the HTTP providers add those selectors automatically. Supplying import objects
at instantiation does not retroactively change the generated binding mode.
The native provider grants native network listeners/connections and trust-store
queries. setDefaultCACertificates changes only that provider’s trust policy,
including its subsequent HTTPS and HTTP/2 configurations. It does not change
the embedding process’s default CA store. Explicit SecureContext objects
retain their original trust settings.
dispose() destroys sockets, including incomplete handshakes, closes listeners,
and releases contexts. Guest callback traps dispose the provider and are passed
to onCallbackError. Standalone contexts and closed server objects remain
provider-owned until disposal, allowing normal inspection and server reuse.
Supplying your own implementation
Import TlsHost and TlsCallbacks from /tls/core. Start with the deny provider
and replace only the operations your component needs:
import denied from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/node-host";
import type { TlsHost } from "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/core";
const host: TlsHost = {
...denied,
// Implement permitted operations; every other operation still fails explicitly.
};
For a WASI provider, createWasiTlsBridge(provider) already supplies such an
object. No alias mapping is necessary when supplying the object directly under
the binding’s existing import key. A custom HTTP transport must understand its
TLS provider’s configuration handles.
TlsConfigurationProvider.takeContextOptions is the native HTTP adapters’ local
integration contract, not an additional WIT import. It is exported as a type from
/tls/core; HTTP hosts only require this method from their TLS provider.
The interface groups operations as follows:
| Operations | Responsibility |
|---|---|
query, set-default-ca | Cipher/CA/identity queries and provider-local trust changes. |
create-context, release-context | Validate TLS options and manage opaque configuration handles. |
connect, create-server | Create a transport for the supplied guest identifier. |
socket-operation, server-operation | Closed enums of inspection and control operations. |
write, end, release | Stream writes, completion acknowledgements, and socket cleanup. |
is-available, start-tls | Optional upgrades of owned WASI streams using the WASI TLS contract. |
Dispatch events after the initiating import returns. target is the guest
socket/server ID. Accepted sockets use a separate positive 31-bit identifier
range. The guest installs an accepted socket when it receives secureConnection.
write events carry { token, error? } and complete exactly one pending write or
end callback. Pause native reads after delivering a data chunk; the guest’s
resume operation signals available buffer capacity. core.ts and host-node.ts
define the event payloads and closed operation argument lists together.
Options and inspection values use wire.ts’s graph JSON format, { root, nodes }.
Primitive values are inline; { ref: index } references bytes, array, or
object nodes. Undefined and non-finite numbers have explicit tagged values.
Object nodes contain key/value pairs and may carry an error name. This preserves
binary fields, error codes, and self-signed certificate issuer cycles. Stream
data itself is list<u8>, not JSON. Functions and native handles never cross this
boundary. WIT result errors carry name, message, and optional code.
Compatibility and engine limits
The full Node 24.20 module export list is present. Local tests cover mutual TLS,
hostname/trust rejection, custom identity checks, ALPN, certificate graphs,
keying material, early end(), and backpressure. The component fixture adapts
the documentation’s echo client/server to use PEM arguments and finite input
instead of filesystem reads and process.stdin. It also exercises HTTPS in
both directions, and the HTTP/2 component fixture exercises the shared provider.
Explicit unsupported operations include wrapping an arbitrary guest socket with
new TLSSocket, native X509Certificate return values, PSK/SNI/ALPN/lookup
callbacks, OpenSSL engine options, and server newSession, resumeSession,
OCSPRequest, keylog, and raw TCP connection events. Use connect, getPeerCertificate,
getCertificate, and Server.addContext where applicable. Socket session,
keylog, and OCSPResponse events are delivered. Renegotiation limits are host
policy; changing CLIENT_RENEG_LIMIT or CLIENT_RENEG_WINDOW throws. The socket
is a portable Duplex; it is not an instance of the separate node:net shim’s
Socket class.
StarlingMonkey runs the component fixture. QuickJS is explicitly skipped with
TODO(unskip) because componentize-qjs cannot link the shared WASI TLS resource
types. Export a synchronous starter for long-lived event-driven work: a guest
export cannot await a promise resolved solely by a future independent host
callback. The fixture’s starter/status exports demonstrate this engine boundary.
node:trace_events
| Imports | Implementation |
|---|---|
node:trace_events | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/trace-events |
Jco supports createTracing(), getEnabledCategories(), and the returned
Tracing object’s categories, enabled, enable(), and disable() members.
The default export shares the named exports. As in Node, Tracing is not an
exported constructor.
Component usage
Use ordinary Node imports in application code:
import { createTracing, getEnabledCategories } from "node:trace_events";
const tracing = createTracing({ categories: ["node.fs.sync"] });
tracing.enable();
try {
// Host filesystem operations performed while enabled can appear in the trace.
console.log(getEnabledCategories());
} finally {
tracing.disable();
}
Bundle the application with jco componentize:
jco componentize source.js --bundle --wit wit --out component.wasm
Jco adds jco:node/trace-events@0.1.0 and its WIT dependency to the selected
world. Tracing requires no additional WASI interfaces of its own.
Granting host tracing
Tracing uses a host provider. The default provider rejects enable() and
getEnabledCategories() with ERR_JCO_TRACE_EVENTS_ADAPTER_REQUIRED.
Importing the module, creating a disabled object, reading its properties, and
disabling an already-disabled object do not call the provider. A failed enable
leaves the object disabled; host errors retain their name, code, and message.
To enable actual Node tracing, select the supplied Node provider explicitly:
jco transpile component.wasm --out-dir out \
--map 'jco:node/trace-events@0.1.0=@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/trace-events/host/node'
The provider uses Node’s real tracing agent. Categories are shared with other
components, native node:trace_events objects, and the host’s
--trace-event-categories flags. Disabling one object releases only its own
categories. Repeated enable/disable calls are idempotent, and active objects stay
alive until disabled. Native Node emits the warning for more than ten active
tracing objects.
Capture boundary
Trace data describes the Node host runtime. It does not instrument the component’s QuickJS or StarlingMonkey engine, guest garbage collection, or guest performance marks. A host operation performed on behalf of a component can emit Node trace events while the corresponding category is enabled.
Node controls trace-file creation, flushing, rotation, timestamps, and the host’s
--trace-event-file-pattern setting. The provider follows Node’s tracing
availability and main-thread restrictions. Guest command-line flags are not
forwarded to the host. The separate node:inspector provider handles the
NodeTracing inspector protocol.
Direct jco-std users can inject a provider with the trace-events/core factory;
that instance can coexist with native Node imports. Application components
should use node:trace_events so Jco supplies the WIT adapter.
Compatibility and implementation source
The compatibility target is Node.js 24.20.0, commit
71b8b174857e25106d39b61a9e6f30d927da8b01. The guest core adapts
lib/trace_events.js
under Node’s MIT license, with the license retained in the source. Shared Jco
helpers supply argument errors, inspection, and WIT error transport; an owned
host resource replaces Node’s native CategorySet handle.
Node 24 requires an array of strings and rejects an empty array. This follows the
pinned runtime even though the Node documentation
describes coercion of array members. An empty string category is accepted.
The categories getter preserves input order and duplicates and reflects later
array mutations; actual capture uses the categories copied at construction.
getEnabledCategories() returns the sorted host-wide union, or undefined when
there are none.
Inspection uses Jco’s existing portable formatter; its quoting and line wrapping can differ from Node for unusual or long category names. Guest state is updated only after successful host operations, including when access is denied.
The audited unenv 2.0.0-rc.24 implementation ignores the supplied categories, returns an empty category query, and only toggles an object’s boolean state. Jco therefore uses the Node adaptation and real host provider instead of enabling that alias. Neither public API is deprecated in the pinned Node release.
node:tty
| Imports | Implementation |
|---|---|
node:tty | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tty |
A component has no terminal of its own, and WASI 0.2 can only say whether its
standard streams are terminals. node:tty therefore resolves against
jco:node/tty@0.1.0, which addresses the embedding process’s descriptors as Node
does: isatty(fd), a handle per descriptor and direction, raw mode, the window
size, blocking reads, writes, and the environment used for color detection. When
bundled source imports node:tty, Jco adds the import to the selected world and
installs tty.wit and the shared types.wit under deps/jco-node-0.1.0.
It is denied by default: every operation, including isatty() on an in-range
descriptor, throws ERR_JCO_TTY_ADAPTER_REQUIRED until the application maps a
provider. jco-std ships one for Node:
jco transpile component.wasm \
--map 'jco:node/tty@0.1.0=@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tty/host/node'
With it, new tty.WriteStream(1) is the embedding process’s standard output when
that is a terminal, and fails with Node’s own ERR_TTY_INIT_FAILED (with errno,
syscall and info) when it is not. process.stdin and process.stdout remain
unsupported on the process facade, so applications construct the streams from
descriptors explicitly. Because readline and the REPL default terminal to
output.isTTY, these streams are what makes an interactive session work:
import { ReadStream, WriteStream } from "node:tty";
import repl from "node:repl";
export function start() {
const input = new ReadStream(0);
const output = new WriteStream(1);
repl.start({ prompt: "app> ", input, output, useGlobal: true });
input.resume();
}
The port follows Node v24.20.0’s lib/tty.js
and lib/internal/tty.js. The pinned unenv tty module answers isatty() === false
and writes through console.log; it is not used.
Boundaries
| Surface | Behavior |
|---|---|
| Prototype chain | WriteStream → Duplex → Readable → Stream → EventEmitter with an internal terminal stream standing in for net.Socket; instanceof net.Socket is false without wasi:sockets. |
| Stream sides | A ReadStream is not writable and a WriteStream is not readable; Node’s sockets open the descriptor read-write. |
| Reading | Blocks the component. A flowing ReadStream pulls one chunk per read and emits 'data' synchronously between pulls; pause() stops after the current chunk. Nothing else runs while the terminal is idle. |
'resize' | Emitted only by _refreshSize(); a component receives no SIGWINCH. |
getColorDepth() / hasColors() | Exact port. The environment defaults to the provider’s; the Windows branch answers the 16-color floor because the build-number probe needs node:os. |
| Standard descriptors on the Node host | Kept open when a stream is destroyed, as Node keeps its own stdio; descriptors above 2 are closed with the stream. Raw mode is restored when the last read handle goes. |
node:url
| Imports | Implementation |
|---|---|
node:url | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/url |
node:url supports URL construction and mutation, live URLSearchParams,
URLPattern, internationalized domains, file URL conversions, formatting, and
HTTP request options. The module and global URL/URLSearchParams constructors
share identity. Ordinary application imports work with jco componentize --bundle
on QuickJS and StarlingMonkey:
import { URL, URLPattern, pathToFileURL, fileURLToPathBuffer } from 'node:url';
const endpoint = new URL('../items', 'https://example.com/api/');
endpoint.searchParams.append('tag', 'two words');
const route = new URLPattern({ pathname: '/items/:id' });
const file = pathToFileURL('/data/a b.txt');
const bytes = fileURLToPathBuffer('file:///data/%FF');
File paths and capabilities
URL parsing, domain conversion, formatting, HTTP options and absolute file paths
need no WIT imports. Relative pathToFileURL() paths use the selected world’s
wasi:cli/environment@0.2.x interface lazily. Missing or ambiguous environment
imports produce an explicit error when cwd resolution is needed; importing the
module and using its pure operations still works.
The default path convention is POSIX. { windows: true } enables drive and UNC
paths on either backend. fileURLToPathBuffer() returns the same Buffer type as
node:buffer, preserving raw bytes and malformed percent escapes. Unlike the
string conversion, Node 24’s Buffer conversion permits encoded slash bytes.
Compatibility target and implementation
The target is Node v24.20.0, commit
71b8b174857e25106d39b61a9e6f30d927da8b01. The portable helpers are adapted from
Node’s MIT-licensed lib/url.js and lib/internal/url.js. The WHATWG core is
whatwg-url@14.2.0, with tr46@5.1.1, webidl-conversions@7.0.0, and
punycode@2.3.1; pattern matching uses urlpattern-polyfill@10.1.0.
Jco adds Node’s constructor coercion, error codes, legacy object formatting, and
lazy path providers. Its UTF-8 adapter handles malformed sequences consistently
across engines; the decoder is adapted from Apache-2.0-licensed
text-decoder@1.2.7. StarlingMonkey’s native URL host parser supplies IDNA
normalization because that engine lacks String.normalize(). At bundle time,
regexpu-core@6.4.0 expands URLPattern’s two Unicode identifier expressions for
StarlingMonkey. Only the exact audited dependency files receive these adapters.
The installed unenv@2.0.0-rc.24 URL implementation was not admitted: it lacks
URLPattern and fileURLToPathBuffer, uses Punycode without domain validation,
and differs in Windows paths, Unicode formatting, and absent HTTP option fields.
The new implementation continues to share Jco’s audited Buffer and querystring
cores. Applications can mix ordinary node: imports with direct jco-std adapters;
the latter expose explicit factories for callers supplying their own providers.
Intentional differences
Deprecated string parsing is refused immediately with
ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API: parse(), resolve(),
resolveObject(), format(string), and the corresponding legacy parsing methods.
These errors occur before argument coercion or callbacks. Use new URL(input, base)
or URL.parse(input, base). Legacy Url construction, object formatting,
parseHost(), and Url.prototype.resolveObject(object) remain functional.
URL.createObjectURL() and URL.revokeObjectURL() throw
ERR_JCO_UNSUPPORTED_NODE_API; Jco does not provide Node’s thread-local Blob URL
registry. Invalid Punycode labels can be rejected more strictly by the WHATWG
fallback than by Node’s Ada parser. Engine-specific inspection and stack formatting
are not reproduced. Errors mentioning the file host platform use posix.
node:util
| Imports | Implementation |
|---|---|
node:util, node:util/types | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/util and /util/types |
node:util provides MIME parsing, argument and environment-file parsing, text
styling, string/array diffs, callback/promise conversion, inspection and formatting,
inheritance, deep equality, and type predicates. Default and named imports are
available; node:util/types shares the same predicate object as util.types.
import { MIMEType, parseArgs, promisify, styleText } from 'node:util';
import { isUint8Array } from 'node:util/types';
const mime = new MIMEType('text/plain; charset=utf-8');
const { values } = parseArgs({
args: ['--verbose'],
options: { verbose: { type: 'boolean' } },
});
const increment = promisify((value, callback) => callback(null, value + 1));
const answer = await increment(41);
const heading = styleText('bold', mime.essence, { validateStream: false });
const bytes = isUint8Array(new Uint8Array([answer]));
The contract targets Node 24.20.0. Portable algorithms run in both QuickJS and
StarlingMonkey. The implementation shares deep equality with node:assert, the
formatting core with node:console, and scheduling and validation with the
existing stream and error helpers.
parseArgsrequires an explicitargsarray. It never reads hostprocess.argv.parseEnvreturns parsed values without modifying an environment.styleTextrequires{ validateStream: false }for unconditional ANSI output, or an explicit stream. Stream validation uses itsisTTYflag; host color environment variables and terminal capabilities are not consulted.TextEncoderandTextDecoderuse the engine constructors. StarlingMonkey provides them; QuickJS currently throwsERR_JCO_UNSUPPORTED_NODE_APIon construction.promisifypreserves custom hooks, receivers and callback results. Passing a declared async function without a custom hook throws a deprecated-API error. The shim cannot identify an ordinary function that returns a promise without calling it; use promise-returning functions directly.callbackifyschedules callbacks through the shared guest microtask queue, without a separate NodenextTickphase.inspect,formatandformatWithOptionssupport ordinary values, collections, descriptors, custom hooks, circular references and inspection options. Native engine details and Node’s full pretty-print layout are not reproduced. Promises display<state unavailable>and weak collections display<items unknown>.showProxyand hidden promise/weak-collection state throw;%oinspects hidden properties without unwrapping proxies. Inspection can trigger proxy traps.- Buffer, typed-array, boxed-value and collection predicates use intrinsic brand
checks. Promise checks require the same realm. Arguments, generator, iterator,
module-namespace and function checks use observable tags and can be spoofed;
error checks have the same limitation when the engine lacks
Error.isError.isCryptoKeyrequires the engine’sCryptoKeyimplementation. abortedrequires the engine’sWeakRefandFinalizationRegistryfor an active signal. It uses public abort listeners; an earlier listener callingstopImmediatePropagation()can prevent notification.
Host process and native engine operations throw ERR_JCO_UNSUPPORTED_NODE_API:
debug/debuglogdeprecategetCallSitesgetSystemErrorNamegetSystemErrorMessagegetSystemErrorMapsetTraceSigIntconvertProcessSignalToExitCodetransferableAbortControllertransferableAbortSignalisProxy,isExternal, andisKeyObjectpredicates
Deprecated isArray, _extend, _errnoException, and
_exceptionWithHostPort throw ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API.
node:v8
Jco provides the Node 24.20.0 node:v8 export surface. Native operations use an
explicit jco:node/v8@0.1.0 capability and are denied by default.
Enable the Node provider
Application source keeps ordinary Node imports:
import { serialize, deserialize, getHeapStatistics } from 'node:v8';
const bytes = serialize({ message: 'hello', count: 42n });
console.log(deserialize(bytes));
console.log(getHeapStatistics());
Bundle with Jco’s Node builtin support, then explicitly grant the host capability:
jco componentize app.js --bundle --wit app.wit -o app.wasm
jco transpile app.wasm -o out \
--map 'jco:node/v8@0.1.0=@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/v8/host/node'
Importing the module does not access the provider. Without an explicit mapping,
native operations throw ERR_JCO_V8_ADAPTER_REQUIRED. The portable adapter can
also be imported directly from jco-std alongside ordinary Node builtins.
Host diagnostics and controls
The provider delegates these operations to public node:v8 APIs:
cachedDataVersionTag, heap, heap-space, code and C++ heap statistics;getHeapSnapshot,writeHeapSnapshot, andsetHeapSnapshotNearHeapLimit;GCProfilerandstartCpuProfile;setFlagsFromString,takeCoverage, andstopCoverage.
These operations inspect or affect the Node host V8 isolate. They do not inspect the QuickJS or SpiderMonkey guest heap. Snapshot paths, coverage settings, and V8 flags belong to the host process. The cached-data tag describes host V8; it cannot establish compatibility with guest compiled code.
getHeapSnapshot returns a portable byte-mode Readable. The provider gathers
the synchronous native snapshot before copying it into the component, so this
requires additional memory and does not provide incremental host streaming.
Profiles stop and release their native resource on stop() or disposal. Repeated
stops return undefined, matching the pinned runtime.
startCpuProfile requires a Node host that provides that API. On older hosts,
including Node 22, it throws ERR_JCO_UNSUPPORTED_NODE_API. Other V8 operations
remain available when supported by the host.
Serialization
serialize and deserialize use the native V8 binary format. Serializer,
Deserializer, DefaultSerializer, and DefaultDeserializer support headers,
values, unsigned integers, doubles, raw bytes and wire-format inspection. Successive
value writes and reads preserve object identity through the native serializer.
The shared component transport supports primitives, bigint, special numbers, cyclic plain records and arrays, Map, Set, Date, RegExp, ArrayBuffer, DataView, and the integer/float typed arrays available in both engines. The default serializer also preserves Buffer branding and visible bytes.
The transport rejects accessors, custom prototypes, Error objects, Float16Array,
shared memory and native objects with explicit errors. Functions and symbols
cannot be serialized. Serializer subclasses, custom native serialization hooks,
and transferArrayBuffer registrations are unsupported across the component
boundary. The corresponding entry points throw ERR_JCO_UNSUPPORTED_NODE_API.
No JSON substitute is returned as a V8 serialization buffer.
Buffers cross WIT by value: readRawBytes returns a copy rather than a view into
the caller’s original input. Deserialization takes a snapshot of the input bytes
at construction. Changes to the input afterwards are not visible to the native
reader. Native format versions are controlled by the selected host Node release.
releaseBuffer() releases a writer’s native resource; a subsequent write reopens
it. Serializer and deserializer objects additionally expose Symbol.dispose for
deterministic cleanup of abandoned writers and completed readers. The convenience
functions clean up their resources automatically.
Guest engine restrictions
promiseHooks, queryObjects, and isStringOneByteRepresentation throw
ERR_JCO_UNSUPPORTED_NODE_API: guest promises, constructors and string storage
cannot be inspected through a host V8 call.
startupSnapshot.isBuildingSnapshot() returns false. The three startup callback
registration methods throw ERR_NOT_BUILDING_SNAPSHOT, matching ordinary Node
execution outside its snapshot builder. Componentization does not run Node’s
startup-snapshot callbacks.
Implementation
The compatibility target is Node v24.20.0,
source commit 71b8b174857e25106d39b61a9e6f30d927da8b01. Public declarations are
reconciled with @types/node 24.13.3 and do not require consumer Node types.
Native V8 owns binary serialization and diagnostics. Jco reuses its shared errors, validation, Buffer, Readable, and the graph transport extracted from the worker-threads implementation. Workers retain their existing cloning policy; V8 opts into Buffer preservation and persistent reference sessions.
The unenv V8 module uses mock statistics and inert serializers, so it is not used.
The codec audit also considered @ungap/structured-clone, devalue, flatted and the
existing cluster JSON transport. The shared worker codec already preserves the
required backing-buffer relationships and clone-marking policy; V8 adds session
identity without replacing the worker format.
node:vfs
Jco implements the experimental Node 26.8.2 VFS API. Application code keeps its
ordinary node:vfs imports. create() uses an isolated MemoryProvider by
default; RealFSProvider accesses an explicitly supplied filesystem capability.
Only the node: specifier is intercepted.
This provider selection mirrors Node.js’s node:vfs API.
MemoryProvider and RealFSProvider are upstream Node.js classes: application
code calls create() for memory storage, or explicitly passes
new RealFSProvider(root) for filesystem storage. Jco’s --with-nodejs-vfs-via
option selects the backend used by RealFSProvider; it does not change the
in-memory default of create().
import { create, RealFSProvider } from 'node:vfs';
export function run(root) {
const fs = create(new RealFSProvider(root));
fs.mkdirSync('/reports', { recursive: true });
fs.writeFileSync('/reports/result.txt', 'done');
return fs.readFileSync('/reports/result.txt', 'utf8');
}
Omit the provider to use memory. Memory operations never consult host providers,
preopens, or a storage resolver. MemoryProvider.setReadOnly() prevents subsequent
write operations through the provider while preserving existing contents.
Choosing a filesystem implementation
| Selection | Host capability | Default behavior |
|---|---|---|
--with-nodejs-vfs-via direct | jco:node/fs@0.1.0 | Filesystem access is denied with ERR_JCO_FS_ADAPTER_REQUIRED. |
direct with an explicit Node host mapping | jco:node/fs@0.1.0 | Node filesystem passthrough under each RealFSProvider root. |
--with-nodejs-vfs-via wasi-filesystem | wasi:filesystem/preopens and types at 0.2.12 | Access is limited to the preopens supplied at instantiation. |
direct is the default. It reuses the same filesystem boundary and host provider
as node:fs; mapping that capability grants it to both APIs in the component.
Imports and provider construction do not themselves perform filesystem operations.
The selected host is accessed lazily when an operation needs it.
For Node passthrough:
jco componentize app.js --wit wit --bundle -o app.wasm
jco transpile app.wasm -o out \
--map 'jco:node/fs@0.1.0=@bytecodealliance/jco-std/wasi/0.2.x/node/26.x.x/vfs/host/node'
The vfs/host/node export reuses the existing Node filesystem provider. It does
not depend on the host having native node:vfs or enabling --experimental-vfs.
The VFS façade, virtual descriptors, and memory tree run inside the component.
For WASI filesystem access:
jco componentize app.js --wit wit --bundle \
--with-nodejs-vfs-via wasi-filesystem -o app.wasm
jco transpile app.wasm -o out --instantiation async
Jco adds the selected interfaces and their WIT dependencies. Configure preopens when instantiating the result, for example with preview2-shim:
import { instantiate } from './out/app.js';
import { WASIShim } from '@bytecodealliance/preview2-shim/instantiation';
const wasi = new WASIShim({
sandbox: { preopens: { '/data': '/srv/application-data' } },
});
const app = await instantiate(undefined, wasi.getImportObject());
app.run('/data');
VFS roots and application paths use POSIX syntax. Real provider roots must be absolute. Filesystem root directories must already exist. Relative paths are resolved from the VFS root, not the host process’s working directory. Root and symlink checks are compatibility checks; VFS is not a replacement for host isolation or correctly scoped WASI capabilities.
Configuring storage placement
By default, a WASI real provider selects the longest preopen mount containing its
root. For a preopen /data, new RealFSProvider('/data/projects/demo') stores
contents in projects/demo within that preopen. A root with no matching preopen
fails with EACCES. An exact mount match uses the preopen’s root directory.
Supply a guest JavaScript module exporting resolveRoot to select another
preopen or directory:
// vfs-storage.js
export function resolveRoot(rootPath, preopens) {
const storage = preopens.find(([, name]) => name === '/data');
if (!storage || rootPath !== '/workspace') {
throw new Error('No storage configured for this VFS root');
}
return { descriptor: storage[0], directory: 'projects/demo' };
}
jco componentize app.js --wit wit --bundle \
--with-nodejs-vfs-via wasi-filesystem \
--with-nodejs-vfs-wasi-config ./vfs-storage.js -o app.wasm
The module path is resolved from the command’s working directory and bundled into
the guest. The callback receives the normalized VFS root and [descriptor, guestPath] preopen pairs. It returns a borrowed descriptor and a directory
relative to it. Absolute directories and .. paths escaping the preopen are
rejected. The selected directory must already exist. The resolver runs once per
real provider, on first use; separate VFS instances can choose different folders.
Exceptions propagate to the caller. The implementation disposes descriptors it
opens, but never disposes the resolver’s borrowed preopen.
Direct adapter users can configure the same callback through
createWasiVfs({ preopens, resolveRoot }) from
@bytecodealliance/jco-std/wasi/0.2.x/node/26.x.x/vfs/impl/wasi-filesystem.
Direct adapters and native Node builtins can coexist in one host application.
Application components should continue importing node:vfs.
Supported operations and limits
The module exports create, VirtualFileSystem, VirtualProvider,
MemoryProvider, and RealFSProvider. File contents, directories, copy/rename,
hard links, symbolic links, metadata, directory handles, and scalar virtual
file-descriptor I/O are supported. Callback and promise façades share the same
provider. Custom providers inherit the base class’s derived file operations.
VFS openAsBlob() returns a snapshot when the component engine supplies Blob.
As in the pinned runtime, vfs.promises.open() returns a numeric virtual
file descriptor, not a Node fs.promises.FileHandle.
Mounting into native node:fs or the module loader, filesystem streams, and
watchers throw ERR_JCO_UNSUPPORTED_NODE_API. mounted stays false and
mountPoint stays null. Providers report supportsWatch: false. Missing custom
provider primitives throw ERR_METHOD_NOT_IMPLEMENTED. The component does not
emit Node’s process-wide experimental warning. Memory metadata uses UID/GID zero
rather than consulting the host process.
WASI 0.2.12 has no chmod/chown or access-permission test operation. Those calls
fail explicitly; existence-only access works. WASI stat fields absent from the
interface use zero for device, inode, ownership and birth time, and conventional
file/directory mode bits. Actual size, link count and available timestamps come
from WASI. Directory listing order is host-dependent. Resource operations use
WASI’s synchronous descriptor methods and preserve 64-bit offsets.
Provenance
The portable VFS algorithms are adapted from Node
v26.8.2,
commit f2f2c2f246c36bd74f082cb43ecfe830657d81c9, with MIT attribution retained.
The implementation reuses Jco’s portable filesystem types, value objects,
validation, error transport, and Node host provider. Audited unenv 2.0.0-rc.24 has
no VFS implementation. Its alias map is not enabled for this module.
node:vm
Jco supports a portable subset of Node v24.20.0’s VM API:
script evaluation and function compilation inside the component. It requires no
additional WIT imports or host provider. Use the node:vm import specifier;
bare vm imports remain unresolved.
Execution model and security
Evaluated code shares the component’s global scope and capabilities. It can read
and modify globalThis, but cannot access the calling function’s lexical variables.
Objects and functions returned by evaluated code retain their identity, and
functions can close over local variables created during evaluation.
Warning
node:vmis not a security sandbox. Only evaluate code you trust with the capabilities available to your application. With passthrough Node adapters, those capabilities can include host filesystem access, process execution, or even running JavaScript on the host (for example, through the inspector adapter). Insecure VM code can use any such APIs you expose to it with the host process’s authority; guest WASI restrictions do not constrain those host-side operations. Native Node VM contexts also do not provide a security boundary for untrusted code.
Scripts and functions
import { Script, compileFunction } from "node:vm";
export function run() {
const expression = new Script("6 * 7", "answer.js");
const add = compileFunction("return left + right", ["left", "right"]);
return expression.runInThisContext() + add(2, 3);
}
For an application world exporting run: func() -> u32, build this source with:
jco componentize app.js --bundle --wit wit -o app.wasm
new Script() and createScript() check syntax without executing the source.
Scripts can run repeatedly through script.runInThisContext(); the top-level
runInThisContext() combines creation and execution. Creating a Script does not
precompile native bytecode: its source is parsed again when it runs.
Scripts support expressions, control flow, block-local lexical declarations, and
explicit globalThis mutations. Use globalThis properties for state shared
between runs:
const increment = new Script("globalThis.counter += 1");
globalThis.counter = 0;
increment.runInThisContext(); // 1
increment.runInThisContext(); // 2
compileFunction() supports ordinary function bodies, including local
declarations, this, arguments, and returned closures. Parameter names must be
individual identifiers; default parameters, rest parameters, and destructuring
patterns are unsupported.
Restrictions
The following operations throw ERR_JCO_UNSUPPORTED_NODE_API:
- Separate contexts:
createContext(),runInContext(),runInNewContext()and the corresponding Script methods, includingconstants.DONT_CONTEXTIFYusage. - Global
var,let,const, class, or function declarations in scripts. These are rejected before execution, includingvardeclarations inside blocks and block functions that could introduce global bindings. UseglobalThisproperties for shared state, orcompileFunction()for local declarations. - Execution limits:
timeoutandbreakOnSigint: true. There is no VM execution watchdog; these options fail before the code runs. - Nonzero
lineOffsetorcolumnOffset, cached data,parsingContext, and nonemptycontextExtensions. - Dynamic
import()syntax andimportModuleDynamicallyoptions. Evaluated code has no built-in Node module loader, including throughconstants.USE_MAIN_CONTEXT_DEFAULT_LOADER. Script.createCachedData()andmeasureMemory(). Memory measurement throws synchronously, rather than returning a promise.- All
Module,SourceTextModule, andSyntheticModuleoperations. These experimental classes are exported, but their constructors, methods, and getters throw immediately without inspecting arguments or invoking callbacks.
isContext() validates its input and returns false; separate contexts cannot
be created. The constants namespace is frozen, and its symbols do not enable
otherwise unsupported operations.
Script’s deprecated produceCachedData option throws
ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API whenever present, including explicit
false or undefined, before converting the source or reading option getters.
For compileFunction(), produceCachedData is not deprecated: false is accepted
and true throws ERR_JCO_UNSUPPORTED_NODE_API.
Source metadata and diagnostics
Script.sourceURL and Script.sourceMapURL expose metadata from source line
comments. The filename option labels the source in diagnostics; line terminators
are removed from that label.
Syntax-error messages and stack traces may differ from Node’s, and
engine-specific syntax extensions are unsupported. displayErrors is validated
but does not control V8-style source excerpts. Function stringification may also
differ from Node’s output.
node:wasi
| Imports | Implementation |
|---|---|
node:wasi | @bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/wasi |
node:wasi runs a WASI preview1 module: the caller compiles it with the
WebAssembly API, instantiates it against wasi.getImportObject(), and hands
the instance to wasi.start() or wasi.initialize(), whose syscalls then read
and write the instance’s linear memory. None of that is possible from inside a
Jco component. Neither guest engine exposes a WebAssembly global, so a
component cannot instantiate a nested module, and a linear memory cannot cross
the component boundary, so no host adapter can run the module either. This is
a property of the component model, not a missing shim, and it is why a Node
passthrough for node:wasi does not exist.
node:wasi therefore resolves so that such source bundles and fails clearly
rather than leaving an unresolved import. The module ports
Node v24.19.0’s lib/wasi.js:
the WASI class, its exact option validation, the 46-entry wasiImport table
with Node’s names and arities, getImportObject(), and the instance checks of
start(), initialize() and finalizeBindings(). When bundled source imports
node:wasi, Jco adds jco:node/wasi@0.1.0 to the selected world and installs
wasi.wit and the shared types.wit under deps/jco-node-0.1.0.
That capability carries the one part of the API a host can honour: the
constructor’s uvwasi_init step, which opens every preopen and checks the
standard descriptors. It is denied by default, so new WASI() throws
ERR_JCO_WASI_ADAPTER_REQUIRED after validating its options; the message also
explains that running a module is unsupported, so the limitation is visible from
the first call. jco-std ships a provider for Node that runs the real node:wasi
constructor and discards the result:
jco transpile component.wasm \
--map 'jco:node/wasi@0.1.0=@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/wasi/host/node'
With it, a missing preopen fails with Node’s own UVWASI_ENOENT (errno,
code and syscall: 'uvwasi_init'), a preopen that is a file with
UVWASI_ENOTDIR, and a closed descriptor with UVWASI_EBADF, in Node’s order
relative to the JavaScript validation (returnOnExit is checked after
initialisation, as in Node). Importing the provider emits Node’s
ExperimentalWarning once in the host process.
import { WASI } from "node:wasi";
export function prepare(sandbox) {
const wasi = new WASI({ version: "preview1", args: ["app"], preopens: { "/sandbox": sandbox } });
return Object.keys(wasi.getImportObject()); // ["wasi_snapshot_preview1"]
}
Boundaries
| Surface | Behavior |
|---|---|
start(), initialize(), finalizeBindings() | Validate instance and instance.exports as Node does, then throw ERR_JCO_UNSUPPORTED_NODE_API explaining that a component cannot instantiate a nested module and pointing at composition (wac, wasm-tools compose) or the host. |
instance.exports.memory | Where a WebAssembly global exists, a value that is not a WebAssembly.Memory still gets Node’s ERR_INVALID_ARG_TYPE; in a guest there is no such global, so every value is refused with the explanation above. |
wasiImport | Node’s 46 syscalls with Node’s bound names and arities. Before start(), which never completes, each answers UVWASI_EINVAL to a call with the wrong argument count or types and throws ERR_WASI_NOT_STARTED otherwise. |
proc_exit | With returnOnExit (the default) records the exit code and throws Node’s kExitCode symbol, as in Node; otherwise behaves like the other syscalls. |
| Experimental warning | Node warns once on require('node:wasi'); the guest module does not, since a component has no warning channel to promise. |
Worker threads
node:worker_threads targets Node 24.20.0. Jco provides a guest adapter and an
opt-in Node host provider for real worker execution. A worker script runs on the
Node host; it is not another JavaScript isolate inside the Wasm component.
File paths and file URLs therefore refer to the host filesystem. eval: true
runs CommonJS source; file and data URLs can load ES modules.
Granting worker execution
Bundle ordinary Node imports:
import { Worker } from 'node:worker_threads';
export function start() {
const worker = new Worker(`
const { parentPort, workerData } = require('node:worker_threads');
parentPort.postMessage(workerData * 2);
`, { eval: true, workerData: 21 });
worker.on('message', value => console.log(value));
worker.on('error', error => console.error(error));
}
jco componentize app.js --wit wit --bundle -o app.wasm
Jco adds jco:node/worker-threads@0.1.0 and its callback export to the world.
The default provider denies worker creation with
ERR_JCO_WORKER_THREADS_ADAPTER_REQUIRED. An import alone grants no execution
capability. To allow workers, instantiate with a separate provider for each
component:
jco transpile app.wasm -o out --instantiation async \
--async-mode jspi --async-exports '*' \
--map jco:node/worker-threads@0.1.0=jco:node/worker-threads@0.1.0
import { instantiate } from './out/app.js';
import { WASIShim } from '@bytecodealliance/preview2-shim/instantiation';
import { createWorkerThreadsHost } from '@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/worker-threads/host/node';
let instance;
const imports = new WASIShim().getImportObject();
imports['jco:node/worker-threads@0.1.0'] = createWorkerThreadsHost(
() => instance.workerThreadsCallbacks,
);
instance = await instantiate(undefined, imports);
await instance.start();
Create workers inside exported functions, after instantiation. The provider
serializes callback entry and resource disposal. It delivers online, message,
messageerror, error, and exit events. Exports that start workers should return
so the host can deliver events between component calls. terminate() resolves
when the exit callback arrives; ref(), unref(), and asynchronous disposal are
supported. Workers use the same EventEmitter adapter as node:events.
StarlingMonkey supports these callbacks. QuickJS can use the module’s local APIs and observe capability denial, but its exported-resource callback limitation currently prevents the real-worker component test from running.
Messages and environment data
Messages and workerData cross WIT using a structured-value graph. It preserves
plain records, sparse arrays, cycles, shared references, undefined, bigints,
special numbers, Map, Set, Date, RegExp, ArrayBuffer and typed-array/DataView
slices. Buffer arrives as Uint8Array, as in native worker messaging.
Functions and symbols cannot be cloned. Accessor properties, custom prototypes,
Error objects, SharedArrayBuffer, native handles and transfer lists are explicitly
unsupported. Rejected values never silently become lossy JSON. An unsupported
message from the host produces messageerror. Transferable ownership and shared
memory are not emulated.
setEnvironmentData() and getEnvironmentData() use a separate Map per component.
Values retain their identity locally; setting undefined deletes a key. Each
worker receives a snapshot when constructed. markAsUncloneable() is enforced
by this adapter’s outgoing message and worker-data serializer, including nested
values. markAsUntransferable() and isMarkedAsUntransferable() track object
identity. These marks do not change the engine’s global structuredClone() or
other APIs outside this adapter.
The guest reports its own main-thread context: isMainThread: true,
isInternalThread: false, threadId: 0, empty threadName and resourceLimits,
and null parentPort/workerData. Code executed in a native worker observes
Node’s actual worker-thread fields instead.
Explicit limits
The public export names match Node 24.20.0. The following operations throw
ERR_JCO_UNSUPPORTED_NODE_API immediately:
- MessageChannel, MessagePort and BroadcastChannel construction and operations;
receiveMessageOnPort(),moveMessagePortToContext()andpostMessageToThread();locks.request()andlocks.query();SHARE_ENV, transfer lists and worker stdio redirection;- Worker stdio accessors, performance telemetry, heap snapshots/statistics and CPU/heap profiling.
The provider executes trusted host-side Node code with the host’s authority. It
does not automatically bundle worker entry files or reinterpret them as guest
components. The limited unenv worker implementation was rejected because its
workers and ports discard messages and its broadcasts are no-ops. This adapter
reuses Jco’s callback, error and event infrastructure and Node’s native Worker;
the environment Map operations are adapted from Node’s MIT-licensed
lib/internal/worker.js at commit 71b8b174857e25106d39b61a9e6f30d927da8b01.
node:zlib
Jco supports the Node.js 24.20 node:zlib module through an explicit compression
host capability. Application code keeps ordinary Node imports:
import { gzipSync, gunzipSync } from "node:zlib";
export function roundTrip(text) {
return gunzipSync(gzipSync(text)).toString();
}
jco componentize app.js --bundle --wit wit -o app.wasm
jco transpile app.wasm -o out \
--map 'jco:node/zlib@0.1.0=@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/zlib/host/node'
Bundling adds jco:node/zlib@0.1.0 to the selected WIT world. Without an explicit
provider mapping, compression, decompression, stream construction and CRC32 fail
with ERR_JCO_ZLIB_ADAPTER_REQUIRED. Importing the module and reading constants
or codes require no capability.
Supported operations
The adapter exposes the Gzip/Gunzip, Deflate/Inflate, DeflateRaw/InflateRaw, Unzip,
BrotliCompress/BrotliDecompress and ZstdCompress/ZstdDecompress constructors,
corresponding create* factories, callback and synchronous convenience methods,
crc32, constants and codes. Constructors are callable with or without new.
Streams share Jco’s portable node:stream.Transform, including piping,
backpressure, errors and close events. Flush, reset and zlib parameter changes
operate on persistent native compression state. Dictionaries, parameter maps,
info: true, output limits and byte counts cross the typed WIT boundary.
The Node provider delegates compression to the embedding Node runtime. Jco does not include a compression algorithm. The guest can run in QuickJS or StarlingMonkey; the Node provider itself requires Node, including native Zstandard support for Zstd operations. A browser host can supply the same WIT interface.
Scheduling and lifetime
WIT operations are synchronous and block the importing thread. Guest callbacks
are scheduled after the initiating call returns. QuickJS requires synchronous
WIT exports; returning a Promise from such an export is a backend restriction.
Each live streaming engine uses a Node worker to execute public Node stream operations and collect output; close,
destroy or WIT resource drop releases it. One-shot synchronous methods call Node
directly. Worker requests have a 60-second deadline and fail with
ERR_JCO_ZLIB_HOST_TIMEOUT if the worker cannot respond. This scheduling differs
from Node’s application-thread/libuv scheduling, and many simultaneous streams
have worker overhead.
Compatibility target
The contract is pinned to Node v24.20.0, commit
71b8b174857e25106d39b61a9e6f30d927da8b01, using lib/zlib.js, its API documentation
and matching-major TypeScript declarations. The installed unenv 2.0.0-rc.24
zlib entries are unimplemented and omit Zstandard, so they are not used.
Deprecated bytesRead and direct properties such as zlib.Z_FINISH throw explicit
deprecation errors; use bytesWritten and zlib.constants.Z_FINISH. Deprecated
constant aliases are available only as default-object error accessors, not named
ESM exports. The separate, flag-gated node:zlib/iter module and ZIP archive APIs from newer Node releases
are outside this adapter. Bare zlib imports are not intercepted.
Direct jco-std consumers can use zlib/core with an explicit typed provider and
mix the resulting module with native Node builtins. Normal component applications
should use node:zlib and the builtin integration shown above.
Express
Express 5.2.1 runs as an ordinary npm dependency. Jco does not replace Express,
its router, or its middleware. The component fixture imports express, registers
routes, installs express.json(), and calls app.listen() unchanged.
Use jco componentize --bundle with --with-nodejs-http-via direct. Keep the
fixture’s WIT world limited to application exports; Jco discovers and adds the
required Node interfaces and wasi:cli/environment while bundling. On the host,
use the per-component createHttpHost(() => instance.httpCallbacks) registration
described in HTTP implementation selection.
An imported capability does not grant access: map the HTTP provider explicitly,
and leave filesystem access denied unless the application needs it.
Create the Express app inside an exported function, as the fixture does:
express() reads the working directory through WASI, which is unavailable
during module pre-initialization.
The end-to-end test compares the same Express app running in Node and in a StarlingMonkey component over real HTTP sockets. It covers concurrent requests, route parameters, query strings, JSON request bodies, malformed JSON, application error middleware, generated ETags, conditional 304 responses, and default 404s. The direct transport buffers request and response bodies; this is not a claim that every Express extension or streaming workload is supported.
Dependency compatibility
CommonJS packages may use audited bare builtin names such as http, stream,
and crypto. Jco first checks normal package resolution, so installed packages
with those names still win. Explicit node: imports always select the builtin.
The stream CommonJS adapter exports jco-std’s existing Stream constructor;
it supplies no alternative stream implementation. HTTP, HTTPS, net, classic
streams, string_decoder, URL, TTY, OS, util, and util/types APIs all use their current jco-std adapters.
The remaining support needed by this dependency graph includes:
- Synchronous SHA-1/SHA-256 hash and HMAC helpers for ETags and cookie signatures.
Other crypto operations have explicit limits; this is not full
node:crypto. setImmediate/clearImmediateglobals backed by component timers. Their scheduling approximates a later turn, not Node’s I/O check phase.- A structured
Error.prepareStackTraceadapter for middleware usingdepd. - Limited unenv implementations of
processandzlib. These retain unsupported entries and are not full Node APIs. In particular, compressed request bodies and zlib transforms are unsupported;sendFileand static files additionally need filesystem authority.
The injected process global comes from unenv so dependency initialization can
run without host calls. Explicit node:process imports use jco-std
and require the separate jco:node/process host capability.
The URL adapter retains its deprecated API restrictions;
Express dependencies that reach legacy url.parse() will throw. TTY operations
require the separate TTY host capability.
Applications needing additional runtime behavior should test it explicitly.
Regex syntax in dependencies
StarlingMonkey’s pinned runtime cannot parse the Unicode property escapes used
by Express 5’s path-to-regexp. During bundling, Jco uses Rolldown’s parser to
find regex literals and regexpu-core
to lower their property escapes and Unicode set syntax. Strings, comments, and
template text stay untouched. Dynamic RegExp constructor strings are not
rewritten. Unicode tables come from the pinned compiler dependency.
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:
- Latest stable Rust with the
wasm32-wasitarget - Node.js 18+
pnpm
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 todist/api.jsand published as@bytecodealliance/jco.src/jco.ts: The jco CLI. It is compiled todist/jco.jsand 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.jspackages/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-shimunit tests.pnpm run --filter 'packages/preview3-shim' test:preview3-shimunit 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
jcotests, you will want to build the project first during a test run, to ensure the latest version of Rust code (i.e.js-component-bindgenis in use):cd packages/jco pnpm run build pnpm run test api.jsThe 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:
buildchorecidebugdocsfeatfixperfrefactorreleaserevertstyletestsec(security)
The following project scopes are valid:
jcop2-shimp3-shimbindgentranspilestd
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:
depsciops
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.