npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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 sandboxedjs

Node 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 /app

Long-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:5173

Serve 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 pane

That 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:

  • postMessage to 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 install and npx <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 installing

Install 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:5173

Open 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 → host

Snapshots

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 port

Run 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 registry

It 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 pass network: { allowOutbound: true }, optionally narrowed with allowedHosts.
  • Host files enter only through files, mount() or copyIn(), and leave only through copyOut() or fs.readFile().
  • timeoutMs bounds runaway commands, and exec settles 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" }); // OSError

It 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" }); // succeeds

Node 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, at sandboxedjs/vite and sandboxedjs/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. comlink is 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 real node:http listener. Use request() to reach an in-container server instead of a host port.
  • The sandboxedjs CLI, 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 directories

The 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/core
const 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:

  • ffprobe does not report an exit status. Whenever it does real work this build leaves through exit() 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. ffmpeg itself 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 .node file cannot be loaded, so a package that ships one has to have a JavaScript or WebAssembly build to fall back on. rollup and esbuild do, and the runtime redirects those two names to @rollup/wasm-node and esbuild-wasm automatically 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 plain http servers 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 /workspace can overwrite that mirror.
  • child_process is asynchronous only. spawn, exec and execFile run through the kernel, so a child sees the same filesystem and coreutils as the shell. execSync, spawnSync and execFileSync throw ERR_FEATURE_UNAVAILABLE_ON_PLATFORM: blocking the JavaScript thread on another process is not expressible here.
  • No net, tls, worker_threads or vm. http and https are served by a virtual stack that request() 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 -9 cannot interrupt a tight synchronous loop, and SIGSTOP only marks state.
  • chroot does not isolate; it runs the command with its cwd inside the target.
  • awk's system() 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 THERE

It 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.