# Penpal: promise-based postMessage for iframes, workers and windows

> Penpal is a TypeScript library that turns raw postMessage into promise-based method calls, with a messenger abstraction covering windows, web workers and shared workers, and an allowedOrigins option that is the only thing standing between you and a cross-origin call. It suits a team that has grown a set of iframe and worker integrations by hand and wants the error handling, the handshake and the type contract in one place.

**Aaronius/penpal** — Penpal simplifies communication with iframes, workers, and windows by using promise-based methods on top of postMessage.

- Repository: https://github.com/Aaronius/penpal
- Stars: 567 · Forks: 63
- Language: TypeScript
- License: MIT
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/aaronius-penpal

## One connect call, a handshake, and a remote proxy

Penpal simplifies communication with iframes, workers, and windows by using promise-based methods on top of postMessage. The README states it in two lines and then the examples do the explaining.

The pattern is the same in every case. One side creates a messenger for the channel, both sides call `connect` with the messenger and the methods they are willing to expose, and each side awaits `connection.promise` to get a proxy of the other side's methods.

For an iframe, the parent side looks like this:

```javascript
import { WindowMessenger, connect } from 'penpal';

const iframe = document.createElement('iframe');
iframe.src = 'https://childorigin.example.com/path/to/iframe.html';
document.body.appendChild(iframe);

const messenger = new WindowMessenger({
  remoteWindow: iframe.contentWindow,
  // Defaults to the current origin.
  allowedOrigins: ['https://childorigin.example.com'],
  // Alternatively,
  // allowedOrigins: [new URL(iframe.src).origin]
});
```

and the iframe side mirrors it with `remoteWindow: window.parent` and its own allowedOrigins.

Two details in the example matter more than the shape. The first is `allowedOrigins`, which defaults to the current origin, and the comment shows the alternative of deriving it from the URL with `new URL(iframe.src).origin`. The second is that `connect` takes a `methods` object, and each method is a plain function that either returns a value or returns a promise if asynchronous processing is needed, as the divide example shows with a setTimeout inside a promise.

The result is that calling across the boundary reads like calling a local function:

```javascript
const remote = await connection.promise;
// Calling a remote method will always return a promise.
const multiplicationResult = await remote.multiply(2, 6);
console.log(multiplicationResult); // 12
const divisionResult = await remote.divide(12, 4);
console.log(divisionResult); // 3
```

The comment on that line, that calling a remote method will always return a promise, appears in every example in the README, and it is the contract that makes the whole thing work. Even the synchronous-looking `multiply` is awaited on the other side, so the calling code never has to know whether the remote implementation was fast or slow.

## Four transports behind one connect interface

The library is called a messenger abstraction in practice, and there are four of them.

`WindowMessenger` handles iframes and opened windows, taking a `remoteWindow` and a list of allowedOrigins. For an opened window rather than an iframe, the difference is on the child side: the child uses `window.opener` as its remoteWindow, and the parent uses the handle returned by `window.open`.

`WorkerMessenger` handles web workers, and it is the simplest of the four because a worker has no origin of its own to worry about, so the constructor only needs the worker:

```javascript
import { WorkerMessenger, connect } from 'penpal';

const worker = new Worker('worker.js');

const messenger = new WorkerMessenger({
  worker,
});
```

The worker side is the mirror image with `remoteWindow` replaced by `self`.

`PortMessenger` handles shared workers, and it takes a `port` rather than a worker object, since a shared worker's port is the thing you post messages on:

```javascript
import { PortMessenger, connect } from 'penpal';

const worker = new SharedWorker('shared-worker.js');

const messenger = new PortMessenger({
  port: worker.port,
});
```

The topic tags name the fourth case as well, with service-worker, message-channel, message-port and port among them, so the transport set covers the main ways two browsing contexts talk to each other.

What all four share is that `connect` and the `methods` contract do not change. The messenger is the only piece that differs, which is what lets the same business logic run in an iframe, a popup, a worker and a shared worker without knowing which one it is in.

The library is MIT licensed, published on npm as `penpal` at version 8.0.1, and the topic list also includes promise and communication. The repository is not archived and the last push was on 2026-08-14, and migration instructions for each major release are found on the corresponding GitHub release tag, which matters because the version 8 line is recent.

## Where it is installed from, and the CDN route

Installation has two routes and the second one has a detail that matters for a browser-only project.

From npm:

`npm install penpal`

Alternatively, load a build of Penpal that is already hosted on a CDN:

`<script src="https://unpkg.com/penpal@^8/dist/penpal.min.js"></script>`

Penpal will then be installed on `window.Penpal`. Usage is similar to if you were using it from npm, but instead of importing each module you would access it on the `Penpal` global variable instead.

That is a real trade rather than a detail. A CDN build means no bundler step and no build pipeline for a page that only needs the library, and it means the library's version is whatever the CDN serves for that major tag. The package manifest shows the CDN entry points explicitly, with `unpkg` and `jsdelivr` both pointing at `./dist/penpal.min.js`, and the `types` field at `./dist/penpal.d.ts`.

The manifest is also careful about module formats, which matters for a library used in both a bundler and a script tag. The package is `"type": "module"` with `sideEffects` set to false, and the exports map has separate `import` and `require` conditions, each with its own types file, pointing at `./dist/penpal.mjs` and `./dist/penpal.cjs`. So a TypeScript project using `require` gets the CommonJS build with `.d.cts` types, and one using `import` gets the ESM build.

The build script reflects the same care. It runs tsdown three times, once for the esm, cjs and iife formats, then again for the iife format minified and with no clean, which is how the CDN bundle and the minified bundle are produced from one source without the second pass deleting the first. There is a separate `build:analysis` script that runs a filesize script against the minified output, which suggests the bundle size is tracked deliberately rather than left to chance.

