Skip to content

Commit a8eadec

Browse files
ldanilekConvex, Inc.
authored and
Convex, Inc.
committed
implement structuredClone (#33796)
implement structuredClone with credit to Deno. this is our first op that doesn't use `convex_macro::v8_op` because it wants to do its own serde. some of the options are simplified away because they aren't hit by the structuredClone codepath. And I skipped some of the optimizations, like calling `slice` to clone TypedArray subclasses, because converting the primordials seemed tricky. added a bunch of tests that i thought were relevant. haven't yet looked through the deno tests to see what other cases we might want to check. GitOrigin-RevId: 9f72cca9e25db1d51149d62fa07b04961b5bb83a
1 parent 31d725d commit a8eadec

File tree

7 files changed

+325
-0
lines changed

7 files changed

+325
-0
lines changed

crates/isolate/src/ops/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ mod http;
1313
mod random;
1414
mod storage;
1515
mod stream;
16+
mod structured_clone;
1617
mod text;
1718
mod time;
1819
mod validate_args;
@@ -43,6 +44,7 @@ use deno_core::{
4344
};
4445
use rand_chacha::ChaCha12Rng;
4546
use sourcemap::SourceMap;
47+
use structured_clone::op_structured_clone;
4648
use uuid::Uuid;
4749
use validate_returns::op_validate_returns;
4850
use value::{
@@ -389,6 +391,7 @@ pub fn run_op<'b, P: OpProvider<'b>>(
389391
"textEncoder/normalizeLabel" => op_text_encoder_normalize_label(provider, args, rv)?,
390392
"atob" => op_atob(provider, args, rv)?,
391393
"btoa" => op_btoa(provider, args, rv)?,
394+
"structuredClone" => op_structured_clone(provider, args.get(1), rv)?,
392395
"environmentVariables/get" => op_environment_variables_get(provider, args, rv)?,
393396
"getTableMappingWithoutSystemTables" => {
394397
op_get_table_mapping_without_system_tables(provider, args, rv)?
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// Copyright 2018-2025 the Deno authors. All rights reserved. MIT license.
2+
// https://github.com/denoland/deno_core/blob/main/core/ops_builtin_v8.rs
3+
4+
use deno_core::v8::{
5+
self,
6+
ValueDeserializerHelper,
7+
ValueSerializerHelper,
8+
};
9+
use errors::ErrorMetadata;
10+
11+
use super::OpProvider;
12+
13+
// NOTE: not using `v8_op` macro because we want to handle serde ourselves.
14+
pub fn op_structured_clone<'b, P: OpProvider<'b>>(
15+
provider: &mut P,
16+
value: v8::Local<v8::Value>,
17+
mut rv: v8::ReturnValue,
18+
) -> anyhow::Result<()> {
19+
let data = op_serialize(provider.scope(), value)?;
20+
let value = op_deserialize(provider.scope(), data)?;
21+
rv.set(value);
22+
Ok(())
23+
}
24+
25+
// The following is copied from `deno_core/core/ops_builtin_v8.rs`
26+
// With simplifications to remove unused options.
27+
struct SerializeDeserialize {
28+
host_object_brand: Option<v8::Global<v8::Symbol>>,
29+
}
30+
31+
impl v8::ValueSerializerImpl for SerializeDeserialize {
32+
#[allow(unused_variables)]
33+
fn throw_data_clone_error<'s>(
34+
&mut self,
35+
scope: &mut v8::HandleScope<'s>,
36+
message: v8::Local<'s, v8::String>,
37+
) {
38+
let error = v8::Exception::type_error(scope, message);
39+
scope.throw_exception(error);
40+
}
41+
42+
fn get_shared_array_buffer_id<'s>(
43+
&mut self,
44+
_scope: &mut v8::HandleScope<'s>,
45+
_shared_array_buffer: v8::Local<'s, v8::SharedArrayBuffer>,
46+
) -> Option<u32> {
47+
None
48+
}
49+
50+
fn get_wasm_module_transfer_id(
51+
&mut self,
52+
_scope: &mut v8::HandleScope<'_>,
53+
_module: v8::Local<v8::WasmModuleObject>,
54+
) -> Option<u32> {
55+
None
56+
}
57+
58+
fn has_custom_host_object(&mut self, _isolate: &mut v8::Isolate) -> bool {
59+
true
60+
}
61+
62+
fn is_host_object<'s>(
63+
&mut self,
64+
scope: &mut v8::HandleScope<'s>,
65+
object: v8::Local<'s, v8::Object>,
66+
) -> Option<bool> {
67+
if let Some(symbol) = &self.host_object_brand {
68+
let key = v8::Local::new(scope, symbol);
69+
object.has_own_property(scope, key.into())
70+
} else {
71+
Some(false)
72+
}
73+
}
74+
75+
fn write_host_object<'s>(
76+
&mut self,
77+
scope: &mut v8::HandleScope<'s>,
78+
_object: v8::Local<'s, v8::Object>,
79+
_value_serializer: &mut dyn v8::ValueSerializerHelper,
80+
) -> Option<bool> {
81+
let message = v8::String::new(scope, "Unsupported object type").unwrap();
82+
self.throw_data_clone_error(scope, message);
83+
None
84+
}
85+
}
86+
87+
impl v8::ValueDeserializerImpl for SerializeDeserialize {
88+
fn get_shared_array_buffer_from_id<'s>(
89+
&mut self,
90+
_scope: &mut v8::HandleScope<'s>,
91+
_transfer_id: u32,
92+
) -> Option<v8::Local<'s, v8::SharedArrayBuffer>> {
93+
None
94+
}
95+
96+
fn get_wasm_module_from_id<'s>(
97+
&mut self,
98+
_scope: &mut v8::HandleScope<'s>,
99+
_clone_id: u32,
100+
) -> Option<v8::Local<'s, v8::WasmModuleObject>> {
101+
None
102+
}
103+
104+
fn read_host_object<'s>(
105+
&mut self,
106+
scope: &mut v8::HandleScope<'s>,
107+
_value_deserializer: &mut dyn v8::ValueDeserializerHelper,
108+
) -> Option<v8::Local<'s, v8::Object>> {
109+
let message: v8::Local<v8::String> =
110+
v8::String::new(scope, "Failed to deserialize host object").unwrap();
111+
let error = v8::Exception::error(scope, message);
112+
scope.throw_exception(error);
113+
None
114+
}
115+
}
116+
117+
pub fn op_serialize(
118+
scope: &mut v8::HandleScope,
119+
value: v8::Local<v8::Value>,
120+
) -> anyhow::Result<Vec<u8>> {
121+
let key = v8::String::new(scope, "Deno.core.hostObject").unwrap();
122+
let symbol = v8::Symbol::for_key(scope, key);
123+
let host_object_brand = Some(v8::Global::new(scope, symbol));
124+
125+
let serialize_deserialize = Box::new(SerializeDeserialize { host_object_brand });
126+
let mut value_serializer = v8::ValueSerializer::new(scope, serialize_deserialize);
127+
value_serializer.write_header();
128+
129+
let scope = &mut v8::TryCatch::new(scope);
130+
let ret = value_serializer.write_value(scope.get_current_context(), value);
131+
if scope.has_caught() || scope.has_terminated() {
132+
scope.rethrow();
133+
// Dummy value, this result will be discarded because an error was thrown.
134+
Ok(vec![])
135+
} else if let Some(true) = ret {
136+
let vector = value_serializer.release();
137+
Ok(vector)
138+
} else {
139+
// TODO: incorrect error type, should be TypeError
140+
Err(ErrorMetadata::bad_request("SerializeFailed", "Failed to serialize response").into())
141+
}
142+
}
143+
144+
pub fn op_deserialize<'a>(
145+
scope: &mut v8::HandleScope<'a>,
146+
data: Vec<u8>,
147+
) -> anyhow::Result<v8::Local<'a, v8::Value>> {
148+
let serialize_deserialize = Box::new(SerializeDeserialize {
149+
host_object_brand: None,
150+
});
151+
let mut value_deserializer = v8::ValueDeserializer::new(scope, serialize_deserialize, &data);
152+
let parsed_header = value_deserializer
153+
.read_header(scope.get_current_context())
154+
.unwrap_or_default();
155+
if !parsed_header {
156+
return Err(
157+
// TODO: incorrect error type, should be RangeError
158+
ErrorMetadata::bad_request("DeserializeFailed", "could not deserialize value").into(),
159+
);
160+
}
161+
162+
let value = value_deserializer.read_value(scope.get_current_context());
163+
match value {
164+
Some(deserialized) => Ok(deserialized),
165+
None => Err(
166+
// TODO: incorrect error type, should be RangeError
167+
ErrorMetadata::bad_request("DeserializeFailed", "could not deserialize value").into(),
168+
),
169+
}
170+
}

