Skip to content

WebAssembly modules

A module packages a WebAssembly program, such as a database, an interpreter or a Rust library compiled to Wasm, so that your application can install it with one command and run it as a process of the VM. The module shares the VM’s files and sockets with real Linux programs: a Python script in the image can connect to a database that a module serves, and the module can read a file that a Linux program wrote. A command module can also declare commands, such as typst, that programs in the VM run from a shell once your application turns them on.

Run the command in your application’s directory. The source is a GitHub release or a directory inside your project:

Terminal window
npm exec -- oxidevm wasm add github:owner/repo@v1.0.0

The command resolves the release, prints what the module declares, and asks before it installs anything: the host operations it uses, its network reach, the browser features and Linux programs it needs, the endpoints it listens on, its commands, the files it writes, and its license. For commands, the card also says that programs run them as /usr/local/bin/<name> and that a command takes the place of an image program with the same name. A module is code that acts on your VM with the authority it declares: its files, processes and network. It runs isolated from your page’s origin, but it is not a sandbox for the VM, so review it the way you review a dependency. For a GitHub source the card also shows the commit the tag points to, whether the release is immutable, and whether GitHub holds an attestation for it. The command only learns that an attestation exists and does not verify it, so none of these signals replaces reading what the module declares.

File Commit it? Purpose
oxidevm.modules.json yes The modules you use and the owners you trust
oxidevm.modules.lock.json yes The exact release of each module, with the sha256 of every file
modules/<id>/module.json yes The installed release’s manifest, so a change shows up in review
.oxidevm/modules/<id>/<release>/ no The staged files your page serves. Each script, stylesheet or page (always the adapter) also has a .bin copy, which the handles use.
src/oxidevm.modules.generated.ts no Typed handles for your code. Never edit it.

After a fresh clone, run oxidevm wasm stage to rebuild the last two from the lock, and oxidevm wasm doctor to check that the declaration, lock, committed manifests, staged files and handles agree.

A file: source must be a directory inside the project, with no .. and no symbolic link on the way to it, so the lock works wherever the project is cloned. To try a release you are developing, copy it into the project (for example vendor/tool) and add file:vendor/tool. For a private GitHub repository the command reads GITHUB_TOKEN, GH_TOKEN or gh auth token, and sends the token only to api.github.com.

The first install asks. List an owner under trusted in oxidevm.modules.json to skip the question for that owner’s later first installs. A release that asks for more authority than the installed one (a new host operation, endpoint, Linux program or command) always asks again, even from a trusted owner, and shows the difference. The installed release is the committed modules/<id>/module.json, but only while it is the very file the lock pins. If it is missing or edited, everything the release asks for counts as new. A different publisher under the same id asks like a first install. --yes accepts after you have reviewed the module. With no terminal and no --yes, the command refuses and writes nothing. A copyleft release that names no license.source is refused before anything is asked.

Every file of a release is pinned by sha256 in the lock, and the release is named by the digest of its SHA256SUMS. Each VM checks each file once. The first spawnModule or enableModuleCommands that needs a file reads it from your page’s origin, and the VM checks it against its pin before any of the module’s code runs. The VM keeps the checked copy, with each WebAssembly file compiled once, and every later start and command run in that VM uses it without fetching anything. A file that does not match makes the call fail with wasm-module-integrity before any process exists. A VM holds at most 512 MiB of checked copies. host.artifacts.url(name) gives the module a blob: URL of the checked copy, so a loader that fetches its own files reads the checked bytes. The copies guard against a server or cache that changes files later; a new VM checks again.

The pins protect the files, not what the code does. The manifest’s mounts and state.durable are information for the card, and nothing enforces them. Once a command is on, every program in the VM can run it, with every host operation the module declares.

The generated handle carries the module’s manifest, so its options and endpoints are typed from the module itself:

use-modules.ts
import type { UserspaceVM } from "@oxidevm/sdk";
import { spawnModule, stopModule } from "@oxidevm/sdk/wasm";
import { database, wordfreq } from "./oxidevm.modules.generated";
export async function demo(vm: UserspaceVM): Promise<number> {
// A service listens until it is stopped, and says where.
const running = await spawnModule(vm, database, { workspaceId: "project-1", options: { dataDir: "/workspace/db" } });
console.log(running.endpoints.pg.port, running.info["version"]);
// A command runs once with arguments, like a program.
const counted = await spawnModule(vm, wordfreq, { args: ["README.md", "counts.json"], cwd: "/workspace" });
const exit = await counted.exit;
await stopModule(running);
return exit.kind === "exited" ? exit.code : -1;
}

