bash
A shell for the terminal, running in the page over the workbench's own files — with node behind it where the page allows one.
bash contributes a shell to the terminal:
just-bash, a real but partial bash interpreter,
running in the page rather than on a server. It needs terminal alongside it to have somewhere
to appear.
Behind node, npm, npx, pnpm and yarn is a WebContainer —
node itself, in the page, with a real node_modules and dev servers — wherever the page is
cross-origin isolated. Where it isn't, node is QuickJS over the workbench's own files and there
is no package manager. One extension either way, and the same shell either way: what changes is
how much of node you get.
import { Workbench, FileSystem } from "codelet/workbench";
import { terminal } from "codelet/extensions/terminal";
import { bash } from "codelet/extensions/bash";
const workbench = new Workbench({
parent: document.getElementById("app")!,
fs: new FileSystem({ "/README.md": "# Hello" }),
extensions: [terminal(), bash()],
});Nothing is bundled: just-bash is fetched from a CDN by the first tab, and the engine behind node
by the first script — a workbench where nobody opens a terminal downloads none of it.
#The two headers
WebContainer needs a SharedArrayBuffer, and a page only gets one cross-origin isolated. Serve
the document with:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corpWith both, node is node and npm install installs. Without them you still get a terminal, a
shell and a node — QuickJS, over the tree — and the reason the rest is missing is one line in
the Logs panel. So bash() is safe to mount unconditionally, including on pages that will never
be isolated.
require-corp is the value to serve. credentialless is the gentler one on paper — a
cross-origin subresource is fetched without credentials rather than turned away — but only
Chromium and Firefox know the token, and a browser that does not reads it as unsafe-none: a
credentialless page is every Safari, and every browser on iOS, with no machine in it. What
require-corp asks of a subresource is a Cross-Origin-Resource-Policy of its own or a
CORS-mode request that passes, and everything codelet fetches — the SDK, xterm, the language
services — is a module import() or a fetch(), both CORS mode. Your own assets are what to
look at: an <img>, a stylesheet or a <script> from another origin is a no-cors load, and
wants Cross-Origin-Resource-Policy: cross-origin from wherever it is served, or a crossorigin
attribute on the tag.
Warning
Safari asks for the header anyway, inside a worker. WebKit reads that "or" as an "and" for
anything a worker imports or fetches, whatever the request mode. The language servers and QuickJS
node load their modules from inside a worker, and esm.sh sends no
Cross-Origin-Resource-Policy — so an isolated page on Safari, and on every browser on iOS, keeps
its machine and quietly loses all of them. Chromium and Firefox take the CORS request and are
unaffected, which is what makes it easy to miss.
The way out is to stop the load being cross-origin at all: serve those modules from your own
origin, where the check never runs. Point cdn at it — on typescript(), css(), html(),
json(), markdown(), and on bash's quickjs — with an absolute URL, since these are read
inside workers built from a blob and a bare /libs has no base to resolve against:
const CDN = `${typeof location === "undefined" ? "" : location.origin}/libs`;codelet.sh serves the nine packages involved at /libs. The tree is built by apps/cdn in this
repository, written at the paths esm.sh answers, and copied into the app's public/, so nothing
but the origin changes. Serving it cross-origin works too, with
Cross-Origin-Resource-Policy: cross-origin on every response — but that is a header to get right
where same-origin is a question that is never asked.
Then pass the same value on — bash({ container: { coep: "require-corp" } }). A dev server's page
is a document nested in this one, and an isolated page frames only a child isolated the same way;
the SDK serves its own origins to match whatever it is told here. It is fixed at the first boot,
whatever a later one asks for.
Note
The first npm on a page asks the reader to agree to
StackBlitz's terms, since WebContainer is theirs and
commercial production use needs a licence from them. The answer is remembered, and a host that has
already agreed on their reader's behalf passes container: { terms: false }.
#Browsers
Chromium is where WebContainer is at its best, and Safari 16.4 and up runs it — desktop, iOS and
iPadOS alike, StackBlitz calling that beta — since SharedArrayBuffer is the thing it was waiting
on. Two caveats there are the platform's rather than this extension's: a page on a phone has a
memory ceiling that a large npm install is what meets first, and a reload on iOS does not
reliably free the machine the last one left. Firefox isolates and boots; a preview served out of
the container is the part to check there.
#Options
Every `bash()` option
| Option | Type | Default | Description |
|---|---|---|---|
name | string | "bash" | What the profile is called in the menu and on the tab. |
workspace | boolean | true | Whether the shell is a shell over the workbench's files. false gives it only its own /tmp. |
mount | string | "/home/workspace" | Where the workbench's files are mounted, and where the shell opens when it has a workspace. |
files | Record<string, string> | — | What the in-memory filesystem around the mount starts with, by absolute path. |
env | Record<string, string> | HOSTNAME the page's host, HOME the mount | Starting environment variables, over just-bash's own. |
cwd | string | mount, or just-bash's own / without a workspace | Where the shell opens. |
prompt | string \| ((cwd: string) => string) | \u@\h:\w\|⇒ | What the prompt says: a PS1, or a function of the working directory. See The prompt. |
network | ShellNetwork \| false | every URL and every method | What curl, wget and ping may reach. See Network. |
history | string \| false | "codelet.just-bash.history" | The localStorage key command history is kept under. false turns off persistence. |
container | BashContainerOptions \| false | on wherever the page is isolated | The machine node and the package managers run on. See below. |
quickjs | boolean \| NodeOptions | true | The node a page with no machine gets, and how it handles .ts/.tsx/.jsx. See node. |
cdn | string | "https://esm.sh" | Where just-bash, quickjs-wasi and the WebContainer SDK are fetched from. |
Every `container` option
| Option | Type | Default | Description |
|---|---|---|---|
terms | boolean | true | Whether the reader is asked to agree to StackBlitz's terms before the first boot. |
exclude | readonly string[] | [".git"] | Directory names never mirrored, matched at any depth. |
commands | readonly string[] | jsh, node, npm, npx, pnpm, yarn, xxd, ps | Which command names are spawned on the machine rather than run in the page. |
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's own origins are framed by it. |
workdirName | string | the SDK's own | Names the working directory: /home/<workdirName>. |
container: false is a page that could have booted one and would rather not: the shell is the
same, and node is QuickJS.
#Try it
Type ls in the terminal: it lists the workbench's own files, mounted at /home/workspace. Run
cat greet.ts, then edit it from the shell with sed -i 's/hi/hey/' greet.ts — the greet.ts
tab above redraws with the change. Run node greet.ts to see it execute. xterm, just-bash and
whatever runs the script each load on first use, so give it a moment.
#Files
By default the shell is a shell over the workbench's own files, mounted at /home/workspace (or
wherever mount says). ls is the tree the explorer draws, and sed -i is a file the editor
redraws — writes go through workspace.fs, the same as a save from the editor.
The mount is also $HOME, so ~ is the tree, a bare cd comes back to it, and the prompt writes
that one character rather than the whole path. The name is /home/-rooted because a WebContainer
can only ever put its working directory there, and matching the two is what keeps a path node
prints — a stack trace, an npm error — a path the shell can cat.
The mount sits inside a small in-memory filesystem rather than being the shell's whole /. A
/tmp the shell writes for itself (a heredoc, a pipeline's scratch file) stays there, outside
the mount, and never touches the workbench.
workspace: false gives the shell only that in-memory filesystem, with no mount at all: a demo
shell with nothing of the reader's in it.
The machine's filesystem is the same tree again. The workbench's files are 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 only exclude (.git) is never carried at all.
node_modules is the one directory with a rule of its own: an install comes back as the
package.json and .d.ts files it wrote and nothing else, so imports in the editor resolve
against the packages really installed without a hundred thousand files landing in the workbench's
memory. The JavaScript beside them stays on the container's disk, where the only thing that runs
it is, and it never goes the other way.
Two things to know. A command only runs on the machine from inside the mount — the shell's own
/tmp is not a place over there. And an empty directory does not reach the container until
something is written into it.
#Commands
Beyond just-bash's own set, this registers:
node,npm,npx,pnpm,yarn— processes on the machine; see Running scripts with node for what a page without one gets.xxd,ps— the machine's own, and only where there is one: just-bash carries neither, and a page has no processes to list.odandstringsare its nearest to the first.jsh— the machine's own shell, for looking at the container as itself:jshon its own is a prompt inside the tab (env,which npm,ls ~), andjsh -c '<line>'runs one line and comes back.exitor^Dends it;^Cbelongs to it rather than to the tab. Only there when there is a machine.open,code— open a file in a workbench tab. Given a directory, it reveals it in the explorer instead, since a directory has no tab. Given a URL, it opens a browser tab, which needs no host at all.vi,vim,nano— the same asopen, after creating the file first if it isn't there yet, since that's what opening an editor on a new path means.realpath— resolve a path.yes— repeat a word ory. Bounded rather than infinite (-n, default 1000, capped at 100,000): a command here answers with its whole output at once, so an unbounded one would hang the tab.dd— copy bytes between a file, stdin and stdout, withbs,count,skipandseek.link,unlink— a hard link and its removal. The workbench's files support neither linking nor symlinks, solinkfails with the filesystem's own error.uname— reportsjust-bashas the kernel name, the host name, the pinned just-bash version, andcodeletas the OS.-misnavigator.platform, the one field a browser actually answers for the machine.hostname— the page's own host,-sfor the part before the first dot and-dfor the rest. A page opened at an address rather than a name —192.168.1.5,[::1]— has no domain to take off, so-sis the whole of it and-dis empty. The shell starts withHOSTNAMEset tolocation.hostnamerather than just-bash'slocalhost, and this,uname -nand\hin the prompt all read that variable — soexport HOSTNAME=box, or anenvgiven toopenBash, moves all three at once. Setting one as an argument is refused, as a realhostname NAMEis for anyone but root.id— the one user there is, uid and gid1000.uptime— how long the page has been open. No load averages: nothing in a page measures one.watch— reruns a command on an interval. Bounded passes (-c, default 3, capped at 20) rather than running until stopped, and no screen clearing between them.sudo— runs the rest of the line as the one user there is. No privileges to raise, so it exists only so a copied command line still runs.wget,ping— only registered whennetworkis configured; see Network.netstat,ss,lsof -i,nc -z,fuser— what is listening, in each command's own columns. They read the workbench's port registry — the same list the Ports tab draws — sonetstat -tlnpfinds a dev server whether this shell started it, a remote host published it, or a live-share guest did. Nothing in a page holds a port, so the pid, fd and queue columns are-or zero rather than a made-up number, and a listing ends in a line per port saying where it's really reachable when that isn'tlocalhost:<port>.fuser -k 3000/tcpends the server where the port could be traced back to a running command, and namespkill -fwhere it couldn't. Anything a page genuinely can't do — a routing table, a unix socket,ncopening a connection — says so and names what works here.
#Network
curl (just-bash's own), plus wget and ping, reach whatever the page's CORS already lets it
reach. That's the whole answer to what a browser can fetch, so network only ever narrows it,
never widens it. It says nothing about the machine: a process in the container has the network
the container has, which is how npm install reaches the registry.
network: false removes all three commands: Tab completion doesn't offer them and they aren't in
the shell at all.
An object is an allow-list:
| Option | Type | Default | Description |
|---|---|---|---|
allowedUrlPrefixes | (string \| { url: string; transform?: RequestTransform[] })[] | — | Origins requests may reach, optionally with a path prefix, e.g. "https://api.example.com/v1/". Nothing else is reachable. |
allowedMethods | ("GET" \| "HEAD" \| "POST" \| "PUT" \| "DELETE" \| "PATCH" \| "OPTIONS")[] | GET, HEAD | Methods a request may use. |
dangerouslyAllowFullInternetAccess | boolean | — | Every URL and every method. |
maxRedirects | number | — | How many redirects a request follows. |
timeoutMs | number | — | How long a request waits before it's cut off. |
maxResponseSize | number | — | The largest response body allowed. |
denyPrivateRanges | boolean | false | just-bash's own SSRF guard. It works by resolving a host with node's dns, which doesn't exist in a browser, so it's off here regardless of just-bash's own default. |
Warning
dangerouslyAllowFullInternetAccess removes the allow-list entirely: every origin and every
method, limited only by what the page's own CORS already allows. This is what bash() defaults to
when you don't pass network at all.
ping has no ICMP to work with in a browser. It's a timed HTTP GET through the same allow-list as
curl, and its output says so rather than claiming to send packets.
#Running scripts with node
With a machine, node is node: node --watch server.js, npm install, pnpm dlx, a test
runner, whatever is on npm. The machine boots on the first one of these commands and is shared by
every tab after that, as one real box would be — the second tab is a second shell over the same
node_modules and the same running processes.
Output goes where a terminal's output goes: straight into the tab as it arrives, and what you type
while it runs reaches it — so npm init can ask you a question, and jsh is a shell you can work
in. A line that reads that output instead — a pipe, a redirect, a $(…) — gets the whole of it as
the command's result, so npm ls | grep react works as it reads. ^C kills the process, except
inside jsh, where it is the shell's own key.
Without one, node runs a .js file in QuickJS, in a worker of its own beside the page. A
worker per run rather than one kept alive is what makes ^C a terminate, and why a run leaves
nothing behind for the next one. import and require resolve against the workbench's own files
— the graph of everything a script imports is read and resolved before the run starts, since
QuickJS asks for a module synchronously and reading the workbench's files is not.
That engine only reads .js. .ts, .tsx and .jsx need a transform to turn them into
something QuickJS can read:
import { bash, typescript } from "codelet/extensions/bash";
bash({ quickjs: { transform: typescript() } });typescript() is a type stripper built on sucrase,
fetched off the same CDN the first time a .ts file is run. It strips types rather than
compiling: nothing is lowered, ESM is left as ESM, and JSX compiles to React.createElement by
default — a name, not a dependency, since a script under QuickJS has no node_modules to find a
real React in. A namespace is dropped rather than emitted, and a decorator is passed through
unchanged, which QuickJS then refuses. None of it reaches the machine's own node, which reads a
.ts or refuses one exactly as the node version in the container does.
Note
Both codelet/extensions/bash and codelet/extensions/lsp/typescript export a function named
typescript, for unrelated purposes — one strips types for QuickJS, the other runs the TypeScript
language service. Import both in the same file and alias one.
What QuickJS is not: there's no node:fs, no sockets, and no node_modules. It has console,
process, the timers, import and require, run over the workbench's own files and nothing
else. It is also what node falls back to on an isolated page where the machine did not start —
the terms declined, or StackBlitz's CDN unreachable — so a script runs either way. npm cannot
fall back anywhere and says what went wrong instead.
#Servers
Start a dev server and it opens in a window floating over the workbench on its own — no click
needed, and your file keeps the whole pane. Drag the window by its bar, size it from its corner,
or put it back in the pane with the dock button beside its close. The address is not the
localhost:3000 the command printed: WebContainer serves it from an origin of its own, and that
window shows the real one. A restart on the same port reloads it in place rather than opening a
second, without throwing the window to the front or taking the cursor out of what you're editing.
On a narrow screen there's nowhere to put a window, so the page opens as a tab in the pane
instead.
Closed it? Run bash: 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 window 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.
#The prompt
The prompt says who and where — user@localhost:~|⇒ — with the name magenta, the host
yellow, the path cyan and the punctuation red. It's a zsh prompt's shape written in bash's escapes,
painted out of the ANSI eight rather than colours of its own, so the terminal's theme is what they
actually come out as. The red bar is a separator with nothing after it yet: it's where a branch
goes in the prompt this borrows from.
prompt names another — a PS1 string, read the way bash reads one:
bash({ prompt: "\\u@\\h:\\w\\$ " }); // user@localhost:~$
bash({ prompt: "\\[\\e[35m\\]\\W\\[\\e[0m\\] ➜ " }); // a magenta directory name\w is where the shell is (\W just the last part of it, $HOME said as ~ in both), \u and
\h who and where it is, \s and \v what it is, \d/\t/\T/\@/\A the date and the time,
\e an escape and \[/\] the brackets around what does not print. \$ is always $, there
being no root here. Four of bash's escapes want a machine underneath — \j a job count, \l a
tty, \# and \! a numbered command — and are left as they were written, which is what bash does
with any escape it does not know. Nothing but a backslash is expanded, so a $PWD in a PS1 is
those four characters rather than the path.
A function is the same job for a prompt easier said in code than in escapes, and is handed the working directory:
bash({ prompt: (cwd) => `\u001B[2m${cwd.split("/").pop()}\u001B[0m λ ` });Whoever is typing has the last word. PS1 is an ordinary variable here, so export PS1='\W \$ '
in the terminal is what the next prompt is, and prompt is the prompt for a reader who has
exported none. unset PS1 gives it back. It's read fresh for every prompt, so nothing has to be
restarted, and a PS1 in env starts the shell with it.
A \n gives a prompt of two rows. It works, with one cost: line editing rubs out the row it's on,
so an arrow key redraws under the last row rather than over the whole prompt.
#History
Lines typed into the shell are kept in localStorage, under the key history names — one
shell's own ("codelet.just-bash.history") unless you name another, or false for a shell whose
memory is only its own tab. Several tabs sharing a key share one history, the way several shells
on the same machine share a .bash_history: each loads it on open, and whichever tab wrote most
recently is what the next one sees.