A JavaScript runtime for WASI and native platforms, built on SpiderMonkey.
StarlingMonkey is designed to be extensible and provides safe high-level abstractions for defining additional builtins as JS classes, WebIDL interfaces, JS modules, and functions and properties on the global object.
starling-componentize builds WebAssembly components from a
JavaScript application and a WIT world.
- Running JavaScript
- Built-in APIs
- CLI Reference
- Componentizing JavaScript
- Extending with Custom Builtins
- Error Handling
- Inheritance
- Promise / Async
- Building and Testing
- Web Platform Tests (WPT)
- GC Rooting Checks
StarlingMonkey runs .js and .mjs files as ES modules by default:
starlingmonkey script.jsES module features work out of the box — import/export, strict mode, and
multi-file projects:
// greet.js
export function greet(name) {
return `hello, ${name}`;
}// main.js
import { greet } from "./greet.js";
console.log(greet("world"));starlingmonkey main.jsJSON modules are imported with an import attribute, and import() loads a module
dynamically:
import config from "./config.json" with { type: "json" };
const { greet } = await import("./greet.js");For quick one-liners, use -e:
starlingmonkey -e 'console.log("hello")'For legacy scripts that rely on sloppy mode or a global this:
starlingmonkey --legacy-script old-code.jscrates/builtins/cpp-builtins runs builtins written for
StarlingMonkey's earlier C++ implementation through a compatibility layer. It provides console.
Builtins are tested against the Web Platform Tests suite running on both native and wasm targets.
starlingmonkey [OPTIONS] [SCRIPT_PATH]
Arguments:
[SCRIPT_PATH] Path to the entry JS/MJS file (default: ./index.js)
Options:
-e, --eval <SCRIPT> Evaluate inline script instead of a file
-i, --initializer-script <PATH> Run an init script (classic, synchronous) before the content script
--async-stacks Show async parent frames in Error.stack (always on under a debugger)
--legacy-script Run as a classic script instead of an ES module
--wpt-mode Enable WPT (Web Platform Tests) mode
--web-security-restrictions[=<BOOL>] Enforce the browser-security Fetch constraints and coarsen performance.now() and Event.timeStamp (default: only with --wpt-mode)
--init-location <URL> Override the location URL for initialization
--serve <PORT> Serve HTTP on this port, dispatching fetch events
--serve-isolated Handle each served request in its own global
--dispatch-timeout <SECONDS> Give up on a request whose respondWith hasn't settled
--response-body-timeout <SECONDS> Truncate a response body not fully sent by then
--waituntil-timeout <SECONDS> Stop driving a served request's leftover waitUntil work
--end-to-end-timeout <SECONDS> Wall-clock ceiling over all of a request's phases together
--max-connection-buffer-size <BYTES> Bytes read from a connection at once (default 408KiB, minimum 8KiB)
--max-request-headers <COUNT> Cap the number of header fields (default 128)
--max-request-body-bytes <BYTES> Cap a request body (default 512MiB)
--max-body-drain-bytes <BYTES> Read at most this much of a body the handler ignored (default 256KiB)
--max-connections <COUNT> Serve at most this many connections at once (default 1024)
--request-read-timeout <SECONDS> Give up on a client that stopped sending (default 30)
--keepalive-timeout <SECONDS> Close an idle kept-alive connection (default 30)
-v, --verbose No effect
-d, --debug No effect
--strip-path-prefix <PREFIX> No effect
-h, --help Print help
starlingmonkey --help describes each option in full. The serve timeouts take whole or
fractional seconds, and 0 means no limit. Without a value, --dispatch-timeout,
--response-body-timeout and --waituntil-timeout have no limit, or 120 seconds under
--wpt-mode. A phase timeout longer than --end-to-end-timeout is rejected. The --max-…
limits, --request-read-timeout, --keepalive-timeout and --serve-isolated exist in native
builds only.
Module mode (default) — strict mode, import/export supported, this
is undefined at the top level.
Legacy script mode (--legacy-script) — sloppy mode, no
import/export, this is the global object.
Output — console.log, console.info and console.debug print to stdout,
and console.warn and console.error print to stderr with a Warn: or Error:
prefix. An exception a timer or other task throws is reported on stderr as
Uncaught with its message and stack, and a promise rejected with no handler as
Uncaught (in promise) with the reason's.
Exit status — 0 when the script and all the work it scheduled complete,
including when a promise rejected with no handler. 1 when the script throws,
its top-level await rejects or never settles, or an initializer, the event loop
or the runtime fails, with the error printed to stderr. 2 for invalid
command-line arguments.
Serving — a script served with --serve or under wasmtime serve that throws,
or whose top-level await rejects, fails startup. The native server exits with
status 1 once that happens, which for a top-level await can be after it began
listening. A wasm instance logs the error once and answers every request with a
500. With --serve-isolated, the script is evaluated for each request, and a
request whose evaluation fails is answered with a 500. The native server prints
serving on http://127.0.0.1:<PORT> on stderr once it listens. With --serve 0, it listens on
a port the OS assigns, which that line names.
starling-componentize builds a WebAssembly component from a JavaScript module and a WIT world.
The component implements the world's exports in JavaScript, and the application calls the world's
imports as ordinary JS functions. The component contains the whole runtime, SpiderMonkey
included, and runs under wasmtime or composes with other components through wasm-tools.
starling-componentize -w <wit-dir> -n <world> componentize <app.js> -o out.wasm- The npm package
@bytecodealliance/starling-componentizeinstalls a prebuilt binary for the platform from an optional dependency, one package per platform, and runs asnpx @bytecodealliance/starling-componentize. ItsbinaryPath()export returns the binary's path. There are packages for macOS (arm64 and x64), Linux (arm64 and x64) and Windows (x64). The Linux binaries are statically linked, so they run with any C library. - Each GitHub release has
starling.wasm, astarling-componentizebinary per platform and aSHA256SUMSfile of their digests. The runtime andstarling-componentizeare released together, under the same version. - In a checkout,
just install-componentizebuilds the release runtime and installsstarling-componentizewith it embedded, so the installed binary needs no other file. The componentizer describes the other recipes.
starling-componentize [OPTIONS] <COMMAND> takes one of three subcommands:
componentize <input> builds a component, types
prints TypeScript declarations for the world, and imports lists
the modules the application may import. These options apply to all three, and may come before or
after the subcommand:
| Option | Effect |
|---|---|
-w, --wit <PATH> |
A .wit file or a directory of them. Repeatable. |
-n, --world-name <NAME> |
The world to target. Repeatable. Requires -w. |
--export-cli |
Export wasi:cli/run (CLI tools). |
--export-http |
Export wasi:http/handler (HTTP servers). |
--disable <FEATURES> |
Leave WASI features out of the component (Disabling WASI features). |
--unstable-features <FEATURES> |
Include the WIT items annotated @unstable(feature = …) with these features. Repeat, or separate them with commas or whitespace. |
--all-unstable-features |
Include every WIT item annotated @unstable. |
-q, --quiet |
Print only errors. Without it, what the application prints while it initializes is shown. |
componentize also takes these:
| Option | Effect |
|---|---|
-o, --output <FILE> |
The component to write. Defaults to js.wasm. |
-p, --base-directory <DIR> |
The directory the application's relative imports resolve within. Defaults to the input file's directory. |
--runtime <PATH> |
The runtime build to link against (also for types and imports). |
--init-location <URL> |
The URL globalThis.location reflects while the application's top level runs. Without it, reading location there throws a TypeError, which fails componentization unless the application catches it. |
--deterministic-insecure-random-seed <SEED> |
Leave out wasi:random/insecure-seed (Disabling WASI features). |
--no-cache |
Keep the compiled component out of wasmtime's compilation cache, which otherwise speeds up componentizing the same world again. |
-w, --export-cli or --export-http is required. A directory passed to -w brings the packages
in its deps directory along. Repeated -ws are read in order, so a package comes after the
packages it uses. A package several of them define is merged into one, with the interfaces, types
and functions of every definition, so libraries can each ship the part of a package they use. The
definitions must agree on the functions they have in common.
-n names a world in the last package loaded, or any world by its qualified name,
namespace:package/world@version. Without -n, the last package's only world is targeted.
Repeated -ns target several worlds, merged into one, starling:componentize/merged, which
imports and exports everything they do.
The runtime is the one --runtime names, or else the one the STARLING_RUNTIME environment
variable names, or else the one embedded in starling-componentize. It must be a starling.wasm
that scripts/build-runtime.sh built (The runtime component). The
componentizer refuses one that a plain cargo build wrote, since it lacks the metadata the script
adds. The
componentizer also refuses a runtime whose version of the starling:componentize/init interface,
which starling/wit/init.wit declares, is not
compatible with its own.
The component's type is exactly the world's: it exports what the world declares and nothing else.
The runtime's own wasi:cli/run and wasi:http/handler are exported only when the world
declares them, which the worlds of --export-cli and --export-http do. The init interface the
componentizer calls is not exported.
The main module provides each function and resource class of an exported interface in exactly one
of the shapes below. For greet in the interface greeter of the package test:app@1.0.0:
// The package layer: `test-app` in lowerCamelCase, then the interface's name.
// The version is not part of the name.
export const testApp = { greeter: { greet } };
// The interface layer.
export const greeter = { greet };
// A bare export.
export function greet(name) { /* … */ }The interface layer is allowed only when no other exported interface has the same name, and a bare
export only when no other exported item or world-level function has the same name. Where the names
of two shapes coincide, the package layer's name takes precedence over an interface-layer and a
bare name, and an interface-layer name over a bare name. A world-level exported function is always
a bare export, and takes precedence over an interface-layer name. An interface the world exports
under a plain name (export local: interface { … }) has no package layer. Functions are
lowerCamelCase and resource classes UpperCamelCase. A reserved word such as default or new is
exported with an export clause (export { make as new }).
Two versions of one interface have the same package-layer and interface-layer names, so each is
provided only by the export named by its full WIT name. A TypeScript build needs
"module": "es2022" or later for such a name:
export { v1 as "local:hello/hello@1.0.0", v2 as "local:hello/hello@2.0.0" };Every export is resolved when the snapshot is taken. An item the main module provides in more than one shape, or in none, fails componentization with a message listing the shapes it may take.
For an HTTP server, pass --export-http, or declare export wasi:http/handler@0.3.0; in your own
world, and the component exports wasi:http/handler. With -w, --export-http merges the world
-n selects with a world exporting the interface, so the application can import the interfaces its
WIT declares as well. The application serves requests in one of two ways:
- Register a listener with
addEventListener('fetch', …). The runtime dispatches each request as afetchevent. - Export its own
handle, in one of the shapes ofwasi:http/handler's exports:wasiHttp.handler.handle,handler.handleor a barehandle.handletakes awasi:http/typesrequest and returns a response: aResponseobject, such as the onefetchresolved to, whose body is then passed on without being read, or awasi:http/typesone.fetchtakes thewasi:http/typesrequest as its input, soreturn fetch(request)forwards a request unchanged. The request can't be used afterfetchtook it.
export async function handle(request) {
if (request.getPathWithQuery() === "/proxy") {
return fetch(request);
}
return new Response(`hello from ${request.getPathWithQuery()}`);
}Which of the two the application uses is determined when the snapshot is taken. An application
that does both, or neither, fails componentization. A world that declares the wasi:http
interfaces itself must agree with the runtime on every item both declare, or leave
wasi:http/handler empty (interface handler {}).
The wasi:http/types resources a handle receives have the methods the runtime's builtins use,
since the component imports nothing beyond them. A world that imports wasi:http/types itself,
with -w/-n, adds the functions it declares, for example fields' get and has.
A command-line tool needs no WIT of its own. Pass --export-cli, which with -w merges the
selected world with one exporting wasi:cli/run, and have the application export a run
function:
starling-componentize --export-cli componentize <app.js> -o cli.wasm
wasmtime run -Sinherit-env=y,http=y cli.wasm// app.js
export async function run() {
console.log("hello from a componentized CLI tool");
}Invoking the component's wasi:cli/run export calls the JavaScript run export, which runs as
an async export does. Running the module as a plain script evaluates its
top level without calling run. Componentizing a module without a run function as a CLI tool
fails, naming the export to add.
--export-cli and --export-http combine, alone or with -w, into one component exporting both
wasi:cli/run and wasi:http/handler. The application then exports run, and registers a fetch
listener or exports handle. The host picks the export it calls, so wasmtime run calls run and
wasmtime serve serves requests, each in instances of its own resumed from the same snapshot:
starling-componentize --export-cli --export-http componentize <app.js> -o app.wasmThe application's top level runs to completion under Wizer, and the component starts from the
snapshot taken after it. Its relative and JSON imports are read from the base directory, and a
top-level await may wait on timers and dynamic import()s. A module that imports the input file
gets the application's main module itself. Exports are resolved once the top level has finished,
so an export declared after a top-level await is found. A top level that throws, rejects, or
awaits a promise that never settles fails componentization with the reason. A dynamic
import() of a module the top level did not load reads the file when an export calls it, through
the component's own filesystem imports and relative to the working directory the host gives it, so
it fails with --disable filesystem.
A snapshot holds memory, not a running event loop, so the top level must leave no asynchronous
work behind when it finishes: no timer or interval still pending, no fetch it did not await.
Componentization fails otherwise, listing each piece of work with the stack that created it:
Error: the application's top level finished evaluating with asynchronous work still pending, [...]
- a pending `interval` timer, created at:
startPolling@app.js:2:14
@app.js:4:1
- a host operation in flight, created at:
@app.js:6:20
The component opens its own standard streams when it first writes after resuming. Calling an
import during initialization throws a TypeError naming it, since the host's imports are not
linked while the snapshot is taken, and fails componentization unless the application catches it.
An async import returns a promise rejected with that error instead, which fails
componentization when the top level awaits it.
Componentizing these worlds fails with an error naming the function or export:
- a world with a function that uses a
map, a fixed-lengthlistorerror-context; - a world with a synchronous export that takes or returns a
streamorfuture, since no event loop drives their transfer (declare itasync); - a world with a world-level import function that takes a
borrowanywhere in its parameters, such as a method of a resource declared directly in the world rather than in an interface, since the composition step cannot encode aborrowoutside an interface (typesstill describes such a world); - a world that exports
wizer-initialize,wasi-http-handleror thestarling:componentize/initinterface, whose names the componentizer's own exports use; - a world that exports
wasi:cli/runorwasi:http/handlerin a version not compatible with the one the runtime provides; - a world that imports an interface at several compatible versions, from its own WIT or the runtime's, where a version lacks a type or function that a lower one declares.
--disable takes a comma-separated list of stdio, random, clocks, http and filesystem,
and may be repeated. The interfaces of the named WASI features are left out of the component's
imports, and stubs that trap when called take their place. With all five disabled, a world that
imports no WASI produces a component that imports nothing beyond wasi:cli/environment,
wasi:cli/exit and wasi:random/insecure-seed. The application's top level still runs with every feature during
componentization.
A disabled clock's now returns zero instead of trapping, since the runtime reads it whatever the
application does. Date.now() and performance.now() then return constants, and only waiting on
the clock traps. random leaves out wasi:random/random only. A trap in a stub names the disabled
function in the backtrace, as in
disabled-features!wasi:random/random@0.3.0#get-random-bytes (disabled).
wasi:io is left out once no remaining feature uses it. A feature that a remaining feature uses
cannot be disabled by itself: filesystem uses the clocks' types, so disabling clocks alone is
refused with a message listing every such use and the features to disable with it.
SpiderMonkey seeds its hash tables from wasi:random/insecure-seed, so the component imports it
unless --deterministic-insecure-random-seed <SEED> is given. SEED is a 128-bit number, in decimal
or with a 0x prefix in hex, and get-insecure-seed then always returns its high and low 64 bits.
With SEED, an attacker can craft input that collides in those hash tables and degrades the
application's performance, so use it only with trusted input.
The targeted world also imports every interface the runtime imports, whether it comes from -w,
--export-cli or --export-http: wasi:cli's environment, exit, standard streams and terminals,
wasi:clocks, wasi:filesystem, wasi:random, wasi:http/types and wasi:http/client, except
those of the features --disable names. Each has the functions the runtime itself imports, so the
application can import them without adding to what the component imports:
import { getEnvironment } from "wasi:cli/environment@0.3";Two versions of an interface are compatible if they have the same canonical version, as the component model defines it for interface names:
- the major number, for a version from
1.0.0on, such as1for1.2.3; 0.followed by the minor number, for a0.x.yversion whose minor number isn't 0, such as0.3for0.3.1;- the whole version without its build metadata, for a
0.0.xversion or a pre-release, such as0.0.4or0.3.0-rc.1. Such a version is compatible only with itself.
A world imports each interface once, even if its WIT and the runtime declare several compatible versions of it: at the lowest of them if they all declare the same types and functions, and at the highest otherwise. Its types then have their plain names. A version that lacks a type or function of a lower compatible version is refused.
A module specifier names an interface by its canonical version, as in wasi:cli/environment@0.3,
and starling-componentize imports and the
TypeScript declarations name it that way. A specifier with
any compatible full version, such as wasi:cli/environment@0.3.0, resolves to the same module.
These WIT types work: all scalars (u64 and s64 are always BigInts, like the elements of a
BigUint64Array), string, list (numeric lists as typed arrays, list<u64> and list<s64> as
BigUint64Array and BigInt64Array, and a numeric list also takes a plain Array), record,
tuple, variant, enum, option, result, flags, resources with constructors, methods,
static methods and borrows, and stream<T> and future<T>. Worlds that
use a map, a fixed-length list or error-context are refused.
Imports are surfaced as ES modules the application can import (see
imports). An interface the world only exports has no module, so
the application cannot import the objects or error classes of its types.
Every imported resource has a class, including one that no constructor, method or static function
names. An imported resource wrapper has a [Symbol.dispose]() method that releases the host handle
early (so using works, and a second call does nothing). Disposing of a wrapper an async import
call still borrows releases the handle once that call returns. A guest class implementing an
exported resource may define [Symbol.dispose](), which the runtime calls when the host drops the
resource. Its methods are looked up on the instance, so a subclass's override runs.
A variant's tag and a record's fields are their WIT names in lowerCamelCase (db-null is
dbNull). An enum crosses as its case index, and a flags set as an int32 of its bits, the bit
of the n-th flag being 1 << n. The module of the interface defining an enum or flags type, and of
each interface that uses it, exports a frozen object under the type's name in that interface, in
UpperCamelCase, as a TypeScript enum's object would be: it maps each case's lowerCamelCase name to
its value and each value back to the name (Qos.atLeastOnce is 1, Qos[1] is "atLeastOnce").
An error class of the same name takes the name instead.
An import argument that does not match its WIT type throws a TypeError naming the argument, the
part of it that failed and its value, such as argument 1 of `bigArgument` `.a1`: expected a string, got 5. The arguments of one call are also checked together: a resource wrapper passed as
an own must not appear anywhere else in them, a wrapper the guest received as a borrow, or one
an async import call still borrows, cannot be passed as an own, and a ReadableStream must be
unlocked and passed only once. An export result that does not match traps the call, and the
message printed on stderr names the export and the part of the result the same way.
A synchronous export runs without an event loop. The microtasks it queues run before it returns. A
timer it starts throws a TypeError naming the export. A fetch, an async import, or a read of
a response body or of a stream from the host it starts returns a promise rejected with one.
Passing a stream or a future to an import throws one too, since nothing would transfer it. A value
it writes to a stream or future that an earlier asynchronous call returned, such as by settling a
promise lowered to a future, is written in the next asynchronous call.
async exported functions return a JS Promise that the component lifts into a WASIp3 async
return. async imports are called as ordinary async JS functions and awaited. Their arguments
are checked and lowered when the call starts, so a change to an argument after the call, such as to
a typed array's elements, has no effect on what the host receives.
Each call to an async export runs its own event loop, which drives guest promises,
imported-function calls, and the builtins' work (fetch, timers, web streams), so one call can
await a fetch and run setTimeout callbacks. The call finishes once its promise has settled
and its imports, streams and futures are done. Timers still pending then are dropped without
running. An async export whose promise is still pending once its call's event loop has nothing
left to run traps, even if a later call could settle the promise.
Streams and futures are web streams and promises:
- A
stream<T>the guest receives is aReadableStream. Astream<u8>is a readable byte stream ofUint8Arraychunks, so BYOB readers work, and any otherstream<T>has one chunk per element. The stream reads from the host only when something reads it, and cancelling it drops it. - A
stream<T>the guest hands over may be aReadableStreamor any async or sync iterable, such as an async generator or an array of chunks. Astream<u8>takesArrayBufferViewandArrayBufferchunks. A source that errors, or produces a chunk or element that does not match the stream's type, ends the stream early and logs why on stderr. After the host stops reading, the source is cancelled when it produces its next chunk. It is also cancelled when the call's event loop runs out of other work while the source produces nothing more. When the host doesn't read a stream element that is an imported resource, its handle is restored to the guest's wrapper. For one nested in a record, tuple, option or list, the wrapper holds no handle afterwards. A stream the guest received and returns unread is passed to the host unchanged. - A
streamwithout a payload type has oneundefinedchunk per element in both directions. A chunk the guest hands over that is notundefinedends the stream early, as a chunk that does not match astream<T>'s type does. - A
future<T>the guest receives is aPromise. Its value is read whether or not the guest uses the promise. Afuture<T>the guest hands over may be a promise or any other value. A promise that never settles does not keep the call running, and its value is still written if it settles later. Anasyncimport the call started and did not await keeps the call running until the import returns, so if the import's host side waits on such a future, the call never finishes. Anasyncexport returning afuture<T>returns it as the promise it produces, so the call completes before the future does. - A
futurewithout a payload type is aPromisethat fulfills withundefined. A promise the guest hands over as one must fulfill withundefined. Fulfilling with any other value or rejecting traps.
Streams work with fetch in both directions: a received stream<u8> can be a request body
(fetch(url, { method: "POST", body, duplex: "half" })), and a response's body can be returned
as a stream<u8>. A stream or body that JavaScript hasn't read from is handed over directly, so
its bytes don't pass through JavaScript. This also applies to a stream<u8> passed on as another
stream<u8> type, and to one piped through an identity TransformStream.
The err arm of a result crosses as an error. An import's err, or a rejected
future<result<T, E>>, throws or rejects with an instance of E's class when E is a named type
other than a resource, and a ComponentError otherwise. E's class is named after the type,
extends ComponentError, and holds the payload as payload. It is exported from the module of the
interface whose function names the type, under the name that interface uses for it
(import { ErrorCode } from "wasi:http/types@0.3"), so a used or renamed type's class is
exported by the interface that uses it. The class of a type the world names is exported from
external. A type that an interface the world only exports uses from another interface has that
interface's class.
An export failing, or a promise the guest hands over as a future<result<T, E>> rejecting,
produces an err from:
- a
ComponentErroror an instance of a class extending it: itspayload, - any other
Error: itsmessage, ifEis astring, or anenumorvariantwith a payload-less case whose JS (camelCase) name it is, - any other value: the value itself.
Anything else traps, as does a rejection where the WIT type has no err arm. An err arm without
a payload takes any thrown or rejected value, and logs it to stderr.
imports prints the module specifiers the guest may import for the selected world, one per line:
each imported interface's WIT name with its canonical version (see
The interfaces the runtime imports), and external for the
world's own imports. A module is listed only if the runtime registers it, which it does if the
module exports a function, a resource class, an error class, or an enum or flags object. A bundler
has to leave these unresolved. A world exporting wasi:http/handler includes the wasi:http
interfaces the runtime's handler uses:
starling-componentize -w wit -n app --export-http imports--version (-V) prints the componentizer's version and the version of the init interface it
calls, and for the runtime in $STARLING_RUNTIME, or else the embedded one, the SHA-256 digest of
its module and the version of the interface it implements. A build tool can tell from it when a
rebuild would produce a different component.
To scaffold (or type-check) the guest module a world expects, generate a TypeScript declaration
(.d.ts) from the WIT:
starling-componentize -w <wit-dir> -n <world> types -o world.d.ts
starling-componentize --export-cli types # the built-in wasi:cli/run world
starling-componentize --export-http types # the built-in serve worldWithout -o, types prints the declarations to stdout. Add the generated file to the TypeScript
program (a files or include entry in tsconfig.json, or a /// <reference path=…/>). It
has no file-level declarations, which makes each declare module in it an ambient module
declaration the guest can import from:
-
starling:types/<world>, where<world>is the world's qualified name (for examplestarling:types/wasi:http/proxy@0.3.0), declares every WIT type the world uses, a class per resource, and an error class pererrtype that has one. It is named after the world so declarations generated for different worlds can be part of one program. They live in a module rather than at file level so a WIT type namedpermissionsorresponsecannot collide with the standard library. A name that several types share is qualified there with the interface's name, then also with the package's (AThing,WasiHttpTypesErrorCode). An interface the world both imports and exports has two classes per resource there, the host's and the guest's (ItemandExportedItem). -
One module per WIT interface the world imports, named by its WIT name with its canonical version, plus
externalfor the world-level imports. Each declares the interface's functions and re-exports the types, resource classes and error classes the interface defines oruses under their WIT names, soimport { ErrorCode } from 'wasi:filesystem/types@0.3'is the filesystem one. The runtime registers no module for an interface without functions, resources, error classes, enums or flags, so only the types of such a module are usable. -
starling:guestdeclares what the guest module exports. Reference it to have the compiler check the guest:import type * as Guest from 'starling:guest'; export const greeter: typeof Guest.greeter = { /* … */ };
starling:guest declares every shape the world allows for each item (see
Export names). types fails for a world whose exports cannot all be named: a
world-level function with a package layer's name.
The WIT→TypeScript types match the runtime's own value mapping. Numeric lists are typed arrays
(list<u8> is Uint8Array, list<u64> is BigUint64Array), u64/s64 are bigint, and a
record's option field is an optional key. result<T, E> on a return type is unwrapped to its
ok payload (errors are thrown), while a result parameter is the { tag, val } union. enum is
an enum numbered from zero, and flags an enum of its bits plus a number alias for a set of
them. A set crosses as an int32, matching the result of |, so the member for bit 31 is
1 << 31. Each is imported from the module that exports its object, so builds that compile files
one at a time (isolatedModules, as esbuild, swc, tsx and Bun do) can use them. A type of an
interface the world only exports is a const enum, whose members tsc inlines.
For the two command worlds, wasi:cli/run is export function run(): void | Promise<void>. For
wasi:http/handler, the guest either registers an addEventListener('fetch', …) listener, whose
FetchEvent the file declares, or implements handle in one of the shapes
HTTP servers lists. handle receives the WIT request and returns a Response,
or the WIT response resource when the world declares one, or a promise of either. The file
declares an overload of fetch taking the WIT request when the world imports
wasi:http/types@0.3.x. types takes the interface's declaration from the runtime the
command line selects. Without a runtime, the request in the built-in serve world,
which declares no wasi:http/types, is unknown.
A resource renders as a class with the constructor, methods and statics its WIT declares. An
imported resource's class declares [Symbol.dispose](): void, has a private member so that no
other object type-checks as one of its handles, and has a private constructor when the WIT
declares none. An exported resource's class declares [Symbol.dispose] as optional, and is also a
member of its interface object (Counter: typeof Counter), since the runtime looks it up there.
Where the guest receives them, stream<T> renders as the DOM library's ReadableStream<T>
(ReadableStream<Uint8Array> for stream<u8>) and future<T> as Promise<T>. Where the guest
hands them over, they render as what it may pass. A stream without a payload type renders with
undefined chunks (ReadableStream<undefined>), and a future without one with void
(Promise<void>). The error class of an err type extends the global ComponentError, which
the file always declares, and is exported from the module Errors names. That module
does not re-export the payload type under the class's name. Name it as ErrorCode['payload']
instead.
StarlingMonkey provides proc macros for exposing Rust code to JavaScript. All
builtins in the web-globals crate are implemented using these macros.
docs/builtin-development.md describes the practices builtins follow, and docs/rooting.md describes GC rooting.
Expose a Rust struct as a JS constructor with methods, getters, setters, and static methods:
use libstarling::{jsclass, jsmethods};
#[jsclass]
struct Counter {
value: i32,
}
#[jsmethods]
impl Counter {
#[constructor]
fn new(initial: i32) -> Self { Self { value: initial } }
#[method]
fn increment(&mut self) { self.data_mut().value += 1; }
#[getter]
fn value(&self) -> i32 { self.data().value }
#[static_method]
fn zero() -> Self { Self { value: 0 } }
}
// Register on the JS global and create an instance from Rust:
Counter::add_to_global(&scope, global);
let c: Result<Counter<'_>, ExnThrown> = Counter::new(&scope, 0);Inside #[jsmethods], self is the stack newtype, not the data struct, so
fields are reached through self.data() and self.data_mut() rather than
directly. From Rust, the generated constructor allocates a JS object and so
returns Result<Counter<'s>, ExnThrown>.
A borrow from self.data() or self.data_mut() must not be held across a call into JavaScript
(see Borrows and re-entrancy).
The #[jsclass] macro generates two types from the annotated struct, allowing
the type to be used from JS and Rust while ensuring proper GC rooting:
| Generated type | Purpose |
|---|---|
CounterImpl |
Inner data struct implementing ClassDef (#[doc(hidden)]). |
Counter<'s> |
Stack newtype wrapping Stack<'s, CounterImpl>, for use within a GC scope. |
A long-lived reference to an instance is a Heap<CounterImpl> in a traced struct (see
docs/rooting.md).
#[jsclass] options:
#[jsclass(name = "MyCounter")] // override the JS class name
#[jsclass(extends = Parent)] // set up a prototype chain
#[jsclass(js_proto = "Error")] // inherit from a built-in JS prototype
#[jsclass(to_string_tag = "MyClass")] // set Symbol.toStringTag
#[jsclass(hidden)] // register without defining the constructor on the global#[jsmethods] attributes:
| Attribute | Role |
|---|---|
#[constructor] |
Called when JS code runs new Counter(...). |
#[method] / #[method(name = "jsName")] |
Instance method on the prototype. |
#[getter] |
Read accessor for a JS property (obj.x). |
#[setter] |
Write accessor; fn set_x(&mut self, v: T) pairs with the x getter. |
#[static_method] |
Method on the constructor (Counter.zero()). |
#[destructor] |
Runs during GC finalization, before the Rust data is dropped. Takes an optional second parameter, *mut JSObject, which receives the object being finalized. A subclass runs its parent's destructor after its own. |
Return types:
| Rust return type | JS behaviour |
|---|---|
() |
undefined |
T: ToJSVal |
Value returned to JS. |
Result<T, E> where E: ThrowException |
Ok → value; Err → typed JS exception. |
Self (from #[static_method] / #[method]) |
New JS instance of the same class. |
PromiseFuture |
JS Promise resolved to the result of a Rust future. |
Ref<'_, T> where T: ToJSVal + ?Sized |
Value returned to JS, converted from data the class still owns. |
Returning borrowed data:
A getter that returns String copies the stored bytes into a String that
exists only to be copied again into a JS string and dropped. &str can't be
returned in its place (it would borrow from the data() guard, which is a
temporary) but the guard itself can be narrowed to the field and returned:
#[getter]
pub fn client_id(&self) -> Ref<'_, str> {
Ref::map(self.data(), |data| data.client_id.as_str())
}The trampoline converts while the guard is alive and drops it after, so the JS
string is built straight from the stored bytes. Ref::map works for any
projection, not just strings — Ref<'_, [u8]> out of a Vec<u8>, say.
Constants:
pub const items in #[jsmethods] blocks become read-only properties on
the constructor:
#[jsmethods]
impl Counter {
pub const MIN: i32 = 0;
pub const MAX: i32 = 1000;
// ...
}Variadic arguments:
Use RestArgs<T> as the last parameter to collect the remaining arguments, each
converted with FromJSVal:
#[static_method]
fn sum(a: f64, rest: RestArgs<f64>) -> f64 {
a + rest.iter().sum::<f64>()
}This works on any callable that takes arguments: #[method],
#[static_method], #[constructor], and the free functions exposed by
#[jsmodule], #[jsglobals], #[jsnamespace], and #[webidl_namespace].
The element type must implement FromJSVal and be GC-safe where applicable.
Use RestArgs<HandleValue<'_>> for untyped elements, or take the raw &CallArgs
for untyped access to the whole argument list.
Promise arguments and dictionary members:
WebIDL's Promise<T> initially
accepts values without a typecheck. The input is wrapped into a promise with
Promise.resolve(value), with the typecheck performed on the resolution value
once the promise settles. The promise exposed at the callsite resolves to the
result of the typecheck, or rejects with a type error..
// WebIDL `undefined waitUntil(Promise<undefined> f)` — every value converts to
// `undefined`, so there is nothing to check.
#[method]
fn wait_until(&self, scope: &Scope<'_>, f: Promise<'_>) -> Result<(), ExnThrown> { /* … */ }
// WebIDL `undefined take(Promise<Payload> p)` — the value it settles with is
// checked against `Payload`.
#[method]
fn take(&self, p: PromiseOf<'_, Payload<'_>>) { /* … */ }
// Dictionary members work the same way.
#[webidl_dictionary]
struct TakeInit<'a> {
p: Option<PromiseOf<'a, Payload<'a>>>,
}PromiseOf<'_, T> derefs to Promise<'_>. Note that a Promise<'_> element
of a RestArgs<…> is an ordinary FromJSVal brand check, not this conversion.
Inherited dictionaries:
#[webidl_dictionary(extends = Parent)] declares an inherited dictionary. It
holds its parent in a parent field and reaches the inherited members through
it — Deref makes that transparent at any depth:
#[webidl_dictionary(extends = EventInit)]
pub struct CustomEventInit<'a> {
pub parent: EventInit,
pub detail: Option<HandleValue<'a>>,
}
// `init.detail` and `init.bubbles` both just work.The parent converts first, then the type's own members lexicographically. That order is observable, since every member is a property get that can run an author's getter.
Parameters by reference:
Any parameter can be taken as &T or Option<&T>; the trampoline converts an
owned T and lends it for the call. While this doesn't help with calls from JS,
where the input has to be converted to an owned value regardless, it means that
calls from Rust can pass a borrow instead of allocating:
#[constructor]
pub fn new(event_type: &str, init: Option<&ExtendableEventInit>) -> Self { /* … */ }
// From Rust: no `.to_string()`.
ExtendableEventImpl::new("fetch", Some(&ExtendableEventInit::new(true)))Taking a dictionary by reference is the other reason, since a reference deref-coerces up an inheritance chain:
#[constructor]
fn new(event_type: String, init: &FetchEventInit<'_>) -> Self {
// `&FetchEventInit` → `&ExtendableEventInit`, which is what this wants.
ExtendableEventImpl::new(event_type, Some(init.deref()))
}Turn a Rust mod block into an importable ES module:
#[jsmodule]
mod math_utils {
pub const PI: f64 = std::f64::consts::PI;
pub fn add(a: f64, b: f64) -> f64 { a + b }
pub fn safe_divide(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 { Err("division by zero".into()) } else { Ok(a / b) }
}
}
// Register before evaluating any JS that imports it:
math_utils::register(&scope);Exported functions are renamed to camelCase; constants keep their declared name.
The import specifier is the mod name camelCased, so mod math_utils is
imported as "mathUtils":
import { PI, add, safeDivide } from "mathUtils";Override the import specifier: #[jsmodule(name = "my-math")]
Install functions, constants, and class constructors directly on the global object:
#[jsglobals]
mod app_globals {
pub use super::Circle; // `pub use` items register #[jsclass] classes;
pub use super::Shape; // any order works — parents are auto-registered first
pub const APP_NAME: &str = "My App";
pub fn greet(name: String) -> String {
format!("Hello, {name}!")
}
}
// Install on a global object:
app_globals::add_to_global(&scope, global);JS sees greet and APP_NAME: as everywhere else, functions are camelCased
and constants keep their declared name.
pub use'd classes must be #[jsclass]es or #[webidl_interface]s.
Create a plain singleton object (like console):
#[jsnamespace(name = "console")]
mod console_ns {
use js::gc::scope::Scope;
use js::native::CallArgs;
pub fn log(scope: &Scope<'_>, args: &CallArgs) { /* ... */ }
pub fn warn(scope: &Scope<'_>, args: &CallArgs) { /* ... */ }
}
console_ns::add_to_global(&scope, global);#[webidl_namespace] is the same but auto-sets Symbol.toStringTag per
WebIDL §3.13.
Like #[jsclass] but with WebIDL §3.7 semantics:
Symbol.toStringTagauto-set to the class name (overridable withto_string_tag)pub constitems installed on both constructor and prototype
#[webidl_interface(js_proto = "Error")]
struct DOMException {
name: String,
message: String,
}
#[webidl_methods]
impl DOMException {
pub const INDEX_SIZE_ERR: u16 = 1;
// ... constructors, methods, getters, as with `#[jsmethods]`
}Pair it with #[webidl_methods] rather than #[jsmethods]: it takes the same
member attributes, but registers methods with WebIDL's property flags (they're
enumerable, unlike JS builtins').
Same options as #[jsclass]: name, extends, js_proto, to_string_tag, hidden.
Generate unsafe impl Trace so SpiderMonkey's GC can find JS references
stored in your Rust structs:
#[derive(Traceable)]
struct AppState {
node: Heap<MyClassImpl>, // traced automatically
#[no_trace]
counter: u32, // excluded from tracing
}docs/rooting.md describes when and how to store JS references.
Methods returning Result<T, E> where E: ThrowException throw typed JS
exceptions on Err:
use js::error::{TypeError, RangeError, SyntaxError};
#[jsmethods]
impl MyClass {
#[method]
fn parse(&self, input: String) -> Result<String, SyntaxError> {
if input.is_empty() {
return Err(SyntaxError("input must not be empty".into()));
}
Ok(input)
}
}Built-in error types:
| Type | JS Exception |
|---|---|
TypeError(String) |
TypeError |
RangeError(String) |
RangeError |
SyntaxError(String) |
SyntaxError |
String |
automatically converted to TypeError |
ExnThrown |
no-op: an exception is already pending |
The first four live in js::error. web_globals::dom_exception adds
DOMExceptionError { name, message }, which throws a DOMException.
Implement ThrowException for custom error types:
use js::error::{ExnThrown, ThrowException, TypeError};
use js::gc::scope::Scope;
struct MyError(String);
impl ThrowException for MyError {
fn throw(self, scope: &Scope<'_>) -> ExnThrown {
TypeError(self.0).throw(scope)
}
}ExnThrown is a witness that a JS exception is now pending and must be percolated up
until the exception is handled.
#[jsclass]
struct Shape { color: String }
#[jsmethods]
impl Shape {
#[constructor]
fn new(color: String) -> Self { Self { color } }
}
#[jsclass(extends = Shape)]
struct Circle {
parent: ShapeImpl, // the parent's data, embedded
radius: f64,
}
#[jsmethods]
impl Circle {
#[constructor]
fn new(color: String, radius: f64) -> Self {
Self { parent: ShapeImpl::new(color), radius }
}
#[method]
fn area(&self) -> f64 {
std::f64::consts::PI * self.data().radius * self.data().radius
}
}A class embeds its parent's data, so the parent field holds the parent's
Impl type, e.g. ShapeImpl for extends = Shape.
Cast between the two from Rust with cast, which checks the JS object's type
tag in both directions:
let circle: Circle<'s> = /* ... */;
let shape: Shape<'s> = circle.cast::<Shape<'_>>().unwrap(); // widening
let back: Result<Circle<'s>, _> = shape.cast::<Circle<'_>>(); // narrowingReturn PromiseFuture from any method to create a JS Promise that resolves
to the result of a Rust future:
use js::promise::PromiseFuture;
#[jsmethods]
impl Fetcher {
#[method]
fn fetch(&self, url: String) -> PromiseFuture {
PromiseFuture::from_value(async move {
// ... async work ...
Ok::<_, String>("response body".to_string())
})
}
}The method returns the Promise to JS immediately, and the future is queued on
the event loop. ScriptEvaluation::run_to_completion, which
libstarling::run drives for you, polls it and settles the Promise with the
future's Ok/Err. PromiseFuture::new_void takes a future resolving to
Result<(), E>, and PromiseFuture::new one resolving to a PromiseOutcome, for
settling that needs the scope.
Prerequisites:
- Rust toolchain, and a beta toolchain for wasm32-wasip3 builds
- just
- WASI-SDK 34
- wasmtime, to run the wasm tests and the wasm WPT harness
- wasm-tools, to build the runtime component
- Node.js, to run the WPT harness, the componentizer's TypeScript test and
just npm-componentize
.github/tool-versions.env lists the versions CI uses.
Checking, building, and testing is done using a justfile.
Commands include:
just build # debug build
just test # all Rust tests, use `-p` for specific packages
just wpt-test # all Web Platform Tests, optionally filtered by a pattern
just fmt # format code
just clippy # run clippy
just check # fmt-check + clippy + the examples' tests + the componentizer's unit tests
just check-all # more extensive tests, including GC checksjust test invokes cargo test with the right feature set and passes
--workspace by default. It accepts all additional arguments to cargo test,
so to test specific packages, pass -p [package name].
For WebAssembly (WASIp2):
just build-wasm # debug build for wasm32-wasip2
just test-wasm # all Rust tests, on wasm32-wasip2
just check-wasm # fmt-check + clippy + wasm testsWASM_TARGET selects the wasm target of the recipes above, the wasm WPT recipes and
just test-wizer, as p2, p3 or a full triple. It defaults to p2:
WASM_TARGET=p3 just test-wasm
WASM_TARGET=p3 just wpt-test-wasmrust-toolchain.toml pins a toolchain that ships no wasm32-wasip3 std, so p3 builds through
cargo +beta, or the toolchain STARLING_BETA names, and p3 needs wasi-sdk 34, the first
with a wasm32-wasip3 sysroot.
cargo build produces the native binary target/debug/starlingmonkey. A wasm build produces the
component target/wasm32-wasip2/debug/starling.wasm from the crate's cdylib target.
just build-runtime builds the runtime component releases ship,
target/wasm32-wasip3/release/starling.wasm, with SpiderMonkey, libc and libc++ statically linked
and dead-code eliminated. just build-runtime-debug builds it with debugmozjs, at
target/wasm32-wasip3/debug/starling.wasm. RUNTIME_TARGET selects their target as WASM_TARGET
does, and defaults to p3. RUNTIME_TARGET=p2 builds for wasm32-wasip2 from the stable toolchain
and an older SDK.
The component runs scripts under wasmtime run, serves under wasmtime serve, and is the runtime
starling-componentize links against. just test-runtime builds the debug component and runs two
scripts with it under wasmtime: a hello-world that logs before and after a timer, and a top level
that rejects after a timer, which must fail the run.
Running a script calls no run export: the script's top level and the event loop it starts are
the whole program. A main module's run export is called only in an instance pre-initialized with
Wizer, such as one componentized as a CLI tool or one wizened directly
through the runtime's wizer-initialize export:
wasmtime wizer --keep-init-func=true --dir=. -Scli=y,http=y \
--env STARLINGMONKEY_CONFIG=app.js target/wasm32-wasip3/release/starling.wasm -o tool.wasm
wasmtime run -Shttp=y tool.wasmA snapshot whose main module registers no fetch listener and exports no run function is
refused, since it can neither serve nor run. As when componentizing, the top
level has to settle and leave no work behind, such as a timer or a fetch in flight. A host
resource the script keeps without pending work, such as the body of a fetch response it did not
read, is not detected. Its handle is not valid in an instance resumed from the snapshot.
componentize is a Cargo workspace of its own, so its recipes run cargo from its directory.
just componentize <args> builds starling-componentize from the checkout and runs it with
<args> in the directory you run it from. It embeds target/wasm32-wasip3/release/starling.wasm,
the runtime just build-runtime writes, or no runtime if there is none. STARLING_EMBED_RUNTIME
names another runtime to embed at build time, and the build fails if that file can't be read.
just install-componentize and just npm-componentize [VERSION] build the release runtime for
RUNTIME_TARGET and embed it. npm-componentize assembles the npm packages for the current
platform in target/npm, and their tarballs in target/npm/tarballs.
just ts-bindings <wit-path> [<world>] prints the TypeScript declarations for a world.
just test-componentize # build the debug runtime, run every suite against it
just test-componentize-only # run the suites against a prebuilt runtime, without building one
just test-componentize-only large_byte_stream # only the tests whose name matches
just test-componentize-unit # the componentizer's unit tests, which need no runtime buildThe suites componentize JS applications and run them under wasmtime. test-componentize-only
reads the runtime from STARLING_RUNTIME, by default the one just build-runtime-debug writes.
It runs several suites at a time, one per 8 GiB of memory by default, and prints a suite's output
only if it fails. COMPONENTIZE_JOBS sets the number of suites, and
scripts/test-componentize.sh describes the other settings.
The wit_to_ts suite type-checks the generated declarations with the TypeScript compiler that
npm ci at the repository root installs, and fails without it.
A change to starling/wit/init.wit needs a version of its package that is not
compatible with the latest release's, which CI checks with
scripts/check-init-wit.sh. The release workflow
publishes the runtime, the componentizer binaries and the npm packages, and
its header describes each kind of run.
The project includes a WPT harness that validates web API conformance against the official test suite.
Running the full test suite requires a bunch of additions to /etc/hosts.
These can be applied with the following command:
just wpt-setupAdditionally, a local clone of the WPT test suite needs to be available.
To use a single clone across multiple working trees, pass the location using
the WPT_ROOT env var, or the --wpt-root when running the suite.
Use the following command to create a new clone at the right revision under deps/:
just clone-wpt-testsjust wpt-test # all configured WPT tests
just wpt-test base64 # only base64 tests
just wpt-test DOMException # only DOMException tests
just wpt-update # run and update expectation filesUse just wpt-test-wasm to run under WebAssembly instead of native, and
just wpt-update-wasm to update wasm-specific expectations.
Tests run concurrently (defaulting to number of CPUs * 2 since many aren't
compute-bound) and results are reported strictly in test order, so the output
does not depend on which test finished first. Use --jobs=N to override the
default.
Test results are compared against expectation files in
tests/wpt-harness/expectations/. When adding new web APIs, add corresponding
WPT test paths to tests/wpt-harness/tests.json and run just wpt-update.
Native and wasm. The same tests run on both targets, which do not always
behave identically: the two HTTP stacks, hyper and wasi:http, differ in what
they accept and preserve.
Tests with different expectations have the additional field "wasm_status":
{
"a subtest that agrees everywhere": { "status": "PASS" },
"a subtest that does not": { "status": "PASS", "wasm_status": "FAIL" }
}A test that cannot run on a target at all is prefixed in tests.json with
SKIP-WASM(reason) or SKIP-NATIVE(reason) instead. Prefer wasm_status:
skipping a whole file gives up the subtests that would have passed.
The js crate's GC-safety guarantees rest on annotations that a custom linter,
crown (adapted from Servo's lint of the same name), checks
statically, and on SpiderMonkey's dynamic GC checks. just check-gc runs crown
and just gc-zeal the dynamic checks, and just check-all runs both.
docs/rooting.md describes them.