Codelet logoCodelet

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-corp

With 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
OptionTypeDefaultDescription
namestring"bash"What the profile is called in the menu and on the tab.
workspacebooleantrueWhether the shell is a shell over the workbench's files. false gives it only its own /tmp.
mountstring"/home/workspace"Where the workbench's files are mounted, and where the shell opens when it has a workspace.
filesRecord<string, string>—What the in-memory filesystem around the mount starts with, by absolute path.
envRecord<string, string>HOSTNAME the page's host, HOME the mountStarting environment variables, over just-bash's own.
cwdstringmount, or just-bash's own / without a workspaceWhere the shell opens.
promptstring \| ((cwd: string) => string)\u@\h:\w\|⇒What the prompt says: a PS1, or a function of the working directory. See The prompt.
networkShellNetwork \| falseevery URL and every methodWhat curl, wget and ping may reach. See Network.
historystring \| false"codelet.just-bash.history"The localStorage key command history is kept under. false turns off persistence.
containerBashContainerOptions \| falseon wherever the page is isolatedThe machine node and the package managers run on. See below.
quickjsboolean \| NodeOptionstrueThe node a page with no machine gets, and how it handles .ts/.tsx/.jsx. See node.
cdnstring"https://esm.sh"Where just-bash, quickjs-wasi and the WebContainer SDK are fetched from.
Every `container` option
OptionTypeDefaultDescription
termsbooleantrueWhether the reader is asked to agree to StackBlitz's terms before the first boot.
excludereadonly string[][".git"]Directory names never mirrored, matched at any depth.
commandsreadonly string[]jsh, node, npm, npx, pnpm, yarn, xxd, psWhich command names are spawned on the machine rather than run in the page.
cdnstring"https://esm.sh"Where @webcontainer/api is fetched from.
coep"require-corp" \| "credentialless" \| "none"the SDK's ownWhich COEP the page is served with, so the SDK's own origins are framed by it.
workdirNamestringthe SDK's ownNames 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. od and strings are its nearest to the first.
  • jsh — the machine's own shell, for looking at the container as itself: jsh on its own is a prompt inside the tab (env, which npm, ls ~), and jsh -c '<line>' runs one line and comes back. exit or ^D ends it; ^C belongs 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 as open, 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 or y. 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, with bs, count, skip and seek.
  • link, unlink — a hard link and its removal. The workbench's files support neither linking nor symlinks, so link fails with the filesystem's own error.
  • uname — reports just-bash as the kernel name, the host name, the pinned just-bash version, and codelet as the OS. -m is navigator.platform, the one field a browser actually answers for the machine.
  • hostname — the page's own host, -s for the part before the first dot and -d for the rest. A page opened at an address rather than a name — 192.168.1.5, [::1] — has no domain to take off, so -s is the whole of it and -d is empty. The shell starts with HOSTNAME set to location.hostname rather than just-bash's localhost, and this, uname -n and \h in the prompt all read that variable — so export HOSTNAME=box, or an env given to openBash, moves all three at once. Setting one as an argument is refused, as a real hostname NAME is for anyone but root.
  • id — the one user there is, uid and gid 1000.
  • 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 when network is 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 — so netstat -tlnp finds 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't localhost:<port>. fuser -k 3000/tcp ends the server where the port could be traced back to a running command, and names pkill -f where it couldn't. Anything a page genuinely can't do — a routing table, a unix socket, nc opening 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:

OptionTypeDefaultDescription
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, HEADMethods a request may use.
dangerouslyAllowFullInternetAccessboolean—Every URL and every method.
maxRedirectsnumber—How many redirects a request follows.
timeoutMsnumber—How long a request waits before it's cut off.
maxResponseSizenumber—The largest response body allowed.
denyPrivateRangesbooleanfalsejust-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.

Read more in Extensions > Terminal.