Skip to content

Author a module

A module lives in its own repository. It has a manifest, a literal object that declares what the module needs, an adapter, a function that receives only the host operations the manifest declared, and artifacts, the WebAssembly files and notices, pinned by sha256. The whole definition is one typed file, module.ts.

  1. Run oxidevm wasm init. It never overwrites existing files:

    Terminal window
    npm exec -- oxidevm wasm init my-tool
    npm exec -- oxidevm wasm init my-database --kind service
    npm exec -- oxidevm wasm init wrapped --from lib.wasm
    npm exec -- oxidevm wasm init fetcher --network internet

    The first creates a command module that wraps a tiny WebAssembly function. The second creates a service that listens on a Unix socket. The third starts from your own WebAssembly file. The fourth declares a network reach: --network none (the default), vm or internet. With a reach, the project also gets src/net.ts, a bounded and cancellable request, and its tests. A command module declares its id as its command, so programs in a VM can run it by that name once an application turns it on; an id that starts with oxidevm gets no command, because that prefix is reserved. To learn which ABI a binary appears to use first, run oxidevm wasm inspect lib.wasm.

  2. Install, then check and test the project as it was generated:

    Terminal window
    pnpm install
    pnpm run check
    pnpm test

    The project has strict TypeScript and ESLint settings, unit tests that run the module through testHost, continuous integration and release workflows, a README “Network” section, and an AGENTS.md for coding agents with the networking rules.

Edit module.ts and the files under src/:

module.ts
import { defineModule } from "@oxidevm/sdk/wasm";
export default defineModule({
manifest: {
id: "wordfreq",
version: "0.1.0",
abi: "bindgen",
artifacts: [
{ name: "wordfreq_bg.wasm", from: "upstream/wordfreq_bg.wasm" },
{ name: "NOTICES.txt", from: "NOTICES.txt" },
],
process: { kind: "command" },
commands: ["wordfreq"],
requires: { host: ["files.sync"] },
license: { spdx: "MIT", notices: ["NOTICES.txt"] },
},
adapter: async host => {
const compiled = await host.artifacts.compile("wordfreq_bg.wasm");
return {
main(io) {
// A program in the VM may have run it through a path or a link: name it the way the caller did.
const name = io.invocation.kind === "command" ? io.invocation.argv0 : "wordfreq";
const [input, output] = io.args;
if (input === undefined || output === undefined) return host.fail(2, `usage: ${name} <in> <out>`);
const text = new TextDecoder().decode(host.fs.readFile(input));
host.fs.writeFile(output, new TextEncoder().encode(JSON.stringify({ words: text.split(/\s+/u).length, compiled: compiled instanceof WebAssembly.Module })));
return 0;
},
};
},
});

The build reads the manifest without running your code, so it must be a literal: no variables, calls or spreads. Its type is inferred from the literal and fixes the adapter’s shape:

  • host holds exactly the members of the capabilities in requires.host, plus the base members. Using host.sockets without declaring sockets.connect, or host.net without net.vm or net.internet, is a type error, and so is network without net.internet.
  • A service’s protocols must have exactly the keys of expose.
  • host.artifacts.compile, bytes and url accept only declared artifact names.
  • host.options holds the declared options with their defaults applied.
  • io.invocation and io.terminals exist only when the manifest declares commands. See Commands that programs in the VM run.

The adapter is a factory. It runs once at start with host, keeps its state in its closure, and returns hooks. A command returns main(io), which returns an exit status from 0 to 119. A service returns one handler per endpoint, which runs for the life of one connection, and may add info() for the ready report and close() for stop. See Module manifest and Module host and handle.

Use host.kit for the work every module repeats: mapping Linux errors to Emscripten’s, length-prefixed framing, a first-in first-out session gate, running a native command with bounded output, creating a directory atomically, and adapting a program’s network layer (kit.emscripten.network, kit.tls, kit.net.describe). Do not rewrite these, and never import the kit.

