Overriding structuredClone's serialization in JavaScript class

347 Views Asked by At

Suppose I have the following class:

class Foo extends EventTarget {
  msg = 'hey';
}

Now, suppose I need to transfer an instance of this class via MessagePort to a new Window or Iframe. This calls structuredClone(), which fails:

structuredClone(new Foo());

Result:

Uncaught DOMException: Failed to execute 'structuredClone' on 'Window': EventTarget object could not be cloned.

I think this happens because of step #20 in the specification:

  1. Otherwise, if value is a platform object, then throw a "DataCloneError" DOMException.

For my usage, I don't need to serialize the entire state of EventTarget. I just want to serialize enumerable properties. This works, for example:

structuredClone({
   ...new Foo()
});

But, in cases where I'm serializing large nested structures, looping through each time and each usage is tedious at best, and not great if I'm building a library that I expect others to be able to use.

What I'd like to do is override some class method to clean up the serialization so that it "just works". If this were JSON, I'd do something like this, with toJSON():

class Foo extends EventTarget {
  msg = 'hey';

  toJSON() {
    return {
      msg: this.msg
    };
  }
}

Is there an equivalent of this method for normal built-in structured serialization?

1

There are 1 best solutions below

0
On

Docs about Serializable. It basically says that if it works with structuredClone then it's considered serializable and links to the supported types.

Here is a completely untested semi-clone implementation that tries to assemble what it thinks will be allowed by structuredClone and doesn't try to handle infinite recursion (it would probably cause a stack overflow if it found a loop). This isn't really meant for use, it's probably better to use an existing library's deep clone implementation to achieve the same thing. It's more to demonstrate that trying to handle everything is almost impossible and that's why methods such as structuredClone effectively just have an internal whitelist and throw an exception if it receives unsupported data.

Since structuredClone flattens (gets rid of) the prototype chain, we don't need to worry if we do the same. I've used for...in because it iterates over properties in the prototype as well as the object itself.

function cleanData(data) {
    if (data instanceof Function || data instanceof Promise
            || data instanceof WeakMap || data instanceof WeakSet || data instanceof WeakRef
            || data instanceof FinalizationRegistry || data instanceof ReadableStream) {
        return undefined; // Not supported by structuredClone
    }
    if (data === null || data === undefined
            || data instanceof Date || data instanceof RegExp || data instanceof Error || data instanceof ArrayBuffer || data instanceof DataView || data instanceof Blob
            || ["string", "number", "boolean", "bigint", "symbol"].includes(typeof data) // primitive types
            || data instanceof String || data instanceof Number || data instanceof Boolean || data instanceof BigInt || data instanceof Symbol // wrapped types
            || (!(data instanceof Array) && Object.prototype.toString(data).endsWith("Array]"))) {
        return data; // Not worth cloning.
    }

    if (data instanceof Map) {
        return new Map( Array.from(data)
                .filter(entry => typeof entry[0] !== "function" && typeof entry[1] !== "function")
                .map(entry => [ cleanData(entry[0]), cleanData(entry[1]) ] );
    }
    if (data instanceof Set) {
        return new Set( Array.from(data)
                .filter(val => typeof val !== "function").map(val => cleanData(val)) );
    }
    if (data instanceof Array) {
        return data.filter(val => typeof val !== "function").map(item => cleanData(item));
    }
    
    let constructor = data.constructor;
    if (constructor !== Object && constructor.toString().contains("[native code]")) {
        return undefined; // Unsupported type
    }

    let entries = [];
    for (const key in data) {
        let value = data[key];
        let valType = typeof value;
        if (valType !== "function") {
            if (valType === "object") {
                value = cleanData(value);
            }
            entries.push([key, value]);
        }
    }
    return Object.fromEntries(entries);
}

Looking at the supported types, you can see that the above support for Symbol is incorrect and also it doesn't do any check for DOM nodes.