raw
current commit
261107.16 · docs(jco): explain catching background traps

Transpiling

Components can be transpiled in two separate modes:

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

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

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

Usage

To transpile a component into JS:

jco transpile component.wasm -o out-dir

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

app.js

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

fn();

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

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

Options

Options include:

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 componentgithub 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 directorygithub.

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

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

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

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

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 Jcogithub to support the new --map wasi:subsytem/*=shimpkg/subsystem#* for the WASI subsystem being implemented.

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

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

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>,
  options?: InstantiateOptions
): 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,
  options?: InstantiateOptions
): { [exportName: string]: any };

Where instead of promises, all functions are synchronous.

options.onTrap is called with each trap of the instance, once, as soon as it happens. That includes a trap in a task a guest left running, after the calls of the host’s that started it have returned, which no promise would otherwise report; a trap that also fails a call does that as well. A deadlock is reported too. A host import throwing something other than a WebAssembly.RuntimeError isn’t a trap, and isn’t reported: the host knows it threw. onTrap is called from the middle of the runtime’s unwinding: it may call _util.stop(), but it shouldn’t use the instance otherwise. An exception it throws is left uncaught. See Detecting traps for examples of catching failed calls and receiving background trap notifications.

Dropping a received future

A future received from a guest is lazy: receiving it does not consume its value. Read it with await future, or call future[Symbol.dispose]?.() to release its readable end. Disposal is idempotent, rejects a pending read, and notifies a waiting or subsequent writer that the reader has gone. Further reads reject. A value already delivered by await or a fulfillment callback belongs to the caller and remains usable after the future is disposed. The caller must dispose that value separately when appropriate. Disposal only releases a copied disposable value if it has not yet been delivered. Host-supplied promises need not implement disposal, so generated future types make the method optional.

Stopping an instance

Call instance._util.stop() to permanently stop a generated instantiation, including its background guest work. For direct ES module output, call the exported _util.stop().

See Stopping running instances for the scope of shutdown, channel and resource ownership, host cleanup, and output draining.

Runtime overrides

The optional fourth argument, options, configures the instantiation. Its type is exported from the generated bindings as InstantiateOptions:

export interface InstantiateOptions {
  onTrap?: (error: WebAssembly.RuntimeError) => void;
  shim?: {
    globals?: {
      WebAssembly?: typeof WebAssembly;
    };
  };
}

Runtime overrides are grouped under shim. Set options.shim.globals.WebAssembly to provide a custom WebAssembly implementation for that instance. It defaults to globalThis.WebAssembly when the override is omitted or nullish at any level. The override applies to the generated component’s helpers and its default core instantiator (WebAssembly.instantiate in async mode, new WebAssembly.Instance in sync mode), which calls the implementation as a method. An explicit instantiateCore callback still takes precedence. When neither an override nor a platform WebAssembly global is available, instantiate throws unless an explicit instantiateCore is supplied.

const component = await instantiate(getCoreModule, imports, undefined, {
  shim: {
    globals: { WebAssembly: customWebAssembly },
  },
});

The supplied WebAssembly implementation must provide the APIs used by the component, including JSPI APIs when needed. To override only selected APIs, inherit from the platform implementation with Object.create(WebAssembly); its properties are not enumerable, so object spread does not copy them. Callbacks supplied by the caller, such as getCoreModule, must use the custom implementation themselves if needed.