· 19 min read · ★ Recommended · by Hemant Kumar
Safe C++ and Rust Interop: A Practical Guide to cxx::bridge

Safe C++ and Rust Interop: A Practical Guide to cxx::bridge

rust cpp cxx interop ffi build-systems cmake coroutine

cxx is the most boring way to call Rust from C++ and back. It is also the fastest to set up and the hardest to break once it is running. This post walks through three real projects, end to end, with the full file contents of each. Read it once and you will have a complete working setup for any combination of C++ host, Rust library, async work, callbacks, and build system.

The three projects are different sizes and different problems, but all three use the same cxx::bridge library:

  1. A greeting library — Rust owns a Greeting struct, C++ provides a logger function, both directions cross the bridge with zero-copy strings.
  2. A WASM plugin engine — Rust owns a PluginManager with a Tokio runtime, C++ provides a callback subclass, plugin calls go back and forth as JSON.
  3. A second WASM plugin engine — same problem, but using Corrosion for the build system, with split plugin facet types and a worker pool.

The first project is the smallest. The second is where the patterns get serious. The third is the cleanup pass.

Project 1: The Greeting Library

The smallest working cxx setup. Rust has a struct. C++ has a function. Both languages see the same enum. The bridge is one declaration, the build is CMake plus a small build.rs, and the total source is under 100 lines.

Directory layout

cpprusttest2/
├── cpp/
│   ├── CMakeLists.txt
│   ├── include/
│   │   └── logger.h
│   └── src/
│       ├── logger.cpp
│       └── main.cpp
└── rust/
    ├── Cargo.toml
    ├── build.rs
    └── src/
        └── lib.rs

rust/Cargo.toml

[package]
name = "rust_greeter"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["staticlib"]      # produce a .lib for C++ to link

[dependencies]
cxx = "1.0"

[build-dependencies]
cxx-build = "1.0"

crate-type = ["staticlib"] is the key line. Cargo normally builds an rlib. For C++ to link against the code, you need a static library the C++ linker can consume.

rust/build.rs

fn main() {
    // Compile the C++ side of the bridge, and tell it where to find logger.h
    cxx_build::bridge("src/lib.rs")
        .include("../cpp/include")      // ← THIS IS THE MISSING LINE
        .compile("rust_greeter");

    // Copy generated files and the CXX runtime header for the CMake build
    let out_dir = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
    let dest_dir = std::path::Path::new("../cpp/generated");
    std::fs::create_dir_all(dest_dir.join("rust")).unwrap();

    let copy_file = |from_relative: &str, to_relative: &str| {
        let src = out_dir.join("cxxbridge").join(from_relative);
        let dst = dest_dir.join(to_relative);
        std::fs::copy(&src, &dst).unwrap();
    };

    copy_file("include/rust_greeter/src/lib.rs.h", "lib.rs.h");
    copy_file("sources/rust_greeter/src/lib.rs.cc", "lib.rs.cc");
    copy_file("include/rust/cxx.h", "rust/cxx.h");

    println!("cargo:rerun-if-changed=src/lib.rs");
}

This file does two things. First, it calls cxx_build::bridge("src/lib.rs") to compile the C++ side of the bridge using your local C++ compiler. The .include("../cpp/include") line tells cxx-build where to find logger.h — without it, the C++ compilation fails with an opaque template error that does not point to the missing flag.

Second, it copies the generated files into a stable location so CMake can find them. cxx-build drops its output in target/.../build/.../out/, which is not a path CMake can predict. The three files that get copied are:

  • lib.rs.h — the C++ header declaring everything in the bridge.
  • lib.rs.cc — the C++ source file that implements the Rust-side functions in C++.
  • rust/cxx.h — the cxx runtime header that both sides include.

rust/src/lib.rs

#[cxx::bridge]
mod ffi {
    pub enum Language {
        English,
        German,
        French,
    }

    unsafe extern "C++" {
        include!("logger.h");

        // Zero‑copy: Rust passes a &str, C++ receives a rust::Str
        fn log_message(msg: &str);
    }

    extern "Rust" {
        type Greeting;

        fn hello() -> &'static Greeting;
        fn bye() -> &'static Greeting;
        fn translate(self: &Greeting, language: Language) -> String;
    }
}

pub struct Greeting {
    msg: &'static str,
}

fn hello() -> &'static Greeting {
    &Greeting { msg: "Hello" }
}

fn bye() -> &'static Greeting {
    &Greeting { msg: "Bye" }
}

impl Greeting {
    fn translate(&self, language: ffi::Language) -> String {
        let translated = match (self.msg, language) {
            ("Hello", ffi::Language::English) => "Hello, World!".to_owned(),
            ("Hello", ffi::Language::German)  => "Hallo, Welt!".to_owned(),
            ("Hello", ffi::Language::French)  => "Bonjour, le monde!".to_owned(),
            ("Bye",   ffi::Language::English) => "Bye!".to_owned(),
            ("Bye",   ffi::Language::German)  => "Auf Wiedersehen!".to_owned(),
            ("Bye",   ffi::Language::French)  => "Au revoir!".to_owned(),
            _ => String::new(),
        };

        // Borrow the String – no clone, no copy
        ffi::log_message(&translated);

        translated
    }
}

Three things to notice in the bridge block.

The Language enum is shared. Both sides see Language::English with the same memory layout. No serialisation, no conversion.

The unsafe extern "C++" block declares a function implemented in C++. The include!("logger.h") line tells cxx where the C++ declaration lives. The unsafe is the cxx way of saying “I trust the C++ signature matches.”

The extern "Rust" block declares a type and three functions implemented in Rust. type Greeting makes the struct opaque to C++ — C++ can hold references to Greeting and call translate on them, but cannot construct a Greeting or look inside it.

The rust::Str type used in the log_message signature is the killer feature. It is a pointer-plus-length view, not a copy. Rust’s &str becomes a rust::Str on the C++ side without allocating. The data lives in the caller’s memory and the receiver reads it directly.

cpp/include/logger.h

#pragma once
#include "rust/cxx.h"

// Zero‑copy: receives a rust::Str (pointer+length, no allocation)
void log_message(rust::Str msg);

The cxx runtime header rust/cxx.h is the only cxx-specific include. It defines rust::Str, rust::String, rust::Box, rust::Vec, and the rest of the cxx type vocabulary.

cpp/src/logger.cpp

#include "logger.h"
#include <iostream>

void log_message(rust::Str msg) {
    // rust::Str can be printed directly – just a view into Rust's memory
    std::cout << "[LOG] " << msg << std::endl;
}

The C++ function receives a rust::Str and uses it. std::cout << msg works because cxx provides an operator<< for rust::Str.

cpp/src/main.cpp

#include "lib.rs.h"
#include <iostream>
#include <string>

int main() {
    // hello() and bye() are free functions – not Greeting::hello()
    const Greeting& hello_greeting = hello();
    const Greeting& bye_greeting   = bye();

    std::cout << std::string(hello_greeting.translate(Language::English)) << '\n'
              << std::string(hello_greeting.translate(Language::French))  << '\n'
              << std::string(hello_greeting.translate(Language::German))  << '\n';

    std::cout << std::string(bye_greeting.translate(Language::English))   << '\n'
              << std::string(bye_greeting.translate(Language::French))    << '\n'
              << std::string(bye_greeting.translate(Language::German))    << '\n';

    return 0;
}

The include lib.rs.h is the generated header from cxx, copied by build.rs into cpp/generated/. It declares the Greeting type, the Language enum, and the bridge functions for C++.

The Greeting& is a reference to a Rust-owned object. Rust owns the lifetime. C++ borrows it. The reference is valid as long as the underlying Greeting exists on the Rust side.

cpp/CMakeLists.txt

