Skip to content

Commit 286bd11

Browse files
committed
feat: switch Bun IPC codec from JSON to CBOR
Replace JSON.stringify/parse with cbor-x (JS) and ciborium (Rust) for the Bun IPC codec path. CBOR encode is ~2x faster than JSON.stringify on the JS side while maintaining binary-native payload support. Node.js path remains unchanged (V8 ValueSerializer).
1 parent 7823672 commit 286bd11

File tree

9 files changed

+374
-59
lines changed

9 files changed

+374
-59
lines changed

native/v8-runtime/Cargo.lock

Lines changed: 95 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

native/v8-runtime/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ v8 = "130"
1414
crossbeam-channel = "0.5"
1515
signal-hook = "0.3"
1616
libc = "0.2"
17+
ciborium = "0.2"

native/v8-runtime/src/bridge.rs

Lines changed: 140 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -12,26 +12,25 @@ use v8::ValueSerializerHelper;
1212

1313
use crate::host_call::BridgeCallContext;
1414

15-
// JSON codec flag: when true, use JSON.stringify/JSON.parse instead of V8
15+
// CBOR codec flag: when true, use CBOR (via ciborium) instead of V8
1616
// ValueSerializer/ValueDeserializer for IPC payloads. Activated by
17-
// SECURE_EXEC_V8_CODEC=json for runtimes whose node:v8 module doesn't
17+
// SECURE_EXEC_V8_CODEC=cbor for runtimes whose node:v8 module doesn't
1818
// produce real V8 serialization format (e.g. Bun).
19-
static USE_JSON_CODEC: AtomicBool = AtomicBool::new(false);
19+
static USE_CBOR_CODEC: AtomicBool = AtomicBool::new(false);
2020

