# @aweftjs/sandbox

A room for modules the host does not trust: an `@aweftjs/modules` loader on the far end of an
`@aweftjs/sync` link, fed a module document, props, and a list of granted names. Everything a
module inside can reach came through that link, by name.

**This package enforces the window; it does not build or promise the wall.** The window is what
crosses the link: the module document, the props, the granted names, and plain data, and nothing
else. The wall around the room is a runner's, and the operator's. `child` runs under Node's
permission model, which Node itself calls a seat belt rather than a boundary; put a jail around it
(the [`recipes/`](/docs/recipes) show bubblewrap and
docker) when a room must be safe beside your database. `iframe` runs in an opaque-origin frame,
which is the browser's own boundary. `inProcess` isolates nothing, and is for tests and for code
you trust.

## Quickstart

```ts
import { createArray, createObject } from '@aweftjs/core';
import { createSandbox, inProcess } from '@aweftjs/sandbox';

// The modules an agent or a user wrote, as source in a document.
const modules = createObject({
	'report/Daily': createObject({ source: `
		export const deps = ['data/Rows'];
		export default ({ imports }) => ({ run: (day) => imports.Rows.forDay(day).length });` }),
});

// The names the room may reach. Change this array whenever you like.
const grants = createArray(['data/Rows']);

const sandbox = await createSandbox({ runner: inProcess(), modules, grants });
sandbox.expose('data/Rows', { forDay: (day) => rowsFor(day) });

const { 'report/Daily': daily } = await sandbox.load(['report/Daily']);
await daily.run('mon');        // a call into the room; arguments and result are data
await sandbox.stop();
```

The module document is an `@aweftjs/core` observable object (`createObject`) keyed by module
name, and `grants` is an `@aweftjs/core` observable array (`createArray`); a plain object or
array will not do, because the room follows their changes. A module names what it needs in
`deps`, and each dependency reaches its factory under the **last segment** of the name, so
`data/Rows` is `imports.Rows`; that is `@aweftjs/modules`' rule, and it holds for a granted
name too.

`createSandbox` starts the room through the runner and shares three documents into it, and
beside them whatever you name in `documents`. `load` instantiates modules inside and hands back
a **stub** for each: a plain object with one async function per function the instance had.
Calling a stub's function is a call across the boundary.

## What crosses, and what does not

The link carries commits and nothing else, so a call is a row in a document: the request goes
in, the answer is written beside it, and the side that asked deletes the row once it has read
the answer. Only data crosses. An argument or a result that is not plain data (a function, an
observable, a class instance, a `Uint8Array`, a cycle) is refused by name, at the end that
tried to send it, before anything is written:

```ts
await daily.run(() => 1);      // throws: reason 'not-data', path 'args[0]'
```

What is data is what `JSON.parse(JSON.stringify(x))` gives back unchanged. Binary is yours to
encode. A call is one commit round trip, about a millisecond in process (measured once, and no
script here reproduces it), so a chatty interface across the boundary is a design smell.

## Granting a name

A module inside names what it needs in `deps`, exactly as any module does. A name it may reach
is one the application both **exposed** and **granted**:

```ts
const withdraw = sandbox.expose('files/Read', { read: (path) => allowedText(path) });
grants.push('files/Read');     // now a module that deps on 'files/Read' can load
// ...
grants.splice(grants.indexOf('files/Read'), 1);   // the next call to it is refused
withdraw();                                         // and the instance is let go
```

Inside the room the granted name is an import: a plain object carrying the instance's
functions. Only functions cross; a property that is not a function is not visible, so expose a
function that returns it. The host checks the grant on **every** call against its own list, so
a room editing its own copy of the list changes nothing.

Modules in one room use `deps` among themselves and may trust each other, which is how a room
of client components imports a button into a larger one. Whether each user, or each module, or
each call gets its own room is yours: a room is a process (about 90 MB, about 120 ms to start,
nothing when stopped; measured once, no script here reproduces it), so per-call, per-user, or shared is your arithmetic, not this package's.

## Sharing your own documents

Name a document and it crosses under that name, writable from both ends:

```ts
const state = createObject({ count: 0 });
const sandbox = await createSandbox({ runner, modules, grants, documents: { state } });
```