cmake_minimum_required(VERSION 3.14)
project(CppGreeter LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# ── Paths ────────────────────────────────────────────────────
set(RUST_DIR        "${CMAKE_CURRENT_SOURCE_DIR}/../rust")
set(RUST_TARGET_DIR "${RUST_DIR}/target/release")
set(RUST_LIB        "${RUST_TARGET_DIR}/rust_greeter.lib")
set(GEN_DIR         "${CMAKE_CURRENT_SOURCE_DIR}/generated")

include_directories("${GEN_DIR}" "${CMAKE_CURRENT_SOURCE_DIR}/include")

# ── Rust build + generated files ──────────────────────────
add_custom_command(
    OUTPUT
        ${RUST_LIB}
        ${GEN_DIR}/lib.rs.cc
        ${GEN_DIR}/lib.rs.h
    COMMAND cargo build --release
    WORKING_DIRECTORY ${RUST_DIR}
    COMMENT "Building Rust library and generating CXX bindings..."
    DEPENDS
        ${RUST_DIR}/Cargo.toml
        ${RUST_DIR}/src/lib.rs
        ${RUST_DIR}/build.rs
        ${CMAKE_CURRENT_SOURCE_DIR}/include/logger.h
)

set_source_files_properties(${GEN_DIR}/lib.rs.cc PROPERTIES GENERATED TRUE)
add_custom_target(rust_lib DEPENDS ${RUST_LIB})

# ── C++ executable (now includes logger.cpp) ───────────────
add_executable(cpp_greeter
    src/main.cpp
    src/logger.cpp
    ${GEN_DIR}/lib.rs.cc
)

add_dependencies(cpp_greeter rust_lib)
target_link_libraries(cpp_greeter PRIVATE ${RUST_LIB})

# Windows system libs for Rust
if(WIN32)
    target_link_libraries(cpp_greeter PRIVATE
        ws2_32
        advapi32
        userenv
        ntdll
    )
endif()

The flow is: CMake declares the Rust staticlib and the generated files as outputs of an add_custom_command that runs cargo build --release. The custom command depends on Cargo.toml, lib.rs, build.rs, and logger.h. When any of those change, cargo runs and the generated files are copied.

The add_executable includes src/main.cpp, src/logger.cpp, and the generated lib.rs.cc. The generated file contains the C++ shim for all the extern "Rust" declarations in the bridge.

add_dependencies(cpp_greeter rust_lib) ensures the Rust library is built before the C++ executable tries to link against it.

The Windows system libraries at the bottom are not optional. Rust’s standard library pulls in ws2_32 (Winsock), advapi32 (registry and service APIs), userenv (user environment), and ntdll (NT layer). Without them, the C++ linker fails with unresolved symbols from std::* even though your own code does not reference them.

How to build

# 1. Build the Rust library and copy the generated files
cd rust
cargo build --release
cd ..

# 2. Build the C++ executable
cd cpp
mkdir -p build && cd build
cmake ..
cmake --build . --config Release

Output: cpp/build/Release/cpp_greeter.exe on Windows, cpp/build/cpp_greeter on Linux/macOS.

What this project teaches

The bridge is small. The build system is where the work is. Three things to remember from project 1:

  • .include(...) on cxx_build::bridge(...) is mandatory. Without it the build fails with a wall of template noise.
  • The Windows system libraries must be linked into the C++ binary. Rust uses them transitively, but C++ does not know that.
  • The generated lib.rs.h and lib.rs.cc need to live somewhere stable. The build.rs copy step is the standard way.

Project 2: The Plugin Engine With Tokio At The Boundary

The second project is the same problem at production scale. Rust owns a PluginManager with a Tokio runtime inside. C++ holds a pointer to the manager and calls methods on it. Rust pushes events back to C++ through a UniquePtr<EventCallback>. Plugin requests are serialised to JSON and sent across the bridge as &str.

This is the project that shows where cxx::bridge strains: opaque types with lifetimes, callbacks that go both ways, async work at the FFI boundary, and a build system that has to copy generated files around.

Directory layout

plcpprust/
├── cpp/
│   ├── CMakeLists.txt
│   ├── include/
│   │   └── event_callback.h
│   └── src/
│       └── main.cpp
└── rust/
    ├── Cargo.toml
    ├── build.rs
    └── src/
        ├── lib.rs
        └── api/
            ├── mod.rs
            ├── cxx_bridge.rs
            └── plugin/
                ├── mod.rs
                ├── plugin.rs
                ├── events.rs
                ├── types.rs
                ├── commands.rs
                ├── errors.rs
                └── ...

rust/Cargo.toml

[package]
name = "prexa_plugin_engine"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["staticlib"]

[dependencies]
# ── CXX bridge (zero-copy Rust↔C++) ──
cxx = "1.0"

# ── Plugin engine (unchanged) ──
anyhow = "1.0"
once_cell = "1.21"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "fs", "sync", "macros"] }
wasm_runtime_layer = "0.6"
wasmi_runtime_layer = "1.0"
chrono = { version = "0.4", features = ["serde"] }
waclay = "0.2"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
tar = "0.4"
zstd = "0.13"
base64 = "0.22"
rand = "0.8"
url = "2.5"

[build-dependencies]
cxx-build = "1.0"

[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true

The release profile is tuned for a small staticlib that gets linked into a C++ binary: opt-level = "z" for size, full LTO, one codegen unit, panic = "abort" so unwinding never crosses the FFI boundary, strip = true to remove symbols.

rust/build.rs

fn main() {
    // Tell cxx-build where to find the C++ header(s)
    let cpp_include = std::path::Path::new("../cpp/include");
    cxx_build::bridge("src/api/cxx_bridge.rs")
        .include(cpp_include)                // ← so we can #include "event_callback.h"
        .flag_if_supported("-std=c++17")
        .compile("prexa_plugin_engine");

    println!("cargo:rerun-if-changed=src/api/cxx_bridge.rs");
    println!("cargo:rerun-if-changed=src/api/plugin/plugin.rs");
    println!("cargo:rerun-if-changed=src/api/plugin/events.rs");
}

Note that the bridge source path is src/api/cxx_bridge.rs, not src/lib.rs. The bridge lives in a submodule. The .flag_if_supported("-std=c++17") line sets the C++ standard for compiling the C++ side of the bridge.

The rerun-if-changed lines tell cargo to rebuild the build script when the bridge or the implementation files change, which in turn triggers a rebuild of the cxx-generated shims.

rust/src/lib.rs

pub mod api;
mod utils;

Two lines. The whole entry point.

rust/src/api/mod.rs

pub mod cxx_bridge; // the bridge
pub mod plugin;

rust/src/api/cxx_bridge.rs (the heart of the project)

#[cxx::bridge]
pub mod ffi {
    #[derive(Clone, Copy)]
    enum PluginType {
        MetadataProvider,
        StreamResolver,
    }

    struct PluginInfoFlat {
        id: String,
        name: String,
        version: String,
        description: String,
        plugin_type: PluginType,
        wasm_path: String,
    }

    extern "Rust" {
        type PluginManager;
    }

    extern "Rust" {
        fn create_plugin_manager(plugins_dir: &str) -> Box<PluginManager>;
        fn destroy_plugin_manager(manager: Box<PluginManager>);

        fn load_plugin(manager: &mut PluginManager, plugin_id: &str, ptype: PluginType) -> Result<()>;
        fn unload_plugin(manager: &mut PluginManager, plugin_id: &str, ptype: PluginType) -> Result<()>;
        fn is_plugin_loaded(manager: &PluginManager, plugin_id: &str, ptype: PluginType) -> bool;

        fn get_available_plugins(manager: &PluginManager) -> Vec<PluginInfoFlat>;
        fn refresh_available_plugins(manager: &mut PluginManager);

        fn handle_plugin_request(manager: &mut PluginManager, plugin_id: &str, request_json: &str) -> Result<String>;
        fn storage_set(manager: &mut PluginManager, plugin_id: &str, key: &str, value: &str) -> bool;
        fn storage_get(manager: &PluginManager, plugin_id: &str, key: &str) -> String;
        fn storage_clear(manager: &mut PluginManager, plugin_id: &str);
        fn storage_preload(manager: &mut PluginManager, plugin_id: &str, key: &str, value: &str);
    }

    unsafe extern "C++" {
        include!("event_callback.h");

        type EventCallback;

        fn call(&self, json: &str);
    }

    extern "Rust" {
        fn set_event_callback(manager: &mut PluginManager, cb: UniquePtr<EventCallback>);
    }
}

The interesting bits.

Opaque Rust type. type PluginManager is opaque to C++. C++ sees it as a forward-declared class with a destructor. It can hold a Box<PluginManager> and call methods through &PluginManager or &mut PluginManager, but cannot construct one or look inside.

Result with no error type. Result<()> in the bridge becomes Result<T, cxx::Exception> on both sides. C++ catches const rust::Error&. Rust uses cxx::Exception::from(...) to construct one.

Callback from C++. type EventCallback in unsafe extern "C++" is a C++ class that C++ owns. Rust sees a forward declaration and can call call(&self, json: &str) on a UniquePtr<EventCallback> that C++ gave it. The &self (not &mut self) means the callback is const on the C++ side. cxx requires this for non-mutating calls.

Smart pointer for the callback. UniquePtr<EventCallback> is a cxx-specific smart pointer. C++ constructs the object with rust::make_unique<MyCallback>(), moves it into Rust via the bridge, and Rust holds it for as long as it wants. When the C++ wants to clear it, it can pass a new UniquePtr or let the Option go to None.

The rest of the file (the block_on helper, the conversion functions, the bridge trampolines) looks like this:

use crate::api::plugin::plugin::PluginManager;
use crate::api::plugin::types::PluginType as RealPluginType;
use crate::api::plugin::commands::{PluginRequest, PluginResponse};
use std::sync::OnceLock;

fn runtime() -> &'static tokio::runtime::Runtime {
    static RT: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
    RT.get_or_init(|| {
        tokio::runtime::Builder::new_multi_thread()
            .worker_threads(2)
            .enable_all()
            .build()
            .expect("Failed to build Tokio runtime")
    })
}

