sandboxedjs 0.1.0 → 0.1.2
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 +334 -7
- package/dist/index.cjs +591 -56
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +15 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +591 -56
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -130,12 +130,111 @@ console.log(bridge.url); // http://127.0.0.1:54321
|
|
|
130
130
|
await bridge.close();
|
|
131
131
|
```
|
|
132
132
|
|
|
133
|
-
###
|
|
133
|
+
### Showing a live preview in an IDE
|
|
134
|
+
|
|
135
|
+
This is the StackBlitz/CodeSandbox preview pane: the user runs a dev server inside the
|
|
136
|
+
container, and sees the rendered page, not just logs.
|
|
137
|
+
|
|
138
|
+
**It works today, as an `<iframe>` — not as a `<div>`.** A `<div>` cannot host it: the preview
|
|
139
|
+
is a whole HTML document with its own scripts, styles, `<base>` and routing, and it must not be
|
|
140
|
+
able to reach into your IDE's DOM. An iframe on its own origin is exactly the isolation you
|
|
141
|
+
want, and it is what StackBlitz and CodeSandbox use too.
|
|
142
|
+
|
|
143
|
+
`expose(port)` gives you a real, browser-loadable URL:
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
box.spawn("npm run dev", { cwd: "/app" });
|
|
147
|
+
await box.waitForPort(5173);
|
|
148
|
+
|
|
149
|
+
const preview = await box.expose(5173);
|
|
150
|
+
document.querySelector("#preview").src = preview.url; // an <iframe>
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
A minimal IDE preview pane, in React:
|
|
154
|
+
|
|
155
|
+
```tsx
|
|
156
|
+
function PreviewPane({ box, port }) {
|
|
157
|
+
const [url, setUrl] = useState(null);
|
|
158
|
+
const frame = useRef(null);
|
|
159
|
+
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
let bridge;
|
|
162
|
+
let cancelled = false;
|
|
163
|
+
|
|
164
|
+
(async () => {
|
|
165
|
+
if (!(await box.waitForPort(port, { timeoutMs: 60_000 }))) return;
|
|
166
|
+
bridge = await box.expose(port);
|
|
167
|
+
if (!cancelled) setUrl(bridge.url);
|
|
168
|
+
})();
|
|
169
|
+
|
|
170
|
+
return () => {
|
|
171
|
+
cancelled = true;
|
|
172
|
+
void bridge?.close();
|
|
173
|
+
};
|
|
174
|
+
}, [box, port]);
|
|
175
|
+
|
|
176
|
+
// Call this after a rebuild to refresh the pane.
|
|
177
|
+
const reload = () => {
|
|
178
|
+
if (frame.current) frame.current.src = frame.current.src;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
if (!url) return <div>starting…</div>;
|
|
182
|
+
return <iframe ref={frame} src={url} style={{ width: "100%", height: "100%", border: 0 }} />;
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Pin the port if you want a stable URL across restarts:
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
const preview = await box.expose(5173, { hostPort: 5173 }); // http://127.0.0.1:5173
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Serve several ports by exposing each one; every call gets its own host port.
|
|
193
|
+
|
|
194
|
+
#### Refreshing on change
|
|
195
|
+
|
|
196
|
+
**`expose()` proxies HTTP, not WebSockets.** A dev server's hot-reload channel is a WebSocket,
|
|
197
|
+
so HMR and live-reload overlays will not reach the iframe. Drive the refresh from your IDE
|
|
198
|
+
instead — which you need to do anyway if you are compiling on save:
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
// after writing the user's edit and rebuilding
|
|
202
|
+
await box.fs.writeFile("/app/src/App.jsx", nextSource);
|
|
203
|
+
await box.exec("npm run build", { cwd: "/app" });
|
|
204
|
+
frame.current.src = frame.current.src; // reload the pane
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
That is a full reload rather than hot module replacement: state in the page is lost. For most
|
|
208
|
+
IDE previews that is acceptable; if you need true HMR, the WebSocket proxy is the missing piece.
|
|
209
|
+
|
|
210
|
+
#### What the IDE can and cannot do with the frame
|
|
211
|
+
|
|
212
|
+
The iframe is cross-origin, so your IDE cannot read its DOM. You can still:
|
|
213
|
+
|
|
214
|
+
- `postMessage` to it, if the app inside cooperates;
|
|
215
|
+
- watch `/app/**` through the container to know when to rebuild;
|
|
216
|
+
- read the dev server's stdout from `spawn()` for a log pane.
|
|
217
|
+
|
|
218
|
+
#### In a pure browser IDE
|
|
219
|
+
|
|
220
|
+
`expose()` opens a real `node:http` listener, so it needs a Node host — an Electron app, or a web
|
|
221
|
+
IDE with a Node backend. With no backend at all, the preview goes through Nodepod's service
|
|
222
|
+
worker instead of this package: boot Nodepod's browser build, pass it in with
|
|
223
|
+
`createContainer({ pod })`, and use Nodepod's own preview iframe support. I have not verified
|
|
224
|
+
that path; see [Running in a browser](#running-in-a-browser).
|
|
225
|
+
|
|
226
|
+
### npm and npx
|
|
227
|
+
|
|
228
|
+
> **Anything that downloads needs `network: { allowOutbound: true }`.**
|
|
229
|
+
> Outbound access is off by default, so a fresh container cannot reach
|
|
230
|
+
> registry.npmjs.org. `npm install` and `npx <not-yet-installed>` will fail
|
|
231
|
+
> until you turn it on. This is the single most common surprise — if a package
|
|
232
|
+
> command is failing, check this first.
|
|
134
233
|
|
|
135
234
|
```ts
|
|
136
235
|
const box = await createContainer({
|
|
137
236
|
cwd: "/app",
|
|
138
|
-
network: { allowOutbound: true }, //
|
|
237
|
+
network: { allowOutbound: true }, // ← without this, npm/npx cannot install
|
|
139
238
|
files: {
|
|
140
239
|
"/app/package.json": JSON.stringify({
|
|
141
240
|
name: "api",
|
|
@@ -156,8 +255,151 @@ box.spawn("npm start", { cwd: "/app" });
|
|
|
156
255
|
await box.waitForPort(3000);
|
|
157
256
|
```
|
|
158
257
|
|
|
159
|
-
`
|
|
160
|
-
|
|
258
|
+
`yarn` and `pnpm` map onto the same installer. `apt`/`apt-get` reports the built-in package set
|
|
259
|
+
rather than pretending to download Debian archives.
|
|
260
|
+
|
|
261
|
+
#### npx
|
|
262
|
+
|
|
263
|
+
`npx` works the way you expect: it runs a local binary if there is one, and otherwise installs
|
|
264
|
+
the package first.
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
const box = await createContainer({ cwd: "/app", network: { allowOutbound: true } });
|
|
268
|
+
|
|
269
|
+
await box.exec("npx cowsay hello"); // installs cowsay, then runs it
|
|
270
|
+
await box.exec("npx cowsay hello"); // second time: instant, already installed
|
|
271
|
+
await box.exec("npx sharjeelbaig"); // any package with a bin works
|
|
272
|
+
await box.exec("npx -p typescript tsc -v"); // package name ≠ command name
|
|
273
|
+
await box.exec("npx prettier@3.3.3 --check ."); // pin a version
|
|
274
|
+
await box.exec("npx --no-install eslint"); // fail instead of installing
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Install progress goes to stderr, so `npx cowsay moo | head -3` pipes cleanly.
|
|
278
|
+
|
|
279
|
+
Without `allowOutbound`, a *not-yet-installed* command fails with a message that names the real
|
|
280
|
+
problem:
|
|
281
|
+
|
|
282
|
+
```
|
|
283
|
+
npx: could not determine executable to run: cowsay
|
|
284
|
+
npx: 'cowsay' is not installed, and installing it needs network access.
|
|
285
|
+
npx: Outbound network access is disabled for this container.
|
|
286
|
+
npx: Enable it with createContainer({ network: { allowOutbound: true } }).
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Commands already present — anything in `node_modules/.bin` or on `$PATH` — still run offline.
|
|
290
|
+
|
|
291
|
+
### Walkthrough: Hello React, in your browser
|
|
292
|
+
|
|
293
|
+
End to end — build a React app inside the container and open it in your own browser. Copy this
|
|
294
|
+
into `hello-react.mjs` and run `node hello-react.mjs`.
|
|
295
|
+
|
|
296
|
+
React itself comes from a CDN through an import map, so there is no bundler to configure; the
|
|
297
|
+
container only has to compile JSX and serve files.
|
|
298
|
+
|
|
299
|
+
```js
|
|
300
|
+
import { createContainer } from "sandboxedjs";
|
|
301
|
+
|
|
302
|
+
const box = await createContainer({
|
|
303
|
+
cwd: "/app",
|
|
304
|
+
network: { allowOutbound: true }, // needed to install the JSX compiler
|
|
305
|
+
files: {
|
|
306
|
+
// 1. The React component, in real JSX.
|
|
307
|
+
"/app/src/App.jsx": `
|
|
308
|
+
import { useState } from 'react';
|
|
309
|
+
|
|
310
|
+
export default function App() {
|
|
311
|
+
const [name, setName] = useState('world');
|
|
312
|
+
return (
|
|
313
|
+
<main style={{ fontFamily: 'system-ui', padding: '3rem' }}>
|
|
314
|
+
<h1>Hello, {name}!</h1>
|
|
315
|
+
<input value={name} onChange={(e) => setName(e.target.value)} />
|
|
316
|
+
</main>
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
`,
|
|
320
|
+
"/app/src/main.jsx": `
|
|
321
|
+
import { createRoot } from 'react-dom/client';
|
|
322
|
+
import App from './App.js';
|
|
323
|
+
createRoot(document.getElementById('root')).render(<App />);
|
|
324
|
+
`,
|
|
325
|
+
|
|
326
|
+
// 2. The page. React comes from a CDN via an import map.
|
|
327
|
+
"/app/public/index.html": `<!doctype html>
|
|
328
|
+
<html>
|
|
329
|
+
<head>
|
|
330
|
+
<meta charset="utf-8" />
|
|
331
|
+
<title>Hello React</title>
|
|
332
|
+
<script type="importmap">
|
|
333
|
+
{"imports": {
|
|
334
|
+
"react": "https://esm.sh/react@18.3.1",
|
|
335
|
+
"react/jsx-runtime": "https://esm.sh/react@18.3.1/jsx-runtime",
|
|
336
|
+
"react-dom/client": "https://esm.sh/react-dom@18.3.1/client"
|
|
337
|
+
}}
|
|
338
|
+
</script>
|
|
339
|
+
</head>
|
|
340
|
+
<body>
|
|
341
|
+
<div id="root"></div>
|
|
342
|
+
<script type="module" src="/main.js"></script>
|
|
343
|
+
</body>
|
|
344
|
+
</html>`,
|
|
345
|
+
|
|
346
|
+
// 3. Compile every .jsx in src/ to plain ES modules in public/.
|
|
347
|
+
"/app/build.js": `
|
|
348
|
+
const Babel = require('@babel/standalone');
|
|
349
|
+
const fs = require('fs');
|
|
350
|
+
|
|
351
|
+
for (const file of fs.readdirSync('/app/src')) {
|
|
352
|
+
if (!file.endsWith('.jsx')) continue;
|
|
353
|
+
const { code } = Babel.transform(fs.readFileSync('/app/src/' + file, 'utf8'), {
|
|
354
|
+
filename: file,
|
|
355
|
+
presets: [['react', { runtime: 'automatic' }]],
|
|
356
|
+
sourceType: 'module',
|
|
357
|
+
});
|
|
358
|
+
fs.writeFileSync('/app/public/' + file.replace('.jsx', '.js'), code);
|
|
359
|
+
console.log('compiled', file);
|
|
360
|
+
}
|
|
361
|
+
`,
|
|
362
|
+
|
|
363
|
+
// 4. A plain static server.
|
|
364
|
+
"/app/server.js": `
|
|
365
|
+
const http = require('http');
|
|
366
|
+
const fs = require('fs');
|
|
367
|
+
const path = require('path');
|
|
368
|
+
|
|
369
|
+
http.createServer((req, res) => {
|
|
370
|
+
const url = req.url === '/' ? '/index.html' : req.url;
|
|
371
|
+
const file = path.join('/app/public', url);
|
|
372
|
+
if (!fs.existsSync(file)) { res.writeHead(404); res.end('not found'); return; }
|
|
373
|
+
const type = url.endsWith('.html') ? 'text/html' : 'text/javascript';
|
|
374
|
+
res.writeHead(200, { 'Content-Type': type });
|
|
375
|
+
res.end(fs.readFileSync(file));
|
|
376
|
+
}).listen(5173, () => console.log('listening on 5173'));
|
|
377
|
+
`,
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// Install the compiler, compile, serve.
|
|
382
|
+
await box.exec("npm install @babel/standalone", { cwd: "/app", timeoutMs: 600_000 });
|
|
383
|
+
console.log((await box.exec("node build.js", { cwd: "/app" })).output);
|
|
384
|
+
box.spawn("node server.js", { cwd: "/app" });
|
|
385
|
+
await box.waitForPort(5173);
|
|
386
|
+
|
|
387
|
+
// Publish it on a real host port and open that URL in your browser.
|
|
388
|
+
const bridge = await box.expose(5173, { hostPort: 5173 });
|
|
389
|
+
console.log(`open ${bridge.url}`);
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
```
|
|
393
|
+
compiled App.jsx
|
|
394
|
+
compiled main.jsx
|
|
395
|
+
open http://127.0.0.1:5173
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
Open that URL and you get a working React app — typing in the input updates the heading. The
|
|
399
|
+
JSX was compiled inside the container, the files live only in memory, and nothing was written to
|
|
400
|
+
your disk.
|
|
401
|
+
|
|
402
|
+
`examples/03-react-app.mjs` is the same idea with a nicer component and a `SIGINT` handler.
|
|
161
403
|
|
|
162
404
|
### Python
|
|
163
405
|
|
|
@@ -266,19 +508,103 @@ Everything runs in your Node process, so a true sandbox escape is a JavaScript-e
|
|
|
266
508
|
This is isolation from mistakes and from ordinary untrusted programs — not a substitute for a
|
|
267
509
|
VM or a real container when facing a determined attacker.
|
|
268
510
|
|
|
511
|
+
## Troubleshooting
|
|
512
|
+
|
|
513
|
+
**`npm install` or `npx <tool>` fails immediately.**
|
|
514
|
+
You almost certainly did not pass `network: { allowOutbound: true }`. It is off by default, so
|
|
515
|
+
the container cannot reach registry.npmjs.org. This is the most common surprise by far.
|
|
516
|
+
|
|
517
|
+
```ts
|
|
518
|
+
const box = await createContainer({ network: { allowOutbound: true } });
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
Narrow it if you like: `network: { allowOutbound: true, allowedHosts: ["registry.npmjs.org"] }`.
|
|
522
|
+
|
|
523
|
+
**`command not found` for something you installed.**
|
|
524
|
+
Check where it landed. `npm install` installs into the nearest `package.json` directory, so run
|
|
525
|
+
it with the right `cwd`:
|
|
526
|
+
|
|
527
|
+
```ts
|
|
528
|
+
await box.exec("npm install express", { cwd: "/app" });
|
|
529
|
+
await box.exec("ls node_modules/.bin", { cwd: "/app" });
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
**A server started with `spawn` never answers.**
|
|
533
|
+
Wait for it rather than racing it:
|
|
534
|
+
|
|
535
|
+
```ts
|
|
536
|
+
box.spawn("node server.js", { cwd: "/app" });
|
|
537
|
+
await box.waitForPort(3000, { timeoutMs: 60_000 });
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
**`exec` hangs on a command that reads stdin.**
|
|
541
|
+
`exec` gives a command an empty stdin unless you pass some. `box.exec("cat")` with no `stdin`
|
|
542
|
+
returns immediately; `box.spawn("cat")` gives you a live pipe to write into.
|
|
543
|
+
|
|
544
|
+
**Output looks wrong when piping.**
|
|
545
|
+
Pass `tty: true` only when you want terminal behaviour (colour, column layout). Without it,
|
|
546
|
+
`ls` emits one name per line, like a real pipe.
|
|
547
|
+
|
|
548
|
+
**A command runs forever.**
|
|
549
|
+
Set `timeoutMs`, per call or as a container default. `exec` settles even if the process ignores
|
|
550
|
+
its kill signal.
|
|
551
|
+
|
|
552
|
+
## Running in a browser
|
|
553
|
+
|
|
554
|
+
This package targets Node today, and that is what is tested. If your goal is a browser IDE, here
|
|
555
|
+
is exactly where things stand.
|
|
556
|
+
|
|
557
|
+
**Done — the package no longer hard-depends on Node at import time.** Compression and hashing
|
|
558
|
+
resolve their implementation at call time (`node:zlib`/`node:crypto` on Node,
|
|
559
|
+
`CompressionStream`/`crypto.subtle` plus a JS MD5 in a browser), so nothing pulls a `node:`
|
|
560
|
+
builtin in when the module loads and a bundler will not fail on it.
|
|
561
|
+
|
|
562
|
+
**You supply the pod.** The default boot path uses `@scelar/nodepod/headless`, which installs a
|
|
563
|
+
`worker_threads` host. In a browser you boot Nodepod's browser build yourself — it needs a
|
|
564
|
+
service worker, per Nodepod's own setup docs — and hand the instance over:
|
|
565
|
+
|
|
566
|
+
```ts
|
|
567
|
+
import { Nodepod } from "@scelar/nodepod";
|
|
568
|
+
import { createContainer } from "sandboxedjs";
|
|
569
|
+
|
|
570
|
+
const pod = await Nodepod.boot({ /* your service-worker setup */ });
|
|
571
|
+
const box = await createContainer({ pod });
|
|
572
|
+
|
|
573
|
+
await box.exec("ls -la /"); // shell + coreutils
|
|
574
|
+
await box.exec("node app.js"); // Node, through Nodepod's browser engine
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
**What will not work in a browser:**
|
|
578
|
+
|
|
579
|
+
- `copyIn()` / `copyOut()` — they read and write the host filesystem, which does not exist.
|
|
580
|
+
- `expose()` — it opens a real `node:http` listener. In a browser you use Nodepod's service
|
|
581
|
+
worker and preview iframe to reach an in-container server instead of a host port.
|
|
582
|
+
- The `sandboxedjs` CLI, obviously.
|
|
583
|
+
|
|
584
|
+
Those three use dynamic imports, so they only fail if you call them.
|
|
585
|
+
|
|
586
|
+
**Not yet verified.** I have not run this in a browser end to end. The Node-side blockers are
|
|
587
|
+
removed and the seam is there, but treat browser support as "should work, unproven" rather than
|
|
588
|
+
a tested claim.
|
|
589
|
+
|
|
269
590
|
## Known limits
|
|
270
591
|
|
|
271
592
|
Honest list of what does not work:
|
|
272
593
|
|
|
273
|
-
- **Vite's dev server**
|
|
274
|
-
|
|
275
|
-
|
|
594
|
+
- **Vite's dev server** loads and reads its config, then stops when esbuild starts: Nodepod
|
|
595
|
+
initialises esbuild by importing it from a CDN over `https:`, which the Node ESM loader
|
|
596
|
+
refuses. Anything that needs esbuild — Vite, and tools built on it — is therefore unavailable
|
|
597
|
+
under Node. Express, Koa, Fastify-style apps and plain `http` servers work. See
|
|
598
|
+
`examples/react-app` for a React setup that runs.
|
|
276
599
|
- **Python is MicroPython**, so C extensions (`numpy`, `pandas`, `cryptography`) are unavailable.
|
|
277
600
|
- **No real sockets.** HTTP servers work through the request proxy; raw TCP/UDP does not.
|
|
278
601
|
- **No real processes.** Processes are cooperative async tasks: `kill -9` cannot interrupt a
|
|
279
602
|
tight synchronous loop, and `SIGSTOP` only marks state.
|
|
280
603
|
- **`chroot` does not isolate**; it runs the command with its cwd inside the target.
|
|
281
604
|
- **`awk`'s `system()`** does not block on the child.
|
|
605
|
+
- **`expose()` does not proxy WebSockets**, so dev-server HMR and live-reload do not reach a
|
|
606
|
+
preview iframe. Reload the frame from your IDE after a rebuild instead — see
|
|
607
|
+
[Showing a live preview in an IDE](#showing-a-live-preview-in-an-ide).
|
|
282
608
|
|
|
283
609
|
## API reference
|
|
284
610
|
|
|
@@ -314,6 +640,7 @@ Honest list of what does not work:
|
|
|
314
640
|
| `expose(port, opts?)` | Bridge to a real host port |
|
|
315
641
|
| `snapshot()` / `restore(s)` | Filesystem persistence |
|
|
316
642
|
| `kernel`, `pod`, `net` | Escape hatches to the internals |
|
|
643
|
+
| `hostname`, `user`, `cwd`, `env` | What the container was booted with |
|
|
317
644
|
| `dispose()` | Tear everything down |
|
|
318
645
|
|
|
319
646
|
Lower-level pieces — `Kernel`, `Vfs`, `Shell`, `Terminal`, `NetworkStack`, `defineCommand` — are
|