Inside the room the far end hands it back as `share('state')` (a room with a page in it wraps
that as the module's `client.share`); a name you did not share is refused at once with
`not-shared`. The names `modules`, `room`, `calls` and `route` are the room's own and are
refused with `reserved`. A room may write whatever it can read, so where it must not, put
`@aweftjs/schema`'s `guard` on your copy: a write the guard refuses never lands on your side,
and the room's copy diverges, exactly as with a page against a server.

## Asking the host

A room with a page in it asks by name, and the ask reaches `client.ask` on the host when the
name is in `grants`:

```ts
const sandbox = await createSandbox({ runner, modules, grants, client });   // client: what createClient answered
```

A name not in `grants` is refused with `refused`, as is every ask when no `client` was given.
A refusal from the server crosses with its own reason and message. The host client's `status`
is mirrored into the room, and reads `closed` when there is no client.

## What leaves the room as data

Uncaught errors, unhandled rejections and the console levels you name cross as plain data:

```ts
const sandbox = await createSandbox({
	runner, modules, grants,
	console: ['error', 'warn'],                            // the default
	handlers: {
		error: ({ kind, message, stack, module }) => log(kind, message),
		console: (level, text, stack) => log(level, text),
	},
});
```

Each realm forwards what it can and what is not already yours. A frame (`iframe`) forwards
errors, rejections and the named console levels, because nothing else in it reaches the page
that made it. A child process (`child`) forwards the console only: its stdout and stderr are
inherited, and an uncaught error ends the process, which you hear as `closed` on every waiting
call. An in-process room forwards nothing, because its console and its errors are already yours.
Every forwarded console line still reaches the room's real console first; the text is cut to
4096 characters and the stack is the room's own. The lines of one tick cross as one call and
reach the handler one by one, in order. A handler that throws costs the room nothing, and a
report that is not the shape above is dropped.

## A room with a page in it

A room can hold an act: a module that renders, on the same stage, with the same documents and
the same `client` a page module gets, inside a frame the page cannot be reached from. Two
subpaths, one for each side of the wall.

**The host act**, from `@aweftjs/sandbox/page`, is a `ui` component for an act:

```tsx
import { createObject } from '@aweftjs/core';
import { Room } from '@aweftjs/sandbox/page';

// Every entry of the module document is its own createObject, as in the quickstart.
const modules = createObject({ 'app/Main': createObject({ source: mainSource }) });

const App = () => (
	<Room inside="/room/room.js" importMap={{ '@aweftjs/ui': '/room/ui.js', '@aweftjs/core': '/room/core.js' }}
		modules={modules} grants={grants} documents={{ board }} client={client} act="app/Main"
		allow={{ images: [] }} handlers={{ error: (entry) => log.write(entry) }} focus />
);
mount(document.body, <StageContext router={router} acts={{ '': Home, 'app/:id': App }}><Stage /></StageContext>);
```

It renders one element on the `room` entry, the frame fills it, and the room is made when the
act mounts and stopped when it leaves: one frame per act instance. Key the host act `app/:id`,
not `app/:id/*tail`: a `*name` takes the rest as a parameter and leaves no tail, so a host key
with one rebuilds the act, the frame and the room on every move inside the room and hands the
room nothing to route on. What an act does not match is its tail, so `app/:id` is enough, and
`app/:id/*`, which parks the rest without taking it, is the same match written out. `theme`,
`class`, `element` and the rest of the props go to the element, as on every component. It is sized by the theme like any element: `<Room theme="tall">` and
`Theme.define({ room_tall: { height: '80vh' } })`, or a `Theme` provider over `room`; the
default height is `$roomHeight`.

**The room entry**, the application's own bundle, calls `room` from `@aweftjs/sandbox/room`
with the port the frame is posted:

```tsx
import { room } from '@aweftjs/sandbox/room';
import { Theme, light } from '@aweftjs/ui';

const Layout = (props) => <Theme value={light}>{props.children}</Theme>;
export const insidePort = (port) => room(port, { template: Layout });
```

`room` runs the far end over the port, builds one loader, mounts a stage on the act the host
named into the frame's body, and takes over the anchors under it. `template` is where `Theme`
and `Icons` go, because a frame starts with no theme at all.

**The room bundle and the import map.** The frame imports `inside` from the page's origin, and
the acts in the module document import bare names (`@aweftjs/ui`, `@aweftjs/core`) that the
frame's import map has to answer from that same origin, because the frame's policy allows
scripts from nowhere else. Build the entry and one file per bare name as one library, so every
entry shares one copy of `ui`:

```ts
// room.config.ts
export default defineConfig({
	plugins: [aweft({ defaultH: '@aweftjs/ui' })],
	build: { lib: { entry: { room: 'room.tsx', ui: 'names/ui.ts', core: 'names/core.ts' }, formats: ['es'], fileName: (_f, name) => `${name}.js` } },
});
// names/ui.ts:   export * from '@aweftjs/ui';
```

`Room` passes `importMap` into the frame and resolves `inside` against the page's URL. Serve
the bundle with `Access-Control-Allow-Origin: *`: the frame's origin is opaque, so every module
it imports is a cross-origin request. [`recipes/room`](/docs/recipes/room) builds it this way and serves it from the
dev server with `cors` on; a production server sends the same header for the room's files.

**Inside the room, a factory gets `client`**, shaped as the page's: `share(name)` for a
document named in `documents`, `ask(name, args)` for a granted name, answered by the page's
own `client.ask` so the server sees the page's identity, and `status`, the page client's
mirrored. `reconnect` and `close` are refused with `not-in-room`; the connection is the page's.
The host's `props` are spread beside it. So an act module runs on either side of the wall
unchanged:

```js
export default ({ client }) => {
	const board = client.share('board');
	return { component: () => <Editor board={board} save={() => client.ask('app/Save')} /> };
};
```

An act with screens of its own renders a `StageContext` with no `router`, as any nested stage
does: it follows the room's router through the tail the act did not take, the same as on a
page. A `createRouter()` inside the frame throws as it is made, because the frame has no
history of its own and the router's first write to it is refused.

**`documents`** is what the page holds and the room may read and write: the same objects the
page shares with the server, so a room write reaches the server through the page, and a server
write reaches the room. Refusing a write is your `schema` guard on the page's copy.

**`allow`** opens what a page in the frame needs to paint and nothing else: inline styles
always, and `images`, `fonts` and `media` from the inside origin, `data:`, `blob:` and the
origins you name. An image from an origin not named is a broken image in the frame, not an
error on the page. Scripts and connections never widen: `fetch` in the room is refused whatever
`allow` says.

**The route across the wall.** The tail of the page's URL under the host act is the room's whole
URL: at `/app/7/second` the room's stage is on `/second`, `''` is its index, and an act's own
nested stage routes on it. The room's stage keys the act on the bare `*` key, which takes no
parameter, so a move inside the room changes the nested stage's screen and the act's component,
with the state it holds, is not built again. A navigation inside the room is a navigation on the page, at the
act's prefix, so the address bar follows, the browser's back works, and a deep link opens the
room deep. A room can push or replace under its tail and nowhere else; its `back` is honoured
only while the entry showing is one it pushed, and one at a time: a second back before the
page's entry has moved is dropped. A `url` over 8192 characters or holding a control character
is refused on both sides. The page's query and hash ride along with the tail, because a routing
tree has one query. Under a stage with no router, or no stage at all, the room's URL is `/` and
its moves change nothing on the page.

**What leaves the room** is data: uncaught errors and unhandled rejections to
`handlers.error`, each carrying `module`, the act, and the console levels you name to
`handlers.console`. The page decides what to do with them; [`recipes/room`](/docs/recipes/room) writes them with
the logs battery.

**Known limits.**

- No pointer lock, no `confirm()`, no `alert()`, no popups: the sandbox attribute is
  `allow-scripts` and nothing else.
- No links out. The room intercepts nothing: an `<a target="_blank">` in the room opens
  nothing, and an application that wants one exposes a name and writes the in-room module
  that asks for it.
- No `connect-src`. A module that must fetch is a room with a network, which is not this
  runner.
- One act per room. A second `StageContext` beside the act's is a content swapper, as on a page.
- The bundle and every name in the import map come from the inside origin, with CORS, or the
  frame imports nothing.

## Libraries and reloading

A library is a module. Pass `bundle` (a module specifier whose default export is a
`fromBundle` map) and the room's loader can import it. Pass `follow: true` and a module whose
source changes in the document is reloaded inside the room, with `handlers.applied` and
`handlers.failed` told as data. In a room with a page in it, a reload of the act or of a module
it depends on rebuilds the act on screen, on the URL it was on. A reload that fails leaves the
module unloaded: the screen keeps what it showed, and a later edit is not followed until the
next navigation loads the module again. Nothing reloads unless you ask.

## The runners

| runner | the room is | the wall is | what it stops |
|---|---|---|---|
| `inProcess()` | this process | none | nothing; the trusted case |
| `iframe({ inside, into, allow })` | an opaque-origin frame | the browser | the page, its storage, cookies, the network, navigation |
| `child(options)`, on `@aweftjs/sandbox/node` | a Node process | Node's permission model, plus your `wrap` | files, network, spawning, workers, native addons, eval, the environment |

`iframe` takes `allow`, what a page in the frame may load beyond scripts: `styles: true` for
inline styles, and `images`, `fonts` and `media`, each a list of origins beside the inside origin,
`data:` and `blob:`. Scripts and connections never widen: `connect-src` stays refused whatever
`allow` says, and the sandbox attribute stays `allow-scripts` alone. `child` takes
`limits.memoryMB`, `read` (paths besides this package's own it may read), `env` (empty unless
given), and `wrap` (a command in front of the Node command: a bubblewrap invocation, a
`sudo -u`). `createSandbox` takes `limits.callMs` (a call the room does not answer in time
errors with `timeout`), because a room of any runner can hang. None of the limits ship with a
value. A runner is `start()` returning a channel and `stop()`; a room on another machine is the
same runner over a socket, and [`recipes/`](/docs/recipes)
shows a docker one whose channel is the container's stdin and stdout.

A runner proves what it stops by passing `roomChecks()` from `@aweftjs/testing`, an append-only
escape suite run in every shipped runner (and, for the frame, under a real browser).

## What this package never decides

Which runner. How many rooms, for how long, or how they are grouped. What the wall is. Who may
load, grant, or call. Limits. What a granted module lets a caller do. Which modules go in a
room, whether a write from the room is acceptable, what the template looks like, whether a link
out of the room opens. It enforces the window, each runner says what it stops, and the wall is
yours.

The design notes are in
[`docs/design/`](https://github.com/torrinworx/aweft/tree/9a5bb24770dc7555257f8a307d79917efc58df70/docs/design) 066 to 070 and 280
to 284.

## API

Every export of `@aweftjs/sandbox`, its signature as the compiler resolves it, and its block comment.

### `@aweftjs/sandbox`

#### `ClientLike`

```ts
interface ClientLike { ask(name: string, args?: unknown): Promise<unknown>; readonly status: Derived<string>; }
```

What the host holds that a room's `ask` goes through. Shaped on the page's client, without naming it.

#### `DocumentLike`

```ts
interface DocumentLike { createElement(tag: 'iframe'): FrameLike; }
```

What this runner needs from a document.

#### `FrameAllow`

```ts
interface FrameAllow { readonly styles?: boolean | undefined; readonly images?: readonly string[] | undefined; readonly fonts?: readonly string[] | undefined; readonly media?: readonly string[] | undefined; }
```

What a page in the room may load beyond scripts (design 284). Each list is origins.

#### `FrameLike`

```ts
interface FrameLike { setAttribute(name: string, value: string): void; addEventListener(type: 'load', fn: () => void): void; remove(): void; readonly contentWindow: { postMessage(message: unknown, origin: string, transfer: unknown[]): void; } | null; }
```

What this runner needs from an iframe element.

#### `FrameOptions`

```ts
interface FrameOptions { readonly inside: string; readonly allow?: FrameAllow | undefined; readonly into: { appendChild(node: FrameLike): unknown; }; readonly importMap?: Readonly<Record<string, string>> | undefined; readonly document?: DocumentLike | undefined; readonly MessageChannel?: (new () => MessageChannelLike) | undefined; }
```

No block comment on this export.

#### `MessageChannelLike`

```ts
interface MessageChannelLike { readonly port1: Parameters<typeof fromMessagePort>[0]; readonly port2: unknown; }
```

What this runner needs from a `MessageChannel`.

#### `PageOptions`

```ts
interface PageOptions { readonly act: string; readonly route: object; }
```

What a room with a page in it is told.

#### `Report`

```ts
interface Report { readonly kind: 'error' | 'rejection' | 'console'; readonly level?: string; readonly message: string; readonly stack: string; readonly module?: string; }
```

One line that left the room as data (design 283): an uncaught error, an unhandled rejection
or a console call on a level the host named.

#### `Runner`

```ts
interface Runner { start(): Promise<Channel>; stop(): Promise<void>; }
```

Makes a room and the channel to it.

A runner decides what the room is: the same process, a browser frame, a child process, a
container. The package decides what crosses the channel. A runner is used by one sandbox.

#### `Sandbox`

```ts
interface Sandbox { load(names: readonly string[]): Promise<Readonly<Record<string, Stub>>>; unload(name: string): Promise<boolean>; loaded(): Promise<readonly string[]>; expose(name: string, instance: object): () => void; stop(): Promise<void>; }
```

The host's side of a room.

#### `SandboxError`

```ts
interface SandboxError extends Error { readonly reason: string; readonly path?: string; }
```

An error this package raises, with a reason a caller can branch on.

Reasons: `not-data` (an argument, a result or a prop that is not plain data; `path` names
it), `refused` (a name that is not granted, or an ask with no client to answer it),
`missing` (a name that is granted but not exposed, or a function the instance does not
have), `malformed` (a row the other end wrote that is not a call), `reserved` (a document
named as one of the room's own topics), `not-shared` (a document the host did not share),
`timeout`, `closed`, `failed` (the function threw; `message` carries what it said),
`no-page` (the iframe runner found no document and no MessageChannel), and the loader's
own reasons passed through unchanged.

#### `SandboxHandlers`

```ts
interface SandboxHandlers { readonly applied?: ((name: string, action: 'reloaded' | 'unloaded') => void) | undefined; readonly failed?: ((name: string, error: string) => void) | undefined; readonly error?: ((entry: Report) => void) | undefined; readonly console?: ((level: string, text: string, stack: string) => void) | undefined; }
```

No block comment on this export.

#### `SandboxLimits`

```ts
interface SandboxLimits { readonly callMs?: number | undefined; }
```

No block comment on this export.

#### `SandboxOptions`

```ts
interface SandboxOptions { readonly runner: Runner; readonly modules: object; readonly grants: string[]; readonly props?: Readonly<Record<string, unknown>> | undefined; readonly bundle?: string | undefined; readonly follow?: boolean | undefined; readonly handlers?: SandboxHandlers | undefined; readonly limits?: SandboxLimits | undefined; readonly documents?: Readonly<Record<string, object>> | undefined; readonly console?: readonly string[] | undefined; readonly client?: ClientLike | undefined; readonly page?: PageOptions | undefined; }
```

Where a room's modules and its libraries come from, and what it is told.

#### `Stub`

```ts
type Stub = Readonly<Record<string, (...args: unknown[]) => Promise<unknown>>>;
```

A loaded module inside the room, as the host sees it: one function per function the instance
had.

**Throws** a `SandboxError` from the promise a stub function returns. `not-data` when an argument or the result is not plain data, with `path` naming it; `missing` when the room has no such module or the instance has no such function; `timeout` when the room did not answer within `limits.callMs`; `closed` when the room stopped with the call still waiting; and, when the function itself threw, whatever it refused for, with its own message unchanged.

#### `createSandbox`

```ts
createSandbox: (options: SandboxOptions) => Promise<Sandbox>
```

Make a room and the host's side of it.

**Params**

- `options.runner`: what makes the room; used by this sandbox alone
- `options.modules`: the module document the room loads from; the room never writes it
- `options.grants`: the names the room may reach, an observable array; change it any time
- `options.props`: plain data, spread into every factory's props inside the room
- `options.bundle`: a module specifier the room imports for a library bundle map
- `options.follow`: reload a module inside the room when its source changes; off by default
- `options.handlers`: where `follow`, errors and console lines report
- `options.limits.callMs`: how long a call into the room may wait; none by default
- `options.documents`: documents shared into the room under their keys, writable both ways
- `options.console`: the console levels the room forwards; `error` and `warn` by default
- `options.client`: what answers the room's asks on granted names; none refuses every ask
- `options.page`: the act the room shows and the route document its tail crosses on

**Returns** the sandbox, once the runner has made the room. `expose` names before `load`.

**Throws** a `SandboxError`. `malformed` when `modules` is not an observable object, `grants` is not an observable array, a document or the route is not an observable, or `page.act` is not a name; `reserved` when a document is named `modules`, `room`, `calls` or `route`; and `not-data` when a prop is not plain data, with `path` naming it.

**Example**

```ts
const grants = createArray(['files/Read']);
const sandbox = await createSandbox({ runner: inProcess(), modules: plugins, grants });
sandbox.expose('files/Read', { read: (path) => allowed(path) });
const { 'report/Summarize': summarize } = await sandbox.load(['report/Summarize']);
```

#### `iframe`

```ts
iframe: (options: FrameOptions) => Runner & { readonly element: FrameLike | undefined; }
```

A runner whose room is a sandboxed iframe.

**Params**

- `options.inside`: the URL of the room's inside module; its origin is the one origin the frame may load scripts from
- `options.into`: where the frame is appended
- `options.importMap`: the frame's import map, when the inside module is not bundled
- `options.allow`: inline styles, and the origins images, fonts and media may come from beyond the inside origin, `data:` and `blob:`; scripts and connections never widen

**Returns** a runner. The frame it makes is `element` once started, so a page can size it.

**Throws** a SandboxError with reason `no-page` when there is no document and no MessageChannel to be found, which is every runtime that is not a page.

**Example**

```ts
const runner = iframe({ inside: '/room/inside.js', into: document.body });
const sandbox = await createSandbox({ runner, modules, grants });
```

#### `inProcess`

```ts
inProcess: () => Runner
```

A runner whose room is this process.

**Returns** a runner. Nothing it runs is isolated from anything, and the README says so.

**Example**

```ts
const sandbox = await createSandbox({ runner: inProcess(), modules, grants });
```

### `@aweftjs/sandbox/inside`

#### `Entered`

```ts
interface Entered { readonly sources: readonly Source[]; readonly props: Readonly<Record<string, unknown>>; readonly page: { readonly act: string; } | null; readonly route: RouteDocument | null; share<T extends object>(name: string): Shared<T>; ask(name: string, args?: unknown): Promise<unknown>; readonly status: Derived<string>; serve(loader: Loader, handlers?: FollowHandlers): Room; }
```

The far end once the host's documents have arrived, before it has a loader.

#### `InsideOptions`

```ts
interface InsideOptions { readonly forward?: { readonly errors?: boolean; readonly console?: boolean; } | undefined; }
```

What the far end may be told about the realm it runs in.

#### `Report`

```ts
interface Report { readonly kind: 'error' | 'rejection' | 'console'; readonly level?: string; readonly message: string; readonly stack: string; readonly module?: string; }
```

One line that left the room as data (design 283): an uncaught error, an unhandled rejection
or a console call on a level the host named.

#### `Room`

```ts
interface Room { stop(): Promise<void>; }
```

The room, as the code inside it holds it.

#### `RouteDocument`

```ts
interface RouteDocument extends Record<string, unknown> { url: string; key: string; move: 'push' | 'replace' | 'back'; seq: number; }
```

What the route document holds. Flat, so every field is one slot.

#### `enter`

```ts
enter: (channel: Channel, options?: InsideOptions) => Promise<Entered>
```

Connect the far end of a room to the host and wait for its documents.

**Params**

- `channel`: the room's end of the channel the runner made
- `options.forward`: what this realm forwards to the host; nothing when left off

**Returns** what a loader is built from, the host's shared documents by name, the way to ask the host, and `serve`, which takes the loader and answers the host's calls against it. Rejects if the link ends first.

**Throws** a `SandboxError` with reason `malformed` when the host's documents are not what this version of the package writes, which is what a host and a room on different versions look like from in here. Example, in a room with a page in it: const entered = await enter(fromMessagePort(port), { forward: { errors: true, console: true } }); const loader = createLoader({ sources: entered.sources, props: { client } }); const room = entered.serve(loader);

#### `inside`

```ts
inside: (channel: Channel, options?: InsideOptions) => Promise<Room>
```

Run the far end of a room over a channel to the host: `enter`, a loader over what it found,
and `serve`.

**Params**

- `channel`: the room's end of the channel the runner made
- `options.forward`: what this realm forwards to the host; nothing when left off

**Returns** the room, once the host's documents have arrived. Rejects if the link ends first.

**Throws** a `SandboxError` with reason `malformed` when the host's documents are not what this version of the package writes, which is what a host and a room on different versions look like from in here. Example, in a child process the `child` runner spawned: const room = await inside(fromIpc(process), { forward: { console: true } });

#### `insidePort`

```ts
insidePort: (port: PortLike) => Promise<Room>
```

The same, over a `MessagePort`: what a frame calls with the port the host posted to it. A
frame forwards its uncaught errors, unhandled rejections and the named console levels.

Example, in the frame's own script:
  addEventListener('message', (event) => { insidePort(event.ports[0]); });

### `@aweftjs/sandbox/node`

#### `ChildOptions`

```ts
interface ChildOptions { readonly limits?: { readonly memoryMB?: number | undefined; } | undefined; readonly wrap?: readonly string[] | undefined; readonly read?: readonly string[] | undefined; readonly env?: Readonly<Record<string, string>> | undefined; }
```

No block comment on this export.

#### `child`

```ts
child: (options?: ChildOptions) => Runner
```

A runner whose room is a child Node process.

**Params**

- `options.limits.memoryMB`: the process's heap limit
- `options.wrap`: a command prefix, such as a bubblewrap invocation
- `options.read`: extra read-only paths
- `options.env`: the process's environment; empty by default The room may read the stack's [`packages`](https://github.com/torrinworx/aweft/tree/9a5bb24770dc7555257f8a307d79917efc58df70/packages) and `node_modules` (so it can import the framework and its dependencies) and whatever `read` adds, and nothing else on disk. What it cannot do is what Node's permission model denies: files outside those paths, the network, spawning, worker threads, native addons, and `eval`. Node calls this a seat belt, not a boundary: it does not deny a determined escape, so put a wall around it with `wrap`.

**Example**

```ts
const runner = child({ limits: { memoryMB: 256 }, wrap: ['bwrap', '--unshare-all', ...] });
```

### `@aweftjs/sandbox/page`

#### `ClientLike`

```ts
interface ClientLike { ask(name: string, args?: unknown): Promise<unknown>; readonly status: Derived<string>; }
```

What the host holds that a room's `ask` goes through. Shaped on the page's client, without naming it.

#### `FrameAllow`

```ts
interface FrameAllow { readonly styles?: boolean | undefined; readonly images?: readonly string[] | undefined; readonly fonts?: readonly string[] | undefined; readonly media?: readonly string[] | undefined; }
```

What a page in the room may load beyond scripts (design 284). Each list is origins.

#### `Report`

```ts
interface Report { readonly kind: 'error' | 'rejection' | 'console'; readonly level?: string; readonly message: string; readonly stack: string; readonly module?: string; }
```

One line that left the room as data (design 283): an uncaught error, an unhandled rejection
or a console call on a level the host named.

#### `Room`

```ts
Room: (props: RoomProps, cleanup: (...fns: (() => void)[]) => void, mounted: (...fns: (() => void)[]) => void) => Mounter
```

An act that runs a module in a frame on the page.

**Params**

- `props`: `inside`, `modules`, `grants`, `act`, and the rest named on `RoomProps`; anything else goes to the element

**Returns** one element on the `room` entry, with the frame inside it once mounted. The runner and the sandbox are made in `mounted` and stopped in `cleanup`, so leaving the act ends the room (designs 069 and 242). One frame per act instance. Under a stage, the tail the act did not take is the room's URL: the host claims it with `claimTail`, writes it into the route document as `/` plus the tail with the page's query and hash, and applies a room `push` or `replace` as the same move on the page's router at the act's own prefix. A room `back` is honoured only while the entry showing is one the host pushed on the room's behalf (design 282). With no stage above, or no router in the tree, the room runs on `act` alone: its URL is `/` and its moves change nothing on the page. A `createSandbox` that rejects is raised where the page already looks, from a microtask, unless the act had left first, in which case the `closed` it rejects with is the leaving.

**Example**

```ts
const AppAct = (props) => (
  <Room inside="/room/room.js" modules={modules} grants={grants} documents={{ board }}
    client={client} act="app/Main" allow={{ images: [] }}
    handlers={{ error: (entry) => log.write(entry) }} focus />
);
```

#### `RoomProps`

```ts
interface RoomProps { readonly inside: string; readonly modules: object; readonly grants: string[]; readonly documents?: Readonly<Record<string, object>> | undefined; readonly client?: ClientLike | undefined; readonly act: string; readonly allow?: FrameAllow | undefined; readonly console?: readonly string[] | undefined; readonly handlers?: SandboxHandlers | undefined; readonly focus?: boolean | undefined; readonly props?: Readonly<Record<string, unknown>> | undefined; readonly bundle?: string | undefined; readonly follow?: boolean | undefined; readonly limits?: SandboxLimits | undefined; readonly importMap?: Readonly<Record<string, string>> | undefined; readonly element?: unknown; readonly theme?: unknown; readonly class?: unknown; readonly [prop: string]: unknown; }
```

What `Room` takes. Everything not named here goes to the element.

#### `SandboxHandlers`

```ts
interface SandboxHandlers { readonly applied?: ((name: string, action: 'reloaded' | 'unloaded') => void) | undefined; readonly failed?: ((name: string, error: string) => void) | undefined; readonly error?: ((entry: Report) => void) | undefined; readonly console?: ((level: string, text: string, stack: string) => void) | undefined; }
```

No block comment on this export.

#### `SandboxLimits`

```ts
interface SandboxLimits { readonly callMs?: number | undefined; }
```

No block comment on this export.

### `@aweftjs/sandbox/room`

#### `FrameDocument`

```ts
interface FrameDocument { readonly body: { insertBefore(node: unknown, before: unknown): unknown; removeChild(node: unknown): unknown; replaceChild(node: unknown, old: unknown): unknown; }; }
```

What the frame's document offers, named structurally so this file typechecks with no DOM library.

#### `Room`

```ts
interface Room { stop(): Promise<void>; }
```

The room, as the code inside it holds it.

#### `RoomOptions`

```ts
interface RoomOptions { readonly template?: Component | undefined; readonly document?: FrameDocument | undefined; }
```

What `room` takes.

#### `room`

```ts
room: (port: PortLike, options?: RoomOptions) => Promise<Room>
```

Run a room with a page in it, over the port the frame was posted.

**Params**

- `port`: the `MessagePort` the host posted into the frame
- `options.template`: what wraps the act, `Theme` and `Icons` among it
- `options.document`: where the stage mounts; the frame's own document when left off

**Returns** the room, once the host's documents have arrived and the stage is mounted. Its `stop` unmounts the stage, unloads everything and closes the link. The far end forwards uncaught errors, unhandled rejections and the console levels the host named (design 283), each attributed to the act. One loader is built over the far end's sources, with the host's `props` and `client` beside them, and handed to the far end and to the stage both. The stage runs `acts={{ '*': act }}` over a router whose entries are the route document, so the tail under the host act is the room's whole URL and `''` is its index; the bare `*` takes no parameter, so a move inside the room is the act's nested stage's and the act is not built again. Anchors under the body are taken over, resolved against that URL. With `follow`, a reload of the act or of a module it depends on unmounts and mounts the stage again, so the act on screen is built from the new source; the route document carries the URL through. Rejects: with `no-act` when the host runs a compute room, which has no act to show. Example, the application's room entry: import { room } from '@aweftjs/sandbox/room'; export const insidePort = (port) => room(port, { template: Layout });

## Refusals

Every error this package raises carries a `reason` to switch on and a `fix` that says what to do. These are its reasons, from `errors.txt`.

| reason | fix |
|---|---|
| `<whatever the far end refused for>` | Handle this reason where the call was made, or fix the function it names. |
| `closed` | Make a new sandbox; a room that has stopped does not start again. |
| `closed` | Stop the room once createSandbox has answered, or take closed from it as the frame leaving first. |
| `malformed` | Build each document in documents with createObject, createArray or createMap from @aweftjs/core. |
| `malformed` | Build the grants with createArray from @aweftjs/core. |
| `malformed` | Build the module document with createObject from @aweftjs/core. |
| `malformed` | Build the route document with createObject from @aweftjs/core, holding url, key, move and seq. |
| `malformed` | Check that the host and the room run the same version of this package. |
| `malformed` | Check that the host runs the same version of this package as the room. |
| `malformed` | Check that the room runs the same version of this package as the host. |
| `malformed` | Name the act the room shows as a string, the way an acts map names a module. |
| `malformed` | Pass props to createSandbox as a plain object. |
| `missing` | Call a function the exposed instance has. |
| `missing` | Call a function the loaded module has. |
| `missing` | Call one of the four the host answers: applied, failed, ask and report. |
| `missing` | Call one of the three the room answers: load, unload and loaded. |
| `missing` | Expose the name on the host with sandbox.expose first. |
| `missing` | Load the module in the room before calling it. |
| `no-act` | Mount Room from @aweftjs/sandbox/page on the host, or make the frame with insidePort from @aweftjs/sandbox/inside. |
| `no-page` | Pass document and MessageChannel to iframe when there is no page. |
| `not-data` | Break the cycle before sending the value. |
| `not-data` | Pass plain JSON data as the arguments. |
| `not-data` | Send a finite number, or null. |
| `not-data` | Send a plain copy of the value, not the observable. |
| `not-data` | Send plain JSON data: null, booleans, finite numbers, strings, arrays and plain objects. |
| `not-data` | Write null instead of undefined inside an array. |
| `not-in-room` | The connection is the page's; a module in a room shares and asks through it and does not manage it. |
| `not-shared` | Name the document in documents on Room, or in documents to createSandbox. |
| `outside-tail` | Push a path starting with / and no . or .. segment; a room moves the page under the host act and nowhere else. |
| `refused` | Add the name to the sandbox grants, or call it from the host instead. |
| `refused` | Pass client to createSandbox, or client to Room, so the room's asks have somewhere to go. |
| `reserved` | Share the document under another name; modules, room, calls and route are the room's own. |
| `timeout` | Raise limits.callMs, or make the call inside the room answer sooner. |

## Recipes

The programs in the stack's gate that use this package, each a job someone would have.

- [`recipes/room`](/docs/recipes/room): An act module stored on the server, run in a frame on the page: the board it shares reaches the server, its ask carries the page's identity, its own stage navigates on the page's URL under the host act, an error inside reaches the page's logs, and the frame paints but cannot fetch
- [`recipes/sandbox`](/docs/recipes/sandbox): the package's own recipe
