High-level message protocol builder for async Rust.
ProtoBuilder simplifies building type-safe communication protocols over async streams by combining:
- Enum-based message types - Define your protocol messages as Rust enums with compile-time type safety
- Automatic serialization - Uses RON (Rusty Object Notation) for human-readable, text-based message format
- Binary framing layer - Configurable framing strategies (length-prefix or chunked) for message boundary detection
- Async/Tokio native - Built from the ground up for async Rust
- Type-safe protocol definitions using Rust enums
- Length-prefix framing (u16 or u32) for simple message boundaries
- Chunked framing for large messages (32KB+)
- Split protocol support for concurrent read/write
- Clean error handling with custom error types
Add this to your Cargo.toml:
[dependencies]
protobuilder = "0.1.0"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }use protobuilder::{Protocol, LengthPrefix, Result};
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug, Clone)]
enum Request {
Ping,
Message(String),
}
#[derive(Serialize, Deserialize, Debug, Clone)]
enum Response {
Pong,
Reply(String),
}
#[tokio::main]
async fn main() -> Result<()> {
// Connect to a server
let stream = tokio::net::TcpStream::connect("127.0.0.1:9999").await?;
// Build the protocol with length-prefix framing
let mut proto = Protocol::<_, Request, Response, _>::builder()
.framing(LengthPrefix::u32())
.build(stream)?;
// Send and receive messages
proto.send(Request::Ping).await?;
let response = proto.recv().await?;
println!("Received: {:?}", response);
Ok(())
}Simple framing using a length header before each message:
use protobuilder::LengthPrefix;
// 16-bit length prefix (max 64KB messages)
let framing = LengthPrefix::u16();
// 32-bit length prefix (max 4GB messages)
let framing = LengthPrefix::u32();
// Custom max size limit
let framing = LengthPrefix::u32().with_max_size(1024 * 1024); // 1MBFor large messages that need to be split across multiple chunks:
use protobuilder::ChunkedFraming;
// 8KB chunks
let framing = ChunkedFraming::new(8 * 1024);
// With custom max message size
let framing = ChunkedFraming::new(32 * 1024)
.with_max_message_size(10 * 1024 * 1024); // 10MBFor concurrent read/write operations, split the protocol into halves:
# use protobuilder::{Protocol, LengthPrefix, Result};
# use serde::{Serialize, Deserialize};
# #[derive(Serialize, Deserialize)]
# enum Request { Ping }
# #[derive(Serialize, Deserialize)]
# enum Response { Pong }
# async fn example() -> Result<()> {
# let stream = tokio::net::TcpStream::connect("127.0.0.1:9999").await?;
let proto = Protocol::<_, Request, Response, _>::builder()
.framing(LengthPrefix::u32())
.build(stream)?;
let mut split_proto = proto.split();
// Now you can use split_proto in concurrent tasks
tokio::spawn(async move {
split_proto.send(Request::Ping).await.unwrap();
});
# Ok(())
# }See examples/basic.rs for a working client-server example:
use protobuilder::{Protocol, LengthPrefix, Result};
use serde::{Serialize, Deserialize};
use tokio::net::{TcpListener, TcpStream};
#[derive(Serialize, Deserialize, Debug, Clone)]
enum ClientPacket {
Ping,
Message(String),
}
#[derive(Serialize, Deserialize, Debug, Clone)]
enum ServerPacket {
Pong,
Reply(String),
}
// Server
async fn server() -> Result<()> {
let listener = TcpListener::bind("127.0.0.1:9999").await?;
loop {
let (stream, _) = listener.accept().await?;
tokio::spawn(async move {
let mut proto = Protocol::<_, ServerPacket, ClientPacket, _>::builder()
.framing(LengthPrefix::u32())
.build(stream)
.unwrap();
while let Ok(packet) = proto.recv().await {
match packet {
ClientPacket::Ping => {
let _ = proto.send(ServerPacket::Pong).await;
}
ClientPacket::Message(text) => {
let _ = proto.send(ServerPacket::Reply(format!("Echo: {}", text))).await;
}
}
}
});
}
}Run the basic example:
cargo run --example basicRun the large data example (demonstrates chunked framing):
cargo run --example large_data// Generic form:
Protocol::<Stream, SendType, RecvType, FramingType>::builder()
.framing(framing_strategy)
.build(stream)
// See the Quick Start example above for a complete working examplesend(packet)- Send a messagerecv()- Receive a messagesplit()- Split into read/write halvesinto_inner()- Extract the underlying streamget_ref()/get_mut()- Access the underlying stream
send(packet)- Send a message (from the writer half)recv()- Receive a message (from the reader half)into_halves()- Extract the reader, writer, and framing components
pub enum ProtocolError {
Io(std::io::Error),
Framing(String),
Serialization(String),
Deserialization(String),
TooLarge(usize),
}
pub type Result<T> = std::result::Result<T, ProtocolError>;- Serialization: Your enum messages are serialized to RON text (e.g.,
Message("Hello")→"Message(\"Hello\")") - Framing: The text payload is wrapped with a binary framing layer for message boundary detection:
- Length Prefix: Prepends a u16/u32 length header (big-endian)
- Chunked: Splits large payloads into chunks with message IDs and continuation flags
- Transmission: The framed binary data is written to/read from the async stream
Note: The wire format is binary (due to the framing layer), but the message payload itself is human-readable RON text. For pure binary serialization (e.g., bincode, protobuf), this crate is not suitable.
Licensed under the Apache License, Version 2.0. See LICENSE for details.