frame-bridge
@mhanzelka/frame-bridge

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 / MethodTypeDefaultDescription
channelNamestringMust match on both sides.
role"parent" | "child"Each side picks a role. For MessageChannel, parent initiates.
enabledTransportType[]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).
targetOriginstringRequired for "post-message-channel". Use "same-origin" for same-origin targets.
targetWindow?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).
idstringExplicit 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.
prefixstringOptional prefix for the generated bridge ID. Ignored when id is set.
options.resolveMessageKey(msg) => stringCustom key used in timeout error messages.

Bridge<T> methods

Prop / MethodTypeDefaultDescription
open()Promise<void>Open all enabled transports.
close()voidClose 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?)voidFire-and-forget — no response expected. Pass options.targetId to address a specific bridge instance instead of broadcasting.
onMessage(handler)() => voidRegister incoming message handler. Returns unsubscribe function.
isOpen()booleanTrue if at least one transport is open.
active()BridgeActiveTransportsLists 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)voidDisable a transport at runtime.
setTarget(win, origin)voidUpdate the postMessage target window and origin.
addMessageObserver(fn)() => voidSubscribe to all sent/received messages for debugging. Each event carries direction: "in" | "out" and the transportType.
stateBridgeStateStoreReactive 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).
Without an explicit 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 }));
Never use 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)