2121
/// Initialize the codec from the SECURE_EXEC_V8_CODEC environment variable.
2222
/// Call once at process startup before any sessions are created.
2323
pub fn init_codec() {
2424
if let Ok(val) = std::env::var("SECURE_EXEC_V8_CODEC") {
25-
if val == "json" {
26-
USE_JSON_CODEC.store(true, Ordering::Relaxed);
27-
eprintln!("secure-exec-v8: using JSON codec for IPC payloads");
25+
if val == "cbor" {
26+
USE_CBOR_CODEC.store(true, Ordering::Relaxed);
2827
}
2928
}
3029
}
3130

32-
/// Returns true if the JSON codec is active.
33-
pub fn is_json_codec() -> bool {
34-
USE_JSON_CODEC.load(Ordering::Relaxed)
31+
/// Returns true if the CBOR codec is active.
32+
pub fn is_cbor_codec() -> bool {
33+
USE_CBOR_CODEC.load(Ordering::Relaxed)
3534
}
3635

3736
/// External references for V8 snapshot serialization.
@@ -73,13 +72,13 @@ impl v8::ValueDeserializerImpl for DefaultDeserializerDelegate {}
7372
/// Serialize a V8 value to bytes using V8's built-in ValueSerializer.
7473
/// Handles all V8 types natively: primitives, strings, arrays, objects,
7574
/// Uint8Array, Date, Map, Set, RegExp, Error, and circular references.
76-
/// When JSON codec is active, uses JSON.stringify instead.
75+
/// When CBOR codec is active, uses ciborium instead.
7776
pub fn serialize_v8_value(
7877
scope: &mut v8::HandleScope,
7978
value: v8::Local<v8::Value>,
8079
) -> Result<Vec<u8>, String> {
81-
if is_json_codec() {
82-
return serialize_json_value(scope, value);
80+
if is_cbor_codec() {
81+
return serialize_cbor_value(scope, value);
8382
}
8483
let context = scope.get_current_context();
8584
let serializer = v8::ValueSerializer::new(scope, Box::new(DefaultSerializerDelegate));
@@ -112,9 +111,8 @@ pub fn deserialize_v8_value<'s>(
112111
scope: &mut v8::HandleScope<'s>,
113112
data: &[u8],
114113
) -> Result<v8::Local<'s, v8::Value>, String> {
115-
// When JSON codec is active, incoming payloads are JSON, not V8 binary
116-
if is_json_codec() {
117-
return deserialize_json_value(scope, data);
114+
if is_cbor_codec() {
115+
return deserialize_cbor_value(scope, data);
118116
}
119117
let context = scope.get_current_context();
120118
let deserializer =
@@ -127,30 +125,141 @@ pub fn deserialize_v8_value<'s>(
127125
.ok_or_else(|| "V8 ValueDeserializer: failed to deserialize value".to_string())
128126
}
129127

130-
/// Serialize a V8 value to JSON bytes using V8's built-in JSON.stringify.
131-
/// Used when SECURE_EXEC_V8_CODEC=json for runtimes like Bun.
132-
pub fn serialize_json_value(
128+
// ── CBOR codec ──
129+
130+
/// Convert a V8 value to a ciborium::Value for CBOR serialization.
131+
fn v8_to_cbor(scope: &mut v8::HandleScope, value: v8::Local<v8::Value>) -> ciborium::Value {
132+
if value.is_null_or_undefined() {
133+
return ciborium::Value::Null;
134+
}
135+
if value.is_boolean() {
136+
return ciborium::Value::Bool(value.boolean_value(scope));
137+
}
138+
if value.is_int32() {
139+
return ciborium::Value::Integer(value.int32_value(scope).unwrap_or(0).into());
140+
}
141+
if value.is_number() {
142+
return ciborium::Value::Float(value.number_value(scope).unwrap_or(0.0));
143+
}
144+
if value.is_string() {
145+
let s = value.to_rust_string_lossy(scope);
146+
return ciborium::Value::Text(s);
147+
}
148+
if value.is_array_buffer_view() {
149+
let view = v8::Local::<v8::ArrayBufferView>::try_from(value).unwrap();
150+
let len = view.byte_length();
151+
let mut buf = vec![0u8; len];
152+
view.copy_contents(&mut buf);
153+
return ciborium::Value::Bytes(buf);
154+
}
155+
if value.is_array() {
156+
let arr = v8::Local::<v8::Array>::try_from(value).unwrap();
157+
let len = arr.length();
158+
let mut items = Vec::with_capacity(len as usize);
159+
for i in 0..len {
160+
if let Some(elem) = arr.get_index(scope, i) {
161+
items.push(v8_to_cbor(scope, elem));
162+
} else {
163+
items.push(ciborium::Value::Null);
164+
}
165+
}
166+
return ciborium::Value::Array(items);
167+
}
168+
if value.is_object() {
169+
let obj = value.to_object(scope).unwrap();
170+
let names = obj
171+
.get_own_property_names(scope, v8::GetPropertyNamesArgs::default())
172+
.unwrap_or_else(|| v8::Array::new(scope, 0));
173+
let len = names.length();
174+
let mut entries = Vec::with_capacity(len as usize);
175+
for i in 0..len {
176+
let key = names.get_index(scope, i).unwrap();
177+
let key_str = key.to_rust_string_lossy(scope);
178+
let val = obj.get(scope, key).unwrap_or_else(|| v8::undefined(scope).into());
179+
entries.push((ciborium::Value::Text(key_str), v8_to_cbor(scope, val)));
180+
}
181+
return ciborium::Value::Map(entries);
182+
}
183+
ciborium::Value::Null
184+
}
185+
186+
/// Convert a ciborium::Value to a V8 value.
187+
fn cbor_to_v8<'s>(
188+
scope: &mut v8::HandleScope<'s>,
189+
value: &ciborium::Value,
190+
) -> v8::Local<'s, v8::Value> {
191+
match value {
192+
ciborium::Value::Null => v8::null(scope).into(),
193+
ciborium::Value::Bool(b) => v8::Boolean::new(scope, *b).into(),
194+
ciborium::Value::Integer(n) => {
195+
let n: i128 = (*n).into();
196+
if n >= i32::MIN as i128 && n <= i32::MAX as i128 {
197+
v8::Integer::new(scope, n as i32).into()
198+
} else {
199+
v8::Number::new(scope, n as f64).into()
200+
}
201+
}
202+
ciborium::Value::Float(f) => v8::Number::new(scope, *f).into(),
203+
ciborium::Value::Text(s) => {
204+
v8::String::new(scope, s).unwrap().into()
205+
}
206+
ciborium::Value::Bytes(b) => {
207+
let len = b.len();
208+
let ab = v8::ArrayBuffer::new(scope, len);
209+
if len > 0 {
210+
let bs = ab.get_backing_store();
211+
unsafe {
212+
std::ptr::copy_nonoverlapping(
213+
b.as_ptr(),
214+
bs.data().unwrap().as_ptr() as *mut u8,
215+
len,
216+
);
217+
}
218+
}
219+
v8::Uint8Array::new(scope, ab, 0, len).unwrap().into()
220+
}
221+
ciborium::Value::Array(items) => {
222+
let arr = v8::Array::new(scope, items.len() as i32);
223+
for (i, item) in items.iter().enumerate() {
224+
let val = cbor_to_v8(scope, item);
225+
arr.set_index(scope, i as u32, val);
226+
}
227+
arr.into()
228+
}
229+
ciborium::Value::Map(entries) => {
230+
let obj = v8::Object::new(scope);
231+
for (k, v) in entries {
232+
let key = cbor_to_v8(scope, k);
233+
let val = cbor_to_v8(scope, v);
234+
obj.set(scope, key, val);
235+
}
236+
obj.into()
237+
}
238+
ciborium::Value::Tag(_, inner) => cbor_to_v8(scope, inner),
239+
_ => v8::undefined(scope).into(),
240+
}
241+
}
242+
243+
/// Serialize a V8 value to CBOR bytes.
244+
pub fn serialize_cbor_value(
133245
scope: &mut v8::HandleScope,
134246
value: v8::Local<v8::Value>,
135247
) -> Result<Vec<u8>, String> {
136-
let context = scope.get_current_context();
137-
let json_str = v8::json::stringify(scope, value)
138-
.ok_or_else(|| "JSON.stringify failed".to_string())?;
139-
let _ = context; // context used implicitly by stringify
140-
Ok(json_str.to_rust_string_lossy(scope).into_bytes())
248+
let cbor_val = v8_to_cbor(scope, value);
249+
let mut buf = Vec::new();
250+
ciborium::into_writer(&cbor_val, &mut buf)
251+
.map_err(|e| format!("CBOR encode failed: {}", e))?;
252+
Ok(buf)
141253
}
142254

143-
/// Deserialize JSON bytes to a V8 value using V8's built-in JSON.parse.
144-
pub fn deserialize_json_value<'s>(
255+
/// Deserialize CBOR bytes to a V8 value.
256+
pub fn deserialize_cbor_value<'s>(
145257
scope: &mut v8::HandleScope<'s>,
146258
data: &[u8],
147259
) -> Result<v8::Local<'s, v8::Value>, String> {
148-
let json_str = std::str::from_utf8(data)
149-
.map_err(|e| format!("JSON codec: invalid UTF-8: {}", e))?;
150-
let v8_str = v8::String::new(scope, json_str)
151-
.ok_or_else(|| "JSON codec: failed to create V8 string".to_string())?;
152-
v8::json::parse(scope, v8_str)
153-
.ok_or_else(|| "JSON codec: JSON.parse failed".to_string())
260+
let cbor_val: ciborium::Value = ciborium::from_reader(data)
261+
.map_err(|e| format!("CBOR decode failed: {}", e))?;
262+
Ok(cbor_to_v8(scope, &cbor_val))
154263
}
155264