Your module runs in the VM’s module frame, an opaque origin. It has no IndexedDB, no Web Locks and no browser network, and in Chromium no crypto.subtle; crypto.getRandomValues works. A manifest that names indexeddb or web-locks in requires.browser cannot start. Keep durable state in the VM’s files.

commands lists the names under which programs in a VM can run a command module:

  • Only a command module declares them; a service cannot.
  • 1 to 16 unique names, stored sorted. A name matches ^[a-z0-9][a-z0-9_.+-]{0,63}$: lower-case letters, digits, ., _, + and -, starting with a letter or digit, at most 64 characters. Names that start with oxidevm are reserved.
  • A module names no paths: every command lives at /usr/local/bin/<name>. requires.guest.executables cannot list the module’s own commands.

An application turns the commands on for one VM with enableModuleCommands, as Run a module’s commands from a shell shows. From then on any program in that VM can run the module, and the run gets the caller’s arguments, environment, working directory and standard streams. Write main for such a caller:

  • Name the command as the caller did. io.invocation is { kind: "command", name, path, argv0 } when a program ran one of the commands, and { kind: "host" } when the application started the module with spawnModule. name is the declared name and path the stub’s path; argv0 is the caller’s argv[0], which may be a relative path or a link. Use argv0 in usage lines and errors, as the example above does. io.args never contains argv[0].
  • Expect any standard input. It may be a terminal, a pipe, a file or empty. Read it only when the command’s interface says so, and read until a read returns no bytes. io.terminals says whether stdin, stdout and stderr are terminals; use colour and prompts only on a terminal.
  • Return a status. Return 0 to 119 from main, and 2 for a usage error. Never throw for a user’s mistake: a throw ends the run with status 123. Exit statuses lists what the runner and the engine use.
  • Start cheaply. Every run is a new start that runs the adapter factory again. The pinned files were checked, and the WebAssembly artifacts compiled, once when the commands were turned on, so keep the rest of the factory small.
  • Choose names with care. /usr/local/bin comes before /usr/bin on PATH, so a command takes the place of an image program with the same name for every program in the VM that looks the name up, including scripts that start with #!/usr/bin/env <name>. Do that only on purpose, for example for a Python module that should answer python3. Never start your own command through PATH.
  • Adding a name asks again. A release that adds a command makes every application that installed the module consent again, even when they trust its owner.

A module states how far it reaches, and the engine holds each of its processes to it. Module networking is the full standard, N-1 to N-10. In short:

  • Declare the reach. Add at most one of net.vm (programs in this VM) or net.internet (also public hosts on TCP 80 and 443, through the VM’s relay) to requires.host. sockets.connect needs one of them. An internet module may disclose the hosts it contacts by default and why, in network: { hosts, purpose }; that is shown at install and never enforced.
  • Use the host’s network. With a reach, fetch, WebSocket and host.net work on the module process’s own VM sockets, with the VM’s DNS and with TLS verified against the VM’s /etc/ssl/cert.pem. The browser’s own network does not exist in the module frame.
  • Adapt the program’s own network layer with the kit instead of writing a new one.
  • Handle being offline with one line and status 69.
  • Test with testHost and oxidevm wasm test.

JavaScript code uses fetch like Node’s, with a signal and a timeout:

// module.ts declares requires: { host: ["files.sync", "net.internet"] }.
async main(io) {
const [output = "rates.json"] = io.args;
try {
const signal = AbortSignal.any([host.signal, AbortSignal.timeout(30_000)]);
const response = await fetch("https://api.example.com/rates", { signal });
if (!response.ok) return host.fail(1, `rates: HTTP ${String(response.status)}`);
host.fs.writeFile(output, new Uint8Array(await response.arrayBuffer()));
return 0;
} catch (error) {
host.log(host.kit.net.describe(error, "rates"));
return 69; // MODULE_EXIT.unavailable: the adapter bundle imports no SDK values but defineModule
}
},