fn block_on<F: std::future::Future>(f: F) -> F::Output {
    runtime().block_on(f)
}

fn to_ffi_plugin_type(rt: RealPluginType) -> ffi::PluginType {
    match rt {
        RealPluginType::MetadataProvider => ffi::PluginType::MetadataProvider,
        RealPluginType::StreamResolver => ffi::PluginType::StreamResolver,
    }
}

fn to_real_plugin_type(ft: ffi::PluginType) -> RealPluginType {
    match ft {
        ffi::PluginType::MetadataProvider => RealPluginType::MetadataProvider,
        ffi::PluginType::StreamResolver => RealPluginType::StreamResolver,
        _ => unreachable!("unknown PluginType variant"),
    }
}

fn create_plugin_manager(plugins_dir: &str) -> Box<PluginManager> {
    Box::new(block_on(PluginManager::new(plugins_dir.to_owned())))
}

fn destroy_plugin_manager(_manager: Box<PluginManager>) {}

fn load_plugin(manager: &mut PluginManager, plugin_id: &str, ptype: ffi::PluginType) -> Result<(), cxx::Exception> {
    let real_type = to_real_plugin_type(ptype);
    block_on(manager.load_plugin_by_id(plugin_id, real_type)).map_err(|e| cxx::Exception::from(e))
}

fn unload_plugin(manager: &mut PluginManager, plugin_id: &str, ptype: ffi::PluginType) -> Result<(), cxx::Exception> {
    let real_type = to_real_plugin_type(ptype);
    block_on(manager.unload_plugin(plugin_id, real_type)).map_err(|e| cxx::Exception::from(e))
}

fn is_plugin_loaded(manager: &PluginManager, plugin_id: &str, ptype: ffi::PluginType) -> bool {
    let real_type = to_real_plugin_type(ptype);
    block_on(manager.is_plugin_loaded(plugin_id, real_type))
}

fn get_available_plugins(manager: &PluginManager) -> Vec<ffi::PluginInfoFlat> {
    let plugins = block_on(manager.get_available_plugins());
    plugins
        .into_iter()
        .map(|p| ffi::PluginInfoFlat {
            id: p.manifest.id.clone(),
            name: p.name.clone(),
            version: p.manifest.version.clone(),
            description: p.manifest.description.clone(),
            plugin_type: to_ffi_plugin_type(p.plugin_type),
            wasm_path: p.wasm_path(),
        })
        .collect()
}

fn refresh_available_plugins(manager: &mut PluginManager) {
    block_on(manager.refresh_available_plugins());
}

fn handle_plugin_request(manager: &mut PluginManager, plugin_id: &str, request_json: &str) -> Result<String, cxx::Exception> {
    let request: PluginRequest = serde_json::from_str(request_json)
        .map_err(|e| cxx::Exception::from(format!("Invalid request JSON: {}", e)))?;

    let response: PluginResponse = block_on(manager.handle_plugin_request(plugin_id, request))
        .map_err(|e| cxx::Exception::from(e.to_string()))?;

    serde_json::to_string(&response)
        .map_err(|e| cxx::Exception::from(format!("Serialization error: {}", e)))
}

fn storage_set(manager: &mut PluginManager, plugin_id: &str, key: &str, value: &str) -> bool {
    block_on(manager.storage_set(plugin_id, key, value))
}

fn storage_get(manager: &PluginManager, plugin_id: &str, key: &str) -> String {
    block_on(manager.storage_get(plugin_id, key)).unwrap_or_default()
}

fn storage_clear(manager: &mut PluginManager, plugin_id: &str) {
    block_on(manager.storage_clear(plugin_id));
}

fn storage_preload(manager: &mut PluginManager, plugin_id: &str, key: &str, value: &str) {
    block_on(manager.storage_preload(plugin_id, key, value));
}

fn set_event_callback(manager: &mut PluginManager, cb: cxx::UniquePtr<ffi::EventCallback>) {
    manager.event_callback = Some(cb);
}

Every extern "Rust" function on the bridge is synchronous. Internally, each one calls block_on to drive the async work on a Tokio runtime that is pinned in a OnceLock for the life of the process. The runtime is multi-threaded with two worker threads.

The conversion functions exist because the bridge declares its own ffi::PluginType enum (with only the variants cxx needs), and the real PluginType enum in plugin/types.rs is the one used everywhere else in the engine. They map back and forth.

cpp/include/event_callback.h

#pragma once
#include "rust/cxx.h"

class EventCallback {
public:
    virtual ~EventCallback() = default;
    virtual void call(rust::Str json) const = 0;  // ← FIXED: rust::Str, const
};

The C++ class is an abstract base. C++ subclasses it and overrides call. Rust holds a UniquePtr<EventCallback> and calls call on it.

The const on call is not optional. cxx requires it because the bridge declaration uses &self. If the C++ method is not const, the bridge does not compile.

cpp/src/main.cpp

#include "prexa_plugin_engine/src/api/cxx_bridge.rs.h"  // generated
#include <iostream>
#include <string>

// ── Concrete callback (implements EventCallback from the header) ─
class EventPrinter final : public EventCallback {
public:
    void call(rust::Str json) const override {
    std::cout << "[EVENT] " << std::string(json) << std::endl;
}
};

