fix(napi): allow structuredClone on napi_create_object results

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
This commit is contained in:
Dylan Conway
2026-02-25 20:21:44 -08:00
parent b2d8504a09
commit 1a8a5cd883
4 changed files with 35 additions and 1 deletions

View File

@@ -117,6 +117,7 @@
#include "JSPrivateKeyObject.h"
#include "CryptoKeyType.h"
#include "JSNodePerformanceHooksHistogram.h"
#include "../napi.h"
#include <limits>
#include <algorithm>
@@ -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);

View File

@@ -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);

View File

@@ -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 = [
{},

View File

@@ -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", () => {