Three languages, one debugger
Adding Rust to a C++ Node addon is a build-system problem for one afternoon. The lasting cost is operational: a process where the JS stack ends in JIT gibberish, the C++ debugger shows Rust strings as raw structs, and a v8::Local is an opaque pointer. If nobody on the team can set a breakpoint on both sides of the boundary, every bug near it costs a day. This post builds the whole stack, then makes one VS Code window debug and test all of it.
The running example wraps oxc behind a C++ NaN addon: node demo.js calls C++, C++ calls Rust, Rust parses TypeScript and prints it back. Everything below is lifted from the scripts/corrosion-oxc-nan workspace, which builds, tests, and records its own debug sessions.
One build: Corrosion makes cargo a CMake target
The Rust side of the build is three lines:
include(FetchContent)
FetchContent_Declare(
Corrosion
GIT_REPOSITORY https://github.com/corrosion-rs/corrosion.git
GIT_TAG v0.6.1
)
FetchContent_MakeAvailable(Corrosion)
# Reads rust/Cargo.toml and creates one CMake target per crate.
# Our crate is named `oxc_bridge`, so this defines a target `oxc_bridge`
# that (a) runs `cargo build` at build time, (b) exposes the produced
# staticlib plus the native libraries Rust's runtime needs (pthread, dl, ...),
# which Corrosion asks cargo for instead of hardcoding.
corrosion_import_crate(MANIFEST_PATH rust/Cargo.toml) corrosion_import_crate reads the manifest and creates a CMake target named after the crate. Behind that target:
- cargo runs on every build and its own incremental tracking decides what to recompile. A no-change build is a sub-second no-op, with no stamp file to go stale the way an
ExternalProjectrecipe does. CMAKE_BUILD_TYPEmaps to cargo profiles: Release becomescargo build --release, Debug becomes the dev profile with full debug info. That mapping is what makes the rest of this post work.- cargo output lands inside the CMake build tree, so
rm -rf buildcleans both worlds. - The native libraries a Rust staticlib needs (
pthreadanddlon Linux,ws2_32and friends on Windows) come fromrustc --print native-static-libs, not from a hand-maintained list.
Linking the crate then reads like any other CMake dependency:
add_library(addon SHARED src/addon.cpp)
set_target_properties(addon PROPERTIES
PREFIX "" # no "lib" prefix
SUFFIX ".node" # Node's require() only loads this extension
CXX_STANDARD 20
CXX_STANDARD_REQUIRED ON
)
target_include_directories(addon PRIVATE
${NODE_INC} # node.h, v8.h
${CMAKE_SOURCE_DIR}/node_modules/nan # nan.h
)
# The payoff line: the Rust crate is linked like any other CMake target.
target_link_libraries(addon PRIVATE oxc_bridge)
# The addon references Node/V8 symbols that only exist inside the node binary;
# they resolve when node dlopen()s the addon. Linux linkers allow undefined
# symbols in shared libs by default, macOS needs to be told. (Windows needs an
# import library instead; that is the one thing cmake-js/node-gyp would buy us.)
if(APPLE)
target_link_options(addon PRIVATE -undefined dynamic_lookup)
endif() No node-gyp and no cmake-js either. An nvm or tarball Node ships its own headers, so CMake just asks node where they live:
execute_process(
COMMAND node -p "require('path').join(require('path').dirname(process.execPath), '..', 'include', 'node')"
OUTPUT_VARIABLE NODE_INC OUTPUT_STRIP_TRAILING_WHITESPACE
COMMAND_ERROR_IS_FATAL ANY
)
if(NOT EXISTS ${NODE_INC}/node.h)
message(FATAL_ERROR "Node headers not found at ${NODE_INC} (nvm/tarball installs ship them; a distro node may need a -devel package)")
endif() On Linux the addon’s Node symbols simply stay undefined until node dlopen()s it, and macOS needs one linker flag for the same behavior. Windows is the platform where node-gyp still earns its keep, because the addon must link against node.lib.
The boundary: two functions, two rules
The crate exports exactly two extern "C" functions, UTF-8 strings in, one JSON string out. Dumb on purpose: there are no #[repr(C)] struct layouts to keep in sync, and a boundary this small leaves only two rules to enforce. Rust allocated the result, so Rust frees it, because free() from C++ would pair two different allocators. And a panic must never unwind into a C frame, so the export wraps everything in catch_unwind. Both rules show up live in the debugger later: the freed json pointer turns to garbage in the Variables panel, and catch_unwind sits as a visible frame in the mixed stack.
/// Parse `source` (named `filename` for language detection and diagnostics)
/// and return a heap-allocated JSON report as a NUL-terminated C string.
///
/// # Safety
/// `source` and `filename` must be valid NUL-terminated UTF-8 strings.
/// The returned pointer must be freed with `oxc_free_string`.
#[no_mangle]
pub unsafe extern "C" fn oxc_analyze(
source: *const c_char,
filename: *const c_char,
) -> *mut c_char {
// Panics must never unwind across the C ABI (undefined behavior),
// so catch them and turn them into an error report.
let result = catch_unwind(|| {
let source = CStr::from_ptr(source).to_str().unwrap_or_default();
let filename = CStr::from_ptr(filename).to_str().unwrap_or_default();
analyze(source, filename).to_string()
});
let json = result.unwrap_or_else(|_| r#"{"panic":"oxc_bridge panicked"}"#.to_string());
// Hand ownership to the caller; from_raw in oxc_free_string takes it back.
CString::new(json)
.unwrap_or_else(|_| CString::new("{}").unwrap())
.into_raw()
}
/// Release a string returned by `oxc_analyze`.
///
/// # Safety
/// `ptr` must be a pointer previously returned by `oxc_analyze`, passed at most once.
#[no_mangle]
pub unsafe extern "C" fn oxc_free_string(ptr: *mut c_char) {
if !ptr.is_null() {
drop(CString::from_raw(ptr));
}
} // analyzeJson(source: string, filename?: string) -> string (JSON report)
NAN_METHOD(AnalyzeJson) {
if (info.Length() < 1 || !info[0]->IsString()) {
return Nan::ThrowTypeError("analyzeJson(source, filename?) expects a string source");
}
Nan::Utf8String source(info[0]);
std::string filename = "input.js";
if (info.Length() > 1 && info[1]->IsString()) {
Nan::Utf8String name(info[1]);
filename.assign(*name, static_cast<size_t>(name.length()));
}
// Rust allocates the result; we must give it back to Rust to free.
// Never free() a Rust allocation: the allocators may not match.
char* json = oxc_analyze(*source, filename.c_str());
v8::Local<v8::String> result = Nan::New(json).ToLocalChecked();
oxc_free_string(json);
info.GetReturnValue().Set(result);
} Breakpoints in all three layers
The C++ and Rust halves need no ceremony. cargo emits ordinary DWARF, so under CodeLLDB a breakpoint in lib.rs binds like any other. Two configuration details carry the rest of the weight.
First, "sourceLanguages": ["cpp", "rust"] in the launch config switches on CodeLLDB’s Rust formatters. Without it a String hover shows RawVec internals instead of the text, which is technically the truth and practically useless.
Second, the JS layer. A native debugger cannot symbolize V8’s JIT frames, so demo.js needs the inspector attached to the same process. The workable direction is not the obvious one. Attaching lldb to a JS-launched process means picking a pid out of a process list on every run. Launching node under lldb with --inspect-brk and letting the JS debugger attach to the port needs no picking at all, because ports are chosen up front and pids are not:
{
// Compound half 1: same as above, but node waits on the inspector
// port until the JS debugger attaches. Not meant to run alone
// (node would wait forever), hence hidden from the dropdown.
"name": "helper: lldb on node --inspect-brk demo.js",
"type": "lldb",
"request": "launch",
"program": "${env:HOME}/.nvm/versions/node/v24.17.0/bin/node",
"args": ["--inspect-brk=9229", "demo.js"],
"cwd": "${workspaceFolder}",
"env": { "OXC_NAN_DEBUG": "1" },
"sourceLanguages": ["cpp", "rust"],
"initCommands": [
"command regex v8obj 's/(.+)/expr -- (void)_v8_internal_Print_Object((void*)*(unsigned long*)((%1).location_))/'",
"command alias v8bt expr -- (void)_v8_internal_Print_StackTrace()",
"command script import ${workspaceFolder}/debug/v8_formatters.py"
],
"preLaunchTask": "build:debug",
"presentation": { "hidden": true }
},
{
// Compound half 2: attach to the port instead of picking a pid.
// The long timeout covers the first cargo debug build of oxc.
"name": "helper: JS attach to port 9229",
"type": "node",
"request": "attach",
"port": 9229,
"timeout": 120000,
"continueOnAttach": true,
"presentation": { "hidden": true }
} Here is what that buys you, in one take: breakpoints set in demo.js, addon.cpp, and lib.rs, one F5 on the compound config, and the session steps through all three, with variables readable at every layer.
The compound session: JS, C++, and Rust breakpoints hit in one window, the
hover on a v8::Local showing the decoded string.
The whole session is also scripted (npm run debug for terminal gdb, npm run debug:dap against the real CodeLLDB adapter over the Debug Adapter Protocol), so the debug setup itself is testable like any other code.
Seeing into V8
One opacity survives all of that: a v8::Local renders as a single location_ pointer. That one is not a formatter gap. The handle points into V8’s garbage-collected heap, and the object behind it has no DWARF description at all.
Node ships two escape hatches. The first: every node binary compiles in V8’s own debug printers, and the launch config aliases them to v8obj (print the JS value behind a handle) and v8bt (print the JS stack with real file and line entries, the readable version of those anonymous JIT frames). Type them into the Debug Console at a native breakpoint. Their output appears in the terminal, because they print from inside the debuggee.
The second fixes the hover itself. debug/v8_formatters.py is an lldb summary provider that decodes V8 strings, numbers, and undefined/null/true/false straight from process memory. Loaded via one initCommands line, it turns this:
result = { location_ = 0x0000000006a1f7a8 }
into this:
result = "{"code":"interface Point {\n\tx: number;\n\ty: number;…" (len 328)
There is no magic inside. Node publishes its own object layout as v8dbg_* constants, int32 globals baked into the binary for exactly this kind of tooling. The decoder looks offsets up by name and walks the object the way the heap sees it:
base = tagged & ~const('HeapObjectTagMask')
map_addr = read(base + const('class_HeapObject__map__Map'), 8) & ~const('HeapObjectTagMask')
itype = read(map_addr + const('class_Map__instance_type__uint16_t'), 2)
length = read(base + const('class_String__length__int32_t'), 4)
representation = itype & const('StringRepresentationMask')
one_byte = (itype & const('StringEncodingMask')) == const('OneByteStringTag')
if representation == const('SeqStringTag'):
take = min(length, budget)
if one_byte:
chars = read_bytes(base + const('class_SeqOneByteString__chars__char'), take)
return chars.decode('latin-1'), length
chars = read_bytes(base + const('class_SeqTwoByteString__chars__char'), take * 2)
return chars.decode('utf-16-le', 'replace'), length That is llnode’s trick in miniature. And because the offsets come out of the binary being debugged, a node upgrade brings its own constants and the hover keeps working.
Tests in the same window
The one-window rule extends to tests. Test Explorer shows all three layers, each through its native runner:
- C++ — the GoogleTest binary links
liboxc_bridge.adirectly and exercises the C ABI with no V8 in the room, so linking, ABI, and ownership regressions fail in a plain debuggable process instead of somewhere inside node. CMake Tools picks it up through CTest. - Rust — ordinary
#[cfg(test)]units run by rust-analyzer, plus oneadd_testthat bridgescargo testinto CTest so both native layers run under a singlectest. - JS —
node:testthrough the built addon, no test dependencies at all.
// Tests the exact FFI surface the addon uses, without any V8 in the room:
// link liboxc_bridge.a into a plain GoogleTest binary and hit the C ABI.
// Catches linking, ABI, and ownership regressions where they are debuggable.
#include <gtest/gtest.h>
#include <string>
extern "C" {
char* oxc_analyze(const char* source, const char* filename);
void oxc_free_string(char* ptr);
}
namespace {
std::string analyze(const char* source, const char* filename) {
char* json = oxc_analyze(source, filename);
std::string result(json);
oxc_free_string(json);
return result;
}
TEST(OxcBridge, ParsesTypeScriptByFilename) {
const std::string report = analyze("const x: number = 1;", "x.ts");
EXPECT_NE(report.find("\"typescript\":true"), std::string::npos);
EXPECT_NE(report.find("\"errorCount\":0"), std::string::npos);
}
TEST(OxcBridge, ReportsSyntaxErrors) {
const std::string report = analyze("const x = ;", "x.js");
EXPECT_NE(report.find("\"errorCount\":1"), std::string::npos);
EXPECT_NE(report.find("\"offset\":10"), std::string::npos);
}
TEST(OxcBridge, SurvivesEmptyAndNonUtf8Input) {
EXPECT_NE(analyze("", "x.js").find("\"errorCount\":0"), std::string::npos);
EXPECT_NE(analyze("\xff\xfe", "x.js").find("errorCount"), std::string::npos);
}
} // namespace #[cfg(test)]
mod tests {
use super::*;
#[test]
fn typescript_parses_clean() {
let report = analyze("const x: number = 1;", "x.ts");
assert_eq!(report["errorCount"], 0);
assert_eq!(report["statementCount"], 1);
assert_eq!(report["sourceType"]["typescript"], true);
}
#[test]
fn broken_source_reports_spans() {
let report = analyze("const x = ;", "x.js");
assert_eq!(report["errorCount"], 1);
assert_eq!(report["errors"][0]["offset"], 10);
}
#[test]
fn c_abi_round_trip_owns_its_memory() {
// The same contract the C++ addon relies on, exercised from Rust.
let source = CString::new("let a = 1").unwrap();
let filename = CString::new("a.js").unwrap();
let ptr = unsafe { oxc_analyze(source.as_ptr(), filename.as_ptr()) };
let json = unsafe { CStr::from_ptr(ptr) }.to_str().unwrap().to_owned();
unsafe { oxc_free_string(ptr) };
assert!(json.contains("\"errorCount\":0"));
}
} // The JS layer tested through the real addon (build/addon.node must exist:
// npm run build first). node:test, so `npm test` needs no dependencies and
// the nodejs-testing extension shows these next to the C++ and Rust tests.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { analyze } = require('../index');
test('detects TypeScript from the filename', () => {
const report = analyze('const x: number = 1;', 'x.ts');
assert.equal(report.sourceType.typescript, true);
assert.equal(report.errorCount, 0);
});
test('reports syntax errors with offsets', () => {
const report = analyze('const x = ;', 'x.js');
assert.equal(report.errorCount, 1);
assert.equal(report.errors[0].offset, 10);
});
test('round-trips code through oxc_codegen', () => {
const report = analyze('let a=1\nconsole.log( a )', 'tidy.js');
assert.equal(report.code, 'let a = 1;\nconsole.log(a);\n');
}); # The C++ test links the Rust staticlib directly: the FFI boundary gets
# exercised in a plain binary, no V8 required. GoogleTest via FetchContent.
FetchContent_Declare(
googletest
URL https://github.com/google/googletest/archive/refs/tags/v1.15.2.tar.gz
)
FetchContent_MakeAvailable(googletest)
enable_testing()
add_executable(bridge_test src/bridge_test.cpp)
target_link_libraries(bridge_test PRIVATE oxc_bridge GTest::gtest_main)
include(GoogleTest)
gtest_discover_tests(bridge_test)
# Bridge `cargo test` into CTest so the Rust unit tests show up in the same
# Test Explorer tree as the C++ ones (CMake Tools runs ctest).
find_program(CARGO cargo REQUIRED)
add_test(
NAME rust.oxc_bridge
COMMAND ${CMAKE_COMMAND} -E env CARGO_TARGET_DIR=${CMAKE_BINARY_DIR}/cargo-test
${CARGO} test --manifest-path ${CMAKE_SOURCE_DIR}/rust/Cargo.toml
) What to take
The workspace at scripts/corrosion-oxc-nan is the whole setup: CMakeLists.txt for the build, .vscode/launch.json for the debugger, debug/v8_formatters.py for the hovers, and the scripted sessions that keep the setup from silently rotting. None of it is specific to oxc. Swap the crate and the two extern functions, and the rest carries over to any Rust-inside-C++ addon your team has to live with.