Skip to content

Module networking

Every WebAssembly module states how far it reaches on the network. The engine holds each module process to that statement. A module that may reach the internet uses ordinary fetch, WebSocket, DNS and TLS. All of it travels on the module process’s own sockets inside the VM, so it follows the rules every guest program follows. Nothing leaves the VM until your application connects it through a relay and the user allows the session. Only TCP ports 80 and 443 go out. Disconnecting cuts everything off at once. Module code never uses the browser’s own network.

The rules below are numbered N-1 to N-10, as in the engine’s architecture record ADR 0029. A module author follows five of them in practice:

  • declare the reach;
  • use the host’s network;
  • adapt an interpreter’s network layer with the SDK’s helpers;
  • handle being offline with one clear message;
  • test with the SDK’s fake network.

A module’s requires.host names at most one reach token:

Reach Declaration The module may It gets
none no token Open no connections of its own. A service still serves programs in the VM through expose. Nothing network-related. fetch and WebSocket fail with “network not declared: add net.vm or net.internet to requires.host”.
VM-only net.vm Connect to programs in this VM: loopback, Unix sockets and the VM’s own addresses. Resolve names from the VM’s /etc/hosts. fetch, Request, Response, Headers, WebSocket and host.net
internet net.internet (includes VM-only) Also reach public hosts on TCP 80 and 443 through the VM’s relay connection, with DNS through the VM’s resolver. The same
  • Naming both tokens is refused.
  • sockets.connect, the raw client sockets of host.sockets, needs a reach token. oxidevm wasm build refuses it alone, so every module.json built from now on states its reach.
  • A module.json built by an earlier CLI that names sockets.connect alone has VM-only reach. Rebuild it with net.internet to reach the internet.
  • The tokens need SDK 0.0.10 or later (Userspace API 146). module.json stays at schema 1.

A module with net.internet may add network:

network: { hosts: ["pypi.org", "*.pythonhosted.org"], purpose: "Installs Python packages that a program asks for." },
  • hosts: up to 32 DNS names it contacts by default. A leading *. is allowed.
  • purpose: one sentence of up to 200 characters.

The install review and oxidevm wasm doctor show them. Nothing enforces them, because an interpreter reaches whatever host its program asks for. network without net.internet is invalid.

Module workers run in the VM’s module frame, whose content security policy allows no network (connect-src blob:). For a module with a reach, the runner installs fetch, Request, Response, Headers, WebSocket and host.net. They run on the module process’s own VM sockets:

  • DNS: names resolve from the VM’s /etc/hosts. With internet reach, the runner also asks the nameservers in the VM’s /etc/resolv.conf over the process’s own datagram socket. The module gets the answers a guest program gets. A VM-only module resolves from /etc/hosts alone.
  • TLS: runs inside the module’s worker, end to end, through the engine’s TLS module. The VM fetches that module once, the first time a module with a reach starts, and never in a VM whose modules have none.
  • fetch: follows Node’s semantics, not a web page’s. There is no CORS, any request header is allowed, and it handles redirects, gzip and deflate, keep-alive, streamed bodies and AbortSignal. Timeouts are 10 s to connect, and 300 s for the response head and between body reads.
  • WebSocket: the standard client over RFC 6455.

The browser’s other network APIs do not work in a module worker: XMLHttpRequest, EventSource, navigator.sendBeacon, RTCPeerConnection, WebTransport, and import() or importScripts of anything but a blob: URL. oxidevm wasm build --check warns about bundled uses of them, with locations (adapter-network). oxidevm wasm test fails network-direct when a module tries one.

N-4 Adapting a program’s own network layer

Section titled “N-4 Adapting a program’s own network layer”
Program Use
Emscripten host.kit.emscripten.network(host, Module) connects the program’s SOCKFS stream sockets (blocking, through host.net.connectSync) and getaddrinfo to the VM network. The program must export SOCKFS, DNS and FS through EXPORTED_RUNTIME_METHODS. Listening sockets are refused with EOPNOTSUPP.
Without TLS of its own host.net.connect({ host, port, tls: true, alpn }), or the sans-IO session of host.kit.tls.session and host.kit.tls.handshakeSync for a program that moves the bytes itself.
WASI preview 1 It has no sockets. Its JavaScript glue uses fetch or host.net.
Situation fetch Sockets and host.net
Before the VM is connected TypeError whose cause has code: "EPERM", reason: "vm-local-only" EPERM
After a revoke ECONNRESET ECONNRESET
Beyond the declared reach EACCES, reason: "module-network-reach" EACCES
A certificate the VM does not trust Node’s codes, such as UNABLE_TO_GET_ISSUER_CERT_LOCALLY, ERR_TLS_CERT_ALTNAME_INVALID or CERT_HAS_EXPIRED The same

