frameport
v1.0.0
Published
Zero-dependency iframe channels over postMessage
Readme
frameport
Turn postMessage into real request/response workflows.
frameport is a zero-dependency library for communication between a page and an iframe, or between any two window contexts that can talk through postMessage. It wraps raw browser messaging 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.
Live website: https://damianmr.github.io/frameport/
What It Solves
frameport is a good fit when you want to:
- keep parent and iframe in sync with one-way updates such as theme changes, ready signals, or host state
- ask the other side for data and treat the interaction more like a small async API call
- prepare the communication flow early and connect it once the embedded window becomes available
What It Gives You
- named channels with an explicit
channelId send/listenfor one-way messagesrequest/respondfor async request-response flows- per-request timeouts
- a default iframe gateway for
window+contentWindow - a
lazyChannelhelper for setups where the target window is not ready yet
Installation
npm install frameportImporting
ESM
import { createChannel, defaultIFrameGateway, lazyChannel } from "frameport";CommonJS
const {
createChannel,
defaultIFrameGateway,
lazyChannel,
} = require("frameport");There is no API difference between import and require; the package ships both ESM and CommonJS builds.
Raw TypeScript source
import {
createChannel,
defaultIFrameGateway,
lazyChannel,
} from "frameport/source";The frameport/source entry is only for toolchains that can consume .ts files directly. For normal npm usage, prefer the default frameport entry.
Quickstart
Use the same id and the same availableMessages on both sides.
Parent side 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 HTML
<!DOCTYPE html>
<html>
<body>
<div>Iframe page UI</div>
<script src="./frameport.js"></script>
<script src="./child.js"></script>
</body>
</html>parent.js
const iframe = document.getElementById("quickstart-frame");
const pendingChannel = frameport.lazyChannel({
id: "quickstart-demo",
availableMessages: ["child-ready", "get-answer"],
});
pendingChannel.onInit(async function (channel) {
channel.listen("child-ready", function (message) {
console.log("Child says:", message.payload.text);
});
const response = await channel.request(
"get-answer",
{ timeout: 2000 },
{ question: "Hello from parent" }
);
console.log("Child answered:", response.payload.text);
});
iframe.addEventListener("load", function () {
if (!iframe.contentWindow) {
return;
}
pendingChannel.init(
frameport.defaultIFrameGateway({
currentWindow: window,
targetWindow: iframe.contentWindow,
})
);
});child.js
const channel = frameport.createChannel({
id: "quickstart-demo",
availableMessages: ["child-ready", "get-answer"],
...frameport.defaultIFrameGateway({
currentWindow: window,
targetWindow: window.parent,
}),
});
channel.respond("get-answer", async function (payload) {
return {
text: `Child received: ${payload.question}`,
};
});
channel.send("child-ready", {
text: "Iframe booted and ready.",
});Core Concepts
Every message has this shape:
type ChannelMessage<Payload> = {
channelId: string;
messageName: string;
payload: Payload;
requestId?: string;
};channelIdisolates one channel from anothermessageNameidentifies the actionpayloadcarries the datarequestIdis added automatically forrequest()/respond()flows
Demo
There is a plain HTML demo under docs/ that uses a compiled browser bundle of the library.
The same site is published on GitHub Pages at https://damianmr.github.io/frameport/ and is deployed automatically from main through the GitHub Actions workflow in .github/workflows/deploy-website.yml.
npm run build
npm run build:websiteThen open docs/index.html in a browser.
API
createChannel(config)
Creates a channel bound to your message transport.
const channel = createChannel({
id: "app-shell",
availableMessages: ["ping", "get-user"],
postMessage,
addEventListener,
removeEventListener,
setTimeout,
clearTimeout,
});Config fields:
id: channel identifier shared by both endsavailableMessages: whitelist of accepted message namespostMessage: transport senderaddEventListener: subscribes to incoming window messagesremoveEventListener: unsubscribes from incoming messagessetTimeout: timeout scheduler used byrequestclearTimeout: timeout cleanup used when a request resolves
channel.send(messageName, payload?)
Sends a one-way message.
channel.send("ping", { now: Date.now() });channel.listen(messageName, handler)
Listens for one-way or request messages. Returns an unsubscribe function.
const stop = channel.listen("ping", (message) => {
console.log(message.payload);
});
stop();channel.request(messageName, { timeout }, payload?)
Sends a request and waits for a response with the same requestId.
const response = await channel.request<{ id: number }, { name: string }>(
"get-user",
{ timeout: 1000 },
{ id: 1 }
);If the response does not arrive in time, the promise rejects with an object like:
{
status: "timeout",
messageName: "get-user",
requestId: "req1",
channelId: "app-shell",
payload: { id: 1 }
}channel.respond(messageName, handler)
Convenience helper for request-response handlers.
channel.respond<{ id: number }, { name: string }>(
"get-user",
async ({ id }) => {
return { name: `user-${id}` };
}
);This assumes callers use request(). If a caller uses send() instead, there is no requestId to answer with.
defaultIFrameGateway({ currentWindow, targetWindow })
Creates the transport handlers for the common iframe case.
const gateway = defaultIFrameGateway({
currentWindow: window,
targetWindow: iframe.contentWindow,
});You can skip this helper and provide your own gateway if you need stricter origin checks or a custom transport.
lazyChannel(partialConfig)
Useful when you know the channel identity up front, but you do not have the real transport yet.
import { lazyChannel, defaultIFrameGateway } from "frameport";
const pendingChannel = lazyChannel({
id: "app-shell",
availableMessages: ["ready", "get-user"],
});
pendingChannel.onInit((channel) => {
channel.listen("ready", () => {
console.log("iframe connected");
});
});
iframe.addEventListener("load", () => {
if (!iframe.contentWindow) {
return;
}
pendingChannel.init(
defaultIFrameGateway({
currentWindow: window,
targetWindow: iframe.contentWindow,
})
);
});Notes
Message Validation
frameport ignores window messages that do not look like channel messages, and throws if you try to use a messageName that is not listed in availableMessages.
Security
The built-in defaultIFrameGateway() uses targetWindow.postMessage(message, "*") for convenience.
That is fine for controlled environments, but it is not a security boundary.
If you need stricter guarantees, provide your own transport functions and validate things like:
event.originevent.source- allowed target origin in
postMessage
Null Payloads
When no payload is provided, the library normalizes it to null on the wire.
Development
The published package is intended for browser consumers and does not require a specific Node.js version at install time.
For contributors to this repository, the build/test environment is pinned to Node 22 via .nvmrc and the GitHub Actions workflows.
npm install
npm run build
npm test
./node_modules/.bin/tsc --noEmit