Migrating from WebContainers
wcvm and WebContainers solve the same problem, Node.js in a browser tab, and the APIs have the same shape: boot, mount a file tree, spawn processes, show a preview. They are separate projects with separate runtimes. wcvm is open source (ISC) and its runtime is Node v24's own lib/ running on a native layer written for the browser.
This page maps the WebContainers calls you know to their wcvm equivalent, and lists what is different. It compares the public guides and API as documented at webcontainers.io; check their documentation for anything that has changed since.
At a glance
| WebContainers | wcvm | |
|---|---|---|
| Package | @webcontainer/api | wcvm |
| Start | await WebContainer.boot() | const wc = boot(); await wc.ready |
| Load files | mount(tree, { mountPoint }) | fs.mount(tree, basePath) |
| Run a command | spawn(cmd, args, opts) | spawn(cmd, args, opts) |
| Server up | on("server-ready", (port, url) => ...) | preview.onListen(({ port, listening }) => ...) and preview.url(port) |
| Output | one output stream of strings | separate stdout and stderr streams of bytes |
| Stop | process.kill(), teardown() | proc.kill(), no teardown() |
Booting
// WebContainers
const wc = await WebContainer.boot();
// wcvm
const wc = boot(); // synchronous
await wc.ready; // optional: resolves when the kernel is upLike WebContainers, call it once per page. Both need cross-origin isolation (COOP: same-origin, COEP: require-corp) and a secure context.
Mounting files
The tree format is the same: { file: { contents } }, { directory }, and symlinks.
// WebContainers
await wc.mount(tree, { mountPoint: "app" });
// wcvm
await wc.fs.mount(tree, "/app");mountlives onwc.fs, and the destination is a plain absolute path argument.- The destination is created if missing; you do not need to
mkdirit first. - WebContainers' binary snapshot format (
@webcontainer/snapshot) has no equivalent: send a JSON tree (base64 for binary files) or fetch files withwc.fs.fetch.
The file system
| WebContainers | wcvm |
|---|---|
fs.readFile(path) returns Uint8Array | Same. |
fs.readFile(path, "utf-8") returns a string | readFile returns bytes; decode with new TextDecoder().decode(bytes). |
fs.readdir(path) returns names | Same. |
fs.readdir(path, { withFileTypes: true }) | Names only; call fs.stat(path).kind for the type. |
fs.writeFile(path, data) | Same (a string or a Uint8Array). |
fs.mkdir(path, { recursive }) | Same. |
fs.rm(path, { recursive, force }) | fs.rm(path, { recursive }). |
fs.watch(path, ...) | Not on the host API. See Watching for changes. |
export() | No built-in export; a short helper builds the tree. |
wcvm adds stat, lstat, rename, cp, symlink, readlink, realpath, chmod, fetch, sync and reset. See Working with the file system.
Processes
// WebContainers
const install = await wc.spawn("npm", ["install"]);
install.output.pipeTo(new WritableStream({ write: (data) => term.write(data) }));
if ((await install.exit) !== 0) throw new Error("install failed");
// wcvm
const install = await wc.spawn("npm", ["install"], { cwd: "/app" });
void pump(install.stdout, (text) => term.write(text)); // pump: see "Reading output"
void pump(install.stderr, (text) => term.write(text));
if ((await install.exit).exitCode !== 0) throw new Error("install failed");Differences to expect:
exitresolves to an object{ exitCode, signal?, errorMessage? }, not a bare number.- Output is two streams of
Uint8Array, so you decode them and merge them yourself (pumphelper). Output is buffered until read. - Input is
proc.stdin, aWritableStream<Uint8Array>. - Working directory. Pass
cwdper call. There is no globalworkdiroption onboot. - Shell. WebContainers ships
jsh; wcvm shipssh(pipes, redirects,&&/||,cd; no$expansion, globbing or control flow). npmis a small built-in, not real npm: no lockfile, no lifecycle scripts, no workspaces.pnpmandyarnare not provided. See npm.- No
SIGINT. Ctrl+C in a terminal means "restart the shell".
Server-ready and the preview
// WebContainers
wc.on("server-ready", (port, url) => { iframe.src = url; });
// wcvm
await wc.preview.enable();
wc.preview.onListen(({ port, listening }) => {
if (listening) iframe.src = wc.preview.url(port); // "/__wcvm_preview__/<port>/"
});- The preview URL is a same-origin path served by a Service Worker, not a separate origin, so it needs the Service Worker header and a base path for client-side routers (Preview a dev server).
onListenalso fires when a server stops (listening: false), and for every port, not just the first.- There is no separate
portorerrorevent; useonListenand the process'sstderrandexit. wc.diagnostics.onEventexposes kernel events for debugging boot problems.
What wcvm adds
- Persistence.
boot({ persist })mirrors the filesystem to the browser's OPFS, with lazy restore for many projects and anexcludelist (["node_modules"]). See Persistence. - Open source. The runtime, the kernel and the vendored Node
lib/are all in the repository, so you can read, debug and patch it. - Studio. A complete editor, terminal and preview built on the same API: studio.wcvmjs.com.
What to check before you switch
- Your packages. wcvm swaps native binaries for WebAssembly builds (Frameworks). Anything with a native add-on that has no WebAssembly build will not load.
- Your install step. If a dependency relies on
postinstall, do that step yourself afternpm install. - Your terminal. Output is bytes in two streams and there is no pty: the terminal does line editing. Connecting a terminal has a working starting point.
- Your hosting. The isolation headers are the same, but the preview also wants
Service-Worker-Allowed: /on the worker script when it is served from a nested path. - Your browsers. See Browser support.