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.
Create the project
Section titled “Create the project”-
Run
oxidevm wasm init. It never overwrites existing files:Terminal window npm exec -- oxidevm wasm init my-toolnpm exec -- oxidevm wasm init my-database --kind servicenpm exec -- oxidevm wasm init wrapped --from lib.wasmnpm exec -- oxidevm wasm init fetcher --network internetThe 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),vmorinternet. With a reach, the project also getssrc/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 withoxidevmgets no command, because that prefix is reserved. To learn which ABI a binary appears to use first, runoxidevm wasm inspect lib.wasm. -
Install, then check and test the project as it was generated:
Terminal window pnpm installpnpm run checkpnpm testThe 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 anAGENTS.mdfor coding agents with the networking rules.
Describe the module
Section titled “Describe the module”Edit module.ts and the files under src/:
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:
hostholds exactly the members of the capabilities inrequires.host, plus the base members. Usinghost.socketswithout declaringsockets.connect, orhost.netwithoutnet.vmornet.internet, is a type error, and so isnetworkwithoutnet.internet.- A service’s
protocolsmust have exactly the keys ofexpose. host.artifacts.compile,bytesandurlaccept only declared artifact names.host.optionsholds the declared options with their defaults applied.io.invocationandio.terminalsexist only when the manifest declarescommands. 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 that programs in the VM run
Section titled “Commands that programs in the VM run”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 withoxidevmare reserved. - A module names no paths: every command lives at
/usr/local/bin/<name>.requires.guest.executablescannot 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.invocationis{ kind: "command", name, path, argv0 }when a program ran one of the commands, and{ kind: "host" }when the application started the module withspawnModule.nameis the declared name andpaththe stub’s path;argv0is the caller’sargv[0], which may be a relative path or a link. Useargv0in usage lines and errors, as the example above does.io.argsnever containsargv[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.terminalssays whetherstdin,stdoutandstderrare 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/bincomes before/usr/binonPATH, 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 answerpython3. Never start your own command throughPATH. - 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.
Network
Section titled “Network”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) ornet.internet(also public hosts on TCP 80 and 443, through the VM’s relay) torequires.host.sockets.connectneeds one of them. An internet module may disclose the hosts it contacts by default and why, innetwork: { hosts, purpose }; that is shown at install and never enforced. - Use the host’s network. With a reach,
fetch,WebSocketandhost.network 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
testHostandoxidevm 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.
Signals
Section titled “Signals”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 }. Everysigqueuevalue 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. interruptBufferis anInt32Arrayin the format that Pyodide’ssetInterruptBufferreads: 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, andraise(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.
Build and test
Section titled “Build and test”pnpm run check # type check, lint, and oxidevm wasm build --checkpnpm test # your own testsnpm exec -- oxidevm wasm buildUnit tests with testHost
Section titled “Unit tests with testHost”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:
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:
npm exec -- oxidevm wasm testoxidevm 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.
Licenses and notices
Section titled “Licenses and notices”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
Section titled “Release”Release by tag. oxidevm wasm release builds and prints the steps; it runs none of them:
- Tag the commit
v<version>, matching the version inmodule.ts, and push the tag. - Create a draft GitHub release containing every file in
dist/. - Download the draft and run
sha256sum --check SHA256SUMS. The digest ofSHA256SUMSitself is the release id that the build printed. - 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>.