WebContainer
node in the terminal, over a filesystem kept in step with the workbench's own.
webcontainer contributes a shell to the terminal, and behind it is
WebContainer: node itself, running in the page, with npm and a real
filesystem. It needs terminal alongside it to have somewhere to appear.
Warning
WebContainer needs the page cross-origin isolated. Serve the document with:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: credentiallessWithout both headers, webcontainer registers no shell at all: no profile in "New Terminal", no
WebContainer: Open Server in the palette. It costs nothing to mount unconditionally — see
The two headers below.
import { Workbench, FileSystem } from "codelet/workbench";
import { terminal } from "codelet/extensions/terminal";
import { webcontainer } from "codelet/extensions/webcontainer";
const workbench = new Workbench({
parent: document.getElementById("app")!,
fs: new FileSystem({ "/index.js": "console.log('hi')" }),
extensions: [terminal(), webcontainer()],
});The SDK is fetched from a CDN by the first terminal tab that asks for it, and by no page that
never opens one. Several tabs are several shells on one machine: WebContainer boots once per
page, so the second tab shares the first one's filesystem, its node_modules and its running
processes.
It sits beside just-bash rather than instead of it — "New Terminal"
offers both where both are mounted. just-bash is an interpreter over the tree and starts
instantly; this is a machine you can npm install on.
#The two headers
WebContainer needs a SharedArrayBuffer, and a page only gets one cross-origin isolated —
Cross-Origin-Opener-Policy: same-origin plus Cross-Origin-Embedder-Policy: credentialless (or
require-corp).
credentialless is the one to start with: under require-corp every cross-origin subresource
has to send its own Cross-Origin-Resource-Policy, which most CDNs do not — including the ones
codelet fetches xterm and language servers from. Safari only understands require-corp, so a
page that has to work there needs every cross-origin asset to cooperate with that instead.
Missing headers are silent by design: the extension registers no shell, so you get the workbench
you had rather than a menu entry that could only ever fail, and the reason is one line in the
Logs panel. That is what makes it safe to mount webcontainer() unconditionally, including on
pages that will never be isolated.
#Options
| Option | Type | Default | Description |
|---|---|---|---|
workspace | boolean | true | Whether the workbench's files are mirrored onto the machine. false is an empty container. |
exclude | readonly string[] | ["node_modules", ".git"] | Directory names never mirrored, matched at any depth. |
command | string | "jsh" | What a tab spawns. |
args | readonly string[] | — | Its arguments. |
env | Record<string, string> | — | Environment variables for every tab. |
name | string | "WebContainer" | What the profile is called in the menu and on the tab. |
cdn | string | "https://esm.sh" | Where @webcontainer/api is fetched from. |
coep | "require-corp" \| "credentialless" \| "none" | the SDK's own | Which COEP the page is served with, so the SDK can reach its runtime. |
workdirName | string | the SDK's own | Names the working directory: /home/<workdirName>. |
#The filesystem
The workbench's tree is handed over when the container boots, and the two are kept in step
after that: what you edit is written into the container, and what a command writes is written
back into the tree. So npm init -y puts a package.json in the explorer, and a file you save
in the editor is the file the dev server rebuilds.
Both directions are debounced, and node_modules and .git are never mirrored back — an
install is a hundred thousand files, and the workbench's tree is memory. Add dist or anything
else a build fills to exclude.
Two things to know. The container's paths are its own: a file the explorer calls /src/app.js
is ~/project/src/app.js in the shell. And an empty directory does not reach the container
until something is written into it.
#Servers
Start a dev server and a tab opens for it on its own — no click needed. The address is not the
localhost:3000 the command printed: WebContainer serves it from an origin of its own, and that
tab shows the real one. A restart on the same port reloads that tab in place rather than opening
a second, and without stealing focus back from whatever the reader is editing.
Closed the tab? Run WebContainer: Open Server from the palette to bring it back — a quick pick
if more than one server is running, otherwise straight to the one there is.
That tab is a real cross-origin frame, not a preview panel: codelet's webviews are sandboxed without same-origin access, which would leave a dev server running in an opaque origin with its storage and its hot-reload socket refused. Its bar has a button to open the same address in a genuine browser tab too, for a login or a popup that refuses to run inside a frame at all.