# Render a site running inside SandboxedJs

For deployment recipes and the FastAPI + static frontend case, start with the
[full-stack deployment guide](fullstack-deployment.md).

A SandboxedJs server listens on a **virtual port**, not an operating-system socket. A log such as `http://localhost:5173` identifies the server inside the container. Pasting that URL into the host browser does not expose it.

Choose the bridge for the environment running the container:

| Goal | API | Result |
| --- | --- | --- |
| Render a whole site in a browser-only coding sandbox | `createPreview(box)` | Service-worker URL for an iframe; no Node backend required |
| Reach a container running in Node | `box.expose(port)` | Real loopback HTTP URL |
| Inspect an API or obtain HTML/assets without rendering | `box.request(port, { path })` | Status, headers, text and exact bytes |
| Display one self-contained HTML response | `renderInto(box, element, { port })` | Opaque-origin iframe; not a complete site proxy |

Use an iframe for a document with its own scripts, styles and navigation. Inserting its HTML into your IDE's DOM does not create a working site environment.

## Browser-only: a complete small example

Run this TypeScript in your host application's browser bundle. Supply an element with a visible height, for example `<div id="preview" style="height:500px"></div>`. Configure hosting as described below first.

```ts
import { createContainer, createPreview } from 'sandboxedjs';

export async function mountSandboxSite(element: HTMLElement) {
  const box = await createContainer({
    cwd: '/workspace',
    isolation: 'worker',
    files: {
      '/workspace/server.cjs': `
        require('http').createServer((req, res) => {
          if (req.url === '/app.js') {
            res.setHeader('Content-Type', 'text/javascript');
            res.end('document.querySelector("button").onclick = () => document.querySelector("button").textContent = "It works!";');
          } else {
            res.setHeader('Content-Type', 'text/html; charset=utf-8');
            res.end('<!doctype html><html><body><h1>Inside SandboxedJs</h1><button>Click me</button><script src="/app.js"></script></body></html>');
          }
        }).listen(3000, () => console.log('Listening on virtual port 3000'));
      `,
    },
  });
  const server = box.spawn('node server.cjs', { cwd: '/workspace' });
  let preview: Awaited<ReturnType<typeof createPreview>> = null;
  const frame = document.createElement('iframe');
  frame.title = 'Sandbox site';
  frame.style.cssText = 'width:100%;height:100%;border:0';

  const dispose = async () => {
    frame.remove();
    server.kill();
    await server.wait();
    try { await preview?.dispose(); }
    finally { box.dispose(); }
  };

  try {
    if (!(await box.waitForPort(3000, { timeoutMs: 30_000 }))) {
      throw new Error('The sandbox server did not open port 3000. Inspect its stdout/stderr.');
    }
    preview = await createPreview(box);
    if (!preview) {
      throw new Error('Preview requires a secure context and service-worker support.');
    }
    frame.src = preview.urlFor(3000);
    element.replaceChildren(frame);
    return {
      box,
      server,
      reload: () => { frame.src = preview!.urlFor(3000); },
      dispose,
    };
  } catch (error) {
    await dispose();
    throw error;
  }
}

// Mount after the host DOM exists:
// const site = await mountSandboxSite(document.querySelector('#preview')!);
// Later: site.reload();
// On application/component teardown: await site.dispose();
```

This example intentionally renders trusted code on the host origin. The absolute `/app.js` request is routed back into the virtual server. No rewriting of the site's HTML or JavaScript is necessary.

### What the browser bridge does

1. `createPreview()` registers the shipped service worker and connects a message channel to the container owner page.
2. `urlFor(3000)` returns a URL under that worker's scope, such as `/assets/__sbx__/3000/`. Treat the returned URL as opaque; do not hardcode the asset directory.
3. An iframe navigation claims a virtual port. The worker associates the resulting browser client with that port.
4. Requests from that client, including absolute asset and API paths, go through `box.request()` and return as browser responses.
5. A previewed page's calls to another **loopback** address — `http://localhost:8000/api`, `http://127.0.0.1:8000/api` — are answered by that port in the same container, not by the reader's machine. A project split into a frontend and a backend can keep the address its code is written against. Requests to any other host (`https://api.example.com`, a LAN address) still go to the network, and a page that is not previewing anything is never redirected.

Responses are delivered whole rather than streamed, so a page reading an
incremental body — a server-sent-event endpoint, a streamed completion — sees
it arrive in one piece when the response ends rather than progressively. A
request is therefore outstanding for as long as the whole answer takes, which
is what `timeoutMs` bounds:

