Skip to main content

Module wire

Module wire 

Source
Expand description

Serialize complex objects into flat byte buffers and transfer them over FFI.

Types like String, Vec<T>, HashMap<K, V>, and structs containing them cannot be passed directly over an FFI boundary. Their in-memory form is a small header (pointer, length, capacity) into a Rust-managed heap allocation — the foreign side cannot read, resize, or free that memory. Wire<T> solves this by serializing the value into a flat byte buffer on one side and deserializing it on the other.

§Examples

§Accepting a complex argument

use interoptopus::ffi;
use interoptopus::wire::Wire;
use std::collections::HashMap;

#[ffi]
pub fn lookup(mut map: Wire<HashMap<String, String>>) -> u32 {
    let map = map.unwire();
    map.len() as u32
}

§Returning a complex value

use interoptopus::ffi;
use interoptopus::wire::Wire;

#[ffi]
pub fn greeting() -> Wire<String> {
    Wire::from("hello".to_string())
}

§Structs containing non-FFI types

Any #[ffi] struct whose fields are not all repr(C) can be wrapped in Wire<T>. The proc macro generates matching serialization code on both sides.

use interoptopus::ffi;
use interoptopus::wire::Wire;

#[ffi]
pub struct UserProfile {
    pub name: String,
    pub tags: Vec<String>,
}

#[ffi]
pub fn accept_profile(mut profile: Wire<UserProfile>) {
    let profile = profile.unwire();
    println!("{}: {:?}", profile.name, profile.tags);
}

§Deeply nested types

Wire<T> handles arbitrarily nested structures, including Vec, HashMap, and Option at any depth:

use interoptopus::ffi;
use interoptopus::wire::Wire;
use std::collections::HashMap;

#[ffi]
pub struct Inner { pub score: u32 }

#[ffi]
pub struct Outer {
    pub items: HashMap<u32, Vec<Inner>>,
}

#[ffi]
pub fn process(mut data: Wire<Outer>) -> u32 {
    let data = data.unwire();
    data.items.values().flatten().map(|i| i.score).sum()
}

§Registering the helpers

Every crate that uses Wire<T> must call builtins_wire! in its inventory so the create/destroy helpers are available to the foreign side:

use interoptopus::ffi;
use interoptopus::wire::Wire;
use interoptopus::{builtins_wire, function};
use interoptopus::inventory::RustInventory;

#[ffi]
pub fn greeting() -> Wire<String> {
    Wire::from("hello".to_string())
}

pub fn ffi_inventory() -> RustInventory {
    RustInventory::new()
        .register(function!(greeting))
        .register(builtins_wire!())
        .validate()
}

§Wire vs. Protobuf

The natural alternative to Wire<T> for passing complex ‘variably-sized’ types over FFI is Protocol Buffers. Protobuf works, but it comes with significant friction: you need to maintain .proto schema files alongside your Rust types, install and run an external code generator as part of your build, integrate that generator into both the Rust and the foreign-language project, and keep all three in sync whenever a type changes. The result is a more complex project setup with more moving parts — and you still have to wire the generated types into your FFI layer by hand.

Wire<T> eliminates all of that. Types are defined once in Rust with #[ffi], and both the serialization logic and the foreign-language deserialization code are generated automatically as part of the normal interoptopus build. There are no .proto files, no external tools, and no schema drift.

Beyond ergonomics, Wire<T> is in most cases also faster. Because both sides share the exact same compiled type layout, there is no field-tag overhead, no varint encoding, and no dynamic dispatch — just a straight sequential read/write of the exact bytes needed. In our benchmarks, Wire<T> usually outperformed Protobuf by roughly 20–200% depending on the payload shape.

wire-vs-protobuf

Note, in the benchmarks above, Protobuf was given a slight advantage over Wire<T> by not having to FFI allocate. This made Protobuf’s performance look slightly better, but would make it unsuitable for async use.

§Under the Hood

A Wire<T> is essentially a serialized buffer that is safe to pass through FFI boundaries.

§Rust -> Foreign

  1. SerializeWire::from (or Wire::try_from) serializes the value into a new Rust-allocated buffer.
  2. Transfer — the Wire<T> is returned from an #[ffi] function; as a repr(C) struct it crosses the FFI boundary by value.
  3. Deserialize — the foreign side (e.g., C#) reads the buffer bytes and reconstructs the managed type.
  4. Free — the foreign side calls Dispose() or similar on the wire object, which invokes interoptopus_wire_destroy (emitted by builtins_wire!) to drop the Rust-allocated buffer.

§Foreign -> Rust

  1. Allocate — the generated WireOf*.From(value) helper calls interoptopus_wire_create (emitted by builtins_wire!) so that Rust allocates the buffer; the foreign side never allocates directly.
  2. Serialize — the value is serialized into that Rust-allocated buffer.
  3. Transfer — the Wire<T> is passed into an #[ffi] function. Rust receives ownership.
  4. DeserializeWire::unwire or Wire::try_unwire reads T from the buffer.
  5. Free — Rust drops the Wire<T> when the function returns, freeing the buffer.

§Wire format

All values are written in little-endian byte order, sequentially, with no padding or alignment between fields:

TypeFormat
u8..u64, i8..i64, f32, f64Fixed-size little-endian bytes
usize / isizePlatform-width little-endian (8 bytes on 64-bit)
bool1 byte (0x00 = false, non-zero = true)
Stringu32 byte-length (LE), then UTF-8 bytes
Vec<T>u32 element count (LE), then each element serialized in order
HashMap<K,V>u32 entry count (LE), then each key followed by value
(A, B, …)Each element serialized in order
User structsEach field serialized in declaration order

The wire format is not self-describing, both sides must agree on the exact type layout.

Note: This section describes an internal implementation detail that may change between versions without notice. Do not rely on it for persistent storage or cross-version compatibility.

Structs§

SerializationError
Error returned when a wire-format serialization or deserialization fails.
Wire
Wraps and transfers complex objects over FFI.