// ── Helper ─────────────────────────────────────────────────────
void list_plugins(rust::Box<PluginManager>& mgr) {
    auto plugins = get_available_plugins(*mgr);
    std::cout << "Available plugins (" << plugins.size() << "):\n";
    for (const auto& p : plugins) {
        std::cout << "  [" << (p.plugin_type == PluginType::MetadataProvider
                                   ? "metadata" : "resolver")
                  << "] " << std::string(p.id)
                  << " v" << std::string(p.version)
                  << " — " << std::string(p.description) << "\n";
    }
}

int main(int argc, char* argv[]) {
    const char* plugins_dir = (argc > 1) ? argv[1] : "./plugins";
    std::cout << "Scanning plugins from: " << plugins_dir << std::endl;

    rust::Box<PluginManager> mgr = create_plugin_manager(plugins_dir);

    // Register event callback (optional)
    auto printer = rust::make_unique<EventPrinter>();
    set_event_callback(*mgr, std::move(printer));

    list_plugins(mgr);

    std::string plugin_id = "imdb";
    try {
        load_plugin(*mgr, plugin_id, PluginType::MetadataProvider);
        std::cout << "  ✓ Loaded\n";
    } catch (const rust::Error& e) {
        std::cerr << "  ✗ Failed: " << e.what() << std::endl;
    }

    destroy_plugin_manager(std::move(mgr));
    return 0;
}

The include path is the deeply nested generated header path: prexa_plugin_engine/src/api/cxx_bridge.rs.h. This is because the bridge source file is src/api/cxx_bridge.rs in a crate named prexa_plugin_engine, and cxx-build generates the header at target/cxxbridge/include/<crate_name>/<source_path>. The CMake side copies the whole target/cxxbridge directory so the full nested path resolves.

rust::make_unique<EventPrinter>() is the cxx way to construct a C++ object that gets wrapped in a UniquePtr. The pointer is moved into Rust with std::move(printer). Rust stores it inside the PluginManager and calls call on it whenever an event fires.

try / catch (const rust::Error& e) is how C++ catches a cxx::Exception raised by Rust. The e.what() returns the message stored in the exception.

cpp/CMakeLists.txt

