Use web Workers and other Windows through a simple Promise API

_config.yml

post-me is a typescript library that provides a simple Promise API for bidirectional communication with web workers and other windows (iframes, popups, etc.).

0. TLDR

With post-me it is easy for a parent (for example the main app) and a child (for example a worker) to expose methods and custom events to each other.

Main features:

  • ๐Ÿ” Parent and child can both expose methods and/or events.
  • ๐Ÿ”Ž Strong typing of method names, arguments, return values, as well as event names and payloads.
  • ๐Ÿค™ Seamlessly pass callbacks to the other context to get progress or partial results.
  • ๐Ÿ“จ Transfer arguments/return values/payloads when needed instead of cloning.
  • ๐Ÿ”— Establish multiple concurrent connections.
  • ๐ŸŒฑ No dependencies: 2kb gzip bundle.
  • ๐Ÿงช Excellent test coverage.
  • ๐Ÿ‘ Open source (MIT): https://github.com/alesgenova/post-me

Below is a minimal example of using post-me to communicate with a web worker. In this example, the worker exposes two methods (sum and mul) and a single event (ping) to the parent. The parent could expose methods and events as well.

Install:

npm install post-me

Parent code:

import { ParentHandshake, WorkerMessenger } from 'post-me';

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

const messenger = new WorkerMessenger({ worker });

ParentHandshake(messenger).then((connection) => {
  const remoteHandle = connection.remoteHandle();

  // Call methods on the worker and get the result as a promise
  remoteHandle.call('sum', 3, 4).then((result) => {
    console.log(result); // 7
  });

  // Listen for a specific custom event from the worker
  remoteHandle.addEventListener('ping', (payload) => {
    console.log(payload) // 'Oh, hi!'
  });
});

Worker code:

import { ChildHandshake, WorkerMessenger } from 'post-me';

// Methods exposed by the worker: each function can either return a value or a Promise.
const methods = {
  sum: (x, y) => x + y,
  mul: (x, y) => x * y
}

const messenger = WorkerMessenger({worker: self});
ChildHandshake(messenger, methods).then((connection) => {
  const localHandle = connection.localHandle();

  // Emit custom events to the app
  localHandle.emit('ping',  'Oh, hi!');
});

In this more complex interactive demo a parent application communicates with a web worker and a child iframe. You can play around with it on codesandbox.

_config.yml

1. History

A few months ago I was using the postmate library at work, to expose methods from my application (which was inside an iframe) to its parent app.

While postmate worked okay initially, I soon started running into some major limitations:

  • You can call a method with arguments, but you canโ€™t get its return value.
  • You can get the return value of a method, but only if the method takes no arguments.
  • No typescript support, makes it hard to enforce the correctness of API exposed by the parent/child across teams
  • If a method throws an error, it canโ€™t be caught by the other end.
  • Only the child can expose methods and events.
  • It works with iframes only.

I thought that it could be a fun weekend project to try and implement a new library that would overcome all of the shortcomings I had found, and that would provide first-class typescript support.

The first working version of post-me came together in a couple of days during the Thanksgiving break, and I was quite happy with it.

I soon realized that what I had wrote could be easily adapted to interface with web workers and beyond, making it more useful than the somewhat niche demand for communicating with iframes.

Now, after a few iterations, I believe post-me is ready to be introduced to a larger audience, and I hope it can be useful to some.

2. Typescript

Using typescript you can ensure that the parent and the child are using each otherโ€™s methods and events correctly. Most coding mistakes will be caught during development by the typescript compiler.

Thanks to post-me extensive typescript support, the correctness of the following items can be statically checked during development:

  • Method names
  • Argument number and types
  • Return values type
  • Event names
  • Event payload type

Letโ€™s rewrite the small example above in typescript!

Types code:

// types.ts

export type WorkerMethods = {
  sum: (x: number, y: number) => number;
  mul: (x: number, y: number) => number;
}

export type WorkerEvents = {
  'ping': string;
}

Parent Code:

import {
 ParentHandshake, WorkerMessenger, RemoteHandle
} from 'post-me';

import { WorkerMethods, WorkerEvents } from './types';

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

const messenger = new WorkerMessenger({ worker });

ParentHandshake(messenger).then((connection) => {
  const remoteHandle: RemoteHandle<WorkerMethods, WorkerEvents>
    = connection.remoteHandle();

  // Call methods on the worker and get the result as a Promise
  remoteHandle.call('sum', 3, 4).then((result) => {
    console.log(result); // 7
  });

  // Listen for a specific custom event from the app
  remoteHandle.addEventListener('ping', (payload) => {
    console.log(payload) // 'Oh, hi!'
  });

  // The following lines have various mistakes that will be caught by the compiler
  remoteHandle.call('mul', 3, 'four'); // Wrong argument type
  remoteHandle.call('foo'); // 'foo' doesn't exist on WorkerMethods type
});

Worker code:

import { ChildHandshake, WorkerMessenger, LocalHandle } from 'post-me';

import { WorkerMethods, WorkerEvents } from './types';

const methods: WorkerMethods = {
  sum: (x: number, y: number) => x + y,
  mul: (x: number, y: number) => x * y,
}

const messenger = WorkerMessenger({worker: self});
ChildHandshake(messenger, methods).then((connection) => {
  const localHandle: LocalHandle<WorkerMethods, WorkerEvents>
    = connection.localHandle();

  // Emit custom events to the worker
  localHandle.emit('ping',  'Oh, hi!');
});

3. Other Windows

As mentioned earlier, post-me can establish the same level of bidirectional communications not only with workers but with other windows too (e.g. iframes).

Internally, the low level differences between communicating with a Worker or a Window have been abstracted, and the Handshake will accept any object that implements the Messenger interface defined by post-me.

This approach makes it easy for post-me to be extended by its users.

A Messenger implementation for communicating between window is already provided in the library (WindowMessenger).

Here is an example of using post-me to communicate with an iframe.

Parent code:

import { ParentHandshake, WindowMessenger } from 'post-me';

// For safety it is strongly adviced to pass the explicit child origin instead of '*'
const messenger = new WindowMessenger({
  localWindow: window,
  remoteWindow: childWindow,
  remoteOrigin: '*'
});

ParentHandshake(messenger).then((connection) => {/* ... */});

Child code:

import { ChildHandshake, WindowMessenger } from 'post-me';

// For safety it is strongly adviced to pass the explicit child origin instead of '*'
const messenger = new WindowMessenger({
  localWindow: window,
  remoteWindow: window.parent,
  remoteOrigin: '*'
});

ChildHandshake(messenger).then((connection) => {/* ... */});

4. Debugging

You can optionally output the internal low-level messages exchanged between the two ends.

To enable debugging, simply decorate any Messenger instance with the provided DebugMessenger decorator.

You can optionally pass to the decorator your own logging function (a glorified console.log by default), which can be useful to make the output more readable, or to inspect messages in automated tests.

import { ParentHandshake, WorkerMessenger, DebugMessenger } from 'post-me';

import debug from 'debug';          // Use the full feature logger from the 'debug' library
// import { debug } from 'post-me'; // Or the lightweight implementation provided

let messenger = new WorkerMessenger(/* ... */);
// To enable debugging of each message exchange, decorate the messenger with DebugMessenger
const log = debug('post-me:parent'); // optional
messenger = DebugMessenger(messenger, log);

ParentHandshake(messenger).then((connection) => {/* ... */});

Output:

_config.yml

5. Conclusion

Thank you for reading, I hope post-me can be useful to other people as well. If you would like to try out or contribute to the library, the source code is available on GitHub.

Written on January 7, 2021