
Penpal simplifies communication with iframes, workers, and windows by
using promise-based methods on top of postMessage.
Install Penpal from npm as follows:
npm install penpal
Alternatively, load a build of Penpal that is already hosted on a CDN:
<script src="https://unpkg.com/penpal@^7/dist/penpal.min.js"></script>
Penpal will then be installed on window.Penpal
. Usage is similar to if you were using it from npm, which is documented below, but instead of importing each module, you would access it on the Penpal
global variable instead.
Expand Details
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]
});
const connection = connect({
messenger,
// Methods the parent window is exposing to the iframe window.
methods: {
add(num1, num2) {
return num1 + num2;
},
},
});
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
import { WindowMessenger, connect } from 'penpal';
const messenger = new WindowMessenger({
remoteWindow: window.parent,
// Defaults to the current origin.
allowedOrigins: ['https://parentorigin.example.com'],
});
const connection = connect({
messenger,
// Methods the iframe window is exposing to the parent window.
methods: {
multiply(num1, num2) {
return num1 * num2;
},
divide(num1, num2) {
// Return a promise if asynchronous processing is needed.
return new Promise((resolve) => {
setTimeout(() => {
resolve(num1 / num2);
}, 1000);
});
},
},
});
const remote = await connection.promise;
// Calling a remote method will always return a promise.
const additionResult = await remote.add(2, 6);
console.log(additionResult); // 8
Expand Details
import { WindowMessenger, connect } from 'penpal';
const windowUrl = 'https://childorigin.example.com/path/to/window.html';
const childWindow = window.open(windowUrl);
const messenger = new WindowMessenger({
remoteWindow: childWindow,
// Defaults to the current origin.
allowedOrigins: ['https://childorigin.example.com'],
// Alternatively,
// allowedOrigins: [new Url(windowUrl).origin]
});
const connection = connect({
messenger,
// Methods the parent window is exposing to the child window.
methods: {
add(num1, num2) {
return num1 + num2;
},
},
});
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
import { WindowMessenger, connect } from 'penpal';
const messenger = new WindowMessenger({
remoteWindow: window.opener,
// Defaults to the current origin.
allowedOrigins: ['https://parentorigin.example.com'],
});
const connection = connect({
messenger,
// Methods the child window is exposing to the parent (opener) window.
methods: {
multiply(num1, num2) {
return num1 * num2;
},
divide(num1, num2) {
// Return a promise if asynchronous processing is needed.
return new Promise((resolve) => {
setTimeout(() => {
resolve(num1 / num2);
}, 1000);
});
},
},
});
const remote = await connection.promise;
// Calling a remote method will always return a promise.
const additionResult = await remote.add(2, 6);
console.log(additionResult); // 8
Expand Details
import { WorkerMessenger, connect } from 'penpal';
const worker = new Worker('worker.js');
const messenger = new WorkerMessenger({
worker,
});
const connection = connect({
messenger,
// Methods the window is exposing to the worker.
methods: {
add(num1, num2) {
return num1 + num2;
},
},
});
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
import { WorkerMessenger, connect } from 'penpal';
const messenger = new WorkerMessenger({
worker: self,
});
const connection = connect({
messenger,
// Methods the worker is exposing to the window.
methods: {
multiply(num1, num2) {
return num1 * num2;
},
divide(num1, num2) {
// Return a promise if asynchronous processing is needed.
return new Promise((resolve) => {
setTimeout(() => {
resolve(num1 / num2);
}, 1000);
});
},
},
});
const remote = await connection.promise;
// Calling a remote method will always return a promise.
const additionResult = await remote.add(2, 6);
console.log(additionResult); // 8
Expand Details
import { PortMessenger, connect } from 'penpal';
const worker = new SharedWorker('shared-worker.js');
const messenger = new PortMessenger({
port: worker.port,
});
const connection = connect({
messenger,
// Methods the window is exposing to the worker.
methods: {
add(num1, num2) {
return num1 + num2;
},
},
});
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
import { PortMessenger, connect } from 'penpal';
self.addEventListener('connect', async (event) => {
const [port] = event.ports;
const messenger = new PortMessenger({
port,
});
const connection = connect({
messenger,
// Methods the worker is exposing to the window.
methods: {
multiply(num1, num2) {
return num1 * num2;
},
divide(num1, num2) {
// Return a promise if asynchronous processing is needed.
return new Promise((resolve) => {
setTimeout(() => {
resolve(num1 / num2);
}, 1000);
});
},
},
});
const remote = await connection.promise;
// Calling a remote method will always return a promise.
const additionResult = await remote.add(2, 6);
console.log(additionResult); // 8
});
Expand Details
import { PortMessenger, connect } from 'penpal';
const initPenpal = async () => {
const { port1, port2 } = new MessageChannel();
navigator.serviceWorker.controller?.postMessage(
{
type: 'INIT_PENPAL',
port: port2,
},
{
transfer: [port2],
}
);
const messenger = new PortMessenger({
port: port1,
});
const connection = connect({
messenger,
// Methods the window is exposing to the worker.
methods: {
add(num1, num2) {
return num1 + num2;
},
},
});
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
};
if (navigator.serviceWorker.controller) {
initPenpal();
}
navigator.serviceWorker.addEventListener('controllerchange', initPenpal);
navigator.serviceWorker.register('service-worker.js');
import { PortMessenger, connect } from 'penpal';
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', () => self.clients.claim());
self.addEventListener('message', async (event) => {
if (event.data?.type !== 'INIT_PENPAL') {
return;
}
const { port } = event.data;
const messenger = new PortMessenger({
port,
});
const connection = connect({
messenger,
// Methods worker is exposing to window.
methods: {
multiply(num1, num2) {
return num1 * num2;
},
divide(num1, num2) {
// Return a promise if asynchronous processing is needed.
return new Promise((resolve) => {
setTimeout(() => {
resolve(num1 / num2);
}, 1000);
});
},
},
});
const remote = await connection.promise;
// Calling a remote method will always return a promise.
const additionResult = await remote.add(2, 6);
console.log(additionResult); // 8
});
At any point in time, call connection.destroy()
to destroy the connection so that event listeners can be removed and objects can be properly garbage collected.
To debug while using Penpal, specify a function for the log
option when calling connect()
. This function will be called whenever Penpal needs to log a message. While this can be any function, Penpal exports a simple logging function called debug
which you can import and use. Passing a prefix into debug
will help to distinguish the origin of log messages.
import { connect, debug } from 'penpal';
...
const connection = connect({
messenger,
log: debug('parent')
});
For more advanced logging, check out the popular debug package which can be used similarly.
import debug from 'debug';
import { connect } from 'penpal';
...
const connection = connect({
messenger,
log: debug('penpal:parent')
});
When establishing a connection, you may specify a timeout in milliseconds. If a connection is not successfully made within the timeout period, the connection promise will be rejected with an error. See Errors for more information on errors.
import { ErrorCode } from 'penpal';
...
const connection = connect({
messenger,
timeout: 5000 // 5 seconds
});
try {
const remote = await connection.promise;
} catch (error) {
if (error.code === ErrorCode.ConnectionTimeout) {
// Connection failed due to timeout.
}
}
When calling a remote method, you may specify a timeout in milliseconds by passing an instance of CallOptions
as the last argument. If a response is not received within the timeout period, the method call promise will be rejected with an error. See Errors for more information on errors.
import { CallOptions, ErrorCode } from 'penpal';
...
const remote = await connection.promise;
try {
const multiplicationResult =
await remote.multiply(2, 6, new CallOptions({ timeout: 1000 }));
} catch (error) {
if (error.code === ErrorCode.MethodCallTimeout) {
// Method call failed due to timeout.
}
}
When sending a value between windows or workers, the browser uses a structured clone algorithm by default to clone the value as it is sent. As a result, the value will exist in memory multiple times--once for the sender and once for the recipient. This is typically fine, but some use cases require sending a large amount of data between contexts which could result in a significant performance hit.
To address this scenario, browsers support transferable objects which allow certain types of objects to be transferred between contexts. Rather than cloning the object, the browser will provide the receiving context a pointer to the object's existing block of memory.
When calling a remote method using Penpal, you may specify which objects should be transferred rather than cloned by passing an instance of CallOptions
as the last argument with the transferables
option set. When responding to a method call, you may specify which objects should be transferred by returning an instance of Reply
with the transferables
option set.
import { connect, CallOptions } from 'penpal';
...
const connection = connect({
messenger
});
const remote = await connection.promise;
const numbersArray = new Int32Array(new ArrayBuffer(8));
numbersArray[0] = 4;
numbersArray[1] = 5;
const multiplicationResultArray = await remote.double(
numbersArray,
new CallOptions({ transferables: [numbersArray.buffer] })
);
console.log(multiplicationResultArray[0]); // 8
console.log(multiplicationResultArray[1]); // 10
import { connect, Reply } from 'penpal';
...
const connection = connect({
messenger,
methods: {
double(numbersArray) {
// numbersArray and resultArray are both Int32Arrays
const resultArray = numbersArray.map(num => num * 2);
return new Reply(resultArray, {
transferables: [resultArray.buffer],
});
}
},
});
In fairly rare cases, you may wish to make parallel connections between two participants. To illustrate, let's use a scenario where you wish to make two parallel connections between a parent window and an iframe window. In other words, you will be calling connect()
twice within the parent window and twice within the iframe window.
In an attempt to establish these two connections, Penpal in the parent window will be calling postMessage()
on the iframe's window object (iframe.contentWindow
). By default, when Penpal within the iframe window receives these messages, it has no way to disambiguate messages related to the parent window's first call to connect()
from messages related to the parent window's second call to connect()
. As a result, the connections may fail to be properly established.
To prevent this issue, Penpal provides the concept of channels. A channel is a string identifier of your choosing that you may provide when calling connect()
within both participants. When a channel is provided, it is used to disambiguate communication between parallel connections. This is better explained in code:
import { WindowMessenger, connect } from 'penpal';
const iframe = document.createElement('iframe');
iframe.src = 'https://childorigin.example.com/iframe.html';
document.body.appendChild(iframe);
const messengerA = new WindowMessenger({
remoteWindow: iframe.contentWindow,
allowedOrigins: ['https://childorigin.example.com'],
});
const connectionA = connect({
messenger: messengerA,
channel: 'A',
methods: {
add(num1, num2) {
return num1 + num2;
},
},
});
// Note that each call to connect() needs a separate messenger instance.
const messengerB = new WindowMessenger({
remoteWindow: iframe.contentWindow,
allowedOrigins: ['https://childorigin.example.com'],
});
const connectionB = connect({
messenger: messengerB,
channel: 'B',
methods: {
subtract(num1, num2) {
return num1 - num2;
},
},
});
import { WindowMessenger, connect } from 'penpal';
const messengerA = new WindowMessenger({
remoteWindow: window.parent,
allowedOrigins: ['https://parentorigin.example.com'],
});
const connectionA = connect({
messenger: messengerA,
channel: 'A',
methods: {
multiply(num1, num2) {
return num1 * num2;
},
},
});
// Note that each call to connect() needs a separate messenger instance.
const messengerB = new WindowMessenger({
remoteWindow: iframe.contentWindow,
allowedOrigins: ['https://parentorigin.example.com'],
});
const connectionB = connect({
messenger: messengerB,
channel: 'B',
methods: {
divide(num1, num2) {
return num1 / num2;
},
},
});
Although we're using WindowMessenger
here to connect between a parent window and an iframe window, channels would similarly need to be used when using WorkerMessenger
to make parallel connections to a worker. When using PortMessenger
, channels are only needed when establishing parallel connections over a single pair of ports.
Penpal will throw or reject promises with errors in certain situations. Each error will be an instance of PenpalError
and will have a code
property which may be used for programmatic decisioning (e.g., take a specific action if a method call times out) along with a message
describing the problem. Changes to error codes will be considered breaking changes and require a new major version of Penpal to be released. Changes to messages will not be considered breaking changes. The following error codes are used:
CONNECTION_DESTROYED
This error will be thrown when attempting to call a method and the connection was previously destroyed.
CONNECTION_TIMEOUT
The promise found at connection.promise
will be rejected with this error after the configured connection timeout duration has elapsed and a connection has not been established.
INVALID_ARGUMENT
This error will be thrown when an invalid argument is passed to Penpal.
METHOD_CALL_TIMEOUT
The promise returned from a method call will be rejected with this error after the configured method call timeout duration has elapsed and a response has not been received.
METHOD_NOT_FOUND
The promise returned from a method call will be rejected with this error if the method does not exist on the remote.
TRANSMISSION_FAILED
When a connection is being established, the promise found at connection.promise
will be rejected with this error if a message cannot be transmitted. When a method call is being made, the promise returned from the method call will be rejected with this error if a message cannot be transmitted.
For your convenience, the above error codes can be imported and referenced as follows:
import { ErrorCode } from 'penpal';
// ErrorCode.ConnectionDestroyed
// ErrorCode.ConnectionTimeout
// ErrorCode.InvalidArgument
// ErrorCode.MethodCallTimeout
// ErrorCode.MethodNotFound
// ErrorCode.TransmissionFailed
Penpal is built in TypeScript and provides full TypeScript support. When calling connect()
, it's recommended you pass a generic type argument that describes the methods the remote will be exposing. This will be used to type the remote
object that connection.promise
is resolved with. This is better explained in code:
import { WorkerMessenger, connect } from 'penpal';
// This interace could be in a module imported by both the window and worker.
interface WorkerApi {
multiply(...args: number[]): number;
}
const worker = new Worker('worker.js');
const messenger = new WorkerMessenger({
worker,
});
// Note we're passing in WorkerApi as a generic type argument.
const connection = connect<WorkerApi>({
messenger,
});
// This `remote` object will contain properly typed methods.
const remote = await connection.promise;
// This `multiplicationResult` constant will be properly typed as a number.
const multiplicationResult = await remote.multiply(2, 6);
When creating a worker, it's highly recommended that you add the following line of code at the top of your worker script depending on which type of worker you're creating:
- Dedicated (regular) worker:
declare const self: DedicatedWorkerGlobalScope;
- Shared worker:
declare const self: SharedWorkerGlobalScope;
- Service worker:
declare const self: ServiceWorkerGlobalScope;
This lets TypeScript know which type of worker you're creating, and you'll run into fewer TypeScript errors.
Penpal exports several types for your usage. Import types as follows:
import { Connection, Methods, RemoteProxy } from 'penpal';
The types are described as follows:
The connection object returned from connect()
is typed as Connection
.
The object you provide for the methods
option when calling connect()
must be compatible with the Methods
type. The generic type argument you pass when calling connect()
must also be compatible with the Methods
type.
The object that connection.promise
resolves to will be of type RemoteProxy
. More specifically, it will be of type RemoteProxy<TMethods>
, where TMethods
is the type you pass as a generic type argument when calling connect()
as described above.
If you're using Penpal within a React app, please check out @weblivion/react-penpal.
Penpal is designed to run successfully on the most recent versions of Chrome, Firefox, Safari, and Edge. Penpal has also been reported to work within Ionic projects on iOS and Android devices.
messenger: Messenger
(required)
A messenger instance. Messengers handle the technical details of transmitting messages. The current available messengers are WindowMessenger
, WorkerMessenger
, and PortMessenger
, though any object that complies with the Messenger interface may be used. Details related to building custom messengers are forthcoming.
methods: Object
(optional)
An object containing methods which should be exposed for the remote to call. The keys of the object are the method names and the values are the functions. Nested objects with function values are recursively included. If a function requires asynchronous processing to determine its return value, make the function immediately return a promise and resolve the promise once the value has been determined.
timeout: number
(optional)
The amount of time, in milliseconds, Penpal should wait for a connection to be established before rejecting the connection promise. There is no timeout by default. See Connection Timeouts for more information.
channel: string
(optional)
A string identifier that disambiguates communication when establishing parallel connections between two participants (e.g., two windows, a window and a worker). See Parallel Connections for more information.
log: (...args: unknown[]) => void
(optional)
Penpal will call the log function each time debugging information is available. Debug messages will only be logged when this is defined. See Debugging for more information.
The return value of connect
is a Connection
object with the following properties:
promise: Promise
A promise which will be resolved once communication has been established. The promise will be resolved with an object that serves as a proxy for the methods the remote has exposed. Calling a method on this proxy object will always return a promise since it involves sending messages to and from the remote which are asynchronous operations. When calling a method on this proxy object, you may always pass an instance of CallOptions
as a final argument. See Method Call Timeouts and Transferring Large Data for more information on CallOptions
.
destroy: () => void
A method that, when called, will disconnect any messaging channels, event listeners, etc. You may call this even before a connection has been established. See Destroying the Connection for more information.
This messenger supports communication between two windows. See Usage with an Iframe and Usage with an Opened Window for examples.
remoteWindow: Window
A reference to the remote window exposing methods to the current (local) window. When connecting between a parent window and a child iframe window, in the parent you would specify the iframe's content window (iframe.contentWindow
) while in the child you would specify the parent window (window.parent
). When connecting between a parent window and a "child" window opened using window.open()
, in the parent would specify the opened window while in the child you would specify the parent (opener) window (window.opener
).
allowedOrigins: (string | RegExp)[]
(optional)
An array of allowed origins with which the window is allowed to communicate. By default, Penpal will restrict communication to the origin of the current HTML document.
In some scenarios, you may want the window to communicate with any origin. In this case, you can specify *
as an allowed origin. This is discouraged as it means any website could potentially send or receive method calls. When using the file://
protocol or data URIs when loading HTML documents, you will likely need to specify *
as an allowed origin due to native browser security policies but, again, understand your risks in doing so.
Regardless of how you configure allowedOrigins
, communication will always be restricted to the window with which you are connecting.
This messenger supports communication with a Worker (also known as a dedicated worker). See Usage with a Worker for an example. This cannot be used for shared workers or service workers, which use the PortMessenger instead.
worker: Worker | DedicatedWorkerGlobalScope
A reference to the worker. When connecting from a window, you would specify the instantiated worker object. When connecting from the worker, you would specify self
.
This messenger supports communication between a pair of MessagePorts. This is particularly useful when establishing a connection with a SharedWorker or ServiceWorker. See Usage with a Shared Worker and Usage with a Service Worker for examples.
port: MessagePort
A reference to the port. Each of the two participants in a Penpal connection will have its own MessagePort.
This library is inspired by:
MIT