forgeframe
v2.1.0
Published
Modern cross-domain iframe component framework - a TypeScript alternative to zoid
Downloads
24,629
Maintainers
Readme
ForgeFrame
A TypeScript library for embedding cross-domain iframes and popups. The embedding page passes typed data and callbacks to a page on another origin, such as a payment form, sign-in widget, third-party integration, or micro-frontend. No runtime dependencies; ships as an ESM build.
Used By
Terminology
Every integration has two sides:
- Consumer: the outer app. It renders the iframe or popup and passes props into it.
- Host: the inner app running in the iframe or popup. It starts with
initHost()and reads those props fromawait host.ready.
For example, a payment provider lets merchants embed its checkout form:
| | Consumer | Host |
|--|----------|------|
| Built by | The merchant | The payment provider |
| Origin | https://shop.example.com | https://pay.example.com |
| Role | Embeds the checkout and handles onSuccess | Renders the checkout UI and calls onSuccess after payment |
┌─────────────────────────────────────────────────────────────────┐
│ Consumer (merchant site, shop.example.com) │
│ │
│ Checkout({ amount: 99, onSuccess: (payment) => { │
│ // Fulfill the order │
│ }}).render('#checkout-container'); │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Host (payment form, pay.example.com) │ │
│ │ │ │
│ │ const { amount, onSuccess } │ │
│ │ = await host.ready; │ │
│ │ │ │
│ │ // The user enters card details and pays │ │
│ │ onSuccess({ paymentId: 'xyz', amount }); │ │
│ │ host.close(); │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘| If you want to... | You build the... | |-------------------|------------------| | Embed another team's or company's component in your app | Consumer | | Build a component or widget for others to embed | Host | | Build both sides, such as your own micro-frontends | Both |
Table of Contents
- Installation
- Quick Start
- Step-by-Step Guide
- Props System
- Host Window API
- Templates (Advanced)
- React Integration (Optional)
- Advanced Features
- Errors
- Versioning
- Migrating from v1 to v2
- API Reference
- TypeScript
- Browser Support
- Developing ForgeFrame
Installation
npm install forgeframeQuick Start
Consumer
Define the component once, in a module that the consumer and the host both import.
// payment-form.ts
import { create, prop } from 'forgeframe';
export const PaymentForm = create({
tag: 'payment-form',
url: 'https://pay.example.com/checkout',
dimensions: { width: 400, height: 300 },
props: {
amount: prop.number(),
onSuccess: prop.function<(txn: { transactionId: string }) => void>(),
},
});import { PaymentForm } from './payment-form';
const payment = PaymentForm({
amount: 99.99,
onSuccess: (txn) => console.log('Payment complete:', txn),
});
await payment.render('#payment-container');
Host
import { initHost } from 'forgeframe';
import { PaymentForm } from './payment-form';
const host = initHost(PaymentForm, {
consumerOrigins: ['https://shop.example.com'],
});
if (host) {
const { amount, onSuccess } = await host.ready;
document.getElementById('total')!.textContent = `$${amount}`;
document.getElementById('pay-btn')!.onclick = async () => {
await onSuccess({ transactionId: 'TXN_123' });
await host.close();
};
}The host starts with initHost(Component, { consumerOrigins }), reads its props from await host.ready, and calls controls such as close() on the host handle; see Host Init with initHost. consumerOrigins is required. List the consumer origins for payment, auth, or account flows, and pass '*' only for widgets that any site may embed. initHost() returns null when the page is not opened by ForgeFrame.
Step-by-Step Guide
1. Define a Component
Consumer
create() returns a reusable component factory. Export it so the host page can import the same definition.
// login-form.ts
import { create, prop, type PropsOf } from 'forgeframe';
export const LoginForm = create({
tag: 'login-form',
url: 'https://auth.example.com/login',
dimensions: { width: 400, height: 350 },
props: {
email: prop.string().optional(),
onLogin: prop.function<(user: { id: number; name: string }) => void>(),
onCancel: prop.function<() => void>().optional(),
},
exports: prop.object({
reset: prop.function<() => void>(),
}),
});
export type LoginProps = PropsOf<typeof LoginForm>;tag(required): unique identifier for the component.url(required): URL of the host page.dimensions: width and height of the iframe or popup. Iframes accept CSS units. Popups accept numbers, numeric strings, orpxstrings; any other unit falls back to 500 pixels when opening, or to the current size when resizing.props: schemas for the props passed to the host. TypeScript infers the props from them:onLoginis required, soLoginForm()without props does not compile.exports: a schema for what the host passes tohost.export(). It typesinstance.exports, and the consumer rejects exports that do not match.PropsOf<typeof LoginForm>: reads the props type back from the component; see TypeScript.
2. Create the Host Page
Host
The host page is served at the component's url. It starts the host runtime with initHost() and reads its props from host.ready.
import { initHost } from 'forgeframe';
import { LoginForm } from './login-form';
const host = initHost(LoginForm, {
consumerOrigins: ['https://app.example.com'],
});
if (host) {
const { email, onLogin, onCancel } = await host.ready;
if (email) (document.getElementById('email') as HTMLInputElement).value = email;
document.getElementById('login-form')!.onsubmit = async (e) => {
e.preventDefault();
await onLogin({
id: 1,
name: 'John Doe',
});
await host.close();
};
document.getElementById('cancel')!.onclick = async () => {
await onCancel?.();
await host.close();
};
}initHost(LoginForm, { consumerOrigins }): starts the host runtime. It validates the incoming props with the component's schemas and infers their types, so the host needs no separate props interface.consumerOriginslists the consumer origins that may embed this page;'*'allows any. It returnsnullwhen the page is not opened by ForgeFrame.await host.ready: resolves to the consumer's props, and only those. Read props only after it resolves.host.close(): closes the iframe or popup. Every host control lives on thehosthandle, so a prop may use any name exceptthenandchildren.- Defining a component with
create()never starts a host, so importing the shared definition has no side effects.
3. Render the Component
Consumer
In the consumer app, create an instance with props and render it.
const login = LoginForm({
email: '[email protected]',
onLogin: (user) => console.log('User logged in:', user),
onCancel: () => console.log('Login cancelled'),
});
await login.render('#login-container');See Instance Lifecycle for URL resolution and for closing an instance during rendering.
4. Handle Events
Consumer
Subscribe to lifecycle events through instance.event. on() and once() return an unsubscribe function, and each handler receives a typed payload.
const instance = LoginForm({ /* props */ });
instance.event.on('rendered', () => console.log('Login form is ready'));
instance.event.on('close', () => console.log('Login form closing'));
const stop = instance.event.on('error', (err) => console.error('Error:', err.message));
instance.event.on('exports', (exports) => console.log('Host exports:', exports));
await instance.render('#container');
stop();A render emits render, then rendered. A close emits close, then closed.
| Event | Payload | Emitted when |
|-------|---------|--------------|
| render | none | Rendering starts, after props are validated and the container is resolved, before any iframe, popup, or placeholder exists. Calling close() here stops the render |
| rendered | none | The host initialized and the component is visible. render() has already resolved, so a listener may call close() |
| close | none | Closing starts, from close(), the host's close(), or the user closing the popup |
| closed | none | Teardown finished. Always the last event; it also follows a failed render, without close, except a POPUP_BLOCKED one, which leaves the instance open |
| error | Error | The handshake timed out (INIT_TIMEOUT), an export failed the exports schema (VALIDATION), or the host called host.reportError() (a plain Error with the host's message). Failures ForgeFrame detects are ForgeFrameErrors |
| props | props | A props update was committed and, when a host is connected, acknowledged |
| resize | Dimensions | A resize from the consumer or the host was applied |
| focus | none | A focus request was applied to a mounted iframe or an open popup |
| exports | exports | The host called host.export(); instance.exports already holds the value |
Only ForgeFrame emits these events: instance.event has no emit, off, or removeAllListeners. Subscribing to any other name throws, and the removed v1 names (prerender, prerendered, display, destroy) name their replacement. After closed, ForgeFrame removes every listener; later subscriptions are accepted and never called.
Handlers run synchronously in registration order, and a handler registered twice keeps its first registration. A handler added during an emission first runs on the next emission, so a once() handler can rearm itself. A handler removed before its turn is skipped; if it is re-registered during the same emission, it waits for the next one. An unsubscribe function removes only the registration it created. Thrown errors and rejected promises are caught and logged.
To use exports as soon as they arrive:
const exports = instance.exports ?? (await new Promise((resolve) => instance.event.once('exports', resolve)));Declared props are never called by ForgeFrame, so a prop named onClose or onError is an ordinary callback for the host to call.
Props System
Props are declared with the fluent prop schema builders. Every prop schema implements Standard Schema, and props also accept schemas from any Standard Schema library.
Defining Props
import { create, prop } from 'forgeframe';
const MyComponent = create({
tag: 'my-component',
url: 'https://widgets.example.com/component',
props: {
name: prop.string(),
count: prop.number(),
createdAt: prop.date(),
enabled: prop.boolean(),
config: prop.object({ locale: prop.string() }),
items: prop.array(prop.string()),
position: prop.tuple(prop.number(), prop.number()),
onSubmit: prop.function<(data: { email: string }) => void>(),
nickname: prop.string().optional(),
theme: prop.string().default('light'),
email: prop.string().email(),
age: prop.number().min(0).max(120),
username: prop.string().min(3).max(20),
slug: prop.string().pattern(/^[a-z0-9-]+$/),
status: prop.enum(['pending', 'active', 'completed']),
tags: prop.array(prop.string()),
scores: prop.array(prop.number().min(0).max(100)),
metadata: prop.record(prop.string()),
themeId: prop.union(prop.string(), prop.number()),
user: prop.object({
name: prop.string(),
email: prop.string().email(),
age: prop.number().optional(),
}),
},
});| Prop | Description |
|------|-------------|
| name, count, enabled, config, items | Basic types: string, number, boolean, object, array. Objects take their shape and arrays their item schema |
| createdAt | prop.date() validates real Date instances |
| position | prop.tuple(...) validates fixed positional arrays |
| onSubmit | Functions are bridged so the host can call them across origins |
| nickname | .optional() makes the prop accept undefined |
| theme | .default('light') provides a fallback value |
| email | .email() validates email format |
| age | .min(0).max(120) constrains the range |
| username | .min(3).max(20) constrains string length |
| slug | .pattern(/.../) validates against a regex |
| status | prop.enum([...]) restricts to specific values |
| tags | prop.array(prop.string()) validates each array item |
| scores | Array items can have their own validation chain |
| metadata | prop.record(...) validates dictionary values by key |
| themeId | prop.union(...) accepts any of several schemas |
| user | prop.object({...}) defines nested object structure |
A prop is required exactly when its schema rejects undefined: name is required, nickname is optional, and theme falls back to 'light'. The same rule applies to the object form below and on the host, and a missing value always fails with the schema's message, such as Validation failed: name: Required.
Prop Schema Methods
All schemas support these base methods:
| Method | Description |
|--------|-------------|
| .optional() | Makes the prop optional (accepts undefined) |
| .nullable() | Accepts null |
| .default(value) | Sets a non-callable default value, or a factory that returns the default |
A function prop's default must be a factory that returns the callback. ForgeFrame calls the factory when the input is omitted and validates its result; it never calls the returned callback while resolving the default.
A default replaces undefined input, so the output type of .optional().default(x) excludes undefined unless the default itself can be undefined.
const onCancel = () => console.log('Cancelled');
const callbackSchema = prop.function<() => void>().default(() => onCancel);Callback Capacity
Each delivered props or exports snapshot can hold up to 500 distinct callbacks per bridge; a callback used in several fields counts once. A larger snapshot is rejected, and callbacks already delivered stay callable. After the host acknowledges an update, callbacks missing from the new snapshot are retired.
During an update, the previous snapshot stays callable while new references are staged. If serialization fails, only the new registrations are removed. If delivery fails, the host may already have installed the new snapshot, so old and possibly delivered references both stay callable in a 1,000-reference recovery pool. When that pool is full, new callbacks are rejected until an acknowledged retry that reuses retained callbacks, a new host session, or teardown frees capacity. A transport failure does not roll back the consumer's committed props.
Peer discovery uses a separate relay registry with a cumulative 500-reference limit per requesting host session. Repeated discovery reuses retained identities and keeps peer snapshots the host already holds; a discovery that would exceed the limit is rejected as a whole. Reconnection clears old relay identities. When a peer replaces its exports, its original exported callbacks are retired.
Schema Types
| Type | Factory | Methods |
|------|---------|---------|
| String | prop.string() | .min(), .max(), .length(), .email(), .url(), .uuid(), .pattern(), .trim(), .nonempty() |
| Finite number | prop.number() | .min(), .max(), .int(), .positive(), .negative(), .nonnegative() |
| Date | prop.date() | .min(), .max() |
| Boolean | prop.boolean() | - |
| Function | prop.function<T>() | - |
| Array | prop.array(itemSchema) | .min(), .max(), .nonempty() |
| Tuple | prop.tuple(...schemas) | - |
| Object | prop.object({...}) | .strict() |
| Record | prop.record(schema) | - |
| Enum | prop.enum([...]) | - |
| Literal | prop.literal(value) | - |
| Union | prop.union(...schemas) | - |
| Any | prop.any<T>() | - |
Validation rules:
- Refinements accumulate:
prop.string().email().pattern(/@corp\.com$/)requires both, and.min(5).nonempty()still requires 5 characters. Length checks run first, then URL syntax, then patterns; number integer checks run before bounds. The first failed rule is reported. prop.number()rejectsNaN,Infinity, and-Infinity, which JSON would otherwise turn intonull.prop.string().url()requires a parseable absolute HTTP(S) URL and returns the string unchanged. It composes with.pattern(); only.trim()changes the returned string.prop.object(shape)andprop.array(item)require their shape or item schema. For a value you deliberately do not check, useprop.any<T>(); the type parameter only types the value, and it accepts any defined value at runtime.- Object schemas validate own fields only. An omitted field is
undefined, so optional fields and defaults work for names such asconstructorandtoString, and inherited values are never treated as input. - Enum values, object shapes, and the item and branch arrays passed to
prop.tuple()andprop.union()are copied when the schema is created. Changing the original array or object later does not affect the schema or the clones made by.optional(),.nullable(), and.default(). Dateprops and exports accept dates created in another window, and record schemas accept plain objects from another window; class instances are rejected.- Literal and enum errors describe the rejected input without calling its
toJSON(), so a laterprop.union()branch can still accept it.
Arrays in props and exports
Arrays delivered to the host, including nested arrays, must not contain undefined entries or sparse holes, because JSON would turn them into null. Use null with a nullable item schema, such as prop.array(prop.string().nullable()), or an item default, such as prop.array(prop.string().default('fallback')). Standalone schema validation still accepts optional entries, and props withheld by sendToHost or deliverTo are not checked.
ForgeFrame reads each indexed entry directly, so arrays with a null prototype or an overridden map method deliver their actual entries. Normalized arrays are checked before the host opens or an update is committed, so a rejected update keeps the previous snapshot. Values produced by a custom toJSON() (including a computed toJSON property) are checked during delivery, after commitment. A toJSON field that is not a function is ordinary data.
Using Standard Schema Libraries
ForgeFrame accepts any Standard Schema compliant library (Zod, Valibot, ArkType, etc.):
import { create } from 'forgeframe';
import { z } from 'zod';
import * as v from 'valibot';
const MyComponent = create({
tag: 'my-component',
url: 'https://widgets.example.com/component',
props: {
email: z.string().email(),
user: z.object({ name: z.string(), role: z.enum(['admin', 'user']) }),
count: v.pipe(v.number(), v.minValue(0)),
},
});Validation is synchronous. If a schema's ~standard.validate returns a promise, ForgeFrame throws an error naming the prop and handles the promise so its rejection is not reported as unhandled.
Advanced Prop Definitions
Use the object form when a prop needs transport rules in addition to validation. The schema still decides whether the prop is required and what its default is; the other fields only control how the value reaches the host.
import { create, prop } from 'forgeframe';
const SecureWidget = create({
tag: 'secure-widget',
url: 'https://widgets.example.com/secure',
props: {
secret: {
schema: prop.string(),
deliverTo: 'same-origin',
},
partnerToken: {
schema: prop.string(),
deliverTo: ['https://pay.example.com', /^https:\/\/[a-z]+\.partner\.example$/],
},
auditId: {
schema: prop.string().optional(),
queryParam: true,
},
internalState: {
schema: prop.any(),
sendToHost: false,
},
},
});| Option | Description |
|--------|-------------|
| schema | Required. Validates the prop, decides whether it is required, and supplies its default |
| sendToHost | Skip sending the prop to the host when set to false. Must be a boolean |
| deliverTo | Restrict which hosts receive the prop. 'same-origin' delivers only after the loaded host is verified to share the consumer's origin, so the value is never in window.name or the initial query/POST request. An origin matcher delivers only to matching host origins; create() rejects an empty array, an empty or blank origin, and 'same-origin' mixed into an array |
| queryParam / bodyParam | Include the prop in the host page's initial HTTP request: true uses the prop key, a string names the parameter, and false is the same as omitting it. Two props cannot share a parameter name, and neither option combines with deliverTo: 'same-origin' |
A wrapped definition accepts only these fields, so a misspelled option such as deliverto fails to compile and create() rejects it, instead of silently delivering the prop to every host.
auditId above is optional only because its schema is .optional(). Without it, { schema: prop.string(), queryParam: true } is required, exactly like prop.string().
Transforms and refinements belong in the schema, such as prop.string().trim() or a Zod .refine(), and cross-prop rules belong in the component's validate({ props }) option. To compute a value from other inputs, compute it before calling the factory or updateProps().
Each prop has one schema, and that schema must accept its own output unchanged. ForgeFrame runs it on the value you pass, then validates the normalized value again with the same schema at the consumer's trust boundaries and on the host, and rejects a value that comes back changed. Idempotent transforms such as prop.string().trim(), coercions such as z.coerce.number(), refinements and defaults all qualify, in the direct and the wrapped form, and for sendToHost: false props too. A transform that changes the type (string to number, string to Date), or that changes an already-normalized value again (such as dollars to cents), does not: create() reports it at compile time when props are inferred, and render() or updateProps() rejects it at runtime. To keep a convenient call site:
- Pass the type the host should receive:
z.number(), and convert before calling the factory orupdateProps(). - Accept both forms in one schema:
z.coerce.number(), orz.union([z.iso.datetime(), z.date()]).transform((value) => new Date(value)), whoseDateoutput is valid input. - Keep the raw type and convert it in the host after reading the prop.
import { z } from 'zod';
const AmountComponent = create({
tag: 'amount-component',
url: 'https://widgets.example.com/amount',
props: {
amount: z.coerce.number().finite().default(1),
},
});
AmountComponent({ amount: '2' }); // Host receives the number 2
AmountComponent(); // Host receives the default, 1prop.* defaults that are arrays, plain objects or Dates are copied for every use, so instances never share them. Other schema libraries may return the same default object to every instance; pass a factory such as z.array(z.string()).default(() => []) when a default is mutated.
A host evaluates sendToHost and deliverTo itself, with its own origin and the verified consumer origin, to decide which props it expects. Props it is not meant to receive are neither required nor accepted: a cross-origin host can share definitions that include a required 'same-origin' secret, and fields for those props, or for sendToHost: false props, are discarded from bootstrap and update snapshots before decoding or validation, even if a stale or misconfigured consumer sends them. Discarded values never reach await host.ready or prop notifications. The same applies to fields the host does not declare at all, including entries set to undefined, so no unvalidated value enters host state. Only a tag-agnostic initHost({ consumerOrigins }) host without props keeps every field, unvalidated.
Component and React-wrapper types are inferred from the schemas. The factory input can be wider than the host props, because defaults make keys optional and coercions accept more types; read it with InputOf<typeof C> and the props with PropsOf<typeof C> (see TypeScript). Definition types such as PropsDefinition<P> are typed by the props alone, so a defaulted schema fits without an input type.
Transport notes:
- Use
deliverTo: 'same-origin'for values that must never be exposed during cross-origin bootstrap. - Props always cross as JSON through the function bridge. Callbacks at any depth arrive as remote functions and
Datevalues arrive asDates, at bootstrap, on every update, and inhost.export(...). A value JSON cannot encode, such as aBigIntor a cycle, rejects with aVALIDATIONerror; for aBigIntitscauseis the encoder'sTypeError. In the first render's props,render()rejects with that error as soon as the host asks for its props, instead of waiting forinitTimeout. An error your owntoJSON()throws reaches you unchanged. - Ordinary objects that contain transport marker fields stay ordinary data.
- An object's
toJSON()result is delivered instead of its fields, as native JSON does, so a class can hide fields such as tokens. A roottoJSON()that returnsundefinedomits the prop. AURLarrives as itshref, and boxed primitives such asnew String('ab')arrive unboxed. A boxed primitive whose prototype was set tonullis not unboxed: it is sent as an ordinary record of its own fields. - Arrays are read entry by entry, and an array's
toJSON()is not called. - A
JSON.rawJSON()value arrives as the value its text represents, asJSON.stringifywrites it. Consumer schemas validate the raw value itself, so return it from a customtoJSON()or pass it under a schema that accepts it, such asprop.any().
Passing Props via URL or POST Body (Advanced)
Use these flags only when the host server needs values in its initial HTTP request:
const Checkout = create({
tag: 'checkout',
url: 'https://payments.example.com/checkout',
props: {
checkoutId: { schema: prop.string(), queryParam: true }, // ?checkoutId=...
requestNonce: { schema: prop.string(), bodyParam: true }, // POST body field
userId: { schema: prop.string(), bodyParam: 'user_id' }, // custom body field name
},
});queryParamappends the value to the query string before any#fragment, keeping existing parameters and the fragment.bodyParamsends the value in a hidden-formPOSTwhen the iframe or popup first loads. LaterupdateProps()calls use postMessage.- POST field names such as
submit,remove, andappendChilddo not interfere with submission or cleanup. Datevalues are sent as ISO strings and other object values are JSON-stringified. Functions,undefined,null, and invalid dates are skipped. To send a custom encoding, pass the encoded string as its own prop, or build the URL in aurl(props)function with ahostOriginsallowlist.- Iframe containers must be attached to the document when rendering, so a
bodyParamPOST always targets the rendered frame. sendToHostanddeliverToalso apply to query and body parameters, matched against the pinned host URL's origin. Same-origin status is verified only after the host loads, sodeliverTo: 'same-origin'props are never in the initial request.- Never put credentials, bearer tokens, session identifiers, or sensitive personal data in
queryParam. URLs leak through browser history, referrers, analytics, and server logs.
Updating Props
updateProps() works before, during and after rendering. Each update runs one sequence: merge the patch, run the schemas (which apply their defaults), check that each schema accepts its own output unchanged, run the component's validate, check the URL and origin, commit, deliver to the host, then notify. It resolves once the update is committed and, when a host is connected, acknowledged.
A patch is shallow, and undefined means "not supplied", the same as in the factory input:
| Patch entry for prop x | Consumer and host see |
|---|---|
| x left out | x unchanged |
| a value, including null | that value |
| undefined, schema has a default | the default, evaluated again (a callback default too) |
| undefined, optional without a default | no value: the host has no x key in its props or the onProps payload |
| undefined, required | the update rejects with the schema's message, and nothing changes |
In TypeScript, updateProps() takes a PropsPatch: it accepts declared props only, and undefined only for optional and defaulted props, so resetting a required prop with an object literal or a precisely typed patch is a compile-time error, as it is in the factory input. A patch typed Partial<Input> still compiles and meets the runtime rejection above, as JavaScript callers do. Generic code that forwards a patch for a generic I types it as PropsPatch<I, Partial<I>>; a plain Partial<I> parameter no longer compiles there.
Updates run in call order across the whole lifecycle, including updates requested from a schema transform, the component's validate, a url function or a lifecycle listener:
- Before
render():render()waits for them, so the initial query/body parameters and bootstrap props use their result. - While
render()is pending: they wait for the render to settle. The host starts with the snapshot the render began with, then receives each update as its ownonPropsnotification, and each update'spropsevent fires afterrendered. An invalid update rejects on its own; the render and later updates continue. If the render fails before anything opened, the updates apply locally and the nextrender()includes them. That covers invalid props or options, a componentvalidaterejection, a URL that is invalid or outsidehostOrigins, and a missing or detached container. A blocked popup also leaves the instance open, so the updates apply locally and a fallbackrender(container, 'iframe')includes them. If it fails after that, orclose()cancels it, they reject with aCLOSEDForgeFrameErrorwhosecauseis the render's own failure, so you can tell them apart from a later close. - After
close()starts: new and queued updates reject withCLOSED, without running validation or prop callbacks.
Do not await an update inside code the render waits on, such as a consumer callback the host calls before it sends INIT. The update waits behind the render, so the render stalls until initTimeout rejects it with INIT_TIMEOUT. Start the update without awaiting it, or await it after render() resolves.
const instance = MyComponent({ name: 'Initial', theme: 'dark' });
const rendering = instance.render('#container');
const updating = instance.updateProps({ name: 'Updated' }); // the host starts with 'Initial', then receives 'Updated'
await Promise.all([rendering, updating]);
await instance.updateProps({ theme: undefined }); // back to the schema defaultThe host receives updates through onProps, which returns an unsubscribe function. A subscriber registered before ready resolves also receives the bootstrap snapshot:
const stop = host.onProps((newProps) => {
console.log('Props updated:', newProps);
});
stop();newProps has the same shape as await host.ready: the consumer's props, without controls. The object ready resolved to is updated in place as well.
Subscribers run in registration order after props are committed. A subscriber added or re-registered during a notification waits for the next update, and a subscription cancelled before its turn is skipped. A stale cancellation handle cannot cancel a later registration of the same callback. Thrown errors and rejected promises are caught and logged. Async subscribers are not awaited, so they do not delay other subscribers or the update acknowledgement.
Two prop names are reserved, and create() and initHost() reject them (a definition with either name also fails to compile): then, because a props object with a then function would make await host.ready treat it as a promise and never settle, and children, so React JSX children never reach a host. A host without props drops fields with these names. Every other name is free, including close or resize, because host controls live on the host handle.
Cross-Window Callback Values
A callback implementation may return synchronously, but calling it from the other window always returns a promise. This applies to callbacks in await host.ready, onProps snapshots, and instance.exports. The exported RemoteValue<T> type describes this remote view, including callbacks nested in objects and arrays.
Declare every signature of a generic or overloaded callback with a Promise return type and use an async implementation. The automatic conversion of synchronous signatures cannot preserve overloads or the relationship between argument and result types.
// Consumer definition: the local implementation can be synchronous.
const Counter = create({
tag: 'counter',
url: 'https://widgets.example.com/counter',
props: { getCount: prop.function<() => number>() },
});
const counter = Counter({ getCount: () => 42 });
await counter.render('#container');
// Host: use await even though getCount's local implementation returns a number.
const host = initHost(Counter, { consumerOrigins });
if (host) {
const props = await host.ready;
const count = await props.getCount();
}Callback arguments and results are JSON-serialized. Pass plain objects, arrays, strings, finite numbers, booleans, and null; a callback may return undefined for no result. Convert Date values to ISO strings and copy plain fields out of FormData before the call. Functions in arguments or results are not bridged, and cyclic or BigInt results reject with a serialization error. Prop snapshots and host.export(...) are different: they bridge functions and preserve Date values.
RemoteValue<T> types this contract for synchronous signatures: a Date result is typed as its ISO string, and a parameter that JSON would change, such as a Date, is typed never, so the call does not compile. Promise-returning signatures keep their declared types, so declare them with JSON-compatible arguments and results.
Remote calls have no timeout. A call waits until the callback in the other window settles, so a slow step such as a payment confirmation is never reported as failed while it is still running. The call rejects only with the callback's own error, or when the connection ends: the instance closes, the host is destroyed, or the host document reconnects. After the host is destroyed, calls to its exports reject until a host initializes again in that window. While a call is pending, ForgeFrame checks every 10 seconds that the other window still answers, so a call into a page that navigated away or closed without teardown rejects within about 20 seconds. To bound a wait, race the call with your own timer, and remember that the callback may still finish in the other window.
Host Window API
In a host window, initHost() returns a host handle with the host controls, and await host.ready resolves to the consumer's props alone. Both are typed from the component passed to initHost; see Typing the host.
Props come from another origin. Render received strings with textContent or other DOM text properties, never by interpolating them into HTML.
In iframe mode, ForgeFrame sets sandbox="allow-scripts allow-same-origin allow-forms allow-popups" unless the consumer component sets the sandbox option. A string is written as-is and must include allow-scripts and allow-same-origin; sandbox: false writes no sandbox attribute. attributes may not set sandbox in any letter case. Popups are never sandboxed, because window.open() cannot apply one. See Sandbox.
Host Init with initHost
initHost() is the only way to start the host runtime. It reads channel metadata from window.name, checks the consumer origin against consumerOrigins, requests the initial props through origin-checked messaging, and validates them. It has two forms:
initHost(Component, { consumerOrigins, autoInit? })takes the component returned bycreate(). It validates props with the component's schemas, infers their types, and throws if the window was opened for another component's tag.initHost({ props?, consumerOrigins, autoInit? })accepts a window opened for any tag, for hosts that serve many components.propsare host prop definitions, and the host receives only the props they declare; without them, every delivered field is kept, unvalidated. With shared prop definitions, this form also keepscreate()and the consumer runtime out of a size-sensitive host bundle.
consumerOrigins is required in both forms; omitting it throws in every environment, before initHost() looks for a consumer, so a host page cannot ship open by accident. It takes a string, a RegExp, or an array of them (see Origin Security). Pass '*' only for a host that any site may embed. An empty array allows none, so configuration that resolves to no origins fails closed. A disallowed consumer makes initHost() throw before any props are requested.
initHost() returns a frozen Host handle:
| Member | Description |
|--------|-------------|
| ready | Resolves to the consumer's props once its verified bootstrap has passed validation. The object keeps its identity, and later updates are applied to it |
| uid, tag | This instance's identifiers |
| consumerOrigin | The consumer origin, checked against consumerOrigins before any props are requested and verified by the bootstrap once ready resolves |
| close(), focus(), resize(), show(), hide() | Host controls; see Available Methods. They work before ready resolves |
| export(), reportError(), getPeerInstances() | Talk back to the consumer |
| onProps(handler) | Observes prop updates; returns an unsubscribe function |
| init() | Sends INIT when autoInit is false; see Deferring INIT |
| destroy() | Tears down the host and tells the consumer the session ended |
| on('error', handler), once('error', handler) | Observes host errors; returns an unsubscribe function |
initHost() returns null without browser globals, outside a ForgeFrame window, for malformed channel metadata, or when the page has no parent or opener to talk to, so the page can also render standalone. Arguments are checked first, in every environment: a missing consumerOrigins, an unknown option, a v1 call shape such as initHost(definitions, origins), or a bare initHost() throws, even on a standalone page or the server. In a window opened by a consumer on another ForgeFrame major version, initHost() throws a VERSION_MISMATCH error that names the consumer's version and sends that consumer nothing; upgrade consumer and host together. A window that only keeps such a name, with no parent or opener left, gets null.
Only one host can be active in a window. A second initHost() call throws while a host is starting or running. Call host.destroy() before starting again; a later initHost() creates a fresh runtime from the retained channel metadata. A start whose ready rejects releases the host before your rejection handler runs, so the handler can call initHost() again.
create() never starts a host, even in a window opened for its tag.
isHost() returns true when a host runtime is active in this window, or when window.name holds a decodable ForgeFrame payload of any protocol version. A payload from an older or newer ForgeFrame counts, so the page reaches initHost() and its upgrade error instead of rendering standalone. It returns false outside a browser and for a name that has the ForgeFrame prefix but no valid payload, where initHost() returns null. It stays true after destroy(), because the channel metadata is kept for a re-start.
Only channel metadata stays in window.name, so reloads and later host documents can reconnect and receive the latest props. Browser policies that clear window names across sites can prevent reconnection.
host.on('error') reports each failed start once, always with a ForgeFrameError. A schema rejection reports its VALIDATION error. Any other failure, such as a failed BOOTSTRAP request or an invalid bootstrap response, reports an error whose message starts with Failed to initialize host component:, whose cause is the original failure, and whose code is the cause's, as INIT delivery failures do. A BOOTSTRAP or INIT request the consumer never answers is INIT_TIMEOUT. For a schema rejection, host.ready rejects with the same error; for a prefixed start-up error, it rejects with that error's cause, which has the same code. It rejects with CLOSED when the host is destroyed before bootstrap completes. When the consumer cannot send its first-render props, such as a BigInt, BOOTSTRAP fails and host.ready rejects with the consumer's VALIDATION error. INIT is sent after ready resolves, so a failed INIT reaches only the error event.
If the host's schemas reject the consumer's props, ready rejects and the host tells the consumer why. The consumer keeps waiting, so a same-page initHost() retry can still connect without any error being reported; if none succeeds, render() rejects at its initTimeout with INIT_TIMEOUT, whose message includes Host initialization failed: ... and whose cause is the host's error with its code, such as VALIDATION. The consumer's single error event carries the same error.
host.destroy() can be called more than once. The old handle's controls reject afterwards, and destroying a stale host never affects a newer one.
Deferring INIT
By default the host sends INIT as soon as ready resolves, which resolves the consumer's render(). To prepare the frame first, for example to hide or resize it, pass autoInit: false and call host.init() when ready:
const host = initHost(LoginForm, { consumerOrigins, autoInit: false });
if (host) {
await host.ready;
await host.hide();
host.init();
}init() returns nothing and is safe to call twice, before ready resolves (INIT then waits for it), or after destroy(). It sends nothing if ready rejects. INIT delivery failures are reported through host.on('error'). If the host never calls init(), the consumer's render() rejects at its initTimeout with INIT_TIMEOUT. Reading props never sends INIT.
A tag-agnostic host types its props from its definitions, keyed by the props it receives:
const amountProps = {
amount: z.number().finite().default(1),
} satisfies PropsDefinition<{ amount: number }>;
const host = initHost({ props: amountProps, consumerOrigins });Available Methods
import { initHost } from 'forgeframe';
import { LoginForm } from './login-form';
const host = initHost(LoginForm, { consumerOrigins: ['https://app.example.com'] });
if (host) {
const props = await host.ready;
props.email;
await props.onLogin({ id: 1, name: 'Ada' });
host.uid;
host.tag;
host.consumerOrigin;
await host.close();
await host.focus();
await host.resize({ width: 500, height: 400 });
await host.show();
await host.hide();
const stop = host.onProps((newProps) => { /* handle updates */ });
await host.reportError(new Error('Something failed'));
await host.export({ reset: () => undefined });
const peers = await host.getPeerInstances();
stop();
}Peer discovery is opt-in. A component that sets peers: 'same-origin' in create() lets its hosts discover peers and is listed to other hosts verified at the same origin as its own host. Without peers, its instances are never listed, and its hosts' getPeerInstances() rejects with an error naming the component and the option. Peers are other instances rendered by the same consumer; allTags: true adds other component tags, still only those that set peers. To let hosts on other origins, such as sibling subdomains, discover a component's instances, set peers to an origin matcher instead of 'same-origin'. Admission is one-way: the matcher on the listed component decides who may see it. Hosts with an opaque origin, such as sandboxed frames without allow-same-origin, neither discover peers nor are listed as peers, even when a matcher matches. getPeerInstances() resolves to PeerInfo[], each with uid, tag, and exports. A peer whose host is still loading is listed without exports; call getPeerInstances() again once it is ready. Peer exports keep data, Date values, and nested methods; methods return promises and are relayed through the consumer. Method references survive unrelated prop updates. After a peer replaces its exports or reconnects, its old methods reject, so call getPeerInstances() again. Discovery counts toward the relay limit in Callback Capacity.
| Member | Description |
|--------|-------------|
| props.email, props.onLogin | Your custom props and callbacks, from await host.ready |
| uid, tag | This instance's identifiers |
| close() | Close the component |
| focus() | Request focus for iframe/popup; resolves once this window has focus, or after a short wait, so the host can then focus its own element. It resolves without waiting when the consumer page has no focus to pass on. WebKit ignores focus() on elements in a cross-origin iframe until that frame has user activation, which can arrive a few frames later, so check document.activeElement and retry if the focus must land |
| resize() | Resize the component |
| show(), hide() | Toggle visibility |
| onProps() | Listen for prop updates (returns an unsubscribe function) |
| reportError() | Report an error to the consumer, which emits it on its error event |
| export() | Export methods/data to consumer |
| consumerOrigin | The consumer origin (a read-only string), verified once ready resolves |
| getPeerInstances() | Get peer component instances from the same consumer and host origin; requires peers on this host's component |
Exporting Data to Consumer
The host can export data and methods for the consumer to call. Describe them with the component's exports schema, which types both sides and is checked on every export.
Shared definition
export const MyComponent = create({
tag: 'my-component',
url: 'https://widgets.example.com/form',
exports: prop.object({
validate: prop.function<() => boolean>(),
getFormData: prop.function<() => { email: string }>(),
}),
});
Host
const host = initHost(MyComponent, { consumerOrigins });
if (host) {
await host.export({
validate: () => document.querySelector('form')!.checkValidity(),
getFormData: () => ({
email: document.querySelector<HTMLInputElement>('#email')!.value,
}),
});
}
Consumer
const instance = MyComponent();
await instance.render('#container');
const isValid = await instance.exports?.validate(); // Promise<boolean>
const data = await instance.exports?.getFormData();export() is the one way to send data to the consumer; it bridges functions and Date values, and exported methods always return promises on the consumer. To push events instead, call a callback prop the consumer passed in.
The consumer validates each export against exports before it replaces instance.exports and emits exports. An invalid export keeps the previous exports, rejects the host's export() with a VALIDATION error carrying the schema's message, and emits that error on the consumer. The host releases the callbacks of the rejected export. A host started with initHost(MyComponent, ...) checks its exports before sending them, so the rejection happens at the host's call site; the tag-agnostic initHost({ consumerOrigins }) has no exports schema and leaves the check to the consumer. The schema validates only: like a prop schema, it must return a valid value unchanged, so transforms are rejected. Describe methods with prop.function(), because Zod's z.function() returns a wrapper rather than the exported function, and keep unknown keys (Zod objects strip them unless declared loose). Without exports, exports are unknown and not validated.
Templates (Advanced)
Container Template
containerTemplate builds the element that wraps the iframe, such as a modal overlay.
const ModalComponent = create({
tag: 'modal',
url: 'https://widgets.example.com/modal',
dimensions: { width: 500, height: 400 },
containerTemplate: ({ doc, frame, prerenderFrame, close }) => {
const overlay = doc.createElement('div');
Object.assign(overlay.style, {
position: 'fixed',
inset: '0',
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: '1000',
});
overlay.onclick = (e) => { if (e.target === overlay) close(); };
const modal = doc.createElement('div');
Object.assign(modal.style, { background: 'white', borderRadius: '8px', overflow: 'hidden' });
const closeBtn = doc.createElement('button');
closeBtn.textContent = '×';
closeBtn.onclick = () => close();
modal.appendChild(closeBtn);
const body = doc.createElement('div');
if (prerenderFrame) body.appendChild(prerenderFrame);
if (frame) body.appendChild(frame);
modal.appendChild(body);
overlay.appendChild(modal);
return overlay;
},
});Prerender Template
prerenderTemplate builds the placeholder shown while the host loads.
const toCssSize = (value?: string | number) =>
typeof value === 'number' ? `${value}px` : (value ?? '');
const MyComponent = create({
tag: 'my-component',
url: 'https://widgets.example.com/component',
prerenderTemplate: ({ doc, dimensions }) => {
const loader = doc.createElement('div');
Object.assign(loader.style, {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: toCssSize(dimensions.width),
height: toCssSize(dimensions.height),
background: '#f5f5f5',
});
loader.textContent = 'Loading...';
return loader;
},
});React Integration (Optional)
React support lives in its own entry point, forgeframe/react. It needs react (16.8 or later; the tests run React 19), which is an optional peer dependency, so projects without React install nothing extra. The root forgeframe entry contains no React code and its types reference no React types. TypeScript resolves the subpath through the package exports map, so set moduleResolution to bundler, node16 or nodenext; node (node10) resolution cannot find forgeframe/react.
Basic Usage
import { useRef, useState } from 'react';
import { create, prop } from 'forgeframe';
import { createReactComponent, type ReactInstance } from 'forgeframe/react';
const LoginComponent = create({
tag: 'login',
url: 'https://auth.example.com/login',
dimensions: { width: 400, height: 350 },
props: {
email: prop.string().optional(),
onLogin: prop.function<(user: { id: number; name: string }) => void>(),
},
exports: prop.object({ reset: prop.function<() => void>() }),
});
const Login = createReactComponent(LoginComponent);
function App() {
const [user, setUser] = useState<{ id: number; name: string } | null>(null);
const login = useRef<ReactInstance<{ reset: () => void }>>(null);
return (
<div>
<h1>My App</h1>
<Login
email="[email protected]"
onLogin={(loggedInUser) => setUser(loggedInUser)}
onRendered={() => console.log('Ready')}
onError={(err) => console.error(err)}
onClose={() => console.log('Closed')}
instanceRef={login}
className="login-frame"
style={{ border: '1px solid #ccc' }}
/>
<button onClick={() => login.current?.exports?.reset()}>Reset</button>
</div>
);
}createReactComponent(Component) takes only the component. React DevTools shows the wrapper as ForgeFrame(<tag>), here ForgeFrame(login). Annotate a wrapper with ReactComponentOf<typeof LoginComponent>.
React Props
The React component accepts your component props alongside these wrapper props:
| Prop | Type | Description |
|------|------|-------------|
| onRendered | () => void | Called once the instance has rendered, right after instanceRef receives it |
| onError | (err: Error) => void | The instance's error event, plus construction, render, and prop-sync failures. Library failures are ForgeFrameErrors; branch on err.code, such as POPUP_BLOCKED |
| onClose | () => void | The instance's close event: the host or the user closed it, instanceRef.current.close() was called, or the wrapper unmounted or changed context |
| context | 'iframe' \| 'popup' | Render mode; changing it closes the instance and renders a new one |
| instanceRef | React.Ref<ReactInstance<Exports>> | Receives the rendered instance, and null when it closes, the wrapper unmounts, context changes, or rendering fails. Accepts a ref object or a callback ref |
| className | string | Container CSS class |
| style | React.CSSProperties | Container inline styles |
| ref | React.Ref<HTMLDivElement> | Access the wrapper's container element. It is attached during React's commit, so a parent's useLayoutEffect can read it, and React 19 callback-ref cleanups run on unmount |
The handle instanceRef receives (ReactInstance<Exports>) has the instance's uid, exports, event (on and once, including the exports event), resize(), focus(), show(), hide() and close(). It has no render() or updateProps(): the wrapper renders the instance and sends prop changes itself. It is set only after render() resolves, so every method works on it; while the frame loads it is null.
These names are reserved: createReactComponent throws a VALIDATION error for a component that declares a prop named onRendered, onError, onClose, context, instanceRef, className, style, ref or key (React keeps ref and key for itself). Give such a prop another name, such as onHostClose.
Removing a prop from your JSX resets it exactly as updateProps({ key: undefined }) does, and passing undefined counts as leaving the prop out, so theme={dark ? 'dark' : undefined} sends no update when it switches between the two. Commits made while the frame is still loading reach the host in order once it has started. After the instance closes, prop changes are not sent until a new instance renders (change context or the React key).
Construction and render failures go to onError and are shown inside the wrapper; sibling React content is unaffected, and the container ref stays attached. To recover, change context (which creates a fresh instance) or change the wrapper's React key to remount it.
Server rendering, React Server Components and Preact
- Server rendering: importing
forgeframe/reacthas no side effects. On the server the wrapper renders only its containerdiv; effects do not run, so no instance is created until the client hydrates. - React Server Components: the wrapper uses hooks, so render it inside a client component (a file with
"use client"). - Preact: alias
reacttopreact/compatin your bundler (for exampleresolve.alias: { react: 'preact/compat' }in Vite).
Advanced Features
Popup Windows
Pass 'popup' to render(), or set context: 'popup' on the component. If the browser blocks the popup, render() rejects with POPUP_BLOCKED and leaves the instance open, so you can fall back to an iframe on the same instance:
try {
await instance.render('#container', 'popup');
} catch (error) {
if (!(error instanceof ForgeFrameError) || error.code !== 'POPUP_BLOCKED')
throw error;
await instance.render('#container', 'iframe');
}
const PopupComponent = create({
tag: 'popup-component',
url: 'https://widgets.example.com/popup',
context: 'popup',
});After the popup initializes, ForgeFrame removes the loading content, including custom prerender content, and the default loading wrapper from the consumer mount. Caller-owned elements and custom container-template shells stay.
Browsers do not let one origin resize another origin's window. For a popup that is not open, instance.resize() does nothing. For a cross-origin popup, instance.resize() throws a VALIDATION error at the call, without emitting resize; call host.resize() from the host instead, which resizes the popup's own window and then notifies the consumer. Browsers can still ignore resize requests, for example for a popup opened as a tab.
Origin Security
Every trust decision is explicit, and '*' is the visible opt-in for "any origin".
Each option takes origins: a scheme, host and port, such as https://app.example.com. A bare host such as example.com never matches an origin, so it is rejected.
hostOriginsis consumer-side trust: the host origins the consumer will render and message. A staticurltrusts its own origin whenhostOriginsis omitted. A functionurlrequireshostOrigins, and every URL it returns must match;create()throws without it, so a URL built from props, a query string or tenant data can never point the consumer at an unlisted origin.hostOrigins: '*'with a functionurltrusts any http(s) origin the function returns.consumerOrigins, passed toinitHost(), is host-side trust: the consumer origins that may embed and initialize the host. It is required;'*'admits any http(s) consumer, and the host still binds the exact origin the browser verified, sohost.consumerOriginand outbound messages use that origin.peersdecides whether, and to which host origins, a component's instances are listed for peer discovery.- Use
hostOriginsandconsumerOriginstogether for payment, auth, account, and other sensitive workflows.
Each accepts a string, a RegExp, or an array mixing them, including a readonly as const array. A string is an exact origin, or '*' alone, which matches any origin. The only wildcard is a leading *. in the host, which stands for one or more labels: 'https://*.example.com' matches https://app.example.com and https://a.b.example.com, but not https://example.com, https://evilexample.com or http://app.example.com, and the scheme and port must match exactly. Any other *, such as 'https://*example.com', 'https://app.example.com*' or 'https://api.*.example.com', a pattern without a scheme, such as '*.example.com', one with a path, such as 'https://app.example.com/', and an exact origin in a form browsers never report, such as 'https://App.example.com' or 'https://example.com:443', throw a VALIDATION error that names the form to write. Use a RegExp for anything more flexible. Empty or blank strings, null, and other values throw, so a missing setting such as process.env.CONSUMER_ORIGIN || '' cannot silently trust every origin. An empty array throws for hostOrigins, peers, and deliverTo, where it could never match; for consumerOrigins it is valid and admits no consumer, so configuration that resolves to nothing fails closed.
No matcher, including '*' or a match-all RegExp, ever admits an opaque ("null") origin, such as a document sandboxed without allow-same-origin. Every opaque document serializes the same way, so the origin cannot identify a peer.
// secure-component.ts, shared by the consumer and the host
export const SecureComponent = create({
tag: 'secure',
url: 'https://secure.example.com/widget',
props: {
accountId: prop.string(),
onComplete: prop.function<(result: { ok: true }) => void>(),
},
hostOrigins: 'https://secure.example.com',
});
// A URL computed from props needs the host origins it may return
export const TenantWidget = create({
tag: 'tenant-widget',
url: (props) => `https://${props.tenant}.widgets.example.com/embed`,
hostOrigins: 'https://*.widgets.example.com',
props: { tenant: prop.string().pattern(/^[a-z0-9-]+$/) },
});On the host page, pass the component and the consumer allowlist to initHost:
const host = initHost(SecureComponent, {
consumerOrigins: [
'https://app.example.com',
'https://*.app.example.com',
/^https:\/\/[a-z0-9-]+\.partner\.example$/,
],
});
// A widget meant to be embedded anywhere says so explicitly
const publicHost = initHost(PublicWidget, { consumerOrigins: '*' });The host verifies the consumer by requiring a response from the expected parent or opener window at its exact origin. Verification works with Referrer-Policy: no-referrer and after full-page host navigation. If the origin is not allowed, initHost() throws ORIGIN_REJECTED before any props are requested, and the consumer's render() rejects at its initTimeout with INIT_TIMEOUT; if the consumer cannot complete the handshake, ready rejects. The allowlist belongs to the host that enforces it, so create() throws if it still receives the v1 host allowlist option. As defence in depth, a host can also send a Content-Security-Policy: frame-ancestors header; ForgeFrame does not set it.
Sandbox
The sandbox component option sets the iframe's sandbox attribute:
| sandbox | Iframe attribute |
|---|---|
| Omitted | allow-scripts allow-same-origin allow-forms allow-popups |
| A string | Written as given. It must include allow-scripts, because the host runs script to call initHost(), and allow-same-origin, because without it the host has an opaque origin that no allowlist admits. Other tokens are left to the browser |
| false | No sandbox attribute |
const Checkout = create({
tag: 'checkout',
url: 'https://pay.example.com/checkout',
sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox',
});create() throws for any other value, and for attributes.sandbox in any letter case; a computed attributes function that returns sandbox rejects render() before an iframe is created. Popups are never sandboxed, because window.open() cannot apply a sandbox; a component that sets sandbox renders popups without one and without a warning. The default combines allow-scripts and allow-same-origin, which lets a same-origin host remove its own sandbox, so it protects only against a cross-origin host.
Pre-render Checks
The component validate option is the single gate before rendering. render() runs schema validation and then `valid
