@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 goInside 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 answeredA 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(), noalert(), no popups: the sandbox attribute isallow-scriptsand 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
StageContextbeside 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/ 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 aloneoptions.modules: the module document the room loads from; the room never writes itoptions.grants: the names the room may reach, an observable array; change it any timeoptions.props: plain data, spread into every factory's props inside the roomoptions.bundle: a module specifier the room imports for a library bundle mapoptions.follow: reload a module inside the room when its source changes; off by defaultoptions.handlers: wherefollow, errors and console lines reportoptions.limits.callMs: how long a call into the room may wait; none by defaultoptions.documents: documents shared into the room under their keys, writable both waysoptions.console: the console levels the room forwards;errorandwarnby defaultoptions.client: what answers the room's asks on granted names; none refuses every askoptions.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 fromoptions.into: where the frame is appendedoptions.importMap: the frame's import map, when the inside module is not bundledoptions.allow: inline styles, and the origins images, fonts and media may come from beyond the inside origin,data:andblob:; 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: () => RunnerA 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 madeoptions.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 madeoptions.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) => RunnerA runner whose room is a child Node process.
Params
options.limits.memoryMB: the process's heap limitoptions.wrap: a command prefix, such as a bubblewrap invocationoptions.read: extra read-only pathsoptions.env: the process's environment; empty by default The room may read the stack'spackagesandnode_modules(so it can import the framework and its dependencies) and whateverreadadds, 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, andeval. Node calls this a seat belt, not a boundary: it does not deny a determined escape, so put a wall around it withwrap.
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) => MounterAn act that runs a module in a frame on the page.
Params
props:inside,modules,grants,act, and the rest named onRoomProps; 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: theMessagePortthe host posted into the frameoptions.template: what wraps the act,ThemeandIconsamong itoptions.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: 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 fetchrecipes/sandbox: the package's own recipe