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