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".
targetOriginstringRequired for "post-message-channel". Use "same-origin" for same-origin targets.
targetWindowTarget window for "post-message-channel".
prefixstringOptional prefix for the generated bridge ID.
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.
sendEvent(data)voidFire-and-forget — no response expected.
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.
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.
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
});

Events and handlers

// Fire-and-forget — no reply expected
bridge.sendEvent({ type: "user-activity", timestamp: Date.now() });

// 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();

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: window.parent,
});

await child.open();
child.onMessage(async (msg) => ({ ...msg, received: true }));
Never use targetOrigin: "*" in production — it skips origin validation on receive.

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)