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 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
- ### npm
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 }, // required to reach the registry
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
- `npx`, `yarn` and `pnpm` map onto the same installer. `apt`/`apt-get` reports the built-in
160
- package set rather than pretending to download Debian archives.
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** starts but fails resolving its own package metadata, because Nodepod's
274
- ESM layer does not give bundled chunks a correct `import.meta.url`. Express, Koa, Fastify-style
275
- apps and plain `http` servers work. See `examples/react-app` for a React setup that runs.
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