Repository: denoland/deno
Pull request: feat(core): add cloneable resource registry for structured clone

## Summary
- Adds infrastructure for custom JS objects to support structured cloning via `postMessage`/`MessageChannel`
- Objects set `[core.hostObjectBrand]` to a serializer function returning `{ type: "<name>", ...data }`, then register a deserializer via `core.registerCloneableResource(name, fn)`
- Marks `op_deserialize` as reentrant so deserializer callbacks can invoke ops
- Passes registered cloneable deserializers during message port deserialization

This enables objects like `CryptoKey` (#12734) and `X509Certificate` to be cloned across message ports, addressing #12067.

For reference, here's how this approach compares to Node.js's implementation of structured cloning for custom objects:

### Node.js approach
- Hooks into V8's serializer delegate at the **C++ level** (`src/node_messaging.cc` — `WriteHostObject`/`ReadHostObject`)
- Objects call `markTransferMode(this, cloneable, transferable)` which sets a **private symbol** with bitflags (`kCloneable = 0x1`, `kTransferable = 0x2`)
- During serialization, the delegate calls `obj[kClone]()` which returns `{ data, deserializeInfo: "module:ClassName" }` (e.g. `"internal/crypto/keys:InternalCryptoKey"`)
- During deserialization, uses **dynamic `require()` module loading** to discover the constructor from the `deserializeInfo` string — no explicit registry needed
- **Two-phase deserialization**: first creates an empty instance via the constructor, then calls `obj[kDeserialize](data)` to populate it
- Supports both C++ `BaseObject`s (via virtual methods) and JS objects (via symbols) in a unified framework

### Our approach
- Hooks into the **same V8 mechanism** but from the Rust/JS side — `[core.hostObjectBrand]` symbol triggers `write_host_object`/`read_host_object` in `ops_builtin_v8.rs` (this infrastructure already existed)
- Objects set `[core.hostObjectBrand]` to a **serializer function** that returns `{ type: "<name>", ...data }`
- Uses an **explicit registry** (`core.registerCloneableResource(name, deserializerFn)`) mapping type name → deserializer function
- **Single-phase deserialization**: the deserializer function creates and returns the final object directly (simpler, no separate create/populate phases)

Ref: https://github.com/denoland/deno/issues/12067
Ref: https://github.com/denoland/deno/issues/12734

Changed files:
filename: ext/web/13_message_port.js
patch: @@ -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) {

filename: libs/core/01_core.js
patch: @@ -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,

filename: libs/core/ops_builtin_v8.rs
patch: @@ -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,

filename: libs/core_testing/unit/serialize_deserialize_test.ts
patch: @@ -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",
+  );
+});