Rajender Joshi Rajender Joshi Engineering leader
← opensource

port

A protocol-first iframe runtime for secure host/child embeds with explicit lifecycle, origin pinning, resize, events, and request/response messaging.

Why this exists

Embedded products often begin as a tactical integration. A team adds an iframe, passes a configuration object into the URL or an initialization message, and waits for one success callback. That shape is fine for a demo, but it does not survive the moment an embed becomes product infrastructure. The host needs readiness, the child needs to resize, support needs enough state to debug failures, and security needs exact origin discipline instead of a casual wildcard.

Port is an attempt to make that boundary explicit. It treats the iframe not as a DOM convenience but as a distributed runtime with two peers that need a contract. The package is deliberately small because the contract itself is the value: mount, handshake, allowed origin, readiness, resize, events, request/response, error, and teardown. Everything else should remain product-owned.

The real failure mode

Raw postMessage is not the villain. The browser API does exactly what it promises: move a message from one window context to another. The problem is that product teams accidentally build a protocol without naming it. One message is an event, another is a command, another is a reply, and another is a state transition. Six months later, the only specification is spread across handlers, timeout branches, and comments that describe a version of reality that no longer exists.

The failure usually appears as drift. One host assumes the child is ready after iframe load. Another waits for a ready event. One child sends a resize event before the host has subscribed. One integration checks origin against the parent site while another checks a CDN domain. The code keeps working in the common path, but every new edge case becomes a local patch instead of a shared rule.

Protocol shape

A useful iframe protocol needs a few boring primitives. It needs a handshake that both sides can observe. It needs message envelopes so a receiver can tell whether a message belongs to this protocol at all. It needs correlation IDs for request/response behavior. It needs lifecycle states that make cleanup visible. It needs exact allowed origins because iframe messaging is often placed directly on sensitive product surfaces.

Port keeps those concepts close together. The host runtime owns the iframe shell. The child runtime owns the embedded app's participation in the contract. Both sides agree on origin and message shape. Resize is not a random custom event; it is a protocol behavior. Calls and replies are not informal callback maps; they are correlated exchanges with failure paths.

What it avoids

I did not want Port to become a framework wrapper. A React component may be convenient, but the iframe boundary exists outside React. A host may be a server-rendered app, a Vue app, a vanilla admin console, or a product page where the embed is inserted by a tag manager. The useful abstraction needs to live below those choices.

I also avoided pretending an iframe should behave like local function calls. The boundary has latency, lifecycle uncertainty, origin rules, and failure states. Hiding that behind a remote-method abstraction makes the first integration feel cleaner while making debugging harder. Port's job is to make the boundary legible, not to erase it.

Where it helps

The package is useful when an embedded surface becomes more than decorative UI: commerce widgets, onboarding flows, scheduling modules, support consoles, analytics panels, partner apps, and any product where the host and child need to coordinate. These flows need a shared language for readiness, resize, close, data requests, and one-off events.

The deeper value is operational. A team can read the host code and child code and see the same protocol names. Security review can inspect origin handling. Product engineers can add a new event without inventing a new reply pattern. Cleanup becomes part of the runtime rather than an afterthought in a component effect.

Usage samples

Install

Add the runtime to both the host app and the embedded child app so the protocol types stay aligned.

npm install @crup/port

Host: mount an embedded app

The host owns the iframe shell, the allowed origin, and the target container. It waits for the child runtime before sending calls.

import { createPortHost } from "@crup/port/host";

const embed = createPortHost({
  url: "https://tools.example.com/checkout-assistant",
  allowedOrigin: "https://tools.example.com",
  target: document.querySelector("#assistant-root"),
  mode: "inline",
});

await embed.mount();
await embed.ready();

const recommendation = await embed.call("cart:recommend", {
  cartId: "cart_123",
  productIds: ["sku_1", "sku_2"],
});

embed.on("assistant:close", () => {
  embed.dispose();
});

Child: expose protocol behavior

The child app declares the trusted host, announces readiness only when it can handle messages, and keeps product events separate from request handlers.

import { createPortChild } from "@crup/port/child";

const host = createPortChild({
  allowedOrigin: "https://shop.example.com",
});

host.handle("cart:recommend", async ({ productIds }) => {
  return {
    message: "Compare fit, warranty, and delivery before checkout.",
    productIds,
  };
});

host.resize({ height: document.body.scrollHeight });
host.ready();

document.querySelector("#close")?.addEventListener("click", () => {
  host.emit("assistant:close", { reason: "user" });
});

Cleanup on route changes

Treat teardown as part of the runtime contract so late replies and event listeners do not survive a closed embed.

const removeRouteListener = router.onChange(() => {
  embed.dispose();
  removeRouteListener();
});