Connecting Modern React Apps to Legacy COM/ActiveX Systems via Rust FFI
Learn how to connect modern React apps to legacy COM/ActiveX systems using Rust FFI, C++ adapters, and Tauri. Real-world enterprise integration solution.
The Context: When Old Meets New
We've all been there - you're building a sleek, modern web application with the latest tech stack (TS, React, Next.js), and then the client drops the bomb: "Oh, by the way, we need this to integrate with our ERP system from 2008."
In my case, the client was using a dated ERP system that only exposed its functionality through a COM/ActiveX API via an "EDP interface" - a proprietary (32bit) driver that had to be installed locally on each user's Windows PC. Meanwhile, I was developing a modern TypeScript application using React and Next.js.
The challenge? These two worlds don't naturally talk to each other.
My first instinct might have been Electron, but I chose Tauri instead. Not only does Tauri offer better performance with its smaller bundle sizes and lower memory footprint, but I also had a personal interest in deepening my Rust skills. This decision would prove instrumental in solving the integration puzzle.
The Plan of Attack: Building a Bridge Across Technologies
The solution required building a multi-layered adapter pattern, essentially creating a chain of translators between the modern web app and the legacy COM interface:
TypeScript / React
↑
└────┐
↓
Tauri (Rust)
↑
└────┐
↓
C++ DLL
↑
└────┐
↓
COM / ActiveX ERP
Here's how the data flow works:
Create a Connection Instance
First, we establish a connection to the ERP system. This connection needs to be managed carefully - more on that later.Build the Query
The ERP system expects queries in its own format, similar to SQL but with its quirks. We construct these search strings in Rust before passing them down.The Unsafe Bridge
This is where things get interesting. We make an unsafe FFI call to our C++ adapter function through the C interface. Here's the key insight: we pass two callback functions:
//Rust
let mut logs: Vec<LogEntry> = vec![];
let mut records: Vec<Record> = vec![];
unsafe {
// Simplified example
fetch_from_erp(
connection.as_ptr(),
query_cstring.as_ptr(),
&mut logs as *mut Vec<LogEntry> as *mut c_void,
log_callback,
&mut records as *mut Vec<Record> as *mut c_void,
add_record,
);
}
The callbacks and their data vectors are crucial - they allow the C++ layer to push data back up to Rust asynchronously as the COM interface returns results.
Collecting Results
After the unsafe block executes, we have twoVeccollections:logs: Contains all logging information, including any errorsrecords: Contains the actual query results
Return to Typescript/Next.js
Finally, we serialize the data as JSON (Tauri v1.x) and return it to the TypeScript frontend through Tauri's IPC mechanism, or return anErrwith a meaningful error message if something went wrong.
The Difficulties: Where Things Got Tricky
Connection Handling: Managing COM Lifecycle
The ERP connection requires careful lifecycle management.
On the C++ side, I wrapped the COM EDP interface in a RAII class that handles ConnectionInitialize/ConnectionUninitialize:
//C++
class Connection {
EDP* edp;
public:
Connection() { ConnectionInitialize(); }
~Connection() { ConnectionUninitialize(); }
EDPQueryPtr createQuery() { return edp->CreateQuery(); }
};
// C-Interface for FFI
extern "C" {
Connection* create_connection(...);
void close_connection(Connection* conn);
}
On the Rust side, this becomes a simple wrapper with automatic cleanup via Drop.
The key insight: the Connection creates Query objects that maintain references to their parent,
ensuring proper COM lifecycle management through Rust's ownership system.
Data Flow Through the Layers: The Real Challenge
Getting data from COM through C++ into Rust and finally to TypeScript was the most interesting puzzle. The solution revolves around callbacks that accumulate data into Rust-owned vectors.
The Callback Pattern
The key insight is using Rust functions as C callbacks to populate vectors:
//Rust
extern "C" fn log_message(str_ptr: *const c_char, level: c_int, ctx: *mut c_void) {
unsafe {
let logs = &mut *(ctx as *mut Vec<LogEntry>);
let message = CStr::from_ptr(str_ptr).to_string_lossy();
logs.push(LogEntry { level, message: message.into() });
}
}
extern "C" fn add_record(id: c_int, name: *const c_char, value: c_float, ctx: *mut c_void) {
unsafe {
let records = &mut *(ctx as *mut Vec<Record>);
records.push(Record {
id,
name: CStr::from_ptr(name).to_string_lossy().into(),
value,
});
}
}
The ctx pointer is crucial - it's cast back to a mutable reference to our Rust vector,
allowing the C++ layer to push data without knowing about Rust's memory model.
The Orchestration
From the Tauri command handler, we set up the vectors and make the unsafe call:
//Rust
#[tauri::command]
pub async fn fetch_data(password: &str) -> Result<String, String> {
let mut logs: Vec<LogEntry> = vec![];
let mut records: Vec<Record> = vec![];
let mut connection = Connection::new().map_err(|e| e.to_string())?;
connection.open(password, &mut logs);
let search = CString::new("search_criteria").unwrap();
unsafe {
fetch_from_erp(
connection.as_ptr(),
search.as_ptr(),
&mut logs as *mut Vec<LogEntry> as *mut c_void,
log_message,
&mut records as *mut Vec<Record> as *mut c_void,
add_record,
);
}
// Check Logs before returning data
if let Some(error) = logs.iter().find(|l| l.level > 0) {
return Err(error.message.clone());
}
serde_json::to_string(&records).map_err(|e| e.to_string())
}
The C++ Side
The C++ layer iterates through COM query results and invokes our callbacks for each record:
//C++
void fetch_from_erp(Connection* conn, const char* search,
void* log_ctx, LogCallback log_cb,
void* data_ctx, DataCallback data_cb) {
QueryPtr query = conn->createQuery();
if (!query->StartQuery("TABLE", "id,name,value", search)) {
_bstr_t error = query->GetLastError();
log_cb(error, 1, log_ctx); // Level 1 === Error
return;
}
while (query->GetNextRecord()) {
int id = bstr2int(query->GetFieldN("id"));
_bstr_t name = query->GetFieldN("name");
float value = bstr2float(query->GetFieldN("value"));
data_cb(id, name, value, data_ctx);
}
}
The beauty of this pattern is its simplicity: Rust owns the memory, C++ just fills it via callbacks. No complex memory management, no manual cleanup - just data flowing through function pointers into pre-allocated vectors. The final JSON serialization happens entirely in Rust, giving us type safety right up to the TypeScript boundary.
Error Handling: Making Failures Helpful
COM errors are notoriously cryptic - a failing call might return 0x80004005 (E_FAIL) with no context.
I addressed this through a multi-layer approach:
The C++ layer catches COM errors and immediately logs them through the callback with human-readable context:
//C++
if (!query->StartQuery(...)) {
// Get actual ERP error message
_bstr_t error = query->GetLastError();
log_cb(L"Query fehlgeschlagen: " + error, 1, log_ctx);
}
The Rust layer checks the logs vector after each unsafe call and converts any errors into proper Result types:
//Rust
if let Some(error) = logs.iter().find(|l| l.level > 0) {
return Err(error.message.clone());
}
This pattern ensures that errors bubble up with meaningful messages instead of cryptic COM codes. By the time an error reaches the TypeScript frontend, it contains actionable information like "Query failed: Invalid field name 'foo'" rather than "0x80004005".
Lessons Learned
Building this bridge taught me several valuable lessons:
Legacy systems speak a different language - literally and conceptually. COM expects synchronous, stateful interactions with manual memory management. Modern web apps expect async, stateless operations with automatic cleanup. The adapter layer doesn't just translate data formats; it translates entire programming paradigms.
Callbacks are your friend for FFI. Rather than trying to marshal complex data structures across language boundaries, the callback pattern lets you keep memory ownership simple. Rust owns the vectors, C++ just fills them. No shared memory, no cleanup coordination - just function pointers and context pointers.
Tauri + Rust was the perfect choice. Rust's unsafe blocks make the dangerous parts explicit and contained,
while its ownership system ensures proper cleanup everywhere else.
The connection dropping when it goes out of scope automatically triggers the entire COM cleanup chain.
Try doing that reliably in JavaScript!
Good error messages are worth the effort. Every layer that translates errors - from COM HRESULT to C++ string to Rust Result to user-facing message - is an opportunity to add context. Your future debugging self will thank you.
Sometimes the most interesting engineering challenges aren't about using cutting-edge technology -
they're about making a 15-year-old ERP system feel native in a modern React app.
There's something deeply satisfying about writing unsafe blocks that make legacy systems safe to use.