aweft

@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/ 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

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:

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:

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:

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:

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:

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:

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:

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:

// 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 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:

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 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

runnerthe room isthe wall iswhat it stops
inProcess()this processnonenothing; the trusted case
iframe({ inside, into, allow })an opaque-origin framethe browserthe page, its storage, cookies, the network, navigation
child(options), on @aweftjs/sandbox/nodea Node processNode's permission model, plus your wrapfiles, 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/ 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/ 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

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

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

What this runner needs from a document.

FrameAllow

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

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

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

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

What this runner needs from a MessageChannel.

PageOptions

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

What a room with a page in it is told.

Report

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

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

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

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

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

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

No block comment on this export.

SandboxOptions

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

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

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

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

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

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

inProcess

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

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

@aweftjs/sandbox/inside

Entered

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

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

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

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

The room, as the code inside it holds it.

RouteDocument

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

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

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

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

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

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 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

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

@aweftjs/sandbox/page

ClientLike

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

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

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

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

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

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

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

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

No block comment on this export.

@aweftjs/sandbox/room

FrameDocument

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

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

The room, as the code inside it holds it.

RoomOptions

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

What room takes.

room

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.

reasonfix
<whatever the far end refused for>Handle this reason where the call was made, or fix the function it names.
closedMake a new sandbox; a room that has stopped does not start again.
closedStop the room once createSandbox has answered, or take closed from it as the frame leaving first.
malformedBuild each document in documents with createObject, createArray or createMap from @aweftjs/core.
malformedBuild the grants with createArray from @aweftjs/core.
malformedBuild the module document with createObject from @aweftjs/core.
malformedBuild the route document with createObject from @aweftjs/core, holding url, key, move and seq.
malformedCheck that the host and the room run the same version of this package.
malformedCheck that the host runs the same version of this package as the room.
malformedCheck that the room runs the same version of this package as the host.
malformedName the act the room shows as a string, the way an acts map names a module.
malformedPass props to createSandbox as a plain object.
missingCall a function the exposed instance has.
missingCall a function the loaded module has.
missingCall one of the four the host answers: applied, failed, ask and report.
missingCall one of the three the room answers: load, unload and loaded.
missingExpose the name on the host with sandbox.expose first.
missingLoad the module in the room before calling it.
no-actMount Room from @aweftjs/sandbox/page on the host, or make the frame with insidePort from @aweftjs/sandbox/inside.
no-pagePass document and MessageChannel to iframe when there is no page.
not-dataBreak the cycle before sending the value.
not-dataPass plain JSON data as the arguments.
not-dataSend a finite number, or null.
not-dataSend a plain copy of the value, not the observable.
not-dataSend plain JSON data: null, booleans, finite numbers, strings, arrays and plain objects.
not-dataWrite null instead of undefined inside an array.
not-in-roomThe connection is the page's; a module in a room shares and asks through it and does not manage it.
not-sharedName the document in documents on Room, or in documents to createSandbox.
outside-tailPush a path starting with / and no . or .. segment; a room moves the page under the host act and nowhere else.
refusedAdd the name to the sandbox grants, or call it from the host instead.
refusedPass client to createSandbox, or client to Room, so the room's asks have somewhere to go.
reservedShare the document under another name; modules, room, calls and route are the room's own.
timeoutRaise 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: 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: the package's own recipe