An Emscripten program keeps its own socket, connect and getaddrinfo. Connect them to the VM network once, before the program’s main runs. The program must export SOCKFS, DNS and FS through EXPORTED_RUNTIME_METHODS:

// module.ts declares requires: { host: ["net.internet"] } and abi: "emscripten".
adapter: async host => {
const Module = await createProgram({ noInitialRun: true }); // the program's Emscripten factory
host.kit.emscripten.network(host, Module); // blocking sockets and real DNS answers, through host.net
return { main: io => Module.callMain([...io.args]) };
},

A program without TLS of its own uses host.net.connect({ host, port, tls: true, alpn }), or the sans-IO session of host.kit.tls.session when it moves the bytes itself. A WASI preview 1 program has no sockets; its JavaScript glue uses fetch or host.net.

A signal that the module has not trapped keeps its default action. Ctrl+C in a terminal (SIGINT), SIGTERM, SIGHUP and the other signals whose default action ends a process end the module’s process at once: close() does not run, and the parent sees the process killed by that signal, so a shell reports 128 + n (130 for Ctrl+C) and stops a loop that runs the command. This holds however the module was started.

To clean up first, or to handle a signal yourself, add process.signals to requires.host and trap it with host.process.signals.trap. This command converts the files named in its arguments and stops between two files on Ctrl+C:

// module.ts declares requires: { host: ["files.sync", "process.signals"] }.
async main(io) {
// From here on, Ctrl+C writes its signal number into the interrupt buffer instead of ending the process.
const trap = await host.process.signals.trap(["SIGINT"]);
let interrupted = false;
for (const file of io.args) {
interrupted = trap.interruptBuffer[0] !== 0;
if (interrupted) break; // stop between two files, never halfway through one
convert(file); // writes its result to a temporary file, then renames it
}
await trap.untrap();
// End the way Ctrl+C would have: the shell reports 130, and a loop that runs this command stops.
if (interrupted) await host.process.signals.raise("SIGINT");
return 0;
},
  • trap(signals) takes any of SIGHUP, SIGINT, SIGQUIT, SIGTERM, SIGUSR1 and SIGUSR2, and the real-time signals by name (SIGRTMIN, SIGRTMIN+n, SIGRTMAX-n, SIGRTMAX) or by number (35 to 64). SIGKILL and SIGSTOP always keep their default action.
  • A real-time signal arrives as { signal, value, code, pid, uid }. Every sigqueue value queues in order, up to the process’s queue limit; one that arrives past the limit has the zeros Linux reports for it.
  • The trap is also an async iterable of the trapped signals as they arrive (for await (const signal of trap)), for code that waits instead of checking. A signal that arrives again before the module has seen it is delivered once.
  • interruptBuffer is an Int32Array in the format that Pyodide’s setInterruptBuffer reads: element 0 holds the number of a trapped signal that arrived, and the reader resets it to 0.
  • While a signal is trapped, its arrival makes a host operation that is waiting, such as a read from standard input, fail with EINTR.
  • untrap() gives the signals back their default action, and raise(signal) then ends the process killed by that signal. Its promise never settles in that case.

Finish quickly once a trapped signal arrives, then untrap() and raise() it. The caller then sees the process killed by that signal, as for any other program, and a shell stops a loop that runs the command.

block(signals) and unblock(signals) change the process’s signal mask, as sigprocmask does, and resolve with the previous mask. SIGKILL and SIGSTOP can never be blocked. A signal that arrives while blocked stays pending, pending() lists the pending ones, and unblocking delivers it once. A process starts with the mask it inherited, so a module started from a shell that blocks SIGINT does not see Ctrl+C until it unblocks it. raise(signal) is kill(getpid(), signal), so a blocked signal it raises stays pending too.

Terminal window
pnpm run check # type check, lint, and oxidevm wasm build --check
pnpm test # your own tests
npm exec -- oxidevm wasm build