```ts
const preview = await createPreview(box, { timeoutMs: 10 * 60_000 });
```

The default is five minutes. When it is exceeded the page gets a 504 whose body
names the path, the port and this setting.

The container owner page must remain alive. The URL is not a published website, a remote tunnel, or a standalone share link. A virtual port appearing in the terminal does not mean the host machine is listening on that port.

## Use a real project or a terminal in an IDE

Create the container with `network: { allowOutbound: true }` when it needs npm downloads. Load project files into its filesystem, then use the normal commands:

```ts
const install = await box.exec('npm install', { cwd: '/workspace/my-app' });
if (install.exitCode !== 0) throw new Error(install.output);

// Keep this process handle. Do not await completion of a long-running server.
const dev = box.spawn('npm run dev', { cwd: '/workspace/my-app' });
if (!(await box.waitForPort(5173, { timeoutMs: 60_000 }))) {
  dev.kill();
  throw new Error('Dev server did not start; show its logs.');
}
const preview = await createPreview(box);
if (!preview) throw new Error('Browser preview unavailable');
iframe.src = preview.urlFor(5173);
```

For an interactive terminal, use `Terminal` with `box.session()` and let the user run `npm create vite`, answer the prompts, and choose installation/startup. Worker support is required for the synchronous child-process calls used by scaffolding tools. The preview can attach to that same container; do not create a second container just to render its site.

For an IDE's port selector, poll `box.net.listening()` and use each entry's `port`. Do not assume every framework uses 5173. For programmatically spawned commands, consume `stdout` and `stderr` using their asynchronous `read()` methods; `Terminal` already forwards terminal output to its configured writer.

Keep the lifetimes separate:

- Hide/close the pane: remove or hide the iframe; keep the server and container alive.
- Reload the page preview: assign `frame.src = preview.urlFor(port)` again.
- Stop the server: kill the owned process, or send Ctrl+C through the active terminal; wait for its port to disappear and clear the frame.
- Replace a project: remove its iframe and await the old preview's disposal before attaching the new container.
- Destroy the IDE session: stop owned processes, dispose the preview, and dispose the container.

The companion CLI implements an icon toggle, port selection, reload/close, a left-side divider on desktop, and a bottom divider on mobile. The divider supports pointer dragging, arrow keys and double-click reset. These are UI concerns; the core bridge only provides URLs.

## Hosting the browser application

The IDE host page—not the Vite server inside the sandbox—needs these response headers for shared memory and threaded WASM:

```http
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
```

Use HTTPS in deployment, or localhost/loopback for local development. Service workers must be enabled. Check the actual page's `crossOriginIsolated` value; a meta tag cannot enable it.

For a Vite-based host application:

```ts
import { defineConfig } from 'vite';
const headers = {
  'Cross-Origin-Opener-Policy': 'same-origin',
  'Cross-Origin-Embedder-Policy': 'require-corp',
};
export default defineConfig({
  optimizeDeps: { exclude: ['@rolldown/binding-wasm32-wasi'] },
  server: { headers },
  preview: { headers },
});
```

### Cloudflare Pages

Cloudflare Pages can host the outer application as static assets. In a Vite host application, create `public/_headers`, build with
`npm run build`, set the Pages output directory to `dist`, and verify that Vite copied
the file to `dist/_headers`:

```text
/*
  Cross-Origin-Opener-Policy: same-origin
  Cross-Origin-Embedder-Policy: require-corp
  Cross-Origin-Resource-Policy: same-origin
```

Pages treats `_headers` as header configuration. It is not a file the browser needs to download.
After deployment, verify `crossOriginIsolated` in the deployed page and confirm that the service
worker script is a JavaScript response under the same HTTPS origin. A Pages static deployment
hosts the IDE and its service worker; it does not host the virtual sandbox port separately.
The site running inside SandboxedJs is fetched through the service-worker/message bridge in the
browser visitor's tab.

Excluding the binding preserves the URL of its WASI worker. For a built application, the package also includes a local static host:

```sh
npm run build
npx sandboxedjs-serve ./dist 4173
```

This starts the **outer host application** with the required headers. It does not replace `npm run dev` inside the container and does not move guest execution to Node. For production, configure your actual host/CDN with HTTPS, these headers, correct JavaScript/WASM MIME types and real asset URLs. Cross-origin assets need compatible CORS/CORP policies.

