sandboxedjs
v0.1.27
Published
A Linux-like container that runs entirely inside Node.js — POSIX shell, ~140 coreutils, Node.js and Python runtimes, virtual filesystem and networking. No Docker, no VM, no native modules.
Maintainers
Readme
sandboxedjs
A Linux-like container that runs entirely inside a Node.js process. No Docker, no VM, no native modules — a virtual filesystem, a POSIX shell, 154 Unix programs, and both Node.js and Python runtimes, all in-process.
The Node.js runtime is its own: a module engine that loads both CommonJS and ES modules, an
in-memory volume, an npm-registry installer and a virtual HTTP stack, with no native modules and
no dependency on the host's node_modules.
import { createContainer } from "sandboxedjs";
const box = await createContainer({
files: { "/app/hello.js": "console.log('hi from', process.platform)" },
});
await box.exec("ls -la /app");
await box.exec("node /app/hello.js"); // → hi from linux
await box.exec("python3 -c 'print(2**64)'"); // → 18446744073709551616
box.dispose();Why
Sometimes you need to run untrusted or generated code, give an AI agent a shell, build a
browser-based IDE backend, or teach Unix — and spinning up a real container is too heavy, too
slow, or unavailable (serverless, CI, the browser). sandboxedjs boots in about 100 ms, costs
nothing but memory, and never touches your real filesystem.
Install
npm install sandboxedjsNode 18.17+. Everything is pure JavaScript and WebAssembly — no compilation step.
What's inside
| | |
|---|---|
| Filesystem | Full FHS tree (/etc, /usr, /var, /home, …), permissions, ownership, symlinks, hard links, umask, sticky bits |
| Shell | POSIX sh (54 builtins) — pipelines, redirection, here-docs, globbing, brace/parameter/arithmetic/command expansion, functions, if/for/while/case/select, job control, traps, arrays, [[ ]], (( )) |
| Coreutils | 154 programs — ls cat cp mv rm mkdir grep sed awk find head tail sort uniq wc cut tr tee xargs chmod chown ln du df ps tar gzip base64 sha256sum diff curl wget and the rest — plus 54 shell builtins |
| Node.js | Its own module engine — require and import, live bindings, exports maps, top-level await, npm packages, http, fs, streams, child_process |
| Python | CPython 3.13 (Pyodide) on the same filesystem, with the real standard library |
| FFmpeg | ffmpeg and ffprobe (FFmpeg 5.1) reading and writing container files directly — optional install |
| /proc | Live and synthesised — ps, top, free and uptime all read the same source |
| Networking | Virtual interfaces, /etc/hosts resolution, in-container HTTP servers, an optional bridge to a real host port |
| Users | Real /etc/passwd and /etc/group; useradd, su, sudo, and permission checks that deny for real — for the shell and Python, but not Node |
Everything shares one filesystem. A file written by echo is readable by require('fs') in a
Node script and by open() in Python, in either direction.
Guide
Booting
const box = await createContainer({
files: { "/app/index.js": "…" }, // seed the filesystem
cwd: "/app", // default working directory
hostname: "sandbox",
user: "root", // or a name → uid 1000 with sudo
env: { NODE_ENV: "production" },
memory: 2 * 1024 ** 3, // what `free` and /proc/meminfo report
cpus: 4, // what `nproc` reports
network: { allowOutbound: false }, // outbound is off by default
timeoutMs: 30_000, // default limit for exec()
});files keys may be absolute or relative to cwd; parent directories are created for you, and
values may be strings or Uint8Arrays. That is the quickest way to drop a whole project in.
Running commands
const { stdout, stderr, exitCode, output } = await box.exec("grep -c . /etc/passwd");
await box.exec("cat", { stdin: "piped in" });
await box.exec("npm test", { cwd: "/app", onStdout: (t) => process.stdout.write(t) });
await box.exec("whoami", { user: "alice" });
await box.exec("sleep 60", { timeoutMs: 1000 }); // → { timedOut: true }
await box.run(["echo", "no shell parsing here"]);exec is stateless, like docker exec. For a shell that remembers things, use a session:
const session = box.session();
await session.run("cd /app");
await session.run("export TOKEN=abc");
await session.run("echo $TOKEN in $(pwd)"); // → abc in /appLong-running processes
const proc = box.spawn("node server.js", { cwd: "/app" });
for (;;) {
const line = await proc.stdout.readLine();
if (line === null) break;
console.log("[server]", line);
}
proc.kill();Servers
An HTTP server started inside the container is reachable three ways:
box.spawn("node /app/server.js");
await box.waitForPort(3000);
// 1. programmatically
const res = await box.request(3000, { path: "/api", method: "POST", body: "{}" });
console.log(res.status, res.json());
// 2. from inside, with the usual tools
await box.exec("curl -s localhost:3000/api");
// 3. from your machine or a browser
const bridge = await box.expose(3000);
console.log(bridge.url); // http://127.0.0.1:54321
await bridge.close();Showing a live preview in an IDE
This is the StackBlitz/CodeSandbox preview pane: the user runs a dev server inside the container, and sees the rendered page, not just logs.
It works today, as an <iframe> — not as a <div>. A <div> cannot host it: the preview
is a whole HTML document with its own scripts, styles, <base> and routing, and it must not be
able to reach into your IDE's DOM. An iframe on its own origin is exactly the isolation you
want, and it is what StackBlitz and CodeSandbox use too.
expose(port) gives you a real, browser-loadable URL:
box.spawn("npm run dev", { cwd: "/app" });
await box.waitForPort(5173);
const preview = await box.expose(5173);
document.querySelector("#preview").src = preview.url; // an <iframe>A minimal IDE preview pane, in React:
function PreviewPane({ box, port }) {
const [url, setUrl] = useState(null);
const frame = useRef(null);
useEffect(() => {
let bridge;
let cancelled = false;
(async () => {
if (!(await box.waitForPort(port, { timeoutMs: 60_000 }))) return;
bridge = await box.expose(port);
if (!cancelled) setUrl(bridge.url);
})();
return () => {
cancelled = true;
void bridge?.close();
};
}, [box, port]);
// Call this after a rebuild to refresh the pane.
const reload = () => {
if (frame.current) frame.current.src = frame.current.src;
};
if (!url) return <div>starting…</div>;
return <iframe ref={frame} src={url} style={{ width: "100%", height: "100%", border: 0 }} />;
}Pin the port if you want a stable URL across restarts:
const preview = await box.expose(5173, { hostPort: 5173 }); // http://127.0.0.1:5173Serve several ports by exposing each one; every call gets its own host port.
Refreshing on change
expose() proxies HTTP, not WebSockets. A dev server's hot-reload channel is a WebSocket,
so HMR and live-reload overlays will not reach the iframe. Drive the refresh from your IDE
instead — which you need to do anyway if you are compiling on save:
// after writing the user's edit and rebuilding
await box.fs.writeFile("/app/src/App.jsx", nextSource);
await box.exec("npm run build", { cwd: "/app" });
frame.current.src = frame.current.src; // reload the paneThat is a full reload rather than hot module replacement: state in the page is lost. For most IDE previews that is acceptable; if you need true HMR, the WebSocket proxy is the missing piece.
What the IDE can and cannot do with the frame
The iframe is cross-origin, so your IDE cannot read its DOM. You can still:
postMessageto it, if the app inside cooperates;- watch
/app/**through the container to know when to rebuild; - read the dev server's stdout from
spawn()for a log pane.
In a pure browser IDE
expose() opens a real node:http listener, so it needs a Node host — an Electron app, or a web
IDE with a Node backend. With no backend at all, an iframe needs a real URL to load, and giving
it one means a service worker that intercepts requests and routes them to request(). That is
not built yet; see Running in a browser. Until it is, you can still
drive an in-container server programmatically through request() and render the result
yourself.
npm and npx
Anything that downloads needs
network: { allowOutbound: true }. Outbound access is off by default, so a fresh container cannot reach registry.npmjs.org.npm installandnpx <not-yet-installed>will fail until you turn it on. This is the single most common surprise — if a package command is failing, check this first.
const box = await createContainer({
cwd: "/app",
network: { allowOutbound: true }, // ← without this, npm/npx cannot install
files: {
"/app/package.json": JSON.stringify({
name: "api",
scripts: { start: "node server.js" },
dependencies: { express: "^4.19.2" },
}),
"/app/server.js": `
const express = require('express');
const app = express();
app.get('/', (req, res) => res.json({ ok: true }));
app.listen(3000);
`,
},
});
await box.exec("npm install", { cwd: "/app", timeoutMs: 300_000 });
box.spawn("npm start", { cwd: "/app" });
await box.waitForPort(3000);yarn and pnpm map onto the same installer. apt/apt-get reports the built-in package set
rather than pretending to download Debian archives.
npx
npx works the way you expect: it runs a local binary if there is one, and otherwise installs
the package first.
const box = await createContainer({ cwd: "/app", network: { allowOutbound: true } });
await box.exec("npx cowsay hello"); // installs cowsay, then runs it
await box.exec("npx cowsay hello"); // second time: instant, already installed
await box.exec("npx sharjeelbaig"); // any package with a bin works
await box.exec("npx -p typescript tsc -v"); // package name ≠ command name
await box.exec("npx [email protected] --check ."); // pin a version
await box.exec("npx --no-install eslint"); // fail instead of installingInstall progress goes to stderr, so npx cowsay moo | head -3 pipes cleanly.
Without allowOutbound, a not-yet-installed command fails with a message that names the real
problem:
npx: could not determine executable to run: cowsay
npx: 'cowsay' is not installed, and installing it needs network access.
npx: Outbound network access is disabled for this container.
npx: Enable it with createContainer({ network: { allowOutbound: true } }).Commands already present — anything in node_modules/.bin or on $PATH — still run offline.
Walkthrough: Hello React, in your browser
End to end — build a React app inside the container and open it in your own browser. Copy this
into hello-react.mjs and run node hello-react.mjs.
React itself comes from a CDN through an import map, so there is no bundler to configure; the container only has to compile JSX and serve files.
import { createContainer } from "sandboxedjs";
const box = await createContainer({
cwd: "/app",
network: { allowOutbound: true }, // needed to install the JSX compiler
files: {
// 1. The React component, in real JSX.
"/app/src/App.jsx": `
import { useState } from 'react';
export default function App() {
const [name, setName] = useState('world');
return (
<main style={{ fontFamily: 'system-ui', padding: '3rem' }}>
<h1>Hello, {name}!</h1>
<input value={name} onChange={(e) => setName(e.target.value)} />
</main>
);
}
`,
"/app/src/main.jsx": `
import { createRoot } from 'react-dom/client';
import App from './App.js';
createRoot(document.getElementById('root')).render(<App />);
`,
// 2. The page. React comes from a CDN via an import map.
"/app/public/index.html": `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Hello React</title>
<script type="importmap">
{"imports": {
"react": "https://esm.sh/[email protected]",
"react/jsx-runtime": "https://esm.sh/[email protected]/jsx-runtime",
"react-dom/client": "https://esm.sh/[email protected]/client"
}}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.js"></script>
</body>
</html>`,
// 3. Compile every .jsx in src/ to plain ES modules in public/.
"/app/build.js": `
const Babel = require('@babel/standalone');
const fs = require('fs');
for (const file of fs.readdirSync('/app/src')) {
if (!file.endsWith('.jsx')) continue;
const { code } = Babel.transform(fs.readFileSync('/app/src/' + file, 'utf8'), {
filename: file,
presets: [['react', { runtime: 'automatic' }]],
sourceType: 'module',
});
fs.writeFileSync('/app/public/' + file.replace('.jsx', '.js'), code);
console.log('compiled', file);
}
`,
// 4. A plain static server.
"/app/server.js": `
const http = require('http');
const fs = require('fs');
const path = require('path');
http.createServer((req, res) => {
const url = req.url === '/' ? '/index.html' : req.url;
const file = path.join('/app/public', url);
if (!fs.existsSync(file)) { res.writeHead(404); res.end('not found'); return; }
const type = url.endsWith('.html') ? 'text/html' : 'text/javascript';
res.writeHead(200, { 'Content-Type': type });
res.end(fs.readFileSync(file));
}).listen(5173, () => console.log('listening on 5173'));
`,
},
});
// Install the compiler, compile, serve.
await box.exec("npm install @babel/standalone", { cwd: "/app", timeoutMs: 600_000 });
console.log((await box.exec("node build.js", { cwd: "/app" })).output);
box.spawn("node server.js", { cwd: "/app" });
await box.waitForPort(5173);
// Publish it on a real host port and open that URL in your browser.
const bridge = await box.expose(5173, { hostPort: 5173 });
console.log(`open ${bridge.url}`);compiled App.jsx
compiled main.jsx
open http://127.0.0.1:5173Open that URL and you get a working React app — typing in the input updates the heading. The JSX was compiled inside the container, the files live only in memory, and nothing was written to your disk.
examples/03-react-app.mjs is the same idea with a nicer component and a SIGINT handler.
Python
Python is CPython 3.13, via Pyodide, mounted on the container's filesystem:
await box.exec("python3 -c \"print(open('/etc/hostname').read())\"");
await box.exec("python3 /app/script.py arg1 arg2");
await box.exec("echo '1 2 3' | python3 -c \"import sys; print(sum(map(int, sys.stdin.read().split())))\"");The bundled standard library includes json, re, os, sys, math, random, hashlib,
binascii, struct, time, collections, itertools, functools, asyncio and more. It is
Real CPython, so sqlite3, dataclasses, decimal and typing all work. Packages must be built for WebAssembly: micropip installs pure-Python wheels, and Pyodide's own distribution covers the rest
(and only with outbound networking enabled).
Filesystem from the host
await box.fs.writeFile("/app/config.json", JSON.stringify(config));
const log = await box.fs.readFile("/var/log/app.log", "utf8");
const entries = await box.fs.readdir("/app");
const { files, bytes } = await box.fs.usage("/app");
await box.copyIn("./my-project", "/app"); // host → container
await box.copyOut("/app/dist", "./dist"); // container → hostSnapshots
const snapshot = box.snapshot(); // serialisable
await box.restore(snapshot);Interactive terminals
Terminal is transport-agnostic: feed it keystrokes, it hands you back what to display. That
works for a real TTY and for xterm.js in a browser alike.
import { Terminal } from "sandboxedjs";
const terminal = new Terminal(box.session(), {
write: (data) => xterm.write(data),
columns: 80,
rows: 24,
});
xterm.onData((data) => terminal.input(data));
terminal.start();You get line editing, history, tab completion over commands and paths, multi-line continuation, and the usual control keys.
Command line
npx sandboxedjs --repl # explore: what runs, what doesn't
npx sandboxedjs # interactive shell
npx sandboxedjs -c 'ls -la /etc' # one command
npx sandboxedjs script.sh # run a script
npx sandboxedjs -v ./app:/app -w /app # mount a host directory
npx sandboxedjs --network -p 3000 # allow outbound, publish a portRun sandboxedjs --help for the full list.
Trying it out
--repl drops you into a shell with a summary of what this container can actually do. Every
line is measured on the spot rather than claimed, so a runtime that is missing says so:
sandboxedjs — a Linux-like container inside Node.js
node v22.12.0 npm, require, http servers
python3 CPython 3.13 only WebAssembly-built C extensions
ffmpeg 5.1.4 video and audio
network on npm install reaches the real registryIt needs no project and no files — it is meant for finding the edges. Because installing
packages is most of what people want to test, --repl allows outbound access; pass
--no-network to take it away and watch what breaks.
Security
The container has no access to your filesystem, environment, or network unless you grant it:
- The filesystem is entirely in memory. Code inside cannot read or write a host path — there is
no
/Users, no/home/you, no way to reach one. - Outbound network access is off by default;
curl https://…fails until you passnetwork: { allowOutbound: true }, optionally narrowed withallowedHosts. - Host files enter only through
files,mount()orcopyIn(), and leave only throughcopyOut()orfs.readFile(). timeoutMsbounds runaway commands, andexecsettles even when a process ignores its kill signal.
The in-container user model does not constrain Node.js
This one matters, so it gets its own heading. The Unix permission layer is enforced for the shell, the coreutils and Python:
await box.exec("cat /root/secret", { user: "agent" }); // Permission denied
await box.exec("python3 -c \"open('/etc/passwd','a')\"", { user: "agent" }); // OSErrorIt is not enforced for Node.js. A node script gets direct access to the underlying volume,
so it can read and write any path in the container regardless of user:
await box.exec("node -e \"require('fs').readFileSync('/root/secret')\"", { user: "agent" }); // succeedsNode code runs on the JavaScript runtime, which owns its own volume and has no notion of
container uids. Treat user as a way to model ordinary multi-user behaviour, not as a
privilege boundary for JavaScript you do not trust. If untrusted JavaScript must not see
something, keep it out of the container rather than relying on file modes.
The runtime also shares the host's JavaScript realm today rather than running in a worker, so a program inside the container can reach host globals. Isolating it in a worker is planned; until then, do not treat the container as a boundary against hostile code.
And it is not a VM
Everything runs in your Node process, so a true sandbox escape is a JavaScript-engine escape. This is isolation from mistakes and from ordinary untrusted programs — not a substitute for a VM or a real container when facing a determined attacker.
Troubleshooting
npm install or npx <tool> fails immediately.
You almost certainly did not pass network: { allowOutbound: true }. It is off by default, so
the container cannot reach registry.npmjs.org. This is the most common surprise by far.
const box = await createContainer({ network: { allowOutbound: true } });Narrow it if you like: network: { allowOutbound: true, allowedHosts: ["registry.npmjs.org"] }.
command not found for something you installed.
Check where it landed. npm install installs into the nearest package.json directory, so run
it with the right cwd:
await box.exec("npm install express", { cwd: "/app" });
await box.exec("ls node_modules/.bin", { cwd: "/app" });A server started with spawn never answers.
Wait for it rather than racing it:
box.spawn("node server.js", { cwd: "/app" });
await box.waitForPort(3000, { timeoutMs: 60_000 });exec hangs on a command that reads stdin.
exec gives a command an empty stdin unless you pass some. box.exec("cat") with no stdin
returns immediately; box.spawn("cat") gives you a live pipe to write into.
Output looks wrong when piping.
Pass tty: true only when you want terminal behaviour (colour, column layout). Without it,
ls emits one name per line, like a real pipe.
A command runs forever.
Set timeoutMs, per call or as a container default. exec settles even if the process ignores
its kill signal.
Running in a browser
The same createContainer() API runs under Node and in modern browsers. Browser execution is
tested under Vite 8; the remaining browser-specific limits are listed below.
Done — the package no longer hard-depends on Node at import time. Compression and hashing
resolve their implementation at call time (node:zlib/node:crypto on Node,
CompressionStream/crypto.subtle plus a JS MD5 in a browser), so nothing pulls a node:
builtin in when the module loads and a bundler will not fail on it.
One call, either side. createContainer() boots its own runtime. There is no host to pick
and no pod to pass:
import { createContainer } from "sandboxedjs";
const box = await createContainer({ files: { "/app/index.js": "console.log(1)" } });
await box.exec("ls -la /"); // shell + coreutils
await box.exec("node app/index.js");Verified in a browser. Booted under Vite 8, a container starts in the page and the shell, coreutils, virtual filesystem and Node runtime all work:
$ uname -a Linux sandbox 5.10.0 (sandboxedjs@sandbox) … x86_64 GNU/Linux
$ ls -la / the full FHS tree
$ node -p process.version v22.12.0
$ cat /etc/os-release SandboxedJS 1.0 (sandbox)Vite 8 works through Rolldown's official WASI binding. Because that binding uses shared WebAssembly memory and workers, the page must be cross-origin isolated. For example:
// vite.config.ts
export default {
server: { headers: {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
} },
};Production hosting must return the same two headers. Without them SandboxedJS reports a direct configuration error when Vite/Rolldown starts. The compiler is loaded only when an installed project actually contains Rolldown, so ordinary container boot does not pay its WASM startup cost.
Not done — these things still stand between this and a complete browser IDE.
- A service worker. A preview iframe needs a real URL, and giving it one means a worker on your
own origin that intercepts requests and routes them into the container.
request()works everywhere and is unaffected; only the iframe needs this. Earlier versions re-exported one from the package this was built on, atsandboxedjs/viteandsandboxedjs/server. Both are gone along with that dependency, and a replacement is not written yet. - Worker isolation. The runtime executes in whichever realm you boot it from, which in a browser
is the main thread — so a long build blocks the UI, and container code can reach page globals.
comlinkis a dependency in anticipation of this and is not used yet. - esbuild-dependent tools. The runtime cannot execute any build of esbuild itself: one dlopens a compiled addon,
the other drives a Go program through facilities the sandbox does not have. On Node it borrows
the host's
esbuild-wasm. Vite 8's Rolldown path works in a browser, but tools which call esbuild directly still fail until esbuild-wasm runs inside the sandbox.
Vite prints two warnings about util being externalized. They come from readable-stream, which
declares "util": false for browsers and falls back on its own; nothing in this package imports
it.
Everything else is already browser-shaped. Nothing in the runtime imports a node: builtin except
through an explicitly Node-only path that returns null elsewhere, compression and hashing pick
their implementation at call time, and the module engine, volume, HTTP stack and npm installer are
built on fetch, acorn, resolve.exports, @noble/hashes and pako.
What does not work in a browser:
copyIn()/copyOut()— they read and write the host filesystem, which does not exist.expose()— it opens a realnode:httplistener. Userequest()to reach an in-container server instead of a host port.- The
sandboxedjsCLI, obviously.
Those use dynamic imports, so they only fail if you call them.
Python in a browser
Python works lazily in a browser without extra configuration. On first use, SandboxedJS loads the matching Pyodide runtime and assets from jsDelivr:
const box = await createContainer();
await box.exec("python3 -c 'import sqlite3; print(sqlite3.sqlite_version)'");To self-host Pyodide or use another trusted CDN, override both URLs:
import { configurePython, createContainer } from "sandboxedjs";
configurePython({
pyodideURL: "https://cdn.jsdelivr.net/pyodide/v0.28.3/full/pyodide.mjs",
indexURL: "https://cdn.jsdelivr.net/pyodide/v0.28.3/full/",
});
const box = await createContainer();
await box.exec("python3 -c 'import sqlite3; print(sqlite3.sqlite_version)'");Python never falls back to a host interpreter or another runtime. A custom Pyodide module and its asset directory must use the same Pyodide version.
Uploading files
A file picker in a browser never hands over a path — it hands over the file's bytes. So getting a picked file into the container is an ordinary write, and there is nothing to mirror from the host disk.
Two ways in, both byte-exact:
// 1. Directly, when your own server received the upload.
await box.fs.writeFile("/workspace/uploads/clip.mp4", bytes); // creates missing directoriesThe File returned by a browser picker can be passed directly too. It is read
as bytes and written at the exact container path, including an application-
generated temporary path:
const picked = input.files[0];
const sourcePath = "/tmp/transcribe-quran-52fjch/source-1786307713702-baleela.f32le";
await box.fs.writeFile(sourcePath, picked);Do not pass picked.path (or any other host path) to a command in the
container. Browser file pickers provide file data, not a path the sandbox can
see.
// 2. Through a server running inside the container — a picker in the previewed
// app posting to its own backend. Publish the port and upload normally.
const bridge = await box.expose(3000);
await fetch(`${bridge.url}/api/upload`, { method: "POST", body: file });Either way the file lands on the container's filesystem, and ffmpeg, Node and Python all see the
same bytes. Uploads are held in memory like the rest of the filesystem, so a very large video is
bounded by RAM.
Video and audio
ffmpeg and ffprobe are FFmpeg 5.1 compiled to WebAssembly, mounted on the container's own
filesystem. The mount is the point: inputs and outputs are ordinary container files, so nothing
is staged in or copied back and a pipeline can be built out of scripts and pipes as usual.
The runtime is ~31MB of WebAssembly, which is a lot to force on someone who wants a shell, so it installs separately. Without it the commands report themselves as missing, exactly as a real system reports an uninstalled binary:
npm install @ffmpeg/coreconst box = await createContainer({ cwd: "/media" });
// Make a clip, then transcode it — both files are just container files.
await box.exec("ffmpeg -f lavfi -i testsrc=size=640x480:rate=25:duration=5 -pix_fmt yuv420p clip.mp4");
await box.exec("ffmpeg -i clip.mp4 -vf scale=320:-2 -frames:v 1 thumb.png");
const thumbnail = await box.fs.readFile("/media/thumb.png");Every FFmpeg invocation gets a fresh WebAssembly instance, because FFmpeg ends by calling
exit() and tears its runtime down as it goes; the compiled module is cached, so only the cheap
half is repeated. Runs are synchronous — a long transcode occupies the thread until it finishes.
Two caveats worth knowing:
ffprobedoes not report an exit status. Whenever it does real work this build leaves throughexit()without setting a return value, so success and failure are indistinguishable to the caller. It is reported as success; branch on its output, not its exit code.ffmpegitself reports status correctly and can be relied on in&&chains.- No hardware acceleration and no native codecs beyond what the WebAssembly build ships.
Known limits
Honest list of what does not work:
- Compiled native addons. A
.nodefile cannot be loaded, so a package that ships one has to have a JavaScript or WebAssembly build to fall back on.rollupandesbuilddo, and the runtime redirects those two names to@rollup/wasm-nodeandesbuild-wasmautomatically when they are installed. Vite 8 is supported through Rolldown's official WASI build (with the browser isolation headers above). Express, Koa, Fastify-style apps and plainhttpservers also run. - Concurrent browser Rolldown projects need distinct absolute working directories. The
official binding owns one WASI memfs per page; SandboxedJS mirrors each project into it before
startup. Two live projects using the same path such as
/workspacecan overwrite that mirror. child_processis asynchronous only.spawn,execandexecFilerun through the kernel, so a child sees the same filesystem and coreutils as the shell.execSync,spawnSyncandexecFileSyncthrowERR_FEATURE_UNAVAILABLE_ON_PLATFORM: blocking the JavaScript thread on another process is not expressible here.- No
net,tls,worker_threadsorvm.httpandhttpsare served by a virtual stack thatrequest()talks to directly, so servers work; raw sockets do not. - Python is CPython via Pyodide. The standard library is the real one. A C
extension works only if it has been built for WebAssembly — Pyodide ships
many, including
numpy, but an arbitrary wheel from PyPI will not install. Starting an interpreter costs about a second and a half; one is kept per container, and each program runs in its own namespace. - No real sockets. HTTP servers work through the request proxy; raw TCP/UDP does not.
- No real processes. Processes are cooperative async tasks:
kill -9cannot interrupt a tight synchronous loop, andSIGSTOPonly marks state. chrootdoes not isolate; it runs the command with its cwd inside the target.awk'ssystem()does not block on the child.expose()does not proxy WebSockets, so dev-server HMR and live-reload do not reach a preview iframe. Reload the frame from your IDE after a rebuild instead — see Showing a live preview in an IDE.
API reference
createContainer(options): Promise<Container>
| Option | Type | Default | |
|---|---|---|---|
| files | Record<string, string \| Uint8Array> | — | Seed the filesystem |
| cwd | string | "/" | Default working directory, and base for relative files keys |
| hostname | string | "sandbox" | |
| user | string \| null | "root" | Login user; a non-root name gets uid 1000 and sudo |
| env | Record<string, string> | — | Extra environment variables |
| memory | number | 2 GiB | Reported by free, top, /proc/meminfo |
| cpus | number | 4 | Reported by nproc, /proc/cpuinfo |
| network | NetworkOptions | outbound off | { allowOutbound, allowedHosts, ipv4, gateway } |
| timezone | string | "UTC" | |
| timeoutMs | number | none | Default limit for exec |
| onStdout / onStderr | (chunk: string) => void | — | Container-wide output taps |
| onServerReady | (port, url) => void | — | Fires when something inside starts listening |
| pod | RuntimePod | booted for you | Share or substitute the JavaScript runtime |
| python | PythonOptions | jsDelivr | Where to load Pyodide from |
Container
| Member | |
|---|---|
| exec(command, opts?) | Run a shell command line; returns { stdout, stderr, output, exitCode, timedOut, durationMs } |
| run(argv, opts?) | Run a program without shell parsing |
| spawn(command, opts?) | Start a process; returns { pid, stdin, stdout, stderr, wait(), kill() } |
| session(opts?) | A stateful shell session |
| fs | readFile, writeFile, readdir, mkdir, rm, stat, walk, usage, … |
| mount(files, opts?) | Add files after boot |
| copyIn / copyOut | Move trees between host and container |
| request(port, init?) | HTTP to an in-container server |
| waitForPort(port, opts?) | Resolve once something is listening |
| expose(port, opts?) | Bridge to a real host port |
| snapshot() / restore(s) | Filesystem persistence |
| kernel, pod, net | Escape hatches to the internals |
| hostname, user, cwd, env | What the container was booted with |
| dispose() | Tear everything down |
Lower-level pieces — Kernel, Vfs, Shell, Terminal, NetworkStack, defineCommand — are
exported too, so you can add your own commands or embed the shell on its own.
Adding a command
import { createContainer, defineCommand } from "sandboxedjs";
const box = await createContainer();
box.kernel.installCommand(
defineCommand({
name: "greet",
summary: "say hello",
run(ctx) {
ctx.line(`hello ${ctx.args[0] ?? "world"}`);
return 0;
},
}),
);
await box.exec("greet there | tr a-z A-Z"); // → HELLO THEREIt becomes a real file in /usr/bin, so which greet, man greet and shebang dispatch all work.
Examples
See examples/: a REPL, an Express API, a React app, a Python data pipeline, an
agent sandbox, and a browser terminal.
License
MIT, with no dependency carrying a stricter licence.