spawnModule returns once a service has bound its endpoints and reported that it is ready, or fails with an error that names the cause. A command returns at once with a handle whose exit settles when it ends. stopModule closes the module’s standard input. A service stops accepting connections, lets working ones finish for five seconds, then cancels them and runs its cleanup, which gets ten seconds at most. A process that has not exited twenty seconds after stop is killed.

A service that is exclusive to a workspace runs at most once per workspace and module across your page’s VMs and tabs. The VM takes the lease before the process starts and holds it until the process ends, so module code can neither hold nor steal it. A second start fails fast with a busy error instead of corrupting the module’s data. A service that was killed leaves its socket file behind, and the next start replaces it when nothing listens on it. If another process listens there, the start fails as busy and the socket is left alone.

See Module host and handle for every option and field.

A command module may declare commands, the names under which programs in the VM run it. Turn them on for one Userspace VM with enableModuleCommands:

commands.ts
import { createVM } from "@oxidevm/sdk";
import { enableModuleCommands } from "@oxidevm/sdk/wasm";
import { wordfreq } from "./oxidevm.modules.generated";
const vm = await createVM({ mode: "userspace", workspaceId: "project-1" });
// Checks the module's files that this VM does not hold yet, then places /usr/local/bin/wordfreq in this VM.
const commands = await enableModuleCommands(vm, wordfreq);
for (const { name, reason } of (await commands.status()).skipped) {
console.warn(`${name} is not on: ${reason}`);
}

From then on, every program in that VM can run the module like any other program. In a terminal:

Terminal window
printf 'one two three' > words.txt
wordfreq words.txt counts.json && cat counts.json # {"words":3,"compiled":true}
wordfreq; echo $? # the usage line, then 2

The module runs as the very process that ran the command: it keeps its process ID, gets the caller’s arguments, environment, working directory, open files and terminal, and its parent waits for its exit status. Ctrl+C reaches the module: unless the module traps the signal, the process ends at once and a shell reports 130. The options you pass to enableModuleCommands apply to every run, because a program in the VM cannot pass any. Your own code runs a command the same way, for example vm.exec({ command: "wordfreq", args: ["words.txt", "counts.json"] }).

Commands need what spawnModule needs: a Userspace VM on a self-hosted engine or the hosted runtime frame, a cross-origin isolated page and the default initialUser: "user". enableModuleCommands lists its options and errors. Read Trust before you turn on commands of a module that runs code it is given.

Programs find a command at /usr/local/bin/<name> through PATH, which must list /usr/local/bin before /usr/bin to reach module commands. The SDK’s default PATH does. Keep that order in a PATH you set yourself, and in the one an image’s shell profile sets.

Because /usr/local/bin comes first, a command takes the place of an image program with the same name in a later directory, such as /usr/bin/python3, for every program that looks the name up, including scripts that start with #!/usr/bin/env <name>. A program that runs the image’s file by its absolute path still gets that file. A command never replaces a file that is already in /usr/local/bin: the SDK leaves that file alone, skips the name and turns on the others, and status() lists the name in skipped with reason: "occupied".

Commands stay on in their VM until you call disable() or dispose of the VM. The SDK keeps them in memory: they are never saved, restore points do not capture them, and no other VM shares them. A restore in the same VM keeps them on: the SDK places their stubs again, and skips a name whose path another file now holds.

Turn them on again after every createVM, including after a page reload and when a VM resumes from a restore point. Until you do, a stub that the VM’s persistent system files kept, or that a restore point brought back, runs as an ordinary program: it prints one line to standard error and exits with status 126.

disable() turns the commands off: the SDK removes their stubs and releases the commands’ checked copies. A shell then no longer finds the command, or finds the image’s program with that name again.

The concurrency option of enableModuleCommands bounds how many runs of the commands it turned on may be active at once: from 1 to 32, 4 by default. Every JavaScript process of a VM also counts toward its 32 host commands. A run beyond either limit does not wait: the stub runs as an ordinary program and exits with status 126. The placement trace names the reason.

