frame-bridge
Core library. Works in any JS/TS project. No runtime dependencies — only browser APIs.
createBridge<T>(options)
Creates a bridge instance. The generic T defines the message type — it's enforced on both send() and onMessage().
import { createBridge } from "@mhanzelka/frame-bridge";
type AppMessage =
| { type: "ping"; value: number }
| { type: "pong"; value: number };
const bridge = createBridge<AppMessage>({
channelName: "my-channel",
role: "parent",
enabled: ["broadcast-channel"],
});
await bridge.open();| Prop / Method | Type | Default | Description |
|---|---|---|---|
| channelName | string | — | Must match on both sides. |
| role | "parent" | "child" | — | Each side picks a role. For MessageChannel, parent initiates. |
| enabled | TransportType[] | — | Which transports to use: "broadcast-channel", "post-message-channel", "message-channel". Order matters — the first listed transport that is open becomes the default for send/sendEvent (override per-call via options.preferredTransport). |
| targetOrigin | string | — | Required for "post-message-channel". Use "same-origin" for same-origin targets. |
| target | Window? | — | Target window for "post-message-channel". Optional in child contexts — auto-detects window.parent (iframe) or window.opener (popup). Parent contexts pass it explicitly (or via setTarget when the iframe mounts). |
| id | string | — | Explicit bridge instance id used as-is — replaces the random one. Useful for naming endpoints so peers can address each other deterministically via options.targetId. Caller is responsible for uniqueness within the same channelName. Ignores prefix when set. |
| prefix | string | — | Optional prefix for the generated bridge ID. Ignored when id is set. |
| options.resolveMessageKey | (msg) => string | — | Custom key used in timeout error messages. |
Bridge<T> methods
| Prop / Method | Type | Default | Description |
|---|---|---|---|
| open() | Promise<void> | — | Open all enabled transports. |
| close() | void | — | Close all transports, reject pending requests. |
| send(data, options?) | Promise<T> | — | Send a request and await the response. Pass options.targetId to address a specific bridge instance. |
| sendEvent(data, options?) | void | — | Fire-and-forget — no response expected. Pass options.targetId to address a specific bridge instance instead of broadcasting. |
| onMessage(handler) | () => void | — | Register incoming message handler. Returns unsubscribe function. |
| isOpen() | boolean | — | True if at least one transport is open. |
| active() | BridgeActiveTransports | — | Lists currently enabled and open transports. |
| waitForReady(options?) | Promise<void> | — | Polls the peer with a sys ping until it answers (or the deadline expires). Use after open() to gate the first send so it doesn't hit a not-yet-mounted child. |
| enable(type) | Promise<void> | — | Enable a transport at runtime. |
| disable(type) | void | — | Disable a transport at runtime. |
| setTarget(win, origin) | void | — | Update the postMessage target window and origin. |
| addMessageObserver(fn) | () => void | — | Subscribe to all sent/received messages for debugging. Each event carries direction: "in" | "out" and the transportType. |
| state | BridgeStateStore | — | Reactive state store, compatible with useSyncExternalStore. |
Send options
const reply = await bridge.send(data, {
timeout: 5000, // ms — throws if no reply (default: 10 000)
signal: abortController.signal, // AbortSignal to cancel
transfer: [arrayBuffer], // Transferable objects
preferredTransport: "message-channel", // hint which transport to use
targetId: peerBridge.id, // address a specific bridge instance (see Targeted addressing)
});Events and handlers
// Fire-and-forget — no reply expected
bridge.sendEvent({ type: "user-activity", timestamp: Date.now() });
// Address an event to a specific bridge instance (others ignore it)
bridge.sendEvent({ type: "focus" }, { targetId: peerBridge.id });
// Register handler for incoming messages
const unsubscribe = bridge.onMessage(async (msg, source) => {
if (msg.type === "ping") return { type: "pong", value: msg.value + 1 };
});
// Remove handler later
unsubscribe();Targeted addressing
Each bridge has a per-instance id exposed on the returned object. Pass it as targetId to send() or sendEvent() to address a specific peer. Bridges with a different id ignore the message at the handler layer (observers still see it for debugging).
Mostly relevant on broadcast-channel, where three or more tabs share the same channel — without addressing, every listener races to answer the same request. Responses are always addressed back to the original requester, so unrelated tabs drop them early.
// Three tabs share the same BroadcastChannel — without targetId, every listener races
// to answer a request, only the first reply wins, and other replies leak as warnings.
//
// Either let the bridge generate an id (random + optional prefix), or pass an explicit
// id to name the endpoint. Caller is responsible for keeping ids unique on the channel.
const lobby = createBridge<Msg>({
id: "lobby", // deterministic id — peers can target it by name
channelName: "rooms",
role: "parent",
enabled: ["broadcast-channel"],
});
await lobby.open();
console.log(lobby.id); // "lobby"
// Send addressed only to a specific peer — others see the message in their observer
// (handy for devtools) but skip the onMessage handler entirely.
const reply = await lobby.send({ type: "ping" }, { targetId: "room-42" });
// Targeted event — only the addressed bridge handles it. Omit targetId to broadcast.
lobby.sendEvent({ type: "focus" }, { targetId: "room-42" });
// Responses are always addressed back to the original requester, so unrelated tabs
// sharing the BroadcastChannel ignore them at the filter layer (no pendingStore lookup,
// no handler invocation).id, the bridge generates a random one on every createBridge() call — per-instance, not stable across reloads. Pass id to name the endpoint deterministically (peers can then target it by name without a discovery handshake).Cross-origin iframe
Use post-message-channel for cross-origin targets. Both sides must specify each other's origin.
// parent.ts — host page
const parent = createBridge({
channelName: "iframe-channel",
enabled: ["post-message-channel"],
role: "parent",
targetOrigin: "https://child.example.com",
target: iframeElement.contentWindow,
});
await parent.open();
// child.ts — inside the iframe at child.example.com
const child = createBridge({
channelName: "iframe-channel",
enabled: ["post-message-channel"],
role: "child",
targetOrigin: "https://parent.example.com",
// target auto-detected from window.parent
});
await child.open();
child.onMessage(async (msg) => ({ ...msg, received: true }));targetOrigin: "*" in production — it skips origin validation on receive.Waiting for the peer
Use waitForReady() after open() to gate the first send. It polls the other side with a system ping until it answers — useful when the iframe loads before the child bridge mounts.
const bridge = createBridge({
channelName: "iframe-channel",
enabled: ["post-message-channel"],
role: "parent",
targetOrigin: "https://child.example.com",
target: iframeElement.contentWindow,
});
await bridge.open();
// Wait until the child bridge answers a sys ping before sending the first request.
// Defaults: timeoutMs = 5000, intervalMs = 200.
await bridge.waitForReady({ timeoutMs: 5000 });
await bridge.send({ type: "init", config });Popup windows
import { openBridgeWindow } from "@mhanzelka/frame-bridge/bridge/BridgeUtils";
await openBridgeWindow({
url: "/popup",
name: "my-popup",
onMessage: async (msg) => ({ ...msg, handled: true }),
onBridgeReady: async (bridge) => {
await bridge.send({ type: "init" });
},
});Debug logging
import { enableBridgeDebug, disableBridgeDebug } from "@mhanzelka/frame-bridge";
enableBridgeDebug(); // prints all bridge activity to console
disableBridgeDebug(); // silence (default)