The preview service worker must be emitted as a same-origin HTTP(S) file, not an inlined `data:` URL. The default package URL includes Vite's `?no-inline` hint. Other bundlers must preserve/copy the worker asset. If you relocate it, use `createPreview(box, { scriptUrl: '/preview/service-worker.js' })`. A custom `scope` must be permitted by the worker script location or the host's `Service-Worker-Allowed` header.

## Node-hosted containers

`box.expose()` opens a real Node HTTP listener. It is not the browser-only API.

```ts
import { createContainer } from 'sandboxedjs';

const box = await createContainer({
  files: {
    '/app/server.cjs': "require('http').createServer((q,r)=>r.end('Hello from the sandbox')).listen(3000)",
  },
});
const server = box.spawn('node server.cjs', { cwd: '/app' });
if (!(await box.waitForPort(3000, { timeoutMs: 30_000 }))) {
  server.kill();
  box.dispose();
  throw new Error('Server startup failed');
}
const bridge = await box.expose(3000); // random available loopback port
console.log(bridge.url);             // open this in a browser or iframe
// For a fixed host port: box.expose(3000, { hostPort: 8080 })

// When finished:
// await bridge.close();
// server.kill();
// await server.wait();
// box.dispose();
```

Loopback is reachable only from the host machine. Remote access needs an explicitly designed backend/proxy and authentication. An HTTPS frontend cannot freely embed an HTTP backend; plan TLS and framing policies for deployments.

This bridge does not make all packages host-independent. In particular, the current Vite 8/Rolldown WASI integration uses the browser binding's mirrored filesystem; the Node binding uses the real host filesystem. See the README's known limits before choosing a Node-hosted toolchain. A plain HTTP server is supported on both hosts.

## A response without a full site

```ts
const response = await box.request(3000, { path: '/api/items' });
console.log(response.status, response.headers, response.body);
const exactBytes = response.bytes;
```

For self-contained HTML, `renderInto()` uses an iframe with `sandbox="allow-scripts"` and an opaque origin:

```ts
import { renderInto } from 'sandboxedjs';
await renderInto(box, document.querySelector('#preview')!, { port: 3000 });
```

This renders one response. It does not route the document's relative/absolute scripts, styles, images or API calls into the container. It is unsuitable as a replacement for `createPreview()` when displaying a normal Vite application. The sandbox attribute also is not a network-access policy.

## Boundaries to plan for

- **Trust:** `createPreview()` serves guest scripts on the host origin. They can access parent-page state, cookies and storage. It is not an isolation boundary for untrusted code. Running the preview/runtime on a separate origin requires additional architecture; there is no `previewOrigin` switch that implements this for you.
- **HMR:** the injected WebSocket shim tunnels supported guest connections through the owner page. It is enabled by default; disabling injection or blocking it with CSP removes this support. External WebSockets and framework-specific behavior need separate testing.
- **Multiple sessions:** the current service worker has one connected container per registration. Reusing the same scope across containers/tabs can replace that connection, and disposing one registration affects its users. Isolate registrations or use one active session; scopes alone do not provide a security boundary. Concurrent browser Rolldown projects also need distinct absolute working directories because the binding shares a WASI filesystem per page.
- **Lifetime:** the worker asks live owner pages to reconnect after restart. Its in-memory client bindings can still be lost; explicit preview-prefixed URLs remain routable. This does not persist the container after the owner page closes or provide multi-tenant routing.
- **HTTP coverage:** the bridge buffers responses; it is not a general TCP socket, streaming transport or complete reverse proxy. Absolute external URLs, redirects, cookies and application-specific framing policies may need additional handling. Check the behavior your framework actually needs.

## Troubleshooting a blank or refused iframe

1. Check the current page/build. An older bundle or stale iframe may still contain a previously fixed error. Save/export needed work before refreshing the host page; tab-memory sessions are not persisted.
2. Confirm the virtual server is listed by `box.net.listening()` and `await box.request(port, { path: '/' })` returns the expected HTML.
3. Confirm the iframe uses `preview.urlFor(port)`, not the guest's printed `localhost` URL.
4. Inspect the service-worker registration, script URL, MIME type, scope and browser console. A successful registration is not evidence that every later navigation was routed.
5. Inspect framing/isolation errors separately from connection errors: CSP, `X-Frame-Options` and COEP can replace a frame with a refusal page even while the virtual server is running.
6. If HTML/assets work but only a WebSocket fails, that is the current HMR limitation, not proof that the HTTP server failed.

A browser-native refusal page is not a diagnosis by itself. Compare the virtual HTTP response, bridge state and actual browser error before changing framework commands.
