-
Notifications
You must be signed in to change notification settings - Fork 2.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Allow objects to customize serialization / deserialization for structured clone #7428
Comments
This feels like a case where we need to step back and deal with use cases, per https://whatwg.org/faq#adding-new-features . In particular, it's not clear to me what use cases this proposal covers which can't be covered by const serializable = customPreSerialize(data);
postMessage(serializable); and
|
In the browser, structured clone is used for IndexedDB and History state in addition to postMessage. [Serializable] platform objects are supported by all of them (if not entirely consistently across browsers). Is this idea about extending the message channel API only or is the goal to extend structured clone in general? Side question: In HTML, “transfer” is a special case of “clone” where “[the object is] not just cloned, [but also becomes] no longer usable on the sending side”. Is this the same meaning intended by JsTransferable / kTransfer? |
Node.js itself presents a solid set of use cases here. Take AbortSignal, for instance. It already inherits from EventTarget. In order to make it cloneable as has been discussed in another issue over in the dom repo, we also have to make it have the JSTransferable in it's prototype chain. We accomplish it by creating the AbortSignal first, then actually creating an instance of JSTransferable then setting its prototype to the AbortSignal. It ends up being largely transparent to the user but it's really a bit of a hack. Given that many of the Web Platform APIs are implemented in JavaScript in Node.js that becomes the only way we currently have to make them cloneable or transferable. Because we're creating a native object, there's also a performance penalty. It would be great if we could avoid that. The other use case is the known issue that JS class instances can't currently be cloned. Sure we could require that every application come up with it's own intermediary format for any JS class object they might want to clone but it would be nicer if we made it a bit easier for them -- and doing so in a way that would work consistently across multiple javascript runtimes by baking it into the standard. Basically, I'd like to be able to create a cloneable JavaScript class that works with structuredClone and postMessage no matter what platform the code is run in. |
I don't understand the applicability of your first paragraph to the HTML and DOM Standards. How Node.js chooses to implement those specs has nothing to do with whether the specs make these things clonable, and in general implementation limitations or choices should have no bearing on standardization or use case discussions. So it sounds like this reduces to making something about user classes nicer. Can you state that in the form of use cases that have come up concretely, like the example in the FAQ entry I linked to? |
Note that there seems to be some demand for this on the TC39 side so proxy objects can be serialized, which seems like a legitimate use case. |
Proxies are a good case, yes. I would argue that making it easier for instances of a The other use case are deeply nested object graphs or maps where you don't really know what's necessarily there in advance and can't really know if it's even possible to extract a serializable representation. Consider a case such as: class Foo {
#bar = undefined;
constructor(a) {
this.#bar = a;
}
doSomething() {
if (this.#bar === 1) { /* do something */ }
else { /* do something else */ }
}
}
const map = new Map();
map.set("abc", new Foo(1));
map.set("xyx", new Foo(2));
postMessage(map); Or, the case where the above Using the |
I think Mark in that thread would probably prefer that Proxies be treated like other objects (i.e. iterate the properties and serialize the values, plus a little complexity around arrays and stuff), rather than explicit support for serializing them in any special way. (That's also my preference, full disclosure.) So I don't think this should count as a use case for the purposes of this thread, necessarily. |
@bakkot That is also what I’d like to see re: proxies, both to close the Proxy-exotic-object-status observability hole it creates and because there are already user-code-invoking paths (including getter invocation) so it seemingly(?) isn’t accomplishing anything useful. (Likewise “value is an Array exotic object” → Regarding the proposed idea itself, I’m still pretty curious if the concept would be specific to messaging. We currently use a “descriptor wrappers” pattern towards these ends sometimes with History. A well-known-symbol contract sounds like an appealing alternative, but I’m not sure how (or if) the premise could really work in that context. |
@bakkot wouldn't that mean you can still sniff out proxies from sets or platform objects and such? |
@annevk A Proxy for a Set already does not behave like a Set: Rather, the concern is whether you can distinguish between a Proxy for a regular, non-platform object and a bare such object. Edit: on discussing with @erights, he's also concerned about a Proxy for a Set being practically usable like a Set, so the sketch above wouldn't entirely satisfy him. |
I see, that seems like a relatively straightforward change then. Nice. |
Personally, I would like to see API like: structuredClone.register(Symbol.for('Foo'), {
deserialize(v: Serializable): Foo;
serialize(v: Foo): Serializable;
})
class Foo {
[Symbol.structuredCloneIdentifier]: Symbol.for('Foo');
} If object with custom |
I just found my self in need of cloning a custom built class that i wish to save in IndexedDB |
One idea for deserialization of custom objects across realms could to be utilize a feature like module blocks to provide deserialization steps. i.e. Suppose we have some class we want to make serializable/deserializable: // just a toy example to demonstrate the API
class Point {
#x;
#y;
constructor(x, y) {
this.#x = x;
this.#y = y;
}
get x() {
return this.#x;
}
get y() {
return this.#y;
}
} Then serialization would be pretty trivial by just providing some method: class Point {
// ...
[structuredClone.serialize]() {
return { x: this.#x, y: this.#y };
}
} However because class Point {
// ...
static [structuredClone.deserializerModule] = module {
// Actually import the Point class, this way
// we can create the Point objects in any
// worker/etc that has this module block
import Point from "./Point.js";
// The actual deserializer function
export function deserialize({ x, y }) {
return new Point(x, y);
}
}
} This would work with something like {
[[Type]]: "custom",
// The serialized data returned by [structuredClone.serialize]
[[Data]]: { x: 3, y: 4 },
// The deserializer [structuredClone.deserializerModule]
[[Deserializer]]: module { ... }
} During deserialization when Now there is one caveat here, because class Point {
// thread local deserializer
static [structuredClone.deserialize]({ x, y }) {
return new Point(x, y);
}
// cross-thread deserializer
static [structuredClone.deserializeModule] = module {
import Point from "./Point.js";
export function deserialize(data) {
return Point[structuredClone.deserialize](data);
}
}
} The actual API shape is fairly immaterial, but it shows the idea that we can transfer a deserializer across threads to perform deserialization. And technically the dependency on module blocks isn't really true either, they could be replaced by just providing a deserializer url (although module blocks definitely solves issues regarding CSP and such): class Point {
static [structuredClone.deserializeModule]
= new URL("./Point_deserializer.js", import.meta.url).href;
} We could even imagine something a bit less dynamic if that would help implementations by having an explicit register step (as previously suggested by @Ginden) akin to how i.e. class Point {
// ...rest of impl
static [structuredClone.serialize](point) {
return { x: point.#x, y: point.#y };
}
static [structuredClone.deserialize]({ x, y }) {
return new Point(x, y);
}
static [structuredClone.deserializeModule] = module {
import Point from "./Point.js";
export function deserialize(data) {
return Point[Symbol.deserialize](data);
}
}
}
// Capture the initial value of [structuredClone.serialize], [structuredClone.deserialize]
// and [structuredClone.deserializeModule] similar to how customElements.define captures
// the initial values of connectedCallback and stuff so it can optimize them more easily
structuredClone.register(Point); |
This comment was marked as off-topic.
This comment was marked as off-topic.
I think you're more so asking for: #3517 |
no progress but the demand is greater and greater these days ... my proposal was to add I don't have strong opinion on the Thanks for considering any progress around this topic, it's essential also in WASM related projects and specially when WASM code runs in Workers. |
Explicit registration step, using either strings or symbols stored in global symbol registry, is the only solution that I can think of that would reasonably satisfy following constraints:
Just registering |
I wasn't suggesting using the class name whatsoever, rather the prototype itself is the registry key. Yes my suggestion doesn't support storage, i.e. it only supports postMessage/structuredClone similar to other non-storage types ( |
FWIWI I don't think registering is helpful + if it uses global symbols it still collides. In my specific use case proxies in a realm don't actually want/need or can't be deserialized, they are forwarded back (Atomics + Proxy) so that serialization is all it's needed. Once serialization can return something else compatible with the structuredClone algorithm I think it'd be up to the user / developer to decide what to do with that serialized data. automagic deserialization looks more dangerous than useful to me as it requires registering things twice per each realm and if the registration is not aligned who knows what happens while if there is just enough control to decide what to send in a |
Another day in WASM / JS interop, another issue to tackle around this topic. First of all, I read again the whole thread and I think we don't strictly need Having just "a say" around how stuff should be passed along when The moment any of those proxies need to survive a
Accordingly, I would suggest to focus solely on a Thanks in advance for eventually considering that part in particular and hopefully moving forward this still open requirement for the Web platform. |
On the other hand ... I wonder if this whole request could be confined to a special symbol/behavior for proxies only, so that it could be moved from something broader to something new Proxy(thing, {
structuredProxy(target) {
// custom
return { thing: [...Object.entries(target)] };
// default
return Reflect.structuredProxy(target);
}
}); This way would confine the issue to proxies only, which is the main/major pain point (imho) without exposing the Proxy nature of the instance directly as that needs to be cloned anyway and there's no direct way to tell, on the other end, if that object literal, as example, was a proxy or not before. If this makes sense to anyone, I'd be super happy to help moving forward with it. P.S. it could be named, at the Reflect level, just |
I'm opposed to making this available only for Proxies. That will lead to people using them where not otherwise necessary just so they can customize cloning, which would be bad. |
@bakkot I am not opposed to you being opposed to my latest idea, I am just out of ideas/use cases to present to eventually move this forward anyhow so I am trying to stretch the goal ... let's scratch that Proxy only idea already but please let's try to figure out a way forward around this issue, thank you! edit ... cause if anyone sees that happening, people using proxies just to have this ability, it means this ability is more than desired so please let's find a way forward! |
I agree this would be nice but alas I do not personally have the time to push for anything concrete here. |
@bakkot fair enough ... now, imagine I could patch globally |
@bakkot ... better done than said ... index.html test page <!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<script type="module">
import 'symbol-structured-clone';
class LiteralMap extends Map {
// when cloned or via postMessage
// it will return an object literal instead
[Symbol.structuredClone]() {
return Object.fromEntries([...this]);
}
}
const lm = new LiteralMap([['a', 1], ['b', 2]]);
structuredClone(reference);
// {"a": 1, "b": 2}
postMessage(lm);
// event.data received as
// {"a": 1, "b": 2}
</script>
</head>
</html> For the little I've tested, I think this demo shows what users need to do in order to fix possible issues before The dance is terse but it took a while around the field to try to make it right and even if I publish this tomorrow on npm not all users would understand what it does, why it's needed, and most importantly, why their code is suddenly more correct and less error prone, but also slower in every raw benchmarks results. I hope this helps understanding what we, Web users, need to deal with when it comes to cross realms or cloning situations and I still hope this issue would move forward sooner than later. |
I went ahead and created a polyfill based on previous code that automatically patches I am not suggesting anyone to use this in production but it surely shows that it is possible to have this feature and if it would be backed into the specs and accepted as new symbol by TC39 too it could be a polyfill to use until all vendors are aligned. Note that if Thanks again for eventually considering this hugely desired improvement to the Web platform. |
My personal use case for this would be to be able to add my own custom primitives that can be passed along between Right now, I'm using my own custom Right now, when passing my |
In Node.js we have implemented a Node.js specific ability for various built-in objects to provide their own serialization/deserialization for cloning or transfer. The mechanism work by placing a platform host object into the JavaScript objects prototype chain then attaching specific symbols to the JavaScript object that implement the serialize and deserialize functions.
For instance,
The
JSTransferable
here is a host object implemented in C++. Our value serializer delegate understands that to clone any object that extends from JSTransferable, it simply needs to look for it's [kClone] method and serializes the returned data in the object's place.The value deserializer delegate is a bit trickier. Currently, only Node.js core objects can extend from JSTransferable because the deserializer has to be sure it can locate and resolve the definition of the Foo class in order to create it and deserialize it properly. Essentially the process is: use the deserializationInfo to resolve the class, then once resolved, pass the data in to the kDeserialize method.
The mechanism works well but is definitely limited because it (a) only works in Node.js and (b) only works with Node.js core objects. We'd like to be able to extend the capability to user defined objects.
There are three key pieces here that would need to be standardized:
kClone
,kTransfer
, andkDeserialize
.kClone
andkTransfer
to feed into the serializer.This mechanism does allow for transferring native host objects but that's more specialized and I'm not asking for that here.
For the receiving side, one possible way of accomplishing the resolution of the deserializer is to use a registry in the form of an Event on the
MessagePort
For structuredClone(), the same basic pattern can be used, passing in an EventTarget as a "deserialization controller"
If a deserializer is not provided or fails, an appropriate DOMException is reported.
The text was updated successfully, but these errors were encountered: