sandboxedjs 0.2.7 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +236 -1048
- package/bin/sandboxedjs-egress.mjs +117 -0
- package/dist/index.cjs +12 -7
- package/dist/index.js +12 -7
- package/dist/worker-entry.js +11 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
modules — a virtual filesystem, a POSIX shell, 154 Unix programs, and both Node.js and Python
|
|
5
|
-
runtimes, all in-process.
|
|
3
|
+
<img src="https://raw.githubusercontent.com/Sharjeelbaig/sandboxedjs/main/assets/logo.png" alt="SandboxedJS" width="140" />
|
|
6
4
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
# SandboxedJS
|
|
6
|
+
|
|
7
|
+
**A Linux-like operating system of its own — written in JavaScript, running inside a browser tab or a Node process.**
|
|
8
|
+
|
|
9
|
+
Not a Linux kernel, not a VM, not Docker, not WebContainers. Its own filesystem, shell, process
|
|
10
|
+
table, package installer and network stack, all in memory, booting in about 100 ms.
|
|
11
|
+
|
|
12
|
+
[](https://www.npmjs.com/package/sandboxedjs)
|
|
13
|
+
[](./LICENSE)
|
|
14
|
+
[](https://nodejs.org)
|
|
15
|
+
[](#running-in-a-browser)
|
|
16
|
+

|
|
17
|
+
|
|
18
|
+
`shell` · `160 commands` · `Node 22` · `CPython 3.13` · `pip` · `npm` · `WASI` · `FFmpeg` · `previews`
|
|
19
|
+
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
---
|
|
10
23
|
|
|
11
24
|
```ts
|
|
12
25
|
import { createContainer } from "sandboxedjs";
|
|
@@ -22,97 +35,14 @@ await box.exec("python3 -c 'print(2**64)'"); // → 18446744073709551616
|
|
|
22
35
|
box.dispose();
|
|
23
36
|
```
|
|
24
37
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
`pip install` works for packages with C, Cython, Rust or Meson extensions. A
|
|
28
|
-
wheel built for this runtime is used when one exists; otherwise one is built
|
|
29
|
-
from source, from a recipe generated out of what PyPI and the package already
|
|
30
|
-
state.
|
|
31
|
-
|
|
32
|
-
```js
|
|
33
|
-
configurePython({ buildFromSource: true }); // build here (Node)
|
|
34
|
-
configurePython({ buildFromSource: "http://localhost:4180/build" }); // ask a builder
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
Browsers have no compiler, so they ask a machine that does:
|
|
38
|
-
|
|
39
|
-
```bash
|
|
40
|
-
npx sandboxedjs-build-wheels 4180
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
See `docs/python/build-on-miss.md`, and `docs/python/compatibility.md` for what
|
|
44
|
-
has been built and tested.
|
|
45
|
-
|
|
46
|
-
## Outbound requests from a browser
|
|
47
|
-
|
|
48
|
-
A page may read a response only from a host that sends CORS headers back, and
|
|
49
|
-
most APIs send none — an `Authorization` header alone forces a preflight that
|
|
50
|
-
plenty of them answer with 405. A container whose guest calls a real API
|
|
51
|
-
therefore works under Node and fails in a browser, for a reason that belongs to
|
|
52
|
-
the page rather than to anything in the container.
|
|
53
|
-
|
|
54
|
-
Give it somewhere to send those requests instead. In development that is a
|
|
55
|
-
process:
|
|
56
|
-
|
|
57
|
-
```bash
|
|
58
|
-
npx sandboxedjs-egress 4181 --allow api.openai.com,ollama.com
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
```ts
|
|
62
|
-
const box = await createContainer({
|
|
63
|
-
network: { allowOutbound: true, proxy: "http://localhost:4181" },
|
|
64
|
-
});
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
In an app that already has a server, mount it there instead — the container
|
|
68
|
-
then calls its own origin, so there is no second port and no CORS on the proxy
|
|
69
|
-
itself:
|
|
70
|
-
|
|
71
|
-
```ts
|
|
72
|
-
import { egressNodeHandler } from "sandboxedjs/egress";
|
|
73
|
-
app.post("/egress", egressNodeHandler({ allow: ["ollama.com"] }));
|
|
74
|
-
// createContainer({ network: { allowOutbound: true, proxy: "/egress" } })
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
A static site has no server, which is what the `Request` form is for — a
|
|
78
|
-
Cloudflare Pages Function, a Worker, a route handler:
|
|
79
|
-
|
|
80
|
-
```ts
|
|
81
|
-
// functions/egress.ts
|
|
82
|
-
import { handleEgressRequest } from "sandboxedjs/egress";
|
|
83
|
-
export const onRequest = ({ request }) => handleEgressRequest(request, { allow: ["ollama.com"] });
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
Every exit honours it — `curl`, `wget`, a guest's own `fetch`, and Python's
|
|
87
|
-
sockets — so it means the same thing whatever the project is written in.
|
|
88
|
-
Loopback stays inside the container, and the container's outbound policy still
|
|
89
|
-
applies before anything is handed over: a proxy widens what a *page* can reach,
|
|
90
|
-
not what the container may.
|
|
91
|
-
|
|
92
|
-
Anything that can reach the proxy can make requests through it carrying
|
|
93
|
-
whatever credentials the guest holds, so keep `--allow` set, keep it on
|
|
94
|
-
loopback in development, and put it behind your own authentication in
|
|
95
|
-
production.
|
|
96
|
-
|
|
97
|
-
## Servers inside the container
|
|
98
|
-
|
|
99
|
-
A project split into a frontend and a backend calls `http://localhost:8000/api`
|
|
100
|
-
from the frontend. That address is true inside the container and is what the
|
|
101
|
-
project uses everywhere else, so it is left exactly as written: a script in
|
|
102
|
-
each previewed page rewrites loopback addresses to the preview's own origin,
|
|
103
|
-
and the service worker routes them by the port in the path.
|
|
104
|
-
|
|
105
|
-
That works between pages of the preview, and from another tab of the same
|
|
106
|
-
browser while the page holding the container is open. It does not work from
|
|
107
|
-
outside that browser — Postman, curl, another machine — because the container
|
|
108
|
-
is the tab. There is no server anywhere to reach.
|
|
38
|
+
The same line boots in Node and in a page. Nothing is compiled, nothing is downloaded, nothing
|
|
39
|
+
touches your disk.
|
|
109
40
|
|
|
110
41
|
## Why
|
|
111
42
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
slow, or unavailable
|
|
115
|
-
nothing but memory, and never touches your real filesystem.
|
|
43
|
+
Run untrusted or AI-generated code. Give an agent a real shell. Build a browser IDE that actually
|
|
44
|
+
installs packages and starts servers. Teach Unix without handing out VMs. A real container is too
|
|
45
|
+
heavy, too slow, or simply unavailable — in a browser tab, in a serverless function, in CI.
|
|
116
46
|
|
|
117
47
|
## Install
|
|
118
48
|
|
|
@@ -120,622 +50,154 @@ nothing but memory, and never touches your real filesystem.
|
|
|
120
50
|
npm install sandboxedjs
|
|
121
51
|
```
|
|
122
52
|
|
|
123
|
-
Node 18.17+.
|
|
124
|
-
|
|
125
|
-
## Deep Agents
|
|
126
|
-
|
|
127
|
-
`SandboxedJsBackend` lets [LangChain Deep Agents](https://github.com/langchain-ai/deepagents)
|
|
128
|
-
use a `sandboxedjs` container as its execution and filesystem sandbox. Install the two packages
|
|
129
|
-
in the host application (plus the LangChain model adapter for your provider):
|
|
130
|
-
|
|
131
|
-
```bash
|
|
132
|
-
npm install sandboxedjs deepagents
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
Create a container, pass its backend to `createDeepAgent`, and dispose the container when the run
|
|
136
|
-
is finished:
|
|
137
|
-
|
|
138
|
-
```ts
|
|
139
|
-
import { createContainer } from "sandboxedjs";
|
|
140
|
-
import { SandboxedJsBackend, installSandboxSkills } from "sandboxedjs/agent";
|
|
141
|
-
import { createDeepAgent } from "deepagents";
|
|
142
|
-
|
|
143
|
-
const box = await createContainer({
|
|
144
|
-
cwd: "/app",
|
|
145
|
-
network: { allowOutbound: true }, // required for npm installs or other downloads
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
await installSandboxSkills(box); // optional: installs the bundled sandbox workflow skills
|
|
149
|
-
|
|
150
|
-
// `model` is any chat model supported by Deep Agents, configured by your application.
|
|
151
|
-
const agent = createDeepAgent({
|
|
152
|
-
model,
|
|
153
|
-
backend: new SandboxedJsBackend(box, { cwd: "/app" }),
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
const result = await agent.invoke({
|
|
157
|
-
messages: [{ role: "user", content: "Create and test a small Node.js service in /app." }],
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
box.dispose();
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
The adapter mirrors `deepagents@1.13.2`'s `SandboxBackendProtocolV2` and provides shell execution plus
|
|
164
|
-
`ls`, `read`, `readRaw`, `write`, `edit`, `grep`, `glob`, `delete`, file upload, and file download
|
|
165
|
-
operations. Paths passed to filesystem tools must be absolute. `deepagents` is intentionally not a
|
|
166
|
-
runtime dependency of `sandboxedjs`; applications that use this integration install and configure
|
|
167
|
-
it alongside their model provider.
|
|
53
|
+
Node 18.17+. Pure JavaScript and WebAssembly — no build step, no native modules.
|
|
168
54
|
|
|
169
55
|
## What's inside
|
|
170
56
|
|
|
171
57
|
| | |
|
|
172
58
|
|---|---|
|
|
173
|
-
| **Filesystem** |
|
|
174
|
-
| **Shell** | POSIX `sh`
|
|
175
|
-
| **
|
|
176
|
-
| **Node.js** | Its own
|
|
177
|
-
| **Python** | Source-built CPython 3.13
|
|
178
|
-
| **WebAssembly** | A WASI
|
|
179
|
-
| **FFmpeg** | `ffmpeg`
|
|
180
|
-
|
|
|
181
|
-
| **Networking** | Virtual interfaces, `/etc/hosts` resolution, in-container HTTP servers, an optional bridge to a real host port |
|
|
182
|
-
| **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](#the-in-container-user-model-does-not-constrain-nodejs) |
|
|
59
|
+
| **Filesystem** | A full FHS tree in memory (`/etc`, `/usr`, `/var`, `/home`), permissions, ownership, symlinks, hard links, `umask` |
|
|
60
|
+
| **Shell** | POSIX `sh` — pipes, redirection, here-docs, globbing, functions, loops, job control, traps, `[[ ]]`, `(( ))` |
|
|
61
|
+
| **Commands** | 160 programs: `ls cat grep sed awk find sort tar gzip curl wget diff sha256sum ps top` … |
|
|
62
|
+
| **Node.js** | Its own runtime reporting v22.12.0 — CommonJS *and* ESM, `http`, `fs`, streams, `child_process`, npm and npx |
|
|
63
|
+
| **Python** | Source-built CPython 3.13 (WASM) on the same filesystem, with `pip` |
|
|
64
|
+
| **WebAssembly** | A WASI preview1 host: any `wasm32-wasi` binary runs as an ordinary process |
|
|
65
|
+
| **FFmpeg** | `ffmpeg` / `ffprobe` 5.1 over container files — [optional install](#video-and-audio) |
|
|
66
|
+
| **Network** | In-container HTTP servers, loopback, live previews, and a proxy for real outbound calls |
|
|
183
67
|
|
|
184
|
-
|
|
185
|
-
|
|
68
|
+
One filesystem underneath all of it: a file written by `echo` is read by `require('fs')` and by
|
|
69
|
+
Python's `open()`, in any direction.
|
|
186
70
|
|
|
187
|
-
##
|
|
188
|
-
|
|
189
|
-
### Booting
|
|
71
|
+
## Quick tour
|
|
190
72
|
|
|
191
73
|
```ts
|
|
192
|
-
const box = await createContainer({
|
|
193
|
-
files: { "/app/index.js": "…" }, // seed the filesystem
|
|
194
|
-
cwd: "/app", // default working directory
|
|
195
|
-
hostname: "sandbox",
|
|
196
|
-
user: "root", // or a name → uid 1000 with sudo
|
|
197
|
-
env: { NODE_ENV: "production" },
|
|
198
|
-
memory: 2 * 1024 ** 3, // what `free` and /proc/meminfo report
|
|
199
|
-
cpus: 4, // what `nproc` reports
|
|
200
|
-
network: { allowOutbound: false }, // outbound is off by default
|
|
201
|
-
timeoutMs: 30_000, // default limit for exec()
|
|
202
|
-
});
|
|
203
|
-
```
|
|
204
|
-
|
|
205
|
-
`files` keys may be absolute or relative to `cwd`; parent directories are created for you, and
|
|
206
|
-
values may be strings or `Uint8Array`s. That is the quickest way to drop a whole project in.
|
|
207
|
-
|
|
208
|
-
### Running commands
|
|
209
|
-
|
|
210
|
-
```ts
|
|
211
|
-
const { stdout, stderr, exitCode, output } = await box.exec("grep -c . /etc/passwd");
|
|
74
|
+
const box = await createContainer({ cwd: "/app", network: { allowOutbound: true } });
|
|
212
75
|
|
|
213
|
-
await box.exec("
|
|
214
|
-
|
|
215
|
-
await box.
|
|
216
|
-
await box.exec("sleep 60", { timeoutMs: 1000 }); // → { timedOut: true }
|
|
76
|
+
await box.exec("npm install express", { cwd: "/app", timeoutMs: 300_000 });
|
|
77
|
+
box.spawn("node server.js", { cwd: "/app" });
|
|
78
|
+
await box.waitForPort(3000);
|
|
217
79
|
|
|
218
|
-
await box.
|
|
80
|
+
const res = await box.request(3000, { path: "/api" }); // talk to it from your code
|
|
81
|
+
console.log(res.status, res.json());
|
|
219
82
|
```
|
|
220
83
|
|
|
221
|
-
|
|
84
|
+
A stateful shell, when `exec` alone is too forgetful:
|
|
222
85
|
|
|
223
86
|
```ts
|
|
224
87
|
const session = box.session();
|
|
225
88
|
await session.run("cd /app");
|
|
226
89
|
await session.run("export TOKEN=abc");
|
|
227
|
-
await session.run("echo $TOKEN in $(pwd)");
|
|
228
|
-
```
|
|
229
|
-
|
|
230
|
-
### Long-running processes
|
|
231
|
-
|
|
232
|
-
A job put in the background with `&` keeps running after the command that started it returns,
|
|
233
|
-
even from a stateless `exec`, and stops with `kill %N` in a session or when the container is
|
|
234
|
-
disposed:
|
|
235
|
-
|
|
236
|
-
```ts
|
|
237
|
-
await box.exec("node server.js > /tmp/server.log 2>&1 &", { cwd: "/app" });
|
|
238
|
-
await box.waitForPort(3000);
|
|
239
|
-
```
|
|
240
|
-
|
|
241
|
-
To hold the process yourself, spawn it:
|
|
242
|
-
|
|
243
|
-
```ts
|
|
244
|
-
const proc = box.spawn("node server.js", { cwd: "/app" });
|
|
245
|
-
|
|
246
|
-
for (;;) {
|
|
247
|
-
const line = await proc.stdout.readLine();
|
|
248
|
-
if (line === null) break;
|
|
249
|
-
console.log("[server]", line);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
proc.kill();
|
|
253
|
-
```
|
|
254
|
-
|
|
255
|
-
### Servers
|
|
256
|
-
|
|
257
|
-
An HTTP server started inside the container is reachable three ways:
|
|
258
|
-
|
|
259
|
-
```ts
|
|
260
|
-
box.spawn("node /app/server.js");
|
|
261
|
-
await box.waitForPort(3000);
|
|
262
|
-
|
|
263
|
-
// 1. programmatically
|
|
264
|
-
const res = await box.request(3000, { path: "/api", method: "POST", body: "{}" });
|
|
265
|
-
console.log(res.status, res.json());
|
|
266
|
-
|
|
267
|
-
// 2. from inside, with the usual tools
|
|
268
|
-
await box.exec("curl -s localhost:3000/api");
|
|
269
|
-
|
|
270
|
-
// 3. from your machine or a browser
|
|
271
|
-
const bridge = await box.expose(3000);
|
|
272
|
-
console.log(bridge.url); // http://127.0.0.1:54321
|
|
273
|
-
await bridge.close();
|
|
274
|
-
```
|
|
275
|
-
|
|
276
|
-
### Showing a live preview in an IDE
|
|
277
|
-
|
|
278
|
-
A container's port is virtual. Its printed `http://localhost:5173` is not automatically
|
|
279
|
-
reachable from your browser. Render a whole site in an iframe using the bridge for your host:
|
|
280
|
-
|
|
281
|
-
| Container host | API | Browser-facing URL |
|
|
282
|
-
| --- | --- | --- |
|
|
283
|
-
| Browser only | `createPreview(box)` | A service-worker URL from `preview.urlFor(port)` |
|
|
284
|
-
| Node.js | `box.expose(port)` | A real loopback HTTP URL from `bridge.url` |
|
|
285
|
-
|
|
286
|
-
Browser-only example, after starting a server in `box`:
|
|
287
|
-
|
|
288
|
-
```ts
|
|
289
|
-
import { createPreview } from 'sandboxedjs';
|
|
290
|
-
if (!(await box.waitForPort(5173, { timeoutMs: 60_000 }))) {
|
|
291
|
-
throw new Error('Server did not start; inspect its logs');
|
|
292
|
-
}
|
|
293
|
-
const preview = await createPreview(box);
|
|
294
|
-
if (!preview) throw new Error('Secure context and service-worker support required');
|
|
295
|
-
iframe.src = preview.urlFor(5173);
|
|
296
|
-
// On teardown: remove the iframe, await preview.dispose(), then dispose the container.
|
|
297
|
-
```
|
|
298
|
-
|
|
299
|
-
The service worker routes that iframe's HTML, scripts, styles and API requests into the
|
|
300
|
-
container without rewriting the site's asset paths. Keep its owner page alive.
|
|
301
|
-
|
|
302
|
-
**Trust and scope:** this browser preview runs on the host page's origin. It is for trusted
|
|
303
|
-
code, not a security boundary for arbitrary projects. Both preview bridges handle HTTP;
|
|
304
|
-
WebSocket HMR is not implemented, so reload the iframe after changes.
|
|
305
|
-
|
|
306
|
-
For complete browser and Node examples, IDE lifecycle/port selection, hosting requirements,
|
|
307
|
-
single-response rendering and troubleshooting, see [Server preview integration](docs/server-previews.md).
|
|
308
|
-
For the startup bug investigation and validation, see [Runtime lifecycle fixes](docs/runtime-lifecycle-fixes.md).
|
|
309
|
-
|
|
310
|
-
### npm and npx
|
|
311
|
-
|
|
312
|
-
> **Anything that downloads needs `network: { allowOutbound: true }`.**
|
|
313
|
-
> Outbound access is off by default, so a fresh container cannot reach
|
|
314
|
-
> registry.npmjs.org. `npm install` and `npx <not-yet-installed>` will fail
|
|
315
|
-
> until you turn it on. This is the single most common surprise — if a package
|
|
316
|
-
> command is failing, check this first.
|
|
317
|
-
|
|
318
|
-
```ts
|
|
319
|
-
const box = await createContainer({
|
|
320
|
-
cwd: "/app",
|
|
321
|
-
network: { allowOutbound: true }, // ← without this, npm/npx cannot install
|
|
322
|
-
files: {
|
|
323
|
-
"/app/package.json": JSON.stringify({
|
|
324
|
-
name: "api",
|
|
325
|
-
scripts: { start: "node server.js" },
|
|
326
|
-
dependencies: { express: "^4.19.2" },
|
|
327
|
-
}),
|
|
328
|
-
"/app/server.js": `
|
|
329
|
-
const express = require('express');
|
|
330
|
-
const app = express();
|
|
331
|
-
app.get('/', (req, res) => res.json({ ok: true }));
|
|
332
|
-
app.listen(3000);
|
|
333
|
-
`,
|
|
334
|
-
},
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
await box.exec("npm install", { cwd: "/app", timeoutMs: 300_000 });
|
|
338
|
-
box.spawn("npm start", { cwd: "/app" });
|
|
339
|
-
await box.waitForPort(3000);
|
|
340
|
-
```
|
|
341
|
-
|
|
342
|
-
`yarn` and `pnpm` map onto the same installer. `apt`/`apt-get` reports the built-in package set
|
|
343
|
-
rather than pretending to download Debian archives.
|
|
344
|
-
|
|
345
|
-
#### npx
|
|
346
|
-
|
|
347
|
-
`npx` works the way you expect: it runs a local binary if there is one, and otherwise installs
|
|
348
|
-
the package first.
|
|
349
|
-
|
|
350
|
-
```ts
|
|
351
|
-
const box = await createContainer({ cwd: "/app", network: { allowOutbound: true } });
|
|
352
|
-
|
|
353
|
-
await box.exec("npx cowsay hello"); // installs cowsay, then runs it
|
|
354
|
-
await box.exec("npx cowsay hello"); // second time: instant, already installed
|
|
355
|
-
await box.exec("npx sharjeelbaig"); // any package with a bin works
|
|
356
|
-
await box.exec("npx -p typescript tsc -v"); // package name ≠ command name
|
|
357
|
-
await box.exec("npx prettier@3.3.3 --check ."); // pin a version
|
|
358
|
-
await box.exec("npx --no-install eslint"); // fail instead of installing
|
|
359
|
-
```
|
|
360
|
-
|
|
361
|
-
Install progress goes to stderr, so `npx cowsay moo | head -3` pipes cleanly.
|
|
362
|
-
|
|
363
|
-
Without `allowOutbound`, a *not-yet-installed* command fails with a message that names the real
|
|
364
|
-
problem:
|
|
365
|
-
|
|
366
|
-
```
|
|
367
|
-
npx: could not determine executable to run: cowsay
|
|
368
|
-
npx: 'cowsay' is not installed, and installing it needs network access.
|
|
369
|
-
npx: Outbound network access is disabled for this container.
|
|
370
|
-
npx: Enable it with createContainer({ network: { allowOutbound: true } }).
|
|
371
|
-
```
|
|
372
|
-
|
|
373
|
-
Commands already present — anything in `node_modules/.bin` or on `$PATH` — still run offline.
|
|
374
|
-
|
|
375
|
-
### Walkthrough: Hello React, in your browser
|
|
376
|
-
|
|
377
|
-
End to end — build a React app inside the container and open it in your own browser. Copy this
|
|
378
|
-
into `hello-react.mjs` and run `node hello-react.mjs`.
|
|
379
|
-
|
|
380
|
-
React itself comes from a CDN through an import map, so there is no bundler to configure; the
|
|
381
|
-
container only has to compile JSX and serve files.
|
|
382
|
-
|
|
383
|
-
```js
|
|
384
|
-
import { createContainer } from "sandboxedjs";
|
|
385
|
-
|
|
386
|
-
const box = await createContainer({
|
|
387
|
-
cwd: "/app",
|
|
388
|
-
network: { allowOutbound: true }, // needed to install the JSX compiler
|
|
389
|
-
files: {
|
|
390
|
-
// 1. The React component, in real JSX.
|
|
391
|
-
"/app/src/App.jsx": `
|
|
392
|
-
import { useState } from 'react';
|
|
393
|
-
|
|
394
|
-
export default function App() {
|
|
395
|
-
const [name, setName] = useState('world');
|
|
396
|
-
return (
|
|
397
|
-
<main style={{ fontFamily: 'system-ui', padding: '3rem' }}>
|
|
398
|
-
<h1>Hello, {name}!</h1>
|
|
399
|
-
<input value={name} onChange={(e) => setName(e.target.value)} />
|
|
400
|
-
</main>
|
|
401
|
-
);
|
|
402
|
-
}
|
|
403
|
-
`,
|
|
404
|
-
"/app/src/main.jsx": `
|
|
405
|
-
import { createRoot } from 'react-dom/client';
|
|
406
|
-
import App from './App.js';
|
|
407
|
-
createRoot(document.getElementById('root')).render(<App />);
|
|
408
|
-
`,
|
|
409
|
-
|
|
410
|
-
// 2. The page. React comes from a CDN via an import map.
|
|
411
|
-
"/app/public/index.html": `<!doctype html>
|
|
412
|
-
<html>
|
|
413
|
-
<head>
|
|
414
|
-
<meta charset="utf-8" />
|
|
415
|
-
<title>Hello React</title>
|
|
416
|
-
<script type="importmap">
|
|
417
|
-
{"imports": {
|
|
418
|
-
"react": "https://esm.sh/react@18.3.1",
|
|
419
|
-
"react/jsx-runtime": "https://esm.sh/react@18.3.1/jsx-runtime",
|
|
420
|
-
"react-dom/client": "https://esm.sh/react-dom@18.3.1/client"
|
|
421
|
-
}}
|
|
422
|
-
</script>
|
|
423
|
-
</head>
|
|
424
|
-
<body>
|
|
425
|
-
<div id="root"></div>
|
|
426
|
-
<script type="module" src="/main.js"></script>
|
|
427
|
-
</body>
|
|
428
|
-
</html>`,
|
|
429
|
-
|
|
430
|
-
// 3. Compile every .jsx in src/ to plain ES modules in public/.
|
|
431
|
-
"/app/build.js": `
|
|
432
|
-
const Babel = require('@babel/standalone');
|
|
433
|
-
const fs = require('fs');
|
|
434
|
-
|
|
435
|
-
for (const file of fs.readdirSync('/app/src')) {
|
|
436
|
-
if (!file.endsWith('.jsx')) continue;
|
|
437
|
-
const { code } = Babel.transform(fs.readFileSync('/app/src/' + file, 'utf8'), {
|
|
438
|
-
filename: file,
|
|
439
|
-
presets: [['react', { runtime: 'automatic' }]],
|
|
440
|
-
sourceType: 'module',
|
|
441
|
-
});
|
|
442
|
-
fs.writeFileSync('/app/public/' + file.replace('.jsx', '.js'), code);
|
|
443
|
-
console.log('compiled', file);
|
|
444
|
-
}
|
|
445
|
-
`,
|
|
446
|
-
|
|
447
|
-
// 4. A plain static server.
|
|
448
|
-
"/app/server.js": `
|
|
449
|
-
const http = require('http');
|
|
450
|
-
const fs = require('fs');
|
|
451
|
-
const path = require('path');
|
|
452
|
-
|
|
453
|
-
http.createServer((req, res) => {
|
|
454
|
-
const url = req.url === '/' ? '/index.html' : req.url;
|
|
455
|
-
const file = path.join('/app/public', url);
|
|
456
|
-
if (!fs.existsSync(file)) { res.writeHead(404); res.end('not found'); return; }
|
|
457
|
-
const type = url.endsWith('.html') ? 'text/html' : 'text/javascript';
|
|
458
|
-
res.writeHead(200, { 'Content-Type': type });
|
|
459
|
-
res.end(fs.readFileSync(file));
|
|
460
|
-
}).listen(5173, () => console.log('listening on 5173'));
|
|
461
|
-
`,
|
|
462
|
-
},
|
|
463
|
-
});
|
|
464
|
-
|
|
465
|
-
// Install the compiler, compile, serve.
|
|
466
|
-
await box.exec("npm install @babel/standalone", { cwd: "/app", timeoutMs: 600_000 });
|
|
467
|
-
console.log((await box.exec("node build.js", { cwd: "/app" })).output);
|
|
468
|
-
box.spawn("node server.js", { cwd: "/app" });
|
|
469
|
-
await box.waitForPort(5173);
|
|
470
|
-
|
|
471
|
-
// Publish it on a real host port and open that URL in your browser.
|
|
472
|
-
const bridge = await box.expose(5173, { hostPort: 5173 });
|
|
473
|
-
console.log(`open ${bridge.url}`);
|
|
90
|
+
await session.run("echo $TOKEN in $(pwd)"); // → abc in /app
|
|
474
91
|
```
|
|
475
92
|
|
|
476
|
-
|
|
477
|
-
compiled App.jsx
|
|
478
|
-
compiled main.jsx
|
|
479
|
-
open http://127.0.0.1:5173
|
|
480
|
-
```
|
|
481
|
-
|
|
482
|
-
Open that URL and you get a working React app — typing in the input updates the heading. The
|
|
483
|
-
JSX was compiled inside the container, the files live only in memory, and nothing was written to
|
|
484
|
-
your disk.
|
|
485
|
-
|
|
486
|
-
`examples/03-react-app.mjs` is the same idea with a nicer component and a `SIGINT` handler.
|
|
487
|
-
|
|
488
|
-
### Python
|
|
489
|
-
|
|
490
|
-
Python is the bundled, source-built CPython 3.13 WebAssembly runtime mounted on the container's filesystem:
|
|
491
|
-
|
|
492
|
-
```ts
|
|
493
|
-
await box.exec("python3 -c \"print(open('/etc/hostname').read())\"");
|
|
494
|
-
await box.exec("python3 /app/script.py arg1 arg2");
|
|
495
|
-
await box.exec("echo '1 2 3' | python3 -c \"import sys; print(sum(map(int, sys.stdin.read().split())))\"");
|
|
496
|
-
```
|
|
497
|
-
|
|
498
|
-
The bundled standard library includes `json`, `re`, `os`, `sys`, `math`, `random`, `hashlib`,
|
|
499
|
-
`binascii`, `struct`, `time`, `collections`, `itertools`, `functools`, `asyncio` and more. It is
|
|
500
|
-
It is real CPython, so `sqlite3`, `dataclasses`, `decimal`, `typing`, `ssl`, and
|
|
501
|
-
`asyncio` work. `pip` installs compatible wheels into the same filesystem and
|
|
502
|
-
creates their `console_scripts` in `/usr/local/bin` (with outbound networking
|
|
503
|
-
enabled). Native extensions still need an Emscripten-compatible build.
|
|
504
|
-
|
|
505
|
-
### WebAssembly binaries
|
|
506
|
-
|
|
507
|
-
Anything compiled to `wasm32-wasi` runs as a normal container process — same filesystem, same
|
|
508
|
-
environment, same pipes:
|
|
509
|
-
|
|
510
|
-
```ts
|
|
511
|
-
await box.fs.writeFile("/usr/local/bin/tool", await readFile("tool.wasm"), { mode: 0o755 });
|
|
512
|
-
await box.exec("tool --version");
|
|
513
|
-
await box.exec("cat data.csv | tool summarise | sort");
|
|
514
|
-
```
|
|
515
|
-
|
|
516
|
-
An executable `.wasm` file is dispatched automatically, the way a `#!` script is — `./tool.wasm`
|
|
517
|
-
runs without naming an interpreter, and a binary installed into `$PATH` under a bare name is
|
|
518
|
-
found by its magic number. You can also invoke the interpreter directly:
|
|
519
|
-
|
|
520
|
-
```
|
|
521
|
-
wasi [--dir PATH] [--mapdir GUEST=PATH] [--env K=V] MODULE.wasm [args...]
|
|
522
|
-
```
|
|
523
|
-
|
|
524
|
-
By default the module sees the whole filesystem. `--dir` and `--mapdir` replace that with exactly
|
|
525
|
-
what you list, and a path that climbs out of a preopened directory is refused with `ENOTCAPABLE`.
|
|
526
|
-
|
|
527
|
-
Building one, with any wasi-sdk clang:
|
|
528
|
-
|
|
529
|
-
```bash
|
|
530
|
-
clang --target=wasm32-wasip1 --sysroot="$WASI_SYSROOT" -O2 tool.c -o tool.wasm
|
|
531
|
-
```
|
|
532
|
-
|
|
533
|
-
Two things to know, both of which match how `wasmtime` behaves:
|
|
534
|
-
|
|
535
|
-
- **A WASI program has no working directory.** wasi-libc fixes its own at `/`, so a guest's
|
|
536
|
-
relative path resolves from the root. Pass absolute paths.
|
|
537
|
-
- **Sockets report `ENOTSUP`.** This container's network is an HTTP router rather than a packet
|
|
538
|
-
path, so a guest that wants to `bind()` cannot. Files, clocks, randomness, arguments,
|
|
539
|
-
environment and standard I/O all work.
|
|
540
|
-
|
|
541
|
-
Standard input is drained before the module starts, because WASI's `fd_read` is synchronous and
|
|
542
|
-
the container's streams are not — so piping and redirection work, but a wasm program reading a
|
|
543
|
-
live terminal sees end-of-file.
|
|
544
|
-
|
|
545
|
-
### Filesystem from the host
|
|
93
|
+
Files in and out, snapshots, and a terminal you can wire to xterm.js:
|
|
546
94
|
|
|
547
95
|
```ts
|
|
548
96
|
await box.fs.writeFile("/app/config.json", JSON.stringify(config));
|
|
549
|
-
const
|
|
550
|
-
const entries = await box.fs.readdir("/app");
|
|
551
|
-
const { files, bytes } = await box.fs.usage("/app");
|
|
552
|
-
|
|
553
|
-
await box.copyIn("./my-project", "/app"); // host → container
|
|
554
|
-
await box.copyOut("/app/dist", "./dist"); // container → host
|
|
555
|
-
```
|
|
556
|
-
|
|
557
|
-
### Snapshots
|
|
558
|
-
|
|
559
|
-
```ts
|
|
560
|
-
const snapshot = box.snapshot(); // serialisable
|
|
561
|
-
await box.restore(snapshot);
|
|
562
|
-
```
|
|
563
|
-
|
|
564
|
-
### Interactive terminals
|
|
565
|
-
|
|
566
|
-
`Terminal` is transport-agnostic: feed it keystrokes, it hands you back what to display. That
|
|
567
|
-
works for a real TTY and for xterm.js in a browser alike.
|
|
97
|
+
const snapshot = box.snapshot(); // serialisable; restore() later
|
|
568
98
|
|
|
569
|
-
```ts
|
|
570
99
|
import { Terminal } from "sandboxedjs";
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
write: (data) => xterm.write(data),
|
|
574
|
-
columns: 80,
|
|
575
|
-
rows: 24,
|
|
576
|
-
});
|
|
577
|
-
xterm.onData((data) => terminal.input(data));
|
|
100
|
+
const terminal = new Terminal(box.session(), { write: (d) => xterm.write(d), columns: 80, rows: 24 });
|
|
101
|
+
xterm.onData((d) => terminal.input(d));
|
|
578
102
|
terminal.start();
|
|
579
103
|
```
|
|
580
104
|
|
|
581
|
-
|
|
582
|
-
|
|
105
|
+
> **Outbound access is off by default.** `npm install`, `pip install` and `npx <new tool>` all fail
|
|
106
|
+
> until you pass `network: { allowOutbound: true }`. This is the single most common surprise.
|
|
583
107
|
|
|
584
|
-
##
|
|
108
|
+
## Running a full-stack project in a browser
|
|
585
109
|
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
npx sandboxedjs # interactive shell
|
|
589
|
-
npx sandboxedjs -c 'ls -la /etc' # one command
|
|
590
|
-
npx sandboxedjs script.sh # run a script
|
|
591
|
-
npx sandboxedjs -v ./app:/app -w /app # mount a host directory
|
|
592
|
-
npx sandboxedjs --network -p 3000 # allow outbound, publish a port
|
|
593
|
-
```
|
|
594
|
-
|
|
595
|
-
Run `sandboxedjs --help` for the full list.
|
|
110
|
+
This is the case most people arrive for: a Vite frontend and a Python backend, in one container, in
|
|
111
|
+
a tab — the frontend calling `http://localhost:8000`, the backend calling a real API.
|
|
596
112
|
|
|
597
|
-
|
|
113
|
+
Two different problems hide in that sentence, and SandboxedJS solves them in two different ways.
|
|
598
114
|
|
|
599
|
-
|
|
600
|
-
line is measured on the spot rather than claimed, so a runtime that is missing says so:
|
|
601
|
-
|
|
602
|
-
```
|
|
603
|
-
sandboxedjs — a Linux-like container inside Node.js
|
|
115
|
+
### 1. Frontend → backend: nothing to configure
|
|
604
116
|
|
|
605
|
-
node v22.12.0 npm, require, http servers
|
|
606
|
-
python3 CPython 3.13 only WebAssembly-built C extensions
|
|
607
|
-
ffmpeg 5.1.4 video and audio
|
|
608
|
-
wasi preview1 run wasm32-wasi binaries; ./tool.wasm works
|
|
609
|
-
network on npm install reaches the real registry
|
|
610
117
|
```
|
|
611
|
-
|
|
612
|
-
It needs no project and no files — it is meant for finding the edges. Because installing
|
|
613
|
-
packages is most of what people want to test, `--repl` allows outbound access; pass
|
|
614
|
-
`--no-network` to take it away and watch what breaks.
|
|
615
|
-
|
|
616
|
-
## Security
|
|
617
|
-
|
|
618
|
-
The container has no access to your filesystem, environment, or network unless you grant it:
|
|
619
|
-
|
|
620
|
-
- The filesystem is entirely in memory. Code inside cannot read or write a host path — there is
|
|
621
|
-
no `/Users`, no `/home/you`, no way to reach one.
|
|
622
|
-
- Outbound network access is **off by default**; `curl https://…` fails until you pass
|
|
623
|
-
`network: { allowOutbound: true }`, optionally narrowed with `allowedHosts`. The same policy
|
|
624
|
-
binds a program's own `fetch`, `http`, `https` and `WebSocket`: a refused request fails with
|
|
625
|
-
`ENETUNREACH`. `localhost` and `127.0.0.1` always mean the container's own servers — never the
|
|
626
|
-
host's.
|
|
627
|
-
- Host files enter only through `files`, `mount()` or `copyIn()`, and leave only through
|
|
628
|
-
`copyOut()` or `fs.readFile()`.
|
|
629
|
-
- `timeoutMs` bounds runaway commands, and `exec` settles even when a process ignores its kill
|
|
630
|
-
signal.
|
|
631
|
-
|
|
632
|
-
### The in-container user model does not constrain Node.js
|
|
633
|
-
|
|
634
|
-
This one matters, so it gets its own heading. The Unix permission layer is enforced for the
|
|
635
|
-
shell, the coreutils and Python:
|
|
636
|
-
|
|
637
|
-
```ts
|
|
638
|
-
await box.exec("cat /root/secret", { user: "agent" }); // Permission denied
|
|
639
|
-
await box.exec("python3 -c \"open('/etc/passwd','a')\"", { user: "agent" }); // OSError
|
|
118
|
+
frontend (:3000) ──▶ http://localhost:8000/agent/run ──▶ FastAPI (:8000)
|
|
640
119
|
```
|
|
641
120
|
|
|
642
|
-
|
|
643
|
-
|
|
121
|
+
Both servers are inside the container, so `localhost:8000` is *true there* — it means the
|
|
122
|
+
container's own backend, never your machine's. Leave the URL exactly as the project writes it. In a
|
|
123
|
+
live preview, an injected script rewrites loopback addresses to the preview's origin and the
|
|
124
|
+
service worker routes them back in by port, so the same code works in the frame without a proxy
|
|
125
|
+
config, a rewrite rule or an environment variable.
|
|
644
126
|
|
|
645
127
|
```ts
|
|
646
|
-
|
|
647
|
-
```
|
|
128
|
+
import { createContainer, createPreview } from "sandboxedjs";
|
|
648
129
|
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
something, keep it out of the container rather than relying on file modes.
|
|
130
|
+
// `allowOutbound` is still what lets the project install and call out at all;
|
|
131
|
+
// the proxy is how those calls leave a browser, not permission to make them.
|
|
132
|
+
const box = await createContainer({ files: project, network: { allowOutbound: true } });
|
|
653
133
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
then, do not treat the container as a boundary against hostile code.
|
|
134
|
+
await box.exec("pip install -r requirements.txt", { cwd: "/app/backend", timeoutMs: 600_000 });
|
|
135
|
+
await box.exec("npm install", { cwd: "/app/frontend", timeoutMs: 600_000 });
|
|
657
136
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
This is isolation from mistakes and from ordinary untrusted programs — not a substitute for a
|
|
662
|
-
VM or a real container when facing a determined attacker.
|
|
663
|
-
|
|
664
|
-
## Troubleshooting
|
|
665
|
-
|
|
666
|
-
**`npm install` or `npx <tool>` fails immediately.**
|
|
667
|
-
You almost certainly did not pass `network: { allowOutbound: true }`. It is off by default, so
|
|
668
|
-
the container cannot reach registry.npmjs.org. This is the most common surprise by far.
|
|
137
|
+
box.spawn("fastapi run", { cwd: "/app/backend" });
|
|
138
|
+
box.spawn("npm run dev", { cwd: "/app/frontend" });
|
|
139
|
+
await box.waitForPort(3000, { timeoutMs: 60_000 });
|
|
669
140
|
|
|
670
|
-
|
|
671
|
-
|
|
141
|
+
const preview = await createPreview(box); // null where service workers are unavailable
|
|
142
|
+
iframe.src = preview!.urlFor(3000);
|
|
672
143
|
```
|
|
673
144
|
|
|
674
|
-
|
|
145
|
+
It works between preview pages and from another tab of the same browser while the owner page is
|
|
146
|
+
open. It does not work from Postman, curl or another machine — the container *is* the tab; there is
|
|
147
|
+
no server anywhere to reach.
|
|
675
148
|
|
|
676
|
-
|
|
677
|
-
Check where it landed. `npm install` installs into the nearest `package.json` directory, so run
|
|
678
|
-
it with the right `cwd`:
|
|
149
|
+
### 2. Backend → the internet: one file, once
|
|
679
150
|
|
|
680
|
-
```ts
|
|
681
|
-
await box.exec("npm install express", { cwd: "/app" });
|
|
682
|
-
await box.exec("ls node_modules/.bin", { cwd: "/app" });
|
|
683
151
|
```
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
Wait for it rather than racing it:
|
|
687
|
-
|
|
688
|
-
```ts
|
|
689
|
-
box.spawn("node server.js", { cwd: "/app" });
|
|
690
|
-
await box.waitForPort(3000, { timeoutMs: 60_000 });
|
|
152
|
+
FastAPI ──▶ https://ollama.com ──✗ blocked by the browser (no CORS headers)
|
|
153
|
+
FastAPI ──▶ egress proxy on your own origin ──▶ https://ollama.com ──✓
|
|
691
154
|
```
|
|
692
155
|
|
|
693
|
-
|
|
694
|
-
`
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
**Output looks wrong when piping.**
|
|
698
|
-
Pass `tty: true` only when you want terminal behaviour (colour, column layout). Without it,
|
|
699
|
-
`ls` emits one name per line, like a real pipe.
|
|
156
|
+
A page may only read a response from a host that sends CORS headers, and most APIs send none — an
|
|
157
|
+
`Authorization` header alone forces a preflight plenty of them answer with 405. That is the
|
|
158
|
+
browser's rule about *pages*, not a container limit, and the only way through it is a request made
|
|
159
|
+
somewhere a page is not. So add one to the project you already deploy:
|
|
700
160
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
## Running in a browser
|
|
706
|
-
|
|
707
|
-
The same `createContainer()` API runs under Node and in modern browsers. Browser execution is
|
|
708
|
-
tested under Vite 8; the remaining browser-specific limits are listed below.
|
|
161
|
+
```bash
|
|
162
|
+
npx sandboxedjs-egress init --allow ollama.com,api.openai.com
|
|
163
|
+
```
|
|
709
164
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
165
|
+
That writes a single function file at the path your host serves — Cloudflare Pages, Vercel and
|
|
166
|
+
Netlify are detected, `--target` names one. Nothing else changes: a container in a browser probes
|
|
167
|
+
its own origin for that proxy before giving up, so **no project passes a `proxy` option and no
|
|
168
|
+
project is configured twice.**
|
|
714
169
|
|
|
715
|
-
|
|
716
|
-
|
|
170
|
+
| Where you are | What to do |
|
|
171
|
+
|---|---|
|
|
172
|
+
| Deployed (Pages, Vercel, Netlify) | `npx sandboxedjs-egress init --allow …`, then deploy |
|
|
173
|
+
| Developing | `npx sandboxedjs-egress` — the probe checks its port |
|
|
174
|
+
| You already have a server | `app.use(EGRESS_PATH, egressNodeHandler({ allow: [...] }))`, before any body parser |
|
|
175
|
+
| Using `npx sandboxedjs-serve` | Nothing; it mounts the proxy itself |
|
|
176
|
+
| Node, not a browser | Nothing; there is no CORS to work around |
|
|
717
177
|
|
|
718
178
|
```ts
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
179
|
+
// Your own Express/Node server:
|
|
180
|
+
import { egressNodeHandler, EGRESS_PATH } from "sandboxedjs/egress";
|
|
181
|
+
app.use(EGRESS_PATH, egressNodeHandler({ allow: ["ollama.com"] }));
|
|
722
182
|
|
|
723
|
-
|
|
724
|
-
|
|
183
|
+
// A static host — this is what `init` writes for you:
|
|
184
|
+
import { handleEgressRequest } from "sandboxedjs/egress";
|
|
185
|
+
export const onRequest = ({ request }) => handleEgressRequest(request, { allow: ["ollama.com"] });
|
|
725
186
|
```
|
|
726
187
|
|
|
727
|
-
|
|
728
|
-
|
|
188
|
+
Every exit honours it — `curl`, `wget`, a guest's `fetch`, Python's sockets and `httpx` — so it
|
|
189
|
+
means the same thing whatever the project is written in. Loopback still stays inside the container,
|
|
190
|
+
and the container's own policy still applies first: the proxy widens what a *page* can reach, never
|
|
191
|
+
what the container is allowed to.
|
|
729
192
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
```
|
|
193
|
+
> ⚠️ Anything that can reach the proxy can make requests through it carrying whatever credentials
|
|
194
|
+
> the project holds. Keep the `allow` list set, keep the dev proxy on loopback, and put a deployed
|
|
195
|
+
> one behind your app's authentication.
|
|
196
|
+
|
|
197
|
+
### 3. The page itself must be cross-origin isolated
|
|
736
198
|
|
|
737
|
-
|
|
738
|
-
|
|
199
|
+
Shared memory and threads need two headers on the **host page**, before `createContainer()` runs —
|
|
200
|
+
no meta tag or polyfill can add them later:
|
|
739
201
|
|
|
740
202
|
```ts
|
|
741
203
|
// vite.config.ts
|
|
@@ -744,452 +206,178 @@ export default {
|
|
|
744
206
|
"Cross-Origin-Opener-Policy": "same-origin",
|
|
745
207
|
"Cross-Origin-Embedder-Policy": "require-corp",
|
|
746
208
|
} },
|
|
747
|
-
|
|
748
|
-
optimizeDeps: { exclude: ["@rolldown/binding-wasm32-wasi"] },
|
|
209
|
+
optimizeDeps: { exclude: ["@rolldown/binding-wasm32-wasi"] }, // see below
|
|
749
210
|
};
|
|
750
211
|
```
|
|
751
212
|
|
|
752
|
-
Production hosting
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
`optimizeDeps.exclude` matters just as much, and fails less obviously. The binding starts its
|
|
756
|
-
WASI worker with `new Worker(new URL("@rolldown/binding-wasm32-wasi/wasi-worker-browser.mjs",
|
|
757
|
-
import.meta.url))`. A bundler that pre-bundles the package rewrites that URL to a path inside its
|
|
758
|
-
own dependency cache, where the worker file does not exist — so the dev server answers with
|
|
759
|
-
`index.html` and the browser rejects it for its MIME type, several layers away from anything that
|
|
760
|
-
mentions Rolldown. Excluding the binding leaves it served from `node_modules`, where that URL
|
|
761
|
-
resolves. The compiler is loaded only when an installed
|
|
762
|
-
project actually contains Rolldown, so ordinary container boot does not pay its WASM startup cost.
|
|
763
|
-
|
|
764
|
-
Host-backed compiler promises now count as active process work. The runtime keeps the guest
|
|
765
|
-
alive while WASM initialization/build operations are pending, rather than guessing a startup
|
|
766
|
-
delay from unreferenced timers. Listening servers remain live until closed, unreferenced, or
|
|
767
|
-
killed. Cancellation releases owned child processes and ports, including children behind
|
|
768
|
-
synchronous `npm`/`npx` wrappers; inherited terminal input bypasses a blocked parent worker.
|
|
769
|
-
These are runtime lifecycle rules, not Vite-specific command replacements.
|
|
770
|
-
|
|
771
|
-
**Not done — these things still stand between this and a complete browser IDE.**
|
|
772
|
-
|
|
773
|
-
- *Preview isolation.* `createPreview()` registers the bundled service worker and routes HTTP
|
|
774
|
-
and WebSocket traffic into the container. It is an origin-local preview for trusted code:
|
|
775
|
-
scripts in the frame share your origin. Serve it from a separate origin for anything you
|
|
776
|
-
did not write.
|
|
777
|
-
- *Complete worker isolation.* Worker-capable programs run off the main thread, but host-backed
|
|
778
|
-
compiler modules can require the local runtime. Container code is not a security boundary
|
|
779
|
-
from the host page; do not treat it as one.
|
|
780
|
-
- *esbuild-dependent tools.* The runtime cannot execute any build of esbuild itself: one dlopens a compiled addon,
|
|
781
|
-
the other drives a Go program through facilities the sandbox does not have. On Node it borrows
|
|
782
|
-
the host's `esbuild-wasm`. Vite 8's Rolldown path works in a browser, but tools which call
|
|
783
|
-
esbuild directly still fail until esbuild-wasm runs *inside* the sandbox.
|
|
784
|
-
|
|
785
|
-
Vite prints two warnings about `util` being externalized. They come from `readable-stream`, which
|
|
786
|
-
declares `"util": false` for browsers and falls back on its own; nothing in this package imports
|
|
787
|
-
it.
|
|
788
|
-
|
|
789
|
-
Everything else is already browser-shaped. Nothing in the runtime imports a `node:` builtin except
|
|
790
|
-
through an explicitly Node-only path that returns `null` elsewhere, compression and hashing pick
|
|
791
|
-
their implementation at call time, and the module engine, volume, HTTP stack and npm installer are
|
|
792
|
-
built on `fetch`, `acorn`, `resolve.exports`, `@noble/hashes` and `pako`.
|
|
793
|
-
|
|
794
|
-
**What does not work in a browser:**
|
|
795
|
-
|
|
796
|
-
- `copyIn()` / `copyOut()` — they read and write the host filesystem, which does not exist.
|
|
797
|
-
- `expose()` — it opens a real `node:http` listener. Use `request()` to reach an in-container
|
|
798
|
-
server instead of a host port.
|
|
799
|
-
- The `sandboxedjs` CLI, obviously.
|
|
800
|
-
|
|
801
|
-
Those use dynamic imports, so they only fail if you call them.
|
|
802
|
-
|
|
803
|
-
### Python in a browser
|
|
804
|
-
|
|
805
|
-
Python works lazily in a browser without extra configuration. The owned CPython
|
|
806
|
-
runtime and its pthread worker are shipped with the npm package:
|
|
213
|
+
Production hosting needs the same two headers. For a built app, `npx sandboxedjs-serve ./dist 4173`
|
|
214
|
+
serves it with them already set (and the egress mounted).
|
|
807
215
|
|
|
808
|
-
|
|
809
|
-
const box = await createContainer();
|
|
810
|
-
await box.exec("python3 -c 'import sqlite3; print(sqlite3.sqlite_version)'");
|
|
811
|
-
```
|
|
216
|
+
That is the whole setup: two headers, one `init`, and projects that run unmodified.
|
|
812
217
|
|
|
813
|
-
|
|
218
|
+
## Running in a browser
|
|
814
219
|
|
|
815
|
-
|
|
816
|
-
|
|
220
|
+
`createContainer()` is the same call on both sides — there is no host to pick and no pod to pass.
|
|
221
|
+
Compression and hashing choose their implementation at call time, so no `node:` builtin is pulled in
|
|
222
|
+
when the module loads and bundlers do not trip over it.
|
|
817
223
|
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
});
|
|
224
|
+
**Vite 8 works in a browser host** through Rolldown's official WASI binding. `optimizeDeps.exclude`
|
|
225
|
+
above matters as much as the headers: a pre-bundled binding rewrites its worker URL into the
|
|
226
|
+
dependency cache, where the worker file does not exist, and the failure surfaces as an unrelated
|
|
227
|
+
MIME-type error.
|
|
823
228
|
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
229
|
+
**Python works with no extra configuration** — the CPython runtime and its worker ship in the
|
|
230
|
+
package. `pip install` resolves pure-Python wheels and the ABI-matched builds bundled for Pydantic 2,
|
|
231
|
+
so FastAPI installs as it does anywhere. Packages with C, Cython, Rust or Meson extensions are built
|
|
232
|
+
from source where a wheel does not exist; browsers have no compiler, so they ask one:
|
|
233
|
+
`npx sandboxedjs-build-wheels 4180`. See [docs/python/build-on-miss.md](docs/python/build-on-miss.md)
|
|
234
|
+
and [compatibility](docs/python/compatibility.md).
|
|
827
235
|
|
|
828
|
-
|
|
829
|
-
|
|
236
|
+
**What does not work in a browser:** `copyIn()` / `copyOut()` and `expose()` (they need a real
|
|
237
|
+
filesystem and a real socket), and the CLI. They fail only if you call them.
|
|
830
238
|
|
|
831
|
-
|
|
832
|
-
bundle, so Vite and other bundlers do not need to copy or serve a separate
|
|
833
|
-
wheel directory. `configurePython({ wheelIndex })` remains available for a
|
|
834
|
-
private index containing additional native extensions.
|
|
239
|
+
### Showing what runs
|
|
835
240
|
|
|
836
|
-
|
|
241
|
+
| Need | Use | Runs guest code on your origin? |
|
|
242
|
+
|---|---|---|
|
|
243
|
+
| One response as data | `box.request(port, init)` | No — nothing executes |
|
|
244
|
+
| One response rendered safely | `renderInto(box, el, { port })` | No — opaque-origin iframe, no `allow-same-origin` |
|
|
245
|
+
| A whole site with real URLs | `createPreview(box)` → `urlFor(port)` | **Yes** — trusted code only |
|
|
837
246
|
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
247
|
+
`createPreview` is what makes a dev server work: requests route by *which client is asking*, so
|
|
248
|
+
`/src/main.js` and `/@vite/client` resolve without rewriting anything, and a tunnelled `WebSocket`
|
|
249
|
+
carries HMR. Serve it from a separate origin for code you did not write. On Node, `box.expose(port)`
|
|
250
|
+
gives a real loopback URL instead. Full lifecycle notes: [Server previews](docs/server-previews.md).
|
|
841
251
|
|
|
842
|
-
|
|
252
|
+
## Command line
|
|
843
253
|
|
|
844
|
-
```
|
|
845
|
-
|
|
846
|
-
|
|
254
|
+
```bash
|
|
255
|
+
npx sandboxedjs --repl # explore: every line measured, not claimed
|
|
256
|
+
npx sandboxedjs # interactive shell
|
|
257
|
+
npx sandboxedjs -c 'ls -la /etc' # one command
|
|
258
|
+
npx sandboxedjs -v ./app:/app -w /app # mount a host directory
|
|
259
|
+
npx sandboxedjs --network -p 3000 # allow outbound, publish a port
|
|
260
|
+
|
|
261
|
+
npx sandboxedjs-serve ./dist 4173 # host a built app with the right headers
|
|
262
|
+
npx sandboxedjs-egress init # add the outbound proxy to a deployment
|
|
263
|
+
npx sandboxedjs-build-wheels 4180 # build Python wheels a browser cannot
|
|
847
264
|
```
|
|
848
265
|
|
|
849
|
-
|
|
850
|
-
as bytes and written at the exact container path, including an application-
|
|
851
|
-
generated temporary path:
|
|
266
|
+
## For AI agents
|
|
852
267
|
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
268
|
+
`SandboxedJsBackend` plugs a container into
|
|
269
|
+
[LangChain Deep Agents](https://github.com/langchain-ai/deepagents) as its execution and filesystem
|
|
270
|
+
sandbox — shell plus `ls`, `read`, `write`, `edit`, `grep`, `glob`, `delete`, upload and download
|
|
271
|
+
(absolute paths only). It mirrors `deepagents@1.13.2`'s `SandboxBackendProtocolV2`; `deepagents` is
|
|
272
|
+
not a runtime dependency, so install it alongside your model provider.
|
|
858
273
|
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
see.
|
|
274
|
+
```ts
|
|
275
|
+
import { SandboxedJsBackend, installSandboxSkills } from "sandboxedjs/agent";
|
|
862
276
|
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
const bridge = await box.expose(3000);
|
|
867
|
-
await fetch(`${bridge.url}/api/upload`, { method: "POST", body: file });
|
|
277
|
+
const box = await createContainer({ cwd: "/app", network: { allowOutbound: true } });
|
|
278
|
+
await installSandboxSkills(box);
|
|
279
|
+
const agent = createDeepAgent({ model, backend: new SandboxedJsBackend(box, { cwd: "/app" }) });
|
|
868
280
|
```
|
|
869
281
|
|
|
870
|
-
Either way the file lands on the container's filesystem, and `ffmpeg`, Node and Python all see the
|
|
871
|
-
same bytes. Uploads are held in memory like the rest of the filesystem, so a very large video is
|
|
872
|
-
bounded by RAM.
|
|
873
|
-
|
|
874
282
|
## Video and audio
|
|
875
283
|
|
|
876
|
-
`ffmpeg` and `ffprobe` are FFmpeg 5.1 compiled to WebAssembly, mounted on the container's
|
|
877
|
-
filesystem
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
The runtime is ~31MB of WebAssembly, which is a lot to force on someone who wants a shell, so it
|
|
881
|
-
installs separately. Without it the commands report themselves as missing, exactly as a real
|
|
882
|
-
system reports an uninstalled binary:
|
|
883
|
-
|
|
884
|
-
```bash
|
|
885
|
-
npm install @ffmpeg/core
|
|
886
|
-
```
|
|
284
|
+
`ffmpeg` and `ffprobe` are FFmpeg 5.1 compiled to WebAssembly, mounted on the container's
|
|
285
|
+
filesystem — inputs and outputs are ordinary container files, so pipelines work as usual. It is
|
|
286
|
+
~31 MB of WASM, so it installs separately (`npm install @ffmpeg/core`); without it the commands
|
|
287
|
+
report themselves missing, as a real system does.
|
|
887
288
|
|
|
888
289
|
```js
|
|
889
|
-
const box = await createContainer({ cwd: "/media" });
|
|
890
|
-
|
|
891
|
-
// Make a clip, then transcode it — both files are just container files.
|
|
892
290
|
await box.exec("ffmpeg -f lavfi -i testsrc=size=640x480:rate=25:duration=5 -pix_fmt yuv420p clip.mp4");
|
|
893
291
|
await box.exec("ffmpeg -i clip.mp4 -vf scale=320:-2 -frames:v 1 thumb.png");
|
|
894
|
-
|
|
895
|
-
const thumbnail = await box.fs.readFile("/media/thumb.png");
|
|
896
292
|
```
|
|
897
293
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
half is repeated. Runs are synchronous — a long transcode occupies the thread until it finishes.
|
|
294
|
+
Two caveats: `ffprobe` exits without setting a status, so branch on its output rather than its exit
|
|
295
|
+
code; and there is no hardware acceleration or codec beyond what the WASM build ships.
|
|
901
296
|
|
|
902
|
-
|
|
297
|
+
## Security — read this part
|
|
903
298
|
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
itself reports status correctly and can be relied on in `&&` chains.
|
|
908
|
-
- **No hardware acceleration and no native codecs** beyond what the WebAssembly build ships.
|
|
299
|
+
The container has nothing until you grant it: the filesystem is memory (there is no `/Users` to
|
|
300
|
+
reach), outbound access is off, `localhost` always means the container itself, and host files enter
|
|
301
|
+
only through `files`, `mount()` or `copyIn()`.
|
|
909
302
|
|
|
910
|
-
|
|
303
|
+
What it is **not**:
|
|
911
304
|
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
305
|
+
- **Not a VM.** Everything runs in your JavaScript engine. A true escape is an engine escape. This
|
|
306
|
+
is isolation from mistakes and ordinary untrusted programs, not from a determined attacker.
|
|
307
|
+
- **The user model does not constrain Node.** `user: "agent"` is enforced for the shell, the
|
|
308
|
+
commands and Python — `cat /root/secret` is denied for real. It is *not* enforced for `node`,
|
|
309
|
+
which reaches the volume directly. Model ordinary multi-user behaviour with it; do not treat it
|
|
310
|
+
as a privilege boundary for JavaScript you do not trust.
|
|
311
|
+
- **A preview shares your origin.** `createPreview` serves guest code from the page that registered
|
|
312
|
+
the service worker, so its scripts can reach your DOM, cookies and storage. Use `renderInto` or a
|
|
313
|
+
separate origin for code you did not write.
|
|
915
314
|
|
|
916
|
-
|
|
917
|
-
|
|
315
|
+
By default each guest program does run on its own thread in a Worker, so it cannot reach your page's
|
|
316
|
+
globals and `execSync` works. Without cross-origin isolation (or where a bundler moved the guest
|
|
317
|
+
bundle) the runtime falls back to your realm and reports why; `isolation: "worker"` refuses to boot
|
|
318
|
+
instead of falling back.
|
|
918
319
|
|
|
919
|
-
|
|
920
|
-
const res = await box.request(5173, { path: "/api/items" });
|
|
921
|
-
element.textContent = res.body; // text
|
|
922
|
-
const items = res.json<Item[]>(); // parsed
|
|
923
|
-
const bytes = res.bytes; // exact bytes, for images and source maps
|
|
924
|
-
```
|
|
925
|
-
|
|
926
|
-
**2. One response, rendered — safely.** `renderInto` puts the response in an iframe with
|
|
927
|
-
`sandbox="allow-scripts"` and deliberately *without* `allow-same-origin`. The document lands in an
|
|
928
|
-
opaque origin: its scripts run, and they can reach neither your DOM nor your cookies and storage.
|
|
929
|
-
|
|
930
|
-
```ts
|
|
931
|
-
import { renderInto } from "sandboxedjs";
|
|
932
|
-
await renderInto(box, document.querySelector("#preview")!, { port: 5173 });
|
|
933
|
-
```
|
|
320
|
+
## Known limits
|
|
934
321
|
|
|
935
|
-
|
|
936
|
-
that only that one response exists — a page asking for `/main.js` gets nothing, because there is no
|
|
937
|
-
origin to serve it from. Good for generated HTML, a chart, a rendered document.
|
|
322
|
+
An honest list:
|
|
938
323
|
|
|
939
|
-
**
|
|
940
|
-
|
|
324
|
+
- **No compiled native addons.** A `.node` file cannot load; a package needs a JS or WASM fallback.
|
|
325
|
+
`rollup` and `esbuild` get redirected to `@rollup/wasm-node` and `esbuild-wasm` automatically.
|
|
326
|
+
- **esbuild cannot run *inside* the sandbox**, so tools that call it directly fail. On Node it
|
|
327
|
+
borrows the host's `esbuild-wasm`.
|
|
328
|
+
- **Vite 8 / Rolldown is browser-only.** The Node binding preopens the real filesystem root and
|
|
329
|
+
cannot be handed another, so it looks for your project on the actual disk. Vite 7 works on both.
|
|
330
|
+
Two live browser projects need distinct absolute working directories.
|
|
331
|
+
- **No raw sockets** — `net`, `tls`, TCP, UDP. HTTP servers run on a virtual stack, which is what
|
|
332
|
+
`request()`, previews and `expose()` speak to.
|
|
333
|
+
- **Processes are cooperative.** `kill -9` cannot interrupt a tight synchronous loop; `SIGSTOP`
|
|
334
|
+
marks state. `chroot` runs the command in the target rather than isolating it.
|
|
335
|
+
- **`execSync` needs the worker runtime.** Where it is unavailable, it throws naming the command —
|
|
336
|
+
answer *No* to prompts like `npm create vite`'s "Install and start now?" and run the steps from
|
|
337
|
+
the shell.
|
|
338
|
+
- **`node:test`** covers what test files use (`test`/`describe`/hooks/`mock.fn`, spec and tap
|
|
339
|
+
reports); `run()`, coverage and mock timers are not implemented.
|
|
340
|
+
- **Python has one shared site-packages** — no virtualenvs, no CPython pip. Use a fresh container for
|
|
341
|
+
dependency isolation. Native extensions must build for Emscripten.
|
|
342
|
+
- **`expose()` does not proxy WebSockets**, so HMR does not reach a Node-hosted preview iframe.
|
|
941
343
|
|
|
942
|
-
|
|
943
|
-
import { createPreview } from "sandboxedjs";
|
|
944
|
-
const preview = await createPreview(box); // null where service workers are unavailable
|
|
945
|
-
iframe.src = preview!.urlFor(5173);
|
|
946
|
-
```
|
|
344
|
+
## API at a glance
|
|
947
345
|
|
|
948
|
-
|
|
949
|
-
|
|
346
|
+
`createContainer(options)` takes `files`, `cwd`, `hostname`, `user`, `env`, `memory`, `cpus`,
|
|
347
|
+
`network`, `timezone`, `timeoutMs`, `onStdout` / `onStderr`, `onServerReady`, `isolation`, `pod`
|
|
348
|
+
and `python`.
|
|
950
349
|
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
> page it serves. Use it for code you trust; for anything else, serve the preview from a separate
|
|
955
|
-
> origin (a subdomain pointed at the same app) or stay with option 2.
|
|
350
|
+
A `Container` gives you `exec` · `run` · `spawn` · `session` · `fs` · `mount` · `copyIn` / `copyOut`
|
|
351
|
+
· `request` · `waitForPort` · `expose` · `connect` · `snapshot` / `restore` · `dispose`, plus
|
|
352
|
+
`kernel`, `pod` and `net` as escape hatches.
|
|
956
353
|
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
a `WebSocket` that tunnels through the host page into the container, injected as one `<script>` at
|
|
960
|
-
the top of `<head>`. URLs that do not name the container keep the native implementation.
|
|
354
|
+
Commands are extensible, and a new one is a real file in `/usr/bin` — `which`, `man` and shebang
|
|
355
|
+
dispatch all find it:
|
|
961
356
|
|
|
962
357
|
```ts
|
|
963
|
-
|
|
964
|
-
```
|
|
965
|
-
|
|
966
|
-
This is what makes HMR work. Without it Vite loses more than hot reload: it loses the only channel
|
|
967
|
-
it has for telling a page that its dependency hashes are stale, which it needs after re-optimizing
|
|
968
|
-
dependencies — and the frame is then stranded on `504 Outdated Optimize Dep` with every module
|
|
969
|
-
failing and nothing to explain it. If you turn injection off, pass `onStale` to reload the frame
|
|
970
|
-
yourself.
|
|
971
|
-
|
|
972
|
-
The container side is a real socket, not a Vite shim: `http` servers emit `upgrade` with a duplex
|
|
973
|
-
socket, so `ws` and `socket.io` work unmodified. To speak to one from your own code rather than
|
|
974
|
-
from a previewed page, use `box.connect(port, init, peer)`.
|
|
975
|
-
|
|
976
|
-
Service workers need a secure context, and some embedded browsers disable them entirely — hence
|
|
977
|
-
the `null` return rather than a throw. Responses are served with both
|
|
978
|
-
`Cross-Origin-Resource-Policy` and `Cross-Origin-Embedder-Policy`, so a preview still frames
|
|
979
|
-
correctly inside the cross-origin isolated page that Rolldown requires.
|
|
980
|
-
|
|
981
|
-
### Serving a built browser application
|
|
982
|
-
|
|
983
|
-
The host page must enable cross-origin isolation **before** `createContainer()` runs.
|
|
984
|
-
This is a prerequisite for both synchronous child processes and threaded WASM; it is
|
|
985
|
-
not something `npm create`, `npm install`, or `npm run` inside the container can fix.
|
|
986
|
-
Headers configured on a development server do not carry over to a plain static server.
|
|
358
|
+
import { defineCommand } from "sandboxedjs";
|
|
987
359
|
|
|
988
|
-
|
|
360
|
+
box.kernel.installCommand(defineCommand({
|
|
361
|
+
name: "greet",
|
|
362
|
+
summary: "say hello",
|
|
363
|
+
run: (ctx) => (ctx.line(`hello ${ctx.args[0] ?? "world"}`), 0),
|
|
364
|
+
}));
|
|
989
365
|
|
|
990
|
-
|
|
991
|
-
npx sandboxedjs-serve ./dist 4173
|
|
366
|
+
await box.exec("greet there | tr a-z A-Z"); // → HELLO THERE
|
|
992
367
|
```
|
|
993
368
|
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
to loopback by default and does not serve dotfiles or symlinks outside the web root.
|
|
997
|
-
This is a local static host, not a production deployment server or SPA route rewriter.
|
|
998
|
-
|
|
999
|
-
For a Node host integration (this subpath must not be imported into browser code):
|
|
1000
|
-
|
|
1001
|
-
```ts
|
|
1002
|
-
import { serveBrowserApp, browserIsolationHeaders } from 'sandboxedjs/browser-host';
|
|
1003
|
-
const host = await serveBrowserApp({ directory: './dist', port: 4173 });
|
|
1004
|
-
console.log(host.url);
|
|
1005
|
-
// Or apply browserIsolationHeaders to your existing server's responses.
|
|
1006
|
-
// await host.close();
|
|
1007
|
-
```
|
|
1008
|
-
|
|
1009
|
-
For remote deployments configure HTTPS plus those headers on your actual web server
|
|
1010
|
-
or CDN. Check `crossOriginIsolated` in the page, not just the network response: iframe
|
|
1011
|
-
permissions and embedded browser restrictions can still deny shared memory. A loaded
|
|
1012
|
-
page cannot grant itself this permission with a polyfill or a meta tag. Cross-origin
|
|
1013
|
-
resources also need CORS or an appropriate CORP policy under `require-corp`.
|
|
1014
|
-
|
|
1015
|
-
Require worker support at boot when the application depends on synchronous Node APIs:
|
|
1016
|
-
|
|
1017
|
-
```ts
|
|
1018
|
-
const box = await createContainer({ isolation: 'worker' });
|
|
1019
|
-
```
|
|
369
|
+
`Kernel`, `Vfs`, `Shell`, `Terminal` and `NetworkStack` are exported too, if you want to embed a
|
|
370
|
+
piece rather than the whole system.
|
|
1020
371
|
|
|
1021
|
-
|
|
1022
|
-
The default `isolation: 'auto'` retains compatibility but reports fallback through
|
|
1023
|
-
`onRuntimeFallback(error)` (or `console.warn` if omitted). `isolation: 'realm'` is an
|
|
1024
|
-
explicit opt-out and does not support synchronous child processes.
|
|
372
|
+
## More
|
|
1025
373
|
|
|
1026
|
-
|
|
374
|
+
[`examples/`](./examples) — a REPL, an Express API, a React app, a Python pipeline, an agent
|
|
375
|
+
sandbox, a browser terminal. [`docs/`](./docs) — previews, Python build and compatibility, browser
|
|
376
|
+
runtime architecture, developer tool packs, frontend automation.
|
|
1027
377
|
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
reaches them through a `SharedArrayBuffer` channel it can wait on synchronously.
|
|
1031
|
-
|
|
1032
|
-
Two things follow from that. Guest code no longer shares your page's realm, so it cannot reach
|
|
1033
|
-
your application's globals. And `child_process.execSync`, `spawnSync` and `execFileSync` work —
|
|
1034
|
-
they cannot be expressed any other way, because a synchronous call has to block its caller while
|
|
1035
|
-
the child it is waiting for still makes progress, which is impossible when both are the same
|
|
1036
|
-
thread.
|
|
1037
|
-
|
|
1038
|
-
In default `auto` mode the runtime reports its fallback reason and stays usable:
|
|
1039
|
-
|
|
1040
|
-
| Condition | What happens |
|
|
1041
|
-
|---|---|
|
|
1042
|
-
| `SharedArrayBuffer` unavailable, or the page is not cross-origin isolated | Falls back to the in-realm runtime |
|
|
1043
|
-
| The guest bundle cannot be loaded (a bundler moved or rewrote it) | Falls back to the in-realm runtime |
|
|
1044
|
-
| `modules` supplied by the host | Falls back — live JavaScript objects cannot cross a thread |
|
|
1045
|
-
| The project has `rolldown` installed | *That process* runs in-realm; the rest still get a thread |
|
|
1046
|
-
|
|
1047
|
-
In a browser this needs the same COOP/COEP headers Rolldown does, which is the usual reason to
|
|
1048
|
-
find yourself in the fallback. `createContainer({ isolation: "realm" })` opts out entirely, and
|
|
1049
|
-
`workerUrl` points at the guest bundle when a bundler has moved it. Explicit
|
|
1050
|
-
`isolation: "worker"` rejects at boot instead of taking either environment fallback.
|
|
1051
|
-
Host-native module processes still use the realm runtime; selecting worker mode is
|
|
1052
|
-
not a guarantee that every package executes off the host thread.
|
|
1053
|
-
|
|
1054
|
-
## Known limits
|
|
1055
|
-
|
|
1056
|
-
Honest list of what does not work:
|
|
1057
|
-
|
|
1058
|
-
- **Compiled native addons.** A `.node` file cannot be loaded, so a package that ships one has to
|
|
1059
|
-
have a JavaScript or WebAssembly build to fall back on. `rollup` and `esbuild` do, and the
|
|
1060
|
-
runtime redirects those two names to `@rollup/wasm-node` and `esbuild-wasm` automatically when
|
|
1061
|
-
they are installed. Vite 8 is supported through Rolldown's official WASI build **in a browser
|
|
1062
|
-
host** (with the two configuration steps above). Express, Koa, Fastify-style apps and plain
|
|
1063
|
-
`http` servers also run.
|
|
1064
|
-
- **Vite 8 / Rolldown does not work when the host is Node.** The binding has two builds. The
|
|
1065
|
-
browser one owns a `memfs` volume, which SandboxedJS mirrors the project into; the Node one
|
|
1066
|
-
builds a `node:wasi` instance that preopens the *real* filesystem root, and there is no way to
|
|
1067
|
-
hand it a different one. Rolldown therefore looks for `/app/my-app/index.html` on your actual
|
|
1068
|
-
disk, does not find it, and the dev server answers every request with its fallback page. Vite 7
|
|
1069
|
-
works on both hosts and is what the Node acceptance test pins.
|
|
1070
|
-
- **Concurrent browser Rolldown projects need distinct absolute working directories.** The
|
|
1071
|
-
official binding owns one WASI memfs per page; SandboxedJS mirrors each project into it before
|
|
1072
|
-
startup. Two live projects using the same path such as `/workspace` can overwrite that mirror.
|
|
1073
|
-
- **`child_process` is synchronous only under the Worker pod.** `spawn`, `exec` and `execFile`
|
|
1074
|
-
always work and run through the kernel, so a child sees the same filesystem and coreutils as the
|
|
1075
|
-
shell. `execSync`, `spawnSync` and `execFileSync` need the guest program to be on its own thread
|
|
1076
|
-
— see *Isolation* above. Where it is, they work; where it is not, they throw
|
|
1077
|
-
`ERR_FEATURE_UNAVAILABLE_ON_PLATFORM` naming the command they were asked to run, and the way
|
|
1078
|
-
through is to answer **No** to a prompt like `npm create vite`'s "Install with npm and start
|
|
1079
|
-
now?" and run `npm install && npm run dev` from the shell instead.
|
|
1080
|
-
- **No `net`, `tls`, `worker_threads` or `vm`.** `http` and `https` are served by a virtual stack
|
|
1081
|
-
that `request()` talks to directly, so servers work; raw sockets do not. A program can reach
|
|
1082
|
-
servers anywhere in the container over HTTP (`http.get`, `fetch`), but not open a `WebSocket` to
|
|
1083
|
-
one.
|
|
1084
|
-
- **`node:test` covers what test files use** — `test`/`it`, `describe`, hooks, subtests, `skip`,
|
|
1085
|
-
`todo`, `only`, `mock.fn` and `mock.method`, with `spec` and `tap` reports — and `node --test`
|
|
1086
|
-
finds and runs test files as Node 22 does. `run()`, coverage and mock timers are not
|
|
1087
|
-
implemented. A test file exits when its tests finish, as under `--test-force-exit`.
|
|
1088
|
-
- **On the in-realm runtime, programs share one global object.** A global one program sets is
|
|
1089
|
-
visible to the next. The worker runtime (the default where shared memory is available) gives
|
|
1090
|
-
each program its own.
|
|
1091
|
-
- **Python is source-built CPython/WASM.** Each program gets its own interpreter
|
|
1092
|
-
process worker. Pure-Python wheels install normally; native extensions must
|
|
1093
|
-
be linked or published for Emscripten. The bundled wheel index includes the
|
|
1094
|
-
ABI-matched `pydantic-core` builds required by Pydantic 2, so FastAPI resolves
|
|
1095
|
-
against Pydantic 2 by default. Virtual environments and CPython's real pip
|
|
1096
|
-
are not implemented; the sandbox `pip` installs into one shared site-packages
|
|
1097
|
-
directory, so use a fresh container for dependency isolation.
|
|
1098
|
-
- **No real sockets.** HTTP servers work through the request proxy; raw TCP/UDP does not.
|
|
1099
|
-
- **No real processes.** Processes are cooperative async tasks: `kill -9` cannot interrupt a
|
|
1100
|
-
tight synchronous loop, and `SIGSTOP` only marks state.
|
|
1101
|
-
- **`chroot` does not isolate**; it runs the command with its cwd inside the target.
|
|
1102
|
-
- **`awk`'s `system()`** does not block on the child.
|
|
1103
|
-
- **`expose()` does not proxy WebSockets**, so dev-server HMR and live-reload do not reach a
|
|
1104
|
-
preview iframe. Reload the frame from your IDE after a rebuild instead — see
|
|
1105
|
-
[Showing a live preview in an IDE](#showing-a-live-preview-in-an-ide).
|
|
1106
|
-
|
|
1107
|
-
## API reference
|
|
1108
|
-
|
|
1109
|
-
### `createContainer(options): Promise<Container>`
|
|
1110
|
-
|
|
1111
|
-
| Option | Type | Default | |
|
|
1112
|
-
|---|---|---|---|
|
|
1113
|
-
| `files` | `Record<string, string \| Uint8Array>` | — | Seed the filesystem |
|
|
1114
|
-
| `cwd` | `string` | `"/"` | Default working directory, and base for relative `files` keys |
|
|
1115
|
-
| `hostname` | `string` | `"sandbox"` | |
|
|
1116
|
-
| `user` | `string \| null` | `"root"` | Login user; a non-root name gets uid 1000 and sudo |
|
|
1117
|
-
| `env` | `Record<string, string>` | — | Extra environment variables |
|
|
1118
|
-
| `memory` | `number` | 2 GiB | Reported by `free`, `top`, `/proc/meminfo` |
|
|
1119
|
-
| `cpus` | `number` | `4` | Reported by `nproc`, `/proc/cpuinfo` |
|
|
1120
|
-
| `network` | `NetworkOptions` | outbound off | `{ allowOutbound, allowedHosts, ipv4, gateway }` |
|
|
1121
|
-
| `timezone` | `string` | `"UTC"` | |
|
|
1122
|
-
| `timeoutMs` | `number` | none | Default limit for `exec` |
|
|
1123
|
-
| `onStdout` / `onStderr` | `(chunk: string) => void` | — | Container-wide output taps |
|
|
1124
|
-
| `onServerReady` | `(port, url) => void` | — | Fires when something inside starts listening |
|
|
1125
|
-
| `pod` | `RuntimePod` | booted for you | Share or substitute the JavaScript runtime |
|
|
1126
|
-
| `python` | `PythonOptions` | jsDelivr | Where to load Pyodide from |
|
|
1127
|
-
|
|
1128
|
-
### `Container`
|
|
1129
|
-
|
|
1130
|
-
| Member | |
|
|
1131
|
-
|---|---|
|
|
1132
|
-
| `exec(command, opts?)` | Run a shell command line; returns `{ stdout, stderr, output, exitCode, timedOut, durationMs }` |
|
|
1133
|
-
| `run(argv, opts?)` | Run a program without shell parsing |
|
|
1134
|
-
| `spawn(command, opts?)` | Start a process; returns `{ pid, stdin, stdout, stderr, wait(), kill() }` |
|
|
1135
|
-
| `session(opts?)` | A stateful shell session |
|
|
1136
|
-
| `fs` | `readFile`, `writeFile`, `readdir`, `mkdir`, `rm`, `stat`, `walk`, `usage`, … |
|
|
1137
|
-
| `mount(files, opts?)` | Add files after boot |
|
|
1138
|
-
| `copyIn` / `copyOut` | Move trees between host and container |
|
|
1139
|
-
| `request(port, init?)` | HTTP to an in-container server |
|
|
1140
|
-
| `waitForPort(port, opts?)` | Resolve once something is listening |
|
|
1141
|
-
| `expose(port, opts?)` | Bridge to a real host port |
|
|
1142
|
-
| `snapshot()` / `restore(s)` | Filesystem persistence |
|
|
1143
|
-
| `kernel`, `pod`, `net` | Escape hatches to the internals |
|
|
1144
|
-
| `hostname`, `user`, `cwd`, `env` | What the container was booted with |
|
|
1145
|
-
| `dispose()` | Tear everything down |
|
|
1146
|
-
|
|
1147
|
-
Lower-level pieces — `Kernel`, `Vfs`, `Shell`, `Terminal`, `NetworkStack`, `defineCommand` — are
|
|
1148
|
-
exported too, so you can add your own commands or embed the shell on its own.
|
|
1149
|
-
|
|
1150
|
-
### Adding a command
|
|
1151
|
-
|
|
1152
|
-
```ts
|
|
1153
|
-
import { createContainer, defineCommand } from "sandboxedjs";
|
|
1154
|
-
|
|
1155
|
-
const box = await createContainer();
|
|
1156
|
-
|
|
1157
|
-
box.kernel.installCommand(
|
|
1158
|
-
defineCommand({
|
|
1159
|
-
name: "greet",
|
|
1160
|
-
summary: "say hello",
|
|
1161
|
-
run(ctx) {
|
|
1162
|
-
ctx.line(`hello ${ctx.args[0] ?? "world"}`);
|
|
1163
|
-
return 0;
|
|
1164
|
-
},
|
|
1165
|
-
}),
|
|
1166
|
-
);
|
|
1167
|
-
|
|
1168
|
-
await box.exec("greet there | tr a-z A-Z"); // → HELLO THERE
|
|
1169
|
-
```
|
|
1170
|
-
|
|
1171
|
-
It becomes a real file in `/usr/bin`, so `which greet`, `man greet` and shebang dispatch all work.
|
|
1172
|
-
|
|
1173
|
-
## Examples
|
|
1174
|
-
|
|
1175
|
-
See [`examples/`](./examples): a REPL, an Express API, a React app, a Python data pipeline, an
|
|
1176
|
-
agent sandbox, and a browser terminal.
|
|
378
|
+
Optional packs add local Git (isomorphic-git), embedded Postgres (PGlite), integrity-checked WASI
|
|
379
|
+
commands and frontend automation; see [developer tool packs](docs/developer-tool-packs.md).
|
|
1177
380
|
|
|
1178
381
|
## License
|
|
1179
382
|
|
|
1180
|
-
MIT
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
### Optional developer tool packs
|
|
1184
|
-
|
|
1185
|
-
Host applications can add local Git operations through `createGitCommand`
|
|
1186
|
-
(isomorphic-git) and embedded PostgreSQL SQL through `createSqlCommand`
|
|
1187
|
-
(PGlite). These engines are optional and are not bundled into the core.
|
|
1188
|
-
`installWasmCommands` installs separately distributed, integrity-checked WASI
|
|
1189
|
-
commands. ELF files use registered compatibility, translation and emulation
|
|
1190
|
-
backends. Original experimental x86-64 engines are available as opt-in backends
|
|
1191
|
-
for a small freestanding instruction/syscall subset; see [Original engines](docs/original-x64.md).
|
|
1192
|
-
See [Developer tool packs](docs/developer-tool-packs.md) for examples and limits.
|
|
1193
|
-
`createFrontendPlaywright` adds Playwright-shaped, frontend-only automation of a
|
|
1194
|
-
same-origin iframe using the host browser; see
|
|
1195
|
-
[Frontend automation](docs/frontend-automation.md) for its supported subset.
|
|
383
|
+
MIT — and no dependency carries a stricter one.
|