The cause is a NetError, exported from @oxidevm/sdk/wasm. A command prints one line from host.kit.net.describe(error, argv0) and returns 69, MODULE_EXIT.unavailable (sysexits EX_UNAVAILABLE). A service fails only the affected request and stays ready. Nothing retries in a loop.

The adapter bundle imports no SDK value except defineModule, so write the number in adapter code and use the constant in tests:

// 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) {
// For example "rates: network unavailable: this VM is not connected to the internet".
host.log(host.kit.net.describe(error, "rates"));
return 69; // MODULE_EXIT.unavailable
}
},

Every request carries host.signal, or a signal derived from it, and a timeout. Every body is streamed or bounded.

  • TLS always verifies against the VM’s own trust store, /etc/ssl/cert.pem, which is what curl in the image trusts. No host API switches verification off, and the SDK bundles no CA data.
  • Credentials reach a module only through its options or guest files. Its frame holds no page cookies or storage.
  • Internet means TCP 80 and 443 plus DNS, the relay’s policy.
  • Nothing on the internet can connect in. Services serve VM clients through expose.
  • Programs that a module starts with process.spawn are ordinary Linux programs under the VM’s own network policy, not the module’s reach. The install review says so.
  • Module code gets no raw datagram sockets. The runner uses one internally for DNS.

testHost from @oxidevm/sdk/wasm/testing runs a module definition through the SDK’s real runner against an in-memory process and network, with the same reach checks and errors:

test/net.test.ts
import { testHost } from "@oxidevm/sdk/wasm/testing";
import definition from "../module.ts";
// Local-only by default: the command prints one line and exits 69.
const offline = await testHost(definition, { args: ["rates.json"] }).run();
// Connected, with an in-memory server for the origin.
const online = await testHost(definition, {
args: ["rates.json"],
net: { connected: true, http: { "https://api.example.com": () => Response.json({ eur: 1 }) } },
}).run();

net.vm serves handlers by port at the VM’s own address. A run resolves with status, stdout, stderr, files and the signal it ended by, if any.

oxidevm wasm test checks three network properties in a real browser:

Property Holds when
network-direct The module tried no browser network API.
network-declared The module connected to nothing beyond its declared reach.
network-offline With the VM local-only, a command run with your --arg values ends with a status from 0 to 119 and, when it fails, a line on standard error within its startup bound. A service stays ready. It applies to every module, with a reach or without one, because the VM is local-only for both.

A module’s README has a “Network” section: the reach, the disclosed hosts and purpose, and what happens offline. oxidevm wasm init --network none|vm|internet generates the declaration, that section, AGENTS.md rules and tests. With a reach it adds src/net.ts, a bounded and cancellable request, and test/net.test.ts. The default is none.

  • Rust decides. Every module process carries its declared reach from its launch. The socket owner refuses connect and sendto beyond that reach with EACCES and the trace reason module-network-reach, which readJSProcessTrace reports as a refusal. Listeners the runner owns for expose are unaffected, and child processes do not inherit the reach.
  • The broker in the module frame refuses host.net operations beyond the reach before they reach Rust, as defence in depth.
  • The runner installs the network globals only for VM-only or internet reach.
  • The install review’s network row shows the reach, the hosts the module discloses and its purpose. A release that widens its reach asks for consent again.
  • A module with internet reach reaches the internet only after your application connects the VM and the user allows the session in the relay. Until then its requests fail with EPERM.
  • Its traffic is ordinary traffic of the VM. It appears in a personal relay’s destination list, and TLS ends inside the module frame, never at the relay or in your page.
  • An interpreter command lets every program in the VM that runs it use its reach.
  • A module is not a sandbox for the VM.