# Frame modes: srcdoc and url

> @faustine-ai/browser-sandbox documentation · https://browser-sandbox.faustine.ai/docs/frame-modes/ · source: README.md on GitHub (https://github.com/faustine-ai/browser-sandbox)

What matters in the frame document is its **CSP**, because the worker
inherits it. The `frame` option says where that document comes from.

## `srcdoc`: no server

```ts
createSandbox({ frame: { srcdoc: true } });
```

The library writes the document inline. Its `<meta>` CSP applies (policies
intersect, so even if your page allows `connect-src` to your API, nothing
inside the frame can reach it). The catch: **a `srcdoc` document also
inherits your page's CSP in full.** So:

- No CSP on your page: works.
- A CSP with `'unsafe-inline'` or `'unsafe-eval'` missing from `script-src`,
  or a nonce-only `script-src`: the frame cannot boot. `run` resolves
  `SandboxUnavailable`; nothing falls back to your page.
- A CSP that does allow inline scripts and eval but has no `worker-src blob:`
  (or `child-src blob:`): the frame boots but the worker cannot start;
  `run` resolves `SandboxUnavailable` with that cause.

## `url`: a static document you serve

```ts
createSandbox({ frame: { url: '/sandbox' } });
```

The same document, served over HTTP with its own headers. Its CSP comes
from the header and inherits nothing from your page, so it works on pages
with a strict CSP. Your page needs one line: `frame-src` to wherever you
serve it (`'self'` if it is on your origin). The document is static and
cacheable (`immutable`); the library appends `?v=<hash>` and checks the
protocol version in the handshake.

Serving it:

```ts
// Next.js: app/sandbox/route.ts
import { frameDocument, frameHeaders } from '@faustine-ai/browser-sandbox/frame';
export function GET() {
  return new Response(frameDocument(), { headers: frameHeaders() });
}
```

```ts
// Express
import { frameDocument, frameHeaders } from '@faustine-ai/browser-sandbox/frame';
app.get('/sandbox', (_req, res) => res.set(frameHeaders()).send(frameDocument()));
```

```sh
# A static file: write it once at build time…
node -e "import('@faustine-ai/browser-sandbox/frame').then(m => process.stdout.write(m.frameDocument()))" > public/sandbox.html
```

```nginx
# …and serve it with the headers (nginx)
location = /sandbox.html {
  add_header Content-Security-Policy "default-src 'none'; connect-src 'none'; img-src 'none'; script-src 'sha256-<hash>' 'unsafe-eval'; worker-src blob:; base-uri 'none'; form-action 'none'; frame-ancestors *" always;
  add_header X-Content-Type-Options nosniff always;
  add_header Referrer-Policy no-referrer always;
  add_header Cache-Control "public, max-age=31536000, immutable" always;
}
```

The exact header values, including the hash, are what `frameHeaders()`
returns; print them once rather than copying from here. `'unsafe-eval'`
is required by the worker's `AsyncFunction` and is harmless in a document
that has no network and no origin.

| | `srcdoc` | `url` |
|---|---|---|
| Install | `npm i` | `npm i` + one static route |
| Network blocked inside | yes (`<meta>` CSP) | yes (header CSP) |
| Page without a CSP | works | works |
| Page with a strict CSP | `SandboxUnavailable` | works, add `frame-src` |
| Page with a lax CSP | needs `worker-src blob:` | works |
| Cache / version | n/a | immutable, `?v=`, checked in the handshake |

The library never falls from one mode to the other on its own.