## The security question the allowedOrigins option is really asking

The one thing to be careful with is `allowedOrigins`, and the reason is worth stating plainly.

The messenger sends messages across a boundary that the browser does not otherwise protect for you. The `postMessage` API will happily deliver to and accept from any origin unless you tell it otherwise, and a listener that accepts anything is a listener that will act on a message from a site an attacker controls.

The option defaults to the current origin, which is the safe default for a same-origin integration, and the examples show the cross-origin case spelled out, with `allowedOrigins: ['https://childorigin.example.com']` on the parent and the matching parent origin on the child side. The alternative form, `allowedOrigins: [new URL(iframe.src).origin]`, is the one to prefer in real code, because it derives the value from the URL you are actually loading rather than from a string you typed twice and then forgot to change.

The other half of the boundary is the methods object. Whatever you put in `methods` is callable by the other side, and in the iframe example the parent exposes `add` and the child exposes `multiply` and `divide`. That is a small, explicit surface, and it is the thing to review: the exposure is what you list, not what the remote side happens to implement. A method that is exposed is callable by whoever can reach the other end of the channel.

The library does not add authentication on top of this, and it is not trying to. The origin list is the gate, and the promise-based surface is what makes the exposed set easy to see in one place rather than scattered across message handlers.

There is one more point on the handshake. `connection.promise` is what you await to get the remote proxy, and it resolves after the two sides have exchanged their method lists. A connection that never resolves is the symptom of a mismatch between the two sides, most often a wrong origin or a method list that fails to serialise.

## What the handshake buys you, and what it does not

The most useful thing to understand about Penpal is what the connection promise actually represents, because it changes how you write the code on both sides.

When one side calls `connect`, it is not yet talking to the other side. It has described what it is willing to expose and set up a messenger. The two sides then exchange their method lists, and only after that does `connection.promise` resolve with a proxy of the remote methods. Until it resolves, `remote` does not exist, which is why every example in the README starts with `const remote = await connection.promise;` and only then calls anything.

The practical consequence is that a method you expose has to be serialisable. The library is passing function references across a channel, which means the remote side reconstructs them from a description rather than receiving code. Anything that cannot be described that way, a closure over local state, for example, will not survive the trip, and the failure will show up as a method that is missing or throws on the other side.

The examples keep the exposed methods simple for a reason. The parent exposes `add`, the child exposes `multiply` and `divide`, and the divide implementation returns a promise that resolves after a second. Nothing in the API is aware of that delay, which is the point: the caller awaits and does not care.

What the library does not do is anything about the contents of the data you pass across. There is no schema, no validation and no type check at runtime, so the contract between the two sides is a convention you maintain. In a TypeScript project you would normally type the remote proxy against an interface you define yourself, and the package's own type definitions cover the library's types rather than your application's. The `types` field in the manifest points at `./dist/penpal.d.ts` and the exports map offers separate `.d.ts` and `.d.cts` files for the import and require conditions, so the types are there for the library while the method contract is yours to write.

That is the honest summary of the trade. Penpal removes the message plumbing, the error handling and the ordering problem, and leaves the contract to you.

## Conclusion

Adopt Penpal if you have several iframe, worker or popup integrations that talk over postMessage, since the library gives you a typed method contract, a handshake and promise semantics instead of hand-rolled message routing. Do not adopt it if you only have one postMessage call to make, since the setup is more code than the message, and check first that you set allowedOrigins on both sides, because the default is the current origin and a cross-origin integration that relies on the default will silently fail to connect.

## FAQ

### What is Penpal?

It is a TypeScript library that simplifies communication with iframes, workers and windows by using promise-based methods on top of postMessage. The npm package is penpal at version 8.0.1, MIT licensed, with the last push on 2026-08-14.

### How do I install Penpal?

With `npm install penpal`, or by loading a CDN build with `<script src="https://unpkg.com/penpal@^8/dist/penpal.min.js"></script>`, in which case Penpal is installed on `window.Penpal` and you access it on that global instead of importing each module.

### Which messengers does Penpal provide?

WindowMessenger for iframes and opened windows, taking a remoteWindow and allowedOrigins; WorkerMessenger for web workers, taking the worker; and PortMessenger for shared workers, taking the port. All of them work with the same connect call and the same methods contract.

### What is allowedOrigins for in Penpal?

It tells the messenger which origins are permitted to exchange messages, and it defaults to the current origin. The examples show the cross-origin case as an explicit list, with the alternative of deriving the value from the loaded URL using new URL(iframe.src).origin.

### How does Penpal handle asynchronous remote methods?

A method listed in the methods object can return a promise when asynchronous processing is needed, and the divide example in the README returns a promise that resolves after a setTimeout. On the calling side every remote method call returns a promise, so the caller awaits it regardless of how fast the remote implementation was.

### Is Penpal still being maintained?

The repository is not archived and the last push was on 2026-08-14. Recent releases are v8.0.1 from 2026-08-14, v8.0.0 from 2026-08-12 and a v8.0.0-next.1 pre-release, and migration instructions for each major release are on the corresponding GitHub release tag.

## Sources

- [Aaronius/penpal on GitHub](https://github.com/Aaronius/penpal)
- [Issues](https://github.com/Aaronius/penpal/issues)
- [License: MIT](https://github.com/Aaronius/penpal/blob/main/LICENSE)
- [README](https://github.com/Aaronius/penpal/blob/main/README.md)
- [Releases](https://github.com/Aaronius/penpal/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/aaronius-penpal
