message-port-rpc
Version:
Turns a MessagePort into an remote procedure call (RPC) stub.
293 lines (181 loc) • 12.1 kB
Markdown
# `message-port-rpc`
Turns a [`MessagePort`](https://developer.mozilla.org/en-US/docs/Web/API/MessagePort) into an remote procedure call (RPC) stub.
## Background
Modern web apps often need to deal with multiple JavaScript workers or VMs. The communication channel is often [`MessagePort`](https://developer.mozilla.org/en-US/docs/Web/API/MessagePort).
By converting a dedicated `MessagePort` into an RPC stub, we can easily offload a Promise function to a different thread.
## How to use
Make sure the pair of `MessagePort` used for RPC is dedicated and not started. No other RPC, listeners, or posters should be using the same pair.
It is highly recommended to create a new [`MessageChannel`](https://developer.mozilla.org/en-US/docs/Web/API/MessageChannel) and convert them into RPC stub.
### Server stub
```ts
import { messagePortRPC } from 'message-port-rpc';
messagePortRPC(port1, (x, y) => x + y);
```
### Client stub
```ts
import { messagePortRPC } from 'message-port-rpc';
const rpc = messagePortRPC(port2);
await rpc(1, 2); // 3.
```
## Full Web Worker example
One of the advantage of Web Worker is to offload computation-intensive functions.
### On main thread (client stub)
Creates a new pair of `MessagePort`, pass one of the port to the worker thread, then create a RPC stub on another port.
```ts
import { messagePortRPC } from 'message-port-rpc';
// TypeScript: define the function type.
type Fn = (x: number, y: number) => Promise<number>;
// Loads a Web Worker.
const worker = new Worker('./static/worker/js/main.js');
// Creates a new pair of `MessagePort` dedicated for RPC.
const { port1, port2 } = new MessageChannel();
// Sends the dedicated port to the worker.
worker.postMessage(undefined, [port2]);
// Creates a function stub.
const callFunction = messagePortRPC<Fn>(port1);
// Calls the function stub.
const result: number = await callFunction(1, 2);
```
### On worker thread (server stub)
Receives the `MessagePort` and registers an RPC function on the port.
```ts
import { messagePortRPC } from 'message-port-rpc';
// TypeScript: define the function type.
type Fn = (x: number, y: number) => Promise<number>;
// Receives the port dedicated for RPC.
addEventListener('message', ({ ports }) => {
// Registers an RPC function on the received `MessagePort`.
messagePortRPC<Fn>(ports[0], (x, y) => Promise.resolve(x + y));
});
```
If the worker takes time to start, it is okay, no invocations would be lost. Thanks to `MessagePort`, all messages will be queued internally until the other side signals ready to receive.
## Aborting the call
Client can abort an invocation sooner by passing an `AbortSignal` via the `withOptions` function. An `AbortSignal` will be passed to the remote function inside `this` context.
In the following example, we assume the client is remotely invoking a `fetch()` function, which supports `AbortSignal` natively.
### Server stub
The following code snippet will use the `AbortSignal` to abort the `fetch()` call.
```ts
messagePortRPC(ports[0], async url => {
// During an RPC call, the `AbortSignal` is passed in the `this` context.
const res = await fetch(url, { signal: this.signal });
// ...
});
```
### Client stub
The following code snippet will call the stub with additional options to pass an `AbortSignal`.
```ts
const abortController = new AbortController();
const remoteFetch = messagePortRPC(port);
// Calls the stub with arguments in array, and options.
const fetchPromise = remoteFetch.withOptions({ signal: abortController.signal })('https://github.com');
// Aborts the ongoing call.
abortController.abort();
// The promise will reject locally.
fetchPromise.catch(error => {});
```
Notes: despite the `AbortSignal` is passed to `fetch()`, when aborted, the rejection will be done locally regardless of the result of the `fetch()` call.
## API
The following is simplified version of the API. Please refer to our published typings for the full version.
```ts
function messagePortRPC<T extends (...args: any[]) => Promise<unknown>>(
port: MessagePort,
fn?: (this: { signal: AbortSignal }, ...args: Parameters<T>) => ReturnType<T>
): {
(...args: Parameters<T>): ReturnType<T>;
withOptions: (
init: {
signal?: AbortSignal;
transfer?: Transferable[];
}
) => T;
};
```
## Behaviors
### Why use a dedicated `MessagePort`?
Instead of multiplexing multiple calls into a single `MessagePort`, a dedicated `MessagePort` simplifies the code, easier to secure and audit the channel, and eliminates crosstalk.
Internally, for every RPC call, we create a new pair of `MessagePort`. The result of the call is passed through the `MessagePort`. After the call is resolved/rejected/aborted, the `MessagePort` will be shutdown.
With a new pair of `MessagePort`, messages are queued until the event listener call [`MessagePort.start()`](https://developer.mozilla.org/en-US/docs/Web/API/MessagePort/start). In other words, with dedicated `MessagePort`, calls are less likely to get lost due to false-start.
### What can be passed as arguments and return value?
All arguments and return value will be send over the `MessagePort`. The values must be transferable using the [Structured Clone Algorithm](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm) by the underlying `MessagePort`.
In other words, you cannot pass `function` or `class` as an argument or return value.
### Will it pass the `this` context?
No, because the `this` context is commonly a class object or [`globalThis`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/globalThis). [Structured Clone Algorithm](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm) probably will not work for most `this`.
If you need to pass `this`, please pass it as an argument.
### Can I use it with `<iframe>`?
Yes, you can use it with `<iframe>`.
However, despite the communication channel in `<iframe>` is very similar to `MessagePort` and supports [Structured Clone Algorithm](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm), it is not `MessagePort`.
You will need to create a new `MessageChannel` and use [`HTMLIFrameElement.contentWindow.postMessage()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) to send one of the `MessagePort` to the `<iframe>` content window. Then, you can convert the `MessagePort` into RPC with this package.
### Why hosting a single function vs. multiple functions?
We think a single function is much simpler, less responsibility, and more flexible approach.
To create a pool of stubs, you should create multiple `MessagePort`, one for each stub. Then, send it through an initializer stub. The receiver side receiving these ports should set up stubs for each of the port, registering their respective subroutine.
### Can I call from the other side too?
Yes, our implementation supports bidirectional and asymmetrical calls over a single pair of `MessagePort`.
You can register different functions on both sides and call from the other side.
```ts
// On main thread:
// - a power function is hosted on the port;
// - the return value is the stub of the worker, which is a sum function.
const sum = messagePortRPC(port1, (x ** y) => x ** y);
await sum(1, 2); // 1 + 2 = 3
```
```ts
// On worker thread:
// - a sum function is hosted on the port;
// - the return value is the stub of the main thread, which is a power function.
addEventListener('message', ({ ports }) => {
const power = messagePortRPC(ports[0], (x + y) => x + y);
await power(3, 4); // 3 ** 4 = 81
});
```
### Do I need to sequence the calls myself?
No, you do not need to wait for a call to return before making another call.
Internally, all calls are isolated by their own pair of `MessagePort` and processed asynchronously.
### Can I send `Error` object?
Yes, thanks to the [Structured Clone Algorithm](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm), you can send objects of `Error` class.
However, there are slight differences in the error object received.
```ts
const obj = await stub();
obj instanceof Error; // False. Error object from SCA has a different prototype.
Object.prototype.toString.call(obj) === '[object Error]'; // True.
```
Alternatively, you can recreate the error object.
### Can I provide my own marshalling function?
No, we do not support marshalling function.
Alternatively, you can channel `MessagePort` to a pair of marshal and unmarshal functions. Make sure you implement both marshal and unmarshal functions on both sides of the port.
### Can I offload a Redux store or `useReducer` to a Web Worker?
Yes, you can offload them to a Web Worker. Some notes to take:
- action and state must be serializable through [Structured Clone Algorithm](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm)
- no classes, functions, DOM elements, no thunk, etc.
- middleware must not contains code that does not work in worker
- no DOM access, etc.
You can look at sample [`useBindReducer`](https://github.com/compulim/message-port-rpc/tree/main/packages/pages/src/app/useBindReducer.ts) and [`useReducerSource`](https://github.com/compulim/message-port-rpc/tree/main/packages/pages/src/iframe/useReducerSource.ts) to see how it work.
We will eventually made these React hooks available. Stay tuned.
### How could I stop the stub from listening to a port?
To stop the stub, you should close the port by calling [`MessagePort.close()`](https://developer.mozilla.org/en-US/docs/Web/API/MessagePort/close).
The port for the stub must be dedicated and not to be reused. When you want to stop the stub from listening to a port, you should simply close the port.
### Why don't you create `MessagePort` for me?
We understood there are hassles to create `MessagePort` yourself.
We spent a lot of time experimenting with different options and landed on this design for several reasons:
- you own the resources and control the lifetime of the resources, less likely to resources leak
- you do not need to create the stub before sending the port to the other side
- you can control which side creates the ports and do not need to pipe them yourself
- you can build marshal/unmarshal function without too much piping
- you can build a `MessagePort`-like custom channel without extra piping
There are downsides:
- you forget to dedicate the `MessagePort` to a stub
- you need to write one more line of code
At the end of the day, we think channel customization outweighted the disadvantages and made a bet on this design.
### Can I use `BroadcastChannel` to listen to many client stubs at once?
No, you cannot use [`BroadcastChannel`](https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel).
`BroadcastChannel` does not support sending `MessagePort` and other [transferable objects](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects), which is critical to the operation of the stub.
### Why should I use this implementation of RPC?
We are professional developers. Our philosophy makes this package easy to use.
- Standards: we use [`MessagePort`](https://developer.mozilla.org/en-US/docs/Web/API/MessagePort) and [Structured Clone Algorithm](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm) as-is
- Airtight: we wrap everything in their own `MessagePort`, no multiplexing = no crosstalks
- Small scope: one `MessagePort` host one function only, more flexibility on building style
- Simple: you almost know how to write this package
- Maintainability: we relies heavily on tooling and automation to maintain this package
## Contributions
Like us? [Star](https://github.com/compulim/message-port-rpc/stargazers) us.
Want to make it better? [File](https://github.com/compulim/message-port-rpc/issues) us an issue.
Don't like something you see? [Submit](https://github.com/compulim/message-port-rpc/pulls) a pull request.