Skip to content

Commit 7823672

Browse files
committed
feat: add Bun runtime support via JSON IPC codec
Bun's node:v8 module produces a different serialization format than Node.js. Detect Bun and use JSON for IPC payloads between the host process and the Rust V8 sidecar. Also fix a temporal dead zone issue where Bun's eager event delivery fires the child exit handler before sessionHandlers is initialized.
1 parent 1c92c0e commit 7823672

5 files changed

Lines changed: 113 additions & 16 deletions

File tree

native/v8-runtime/src/bridge.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
use std::cell::RefCell;
44
use std::collections::HashMap;
55
use std::ffi::c_void;
6+
use std::sync::atomic::{AtomicBool, Ordering};
67
use std::sync::OnceLock;
78

89
use v8::MapFnTo;
@@ -11,6 +12,28 @@ use v8::ValueSerializerHelper;
1112

1213
use crate::host_call::BridgeCallContext;
1314

15+
// JSON codec flag: when true, use JSON.stringify/JSON.parse instead of V8
16+
// ValueSerializer/ValueDeserializer for IPC payloads. Activated by
17+
// SECURE_EXEC_V8_CODEC=json for runtimes whose node:v8 module doesn't
18+
// produce real V8 serialization format (e.g. Bun).
19+
static USE_JSON_CODEC: AtomicBool = AtomicBool::new(false);
20+
21+
/// Initialize the codec from the SECURE_EXEC_V8_CODEC environment variable.
22+
/// Call once at process startup before any sessions are created.
23+
pub fn init_codec() {
24+
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");
28+
}
29+
}
30+
}
31+
32+
/// Returns true if the JSON codec is active.
33+
pub fn is_json_codec() -> bool {
34+
USE_JSON_CODEC.load(Ordering::Relaxed)
35+
}
36+
1437
/// External references for V8 snapshot serialization.
1538
/// Maps function pointer indices in the snapshot to current addresses.
1639
/// Must be identical at snapshot creation and restore time.
@@ -50,10 +73,14 @@ impl v8::ValueDeserializerImpl for DefaultDeserializerDelegate {}
5073
/// Serialize a V8 value to bytes using V8's built-in ValueSerializer.
5174
/// Handles all V8 types natively: primitives, strings, arrays, objects,
5275
/// Uint8Array, Date, Map, Set, RegExp, Error, and circular references.
76+
/// When JSON codec is active, uses JSON.stringify instead.
5377
pub fn serialize_v8_value(
5478
scope: &mut v8::HandleScope,
5579
value: v8::Local<v8::Value>,
5680
) -> Result<Vec<u8>, String> {
81+
if is_json_codec() {
82+
return serialize_json_value(scope, value);
83+
}
5784
let context = scope.get_current_context();
5885
let serializer = v8::ValueSerializer::new(scope, Box::new(DefaultSerializerDelegate));
5986
serializer.write_header();
@@ -85,6 +112,10 @@ pub fn deserialize_v8_value<'s>(
85112
scope: &mut v8::HandleScope<'s>,
86113
data: &[u8],
87114
) -> 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);
118+
}
88119
let context = scope.get_current_context();
89120
let deserializer =
90121
v8::ValueDeserializer::new(scope, Box::new(DefaultDeserializerDelegate), data);
@@ -96,6 +127,32 @@ pub fn deserialize_v8_value<'s>(
96127
.ok_or_else(|| "V8 ValueDeserializer: failed to deserialize value".to_string())
97128
}
98129

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(
133+
scope: &mut v8::HandleScope,
134+
value: v8::Local<v8::Value>,
135+
) -> 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())
141+
}
142+
143+
/// Deserialize JSON bytes to a V8 value using V8's built-in JSON.parse.
144+
pub fn deserialize_json_value<'s>(
145+
scope: &mut v8::HandleScope<'s>,
146+
data: &[u8],
147+
) -> 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())
154+
}
155+
99156
/// Pre-allocated serialization buffers reused across bridge calls within a session.
100157
/// Grows to high-water mark; cleared (not deallocated) between calls via buf.clear().
101158
pub struct SessionBuffers {

native/v8-runtime/src/main.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,9 @@ fn main() {
294294
// Close all inherited FDs > 2 before doing anything else
295295
close_inherited_fds();
296296

297+
// Initialize codec from env before any sessions are created
298+
bridge::init_codec();
299+
297300
// Initialize V8 platform on the main thread before any session threads
298301
isolate::init_v8_platform();
299302

packages/nodejs/src/bridge-handlers.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,14 @@ import { Duplex, PassThrough } from "node:stream";
1414
import { readFileSync, realpathSync, existsSync } from "node:fs";
1515
import { dirname as pathDirname, join as pathJoin, resolve as pathResolve } from "node:path";
1616
import { createRequire } from "node:module";
17-
import { serialize } from "node:v8";
17+
import v8Mod from "node:v8";
18+
19+
// Bun's node:v8 module doesn't produce real V8 serialization format
20+
const _isBun = typeof (globalThis as Record<string, unknown>).Bun !== "undefined";
21+
function ipcSerialize(value: unknown): Buffer {
22+
if (_isBun) return Buffer.from(JSON.stringify(value), "utf-8");
23+
return Buffer.from(v8Mod.serialize(value));
24+
}
1825
import {
1926
randomFillSync,
2027
randomUUID,
@@ -4884,7 +4891,7 @@ export function buildNetworkBridgeHandlers(deps: NetworkBridgeDeps): NetworkBrid
48844891
registerPendingHttpResponse(options.serverId, requestId, resolve);
48854892
});
48864893
state.pendingRequests += 1;
4887-
deps.sendStreamEvent("http_request", serialize({
4894+
deps.sendStreamEvent("http_request", ipcSerialize({
48884895
requestId,
48894896
serverId: options.serverId,
48904897
request: requestJson,

packages/nodejs/src/execution-driver.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,8 +1414,13 @@ export class NodeExecutionDriver implements RuntimeDriver {
14141414
let exports: T | undefined;
14151415
if (options.mode === "run" && result.exports) {
14161416
try {
1417-
const { deserialize } = await import("node:v8");
1418-
exports = deserialize(result.exports) as T;
1417+
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;
1420+
} else {
1421+
const { deserialize } = await import("node:v8");
1422+
exports = deserialize(result.exports) as T;
1423+
}
14191424
} catch {
14201425
exports = undefined;
14211426
}

packages/v8/src/runtime.ts

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,26 @@ import { IpcClient } from "./ipc-client.js";
1313
import type { BinaryFrame } from "./ipc-binary.js";
1414
import type { V8Session, V8SessionOptions } from "./session.js";
1515

16+
// Bun's node:v8 module doesn't produce real V8 serialization format.
17+
// Detect Bun and use JSON codec instead.
18+
const isBun = typeof (globalThis as Record<string, unknown>).Bun !== "undefined";
19+
20+
/** Serialize a value for IPC — JSON when running under Bun, V8 otherwise. */
21+
function ipcSerialize(value: unknown): Buffer {
22+
if (isBun) {
23+
return Buffer.from(JSON.stringify(value), "utf-8");
24+
}
25+
return Buffer.from(v8.serialize(value));
26+
}
27+
28+
/** Deserialize an IPC payload — JSON when running under Bun, V8 otherwise. */
29+
function ipcDeserialize(buf: Buffer | Uint8Array): unknown {
30+
if (isBun) {
31+
return JSON.parse(Buffer.from(buf).toString("utf-8"));
32+
}
33+
return v8.deserialize(buf);
34+
}
35+
1636
const __filename = fileURLToPath(import.meta.url);
1737
const __dirname = dirname(__filename);
1838

@@ -104,6 +124,9 @@ export async function createV8Runtime(
104124
...process.env as Record<string, string>,
105125
SECURE_EXEC_V8_TOKEN: authToken,
106126
};
127+
if (isBun) {
128+
childEnv.SECURE_EXEC_V8_CODEC = "json";
129+
}
107130
if (options?.maxSessions != null) {
108131
childEnv.SECURE_EXEC_V8_MAX_SESSIONS = String(options.maxSessions);
109132
}
@@ -116,6 +139,16 @@ export async function createV8Runtime(
116139
// Forward V8 runtime stderr to host stderr for debugging
117140
child.stderr?.pipe(process.stderr);
118141

142+
// Message routing: session-level handlers registered per session_id.
143+
// Declared before the exit handler so Bun's eager event delivery
144+
// doesn't hit the temporal dead zone.
145+
const sessionHandlers = new Map<
146+
string,
147+
(frame: BinaryFrame) => void
148+
>();
149+
// Per-session reject functions for rejecting in-flight execute() promises
150+
const sessionRejects = new Map<string, (err: Error) => void>();
151+
119152
// Track whether the process is alive
120153
let processAlive = true;
121154
let exitError: Error | null = null;
@@ -187,14 +220,6 @@ export async function createV8Runtime(
187220
let ipcClient: IpcClient | null = null;
188221
let disposed = false;
189222

190-
// Message routing: session-level handlers registered per session_id
191-
const sessionHandlers = new Map<
192-
string,
193-
(frame: BinaryFrame) => void
194-
>();
195-
// Per-session reject functions for rejecting in-flight execute() promises
196-
const sessionRejects = new Map<string, (err: Error) => void>();
197-
198223
ipcClient = new IpcClient({
199224
socketPath,
200225
onMessage: (frame) => {
@@ -336,8 +361,8 @@ export async function createV8Runtime(
336361
throw new Error("IPC client is not connected");
337362
}
338363

339-
// Inject globals — V8-serialize { processConfig, osConfig }
340-
const globalsPayload = v8.serialize({
364+
// Inject globals — serialize { processConfig, osConfig }
365+
const globalsPayload = ipcSerialize({
341366
processConfig: execOptions.processConfig,
342367
osConfig: execOptions.osConfig,
343368
});
@@ -373,7 +398,7 @@ export async function createV8Runtime(
373398
// Deserialize args and call handler
374399
void (async () => {
375400
try {
376-
const args = v8.deserialize(
401+
const args = ipcDeserialize(
377402
frame.payload,
378403
) as unknown[];
379404
const result = await handler(
@@ -400,7 +425,7 @@ export async function createV8Runtime(
400425
status: 0,
401426
payload:
402427
result !== undefined
403-
? Buffer.from(v8.serialize(result))
428+
? ipcSerialize(result)
404429
: Buffer.alloc(0),
405430
});
406431
}

0 commit comments

Comments
 (0)