testHost from @oxidevm/sdk/wasm/testing runs your definition through the SDK’s real runner, without a browser, against an in-memory Linux process and VM network. The host, option checks, capability narrowing, errors and network reach are the ones the browser gives the module:

test/command.test.ts
import { readFile } from "node:fs/promises";
import { testHost } from "@oxidevm/sdk/wasm/testing";
import definition from "../module.ts";
const wasmBytes = await readFile("upstream/wordfreq_bg.wasm");
const result = await testHost(definition, {
args: ["/workspace/in.txt", "/workspace/out.json"],
files: { "/workspace/in.txt": "one two three" },
artifacts: { "wordfreq_bg.wasm": wasmBytes },
}).run();
// result: { status, stdout, stderr, files, signal? }

It takes args, env, cwd, invocation, terminals, stdin, files, options, artifacts and net. The network is local-only unless net.connected is set; net.http then serves request handlers by origin, and net.vm by port inside the VM. While a run lasts, the global fetch and WebSocket are the module’s, so runs of modules that use them must not overlap.

oxidevm wasm build bundles the adapter into one self-contained adapter.mjs, pins every artifact, and writes module.json, adapter.mjs, the artifacts and SHA256SUMS to dist/. It imports nothing from the SDK at run time except what defineModule needs, and it lists third-party packages that ended up in the bundle. When something is wrong it exits with status 3, prints findings with file and line, and writes nothing. Warnings do not stop the build, but read them: a dependency that mentions window, import.meta or eval, imports of Node built-ins in dependency code that were replaced with empty modules, a bundled package that your license.spdx does not name, or a browser network API that a module cannot use (adapter-network). --check verifies without writing.

Then run the build in a real browser:

Terminal window
npm exec -- oxidevm wasm test

oxidevm wasm test starts the module in a Userspace VM with the engine the SDK pins and checks the runner’s rules, not your module’s own behaviour: that a copy with one altered adapter byte is refused, that a command exits with a status from 0 to 119, that a service becomes ready, accepts a connection on every endpoint, refuses a second start beside the first, blocks a restore point while it runs, stops within 20 seconds of the end of its input, and starts again after a stop and after a kill, that the adapter reached for no host member its requires.host did not declare, and the three network properties: network-direct (no browser network API), network-declared (nothing beyond the declared reach) and network-offline (with the VM local-only, a command ends with a status from 0 to 119 and one line on standard error, and a service stays ready). Each property is passed, failed or not applicable, with a reason, and a failed property exits with status 3. network-offline applies to every module, with a network reach or without one. When the browser cannot run the module at all, because it cannot isolate module workers (wasm-module-isolation-unavailable, with the capability that failed, such as blob-workers) or lacks a prerequisite the manifest declares (wasm-module-prerequisite), the result is refused instead, no property runs, and the command exits with status 4. Pass a command’s arguments with --arg, option values with --option name=value, and another image with --image and --image-trust when the module needs programs the default image does not have. Projects created with oxidevm wasm init run it as pnpm run test:conformance, also in CI.

State the license of what you ship in license.spdx. A copyleft module must set license.source to where its complete source is, and every module should carry the notices of what it bundles in an artifact named in license.notices. Mirror upstream binaries byte for byte, record where each came from and its sha256, and do not edit them.

Release by tag. oxidevm wasm release builds and prints the steps; it runs none of them:

  1. Tag the commit v<version>, matching the version in module.ts, and push the tag.
  2. Create a draft GitHub release containing every file in dist/.
  3. Download the draft and run sha256sum --check SHA256SUMS. The digest of SHA256SUMS itself is the release id that the build printed.
  4. Publish the draft. Never replace a published tag or asset.

The generated release.yml does exactly this when a tag is pushed: one job with a read-only token installs and builds, and a second job that installs nothing holds the write permission and drafts, verifies and publishes. Consumers then install the module with oxidevm wasm add github:owner/repo@v<version>.