Turn postMessage into real request/response workflows

frameport is a zero-dependency library that makes parent/iframe communication feel predictable instead of ad hoc. It wraps window.postMessage in a small channel API so you can move real behavior between windows without re-solving message names, reply handling, timeout behavior, and late iframe startup every time.

That makes it a strong fit when you want to:

Keep parent and iframe in sync Push updates such as theme changes, ready signals, or host state across windows without inventing a new message contract for every feature.
Ask the other side for data Treat cross-window communication more like a small API call when the parent or child needs configuration, profile data, or computed results.
Start safely when the iframe loads later Prepare the communication flow early, then connect it as soon as the embedded window becomes available.

Quickstart

This is the absolute minimum setup for a parent page and an iframe to talk through frameport. The parent uses lazyChannel(), the child sends one simple boot message, and the parent performs one simple request that the child answers.

Use the same id and the same availableMessages on both sides.
Parent side

Minimal parent HTML

<!doctype html>
<html>
  <body>
    <iframe id="quickstart-frame" src="./child.html"></iframe>

    <script src="./frameport.js"></script>
    <script src="./parent.js"></script>
  </body>
</html>
Iframe side

Minimal iframe HTML

<!doctype html>
<html>
  <body>
    <div>Iframe page UI</div>

    <script src="./frameport.js"></script>
    <script src="./child.js"></script>
  </body>
</html>
Parent side

Minimal parent.js

const iframe = document.getElementById("quickstart-frame");

// Parent page: define the channel before the iframe is ready.
const pendingChannel = frameport.lazyChannel({
  id: "quickstart-demo",
  availableMessages: ["child-ready", "get-answer"],
});

pendingChannel.onInit(async function (channel) {
  // Listen for a simple boot message from the child.
  channel.listen("child-ready", function (message) {
    console.log("Child says:", message.payload.text);
  });

  // Ask the child to answer one simple request.
  const response = await channel.request(
    "get-answer",
    { timeout: 2000 },
    { question: "Hello from parent" }
  );

  console.log("Child answered:", response.payload.text);
});

// Connect the real iframe transport once contentWindow exists.
iframe.addEventListener("load", function () {
  if (!iframe.contentWindow) {
    return;
  }

  pendingChannel.init(
    frameport.defaultIFrameGateway({
      currentWindow: window,
      targetWindow: iframe.contentWindow,
    })
  );
});
Iframe side

Minimal child.js

// child.js: create the channel immediately.
const channel = frameport.createChannel({
  id: "quickstart-demo",
  availableMessages: ["child-ready", "get-answer"],
  ...frameport.defaultIFrameGateway({
    currentWindow: window,
    targetWindow: window.parent,
  }),
});

// Answer one simple request coming from the parent.
channel.respond("get-answer", async function (payload) {
  return {
    text: `Child received: ${payload.question}`,
  };
});

// Send one one-way message to announce the iframe is ready.
channel.send("child-ready", {
  text: "Iframe booted and ready.",
});

Demo

The live example below shows the host window, the embedded iframe, and a shared event stream side by side.

Parent Window

These controls live in the host page and talk to the iframe through frameport.

Last child status
none yet
Last iframe profile
not requested yet
Current theme
midnight
Message whitelist
5 shared message names

Child Window

This middle column is a real embedded <iframe>. The host cannot call child functions directly; all interaction goes through postMessage and frameport.

Embedded iframe

Events

See a combined event stream from both windows and filter by side.

Showing both parent and child events.

Code Examples

These examples mirror the scenarios shown in the demo and explain what each method solves, when to reach for it, and how the pieces fit together.

Setup

createChannel() + defaultIFrameGateway()

Use this when you already have access to both windows and want to turn raw postMessage into a named, validated channel.

// Add this on the parent side once iframe.contentWindow exists.
const channel = frameport.createChannel({
  id: "app-shell",
  availableMessages: [
    "parent-notify",
    "child-status",
    "get-profile",
    "get-host-theme",
  ],
  ...frameport.defaultIFrameGateway({
    currentWindow: window,
    targetWindow: iframe.contentWindow,
  }),
});

What it solves: it centralizes the low-level transport wiring. Instead of every caller remembering how to attach listeners, call postMessage, and filter the incoming message shape, you create one channel object that owns the browser-specific details.

How to use it: both sides of the connection should use the same id and the same set of availableMessages. That gives you a small contract: if code tries to send or listen to a message name outside that list, frameport throws instead of silently failing.

One-way events

send() + listen()

Use this pair for notifications and state broadcasts where the sender does not need a reply.

// Add this on the iframe side if the child should react to parent notices.
channel.listen("parent-notify", function (message) {
  console.log("Parent says:", message.payload.text);
});

// Add this on the parent side when the host wants to notify the iframe.
channel.send("parent-notify", {
  text: "The host theme changed to forest.",
});

What it solves: this is the clean replacement for ad hoc event payloads passed directly through postMessage. It works well for notifications such as “theme changed”, “iframe booted”, or “new unread count”.

How to use it: register a listener on the side that should react, then call send() from the side that emits the update. The listener receives the full channel message, including channelId, messageName, and the typed payload, so debugging stays straightforward.

Request / response

request() + respond()

Use this when one side needs data or work from the other and you want the interaction to feel like an async function call.

// Add this on the iframe side if the child owns the profile data.
channel.respond("get-profile", async function (payload) {
  return {
    id: payload.userId,
    name: "Ada Lovelace",
    role: "analysis engine operator",
  };
});

// Add this on the parent side when the host needs that profile.
const response = await channel.request(
  "get-profile",
  { timeout: 2000 },
  { userId: 42 }
);

console.log(response.payload.name);

What it solves: raw postMessage has no built-in concept of “reply to this exact request”. frameport adds a generated requestId, matches the reply for you, and rejects if the timeout expires.

How to use it: define the responder on the side that owns the data, then call request() from the caller side. This is ideal for “get profile”, “fetch app config”, “ask the host for theme”, or any other API-like interaction between windows.

Late iframe load

lazyChannel()

Use this when the iframe window does not exist yet, but you still want to prepare the channel logic in advance.

// Add this on the parent side when the iframe loads later than the host page.
const pendingChannel = frameport.lazyChannel({
  id: "app-shell",
  availableMessages: ["ready", "get-profile"],
});

pendingChannel.onInit(function (channel) {
  channel.listen("ready", function () {
    console.log("iframe connected");
  });
});

iframe.addEventListener("load", function () {
  pendingChannel.init(
    frameport.defaultIFrameGateway({
      currentWindow: window,
      targetWindow: iframe.contentWindow,
    })
  );
});

What it solves: in real pages, the parent often renders before the iframe is ready, so iframe.contentWindow is unavailable during initial setup. lazyChannel() lets you describe the channel early and attach behavior before the transport exists.

How to use it: create the lazy channel with the shared id and message names, register onInit() callbacks, and call init() once the iframe load event gives you a real target window. This keeps bootstrap code simple and avoids spreading conditional iframe checks across the app.