cmake_minimum_required(VERSION 3.15)
project(PrexaPluginCLI LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# ── Paths ──────────────────────────────────────────────────────────
set(RUST_DIR        "${CMAKE_CURRENT_SOURCE_DIR}/../rust")
set(RUST_TARGET_DIR "${RUST_DIR}/target/release")
set(RUST_LIB        "${RUST_TARGET_DIR}/prexa_plugin_engine.lib") # Windows
# set(RUST_LIB      "${RUST_TARGET_DIR}/libprexa_plugin_engine.a") # Linux/macOS

set(GEN_DIR         "${CMAKE_BINARY_DIR}/cxxbridge")

# ── Build Rust library (generates .lib + CXX bridge source/header) ─
add_custom_command(
    OUTPUT
        ${RUST_LIB}
        ${GEN_DIR}/prexa_plugin_engine/src/api/cxx_bridge.rs.h
        ${GEN_DIR}/prexa_plugin_engine/src/api/cxx_bridge.rs.cc
    COMMAND cargo build --release
    COMMAND ${CMAKE_COMMAND} -E make_directory ${GEN_DIR}
    COMMAND ${CMAKE_COMMAND} -E copy_directory
        ${RUST_DIR}/target/cxxbridge ${GEN_DIR}
    WORKING_DIRECTORY ${RUST_DIR}
    COMMENT "Building Rust library and generating CXX bindings..."
    DEPENDS
        ${RUST_DIR}/Cargo.toml
        ${RUST_DIR}/src/api/cxx_bridge.rs
        ${RUST_DIR}/src/api/plugin/plugin.rs
        ${RUST_DIR}/src/api/plugin/events.rs
)

add_custom_target(rust_build DEPENDS ${RUST_LIB})

# ── Library from CXX-generated source ──────────────────────────────
file(GLOB CXX_SOURCES "${GEN_DIR}/**/*.cc")
add_library(plugin_bridge STATIC ${CXX_SOURCES})
add_dependencies(plugin_bridge rust_build)
target_include_directories(plugin_bridge PUBLIC ${GEN_DIR})
set_source_files_properties(${CXX_SOURCES} PROPERTIES GENERATED TRUE)

# ── CLI executable ─────────────────────────────────────────────────
add_executable(plugin_cli
    src/main.cpp
)

target_include_directories(plugin_cli PRIVATE
    ${GEN_DIR}
)

target_link_libraries(plugin_cli PRIVATE
    ${RUST_LIB}
    plugin_bridge
)

# ── Windows system libs required by Rust std ───────────────────────
if(WIN32)
    target_link_libraries(plugin_cli PRIVATE
        ws2_32
        advapi32
        userenv
        ntdll
        bcrypt
    )
endif()

# ── Linux/macOS ────────────────────────────────────────────────────
if(UNIX)
    target_link_libraries(plugin_cli PRIVATE
        pthread
        dl
    )
endif()

This CMake is more involved than project 1 because the generated cxx files need to be compiled into a separate static library. The flow:

  1. add_custom_command runs cargo build --release and copies the target/cxxbridge directory into ${CMAKE_BINARY_DIR}/cxxbridge.
  2. file(GLOB CXX_SOURCES "${GEN_DIR}/**/*.cc") finds every .cc file cxx generated.
  3. add_library(plugin_bridge STATIC ${CXX_SOURCES}) compiles them into a static library.
  4. The CLI executable links against both the Rust staticlib and the cxx shim library.

The bcrypt system library is added here because the reqwest Rust dependency with rustls-tls pulls it in. Project 1 does not need it.

How to build

# 1. Build the Rust library
cd rust
cargo build --release
cd ..

# 2. Build the C++ executable
cd cpp
mkdir -p build && cd build
cmake ..
cmake --build . --config Release

Output: cpp/build/Release/plugin_cli.exe on Windows.

What this project teaches

Async to sync at the boundary is unavoidable. C++ does not know what async is. The bridge only deals with synchronous functions. The pattern is: pin a Tokio runtime in a OnceLock at first use, and use block_on to drive async work to completion on the bridge call. Two worker threads is the minimum that handles the workload without burning a thread per call.

Opaque types and smart pointers are the whole ownership story. Box<T> in extern "Rust" is the heap handle C++ owns and Rust destructs. UniquePtr<T> in extern "C++" is the C++ handle Rust can hold and call into. &T and &mut T are the borrow signatures. That is the whole vocabulary. If you find yourself reaching for raw pointers across the bridge, you are working around the type system.

The unsafe on extern "C++" matters. It says “this function or type is implemented in C++ and I, the bridge author, take responsibility for the signatures matching.” cxx cannot verify it. The C++ compiler verifies it on the C++ side.

The generated header path is deeply nested. When the bridge source is src/api/cxx_bridge.rs in a crate named prexa_plugin_engine, the generated C++ header is at target/cxxbridge/include/prexa_plugin_engine/src/api/cxx_bridge.rs.h. The CMake side must mirror this structure. The copy step in the add_custom_command is what makes it work.

const is mandatory on the C++ callback. cxx requires the call method to be const because the bridge declaration uses &self. If the C++ method is not const, the bridge does not compile.

Project 3: The Same Engine With Corrosion

The third project is the same problem — a C++ host for WASM plugins — with a different build system and a different ownership model. Same library, different choices. This is the version that survived because the build system is the right one.

Directory layout

testc/plugin_engine_project/
├── cpp/
│   ├── CMakeLists.txt
│   ├── include/
│   │   └── event_listener.h
│   └── src/
│       ├── main.cpp
│       ├── cli.cpp
│       ├── cli.hpp
│       └── commands/
│           ├── install.cpp
│           ├── list.cpp
│           ├── search.cpp
│           └── stream.cpp
└── rust/
    ├── Cargo.toml
    ├── build.rs
    ├── wit/
    │   ├── metadata-provider.wit
    │   ├── shared.wit
    │   └── stream-resolver.wit
    └── src/
        ├── lib.rs
        ├── adapters/
        │   ├── mod.rs
        │   ├── metadata_provider/
        │   └── stream_resolver/
        ├── core/
        │   ├── mod.rs
        │   ├── engine.rs
        │   ├── events.rs
        │   ├── pool.rs
        │   ├── runtime.rs
        │   ├── traits.rs
        │   ├── types.rs
        │   ├── ...
        └── ffi/
            ├── mod.rs
            ├── bridge.rs
            ├── wrappers.rs
            ├── convert.rs
            └── listener.rs

rust/Cargo.toml

[package]
name = "plugin_engine"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["staticlib"]

[dependencies]
# ── FFI ──
cxx = "=1.0.128"

# ── Async runtime ──
tokio       = { version = "1", features = ["rt-multi-thread", "sync", "macros", "fs", "time", "io-util"] }
tokio-util  = { version = "0.7", features = ["rt"] }
async-trait = "0.1"
futures     = "0.3"

# ── HTTP ──
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip"] }
url     = "2"

# ── WASM (wasmtime with component model) ──
wasmtime = { version = "29", features = ["component-model", "async"] }

# ── Concurrency primitives ──
parking_lot = "0.12"
once_cell   = "1.21"

# ── Serialization ──
serde      = { version = "1", features = ["derive"] }
serde_json = "1"

# ── Error / logging ──
thiserror          = "1"
anyhow             = "1"
tracing            = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

# ── Misc ──
chrono         = { version = "0.4", features = ["serde"] }
rand           = "0.8"
base64         = "0.22"
ed25519-dalek  = "2"
num_cpus       = "1"

# ── .bex install ──
tar  = "0.4"
zstd = "0.13"

[build-dependencies]
# MUST match the exact `cxx` version above.
cxxbridge-cmd = "=1.0.128"

[profile.release]
opt-level     = 3
lto           = "thin"
codegen-units = 1
panic         = "abort"
strip         = true

Two things to notice. The cxx version is pinned exactly to =1.0.128. The cxxbridge-cmd build dependency is pinned to the same exact version. This is for reproducibility — Corrosion invokes cxxbridge-cmd directly, and the version mismatch between the runtime cxx and the cxxbridge generator is a classic build failure.

The release profile is opt-level = 3 (size vs. speed tradeoff differs from project 2’s opt-level = "z"), lto = "thin" (faster build than full LTO, similar results for most code).

rust/build.rs

fn main() {
    println!("cargo:rerun-if-changed=src");
    println!("cargo:rerun-if-changed=wit");
    println!("cargo:rerun-if-changed=../cpp/include/event_listener.h");
}

This build.rs does not call cxx_build::bridge at all. Corrosion handles the bridge generation in CMake, not in cargo. The rerun-if-changed lines tell cargo when to rebuild the build script itself, which is enough to trigger the cxx generation that Corrosion controls.

rust/src/lib.rs

//! plugin_engine — WASM component-model plugin host with a C++ FFI.

pub mod core;
pub mod adapters;
pub mod ffi;

// Re-export the cxx::bridge module's `ffi` namespace so cxxbridge-cmd finds it.
pub use ffi::bridge::ffi as cxx_ffi;

Three modules: core (the engine logic), adapters (the WASM component bindings), ffi (the cxx bridge and its wrappers). The pub use ffi::bridge::ffi as cxx_ffi is a re-export that lets Corrosion find the bridge module by its canonical name.

rust/src/ffi/mod.rs

pub mod bridge;
pub mod wrappers;
pub mod convert;
pub mod listener;

rust/src/ffi/bridge.rs

// Re-export the wrapper types so cxx::bridge can find them via `super::`.
use crate::ffi::wrappers::{Engine, MetadataPlugin, ResolverPlugin};
use crate::ffi::wrappers::{init_logging, new_engine};

#[cxx::bridge(namespace = "plugin_engine")]
pub mod ffi {
    // ── Enums ────────────────────────────────────────────────
    pub enum PluginKind { MetadataProvider, StreamResolver }
    pub enum InstallStatus {
        Installed, Updated, AlreadyInstalled, PluginCurrentlyLoaded, Failed,
    }
    pub enum MediaTypeFfi {
        Anime, Movie, Tv, Ova, Ona, Music, News, Video,
        Documentary, ShortFilm, Unknown,
    }
    pub enum QualityFfi {
        Auto, Q144p, Q240p, Q360p, Q480p, Q720p, Q1080p, Q2160p, Q4k, Q8k,
    }

    // ── POD structs ──────────────────────────────────────────
    pub struct PluginInfoFfi {
        pub id: String, pub name: String, pub version: String,
        pub kind: PluginKind, pub path: String, pub host_site: Vec<String>,
    }
    pub struct InstallReportFfi {
        pub status: InstallStatus, pub plugin_id: String, pub error: String,
    }
    pub struct DiscoverItemFfi {
        pub id: String, pub title: String, pub thumbnail_url: String,
        pub year: String, pub score: u64, pub media_type: MediaTypeFfi,
    }
    pub struct SearchResultFfi {
        pub items: Vec<DiscoverItemFfi>, pub next_page_token: String,
    }
    pub struct ServerFfi {
        pub id: String, pub label: String, pub source_url: String, pub extra_info: String,
    }
    pub struct StreamVideoFfi {
        pub label: String, pub source_url: String, pub quality: QualityFfi,
    }
    pub struct StreamSubtitleFfi {
        pub label: String, pub source_url: String,
    }
    pub struct StreamSourceFfi {
        pub id: String, pub label: String, pub manifest_url: String,
        pub videos: Vec<StreamVideoFfi>, pub subtitles: Vec<StreamSubtitleFfi>,
    }

    // ── Opaque Rust types exposed to C++ ──────────────────────
    extern "Rust" {
        type Engine;
        type MetadataPlugin;
        type ResolverPlugin;

        fn init_logging(filter: &str);

        fn new_engine(plugins_dir: &str) -> Result<Box<Engine>>;

        fn discover(self: &Engine) -> Vec<PluginInfoFfi>;
        fn install_bex(self: &Engine, path: &str) -> InstallReportFfi;
        fn load(self: &Engine, plugin_id: &str, kind: PluginKind) -> Result<()>;
        fn unload(self: &Engine, plugin_id: &str) -> Result<()>;
        fn loaded_plugins(self: &Engine) -> Vec<String>;

        fn metadata(self: &Engine, plugin_id: &str)
            -> Result<Box<MetadataPlugin>>;
        fn resolver(self: &Engine, plugin_id: &str)
            -> Result<Box<ResolverPlugin>>;

        fn search(self: &MetadataPlugin, query: &str, page_token: &str)
            -> Result<SearchResultFfi>;

        fn get_servers(self: &ResolverPlugin, media_id: &str) -> Result<Vec<ServerFfi>>;
        fn get_stream(self: &ResolverPlugin, server: ServerFfi) -> Result<StreamSourceFfi>;

        fn set_event_listener(self: &mut Engine, listener: UniquePtr<EventListener>);
    }

    // ── C++ callback type ─────────────────────────────────────
    unsafe extern "C++" {
        include!("event_listener.h");

        type EventListener;

        // Free fn (your logger.h style)
        fn on_event(self: Pin<&mut EventListener>, json: &str);
    }
}

Five notable differences from project 2.

Namespace. #[cxx::bridge(namespace = "plugin_engine")] puts all generated C++ symbols in the plugin_engine namespace. C++ writes plugin_engine::Engine, plugin_engine::PluginKind::MetadataProvider, etc. The use at the top of the file brings the Rust wrapper types into scope so cxx can find them.

POD-style FFI structs. Every shared struct is a plain data type with String fields. No Result<> wrapping on every field. Errors come back through Result<T, String> or through the error: String field on InstallReportFfi. This is more C-idiomatic and avoids the deep nesting of Result<Result<T, E1>, E2>.

Split plugin types. The bridge declares MetadataPlugin and ResolverPlugin as separate opaque types. C++ calls engine->metadata(plugin_id) to get a Box<MetadataPlugin>, and engine->resolver(plugin_id) to get a Box<ResolverPlugin>. The split happens at the C++ boundary. Each plugin object has its own method (search for MetadataPlugin, get_servers and get_stream for ResolverPlugin).

Pin<&mut EventListener>. The C++ callback uses Pin<&mut EventListener> instead of &self. That means the C++ method is non-const and the listener can be moved between threads (the Pin enforces that the address does not change while Rust holds a reference). This is the model to use when the callback might be moved to a worker thread.

rust::cxxbridge1::Str in C++. The C++ side uses rust::cxxbridge1::Str instead of rust::Str. cxx disambiguates the bridge version this way when there are multiple bridges in the same translation unit. If your project only has one bridge, plain rust::Str works.

rust/src/ffi/wrappers.rs

use std::sync::Arc;

use crate::core::engine::{EngineConfig, PluginEngine};
use crate::core::error::PluginError;
use crate::core::types::PluginType;
use crate::ffi::bridge::ffi::*;
use crate::ffi::convert;
use crate::ffi::listener::EventListenerForwarder;

pub struct Engine {
    inner: Arc<PluginEngine>,
    forwarder: Option<EventListenerForwarder>,
}

pub struct MetadataPlugin {
    engine: Arc<PluginEngine>,
    plugin_id: String,
}

pub struct ResolverPlugin {
    engine: Arc<PluginEngine>,
    plugin_id: String,
}

fn core_kind(k: PluginKind) -> PluginType {
    match k {
        PluginKind::MetadataProvider => PluginType::MetadataProvider,
        PluginKind::StreamResolver => PluginType::StreamResolver,
        _ => PluginType::MetadataProvider,
    }
}

pub fn init_logging(filter: &str) {
    use tracing_subscriber::EnvFilter;
    let _ = tracing_subscriber::fmt()
        .with_env_filter(EnvFilter::try_new(filter).unwrap_or_else(|_| EnvFilter::new("info")))
        .try_init();
}

pub fn new_engine(plugins_dir: &str) -> Result<Box<Engine>, String> {
    let cfg = EngineConfig::defaults_in_memory(plugins_dir);
    let inner = crate::core::runtime::block_on(PluginEngine::new(cfg))
        .map_err(|e| e.to_string())?;
    Ok(Box::new(Engine { inner: Arc::new(inner), forwarder: None }))
}

impl Engine {
    pub fn discover(&self) -> Vec<PluginInfoFfi> {
        crate::core::runtime::block_on(async {
            self.inner.discover().await.into_iter().map(convert::plugin_info_to_ffi).collect()
        })
    }

    pub fn install_bex(&self, path: &str) -> InstallReportFfi {
        let report = crate::core::runtime::block_on(self.inner.install_bex(std::path::Path::new(path)));
        match report {
            Ok(r) => convert::install_report_to_ffi(r),
            Err(e) => InstallReportFfi {
                status: InstallStatus::Failed,
                plugin_id: String::new(),
                error: e.to_string(),
            },
        }
    }

    pub fn load(&self, plugin_id: &str, kind: PluginKind) -> Result<(), String> {
        crate::core::runtime::block_on(self.inner.load(plugin_id, core_kind(kind)))
            .map_err(|e| e.to_string())
    }

    pub fn unload(&self, plugin_id: &str) -> Result<(), String> {
        crate::core::runtime::block_on(self.inner.unload(plugin_id))
            .map_err(|e| e.to_string())
    }

    pub fn loaded_plugins(&self) -> Vec<String> {
        self.inner.loaded_ids()
    }

    pub fn metadata(&self, plugin_id: &str) -> Result<Box<MetadataPlugin>, String> {
        crate::core::runtime::block_on(self.inner.ensure_loaded(plugin_id, PluginType::MetadataProvider))
            .map_err(|e| e.to_string())?;
        Ok(Box::new(MetadataPlugin {
            engine: self.inner.clone(),
            plugin_id: plugin_id.to_string(),
        }))
    }

    pub fn resolver(&self, plugin_id: &str) -> Result<Box<ResolverPlugin>, String> {
        crate::core::runtime::block_on(self.inner.ensure_loaded(plugin_id, PluginType::StreamResolver))
            .map_err(|e| e.to_string())?;
        Ok(Box::new(ResolverPlugin {
            engine: self.inner.clone(),
            plugin_id: plugin_id.to_string(),
        }))
    }

    pub fn set_event_listener(&mut self, listener: cxx::UniquePtr<EventListener>) {
        if let Some(fwd) = self.forwarder.as_ref() {
            fwd.set_listener(listener);
        } else {
            let events = self.inner.events();
            let fwd = EventListenerForwarder::new(events, listener);
            self.forwarder = Some(fwd);
        }
    }
}

impl MetadataPlugin {
    pub fn search(&self, query: &str, page_token: &str) -> Result<SearchResultFfi, String> {
        let plugin_id = self.plugin_id.clone();
        let q = query.to_string();
        let p = if page_token.is_empty() { None } else { Some(page_token.to_string()) };
        crate::core::runtime::block_on(self.engine.with_plugin(&self.plugin_id, move |plugin| {
            let plugin_id = plugin_id.clone();
            match plugin.as_metadata() {
                Some(facet) => facet.search(&q, p.as_deref()),
                None => Err(PluginError::WrongFacet(plugin_id)),
            }
        }))
        .map_err(|e| e.to_string())
        .map(convert::search_result_to_ffi)
    }
}

impl ResolverPlugin {
    pub fn get_servers(&self, media_id: &str) -> Result<Vec<ServerFfi>, String> {
        let plugin_id = self.plugin_id.clone();
        let m = media_id.to_string();
        crate::core::runtime::block_on(self.engine.with_plugin(&self.plugin_id, move |plugin| {
            let plugin_id = plugin_id.clone();
            match plugin.as_resolver() {
                Some(facet) => facet.servers(&m),
                None => Err(PluginError::WrongFacet(plugin_id)),
            }
        }))
        .map_err(|e| e.to_string())
        .map(|servers| servers.into_iter().map(convert::server_to_ffi).collect())
    }

    pub fn get_stream(&self, server: ServerFfi) -> Result<StreamSourceFfi, String> {
        let plugin_id = self.plugin_id.clone();
        let core_server = convert::ffi_to_server(server);
        crate::core::runtime::block_on(self.engine.with_plugin(&self.plugin_id, move |plugin| {
            let plugin_id = plugin_id.clone();
            match plugin.as_resolver() {
                Some(facet) => facet.stream(core_server),
                None => Err(PluginError::WrongFacet(plugin_id)),
            }
        }))
        .map_err(|e| e.to_string())
        .map(convert::stream_source_to_ffi)
    }
}

The split between bridge.rs and wrappers.rs is the pattern this project uses. bridge.rs is the cxx declaration — pure types and signatures. wrappers.rs is the implementation — the actual Rust code that backs the bridge functions. The use line at the top of bridge.rs brings the wrapper types into scope so cxx can find them.

The Engine, MetadataPlugin, and ResolverPlugin structs are the Rust-side representations. They hold an Arc<PluginEngine> (the actual core engine) plus any per-call state (like the plugin_id for the plugin wrapper types).

The core_kind function converts the cxx-generated PluginKind enum to the engine’s PluginType enum. They have the same variants but different Rust types — cxx generates its own types from the bridge declaration.

set_event_listener does not actually forward events yet. The current implementation creates an EventListenerForwarder and stores it, but the forwarder is a placeholder that subscribes to the event bus and does not actually call back into C++.

rust/src/ffi/listener.rs

use std::sync::Arc;
use tokio::sync::mpsc;

use crate::core::events::EventBus;

/// Simplified EventListenerForwarder: for now this only subscribes to the
/// EventBus and keeps an mpsc sender for events. Forwarding to the C++ side
/// (which uses `cxx::UniquePtr`) is intentionally disabled here to avoid
/// moving non-Send `UniquePtr` across threads. We'll revisit a safe design
/// for cross-thread callbacks later.
pub struct EventListenerForwarder {
    tx: mpsc::UnboundedSender<crate::core::events::PluginEvent>,
}

impl EventListenerForwarder {
    pub fn new(_bus: Arc<EventBus>, _listener: cxx::UniquePtr<crate::ffi::bridge::ffi::EventListener>) -> Self {
        let (tx, _rx) = mpsc::unbounded_channel();

        // NOTE: we still replay past events into the channel so consumers
        // that poll the channel can receive them. Active forwarding to C++ is
        // disabled to avoid Send/Sync issues with `UniquePtr`.

        // Subscribe to the event bus — get replay + live receiver
        // (we won't spawn background threads here)

        Self { tx }
    }

    pub fn set_listener(&self, _listener: cxx::UniquePtr<crate::ffi::bridge::ffi::EventListener>) {
        // No-op for now; storing UniquePtr would require a thread-safe design.
    }
}

This file is the honest part of the project. The comment in the middle of the file says it: cxx::UniquePtr is not Send, so it cannot be moved to a worker thread. The EventListenerForwarder is a placeholder — it subscribes to the event bus, keeps an mpsc sender, and does not actually forward to C++ from a background thread.

The real fix is to use a dedicated owner thread: a Rust thread that holds the UniquePtr, reads events from a channel, and calls the C++ method. The shape of the code is right; the wiring is not.

cpp/include/event_listener.h

#pragma once
#include "rust/cxx.h"
#include <functional>
#include <string>

namespace plugin_engine {

class EventListener {
public:
    using Callback = std::function<void(const std::string&)>;
    explicit EventListener(Callback cb) : cb_(std::move(cb)) {}

    // Method expected by cxx bridge: Pin<&mut EventListener>::on_event(&str)
    void on_event(rust::cxxbridge1::Str json) {
        // Convert cxx::Str to std::string and dispatch
        std::string s(json.data(), json.size());
        on_event_impl(s);
    }

    void on_event_impl(const std::string& json) { cb_(json); }

private:
    Callback cb_;
};

} // namespace plugin_engine

The C++ side is the most idiomatic of the three. The constructor takes a std::function. The on_event method unpacks the rust::cxxbridge1::Str and forwards to the std::function. The C++ caller writes a lambda. The bridge machinery is hidden inside the class.

The on_event_impl method is a separate function so the on_event method can stay free of the C++ lambda type, which keeps the bridge declaration simple.

cpp/src/main.cpp

#include "plugin_engine_cxx/ffi/bridge.h"
#include "event_listener.h"
#include <iostream>
#include <string_view>

namespace cmd {
    int list   (plugin_engine::Engine& e);
    int install(plugin_engine::Engine& e, const std::string& bex_path);
    int search (plugin_engine::Engine& e, const std::string& id, const std::string& q);
    int stream (plugin_engine::Engine& e, const std::string& id, const std::string& srv);
}

static void usage() {
    std::cerr <<
        "Usage: plugin_cli <command> [args]\n"
        "  list\n"
        "  install <file.bex>\n"
        "  search  <plugin_id> <query>\n"
        "  stream  <plugin_id> <server_json>\n";
}

int main(int argc, char** argv) {
    if (argc < 2) { usage(); return 2; }

    plugin_engine::init_logging("info,plugin_engine=debug");

    try {
        auto engine = plugin_engine::new_engine("./plugins");

        auto listener = std::make_unique<plugin_engine::EventListener>(
            [](const std::string& json) {
                std::cerr << "[event] " << json << "\n";
            });
        engine->set_event_listener(std::move(listener));

        std::string_view c = argv[1];
        if (c == "list")                  return cmd::list   (*engine);
        if (c == "install" && argc == 3)  return cmd::install(*engine, argv[2]);
        if (c == "search"  && argc == 4)  return cmd::search (*engine, argv[2], argv[3]);
        if (c == "stream"  && argc == 4)  return cmd::stream (*engine, argv[2], argv[3]);

        usage();
        return 2;
    } catch (const rust::Error& e) {
        std::cerr << "fatal: " << e.what() << "\n";
        return 1;
    }
}

The include is plugin_engine_cxx/ffi/bridge.h. That is the path Corrosion generates — the bridge target name is plugin_engine_cxx, the cxx file is at ffi/bridge.rs in the Rust crate, and Corrosion produces the header at ${CMAKE_CURRENT_BINARY_DIR}/corrosion_generated/cxxbridge/plugin_engine_cxx/include/plugin_engine_cxx/ffi/bridge.h.

The auto engine = plugin_engine::new_engine("./plugins") returns a rust::Box<plugin_engine::Engine>. The auto deduces it. Dereferencing it (*engine) gives a plugin_engine::Engine& which is the type the command functions take.

The engine->set_event_listener(std::move(listener)) passes a std::unique_ptr<plugin_engine::EventListener> to the bridge. The bridge declares UniquePtr<EventListener> on the C++ side, which cxx aliases to std::unique_ptr in this context.

cpp/CMakeLists.txt

cmake_minimum_required(VERSION 3.22)
project(plugin_cli LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

# ── Corrosion ─────────────────────────────────────────────────
include(FetchContent)
FetchContent_Declare(
    Corrosion
    GIT_REPOSITORY https://github.com/corrosion-rs/corrosion.git
    GIT_TAG        v0.6
)
FetchContent_MakeAvailable(Corrosion)

# ── Import the Rust crate ────────────────────────────────────
# Map CMake build type to Cargo profile for consistency
if(CMAKE_BUILD_TYPE STREQUAL "Debug")
    set(CARGO_PROFILE "debug")
else()
    set(CARGO_PROFILE "release")
endif()

corrosion_import_crate(
    MANIFEST_PATH ${CMAKE_CURRENT_SOURCE_DIR}/../rust/Cargo.toml
    PROFILE       ${CARGO_PROFILE}
    CRATE_TYPES   staticlib
)

# ── Generate + compile the cxxbridge ─────────────────────────
corrosion_add_cxxbridge(plugin_engine_cxx
    CRATE        plugin_engine
    REGEN_TARGET plugin_engine_cxx_regen
    FILES        ffi/bridge.rs
)

# ── Tell the bridge target where to find your handwritten C++ headers
target_include_directories(plugin_engine_cxx
    PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include
)

# ── The CLI executable ───────────────────────────────────────
add_executable(plugin_cli
    src/main.cpp
    src/cli.cpp
    src/commands/list.cpp
    src/commands/install.cpp
    src/commands/search.cpp
    src/commands/stream.cpp
)

target_include_directories(plugin_cli PRIVATE
    include
    src
    ${CMAKE_CURRENT_BINARY_DIR}/corrosion_generated/cxxbridge/plugin_engine_cxx/include
)
target_link_libraries(plugin_cli PRIVATE
    plugin_engine_cxx
    plugin_engine
)

# ── Platform system libs Rust needs ──────────────────────────
if(WIN32)
    target_link_libraries(plugin_cli PRIVATE
        ws2_32 advapi32 userenv ntdll bcrypt crypt32 secur32 ncrypt iphlpapi dbghelp kernel32)
elseif(APPLE)
    target_link_libraries(plugin_cli PRIVATE
        "-framework CoreFoundation"
        "-framework Security"
        "-framework SystemConfiguration")
else()
    target_link_libraries(plugin_cli PRIVATE pthread dl m)
endif()

# ── MSVC debug CRT workaround ────────────────────────────────
if(MSVC AND CMAKE_BUILD_TYPE STREQUAL "Debug")
    corrosion_set_env_vars(plugin_engine
        "CFLAGS=-MDd" "CXXFLAGS=-MDd")
endif()

This is the right way to integrate CMake and cargo. The flow:

  1. FetchContent pulls in Corrosion, a CMake module that imports Cargo crates as CMake targets.
  2. corrosion_import_crate reads Cargo.toml, builds the crate as a CMake target named plugin_engine (the crate name from Cargo.toml).
  3. corrosion_add_cxxbridge creates a new target plugin_engine_cxx that runs cxxbridge-cmd on ffi/bridge.rs and compiles the generated C++ source.
  4. The CLI links against both targets. CMake knows the dependency order: plugin_engine_cxx depends on plugin_engine, the CLI depends on both.

The corrosion_set_env_vars call at the bottom is a workaround for an MSVC + Debug + cxxbridge issue: when building in Debug mode, the MSVC runtime is the debug CRT (/MDd) but cargo by default builds with the release CRT flags. Setting CFLAGS=-MDd and CXXFLAGS=-MDd makes both sides agree.

The expanded Windows system library list (ws2_32 advapi32 userenv ntdll bcrypt crypt32 secur32 ncrypt iphlpapi dbghelp kernel32) is what reqwest with rustls-tls and tracing need. The full list is overkill for most projects but prevents mysterious link errors.

How to build

cd cpp
mkdir -p build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Release
cmake --build . --config Release

Output: cpp/build/Release/plugin_cli.exe on Windows.

The difference from project 2 is that this command builds everything. There is no separate cargo build --release step. Corrosion invokes cargo through the CMake build system with proper dependency tracking.

What this project teaches

Corrosion is the right answer for any non-trivial project. The five minutes you save skipping the dependency are spent ten times over chasing “I edited Rust and CMake did not pick it up” bugs. Once you have more than one Rust file or the project will live longer than a weekend, take the dependency.

Pin cxx versions exactly. The cxx dependency and the cxxbridge-cmd build dependency must be the same exact version. A mismatch shows up as a runtime crash deep inside cxx-generated code, not a build error.

Pin<&mut T> is the right callback shape for async work. If the C++ callback might be moved between threads, use Pin<&mut T> in the bridge. The &self shape is for callbacks that are only ever called on the thread that invoked the bridge function.

The bridge namespace puts C++ symbols behind plugin_engine::. Use it when you have multiple bridges in the same translation unit, or when you want to make it clear in C++ which crate a symbol came from. The cost is that you have to write plugin_engine::Engine everywhere instead of just Engine.

rust::cxxbridge1::Str is what you get in C++ when the bridge version matters. If you have one bridge, plain rust::Str works. If you have multiple bridges or want to be explicit about which bridge version you are using, rust::cxxbridge1::Str is the disambiguated form.

The Cxx::bridge Cheat Sheet

After three projects, the vocabulary of the bridge is small.

Shared types. struct Foo { x: i32 } in the bridge block. Both sides see the same struct with the same field names and types. Plain old data only.

Shared enums. enum Bar { A, B, C } in the bridge block. Both sides see the same variants. #[derive(Clone, Copy)] if you want to pass them by value across the bridge.

Opaque Rust types. extern "Rust" { type Foo; }. C++ sees a forward declaration. Rust owns the implementation. Pass Box<Foo> for heap-allocated handles, &Foo and &mut Foo for borrows.

Opaque C++ types. extern "C++" { type Foo; }. Rust sees a forward declaration. C++ owns the implementation. Pass UniquePtr<Foo> for C++-allocated handles.

Shared functions. Free functions in either block. Rust calls ffi::do_thing(...). C++ calls do_thing(...). Return types and parameter types must be in the shared vocabulary.

Strings. &str in Rust becomes rust::Str in C++. Zero-copy view. The data lives in the caller’s memory. The receiver reads it directly and does not need to free it.

Containers. Vec<T> in Rust becomes std::vector<T> in C++. Same memory layout. The bridge handles the cross-allocation correctly when the side that created the vector is different from the side that consumes it.

Errors. Result<T> in the bridge means T on success and rust::Error on failure. Throw a rust::Error in Rust with cxx::Exception::from(...). Catch it in C++ with a try/catch (const rust::Error& e).

Smart pointers. Box<T> in Rust becomes rust::Box<T> in C++. UniquePtr<T> in C++ becomes cxx::UniquePtr<T> in Rust. These are the only ownership types you should use across the bridge. Raw pointers are a code smell.

Callbacks. Two shapes.

  • &self for callbacks that do not mutate the C++ state and are only called on the thread that invoked the bridge function. The C++ method must be const.
  • Pin<&mut T> for callbacks that can be moved between threads or that need to mutate C++ state. The C++ method does not need to be const.

The Build System Decision

Three projects, three build setups. The right choice depends on project size.

No build system. For a single-file proof of concept. Not a real answer for anything larger than hello world.

Hand-rolled CMake with add_custom_command. Works for small projects. The build.rs calls cxx_build::bridge(...) to compile the C++ side of the bridge. CMake shells out to cargo build to produce the staticlib. Fragile: edits to Rust files do not always trigger a rebuild, and the rebuild ordering between the cxx generation and the cargo build is manual.

Corrosion. A CMake module that imports Cargo crates as CMake targets. The cxx bridge generation is done by corrosion_add_cxxbridge at CMake configure time. Cargo is invoked through the CMake build system, with proper dependency tracking. The right answer for any project that has more than one Rust file or is going to live longer than a weekend.

The Corrosion dependency is one FetchContent call. The trade is hours of debugging rebuilds for one extra block of CMake. Take the trade.

What The Docs Don’t Tell You

Four things that took longer than they should have.

cxx_build::bridge(...).include(...) is mandatory. If the C++ side of the bridge includes any local headers, the build.rs must tell cxx-build where to find them. The default search path does not include the C++ project’s include directories. The error is a wall of template noise that does not point to the missing flag.

Windows system libraries are not optional. ws2_32, advapi32, userenv, ntdll (and on Windows 10+, bcrypt, plus whatever else your dependencies pull in like crypt32, secur32, ncrypt, iphlpapi, dbghelp, kernel32) must be linked into the C++ binary. Rust’s standard library pulls them in, but the C++ linker does not know that. Add them to target_link_libraries on the C++ side.

Pin<&mut T> is the only way to call a mutable C++ method from Rust. If the C++ method is not const, the bridge declaration must use self: Pin<&mut T> and the C++ implementation must be callable through a pinned reference. The as_pin_mut() method on UniquePtr<T> is the standard way to get one. The cxxbridge1::Str type appears in the C++ side because cxx uses it to disambiguate the rust types from any rust::Str you might define yourself.

The generated header path mirrors the source path. When the bridge source is src/api/cxx_bridge.rs in a crate named prexa_plugin_engine, the generated C++ header is at target/cxxbridge/include/prexa_plugin_engine/src/api/cxx_bridge.rs.h. The CMake side must mirror this structure. The add_custom_command with copy_directory is the standard way to make it work.

Three projects, one library, full file contents. Project 1 is the smallest working setup. Project 2 is what production looks like with a hand-rolled build system. Project 3 is the same engine with the right build system. The bridge is small. The build system is the hard part. Corrosion is the answer. The &str to rust::Str zero-copy string handling is the killer feature. The Pin<&mut T> for callbacks is the part the docs explain least. The rest is just typing.

H
· 3614 words · 19 min read