crates/isolate/src/tests/js_builtins.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,3 +207,16 @@ async fn test_set_timeout(rt: TestRuntime) -> anyhow::Result<()> {
207207
Ok(())
208208
}).await
209209
}
210+
211+
#[convex_macro::test_runtime]
212+
async fn test_structured_clone(rt: TestRuntime) -> anyhow::Result<()> {
213+
UdfTest::run_test_with_isolate2(rt, async move |t: UdfTestType| {
214+
must_let!(let ConvexValue::String(r) = t.query("js_builtins/structuredClone", assert_obj!()).await?);
215+
assert_eq!(String::from(r), "success".to_string());
216+
217+
let e = t.query_js_error("js_builtins/structuredClone:withTransfer", assert_obj!()).await?;
218+
assert_contains(&e, "structuredClone with transfer not supported");
219+
Ok(())
220+
})
221+
.await
222+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { performOp } from "udf-syscall-ffi";
2+
import { throwUncatchableDeveloperError } from "./helpers";
3+
4+
function structuredClone(value: any, options?: { transfer: any[] }) {
5+
if (options !== undefined) {
6+
return throwUncatchableDeveloperError(
7+
"structuredClone with transfer not supported",
8+
);
9+
}
10+
return performOp("structuredClone", value);
11+
}
12+
13+
export const setupStructuredClone = (global: any) => {
14+
global.structuredClone = structuredClone;
15+
};

npm-packages/udf-runtime/src/setup.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { setupSourceMapping } from "./errors.js";
1717
import { throwUncatchableDeveloperError } from "./helpers.js";
1818
import { getBlob, getResponse, storeBlob, storeRequest } from "./storage.js";
1919
import { performOp } from "udf-syscall-ffi";
20+
import { setupStructuredClone } from "./02_structured_clone.js";
2021

2122
/**
2223
* Set up the global object for a UDF context with deterministic Convex APIs.
@@ -39,6 +40,7 @@ export function setup(global: any) {
3940
setupDOMException(global);
4041
setupConsole(global);
4142
setupEvent(global);
43+
setupStructuredClone(global);
4244
setupTimers(global);
4345
setupAbortSignal(global);
4446
setupStreams(global);

npm-packages/udf-tests/convex/_generated/api.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import type * as js_builtins_request from "../js_builtins/request.js";
5656
import type * as js_builtins_response from "../js_builtins/response.js";
5757
import type * as js_builtins_setTimeout from "../js_builtins/setTimeout.js";
5858
import type * as js_builtins_stream from "../js_builtins/stream.js";
59+
import type * as js_builtins_structuredClone from "../js_builtins/structuredClone.js";
5960
import type * as js_builtins_testHelpers from "../js_builtins/testHelpers.js";
6061
import type * as js_builtins_textEncoder from "../js_builtins/textEncoder.js";
6162
import type * as js_builtins_url from "../js_builtins/url.js";
@@ -131,6 +132,7 @@ declare const fullApi: ApiFromModules<{
131132
"js_builtins/response": typeof js_builtins_response;
132133
"js_builtins/setTimeout": typeof js_builtins_setTimeout;
133134
"js_builtins/stream": typeof js_builtins_stream;
135+
"js_builtins/structuredClone": typeof js_builtins_structuredClone;
134136
"js_builtins/testHelpers": typeof js_builtins_testHelpers;
135137
"js_builtins/textEncoder": typeof js_builtins_textEncoder;
136138
"js_builtins/url": typeof js_builtins_url;
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { query } from "../_generated/server";
2+
import { assert } from "chai";
3+
import { wrapInTests } from "./testHelpers";
4+
5+
function testStructuredCloneJson() {
6+
// Test simple string
7+
const stringValue = "hello";
8+
const clonedString = structuredClone(stringValue);
9+
assert.strictEqual(clonedString, stringValue);
10+
11+
// Test nested object
12+
const objectValue = { a: ["b", "c"], d: 1 };
13+
const clonedObject = structuredClone(objectValue);
14+
assert.deepEqual(clonedObject, objectValue);
15+
assert.notEqual(clonedObject, objectValue); // Different object reference
16+
}
17+
18+
function testStructuredCloneArrayBuffer() {
19+
const size = 5;
20+
const buffer = new ArrayBuffer(size);
21+
const view = new Uint8Array(buffer);
22+
for (let i = 0; i < size; i++) {
23+
view[i] = i % 256;
24+
}
25+
const cloned = structuredClone(buffer);
26+
const clonedView = new Uint8Array(cloned);
27+
assert.instanceOf(cloned, ArrayBuffer);
28+
assert.deepEqual(Array.from(clonedView), [0, 1, 2, 3, 4]);
29+
}
30+
31+
function testStructuredCloneTypedArray() {
32+
const values = [1, 2, 4];
33+
const array = new Int32Array(values);
34+
const cloned = structuredClone(array);
35+
assert.instanceOf(cloned, Int32Array);
36+
assert.deepEqual(Array.from(cloned), values);
37+
}
38+
39+
function testStructuredCloneNonJson() {
40+
// Test Date
41+
const date = new Date(100000.0);
42+
const cloneDate = structuredClone(date);
43+
assert.strictEqual(date.getTime(), cloneDate.getTime());
44+
45+
// Test Map
46+
const map = new Map();
47+
map.set(1, 2);
48+
const cloneMap = structuredClone(map);
49+
assert.strictEqual(cloneMap.size, 1);
50+
assert.strictEqual(cloneMap.get(1), 2);
51+
52+
// Test Set
53+
const set = new Set();
54+
set.add(1);
55+
const cloneSet = structuredClone(set);
56+
assert.strictEqual(cloneSet.size, 1);
57+
assert.isTrue(cloneSet.has(1));
58+
59+
// Test BigInt
60+
const bigint = BigInt(1);
61+
const cloneBigint = structuredClone(bigint);
62+
assert.strictEqual(bigint, cloneBigint);
63+
64+
// Test RegExp
65+
const regex = /a/g;
66+
const cloneRegex = structuredClone(regex);
67+
assert.strictEqual(regex.toString(), cloneRegex.toString());
68+
assert.instanceOf(cloneRegex, RegExp);
69+
70+
// Test Function
71+
const func = () => {};
72+
assert.throws(() => structuredClone(func), /could not deserialize value/);
73+
74+
// Test Symbol
75+
const symbol = Symbol("test");
76+
assert.throws(() => structuredClone(symbol), /could not deserialize value/);
77+
78+
// Test Error
79+
const error = new Error("test");
80+
const cloneError = structuredClone(error);
81+
assert.strictEqual(error.message, cloneError.message);
82+
assert.instanceOf(cloneError, Error);
83+
}
84+
85+
function testStructuredCloneRecursive() {
86+
// Test recursive object
87+
const recursiveObject: any = { a: 1 };
88+
recursiveObject["b"] = recursiveObject;
89+
const cloned = structuredClone(recursiveObject);
90+
assert.strictEqual(cloned.a, 1);
91+
assert.strictEqual(cloned.b, cloned);
92+
assert.notEqual(cloned, recursiveObject);
93+
94+
// Test recursive array
95+
const recursiveArray: any[] = [1];
96+
recursiveArray.push(recursiveArray);
97+
recursiveArray.push(recursiveObject);
98+
const clonedArray = structuredClone(recursiveArray);
99+
assert.strictEqual(clonedArray[0], 1);
100+
assert.strictEqual(clonedArray[1], clonedArray);
101+
assert.strictEqual(clonedArray[2].b, clonedArray[2]);
102+
assert.notEqual(clonedArray, recursiveArray);
103+
assert.notEqual(clonedArray[2], recursiveObject);
104+
}
105+
106+
export default query(async (): Promise<string> => {
107+
return await wrapInTests({
108+
testStructuredCloneJson,
109+
testStructuredCloneArrayBuffer,
110+
testStructuredCloneTypedArray,
111+
testStructuredCloneNonJson,
112+
testStructuredCloneRecursive,
113+
});
114+
});
115+
116+
export const withTransfer = query(async () => {
117+
const array = new ArrayBuffer(8);
118+
const _tranferred = structuredClone(array, { transfer: [array] });
119+
return "expected uncatchable error";
120+
});

0 commit comments

Comments
 (0)