Calling in-VM servers from your code
sandbox.fetch() reaches a server running inside the box directly — no service worker, no port forwarding — for tests, benchmarks, CI and server-side use.
A dev server inside a box is reachable from a browser page through a service worker, and from outside the machine through a tunnel. But often you just want to call it from the code that owns the box — a test, a benchmark, a CI script, a server-side route.
sandbox.fetch() does that directly:
const sandbox = await Sandbox.create({ files, cwd: "/home/user/app" });
sandbox.shell.execute("npx tinbase --engine pgmem", { cwd: "/home/user/app" });
await sandbox.waitForPort(54321);
const res = await sandbox.fetch("http://localhost:54321/rest/v1/todos", {
headers: { apikey: ANON_KEY },
});
const todos = await res.json();No service worker, no port forwarding, no host networking — the request goes straight to the in-VM handler and back. It works identically in Node and in the browser, which makes it the way to drive a box from a test.
Full options in the Sandbox API reference.
It returns a real Response
const res = await sandbox.fetch("http://localhost:3000/api/health");
res.status; // 200
res.headers.get("content-type");
await res.json(); // or .text() / .arrayBuffer()Because it's a standard Response, a client library that accepts a custom fetch
works unmodified against a server in the box. For example, supabase-js against
an in-VM tinbase:
const supabase = createClient("http://localhost:54321", ANON_KEY, {
global: { fetch: (url, init) => sandbox.fetch(String(url), init) },
});
const { data } = await supabase.from("todos").select("*").order("id");Failures are responses
A transport problem comes back as a status rather than a thrown error, matching what the service worker returns — so host code and browser code see the same thing for the same box:
const res = await sandbox.fetch("http://localhost:9999/");
res.status; // 404
res.headers.get("x-lifo"); // "no-server"| Situation | Status | x-lifo |
|---|---|---|
| Nothing listening on that port | 404 | no-server |
| Server didn't answer within the timeout | 504 | timeout |
| Handler threw | 500 | handler-error |
The header is what tells you the box produced it rather than the app: an app's own
404 has no x-lifo header. Bad arguments still throw — a non-loopback host, or
a bare path with no port, is a programming error, not a transport result.
Waiting for the server
A server started from the shell binds asynchronously, so the port won't exist the
moment execute() returns:
await sandbox.waitForPort(8081, { timeout: 30_000 });This resolves when something binds the port. It can't know whether the server is warmed up — a dev server that binds before it can compile still needs a retried request. When you need "serving a real page", poll a request instead:
let res = await sandbox.fetch("/", { port: 8081 });
while (res.headers.get("x-lifo") === "no-server" || !res.ok) {
await new Promise((r) => setTimeout(r, 500));
res = await sandbox.fetch("/", { port: 8081 });
}WebSockets
sandbox.connect() is the same idea for a ws server — HMR, or a realtime
subscription:
await sandbox.waitForPort(5173);
const ws = await sandbox.connect(5173, "/hot");
ws.onmessage = (e) => console.log(e.data);
ws.send("ping");
const msg = await ws.nextMessage(); // resolves with the next messageIt resolves after the server's handshake, so sending straight away is safe. Text
arrives as string, binary as Uint8Array. Full surface in the
Sandbox API reference.
One thing worth knowing: a server often sends its greeting in the same write as
the handshake, so it lands before your code has the socket back. A handler attached
right after await still receives it — message events are held until a handler
exists rather than being dropped.
Under the hood
An in-VM server registers a handler in the kernel's
portRegistry. Handlers are called synchronously but finish
asynchronously — an Express or Vite handler returns immediately and writes its
response later. sandbox.fetch goes through one shared dispatcher that knows to
wait for that completion, bounds it with a timeout, and hands back binary-safe
bytes.
That dispatcher is what every transport uses — the service worker, the
SW-free preview, curl inside the box, and tunnels — so
they all behave the same way about slow servers, timeouts and unbound ports. The
WebSocket side has the same shape: openWsPipe forges the upgrade, splits the 101
response from frame bytes that share a write, reassembles fragments and auto-pongs,
so no transport reimplements RFC 6455.
Both are exported if you're building a transport of your own:
import { dispatchRequest, waitForPort } from "@lifo-sh/core";
const res = await dispatchRequest(kernel.portRegistry, 3000, {
method: "POST",
url: "/api/todos",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title: "hi" }),
});
// res.statusCode, res.headers, res.body, res.bodyBytesTip
sandbox.fetch is a host → VM call. Code running inside the box has its own
fetch from the Node runtime, which reaches the real network
(or another in-VM port) on its own terms.