Wireframe is an experimental Rust library that simplifies building servers and clients for custom binary protocols. The design borrows heavily from Actix Web to provide a familiar, declarative API for routing, extractors, and middleware.
Manual handling of binary protocols typically involves verbose serialization
code, custom frame parsing, and complex dispatch logic. wireframe
aims to
reduce this boilerplate through layered abstractions:
- Transport adapter built on Tokio I/O
- Framing layer for length‑prefixed or custom frames
- Connection preamble with customizable validation callbacks [docs]
- Call
with_preamble::<T>()
before registering success or failure callbacks - Serialization engine using
bincode
or awire-rs
wrapper - Routing engine that dispatches messages by ID
- Handler invocation with extractor support
- Middleware chain for request/response processing
- Connection lifecycle hooks for per-connection setup and teardown
These layers correspond to the architecture outlined in the design document【F:docs/rust-binary-router-library-design.md†L292-L344】.
Applications are configured using a builder pattern similar to Actix Web. A
WireframeApp
defines routes and middleware, while WireframeServer
manages
connections and runs the Tokio event loop:
WireframeServer::new(|| {
WireframeApp::new()
.frame_processor(MyFrameProcessor::new())
.app_data(state.clone())
.route(MessageType::Login, handle_login)
.wrap(MyLoggingMiddleware::default())
})
.bind("127.0.0.1:7878")?
.run()
.await
By default, the number of worker tasks equals the number of CPU cores. If the CPU count cannot be determined, the server falls back to a single worker.
The builder supports methods like frame_processor
, route
, app_data
, and
wrap
for middleware configuration. app_data
stores any Send + Sync
value
keyed by type; registering another value of the same type overwrites the
previous one. Handlers retrieve these values using the SharedState<T>
extractor【F:docs/rust-binary-router-library-design.md†L622-L710】.
Handlers are asynchronous functions whose parameters implement extractor traits
and may return responses implementing the Responder
trait. This pattern
mirrors Actix Web handlers and keeps protocol logic
concise【F:docs/rust-binary-router-library-design.md†L682-L710】.
The design document includes a simple echo server that demonstrates routing based on a message ID and the use of a length‑prefixed frame processor:
async fn handle_echo(req: Message<EchoRequest>) -> WireframeResult<EchoResponse> {
Ok(EchoResponse {
original_payload: req.payload.clone(),
echoed_at: time_now(),
})
}
WireframeServer::new(|| {
WireframeApp::new()
.serializer(BincodeSerializer)
.route(MyMessageType::Echo, handle_echo)
})
.bind("127.0.0.1:8000")?
.run()
.await
This example showcases how derive macros and the framing abstraction simplify a binary protocol server. See the full example in the design document for further details.
WireframeApp
defaults to a simple Envelope
containing a message ID and raw
payload bytes. Applications can supply their own envelope type by calling
WireframeApp::<_, _, MyEnv>::new()
. The custom type must implement the
Packet
trait:
use wireframe::app::{Packet, PacketParts, WireframeApp};
#[derive(bincode::Encode, bincode::BorrowDecode)]
struct MyEnv { id: u32, correlation_id: Option<u64>, payload: Vec<u8> }
impl Packet for MyEnv {
fn id(&self) -> u32 { self.id }
fn correlation_id(&self) -> Option<u64> { self.correlation_id }
fn into_parts(self) -> PacketParts {
PacketParts::new(self.id, self.correlation_id, self.payload)
}
fn from_parts(parts: PacketParts) -> Self {
let id = parts.id();
let correlation_id = parts.correlation_id();
let payload = parts.payload();
Self { id, correlation_id, payload }
}
}
let app = WireframeApp::<_, _, MyEnv>::new()
.unwrap()
.route(1, std::sync::Arc::new(|env: &MyEnv| Box::pin(async move { /* ... */ })))
.unwrap();
A None
correlation ID denotes an unsolicited event or server-initiated push.
Use None
rather than Some(0)
when a frame lacks a correlation ID. See
PacketParts for field details.
This allows integration with existing packet formats without modifying
handle_frame
.
Handlers can return types implementing the Responder
trait. These values are
encoded using the application's configured serializer and written back through
the FrameProcessor
【F:docs/rust-binary-router-library-design.md†L724-L730】.
The included LengthPrefixedProcessor
illustrates a simple framing strategy
that prefixes each frame with its length. The format is configurable (prefix
size and endianness) and defaults to a 4‑byte big‑endian length
prefix【F:docs/rust-binary-router-library-design.md†L1082-L1123】.
use wireframe::frame::{LengthFormat, LengthPrefixedProcessor};
let app = WireframeApp::new()?
.frame_processor(LengthPrefixedProcessor::new(LengthFormat::u16_le()));
Protocol callbacks are consolidated under the WireframeProtocol
trait,
replacing the individual on_connection_setup
/on_connection_teardown
closures. The trait methods are synchronous so the trait remains object safe,
but callbacks can spawn asynchronous tasks when needed. A protocol
implementation registers hooks for connection setup, frame mutation and command
completion. The associated ProtocolError
type is used by other parts of the
API, such as request handling.
pub trait WireframeProtocol: Send + Sync + 'static {
type Frame: FrameLike;
type ProtocolError;
fn on_connection_setup(
&self,
handle: PushHandle<Self::Frame>,
ctx: &mut ConnectionContext,
);
fn before_send(&self, frame: &mut Self::Frame, ctx: &mut ConnectionContext);
fn on_command_end(&self, ctx: &mut ConnectionContext);
}
struct MySqlProtocolImpl;
impl WireframeProtocol for MySqlProtocolImpl {
type Frame = Vec<u8>;
type ProtocolError = ();
fn on_connection_setup(
&self,
handle: PushHandle<Self::Frame>,
_ctx: &mut ConnectionContext,
) {
// Spawn an async task to send a heartbeat after setup
tokio::spawn(async move {
let _ = handle.push_high_priority(b"ping".to_vec()).await;
});
}
fn before_send(&self, _frame: &mut Self::Frame, _ctx: &mut ConnectionContext) {}
fn on_command_end(&self, _ctx: &mut ConnectionContext) {}
}
let app = WireframeApp::new().with_protocol(MySqlProtocolImpl);
The [SessionRegistry
] stores weak references to [PushHandle
]s for
active connections. Background tasks can look up a handle by [ConnectionId
]
to send frames asynchronously without keeping the connection alive. Entries are
pruned on lookup and when calling active_handles()
. DashMap::retain
holds
per-bucket write locks while collecting, so heavy traffic may experience
contention. Invoke prune()
from a maintenance task when only removal of dead
entries is required, without collecting handles.
use wireframe::{
session::{ConnectionId, SessionRegistry},
push::PushHandle,
ConnectionContext,
};
let registry: SessionRegistry<MyFrame> = SessionRegistry::default();
// inside a `WireframeProtocol` implementation
fn on_connection_setup(&self, handle: PushHandle<MyFrame>, _ctx: &mut ConnectionContext) {
let id = ConnectionId::new(42);
registry.insert(id, &handle);
}
Extractors are types that implement FromMessageRequest
. When a handler lists
an extractor as a parameter, wireframe
automatically constructs it using the
incoming [MessageRequest
] and remaining [Payload
]. Built‑in extractors
like Message<T>
, SharedState<T>
and ConnectionInfo
decode the payload,
access app state or expose peer information.
Custom extractors let you centralize parsing and validation logic that would otherwise be duplicated across handlers. A session token parser, for example, can verify the token before any route-specific code executes Design Guide: Data Extraction and Type Safety.
use wireframe::extractor::{ConnectionInfo, FromMessageRequest, MessageRequest, Payload};
pub struct SessionToken(String);
impl FromMessageRequest for SessionToken {
type Error = std::convert::Infallible;
fn from_message_request(
_req: &MessageRequest,
payload: &mut Payload<'_>,
) -> Result<Self, Self::Error> {
let len = payload.as_ref()[0] as usize;
let token = std::str::from_utf8(&payload.as_ref()[1..=len]).unwrap().to_string();
payload.advance(1 + len);
Ok(Self(token))
}
}
Custom extractors integrate seamlessly with other parameters:
async fn handle_ping(token: SessionToken, info: ConnectionInfo) {
println!("{} from {:?}", token.0, info.peer_addr());
}
Middleware allows inspecting or modifying requests and responses. The from_fn
helper builds middleware from an async function or closure:
use wireframe::middleware::from_fn;
let logging = from_fn(|req, next| async move {
tracing::info!("received request: {:?}", req);
let res = next.call(req).await?;
tracing::info!("sending response: {:?}", res);
Ok(res)
});
Example programs are available in the examples/
directory:
echo.rs
— minimal echo server using routingping_pong.rs
— showcases serialization and middleware in a ping/pong protocol. See examples/ping_pong.md for a detailed overview.packet_enum.rs
— shows packet type discrimination with a bincode enum and a frame containing container types likeHashMap
andVec
.
Run an example with Cargo:
cargo run --example echo
Try the echo server with netcat:
$ cargo run --example echo
# in another terminal
$ printf '\x00\x00\x00\x00\x01\x00\x00\x00' | nc 127.0.0.1 7878 | xxd
Try the ping‑pong server with netcat:
$ cargo run --example ping_pong
# in another terminal
$ printf '\x00\x00\x00\x08\x01\x00\x00\x00\x2a\x00\x00\x00' | nc 127.0.0.1 7878 | xxd
Connection handling now processes frames and routes messages. Although the server is still experimental, it now compiles in release mode for evaluation or production use.
Development priorities are tracked in docs/roadmap.md. Key tasks include building the Actix‑inspired API, implementing middleware and extractor traits, and providing example applications【F:docs/roadmap.md†L1-L24】.
Wireframe is distributed under the terms of the ISC licence. See LICENSE for details.