shish on WebAssembly
shish compiles to a 184 KB .wasm module. That is a whole POSIX-ish
shell — pipelines, functions, parameter expansion, arithmetic,
redirections — running in a browser tab, a serverless worker, or any
WASI runtime, with no process and no container underneath it.
Try it in the playground.
Emscripten (browser, with JS glue)
CC=emcc CXX=em++ cmake -S . -B build/emscripten \
-DCMAKE_TOOLCHAIN_FILE="$(dirname "$(which emcc)")/cmake/Modules/Platform/Emscripten.cmake" \
-DCMAKE_EXE_LINKER_FLAGS="-s WASM=1 -sEXPORTED_RUNTIME_METHODS=['callMain'] -sINVOKE_RUN=0" \
-DCMAKE_EXECUTABLE_SUFFIX=".html" -DENABLE_SHARED=OFF -DENABLE_PIC=FALSE
cmake --build build/emscripten -jThat produces build/emscripten/shish.js and shish.wasm. Add
-DENABLE_ALL_BUILTINS=ON (see Builtins) to also get
cat/mkdir/grep/sed/etc. as in-process builtins instead of just the
minimal/default set — this is what the playground itself
is built with (build/emscripten-all/: 184 KB core + 63 KB of extra
builtins = 247 KB); the
site's files config
copies straight from that directory, so rebuild it there before publishing.
The linker flags above already pass what the glue needs:
-sEXPORTED_RUNTIME_METHODS=['callMain'] -sINVOKE_RUN=0so the module loads without running anything, and each script is one
callMain():
<script>
var Module = {
print: function (line) { output(line); },
printErr: function (line) { output(line); },
onRuntimeInitialized: function () { ready(); },
};
</script>
<script src="shish.js"></script>
<script>
function run(script) {
try {
Module.callMain(['-c', script]);
} catch (e) {
// exit() unwinds the wasm stack by throwing; that is not an error
if (!(e && e.name === 'ExitStatus')) throw e;
}
}
</script>web/index.html in the repo is exactly this, in 60 lines.
What works, and what does not
Everything that does not need a process does. Expansion, arithmetic, control flow, functions, here-documents, variables, the file-utility builtins against Emscripten's in-memory filesystem.
What cannot work in the browser is anything that forks: pipelines between
two external commands, background jobs, $(...) that runs a program.
There are no processes to fork. Compile the utilities you need in as
builtins and they run in-process instead.
WASI (wasmtime, wasmer, Node, webassembly.sh)
Needs wasi-sdk (tested with 34,
/opt/wasi-sdk; set WASI_SDK_PREFIX otherwise):
. ./cfg-cmake.sh
cfg-wasi # writes build/wasi/, MinSizeRel, all builtins
cmake --build build/wasi -jThis produces build/wasi/shish, a wasm32-wasip1 module: 1.1 MB with every
builtin, including gzip, xz, zstd and the other compressors. Each
compression codec has its own switch (-DLIBARCHIVE_GZIP, _BZIP2, _LZ4,
_LZMA, _ZSTD, _LZO, all ON); a codec that is off is not built and its
builtins are not compiled in. With zstd, lz4, lzo and bzip2 off, the module is
585 KB:
cfg-wasi -DLIBARCHIVE_ZSTD=OFF -DLIBARCHIVE_LZ4=OFF -DLIBARCHIVE_LZO=OFF -DLIBARCHIVE_BZIP2=OFFA configure that is repeated in an existing build/wasi can keep a stale
toolchain choice; delete the directory before changing the toolchain. liblzma
is built single-threaded there (XZ_THREADS=no), as WASI has no threads or
signal masks. wasi-libc
has no fork, exec, wait, termios, pwd or sigaction, so
src/wasi/wasi_compat.h (force-included on this target only) supplies
stubs that fail the way a system without the feature does. The build also
links wasi-libc's -lwasi-emulated-{signal,getpid,process-clocks,mman}.
Runtime requirement: WebAssembly exceptions
setjmp/longjmp carry subshells, pipelines, functions, break and
return, and wasi-sdk implements them with WebAssembly exception handling
(the exnref form; the older opcodes are no longer emitted). The engine
must support it:
node --experimental-wasm-exnref run.mjs build/wasi/shish -c 'echo hello' # Node 23Node 24+, recent wasmtime and browsers that ship exnref need no flag.
Whether a given host supports it has to be checked per host: if it does
not, instantiation fails before any shell code runs.
webassembly.sh
Tested with Chrome 152: run wapm upload in the terminal, choose
shish.wasm (rename build/wasi/shish to that), then shish -c 'echo hi'.
Functions, subshells, break and pipelines between builtins work there.
Plain shish starts, but is not a usable interactive shell: webassembly.sh
gives a WASI program no terminal, and answers every read from standard input
with a browser prompt() dialog ("Please enter text for stdin:"), one line
at a time, with no prompt line and no editing. Use -c there. An interactive
page needs its own terminal front end (see the roadmap).
What works on WASI
Everything that does not need another process: expansion, arithmetic,
control flow, functions, here-documents, $(...) of builtins, pipelines
between builtins and subshells (they run in-process), redirections, and
the file utilities that are compiled in as builtins.
What does not
- External commands (
sort,/bin/sh, ...) and background jobs (&) needfork: they print<cmd>: Function not implementedand give status 1. Compile the utilities you need in as builtins. - No job control, no terminal handling (
tcgetattrreports "not a tty"), no/etc/passwd(~userdoes not expand), a single anonymous user. - No working directory to change:
cdfails, relative paths resolve only where the host provides a cwd.ulimitreports "not supported". - Files are visible only through the host's preopened directories.
Why put a shell in WebAssembly
- Documentation and tutorials that actually run. A shell prompt on a docs page, doing real work, with no backend to keep alive.
- Agent UIs. A browser-side coding assistant can run its shell commands in the tab, against a virtual filesystem, without a container per session — see Agent sandboxes.
- Edge and serverless. A 184 KB module is a plausible unit of deployment where a container image is not.
- Teaching and testing. The same shell binary in CI, in the container, and in the browser means one behaviour to learn.