TypeScript and JavaScript to WebAssembly components, written for WASI 0.3.
dwarf takes a TypeScript or JavaScript file and a
WIT world and
produces a standalone
component, using
QuickJS — QuickJS-sized, not a ~13 MB
SpiderMonkey embedding, with full typed bindings for arbitrary worlds rather
than just the standard WASI ones.
The part worth leading with is WASI 0.3 (Preview 3). An async func export
is an async function; a stream<u8> is an object you read() and
writeAll(); a future<T> is one you await. Concurrency is the component
model's own, not a callback shape bolted on top, and 0.2 remains supported for
worlds that still need it.
Output runs on any component-model runtime — Wasmtime
directly, or Trail, the workload runtime of
Periapsis, which serves
wasi:http/handler@0.3.0 components and is what the HTTP examples here are
verified against. See Running the output.
dwarf is a fork of
componentize-qjs
(Apache-2.0), rebranded and maintained here for the
Periapsis project family. See
NOTICES for full upstream attribution and git log for
commit-level history predating the fork. Not published to crates.io or npm —
build from source.
0.3 is the version where the component model's concurrency stops needing a translation layer, and that shows up directly in the code you write.
world greeter {
export greet: async func(name: string) -> string;
}export async function greet(name: string): Promise<string> {
return `Hello, ${name}!`; // an `async func` is just an async function
}The same holds for the types around it. A stream<u8> is an object with
read() and writeAll(); a future<T> is one you await; a result<T, E>
returns T or throws E; an option<T> is T or null. Full detail in
WIT Type Mappings,
Async Exports, Streams and
Futures.
Concretely, that means a component can serve — hold a connection open,
stream a body, await a timer, handle requests concurrently — rather than only
answer one synchronous call at a time. wasi:http/handler@0.3.0,
WebSockets and Timers all fall out of the same
mechanism.
WASI 0.2 worlds still build and still work; --sync targets hosts without
component-model async at all. Where an 0.3 name might shift meaning later,
the version-pinned ...P3 aliases stay bound to
the 0.3 implementation.
dwarf takes a source file (TypeScript or JavaScript) and a
WIT definition,
and produces a standalone WebAssembly component that can run on any
component-model runtime (see Running the output).
Under the hood it:
- Embeds the QuickJS engine (via rquickjs) as the JavaScript runtime.
- Uses wit-dylib to generate WIT bindings that bridge the component model and the JS engine.
- Snapshots the initialized JS state with Wizer so startup cost is paid at build time, not at runtime.
Rust 1.94 or later is required (the wasm32-wasip2 target needs a recent
toolchain for PIC support in wasi-libc).
Two optional external tools enable extra features, checked for on PATH at
runtime — neither is required to build or run dwarf itself:
# wkg - enables automatic fetching of missing WIT dependencies (e.g. wasi:*
# imports) into deps/ instead of vendoring them by hand
cargo install wkg
# jco - enables `dwarf --emit-types <dir>`, generating TypeScript declarations
# for a WIT world. Note: the unscoped `jco` package on npm is an unrelated
# placeholder - the real package is scoped.
npm install -g @bytecodealliance/jcoNot published to crates.io or npm yet. Build from source:
git clone https://github.com/apsis-io/dwarf.git
cd dwarfcargo install --path . --lockedThis installs the dwarf command.
cd npm && npm install && npm run build1. Define a WIT interface (hello.wit):
package test:hello;
world hello {
export greet: func(name: string) -> string;
}2. Implement it (hello.ts):
Sources are ES modules, TypeScript or JavaScript. Export WIT functions and interfaces directly from the module.
export function greet(name: string): string {
return `Hello, ${name}!`;
}3. Build the component:
dwarf --wit hello.wit --file hello.ts -o hello.wasmA .ts file needs no separate compile step: dwarf strips the types itself
(see TypeScript). Plain .js works exactly as before.
4. Run it:
wasmtime run --wasm component-model-async=y --invoke 'greet("World")' hello.wasm
# "Hello, World!"The built-in runtime published with dwarf includes component-model
async support. Pass --sync to embed the built-in non-async runtime instead,
producing components that run on hosts without component-model async support. A
custom runtime can also be supplied with --runtime.
--file app.ts is all it takes. dwarf strips the types itself, in-process
(via oxc) — no tsc, no esbuild, nothing extra on
PATH, and no build step between your source and the component.
dwarf --wit wit --file app.ts -o app.wasmIt applies to imports too, so a project is TypeScript all the way down rather than only at its entry point:
// app.ts
import { render } from "./greeting.js"; // resolves greeting.ts
export function greet(name: string): string {
return render({ name });
}That ./greeting.js spelling is deliberate: TypeScript's own convention
under NodeNext is that a specifier names the emitted module. dwarf
resolves .js to the .ts beside it, so idiomatic source works unchanged,
and extensionless (./greeting) works too. A .js entry may import .ts
modules and vice versa, which is what a half-migrated tree looks like.
Everything TypeScript emits code for — enum, parameter properties,
decorators — is compiled, not merely erased. Type-only syntax disappears.
Types are stripped, never checked. This is the same contract as Node's
own type stripping and esbuild: code tsc would reject still builds and
still runs. Keep tsc --noEmit in your editor and CI for that job — a
component builder that silently became your type checker would slow every
build down to do worse what your toolchain already does. A syntax error,
by contrast, fails the build and says which file.
For the types of the WIT world itself — what a record looks like, which
imports exist — generate declarations rather than writing them by hand:
dwarf --wit wit --file app.ts --emit-types types/ -o app.wasmSee TypeScript Types for what --emit-types produces
and the two places jco's conventions are patched to match dwarf's.
The output is an ordinary WebAssembly component: any component-model host can run it. Two that get used here.
Wasmtime, for one-shot calls and wasi:cli/run components. WASI 0.3 needs
the async feature turned on:
wasmtime run --wasm component-model-async=y --invoke 'greet("World")' hello.wasmTrail, the workload runtime of
Periapsis, for components that serve
HTTP. It drives wasi:http/handler@0.3.0 over a hyper HTTP/1.1 server:
trail --p3 --serve --component app.wasm --listen 127.0.0.1:8080By default each request gets a fresh Store and instance — the snapshot
Wizer took is the starting point every time, so one request cannot leave
state behind for the next. --persistent holds a single instance for the
process lifetime instead, keeping module-level state across requests at the
cost of that isolation. Which one you want is the same question as
Reactors: instantiate once, call many,
and _initialize is where per-instance setup belongs under either.
examples/hono is a full worked example — the Hono router on
wasi:http/handler@0.3.0, verified end to end under trail --p3 --serve.
Periapsis also runs a complete Nuxt 4 SSR app as a dwarf component
(examples/wasm/js-dwarf-nuxt there), which is where this repo's
request/response bridge comes from.
Trail's source is not public yet — apsis-io/showcase is Periapsis's public overview. Nothing in dwarf depends on either: the components it builds are plain WASI 0.3 components, and Wasmtime runs them.
See also: docs/cli-cheatsheet.md for a dense, single-page quick reference (flags, recipes, polyfills, type mappings) without the surrounding prose.
dwarf [OPTIONS] --wit <WIT> --file <JS>
| Flag | Short | Description |
|---|---|---|
--wit <PATH> |
-w |
Path to the WIT file or directory |
--file <PATH> |
-j |
Path to the JavaScript source file |
--output <PATH> |
-o |
Output path (default: output.wasm) |
--module-root <PATH> |
Root directory exposed read-only during Wizer for resolving JavaScript imports | |
--world <NAME> |
-n |
World name when the WIT defines multiple worlds |
--stub-wasi |
Replace all WASI imports with trap stubs | |
--no-vendor |
Disable automatically fetching missing WIT dependencies via wkg wit fetch |
|
--emit-types <DIR> |
Also generate TypeScript type declarations for the WIT world via jco types |
|
--polyfill <NAME> |
Include a static polyfill (repeatable), e.g. --polyfill buffer — see Polyfills |
|
--minify |
-m |
Minify JS source before embedding |
--disable-gc |
Disable automatic garbage collection in the QuickJS runtime | |
--opt-size |
Use the built-in QuickJS runtime optimized for size | |
--sync |
Use the built-in non-async runtime (combine with --opt-size for the non-async opt-size runtime) |
|
--runtime <PATH> |
Custom QuickJS runtime Wasm module to embed | |
--optimize <MODULE> |
Compile a TypeScript module statically with scriptc and plug it in (repeatable) | |
--scriptc <PROFILE> |
Same, from a profile that declares the boundary explicitly instead of deriving it (repeatable) | |
--scriptc-bin <PATH> |
The scriptc executable for --scriptc (default: scriptc on PATH) |
| Feature | Effect |
|---|---|
component-model-async |
(default) Embed the component-model async runtime as the default built-in. The non-async runtime is always embedded and selectable via --sync. Disable to build a smaller binary with only the non-async runtime |
opt-size |
Selects the bundled opt-size runtime when no runtime option is provided by the CLI or npm API |
Build with features:
cargo build --release --features opt-size--emit-types <DIR> generates .d.ts declarations for the WIT world via
jco types (must be on PATH,
see Prerequisites). jco's output assumes its own
(componentize-js-oriented) JS binding conventions, which diverge from dwarf's
actual runtime in two confirmed ways — u64/s64 typed bigint instead of
number, and option<T> typed T | undefined/an omittable field?: T
instead of dwarf's actual T | null (dwarf always includes the
property/value, using null for "none," never undefined and never omitting
it). dwarf patches jco's generated files to match its own conventions before
writing them out. This is a best-effort textual patch, not a from-scratch
type generator — deeply nested option shapes (e.g. option<option<T>>,
which dwarf represents with a tagged { tag, val } form rather than plain
null) aren't specifically handled and may still read as jco's own
convention.
The same inputs produce a byte-identical component. Wizer freezes the initialized guest heap into the artifact, so anything the guest observes while being snapshotted is baked in — which makes reproducibility a property of the build environment, not just of the code. dwarf pins that environment:
| pinned | why |
|---|---|
| the wall clock | QuickJS seeds ctx->random_state from js__gettimeofday_us(), so Math.random's state was the build time |
wasi:random |
something in the guest caches 16 bytes of it at init (std's RandomState shape); the runtime's own maps were already fixed-seed |
| the implicit module root | it was the process's cwd, and the root decides each module's guest-visible path — so the same entry built from two directories differed |
Only the snapshot sees any of this. The component you ship reads the host's real clock and real randomness at run time; nothing about its behaviour changes.
SOURCE_DATE_EPOCH is the one deliberate knob, following the
reproducible-builds
convention: set it and the snapshot's clock reads that instant, leave it and
the clock reads the Unix epoch. The same value always gives the same bytes.
dwarf --wit hello.wit --file hello.ts -o a.wasm
dwarf --wit hello.wit --file hello.ts -o b.wasm
cmp a.wasm b.wasm && echo identicaltests/deterministic.rs guards this at the level a consumer cares about —
the bytes — including that the working directory cannot change the output.
Every ordinary dwarf --wit x.wit --file x.js build already produces what the
component-model community calls a reactor — a component that exports its
own world and is instantiated once, then called repeatedly. The only other
shape is a command: a component exporting wasi:cli/run, which a host
runs to completion once (the wasi:cli/command world, where the implicit
run export needs export function run() { ... } in your JS).
The reactor/command label is a preview-1 core-module distinction —
_initialize versus _start — and dwarf still links the p1 reactor adapter
because its QuickJS module is built against p1. At the component level the
label carries no meaning and WASI 0.3 has no wasi:cli/reactor world: a
component simply exports what its world declares. What survives into p3 is
the lifecycle, and that is worth knowing precisely:
| the JS module's top level | runs at build time, under Wizer, and is snapshotted |
| a fresh instance | starts from that snapshot, never from another instance's state |
| state between calls | persists for the life of one instance |
_initialize |
runs once per instance, before the first exported call |
| teardown | there is none — see below |
let hits = 0; // snapshotted at build time: every instance starts at 0
export function _initialize() {
// Per-instance setup that CANNOT be snapshotted: a handle, a clock read,
// configuration pulled through an imported interface.
}
export function handle(req) {
hits += 1; // persists across calls on this instance
return `${hits}`;
}_initialize cannot collide with a WIT export: WIT identifiers are
lowercase kebab-case and reach JS as camelCase, so no export is ever named
_initialize. If your module does not export it, nothing runs. If it
throws, the call traps rather than serving traffic from a half-built
instance.
There is no teardown hook, because the component model gives a guest no
destructor callback — an instance is simply dropped by the host. If you need
explicit cleanup, declare it in your world as an ordinary export
(export shutdown: func();) and have the host call it; that needs nothing
from dwarf.
A Periapsis Comet is exactly this shape: a reactor exporting its own
interface rather than wasi:cli/run, so it is written and built the way
this section describes.
WIT imports are available as ES module imports using their fully-qualified WIT interface name:
// imports.wit
package local:test;
interface math {
add: func(a: s32, b: s32) -> s32;
multiply: func(a: s32, b: s32) -> s32;
}
world imports {
import math;
export double-add: func(a: s32, b: s32) -> s32;
}// imports.js
import math from "local:test/math";
export function doubleAdd(a, b) {
const sum = math.add(a, b);
return math.multiply(sum, 2);
}JavaScript modules imported by the entry file are resolved during Wizer
initialization. Relative imports are resolved from the entry file path passed to
--file; bare package imports are resolved under the read-only module root. By
default the CLI uses the entry file's own directory. Use --module-root <PATH> to expose
a project root that contains shared files or node_modules.
Static import only — dynamic import() doesn't work at runtime.
Resolving a module needs real filesystem access, which only exists during
Wizer's build-time pre-init (where module resolution runs); the actual
runtime a component executes in later has no such capability. Every
import/import() your code can reach must be resolvable at build time —
which in practice means only ever calling import() from top-level module
code (so it runs during Wizer's own pre-init, not deferred until a real
request/export call). A bundled third-party library that does its own
dynamic import() lazily — e.g. only the first time a particular code path
actually runs, rather than at module load — throws a normal, catchable
Error the moment that path is reached at real runtime, naming the module
it tried to load. If a bundled dependency does this, configure your
bundler to inline dynamic imports rather than emit runtime import()
calls — e.g. Rollup/Vite's
build.rollupOptions.output.inlineDynamicImports: true.
Some WASI capabilities (wasi:http/client in particular) need constructing
resources — headers, request bodies as stream<u8>, trailers as future<T>
— whose wit.Stream/wit.Future type indices are auto-assigned per
component based on everything else in that component's own WIT world. That
makes them a poor fit for prebuilt, reusable glue meant to be composed
into many different consumers without recompiling (a real, standalone
component's own fixed indices can't reliably match an arbitrary caller's).
The pattern for that case: build the capability as its own small, focused
component (a fixed world, so its indices are fixed and known) that exports a
plain-types-only interface — strings, lists, records, no resources/streams/
futures crossing the boundary — and compose it into your own component with
wac plug. See
examples/fetch-provider for a complete,
verified-against-real-HTTP-servers example of this pattern.
This constraint does not apply to dwarf's own codegen, though, which
generates fresh JS for each consuming component's own specific WIT world at
build time and so always has the right indices for that world — which is
why the global fetch() polyfill (--polyfill fetch-classes, see
Polyfills) can wire directly to wasi:http/client without
needing a separate composed component at all, for the common case of one
component wanting its own fetch().
Code that runs hot does not have to run in QuickJS.
scriptc compiles TypeScript ahead
of time, and --optimize builds a module with it and plugs the result into
the component being generated — QuickJS keeps everything dynamic, while leaf
modules doing real work over numbers, strings, and bytes become native Wasm.
Point it at a module and the boundary is derived from that module's exported signatures:
dwarf --wit app.wit --file app.ts --optimize hot.ts -o app.wasmJavaScript imports it under scriptc:<module name>/ops, and your world
declares nothing — dwarf adds the interface itself from the WIT scriptc
generates:
import ops from "scriptc:hot/ops";
export function digest(text) {
return ops.checksum(new TextEncoder().encode(text));
}The seam does not survive into the output: the interface is satisfied by composition, so the finished component imports only WASI.
What crosses the boundary is limited to what the canonical ABI carries
cheaply — number (as f64), boolean, string, and Uint8Array. An
export taking a callback, a class instance, or a closure cannot cross, nor
can an async or generic one; each is named on stderr as it is left out,
so the interface is never a silent subset.
Crossing is necessary, not sufficient. Moving a call across costs roughly 1.5µs per KB of string or list payload, so it pays only where the call does substantially more work than that. Two functions of the same shape, measured against a release build:
| payload | result | |
|---|---|---|
sha1(bytes) -> bytes |
1 KiB in, 20 B out | 4.2× faster |
layout(string, string) -> string |
~20 B in, 2.4 KB out | 4.7× slower |
sha1 does ~1500µs of work per call and pays ~1µs to cross. layout
builds a template string in ~0.7µs and pays ~2.4µs. Use scriptc boundary
to find candidates, then measure — the win concentrates in leaf modules
whose work per call dwarfs their payload.
Measure with a release build: build.rs compiles the embedded QuickJS
runtime with profile-dependent flags (-Clto=fat -Copt-level=3 for
release, none for debug), and the component-model marshalling runs inside
that runtime — so a debug host inflates both the wins and the losses.
--optimize writes the profile it derived beside the module, so you can
read it, check it in, and edit it once the defaults stop fitting — then
pass it with --scriptc instead. A profile also reaches what inference
will not guess: the sized integer classes (u8/u32/i32/i64/u64),
since number alone does not say which was meant.
A profile names the module and the functions to expose:
{
"profile_format": 1,
"name": "hot",
"entry": "hot.ts",
"emission": "c",
"abi": {
"prefix": "hot_",
"init_symbol": "hot_init",
"sink_register_symbol": "hot_set_panic_sink",
"collect_symbol": "hot_collect",
"result_reset_symbol": null
},
"exports": [
{ "export": "checksum", "symbol": "hot_checksum", "params": ["bytes"], "returns": "f64" }
]
}dwarf --wit app.wit --file app.ts --scriptc hot/profile.json -o app.wasmEither flag needs a scriptc install and the toolchain behind it (zig and
wasm-tools); the WASI adapter is dwarf's own, so there is nothing else to
configure.
When --wit points at a directory, referenced packages (e.g. wasi:cli)
are normally vendored under a deps/ subdirectory next to your WIT files. If
a package is missing, dwarf automatically runs wkg wit fetch to fetch it
(requires wkg on PATH) and retries — no manual deps/
setup needed for a quick start. Disable this with --no-vendor; auto-vendoring
only applies to directory --wit paths, since a single standalone WIT file has
no deps/ directory to populate.
Every WASI-backed global below (console, process, crypto.getRandomValues,
setTimeout/setInterval/clearTimeout/clearInterval, fetch,
WebSocketServer) is also available under an explicit ...P3 name
(consoleP3, processP3, crypto.getRandomValuesP3, setTimeoutP3, etc.) —
the exact same object/function as the plain name, just under a second,
stable name. This is purely additive: nothing that depends on the plain
name (your own code, vendored library code) is affected.
All of these are backed by WASI 0.3 today, so right now the alias changes
nothing. It exists for later: if a future WASI version ever changes what the
plain name points to, xP3 stays pinned to "the 0.3 implementation,
specifically" — reach for it when you want that guarantee. Otherwise, just
use the plain name; if you want the short name back after depending on the
pinned one, TypeScript's own import aliasing covers that at your own call
site (import { consoleP3 as console } from "...").
console.log/info/debug and console.warn/error are available when the
world imports wasi:cli/stdout/stderr@0.3.x (matched by write-via-stream).
WASI 0.3 has no synchronous write primitive, so these calls return a
Promise<void> instead of undefined.
world hello {
import wasi:cli/stdout@0.3.0;
import wasi:cli/stderr@0.3.0;
export greet: async func(name: string) -> string;
}export async function greet(name) {
await console.log("greeting", name);
return `Hello, ${name}!`;
}console always exists — if the import isn't declared, calling the relevant
method throws a clear error naming it, rather than leaving console
undefined or silently doing nothing.
Unawaited calls can silently lose output if nothing else in the same
async export subsequently awaits or yields — confirmed empirically: two
unawaited console.log calls followed by an await on a third all flushed
correctly, but an unawaited console.error as the last statement before an
export returns produced no output at all. Await console.log/error/etc.
whenever you can't otherwise guarantee the surrounding async export keeps
running long enough to flush the write.
Non-string arguments are formatted with JSON.stringify — a compact
single-line dump ({"foo":"bar"}, [1,2,3]), not Node's multi-line
util.inspect-style output. Two quirks worth knowing: console.log(undefined)
prints a blank line (JSON.stringify(undefined) is the JS value undefined,
not a string, and Array.join coerces it to ''), and a circular-reference
object throws synchronously at the call site rather than being silently
swallowed.
console.print/println (stdout) and console.eprint/eprintln (stderr)
always exist and always return a Promise<void> that rejects rather than
throwing synchronously — print/eprint write with no trailing newline,
println/eprintln append one. They use wasi:cli/stdout/stderr@0.3.x
(matched by write-via-stream) if imported — genuinely async, via a real
stream<u8> handoff — and otherwise the returned promise rejects, naming
the missing import.
export async function greet(name) {
await console.print("no newline, ");
await console.println("then a line.");
return `Hello, ${name}!`;
}Only safe to call from an async export. WASI 0.3 uses component-model
stream/future machinery that has no task state outside an active async
export call — calling it (even indirectly via console.print, or
console.log/error) from a plain sync export crashes outright ("no
active task state"), not a graceful error.
process.env/process.argv/process.cwd() are wired to
wasi:cli/environment, and process.exit(code) to wasi:cli/exit's
exit-with-code, when the world imports them (this interface's shape is
unchanged between WASI 0.2 and 0.3, so unlike console there's no version
branching). Like console, process always exists — accessing an
unbacked property throws a clear error naming what to add.
world hello {
import wasi:cli/environment@0.3.0;
import wasi:cli/exit@0.3.0;
export greet: func(name: string) -> string;
}export function greet(name) {
const key = process.env.API_KEY;
if (!key) process.exit(1);
return `Hello, ${name}!`;
}Divergences from Node worth knowing: argv is exactly get-arguments()
with no synthetic node/script-path entries prepended (WASI has no such
convention); cwd() returns null (not a fabricated path) when
initial-cwd() is option::none; exit(code) maps onto
exit-with-code(status-code: u8), so code is coerced into a single byte
the same way Node itself truncates exit codes outside 0-255. env/argv
are always re-fetched on access rather than cached, matching the same
Wizer-snapshot-timing concern as console's stdout handle.
setTimeout/setInterval are wired to
wasi:clocks/monotonic-clock@0.3.x's wait-for (a genuine async func,
directly awaitable) when the world imports it — throws a clear error
otherwise. WASI 0.2's monotonic-clock has no non-blocking wait primitive
at all (only subscribe-duration + wasi:io/poll's pollable, which
blocks the whole component — the opposite of what setTimeout is for),
so only 0.3 is supported. clearTimeout/clearInterval are always defined
and never throw, even in a world where setTimeout itself would.
world poller {
import wasi:clocks/monotonic-clock@0.3.0;
export run: async func() -> string;
}function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function run() {
await sleep(100);
return "done";
}Important caveat, unavoidable under component-model-async: once the
async export that (transitively) called setTimeout/setInterval settles,
any still-pending timer callback that wasn't part of the explicitly-awaited
chain is cancelled along with the rest of that task — there is no way for
dwarf to keep a timer alive past its originating export call the way a real
JS host's event loop would. Reliable only when awaited (as in sleep
above) or when called from a still-running, long-lived export.
A global WebSocketServer class is wired to wasi:sockets/types@0.3.0's
tcp-socket resource when the world imports it — throws a clear error
otherwise. It also needs --polyfill webcrypto for computing the
Sec-WebSocket-Accept handshake header (crypto.subtle.digest("SHA-1", ...)); calling listen() without it throws a clear error naming the flag.
world ws-server {
import wasi:sockets/types@0.3.0;
export run: async func(port: u16);
}export async function run(port) {
const server = new WebSocketServer();
server.on("connection", (conn) => {
conn.on("message", (data) => {
// `data` is a string for text frames, a Uint8Array for binary.
conn.send(typeof data === "string" ? "echo:" + data : data);
});
conn.on("close", (code, reason) => {});
});
await server.listen(port, "127.0.0.1"); // runs forever, accepting connections
}server.listen(port, host) binds, starts listening, and accept-loops
forever — it's meant to be awaited from a long-lived entrypoint (e.g. a
wasi:cli/run run() kept alive as a background task via the host's own
persistent/also-run mechanism alongside normal request handling, the same
class of setup trail's --persistent flag provides). Each accepted
connection is handled concurrently with accepting further connections
(ordinary single-threaded JS promise concurrency, not real parallelism).
After binding, server.port holds the actual bound port (useful when
port is 0, requesting an OS-assigned ephemeral port). server.close()
stops the accept loop.
A connection object passed to the 'connection' handler has .send(data)
(string or Uint8Array), .ping(data), .close(code, reason), .path
and .headers (from the upgrade request), and .on(event, cb) for
'message', 'ping', 'pong', 'close', and 'error'.
The frame parser (validation rules, fragmentation/control-frame handling)
is adapted from websockets/ws's
receiver.js (see NOTICES), cross-tested against a real, independent
client. Scope cuts: IPv4 only, no permessage-deflate (never negotiated, so
compliant clients simply don't use it), no Blob/ArrayBuffer
binaryType switch (binary messages are always Uint8Array — there's no
Blob in dwarf).
WebSocketServer also doubles as a general router when a host can only
reach a component on one port (e.g. a TLS-passthrough-by-SNI edge with a
single backend, unable to split a normal HTTP response path and WebSocket
upgrades across two ports). Register an on("request", ...) handler —
additionally requires --polyfill fetch-classes, for real Request/
Response — and any non-upgrade HTTP request is routed there instead of
being dropped; WS upgrade requests still go through the handshake/frame
path unchanged, on the very same listening socket:
export async function run(port) {
const server = new WebSocketServer();
server.on("request", async (request) => {
// Wire your own SSR/HTTP handler here - e.g. Nitro/h3's
// `toWebHandler(app)` produces exactly this (Request) => Response
// shape, so `server.on("request", nitroHandler)` works directly.
const url = new URL(request.url);
if (url.pathname === "/api/health") return new Response("ok");
return new Response("not found", { status: 404 });
});
server.on("connection", (conn) => {
conn.on("message", (data) => conn.send(`echo:${data}`));
});
await server.listen(port, "127.0.0.1");
}Without a "request" handler registered, behavior is unchanged from
before this existed: a non-upgrade request is dropped. With one, each
accepted connection runs an ordinary HTTP/1.1 keep-alive loop — multiple
requests over one connection are supported (real SSR pages need several
asset fetches), ending either when a request upgrades to a WebSocket (which
then owns the connection for the rest of its life, handed to
"connection"'s handler), Connection: close, or the peer disconnecting.
Request bodies are read via Content-Length only (no chunked
transfer-encoding, no Expect: 100-continue).
This parses untrusted network input inside the guest, so it's hardened
against hostile/malformed requests, failing closed and bounded rather than
hanging or over-allocating: request header blocks are capped at 16 KiB
(431 if exceeded) with at most 100 headers, request lines and
Content-Length are strictly validated (400 on anything malformed —
negative, non-numeric, or otherwise not a plain non-negative integer),
Transfer-Encoding is rejected outright (501) rather than silently
mishandled (treating a chunked body as length-zero would let its bytes be
misread as the next pipelined request), an oversized Content-Length is
rejected (413) before a single byte of the body is read or buffered, and
a connection that goes idle mid-request (opens, then sends nothing, or
trickles bytes forever — the slowloris shape) is given up on after
idleTimeoutMs (default 30s; needs wasi:clocks/monotonic-clock@0.3.x
imported to be enforced at all — see WebSocketServer's constructor
options). Any parse failure closes the connection afterward rather than
keeping it alive for more pipelining, since the byte stream's framing can
no longer be trusted.
TextEncoder/TextDecoder (UTF-8 only) are always available — dwarf's
QuickJS runtime doesn't provide them natively, but they're foundational
enough (no WIT/host dependency, needed internally by other polyfills like
url) to include unconditionally rather than gate behind a flag.
AbortController/AbortSignal are likewise always available and have real
semantics (abort() flips aborted, sets reason, fires
onabort/'abort' listeners) — nothing in dwarf's own fetch() observes
signal yet, but a caller polling signal.aborted in its own loop works
today. crypto.getRandomValues is likewise always available, wired to
wasi:random/random#get-random-bytes when the world imports it (throws a
clear error otherwise, matching console/process above) — independent of
--polyfill webcrypto, which only adds crypto.subtle.
Beyond that, --polyfill <name> (repeatable) includes vendored third-party
libraries with no WIT/host dependency at all — opt-in, since (unlike
console/process) there's nothing in a WIT world to auto-detect "this is
wanted" from:
| Name | Provides | Notes |
|---|---|---|
buffer |
Buffer |
feross/buffer, Node's Buffer reimplemented on Uint8Array |
url |
URL, URLSearchParams |
whatwg-url, spec-compliant including internationalized domain names — ~750KB before Wizer snapshotting (mostly Unicode/IDNA tables), only paid by components that request it |
fetch-classes |
Headers, Request, Response, DOMException |
whatwg-fetch, trimmed to just the classes. Requesting this polyfill also enables a real global fetch() (always-on, not gated by this flag itself — see below), wired directly to wasi:http/client@0.3.x when the world imports it |
path |
path (.join, .dirname, .basename, etc. — matches Node's path module shape) |
unjs/pathe, fast and dependency-free |
readable-stream |
ReadableStream, wit.readableStreamFromStream(readable) |
Hand-written (not vendored), verified against real ReadableStream — minimal pull-based-controller subset (no BYOB readers, tee(), or pipeTo/pipeThrough), matching what libraries like h3 actually use for streaming response bodies. The bridge helper wraps a wit.Stream() readable end for handing a WASI-backed body to code expecting the standard interface — single-read only, same reason as fetch-provider's documented limitation (see below) |
webcrypto |
crypto.subtle (digest, HMAC, ECDSA/ECDH on P-256/P-384, HKDF, AES-GCM) |
@noble/hashes + @noble/curves + @noble/ciphers, wrapped in a hand-written crypto.subtle covering a deliberate subset of the real Web Crypto API — no RSA, AES-CBC/CTR, PBKDF2, spki/pkcs8 DER (only "raw"/"jwk"), or Ed25519/X25519. generateKey needs crypto.getRandomValues (above), which needs wasi:random/random imported |
ufo |
ufo.* — functional URL utilities (joinURL, withQuery, parseURL, normalizeURL, etc.) |
unjs/ufo. Complements url's class-based URL/URLSearchParams with the more ergonomic functional helpers many h3/nitro-style codebases use |
scule |
scule.* — string case conversion (camelCase, kebabCase, snakeCase, pascalCase, trainCase, titleCase, etc.) |
unjs/scule |
klona |
klona(value) — deep clone |
lukeed/klona. dwarf's QuickJS-ng has no structuredClone at all (confirmed, not just "klona is faster") |
ohash |
ohash.* — hash()/serialize()/isEqual() |
unjs/ohash, bundled with its pure-JS (non-Node) SHA-256-based digest. Not for security use — see webcrypto for cryptographic hashing |
knitwork |
knitwork.* — JS/TS code-string generation (genImport, genObjectFromRaw, genInterface, etc., no parsing) |
unjs/knitwork |
unstorage |
unstorage.* — universal key-value storage (createStorage(), .getItem/.setItem/.getKeys/etc.) |
unjs/unstorage. Only the core plus its zero-config default (in-memory) driver are bundled — every other driver (fs, redis, cloudflare-kv, etc.) is Node/host-specific and not available. In-process memory only, not persisted across restarts |
An unknown --polyfill name is a build error listing the available names.
See NOTICES for full attribution of vendored polyfill code.
Vendored (bundled-from-npm) static polyfills are bundled and registered with
scripts/bundle-polyfill, a dev-only tool that
generates the install line's global-exposure object from the bundle's own
actual export list (via knitwork) rather
than a hand-transcribed one.
Every polyfill also has a .d.ts (in crates/core/polyfills/), automatically
included in --emit-types' output as globals.d.ts alongside the WIT-derived
types — covering the always-on globals (console, process,
TextEncoder/TextDecoder) plus whichever --polyfill names were requested,
so --polyfill and --emit-types give full type coverage together.
Development note: setting DWARF_POLYFILLS_DIR=/path/to/crates/core/polyfills
(pointed at a local dwarf checkout) makes dwarf read polyfill .js/.d.ts
content fresh from disk instead of what's compiled into the binary — editing
a polyfill's source takes effect immediately, no cargo build needed. Unset
(the default), dwarf stays a single self-contained binary with no runtime
directory dependency.
| WIT Type | JS Type | Notes |
|---|---|---|
bool |
boolean |
|
u8, u16, u32 |
number |
|
s8, s16, s32 |
number |
|
u64, s64 |
number |
Precision limited to 2⁵³ (Number.MAX_SAFE_INTEGER) |
f32, f64 |
number |
|
char |
string |
Must be exactly one Unicode scalar value |
string |
string |
| WIT Type | JS Type | Example |
|---|---|---|
list<T> |
Array |
[1, 2, 3] |
list<u8> |
Uint8Array or Array |
new Uint8Array([1, 2, 3]) |
tuple<T, U, ...> |
Array |
[42, "hello"] |
option<T> |
T | null (nested: { tag: "some"|"none", val }) |
null for none; option<option<T>> is wrapped |
result<T, E> |
top-level function result: return T or throw E; nested result: { tag: "ok"|"err", val?: T|E } |
return 42 / throw "error" |
record { ... } |
object (camelCase keys) |
{ myField: 1 } |
variant |
{ tag: string, val?: T } |
{ tag: "circle", val: 2.5 } |
enum |
string (case name) |
"red" |
flags |
object (camelCase booleans) |
{ read: true, write: false } |
own<R>, borrow<R> |
resource object (methods on its prototype) | input.blockingRead(n) |
result<T, E>'s err case is surfaced differently depending on which side
of the boundary you're on — this is intentional, not an inconsistency to
work around:
- Calling an import that comes back
err: dwarf always wraps the raw err payload in a realError— real.message/.stack,instanceof Error, catchable and loggable like any normal JS exception — with the raw tagged payload attached aserror.payload. For avariant/enum/recorderr type, that means readingerror.payload.tag/.val, noterror.tag/.valdirectly. - An export signaling its own
errresult can throw a plain object matching the WIT shape directly —throw { tag: "not-found" }is used as-is, no.payloadneeded — or a realErrorwith a.payloadproperty shaped like the WIT error (unwrapped on the way out). Astringerr type also accepts a bare thrown string, or anError's.message.
The reasoning: an import's error is something JS receives, so wrapping it
in a real Error gives normal exception ergonomics for a value JS didn't
construct itself. An export's error is something JS authors, so accepting
the same { tag, val } shape used everywhere else for variants/results
avoids forcing an Error wrapper around a value whose shape the author
already knows.
Imported resources are exposed as JavaScript classes. Resource methods are called on the handle:
import { TcpSocket } from "wasi:sockets/types@0.3.0";
// [static] methods are exposed directly on the class.
const sock = TcpSocket.create("ipv4");
// Methods whose WIT return type is result<...> return the ok payload or throw.
sock.bind({ tag: "ipv4", val: { port: 0, address: [0, 0, 0, 0] } });
// async func methods return a real Promise.
await sock.connect({ tag: "ipv4", val: { port: 80, address: [93, 184, 216, 34] } });[static] methods are exposed on the resource class and [constructor] makes
the class callable with new.
Async exports are declared with the async keyword in WIT and implemented
as JavaScript async functions:
package example:greeting;
world greeter {
export greet: async func(name: string) -> string;
}export async function greet(name) {
// You can use await here
await Promise.resolve();
return `Hello, ${name}!`;
}Streams transfer a sequence of values between components.
The wit global provides Stream and Future constructors for creating
stream/future pairs. The type is automatically determined from the WIT definition:
package example:streaming;
world streaming {
export produce: async func() -> stream<u8>;
}async function produce() {
// When only one stream type exists in the WIT, no argument needed
const { readable, writable } = wit.Stream();
writable.write(new Uint8Array([1, 2, 3]));
writable.drop();
return readable;
}When the WIT defines multiple stream types, use the type constant:
// wit.Stream.U8, wit.Stream.STRING, wit.Stream.U32, etc.
const { readable, writable } = wit.Stream(wit.Stream.U8);
const { readable, writable } = wit.Stream(wit.Stream.STRING);Available type constants (populated from WIT metadata):
| WIT type | Constant |
|---|---|
stream<u8> / future<u8> |
wit.Stream.U8 / wit.Future.U8 |
stream<u32> / future<u32> |
wit.Stream.U32 / wit.Future.U32 |
stream<string> / future<string> |
wit.Stream.STRING / wit.Future.STRING |
stream<f64> / future<f64> |
wit.Stream.F64 / wit.Future.F64 |
All constructors return { readable, writable }.
Complex element types are also supported. The type constant is generated recursively from the WIT type structure:
// stream<result<string, u32>>
wit.Stream(wit.Stream.RESULT_STRING_U32);
// stream<option<u32>>
wit.Stream(wit.Stream.OPTION_U32);
// stream<tuple<u32, string>>
wit.Stream(wit.Stream.TUPLE_U32_STRING);
// Named record types use their WIT name:
// record point { x: f64, y: f64 }
// stream<point>
wit.Stream(wit.Stream.POINT);Use wit.Stream.types or wit.Future.types to discover all available type
constants at runtime.
StreamReadable methods:
| Method | Returns | Description |
|---|---|---|
read(count?) |
Promise<T[]> (or Uint8Array for u8) |
Read up to count values |
cancelRead() |
result or undefined |
Cancel an in-progress read |
drop() |
void |
Release the stream handle |
Calling read() again after the writable end has dropped and all data is
already consumed is not a catchable JS error — confirmed empirically, it's
a hard host-level trap ("cannot read after being notified that the writable
end dropped"). A drain-until-empty loop is only safe if you have some other
way to know more data is coming before calling read() again; otherwise,
read once with a generously-sized count and treat that as the whole
payload (the pattern examples/fetch-provider and the readable-stream
polyfill's WASI bridge both use).
StreamWritable methods:
| Method | Returns | Description |
|---|---|---|
write(data) |
Promise<number> |
Write values, returns count written |
writeAll(data) |
Promise<number> |
Write all values, retrying as needed |
cancelWrite() |
result or undefined |
Cancel an in-progress write |
drop() |
void |
Release the stream handle |
Futures transfer a single value. They work like streams but carry exactly one value:
package example:async-value;
world async-value {
export compute: async func() -> future<string>;
}async function compute() {
const { readable, writable } = wit.Future();
// Write the value (fire-and-forget; completes when reader reads)
writable.write("computed result");
return readable;
}Future type constants follow the same pattern: wit.Future.U32,
wit.Future.STRING, etc.
FutureReadable methods:
| Method | Returns | Description |
|---|---|---|
read() |
Promise<T> |
Read the single value |
cancelRead() |
result or undefined |
Cancel an in-progress read |
drop() |
void |
Release the future handle |
FutureWritable methods:
| Method | Returns | Description |
|---|---|---|
write(value) |
Promise<boolean> |
Write the value, returns success |
cancelWrite() |
result or undefined |
Cancel an in-progress write |
drop() |
void |
Release the future handle |
Stream and future handles support
Explicit Resource Management
via Symbol.dispose. In environments that support using:
{
using stream = wit.Stream();
// stream.writable and stream.readable are auto-dropped when leaving scope
}Otherwise, call .drop() explicitly to release handles.
The npm package exposes both a CLI and a programmatic API.
npx dwarf --wit hello.wit --file hello.ts -o hello.wasm(Or, if you installed the package globally, just dwarf ....)
import { componentize } from "dwarf";
const { component } = await componentize({
witPath: "hello.wit",
jsSource: "export function greet(name) { return `Hello, ${name}!`; }",
optSize: true,
});
// component is a Buffer containing the WebAssembly component bytesRuntime selection is available through optSize, sync, runtime, or
runtimeBytes. optSize and sync may be combined to select the non-async
opt-size runtime, but neither can be combined with a custom runtime/runtimeBytes.
The runtime option is a path to a custom QuickJS runtime Wasm module.
dwarf is a fork of componentize-qjs
by Andrei (andreiltd) — nearly all functional code originates there; see
NOTICES. componentize-qjs itself builds on ideas and code from:
- ComponentizeJS by Joel Dice
- lua-component-demo by Alex Crichton
Licensed under Apache-2.0.