Framie is an iframe widget SDK for building embedded products with a strict host/iframe contract.
It is split into three packages:
@framie/core: host-side widget lifecycle, DOM mounting, iframe transport, handshake, typed messaging.@framie/peer: iframe-side transport for code running inside the embedded app.@framie/react: React bindings on top of@framie/core.
Framie gives you:
- a host-side widget object that mounts and unmounts an iframe safely
- a message bus over
postMessage - request/response RPC on top of events
- bidirectional handshake with protocol version checks
- buffering on the host until the iframe is actually ready
- React ergonomics without hiding the underlying imperative widget
Typical use case:
- The host app opens an iframe widget.
- The iframe bootstraps its own app.
- Both sides communicate through typed events and typed requests.
Install the packages you need.
npm install @framie/corenpm install @framie/core @framie/reactnpm install @framie/core @framie/peerFor this monorepo itself:
npm install
npm run buildReady-to-copy examples live in examples/README.md.
Use them as starting points for:
- host-side widget control with
@framie/core - iframe-side messaging with
@framie/peer - React integration with
@framie/react - end-to-end host/iframe flows
Framie uses a host/peer model.
- Host page: creates and controls the widget with
@framie/core - Iframe app: joins the channel with
@framie/peer
Handshake flow:
- Host mounts an iframe.
- On iframe
load, host sends__framie:hellowithsdkVersionandprotocolVersion. - Peer app calls
peer.ready(). - Peer sends
__framie:readywith its own version payload. - Host validates protocol compatibility.
- If compatible, queued host messages are flushed.
- If incompatible,
onErroris called and the queue remains blocked.
Important behavior:
- Host
send()andrequest()calls are buffered until the peer is ready. - Peer
ready()is not buffered. It is the readiness signal. - A protocol mismatch is treated as a hard integration error.
import { createWidget } from "@framie/core";
const widget = createWidget({
url: "https://widget.example.com",
mode: "modal",
onAfterOpen: () => {
console.log("widget opened");
},
onAfterClose: () => {
console.log("widget closed");
},
onError: (error) => {
console.error("framie error", error);
widget.destroy();
},
});
widget.mount({
userId: "u_123",
plan: "pro",
});import { PeerChannel } from "@framie/peer";
const peer = new PeerChannel({
allowedOrigin: "https://app.example.com",
onError: (error) => {
console.error("protocol mismatch", error);
},
});
peer.on("theme:set", ({ theme }) => {
document.documentElement.dataset.theme = theme;
});
peer.onRequest("auth:get-token", async () => {
return { token: "abc123" };
});
peer.ready();import * as React from "react";
import { useFramie } from "@framie/react";
export function CheckoutButton() {
const framie = useFramie({
url: "https://widget.example.com",
onError: (error) => {
console.error(error);
framie.destroy();
},
});
return (
<button type="button" onClick={() => framie.mount({ cartId: "cart_1" })}>
Open checkout
</button>
);
}@framie/core is the host-side package.
import {
createWidget,
FramieWidget,
FramieChannel,
HandshakeError,
} from "@framie/core";Creates a FramieWidget.
const widget = createWidget({ url: "https://widget.example.com" });interface FramieOptions {
url: string;
mode?: "modal" | "bottomSheet";
container?: HTMLElement;
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
onBeforeMount?: () => void;
onMount?: () => void;
onBeforeUnmount?: () => void;
onUnmount?: () => void;
onMinimize?: () => void;
onRestore?: () => void;
onBeforeOpen?: () => void;
onAfterOpen?: () => void;
onBeforeClose?: () => void;
onAfterClose?: () => void;
onError?: (error: Error) => void;
}Notes:
urlmust usehttp:orhttps:.containerdefaults todocument.body.contextis not part ofFramieOptions; it is passed tomount(context).onBeforeOpenandonAfterOpenare product-facing aliases for mount lifecycle.onBeforeCloseandonAfterCloseare product-facing aliases for unmount lifecycle.
Main host-side controller.
state: WidgetStatechannel: FramieChannel
WidgetState values:
"idle" | "mounting" | "mounted" | "minimized" | "unmounting" | "unmounted" | "destroyed"Creates DOM, inserts the iframe, attaches the channel, starts the handshake.
widget.mount({ userId: "u1", plan: "pro" });Behavior:
contextis appended to the iframe URL as query params.- calling
mount()when already mounted is a no-op.
Removes the widget DOM and detaches the channel.
widget.unmount();Hides the widget without destroying it.
widget.minimize();Restores the widget from the minimized state.
widget.restore();Final teardown. Rejects pending requests, removes listeners, and permanently disables the widget instance.
widget.destroy();Lifecycle event emitter.
Events:
type FramieEventMap = {
beforeMount: void;
mount: void;
beforeUnmount: void;
unmount: void;
minimize: void;
restore: void;
destroy: void;
};Example:
const off = widget.on("mount", () => {
console.log("mounted");
});
off();The transport object exposed at widget.channel.
- outgoing messages are sent only to
targetOrigin - incoming messages are accepted only from the mounted iframe window
- non-Framie messages are ignored
Fire-and-forget event to the peer.
widget.channel.send("theme:set", { theme: "light" });If the peer is not ready yet, the message is buffered.
Send a request and await a response.
const profile = await widget.channel.request<{ name: string }>("profile:get");With timeout and cancellation:
const controller = new AbortController();
const result = await widget.channel.request<{ ok: boolean }>(
"checkout:validate",
{ coupon: "SAVE10" },
{
timeoutMs: 3000,
signal: controller.signal,
},
);RequestOptions:
interface RequestOptions {
timeoutMs?: number;
signal?: AbortSignal;
}Subscribe to a regular peer message.
const off = widget.channel.on<{ orderId: string }>("checkout:success", ({ orderId }) => {
console.log(orderId);
});Register a host-side RPC handler for peer-initiated requests.
const off = widget.channel.onRequest<{ sku: string }, { price: number }>(
"product:get-price",
async ({ sku }) => {
return { price: await fetchPrice(sku) };
},
);Whether the peer handshake has completed successfully.
Advanced protocol symbols are exported from @framie/core as well:
FRAMIE_MARKER
HELLO_EVENT
READY_EVENT
PROTOCOL_VERSION
SDK_VERSION
HandshakeErrorThese are useful for testing, diagnostics, or very low-level integrations.
@framie/peer is the iframe-side package.
import { PeerChannel, HandshakeError } from "@framie/peer";interface PeerChannelOptions {
allowedOrigin: string;
requestTimeout?: number;
sdkVersion?: string;
onError?: (error: Error) => void;
}Notes:
allowedOriginis the expected host origin.- use
"*"only in trusted or development-only scenarios. onErroris called when the host sends an incompatible protocol version.
Signals that the iframe app is ready and completes the handshake from the peer side.
peer.ready();Call this after your iframe app is initialized enough to receive messages.
Send an event to the host.
peer.send("checkout:success", { orderId: "o_1" });Send an RPC request to the host.
const token = await peer.request<{ token: string }>("auth:get-token");PeerRequestOptions:
interface PeerRequestOptions {
timeoutMs?: number;
signal?: AbortSignal;
}Subscribe to host events.
const off = peer.on<{ theme: string }>("theme:set", ({ theme }) => {
document.documentElement.dataset.theme = theme;
});Register an iframe-side RPC handler.
peer.onRequest("profile:get", async () => {
return { name: "Alice" };
});Removes listeners and rejects pending requests.
peer.destroy();@framie/react keeps the widget imperative, but makes it easy to integrate into React lifecycles.
import {
useFramie,
useFramieState,
useFramieEvent,
useFramieChannelEvent,
useFramieRequestHandler,
} from "@framie/react";Returns a stable controller handle.
const framie = useFramie({
url: "https://widget.example.com",
});Handle API:
interface FramieHandle {
getWidget(): FramieWidget;
mount(context?: WidgetContext): void;
unmount(): void;
minimize(): void;
restore(): void;
destroy(): void;
}Behavior:
- widget creation is lazy
- the same widget instance is reused while options are shallow-equal
- if options change, the previous widget is destroyed and a fresh one is created
- on React unmount, the widget is destroyed automatically
Example:
function BillingButton() {
const framie = useFramie({ url: "https://billing.example.com" });
return (
<button type="button" onClick={() => framie.mount({ customerId: "c_42" })}>
Open billing
</button>
);
}Controlled React wrapper around useFramie.
function ControlledWidget({ open }: { open: boolean }) {
const framie = useFramieState({
url: "https://widget.example.com",
open,
context: { source: "sidebar" },
});
return null;
}Options:
interface UseFramieStateOptions extends FramieOptions {
open: boolean;
context?: WidgetContext;
destroyOnClose?: boolean;
}Notes:
open: truemounts the widgetopen: falseunmounts it- with
destroyOnClose: true, the instance is destroyed instead of just unmounted contextis applied when the widget transitions into the open state
Subscribe to widget lifecycle events from React.
function Example() {
const framie = useFramie({ url: "https://widget.example.com" });
useFramieEvent(framie, "mount", () => {
console.log("widget mounted");
});
return <button onClick={() => framie.mount()}>Open</button>;
}Subscribe to peer-to-host messages from React.
function Example() {
const framie = useFramie({ url: "https://widget.example.com" });
useFramieChannelEvent<{ orderId: string }>(framie, "checkout:success", ({ orderId }) => {
console.log(orderId);
framie.unmount();
});
return <button onClick={() => framie.mount()}>Open</button>;
}Register host-side request handlers from React.
function Example() {
const framie = useFramie({ url: "https://widget.example.com" });
useFramieRequestHandler(framie, "auth:get-token", async () => {
return { token: await getAccessToken() };
});
return <button onClick={() => framie.mount()}>Open</button>;
}import { createWidget } from "@framie/core";
const widget = createWidget({
url: "https://widget.example.com/checkout",
onAfterOpen: () => console.log("opened"),
onError: (error) => {
console.error(error);
widget.destroy();
},
});
widget.channel.on("checkout:success", ({ orderId }) => {
console.log("success", orderId);
widget.unmount();
});
widget.channel.onRequest("auth:get-token", async () => {
return { token: await getAccessToken() };
});
document.querySelector("#open-checkout")?.addEventListener("click", () => {
widget.mount({ cartId: "cart_1", locale: "en" });
});import { PeerChannel } from "@framie/peer";
const peer = new PeerChannel({
allowedOrigin: "https://app.example.com",
});
peer.onRequest("checkout:get-draft", async () => {
return { items: [] };
});
async function finishCheckout() {
const auth = await peer.request<{ token: string }>("auth:get-token");
console.log(auth.token);
peer.send("checkout:success", { orderId: "ord_123" });
}
peer.ready();Framie is strict by default.
- Host only accepts messages from the configured origin.
- Host only accepts messages from the exact mounted iframe window.
- Peer only accepts messages from
allowedOriginunless configured with"*". - Non-Framie
postMessagetraffic is ignored.
Still recommended:
- always set a precise
url - always use a precise
allowedOrigin - avoid
"*"outside development or highly trusted environments - keep
@framie/coreand@framie/peerversions aligned
widget.mount({
userId: "u1",
locale: "ru",
feature: "upsell",
});This becomes query params on the iframe URL.
widget.channel.send("ui:set-theme", { theme: "dark" });
peer.send("analytics:event", { name: "checkout_started" });const coupon = await widget.channel.request<{ valid: boolean }>("coupon:validate", {
code: "SAVE10",
});peer.onRequest("product:get-price", async ({ sku }) => {
return { price: await fetchPrice(sku) };
});function Example({ open }: { open: boolean }) {
useFramieState({
url: "https://widget.example.com",
open,
destroyOnClose: false,
});
return null;
}npm run build
npm run typecheck
npm run test
npm run coverage
npm run devThe monorepo currently has:
- host widget lifecycle in
@framie/core - typed messaging and RPC in
@framie/coreand@framie/peer - handshake and protocol version validation
- React integration built around explicit
mount()control
@framie/react intentionally does not hide the widget behind a declarative component. The recommended model is: keep widget control explicit and call mount() from application code when the UI wants to open the iframe.