Repository: node
Issue: Userland transferable/cloneable objects
Labels: c++,discuss,feature request,node-api,worker

@addaleax @nodejs/n-api @nodejs/workers ...

Currently there is no way for userland code to define their own cloneable/transferable objects that work with the MessageChannel/MessagePort API.

For instance,

```js
class Foo {
  a = 1;
}

const foo = new Foo();

const mc = new MessageChannel();
mc.port1.onmessage = ({ data }) => console.log(data);   // outputs {}
mc.port2.postMessage(foo);
```

The structured clone algorithm takes the Foo object and clones it as an ordinary JavaScript object that has lost information.

With Node.js built in objects we are able to get around this because at the native layer, `node::BaseObject` is a "host object" in v8 terms, allowing us to delegate serialization and deserialization to our own provider. We use this, for instance, to transfer `KeyObject`, `X509Certificate`, and `FileHandle` objects in a way that just works.

The `node::BaseObject` base class has a set of APIs that allow an object to declare itself as being cloneable or transferable, along with functions that provide the serialization and deserialization delegates. For JavaScript objects, we provide the `JSTransferable` base class that derives from `node::BaseObject`. The JavaScript `KeyObject`, `X509Certificate` and `FileHandle` objects derive from `JSTransferable`. On deserialization of a cloned or transfered object, `JSTransferable` will parse information out of the serialized image and use that to `require()` and create the implementation class on the receiving side. It all just works.

The challenge, however, is that user code cannot use either `node::BaseObject` or `JSTransferable`.

On the JavaScript side, `JSTransferable` is internal only, and is not exposed to userland code. Even if it was exposed to userland code, it wouldn't work because the internal `require()` function it uses during deserialization only sees the Node.js built in modules and will not resolve userland code. Also, it uses `require()` and would not work with ESM modules.

On the C++ side, the node-api `napi_define_class()` does not define the JavaScript/C++ object wrapper in the same way as what we use internally with `BaseObject`. Specifically, node-api wrapper objects are *not* seen as host objects by v8 and therefore will use the default structured clone algorithm to clone only the enumerable JavaScript object properties on the wrapper object. Because these objects would need to provide their own serialization/deserialization logic it's not clear if `napi_define_class()` can be modified in a backwards compatible way such that the objects can be host objects.

What I'd like to be able to achieve is efficiently and easily creating both JavaScript classes and native wrapper objects that can be cloned/transferred intact over `MessagePort` but it's not clear at all exactly how we should do it.

There is an argument that can be made for Just Using ArrayBuffers(tm). Specifically, have user code serialize it's objects into an `ArrayBuffer`, then just transfer/copy that `ArrayBuffer` over the `MessagePort` and manually deserialize on the other side. That's obviously far less efficient. For comparison, consider how we handle other native objects like `KeyObject` and `X509Certificate`. For those, we are able to clone the JavaScript wrapper while avoiding the cost of copying and reallocating the underlying native data structures. The custom serialization/deserialization/copy is far less efficient.

I'm opening this issue to see if we can discuss and identify an approach to implementing this that would work, as the fix is not clear and touches on several subsystems (node-api, messaging, workers, etc).