156265
/// Pre-allocated serialization buffers reused across bridge calls within a session.

packages/nodejs/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@
109109
"dependencies": {
110110
"@secure-exec/core": "workspace:*",
111111
"@secure-exec/v8": "workspace:*",
112+
"cbor-x": "^1.6.4",
112113
"cjs-module-lexer": "^2.1.0",
113114
"es-module-lexer": "^1.7.0",
114115
"esbuild": "^0.27.1",

packages/nodejs/src/bridge-handlers.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,16 @@ import v8Mod from "node:v8";
1818

1919
// Bun's node:v8 module doesn't produce real V8 serialization format
2020
const _isBun = typeof (globalThis as Record<string, unknown>).Bun !== "undefined";
21+
let _cbor: typeof import("cbor-x") | null = null;
22+
function _getCbor(): typeof import("cbor-x") {
23+
if (!_cbor) {
24+
// eslint-disable-next-line @typescript-eslint/no-require-imports
25+
_cbor = require("cbor-x") as typeof import("cbor-x");
26+
}
27+
return _cbor;
28+
}
2129
function ipcSerialize(value: unknown): Buffer {
22-
if (_isBun) return Buffer.from(JSON.stringify(value), "utf-8");
30+
if (_isBun) return Buffer.from(_getCbor().encode(value));
2331
return Buffer.from(v8Mod.serialize(value));
2432
}
2533
import {

packages/nodejs/src/execution-driver.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1415,8 +1415,10 @@ export class NodeExecutionDriver implements RuntimeDriver {
14151415
if (options.mode === "run" && result.exports) {
14161416
try {
14171417
if (typeof (globalThis as Record<string, unknown>).Bun !== "undefined") {
1418-
// Bun: JSON codec — payload is JSON bytes
1419-
exports = JSON.parse(Buffer.from(result.exports).toString("utf-8")) as T;
1418+
// Bun: CBOR codec — payload is CBOR bytes
1419+
// eslint-disable-next-line @typescript-eslint/no-require-imports
1420+
const cbor = require("cbor-x") as typeof import("cbor-x");
1421+
exports = cbor.decode(Buffer.from(result.exports)) as T;
14201422
} else {
14211423
const { deserialize } = await import("node:v8");
14221424
exports = deserialize(result.exports) as T;

0 commit comments

Comments
 (0)