Each VM gets one hidden module frame the first time spawnModule or enableModuleCommands needs it, and loses it when the VM is disposed. The frame is an <iframe sandbox="allow-scripts" allow="cross-origin-isolated">. Its document comes from the runtime’s own origin, and the sandbox gives it an opaque origin. Inside it, a broker worker relays each module process’s requests to the VM, and one runner worker runs each module process.

  • Module code cannot reach your page’s cookies, IndexedDB, OPFS, CacheStorage, Web Locks or BroadcastChannels, and has no network of its own. Its network is the VM’s: see Network.
  • In Chromium, workers of an opaque origin are not secure contexts, so crypto.subtle is unavailable to module code. crypto.getRandomValues remains.
  • A module whose manifest names indexeddb or web-locks in requires.browser cannot start, because the frame has neither.
  • Each synchronous request of a module pays a relay of about 0.1 ms. That figure was measured on a loaded host and is diagnostic.
  • A frame that leaves the document or stops answering is lost. The VM ends its module processes with status 125, and the next start opens a new frame.

Where the frame is served:

Runtime Module frame
Hosted runtime frame /v/<sdk version>/module-frame/ on the runtime origin, served with the runtime frame. Your page still reads the module’s staged files from its own origin and hands the VM the files it does not hold yet.
Self-hosted <runtime.baseUrl>module-frame/. You serve it with its own headers. See Serve the module frame.

A browser that cannot isolate module workers refuses modules with FEATURE_NOT_AVAILABLE, detail.reason: "wasm-module-isolation-unavailable" and a detail.capability naming the check that failed. Nothing falls back to your page’s origin. The frame runs the same probe in every browser. WebKit fails blob-workers, so Safari refuses modules. That result comes from Playwright’s WebKit on Linux; Safari itself has not been tested.

Every module declares a network reach: none, the VM only (net.vm), or the internet (net.internet). The engine holds each module process to it. The install review shows the reach, and for an internet module the hosts it discloses and its purpose. Those hosts are information, not an allowlist.

  • A module with internet reach reaches the internet only after your application connects the VM through a relay and the user allows the session. Until then its requests fail, and a command prints one line and exits with status 69. Disconnecting resets its open connections.
  • Through the relay, the internet means public hosts on TCP ports 80 and 443, plus DNS. Nothing on the internet can connect to a module.
  • A module’s traffic is the VM’s traffic. TLS ends inside the module frame, never at the relay or in your page. A personal relay lists the destinations on its control page, from personal relay 0.2.0 on.
  • Programs that a module starts are ordinary Linux programs under the VM’s own network policy.

Module networking is the full standard, with what has been verified in browsers and the limits of that evidence.

A restore point cannot capture a live JavaScript process, so vm.snapshots.capture fails with FEATURE_NOT_AVAILABLE while a module runs, including one that a program in the VM started as a command. Stop the modules, capture, and start them again. A service reopens its durable state.

The generated handles name the staged files with new URL(path, import.meta.url), so bundlers copy them as assets. Vite inlines files smaller than 4 KiB as data: URLs. Both forms work, and the sha256 check applies either way. Set build.assetsInlineLimit: 0 if you want every file served as a file. A development server rewrites files that it serves as scripts, stylesheets or pages, which would change the bytes the lock pins, so the handles name the neutral .bin copies that staging keeps beside those files. oxidevm wasm doctor checks the copies too.

Under a Content Security Policy, your page needs connect-src 'self' to read the staged files, plus data: when a bundler inlines them, and frame-src for the origin that serves the module frame. The runner imports the verified adapter from a blob: URL inside the module frame, under the frame’s own policy. See Serve the module frame.

A failure of spawnModule to start a module, or of enableModuleCommands, is a VMError with a stable detail.reason, listed under Reasons. A module that ends reports an exit status, which a shell sees as $?:

Status Meaning
0 to 119 The module’s own status.
120 The module could not be launched.
121 Reserved for a file that did not match its pin. Files are now checked before the process exists, so the call fails with wasm-module-integrity instead.
122 A browser feature or Linux program the module needs is missing.
123 The module’s adapter failed.
124 A service is busy: another process holds its workspace lease or an endpoint.
125 The engine reports a failed worker, the module frame was lost, or the frame ended a process that sent it a malformed message.
126 A command’s stub ran as an ordinary program: the command is not on in this VM, the file is a copy of the stub, the caller does not run as uid and gid 1000 outside a container, or concurrency or the 32 host commands are already in use.
128 + n Signal n ended the process, for example 130 for Ctrl+C.

Exit statuses gives the error reason of each.

To package a WebAssembly program for others, see Author a module.