From b3c6f5ee4accb30a842364deb9200c5882c4a9e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Thu, 12 Mar 2026 18:41:52 +0100 Subject: [PATCH 1/2] feat(core): add cloneable resource registry for structured clone support Adds infrastructure for custom JS objects to support structured cloning via `postMessage`/`MessageChannel`. This enables objects like `CryptoKey` and `X509Certificate` to be cloned across message ports. The mechanism works by: 1. Objects set `[core.hostObjectBrand]` to a serializer function that returns `{ type: "", ...data }` 2. Extensions register a deserializer via `core.registerCloneableResource(name, deserializerFn)` 3. During deserialization, the registry is consulted to reconstruct the original object Changes: - `libs/core/01_core.js`: Add `registerCloneableResource` / `getCloneableDeserializers` registry, auto-pass deserializers in `structuredClone` - `libs/core/ops_builtin_v8.rs`: Mark `op_deserialize` as reentrant so deserializer callbacks can invoke ops - `ext/web/13_message_port.js`: Pass cloneable deserializers during message deserialization Ref: https://github.com/denoland/deno/issues/12067 Ref: https://github.com/denoland/deno/issues/12734 Co-Authored-By: Claude Opus 4.6 --- ext/web/13_message_port.js | 6 ++++++ libs/core/01_core.js | 12 +++++++++++- libs/core/ops_builtin_v8.rs | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/ext/web/13_message_port.js b/ext/web/13_message_port.js index f6b3d74703aa3e..49c08e595828c4 100644 --- a/ext/web/13_message_port.js +++ b/ext/web/13_message_port.js @@ -409,6 +409,12 @@ function deserializeJsMessageData(messageData) { }; } + const deserializers = core.getCloneableDeserializers(); + if (!options) { + options = { deserializers }; + } else { + options.deserializers = deserializers; + } const data = core.deserialize(messageData.data, options); for (let i = 0; i < arrayBufferIdsInTransferables.length; ++i) { diff --git a/libs/core/01_core.js b/libs/core/01_core.js index 27f0f41ba4500d..45c4ccbcf40793 100755 --- a/libs/core/01_core.js +++ b/libs/core/01_core.js @@ -683,6 +683,14 @@ transferableResources[name] = { send, receive }; }; const getTransferableResource = (name) => transferableResources[name]; + const cloneableDeserializers = { __proto__: null }; + const registerCloneableResource = (name, deserialize) => { + if (cloneableDeserializers[name]) { + throw new Error(`${name} is already registered`); + } + cloneableDeserializers[name] = deserialize; + }; + const getCloneableDeserializers = () => cloneableDeserializers; // A helper function that will bind our own console implementation // with default implementation of Console from V8. This will cause @@ -1025,11 +1033,13 @@ hostObjectBrand, registerTransferableResource, getTransferableResource, + registerCloneableResource, + getCloneableDeserializers, encode: (text) => op_encode(text), encodeBinaryString: (buffer) => op_encode_binary_string(buffer), decode: (buffer) => op_decode(buffer), structuredClone: (value, deserializers) => - op_structured_clone(value, deserializers), + op_structured_clone(value, deserializers ?? cloneableDeserializers), serialize: ( value, options, diff --git a/libs/core/ops_builtin_v8.rs b/libs/core/ops_builtin_v8.rs index b0da9327213fe7..03496f7529063c 100644 --- a/libs/core/ops_builtin_v8.rs +++ b/libs/core/ops_builtin_v8.rs @@ -848,7 +848,7 @@ pub fn op_serialize<'s, 'i>( } } -#[op2] +#[op2(reentrant)] pub fn op_deserialize<'s, 'i>( scope: &mut v8::PinScope<'s, 'i>, #[buffer] zero_copy: JsBuffer, From 3abfed704f8d8f23fd4f2c25dbb95338a4261b97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Thu, 12 Mar 2026 18:49:03 +0100 Subject: [PATCH 2/2] test(core): add tests for cloneable resource registry Tests cover: - structuredClone with a cloneable object - serialize/deserialize round-trip with cloneable object - cloneable object nested inside a plain object - duplicate registration throws Co-Authored-By: Claude Opus 4.6 --- .../unit/serialize_deserialize_test.ts | 108 +++++++++++++++++- 1 file changed, 107 insertions(+), 1 deletion(-) diff --git a/libs/core_testing/unit/serialize_deserialize_test.ts b/libs/core_testing/unit/serialize_deserialize_test.ts index 674ac5581df4cc..6ac454741c2fcb 100644 --- a/libs/core_testing/unit/serialize_deserialize_test.ts +++ b/libs/core_testing/unit/serialize_deserialize_test.ts @@ -1,5 +1,11 @@ // Copyright 2018-2026 the Deno authors. MIT license. -import { assertArrayEquals, assertEquals, test } from "checkin:testing"; +import { + assert, + assertArrayEquals, + assertEquals, + assertThrows, + test, +} from "checkin:testing"; test(function testIssue20727() { // https://github.com/denoland/deno/issues/20727 @@ -109,3 +115,103 @@ test(function structuredClone() { assertEquals(cloned.test2, circularObject.test2); assertEquals(cloned.test3, circularObject.test3); }); + +test(function cloneableResourceStructuredClone() { + // Create a class that supports structured cloning via hostObjectBrand + class MyCloneable { + value: string; + constructor(value: string) { + this.value = value; + // deno-lint-ignore no-this-alias + const self = this; + this[Deno.core.hostObjectBrand] = () => ({ + type: "MyCloneable", + value: self.value, + }); + } + } + + Deno.core.registerCloneableResource( + "MyCloneable", + (data: { value: string }) => new MyCloneable(data.value), + ); + + const original = new MyCloneable("hello"); + const cloned = Deno.core.structuredClone(original); + + assert(cloned instanceof MyCloneable); + assertEquals(cloned.value, "hello"); + assert(cloned !== original); +}); + +test(function cloneableResourceSerializeDeserialize() { + // Use a different name to avoid duplicate registration + class AnotherCloneable { + data: number; + constructor(data: number) { + this.data = data; + // deno-lint-ignore no-this-alias + const self = this; + this[Deno.core.hostObjectBrand] = () => ({ + type: "AnotherCloneable", + data: self.data, + }); + } + } + + Deno.core.registerCloneableResource( + "AnotherCloneable", + (d: { data: number }) => new AnotherCloneable(d.data), + ); + + const original = new AnotherCloneable(42); + const serialized = Deno.core.serialize(original); + const deserialized = Deno.core.deserialize(serialized, { + deserializers: Deno.core.getCloneableDeserializers(), + }); + + assert(deserialized instanceof AnotherCloneable); + assertEquals(deserialized.data, 42); +}); + +test(function cloneableResourceNestedInObject() { + class NestedCloneable { + name: string; + constructor(name: string) { + this.name = name; + // deno-lint-ignore no-this-alias + const self = this; + this[Deno.core.hostObjectBrand] = () => ({ + type: "NestedCloneable", + name: self.name, + }); + } + } + + Deno.core.registerCloneableResource( + "NestedCloneable", + (d: { name: string }) => new NestedCloneable(d.name), + ); + + const obj = { + foo: "bar", + nested: new NestedCloneable("test"), + num: 123, + }; + + const cloned = Deno.core.structuredClone(obj); + + assertEquals(cloned.foo, "bar"); + assertEquals(cloned.num, 123); + assert(cloned.nested instanceof NestedCloneable); + assertEquals(cloned.nested.name, "test"); +}); + +test(function cloneableResourceDuplicateRegistrationThrows() { + Deno.core.registerCloneableResource("DuplicateTest", () => {}); + assertThrows( + () => Deno.core.registerCloneableResource("DuplicateTest", () => {}), + Error, + "already registered", + ); +});