From 1a8a5cd883b672fb7a3541fc321c263fbc31d899 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Wed, 25 Feb 2026 20:21:44 -0800 Subject: [PATCH] fix(napi): allow structuredClone on napi_create_object results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bun's napi_create_object creates a NapiPrototype (JSDestructibleObject subclass) instead of a plain JSFinalObject. The NapiPrototype has an inline napiRef field as a fast path for napi_wrap/unwrap. WebKit's SerializedScriptValue serializer checks classInfo() != JSFinalObject::info() before treating an object as a generic clonable object. NapiPrototype has its own ClassInfo, so the check failed with DataCloneError. This adds NapiPrototype::info() to the serializer's allowlist. The inline napiRef C++ field is invisible to property enumeration (serializer uses getOwnPropertyNames with PrivateSymbolMode::Exclude), so cloned objects correctly get only the JS properties — matching Node.js behavior where napi_unwrap also fails on cloned objects. Fixes #25658 --- .../bindings/webcore/SerializedScriptValue.cpp | 5 ++++- test/napi/napi-app/js_test_helpers.cpp | 8 ++++++++ test/napi/napi-app/module.js | 16 ++++++++++++++++ test/napi/napi.test.ts | 7 +++++++ 4 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/bun.js/bindings/webcore/SerializedScriptValue.cpp b/src/bun.js/bindings/webcore/SerializedScriptValue.cpp index 7a0e11d3f5..cc40fe8eb4 100644 --- a/src/bun.js/bindings/webcore/SerializedScriptValue.cpp +++ b/src/bun.js/bindings/webcore/SerializedScriptValue.cpp @@ -117,6 +117,7 @@ #include "JSPrivateKeyObject.h" #include "CryptoKeyType.h" #include "JSNodePerformanceHooksHistogram.h" +#include "../napi.h" #include #include @@ -2665,7 +2666,9 @@ SerializationReturnCode CloneSerializer::serialize(JSValue in) // objects have been handled. If we reach this point and // the input is not an Object object then we should throw // a DataCloneError. - if (inObject->classInfo() != JSFinalObject::info()) + // NapiPrototype is allowed because napi_create_object should behave + // like a plain object from JS's perspective (matches Node.js). + if (inObject->classInfo() != JSFinalObject::info() && inObject->classInfo() != Zig::NapiPrototype::info()) return SerializationReturnCode::DataCloneError; inputObjectStack.append(inObject); indexStack.append(0); diff --git a/test/napi/napi-app/js_test_helpers.cpp b/test/napi/napi-app/js_test_helpers.cpp index 8340c3560d..ba031a754b 100644 --- a/test/napi/napi-app/js_test_helpers.cpp +++ b/test/napi/napi-app/js_test_helpers.cpp @@ -236,6 +236,13 @@ static napi_value make_empty_array(const Napi::CallbackInfo &info) { return array; } +static napi_value make_empty_object(const Napi::CallbackInfo &info) { + napi_env env = info.Env(); + napi_value object; + NODE_API_CALL(env, napi_create_object(env, &object)); + return object; +} + // add_tag(object, lower, upper) static napi_value add_tag(const Napi::CallbackInfo &info) { Napi::Env env = info.Env(); @@ -424,6 +431,7 @@ void register_js_test_helpers(Napi::Env env, Napi::Object exports) { REGISTER_FUNCTION(env, exports, throw_error); REGISTER_FUNCTION(env, exports, create_and_throw_error); REGISTER_FUNCTION(env, exports, make_empty_array); + REGISTER_FUNCTION(env, exports, make_empty_object); REGISTER_FUNCTION(env, exports, add_tag); REGISTER_FUNCTION(env, exports, try_add_tag); REGISTER_FUNCTION(env, exports, check_tag); diff --git a/test/napi/napi-app/module.js b/test/napi/napi-app/module.js index facabd3dd0..e2c0da93ed 100644 --- a/test/napi/napi-app/module.js +++ b/test/napi/napi-app/module.js @@ -446,6 +446,22 @@ nativeTests.test_reflect_construct_no_prototype_crash = () => { console.log("✓ Success - no crash!"); }; +nativeTests.test_napi_create_object_structured_clone = () => { + // https://github.com/oven-sh/bun/issues/25658 + const obj = nativeTests.make_empty_object(); + assert.deepStrictEqual(obj, {}); + const cloned = structuredClone(obj); + assert.deepStrictEqual(cloned, {}); + + obj.foo = "bar"; + obj.nested = { x: 1 }; + const cloned2 = structuredClone(obj); + assert.deepStrictEqual(cloned2, { foo: "bar", nested: { x: 1 } }); + assert(cloned2 !== obj); + assert(cloned2.nested !== obj.nested); + console.log("pass"); +}; + nativeTests.test_napi_wrap = () => { const values = [ {}, diff --git a/test/napi/napi.test.ts b/test/napi/napi.test.ts index e6390ad61f..f3e4654214 100644 --- a/test/napi/napi.test.ts +++ b/test/napi/napi.test.ts @@ -426,6 +426,13 @@ describe.concurrent("napi", () => { }); }); + describe("napi_create_object", () => { + // https://github.com/oven-sh/bun/issues/25658 + it("result is clonable with structuredClone", async () => { + await checkSameOutput("test_napi_create_object_structured_clone", []); + }); + }); + // TODO(@190n) test allocating in a finalizer from a napi module with the right version describe("napi_wrap", () => {