docs(research): add iroh suite deep-dive references for iroh, irpc, iroh-blobs, iroh-gossip, iroh-live, and iroh-docs

This commit is contained in:
2026-06-10 12:34:30 +00:00
parent 6e71d1f306
commit 5bb5e1064c
49 changed files with 9923 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
# irpc: Overview and Architecture
## What is irpc?
`irpc` is a **streaming RPC system** built for [iroh](https://docs.rs/iroh) and [noq](https://docs.rs/noq) (QUIC-based transports). It provides a framework for defining RPC protocols in Rust that work identically whether the communication is **in-process** (via tokio channels) or **cross-process/cross-network** (via QUIC streams).
**Key design goals:**
1. **Zero-overhead local use** — When used in-process, irpc should be as lightweight as raw tokio channels, replacing the common pattern of a giant `enum` over an `mpsc` channel with typed backchannels.
2. **Transparent local/remote abstraction** — The same protocol definition and client API works for both in-process and remote communication.
3. **Streaming-first** — Full support for unary RPC, server streaming, client streaming, and bidirectional streaming interaction patterns.
4. **QUIC-native** — Does not abstract over stream types; directly uses noq/iroh QUIC streams, enabling per-request stream tuning (priorities, etc.).
**Non-goals:**
- Cross-language interop (Rust-to-Rust only)
- Versioning (users must handle this themselves)
- Making remote calls look like local async function calls
- Runtime agnosticism (tokio only)
## Crate Structure
```
irpc/
├── src/lib.rs # Core library: traits, channels, Client, RPC module
├── src/util.rs # Varint utilities, noq endpoint setup helpers
├── src/tests.rs # Channel filter/map tests
├── irpc-derive/ # Procedural macro crate (rpc_requests)
├── irpc-iroh/ # Iroh transport integration
├── examples/ # Working examples (storage, compute, derive, local)
└── tests/ # Integration tests (channels, derive)
```
### Features
| Feature | Default | Purpose |
|---|---|---|
| `rpc` | ✅ | Enables remote RPC (noq transport, postcard serialization) |
| `derive` | ✅ | Enables the `#[rpc_requests]` macro |
| `spans` | ✅ | Preserves tracing spans across message passing |
| `stream` | ✅ | Enables `into_stream()` on mpsc receivers |
| `noq_endpoint_setup` | ✅ | Utilities to create noq endpoints (testing, localhost) |
| `varint-util` | ❌ | Varint read/write utilities without full RPC |
## High-Level Architecture
```
┌─────────────────────────────────────────────────────────┐
│ Application │
│ │
│ ┌──────────┐ ┌───────────┐ ┌───────────┐ │
│ │ Client │─────│ Protocol │─────│ Actor/ │ │
│ │<S> │ │ Enum (S) │ │ Handler │ │
│ └────┬─────┘ └───────────┘ └─────┬─────┘ │
│ │ │ │
│ ┌────▼─────────────────────────────────────▼─────┐ │
│ │ WithChannels<I, S> │ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌─────┐ │ │
│ │ │ inner │ │ tx │ │ rx │ │span │ │ │
│ │ │ (I) │ │(Sender)│ │(Recv) │ │ │ │ │
│ │ └────────┘ └────────┘ └────────┘ └─────┘ │ │
│ └────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────┐ ┌─────────────────────────┐ │
│ │ Local Path │ │ Remote Path (rpc feat) │ │
│ │ tokio::mpsc │ │ noq QUIC streams │ │
│ │ tokio::oneshot │ │ postcard serialization │ │
│ └────────────────────┘ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
### Core Flow
1. **Define a protocol** — An enum where each variant represents an RPC method, annotated with `#[rpc(tx=..., rx=...)]`.
2. **The `rpc_requests` macro** generates:
- `Channels<S>` impl for each request type
- A message enum wrapping each request in `WithChannels<I, S>`
- `Service` and `RemoteService` trait implementations
- `From` conversions between request types, protocol enum, and message enum
3. **Client sends messages**`Client<S>` either sends over a local `mpsc` channel or serializes and sends over a QUIC stream.
4. **Actor/handler processes messages** — Matches on the message enum, extracts `WithChannels { inner, tx, rx, .. }`, and uses `tx`/`rx` to communicate back.
## Dependency Graph
```
irpc (core)
├── serde (always)
├── tokio (sync, macros)
├── tokio-util
├── n0-error
├── n0-future
├── postcard (rpc feature)
├── noq (rpc feature)
├── smallvec (rpc feature)
├── tracing (spans feature)
└── irpc-derive (derive feature)
irpc-iroh
├── irpc
├── iroh
├── iroh-base
├── postcard
└── n0-error, n0-future, tokio, tracing, serde
```
## License
Dual-licensed: Apache-2.0 OR MIT

View File

@@ -0,0 +1,239 @@
# irpc: Key Types and Traits
## Core Traits
### `RpcMessage`
```rust
pub trait RpcMessage: Debug + Serialize + DeserializeOwned + Send + Sync + Unpin + 'static {}
```
A blanket trait implemented for all types that satisfy the bounds. Every message sent through irpc (both local and remote) must implement this. The `Serialize + DeserializeOwned` requirement exists even without the `rpc` feature because the same protocol definition should work in both modes.
### `Service`
```rust
pub trait Service: Serialize + DeserializeOwned + Send + Sync + Debug + 'static {
type Message: Send + Unpin + 'static;
}
```
Implemented on the **protocol enum** (e.g., `StorageProtocol`). The `Message` associated type is the **message enum** — an enum with identical variant names but whose single field is `WithChannels<InnerType, Self>`.
The `Service` trait acts as a **scope** for channel type definitions, allowing the same inner request type to be used with multiple services.
### `Channels<S>`
```rust
pub trait Channels<S: Service>: Send + 'static {
type Tx: Sender;
type Rx: Receiver;
}
```
Implemented on each **request type** (e.g., `Get`, `Set`). Specifies what kind of channels accompany that request when sent through service `S`. The `Tx` type is the response channel (server → client); the `Rx` type is the update channel (client → server).
### `Sender` and `Receiver`
```rust
pub trait Sender: Debug + Sealed {}
pub trait Receiver: Debug + Sealed {}
```
Sealed marker traits. Only the types in `irpc::channel` implement these: `oneshot::Sender`, `oneshot::Receiver`, `mpsc::Sender`, `mpsc::Receiver`, `NoSender`, `NoReceiver`.
### `RemoteService` (rpc feature)
```rust
pub trait RemoteService: Service + Sized {
fn with_remote_channels(self, rx: noq::RecvStream, tx: noq::SendStream) -> Self::Message;
fn remote_handler(local_sender: LocalSender<Self>) -> Handler<Self> {
// Default: convert deserialized protocol enum + streams → Message, send to local sender
}
}
```
Implemented on the protocol enum. Maps a deserialized protocol variant + a pair of QUIC streams into a `WithChannels` message, which is then forwarded to the local actor.
### `RemoteConnection` (rpc feature)
```rust
pub trait RemoteConnection: Send + Sync + Debug + 'static {
fn clone_boxed(&self) -> Box<dyn RemoteConnection>;
fn open_bi(&self) -> BoxFuture<Result<(noq::SendStream, noq::RecvStream), RequestError>>;
fn zero_rtt_accepted(&self) -> BoxFuture<bool>;
}
```
Abstraction over how to open a bidirectional QUIC stream. Implemented for:
- `noq::Connection` — direct noq connection
- `NoqLazyRemoteConnection` — lazy connection that caches the underlying QUIC connection
- `IrohRemoteConnection` — iroh connection (in `irpc-iroh`)
- `IrohLazyRemoteConnection` — lazy iroh connection (in `irpc-iroh`)
- `IrohZrttRemoteConnection` — 0-RTT iroh connection (in `irpc-iroh`)
## Key Structs
### `WithChannels<I, S>`
```rust
pub struct WithChannels<I: Channels<S>, S: Service> {
pub inner: I,
pub tx: <I as Channels<S>>::Tx,
pub rx: <I as Channels<S>>::Rx,
#[cfg(feature = "spans")]
pub span: tracing::Span,
}
```
The central message wrapper. Wraps a request type `I` with its typed channels for service `S`. Implements `Deref` to `I` for convenient field access.
**Construction** via tuple conversions:
- `(inner, tx, rx)` → full channels
- `(inner, tx)` → when `Rx = NoReceiver` (most common for RPC/server-streaming)
- `(inner,)` → when `Tx = NoSender, Rx = NoReceiver` (notify)
### `Client<S>`
```rust
#[derive(Debug)]
pub struct Client<S: Service>(ClientInner<S::Message>, PhantomData<S>);
```
The primary client type. Generic over a service `S`. Can be either local or remote.
**Construction:**
- `Client::local(mpsc_sender)` — from a tokio mpsc sender
- `Client::noq(endpoint, addr)` — from a noq endpoint + address (rpc feature)
- `Client::boxed(remote_connection)` — from any `RemoteConnection` impl
**Key methods** (all handle both local and remote transparently):
| Method | Pattern | Tx Type | Rx Type |
|---|---|---|---|
| `rpc()` | Unary RPC | `oneshot::Sender<Res>` | `NoReceiver` |
| `server_streaming()` | Server streaming | `mpsc::Sender<Res>` | `NoReceiver` |
| `client_streaming()` | Client streaming | `oneshot::Sender<Res>` | `mpsc::Receiver<Update>` |
| `bidi_streaming()` | Bidirectional | `mpsc::Sender<Res>` | `mpsc::Receiver<Update>` |
| `notify()` | Fire-and-forget | `NoSender` | `NoReceiver` |
| `rpc_0rtt()` | 0-RTT unary | `oneshot::Sender<Res>` | `NoReceiver` |
| `server_streaming_0rtt()` | 0-RTT server streaming | `mpsc::Sender<Res>` | `NoReceiver` |
| `notify_0rtt()` | 0-RTT fire-and-forget | `NoSender` | `NoReceiver` |
Each method creates the appropriate channel pair, wraps the message into `WithChannels`, and sends it.
### `LocalSender<S>`
```rust
#[repr(transparent)]
pub struct LocalSender<S: Service>(crate::channel::mpsc::Sender<S::Message>);
```
A thin wrapper around `mpsc::Sender<S::Message>` for sending messages to a local actor. Provides:
```rust
impl<S: Service> LocalSender<S> {
pub fn send<T>(&self, value: impl Into<WithChannels<T, S>>) -> impl Future<Output = Result<(), SendError>>
where
T: Channels<S>,
S::Message: From<WithChannels<T, S>>;
pub fn send_raw(&self, value: S::Message) -> impl Future<Output = Result<(), SendError>>;
}
```
### `Request<L, R>`
```rust
pub enum Request<L, R> {
Local(L),
Remote(R),
}
```
A generic enum distinguishing local vs remote requests. `Client::request()` returns `Request<LocalSender<S>, RemoteSender<S>>`.
### `RemoteSender<S>` (rpc feature)
```rust
pub struct RemoteSender<S>(noq::SendStream, noq::RecvStream, PhantomData<S>);
```
Holds a QUIC stream pair after opening a bidirectional stream. The `write()` method serializes the protocol message with postcard + varint length prefix and sends it over the send stream.
### `Handler<R>` (rpc feature)
```rust
pub type Handler<R> = Arc<
dyn Fn(R, noq::RecvStream, noq::SendStream) -> BoxFuture<Result<(), SendError>>
+ Send + Sync + 'static,
>;
```
A shared handler function that processes incoming remote requests. Typically created via `Protocol::remote_handler(local_sender)`.
## Error Types
### `RequestError`
```rust
pub enum RequestError {
Connect { source: noq::ConnectError }, // Connection establishment failed
Connection { source: noq::ConnectionError }, // Stream open failed
Other { source: AnyError }, // Generic error for non-noq transports
}
```
### `SendError` (in `channel` module)
```rust
pub enum SendError {
ReceiverClosed, // Local: receiver dropped
MaxMessageSizeExceeded, // Remote: message > 16 MiB
Io { source: io::Error }, // Remote: network/serialization error
}
```
### `RecvError` (oneshot and mpsc variants)
```rust
// oneshot::RecvError
pub enum RecvError {
SenderClosed, // Local: sender dropped
MaxMessageSizeExceeded, // Remote: message > 16 MiB
Io { source: io::Error }, // Remote: network/deserialization error
}
// mpsc::RecvError
pub enum RecvError {
MaxMessageSizeExceeded, // Remote: message > 16 MiB
Io { source: io::Error }, // Remote: network/deserialization error
}
```
Note: `mpsc::RecvError` does **not** have `SenderClosed` — mpsc receivers return `Ok(None)` when the sender is dropped.
### `WriteError` (rpc feature)
```rust
pub enum WriteError {
Noq { source: noq::WriteError }, // QUIC stream write error
MaxMessageSizeExceeded, // Message > 16 MiB
Io { source: io::Error }, // Serialization error
}
```
### `Error` (top-level umbrella)
```rust
pub enum Error {
Request { source: RequestError },
Send { source: SendError },
MpscRecv { source: mpsc::RecvError },
OneshotRecv { source: oneshot::RecvError },
Write { source: rpc::WriteError }, // rpc feature only
}
```
All error types implement `From<Error>` for `io::Error`, allowing integration with `?` in `io::Result` contexts.

View File

@@ -0,0 +1,168 @@
# irpc: Channel System
The channel system is the heart of irpc. It provides channel types that abstract over local (tokio) and remote (QUIC stream) communication, with the same API surface regardless of transport.
## Channel Kinds
irpc provides three kinds of channels, each with local and remote variants:
### Oneshot Channels (`channel::oneshot`)
Single-value, single-use channels for RPC responses.
| Type | Local Backend | Remote Backend |
|---|---|---|
| `oneshot::Sender<T>` | `tokio::sync::oneshot::Sender` | `BoxedSender<T>` (FnOnce over QUIC write) |
| `oneshot::Receiver<T>` | `FusedOneshotReceiver<T>` | `BoxedReceiver<T>` (boxed future over QUIC read) |
**Creation:** `oneshot::channel::<T>()` returns `(Sender<T>, Receiver<T>)`
**Sender behavior:**
- Local: `send(value)` is synchronous-ish, fails only if receiver dropped
- Remote: `send(value)` is async — serializes with postcard, length-prefixes with varint, writes to QUIC stream
**Receiver behavior:**
- Implements `Future<Output = Result<T, RecvError>>`
- Local: resolves to the value or `SenderClosed` error
- Remote: reads varint length prefix, reads that many bytes, deserializes with postcard
**Filtering/Mapping** (on `Sender<T>` where `T: Send + Sync + 'static`):
```rust
sender.with_filter(|v| v > 0) // Drop messages failing predicate
sender.with_map(|v: U| v.into()) // Transform before sending
sender.with_filter_map(|v| ...) // Combined filter + map
```
### MPSC Channels (`channel::mpsc`)
Multi-producer, single-consumer streaming channels for server-streaming, client-streaming, and bidirectional patterns.
| Type | Local Backend | Remote Backend |
|---|---|---|
| `mpsc::Sender<T>` | `tokio::sync::mpsc::Sender` | `Arc<DynSender<T>>` (NoqSender) |
| `mpsc::Receiver<T>` | `tokio::sync::mpsc::Receiver` | `Box<dyn DynReceiver<T>>` (NoqReceiver) |
**Creation:** `mpsc::channel::<T>(buffer)` returns `(Sender<T>, Receiver<T>)`
**Sender behavior:**
- `send(value).await` — sends, yielding if full (remote: serializes + writes to stream)
- `try_send(value).await` — non-blocking attempt; returns `Ok(false)` if would block
- `closed().await` — waits until all receivers are dropped
- `is_rpc()` — returns `true` for remote senders
**Receiver behavior:**
- `recv().await``Result<Option<T>, RecvError>``None` means sender closed/cleanly finished
- `filter(pred)`, `map(fn)`, `filter_map(fn)` — chainable transformations
- `into_stream()` (with `stream` feature) — converts to `Stream<Item = Result<T, RecvError>>`
**Cloning:** `mpsc::Sender<T>` implements `Clone`. Local senders clone the underlying tokio sender; remote senders clone the `Arc`.
### None Channels (`channel::none`)
Placeholder channels for when no communication is needed.
```rust
pub struct NoSender; // Implements Sender, does nothing
pub struct NoReceiver; // Implements Receiver, does nothing
```
Used as defaults when `#[rpc(tx=...)]` or `#[rpc(rx=...)]` are omitted.
## Remote Channel Internals
### NoqSender<T>
```rust
struct NoqSender<T>(tokio::sync::Mutex<NoqSenderState<T>>);
enum NoqSenderState<T> {
Open(NoqSenderInner<T>),
Closed,
}
struct NoqSenderInner<T> {
send: noq::SendStream,
buffer: SmallVec<[u8; 128]>, // Stack-allocated buffer for small messages
_marker: PhantomData<T>,
}
```
Key behaviors:
- **Mutex-protected state**: The inner state is `Mutex`-protected because `DynSender::send()` takes `&self`. When a send fails, the state transitions to `Closed` and all subsequent sends return `BrokenPipe`.
- **Buffer reuse**: Uses `SmallVec<[u8; 128]>` to avoid heap allocation for messages that serialize to ≤128 bytes.
- **Serialization**: Each message is postcard-serialized with a varint length prefix. If serialization exceeds `MAX_MESSAGE_SIZE` (16 MiB), the stream is reset with error code `ERROR_CODE_MAX_MESSAGE_SIZE_EXCEEDED` (1).
- **Serialization errors**: If postcard serialization fails, the stream is reset with `ERROR_CODE_INVALID_POSTCARD` (2).
### NoqReceiver<T>
```rust
struct NoqReceiver<T> {
recv: noq::RecvStream,
_marker: PhantomData<T>,
}
```
Reads a varint length prefix, allocates a buffer of that size, reads the data, and deserializes with postcard. If the length exceeds `MAX_MESSAGE_SIZE`, stops the stream with the appropriate error code.
### Oneshot Remote Sender
For `oneshot::Sender<T>` over QUIC, the sender is a `BoxedSender<T>` — a `Box<dyn FnOnce(T) -> BoxFuture<Result<(), SendError>>>`. This captures the `noq::SendStream` and on invocation:
1. Computes `postcard::experimental::serialized_size(&value)`
2. Checks against `MAX_MESSAGE_SIZE`
3. Writes length-prefixed postcard data to the stream
### Oneshot Remote Receiver
For `oneshot::Receiver<T>` over QUIC, the receiver is constructed from a `noq::RecvStream`:
1. Reads a varint length prefix
2. Reads that many bytes
3. Deserializes with postcard
4. Returns the value
## Channel Conversion Table
When a QUIC stream pair `(SendStream, RecvStream)` is received for a request:
| Channel Kind | `Tx` (SendStream →) | `Rx` (RecvStream →) |
|---|---|---|
| `oneshot::Sender<T>` | Serialize + write, then finish | Read length-prefixed data |
| `mpsc::Sender<T>` | Repeatedly serialize + write | N/A |
| `oneshot::Receiver<T>` | N/A | Read single length-prefixed value |
| `mpsc::Receiver<T>` | N/A | Repeatedly read length-prefixed values |
| `NoSender` | Drop the stream | N/A |
| `NoReceiver` | N/A | Drop the stream |
The `From<noq::RecvStream>` and `From<noq::SendStream>` impls handle these conversions automatically based on the target type.
## DynSender and DynReceiver Traits
The `mpsc` module exposes traits for dynamic dispatch:
```rust
pub trait DynSender<T>: Debug + Send + Sync + 'static {
fn send(&self, value: T) -> Pin<Box<dyn Future<Output = Result<(), SendError>> + Send + '_>>;
fn try_send(&self, value: T) -> Pin<Box<dyn Future<Output = Result<bool, SendError>> + Send + '_>>;
fn closed(&self) -> Pin<Box<dyn Future<Output = ()> + Send + Sync + '_>>;
fn is_rpc(&self) -> bool;
}
pub trait DynReceiver<T>: Debug + Send + Sync + 'static {
fn recv(&mut self) -> Pin<Box<dyn Future<Output = Result<Option<T>, RecvError>> + Send + Sync + '_>>;
}
```
These enable boxing of remote senders/receivers while keeping the local variants unboxed for zero overhead.
## FusedOneshotReceiver
A thin wrapper around `tokio::sync::oneshot::Receiver` that prevents panics when polling an already-completed receiver. It tracks completion state and returns `Poll::Pending` indefinitely after resolution, matching the `FusedFuture` pattern.
## Cancellation Safety
For remote `mpsc::Sender`:
- If a `send()` future is dropped before completion, the underlying QUIC stream is closed.
- All clones of the sender will receive `SendError::Io(BrokenPipe)` on subsequent send attempts.
- This is documented behavior: **always poll send futures to completion if you want to reuse the sender**.
For remote `oneshot::Sender`:
- Since it's `FnOnce`, dropping the future before sending simply means the value is never sent. The receiver will get `SenderClosed`.

View File

@@ -0,0 +1,272 @@
# irpc: Protocol and Message Flow
## Wire Protocol
When the `rpc` feature is enabled, irpc uses the following wire format over QUIC streams:
### Message Framing
Every message on the wire is **length-prefixed using postcard varints** (LEB128 encoding):
```
┌─────────────────┬──────────────────────┐
│ varint length │ postcard-serialized │
│ (1-10 bytes) │ message data │
└─────────────────┴──────────────────────┘
```
- **Length prefix**: LEB128 varint encoding of `u64` length. Each byte uses 7 bits for the value and the MSB as a continuation bit. Maximum 10 bytes for a full `u64`.
- **Payload**: Postcard-encoded (compact, no-schema serde format) Rust message.
### Maximum Message Size
`MAX_MESSAGE_SIZE = 16 MiB (16 * 1024 * 1024)`
Messages exceeding this limit are rejected:
- **Send side**: The sender checks `postcard::experimental::serialized_size()` before sending. If exceeded, the stream is reset with error code `1` (`ERROR_CODE_MAX_MESSAGE_SIZE_EXCEEDED`).
- **Receive side**: After reading the varint length, if it exceeds `MAX_MESSAGE_SIZE`, the stream is stopped with error code `1`.
### Error Codes
| Code | Constant | Meaning |
|---|---|---|
| `1` | `ERROR_CODE_MAX_MESSAGE_SIZE_EXCEEDED` | Message larger than 16 MiB |
| `2` | `ERROR_CODE_INVALID_POSTCARD` | Postcard serialization failed |
These are used as QUIC stream reset/stop error codes.
### Connection Closure
Error code `0` on the QUIC connection means "clean close" — the remote side intentionally shut down. This is distinguished from actual errors.
## Message Flow: Local Path
```
Client Actor
│ │
│ Client::rpc(Get { key: "x" }) │
│ │
│ 1. Create oneshot channel pair │
│ (tx, rx) = oneshot::channel() │
│ │
│ 2. Wrap into WithChannels │
│ WithChannels { │
│ inner: Get { key: "x" }, │
│ tx: oneshot::Sender<Res>, │
│ rx: NoReceiver, │
│ span: current_span, │
│ } │
│ │
│ 3. Convert to Message enum │
│ StorageMessage::Get(wc) │
│ │
│ 4. Send over mpsc channel ────────►│
│ │
│ 5. Await on oneshot receiver │
│ rx.await ◄─────────────────────│
│ tx.send(res)│
│ │
│ Result: res │
```
For bidirectional streaming:
```
Client Actor
│ │
│ Client::bidi_streaming(Sum, 4, 4) │
│ │
│ 1. Create channel pairs │
│ (update_tx, update_rx) │
│ (res_tx, res_rx) │
│ │
│ 2. WithChannels { │
│ inner: Sum, │
│ tx: mpsc::Sender<i64>, │
│ rx: mpsc::Receiver<i64>, │
│ } │
│ │
│ 3. Send message ──────────────────►│
│ │
│ 4. Use update_tx.send(val) ───────►│
│ Use res_rx.recv() ◄─────────│
│ res_tx.send(val)
│ │
```
## Message Flow: Remote Path
```
Client Server
│ │
│ Client::rpc(Get { key: "x" }) │
│ │
│ 1. open_bi() → (SendStream, RecvStream)
│ │
│ 2. Serialize StorageProtocol::Get(Get { key: "x" })
│ with postcard + varint prefix │
│ │
│ 3. Write to SendStream ───────────►│
│ │
│ │ 4. Accept bi stream
│ │ 5. Read varint + deserialize
│ │ 6. RemoteService::with_remote_channels()
│ │ → WithChannels { inner, tx, rx }
│ │ 7. Forward to local actor
│ │
│ │ Actor processes, sends response
│ │ on the SendStream (which is the
│ │ oneshot::Sender<T> backed by QUIC)
│ │
│ 8. Read from RecvStream ◄──────────│
│ 9. Deserialize response │
│ │
│ Result: res │
```
For bidirectional streaming over remote:
```
Client Server
│ │
│ Client::bidi_streaming(Sum, 4, 4) │
│ │
│ open_bi() → (SendStream, RecvStream)
│ │
│ SendStream → mpsc::Sender<Update> │ RecvStream → mpsc::Receiver<Update>
│ RecvStream → oneshot::Receiver<Res>│ SendStream → oneshot::Sender<Res>
│ (or mpsc::Receiver<Res> for │
│ server-streaming with mpsc tx) │
│ │
│ The initial message is sent on │
│ SendStream with varint prefix. │
│ │
│ Subsequent updates are sent on │
│ the same SendStream as varint- │
│ prefixed postcard messages. │
│ │
│ The response stream is read from │
│ the RecvStream as varint-prefixed │
│ postcard messages. │
```
## Stream Direction Convention
In irpc's QUIC stream model:
- **Client opens** a bidirectional stream (`open_bi()`)
- **SendStream** (client → server): carries the initial request message, plus any client-streaming updates
- **RecvStream** (server → client): carries the response(s) from the server
The `RemoteService::with_remote_channels()` method decides how to map streams to channels:
```rust
// For a simple RPC (tx=oneshot, rx=none):
fn with_remote_channels(self, rx: RecvStream, tx: SendStream) -> Self::Message {
// rx stream is unused (NoReceiver), tx carries response
WithChannels::from((msg, tx.into(), rx.into()))
// tx → oneshot::Sender<Res> (or mpsc::Sender<Res>)
// rx → NoReceiver
}
```
Wait — looking at the actual implementation more carefully:
The `RemoteService::with_remote_channels` method takes `(self, rx: RecvStream, tx: SendStream)` where:
- `rx` = the `RecvStream` from the bidirectional stream (client reads from this)
- `tx` = the `SendStream` from the bidirectional stream (client writes to this)
But for the **server side**, the `RecvStream` is what the server reads from (client updates), and `SendStream` is what the server writes to (server responses).
In the `with_remote_channels` generated code:
```rust
// For rpc(tx=oneshot::Sender<Res>, rx=mpsc::Receiver<Update>):
WithChannels::from((msg, tx.into(), rx.into()))
// tx (SendStream) → oneshot::Sender<Res> — server writes response
// rx (RecvStream) → mpsc::Receiver<Update> — server reads client updates
```
So the naming in `with_remote_channels` is from the **server's perspective**:
- `rx` parameter = RecvStream = what server receives (client → server updates)
- `tx` parameter = SendStream = what server sends (server → client responses)
## Connection Management
### NoqLazyRemoteConnection
```rust
struct NoqLazyRemoteConnection(Arc<NoqLazyRemoteConnectionInner>);
struct NoqLazyRemoteConnectionInner {
endpoint: noq::Endpoint,
addr: SocketAddr,
connection: Mutex<Option<noq::Connection>>,
}
```
- Lazily establishes connection on first use
- Caches the `noq::Connection` inside a `Mutex<Option<...>>`
- On `open_bi()`: if cached connection exists, tries to reuse it; if it fails, clears cache and reconnects once
- Thread-safe via `Arc` + `Mutex`
### IrohLazyRemoteConnection (irpc-iroh)
Same pattern but for iroh endpoints, with an additional `alpn` field for protocol identification.
### 0-RTT Support
irpc supports QUIC 0-RTT for reduced latency on reconnections:
- `Client::rpc_0rtt()` — sends request immediately with 0-RTT data; if the server rejects 0-RTT, re-sends
- `Client::server_streaming_0rtt()` — same for server-streaming
- `Client::notify_0rtt()` — same for fire-and-forget
The 0-RTT flow:
1. Client serializes the message into a buffer (`prepare_write()`)
2. Sends the buffer over a 0-RTT connection
3. Awaits `zero_rtt_accepted()` to check if 0-RTT was accepted
4. If not accepted, opens a new connection and re-sends the same buffer
`RemoteConnection::zero_rtt_accepted()` returns `true` for regular connections and for lazy connections. For `IrohZrttRemoteConnection`, it checks the actual 0-RTT status via `handshake_completed()`.
## Server-Side: Accepting Connections
### Using noq (direct QUIC)
```rust
irpc::rpc::listen(endpoint, handler)
```
This function:
1. Loops on `endpoint.accept()` to accept incoming connections
2. For each connection, spawns a task running `handle_connection()`
3. `handle_connection()` loops on `read_request_raw()` to read requests from bidirectional streams
4. Each request is deserialized and passed to the `Handler`
### Using iroh
```rust
IrohProtocol::with_sender(local_sender)
```
This creates a `ProtocolHandler` that can be registered with `iroh::protocol::Router`. When a connection arrives, it calls `handle_connection()` from irpc-iroh, which handles the protocol handshake and reads requests.
For 0-RTT support:
```rust
Iroh0RttProtocol::with_sender(local_sender)
```
This implements `ProtocolHandler::on_accepting()` to handle 0-RTT connections.
### Handler Function
```rust
type Handler<R> = Arc<
dyn Fn(R, noq::RecvStream, noq::SendStream) -> BoxFuture<Result<(), SendError>>
+ Send + Sync + 'static,
>;
```
The handler receives:
1. The deserialized protocol message (`R`)
2. The `RecvStream` (for client → server updates)
3. The `SendStream` (for server → client responses)
Typically created via `Protocol::remote_handler(local_sender)`, which converts streams to typed channels and forwards the `WithChannels` message to a local actor.

View File

@@ -0,0 +1,278 @@
# irpc: The rpc_requests Macro
The `#[rpc_requests]` attribute macro is the primary way to define an irpc protocol. It generates the boilerplate for channel typing, message wrapping, and service trait implementations.
## Basic Usage
```rust
use irpc::{channel::{mpsc, oneshot}, rpc_requests, Client, WithChannels};
use serde::{Deserialize, Serialize};
#[rpc_requests(message = ComputeMessage)]
#[derive(Debug, Serialize, Deserialize)]
enum ComputeProtocol {
/// Unary RPC: one request, one response
#[rpc(tx=oneshot::Sender<i64>)]
#[wrap(Multiply)]
Multiply(i64, i64),
/// Bidirectional streaming
#[rpc(tx=mpsc::Sender<i64>, rx=mpsc::Receiver<i64>)]
#[wrap(Sum)]
Sum,
}
```
This single macro invocation generates:
1. **Wrapper structs** (from `#[wrap]`): `Multiply` and `Sum` struct types
2. **`Channels<ComputeProtocol>` impls**: For each variant's inner type, specifying `Tx` and `Rx`
3. **`Service` impl**: `impl Service for ComputeProtocol { type Message = ComputeMessage; }`
4. **`RemoteService` impl** (rpc feature): Maps protocol variants + QUIC streams to messages
5. **`ComputeMessage` enum**: Wraps each request in `WithChannels`
6. **`From` conversions**: Between inner types, `ComputeProtocol`, and `ComputeMessage`
## Macro Arguments
### Top-level (on the enum)
| Argument | Required | Description |
|---|---|---|
| `message = Name` | Recommended | Name of the generated message enum. Also generates `Service` and `RemoteService` impls. |
| `alias = "Suffix"` | Optional | Generates type aliases like `MultiplyMsg = WithChannels<Multiply, ComputeProtocol>` |
| `rpc_feature = "feat"` | Optional | Feature-gates the `RemoteService` impl with `#[cfg(feature = "feat")]` |
| `no_rpc` | Optional | Skips generating `RemoteService` impl entirely |
| `no_spans` | Optional | Skips span-related code (for use without the `spans` feature) |
### Per-variant
#### `#[rpc(tx=Type, rx=Type)]`
Specifies channel types for each request:
- `tx` — response channel type (server → client). Defaults to `NoSender`.
- `rx` — update channel type (client → server). Defaults to `NoReceiver`.
Valid types:
- `oneshot::Sender<T>` — single response
- `mpsc::Sender<T>` — streaming response
- `oneshot::Receiver<T>` — not valid as tx (use for rx pattern)
- `mpsc::Receiver<T>` — streaming updates (client → server)
- `NoSender` / `NoReceiver` — no channel in that direction
#### `#[wrap(TypeName, derive(Traits))]`
Generates a struct from the variant's fields:
- `TypeName` — name of the generated struct
- Optional visibility prefix (e.g., `pub(crate) TypeName`)
- `derive(...)` — additional derive macros beyond the default `Serialize, Deserialize, Debug`
If `#[wrap]` is not used, each variant must have exactly one unnamed field (a named type).
## Generated Code Walkthrough
Given this input:
```rust
#[rpc_requests(message = StoreMessage)]
#[derive(Debug, Serialize, Deserialize)]
enum StoreProtocol {
#[rpc(tx=oneshot::Sender<String>)]
#[wrap(GetRequest, derive(Clone))]
Get(String),
#[rpc(tx=oneshot::Sender<()>)]
#[wrap(SetRequest)]
Set { key: String, value: String },
}
```
The macro generates:
### 1. Wrapper Structs
```rust
#[derive(Debug, Serialize, Deserialize, Clone)]
pub GetRequest(pub String);
#[derive(Debug, Serialize, Deserialize)]
pub SetRequest { pub key: String, pub value: String }
```
The variants are rewritten to use these:
```rust
enum StoreProtocol {
Get(GetRequest),
Set(SetRequest),
}
```
### 2. Channels Implementations
```rust
impl Channels<StoreProtocol> for GetRequest {
type Tx = oneshot::Sender<String>;
type Rx = NoReceiver;
}
impl Channels<StoreProtocol> for SetRequest {
type Tx = oneshot::Sender<()>;
type Rx = NoReceiver;
}
```
### 3. Message Enum
```rust
#[doc = "Message enum for [`StoreProtocol`]"]
#[allow(missing_docs)]
#[derive(Debug)]
pub enum StoreMessage {
Get(WithChannels<GetRequest, StoreProtocol>),
Set(WithChannels<SetRequest, StoreProtocol>),
}
```
### 4. Service Implementation
```rust
impl Service for StoreProtocol {
type Message = StoreMessage;
}
```
### 5. RemoteService Implementation (rpc feature)
```rust
impl RemoteService for StoreProtocol {
fn with_remote_channels(
self,
rx: noq::RecvStream,
tx: noq::SendStream,
) -> Self::Message {
match self {
StoreProtocol::Get(msg) => {
StoreMessage::from(WithChannels::from((msg, tx, rx)))
}
StoreProtocol::Set(msg) => {
StoreMessage::from(WithChannels::from((msg, tx, rx)))
}
}
}
}
```
### 6. From Conversions
```rust
// Inner type → Protocol enum
impl From<GetRequest> for StoreProtocol { ... }
impl From<SetRequest> for StoreProtocol { ... }
// WithChannels → Message enum
impl From<WithChannels<GetRequest, StoreProtocol>> for StoreMessage { ... }
impl From<WithChannels<SetRequest, StoreProtocol>> for StoreMessage { ... }
```
### 7. parent_span Method (spans feature)
```rust
impl StoreMessage {
pub fn parent_span(&self) -> tracing::Span {
let span = match self {
StoreMessage::Get(inner) => inner.parent_span_opt(),
StoreMessage::Set(inner) => inner.parent_span_opt(),
};
span.cloned().unwrap_or_else(|| tracing::Span::current())
}
}
```
## Interaction Pattern Mapping
The `#[rpc]` attribute maps directly to gRPC-like patterns:
| Pattern | `tx` type | `rx` type | Example |
|---|---|---|---|
| **Unary RPC** | `oneshot::Sender<R>` | `NoReceiver` | Get by key, return value |
| **Server streaming** | `mpsc::Sender<R>` | `NoReceiver` | List all items |
| **Client streaming** | `oneshot::Sender<R>` | `mpsc::Receiver<U>` | Upload items, get count |
| **Bidirectional** | `mpsc::Sender<R>` | `mpsc::Receiver<U>` | Chat, live updates |
| **Notify (fire & forget)** | `NoSender` | `NoReceiver` | Log event |
## Client Methods Generated by Patterns
The `Client<S>` methods correspond to channel types:
```rust
// Unary RPC: tx=oneshot::Sender<Res>, rx=NoReceiver
client.rpc(Get { key: "x" }).await // → Result<Res>
// Server streaming: tx=mpsc::Sender<Res>, rx=NoReceiver
client.server_streaming(List, 16).await // → Result<mpsc::Receiver<Res>>
// Client streaming: tx=oneshot::Sender<Res>, rx=mpsc::Receiver<Update>
client.client_streaming(SetMany, 4).await // → Result<(mpsc::Sender<Update>, oneshot::Receiver<Res>)>
// Bidirectional: tx=mpsc::Sender<Res>, rx=mpsc::Receiver<Update>
client.bidi_streaming(Sum, 4, 4).await // → Result<(mpsc::Sender<Update>, mpsc::Receiver<Res>)>
// Notify: tx=NoSender, rx=NoReceiver
client.notify(Log { msg: "hi" }).await // → Result<()>
```
## Manual Protocol Definition (Without Macro)
You can define protocols manually instead of using the macro:
```rust
use irpc::{channel::{mpsc, none::NoReceiver, oneshot}, Channels, Service, WithChannels};
use serde::{Deserialize, Serialize};
// 1. Define request types
#[derive(Debug, Serialize, Deserialize)]
struct Get { key: String }
#[derive(Debug, Serialize, Deserialize)]
struct Set { key: String, value: String }
// 2. Implement Channels for each type
impl Channels<StorageProtocol> for Get {
type Tx = oneshot::Sender<Option<String>>;
type Rx = NoReceiver;
}
impl Channels<StorageProtocol> for Set {
type Tx = oneshot::Sender<()>;
type Rx = NoReceiver;
}
// 3. Define protocol enum
#[derive(derive_more::From, Serialize, Deserialize, Debug)]
enum StorageProtocol {
Get(Get),
Set(Set),
}
// 4. Define message enum
#[derive(derive_more::From)]
enum StorageMessage {
Get(WithChannels<Get, StorageProtocol>),
Set(WithChannels<Set, StorageProtocol>),
}
// 5. Implement Service
impl Service for StorageProtocol {
type Message = StorageMessage;
}
// 6. Implement RemoteService (rpc feature)
impl RemoteService for StorageProtocol {
fn with_remote_channels(self, rx: noq::RecvStream, tx: noq::SendStream) -> Self::Message {
match self {
StorageProtocol::Get(msg) => WithChannels::from((msg, tx, rx)).into(),
StorageProtocol::Set(msg) => WithChannels::from((msg, tx, rx)).into(),
}
}
}
```
This manual approach gives full control but requires more boilerplate. The macro generates all of this automatically.

View File

@@ -0,0 +1,274 @@
# irpc: RPC Module and Remote Transport
The `rpc` module (enabled by the `rpc` feature) contains all cross-process RPC functionality: QUIC stream handling, connection management, serialization, and server-side request processing.
## Module Structure
```rust
pub mod rpc {
pub const MAX_MESSAGE_SIZE: u64 = 1024 * 1024 * 16;
pub const ERROR_CODE_MAX_MESSAGE_SIZE_EXCEEDED: u32 = 1;
pub const ERROR_CODE_INVALID_POSTCARD: u32 = 2;
pub enum WriteError { Noq, MaxMessageSizeExceeded, Io }
pub trait RemoteConnection: Send + Sync + Debug + 'static { ... }
pub struct RemoteSender<S>(SendStream, RecvStream, PhantomData<S>);
pub type Handler<R> = Arc<dyn Fn(R, RecvStream, SendStream) -> BoxFuture<Result<(), SendError>> + Send + Sync>;
pub trait RemoteService: Service + Sized { ... }
pub async fn listen<R>(endpoint, handler);
pub async fn handle_connection<R>(connection, handler) -> io::Result<()>;
pub async fn read_request<S: RemoteService>(connection) -> io::Result<Option<S::Message>>;
pub async fn read_request_raw<R>(connection) -> io::Result<Option<(R, RecvStream, SendStream)>>;
}
```
## RemoteConnection Implementations
### NoqLazyRemoteConnection
The default remote connection for noq (QUIC-by-socket-address):
```rust
struct NoqLazyRemoteConnection(Arc<NoqLazyRemoteConnectionInner>);
struct NoqLazyRemoteConnectionInner {
endpoint: noq::Endpoint,
addr: SocketAddr,
connection: Mutex<Option<noq::Connection>>,
}
```
**Behavior:**
- `open_bi()`:
1. Locks the `Mutex<Option<Connection>>`
2. If a cached connection exists, tries `conn.open_bi()`
3. If that fails, clears the cache and establishes a new connection
4. If no cached connection, establishes a new one
5. Returns `(SendStream, RecvStream)` pair
- `zero_rtt_accepted()`: Always returns `true` (noq doesn't have 0-RTT concept in this context)
- `clone_boxed()`: Clones the `Arc`, sharing the same connection cache
### Direct noq::Connection
```rust
impl RemoteConnection for noq::Connection {
fn open_bi(&self) -> BoxFuture<Result<(SendStream, RecvStream), RequestError>> {
// Directly opens a bidirectional stream on the connection
}
fn zero_rtt_accepted(&self) -> BoxFuture<bool> { Box::pin(async { true }) }
}
```
## RemoteSender
```rust
pub struct RemoteSender<S>(noq::SendStream, noq::RecvStream, PhantomData<S>);
```
Created by `Client::request()` when the client is remote. Holds both sides of a QUIC bidirectional stream.
### Key Methods
```rust
impl<S: Service> RemoteSender<S> {
pub fn new(send: SendStream, recv: RecvStream) -> Self;
pub async fn write(self, msg: impl Into<S>) -> Result<(SendStream, RecvStream), WriteError> {
let buf = prepare_write(msg)?;
self.write_raw(&buf).await
}
// Internal: writes pre-serialized buffer
pub(crate) async fn write_raw(self, buf: &[u8]) -> Result<(SendStream, RecvStream), WriteError>;
}
```
The `write()` method:
1. Converts `msg` into the protocol enum `S` via `Into`
2. Checks serialized size against `MAX_MESSAGE_SIZE`
3. Length-prefixes with varint + postcard serialization
4. Writes to the `SendStream`
5. Returns the stream pair (now usable for response channels)
The `write_raw()` method is used for 0-RTT where the message is pre-serialized to allow re-sending without re-serialization.
### prepare_write
```rust
fn prepare_write<S: Service>(msg: impl Into<S>) -> Result<SmallVec<[u8; 128]>, WriteError> {
let msg = msg.into();
if postcard::experimental::serialized_size(&msg)? as u64 > MAX_MESSAGE_SIZE {
return Err(WriteError::MaxMessageSizeExceeded);
}
let mut buf = SmallVec::<[u8; 128]>::new();
buf.write_length_prefixed(&msg)?;
Ok(buf)
}
```
Uses `SmallVec<[u8; 128]>` to avoid heap allocation for small messages.
## Stream-to-Channel Conversions
When a QUIC stream pair is received on the server side, it needs to be converted into typed channels. The `From` implementations handle this:
### SendStream → Channel Tx
```rust
// NoSender: drop the stream
impl From<SendStream> for NoSender { ... }
// Oneshot: serialize and send single value, then done
impl<T: RpcMessage> From<SendStream> for oneshot::Sender<T> { ... }
// MPSC: repeatedly serialize and send values
impl<T: RpcMessage> From<SendStream> for mpsc::Sender<T> { ... }
```
### RecvStream → Channel Rx
```rust
// NoReceiver: drop the stream
impl From<RecvStream> for NoReceiver { ... }
// Oneshot: read single length-prefixed value
impl<T: DeserializeOwned> From<RecvStream> for oneshot::Receiver<T> { ... }
// MPSC: repeatedly read length-prefixed values
impl<T: RpcMessage> From<RecvStream> for mpsc::Receiver<T> { ... }
```
## Server-Side Request Processing
### read_request_raw
```rust
pub async fn read_request_raw<R: DeserializeOwned + 'static>(
connection: &noq::Connection,
) -> io::Result<Option<(R, RecvStream, SendStream)>>
```
1. Calls `connection.accept_bi()` to accept an incoming bidirectional stream
2. If `ApplicationClosed(0)`, returns `Ok(None)` (clean shutdown)
3. Reads a varint length prefix from the `RecvStream`
4. Checks against `MAX_MESSAGE_SIZE`
5. Reads `length` bytes from the stream
6. Deserializes with `postcard::from_bytes::<R>()`
7. Returns `(deserialized_message, RecvStream, SendStream)`
### read_request (typed)
```rust
pub async fn read_request<S: RemoteService>(
connection: &noq::Connection,
) -> io::Result<Option<S::Message>>
```
Calls `read_request_raw()` and then applies `S::with_remote_channels()` to convert the raw protocol message + stream pair into a `WithChannels`-wrapped `Message`.
### handle_connection
```rust
pub async fn handle_connection<R: DeserializeOwned + 'static>(
connection: noq::Connection,
handler: Handler<R>,
) -> io::Result<()>
```
Loops:
1. Calls `read_request_raw()` to get the next request
2. If `None`, returns `Ok(())` (connection closed)
3. Invokes `handler(msg, rx, tx)` to process the request
4. Continues until the connection closes or an error occurs
Each connection is handled in a separate task (spawned by `listen()`).
### listen
```rust
pub async fn listen<R: DeserializeOwned + 'static>(
endpoint: noq::Endpoint,
handler: Handler<R>,
)
```
The top-level server loop:
1. Accepts incoming connections from the `noq::Endpoint`
2. Spawns a task for each connection
3. Each task calls `handle_connection()`
4. Uses a `JoinSet` to manage and clean up completed tasks
## The Handler and Local Forwarding
The typical handler is created by `Protocol::remote_handler(local_sender)`:
```rust
fn remote_handler(local_sender: LocalSender<Self>) -> Handler<Self> {
Arc::new(move |msg, rx, tx| {
let msg = Self::with_remote_channels(msg, rx, tx);
Box::pin(local_sender.send_raw(msg))
})
}
```
This converts the raw (deserialized protocol message, RecvStream, SendStream) tuple into a typed `WithChannels` message and forwards it to the local actor via the mpsc channel. The local actor can then use the typed channels without knowing whether they're local or remote.
## Full Request Lifecycle (Remote)
```
CLIENT SERVER
│ │
│ 1. Client::request() │
│ → open_bi() on connection │
│ │
│ 2. RemoteSender::write(protocol_msg) │
│ → serialize + send on SendStream ────►│
│ │ 3. accept_bi()
│ │ 4. read_request_raw()
│ │ → read varint + data
│ │ → deserialize protocol_msg
│ │
│ │ 5. RemoteService::with_remote_channels()
│ │ → creates WithChannels
│ │ → SendStream → tx channel
│ │ → RecvStream → rx channel
│ │
│ │ 6. handler(msg, rx, tx)
│ │ → local_sender.send_raw(message)
│ │ → message goes to actor
│ │
│ │ 7. Actor processes:
│ │ match message {
│ │ Msg::Get(wc) => {
│ │ let res = db.get(wc.inner.key);
│ │ wc.tx.send(res).await;
│ │ // tx.send() writes to SendStream
│ │ }
│ │ }
│ │
│ 8. RecvStream reads response ◄───────────│
│ 9. Deserialize response │
│ 10. Return to caller │
```
## 0-RTT Flow
```
CLIENT SERVER
│ │
│ 1. Serialize message into buffer │
│ (prepare_write) │
│ │
│ 2. Open 0-RTT connection │
│ → write buffer immediately ─────────►│
│ │
│ 3. Check zero_rtt_accepted() │
│ → If true: done, read response │
│ → If false: │
│ 4. Open new (full) connection │
│ 5. Re-send same buffer ────────────►│
│ │
│ 6. Read response ◄──────────────────────│
```
The key insight: the message buffer is pre-serialized so it can be re-sent without re-serialization if 0-RTT is rejected.

View File

@@ -0,0 +1,271 @@
# irpc: irpc-iroh — Iroh Transport Integration
The `irpc-iroh` crate provides transport integration for iroh, enabling irpc to work with iroh's QUIC connections that use endpoint IDs (rather than socket addresses) for routing.
## Crate Overview
```toml
[package]
name = "irpc-iroh"
version = "0.13.0"
description = "Iroh transport for irpc"
```
Dependencies: `iroh`, `irpc`, `tokio`, `tracing`, `serde`, `postcard`, `n0-error`, `n0-future`
## Key Types
### IrohRemoteConnection
```rust
#[derive(Debug, Clone)]
pub struct IrohRemoteConnection(Connection);
```
Wraps an existing iroh `Connection`. Simplest way to use irpc with iroh — create a connection externally and wrap it.
```rust
impl RemoteConnection for IrohRemoteConnection {
fn clone_boxed(&self) -> Box<dyn RemoteConnection> { ... }
fn open_bi(&self) -> BoxFuture<Result<(SendStream, RecvStream), RequestError>> {
// Delegates to connection.open_bi()
}
fn zero_rtt_accepted(&self) -> BoxFuture<bool> {
// Always true — fully authenticated connection
}
}
```
**Note:** This stops working when the underlying connection is closed. For automatic reconnection, use `IrohLazyRemoteConnection`.
### IrohZrttRemoteConnection
```rust
#[derive(Debug, Clone)]
pub struct IrohZrttRemoteConnection(OutgoingZeroRttConnection);
```
Wraps an iroh 0-RTT (Zero Round Trip Time) connection. This enables sending data before the full handshake completes for reduced latency on reconnections.
```rust
impl RemoteConnection for IrohZrttRemoteConnection {
fn open_bi(&self) -> BoxFuture<Result<(SendStream, RecvStream), RequestError>> {
// Delegates to the 0-RTT connection's open_bi()
}
fn zero_rtt_accepted(&self) -> BoxFuture<bool> {
// Actually checks handshake_completed() to determine
// if 0-RTT data was accepted
}
}
```
The `zero_rtt_accepted()` method:
- Returns `true` if `ZeroRttStatus::Accepted`
- Returns `false` if `ZeroRttStatus::Rejected` or on error
- This allows the `Client` to decide whether to re-send data
### IrohLazyRemoteConnection
```rust
#[derive(Debug, Clone)]
pub struct IrohLazyRemoteConnection(Arc<IrohRemoteConnectionInner>);
struct IrohRemoteConnectionInner {
endpoint: iroh::Endpoint,
addr: iroh::EndpointAddr,
connection: tokio::sync::Mutex<Option<Connection>>,
alpn: Vec<u8>,
}
```
The lazy connection caches the underlying iroh `Connection` and reconnects automatically:
1. On first `open_bi()`, establishes a connection via `endpoint.connect(addr, alpn)`
2. Caches the connection in a `Mutex<Option<Connection>>`
3. On subsequent `open_bi()`, tries to reuse the cached connection
4. If the cached connection fails, clears the cache and reconnects once
The `alpn` field is required because iroh connections need an ALPN protocol identifier.
### `client()` Function
```rust
pub fn client<S: irpc::Service>(
endpoint: iroh::Endpoint,
addr: impl Into<iroh::EndpointAddr>,
alpn: impl AsRef<[u8]>,
) -> irpc::Client<S>
```
Convenience function to create a `Client<S>` using iroh. Creates an `IrohLazyRemoteConnection` and wraps it with `Client::boxed()`.
## Server-Side: IrohProtocol
### IrohProtocol
```rust
pub struct IrohProtocol<R> {
handler: Handler<R>,
request_id: AtomicU64,
}
```
Implements `iroh::protocol::ProtocolHandler`, allowing it to be registered with iroh's `Router`:
```rust
impl<R: DeserializeOwned + Send + 'static> ProtocolHandler for IrohProtocol<R> {
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
// Handle the connection using irpc's handle_connection
let handler = self.handler.clone();
let fut = handle_connection(&connection, handler).map_err(AcceptError::from_err);
fut.instrument(span).await
}
}
```
**Usage:**
```rust
let protocol = IrohProtocol::with_sender(local_sender);
// or
let protocol = IrohProtocol::new(handler);
let router = Router::builder(endpoint)
.accept(ALPN, protocol)
.spawn();
```
### Iroh0RttProtocol
```rust
pub struct Iroh0RttProtocol<R> { ... }
```
Supports 0-RTT connections by implementing `ProtocolHandler::on_accepting()`:
```rust
impl<R: DeserializeOwned + Send + 'static> ProtocolHandler for Iroh0RttProtocol<R> {
async fn on_accepting(&self, accepting: Accepting) -> Result<Connection, AcceptError> {
let zrtt_conn = accepting.into_0rtt();
// Handle 0-RTT data immediately
handle_connection(&zrtt_conn, handler).await?;
// Wait for handshake completion
let conn = zrtt_conn.handshake_completed().await?;
Ok(conn)
}
async fn accept(&self, _connection: Connection) -> Result<(), AcceptError> {
// Noop — handled in on_accepting
Ok(())
}
}
```
**Warning:** 0-RTT data is replayable. Only use for idempotent operations. See <https://www.iroh.computer/blog/0rtt-api>.
### IncomingRemoteConnection Trait
```rust
pub trait IncomingRemoteConnection {
fn accept_bi(&self) -> impl Future<Output = Result<(SendStream, RecvStream), ConnectionError>> + Send;
fn close(&self, error_code: VarInt, reason: &[u8]);
fn remote_id(&self) -> Result<EndpointId, RemoteEndpointIdError>;
}
```
Abstraction over `Connection` and `IncomingZeroRttConnection`, enabling `handle_connection` and `read_request` to work with both regular and 0-RTT connections.
Implemented for:
- `Connection` — regular iroh connection
- `IncomingZeroRttConnection` — 0-RTT connection
## handle_connection (iroh variant)
```rust
pub async fn handle_connection<R: DeserializeOwned + 'static>(
connection: &impl IncomingRemoteConnection,
handler: Handler<R>,
) -> io::Result<()>
```
Similar to the noq version but works with iroh's `IncomingRemoteConnection` trait. Records the remote endpoint ID in the tracing span.
## read_request and read_request_raw (iroh variants)
Same logic as the noq versions but using `IncomingRemoteConnection` instead of `noq::Connection`:
```rust
pub async fn read_request<S: RemoteService>(
connection: &impl IncomingRemoteConnection,
) -> io::Result<Option<S::Message>>
pub async fn read_request_raw<R: DeserializeOwned + 'static>(
connection: &impl IncomingRemoteConnection,
) -> io::Result<Option<(R, RecvStream, SendStream)>>
```
## listen (iroh variant)
```rust
pub async fn listen<R: DeserializeOwned + 'static>(endpoint: iroh::Endpoint, handler: Handler<R>)
```
Accepts connections from an iroh `Endpoint` and handles them with the provided handler. Uses `n0_future::task::JoinSet` for task management.
## Example Usage
### Server
```rust
use irpc::{rpc_requests, channel::oneshot, Client, WithChannels};
use irpc_iroh::IrohProtocol;
use iroh::{endpoint::presets, protocol::Router, Endpoint};
#[rpc_requests(message = FooMessage)]
#[derive(Debug, Serialize, Deserialize)]
enum FooProtocol {
#[rpc(tx=oneshot::Sender<String>)]
Get(String),
}
async fn server() -> Result<()> {
let (tx, rx) = tokio::sync::mpsc::channel(16);
tokio::task::spawn(actor(rx));
let client = Client::<FooProtocol>::local(tx);
let endpoint = Endpoint::bind(presets::N0).await?;
let protocol = IrohProtocol::with_sender(client.as_local().unwrap());
let router = Router::builder(endpoint).accept(ALPN, protocol).spawn();
// ... keep running
}
```
### Client
```rust
async fn connect(endpoint_id: EndpointId) -> Result<Client<FooProtocol>> {
let endpoint = Endpoint::bind(presets::N0).await?;
let client = irpc_iroh::client(endpoint, endpoint_id, ALPN);
Ok(client)
}
// Or with direct connection:
async fn connect_direct(endpoint: Endpoint, addr: EndpointAddr) -> Result<Client<FooProtocol>> {
let conn = endpoint.connect(addr, ALPN).await?;
Ok(Client::boxed(IrohRemoteConnection::new(conn)))
}
```
### 0-RTT Client
```rust
async fn connect_0rtt(endpoint: Endpoint, addr: EndpointAddr) -> Result<Client<EchoProtocol>> {
let connecting = endpoint.connect_with_opts(addr, ALPN, Default::default()).await?;
match connecting.into_0rtt() {
Ok(conn) => Ok(Client::boxed(IrohZrttRemoteConnection::new(conn))),
Err(connecting) => {
let conn = connecting.await?;
Ok(Client::boxed(IrohRemoteConnection::new(conn)))
}
}
}
```

View File

@@ -0,0 +1,134 @@
# irpc: Serialization and Utility Modules
## Varint Utilities
The `varint-util` module (available with `rpc` or `varint-util` feature) provides LEB128 varint encoding/decoding compatible with postcard's format.
### Async Reading
```rust
pub async fn read_varint_u64<R: AsyncRead + Unpin>(reader: &mut R) -> io::Result<Option<u64>>
```
Reads a LEB128-encoded `u64` from an async reader. Returns `Ok(None)` on `UnexpectedEof` at the first byte position (clean stream end).
**Format:** Each byte uses 7 bits for the value, MSB as continuation bit. Values stored little-endian (least significant group first).
### Sync Writing
```rust
pub fn write_varint_u64_sync<W: io::Write>(writer: &mut W, value: u64) -> io::Result<usize>
```
Writes a `u64` as LEB128 to a synchronous writer.
### Length-Prefixed Encoding
```rust
// Sync:
pub fn write_length_prefixed<T: Serialize>(write: impl io::Write, value: T) -> io::Result<()>
pub trait WriteVarintExt: io::Write {
fn write_varint_u64(&mut self, value: u64) -> io::Result<usize>;
fn write_length_prefixed<T: Serialize>(&mut self, value: T) -> io::Result<()>;
}
// Async:
pub trait AsyncReadVarintExt: AsyncRead + Unpin {
fn read_varint_u64(&mut self) -> impl Future<Output = io::Result<Option<u64>>>;
fn read_length_prefixed<T: DeserializeOwned>(&mut self, max_size: usize) -> impl Future<Output = io::Result<T>>;
}
pub trait AsyncWriteVarintExt: AsyncWrite + Unpin {
fn write_varint_u64(&mut self, value: u64) -> impl Future<Output = io::Result<usize>>;
fn write_length_prefixed<T: Serialize>(&mut self, value: V) -> impl Future<Output = io::Result<usize>>;
}
```
The length-prefix format is:
```
[varint-encoded-length][postcard-serialized-data]
```
Used internally by irpc for framing all messages on QUIC streams. The `max_size` parameter in `read_length_prefixed` prevents memory exhaustion from malicious length values.
## noq Endpoint Setup
The `noq_endpoint_setup` feature provides helpers for creating noq endpoints with TLS configuration:
```rust
pub fn configure_client(server_certs: &[&[u8]]) -> Result<ClientConfig>
pub fn configure_server() -> Result<(ServerConfig, Vec<u8>)>
pub fn configure_client_insecure() -> Result<ClientConfig>
// Non-WASM only:
pub fn make_client_endpoint(bind_addr: SocketAddr, server_certs: &[&[u8]]) -> Result<Endpoint>
pub fn make_insecure_client_endpoint(bind_addr: SocketAddr) -> Result<Endpoint>
pub fn make_server_endpoint(bind_addr: SocketAddr) -> Result<(Endpoint, Vec<u8>)>
```
- `configure_server()`: Creates a self-signed certificate with rcgen and configures the server with TLS 1.3. Returns the DER-encoded certificate for clients to trust.
- `configure_client()`: Configures a client to trust specific DER certificates.
- `configure_client_insecure()`: Skips certificate verification (for testing only).
- Server endpoints set `max_concurrent_uni_streams(0)` to disable unidirectional streams (only bidirectional streams are used).
- Keep-alive interval is set to 1 second on client configs.
## FusedOneshotReceiver
```rust
pub(crate) struct FusedOneshotReceiver<T>(pub tokio::sync::oneshot::Receiver<T>);
```
A wrapper that prevents panics when polling an already-completed oneshot receiver. After the inner receiver resolves, subsequent polls return `Poll::Pending` indefinitely instead of panicking.
This is important because irpc's `oneshot::Receiver` can be wrapped in `Receiver::Boxed` (a `BoxFuture`), and the inner future might be polled multiple times in certain select patterns.
## now_or_never
```rust
pub(crate) fn now_or_never<F: Future>(future: F) -> Option<F::Output>
```
Attempts to complete a future immediately without blocking. If the future would block, returns `None`. Used internally by `NoqSenderInner::try_send()` to attempt an immediate write to the QUIC stream without yielding.
Implementation uses a no-op waker to poll the future once.
## Spans Feature
When the `spans` feature is enabled (default), `WithChannels` includes a `span: tracing::Span` field:
```rust
pub struct WithChannels<I: Channels<S>, S: Service> {
pub inner: I,
pub tx: <I as Channels<S>>::Tx,
pub rx: <I as Channels<S>>::Rx,
#[cfg(feature = "spans")]
pub span: tracing::Span,
}
```
The span is captured from `tracing::Span::current()` at the time of `WithChannels` construction (via `From` implementations). This preserves tracing context across async message-passing boundaries.
The `rpc_requests` macro generates a `parent_span()` method on the message enum when `no_spans` is not set:
```rust
impl ComputeMessage {
pub fn parent_span(&self) -> tracing::Span {
let span = match self {
ComputeMessage::Multiply(inner) => inner.parent_span_opt(),
ComputeMessage::Sum(inner) => inner.parent_span_opt(),
};
span.cloned().unwrap_or_else(|| tracing::Span::current())
}
}
```
This allows server-side handlers to enter the client's tracing span:
```rust
async fn handle(msg: ComputeMessage) {
let _entered = msg.parent_span().enter();
// ... processing happens in the client's tracing context
}
```
When `no_spans` is set in the macro, no span-related code is generated, making it compatible with builds that don't have the `spans` feature enabled.

View File

@@ -0,0 +1,249 @@
# irpc: Design Patterns and Usage Examples
## Pattern 1: Actor Model (Most Common)
The primary usage pattern is an actor that receives messages and processes them sequentially:
```rust
struct StorageActor {
recv: tokio::sync::mpsc::Receiver<StorageMessage>,
state: BTreeMap<String, String>,
}
impl StorageActor {
pub fn spawn() -> StorageApi {
let (tx, rx) = tokio::sync::mpsc::channel(16);
let actor = Self { recv: rx, state: BTreeMap::new() };
tokio::task::spawn(actor.run());
StorageApi { inner: Client::local(tx) }
}
async fn run(mut self) {
while let Some(msg) = self.recv.recv().await {
self.handle(msg).await;
}
}
async fn handle(&mut self, msg: StorageMessage) {
match msg {
StorageMessage::Get(wc) => {
let WithChannels { inner, tx, .. } = wc;
tx.send(self.state.get(&inner.key).cloned()).await.ok();
}
StorageMessage::Set(wc) => {
let WithChannels { inner, tx, .. } = wc;
self.state.insert(inner.key, inner.value);
tx.send(()).await.ok();
}
}
}
}
```
**Key points:**
- The actor owns state and processes messages sequentially
- `Client::local(tx)` wraps the sender side of the mpsc channel
- `WithChannels` destructuring gives access to `inner` (the request data), `tx` (response channel), and `rx` (update channel)
- The `..` pattern ignores `rx` when it's `NoReceiver` and `span` (with `spans` feature)
## Pattern 2: Concurrent Task Per Request
For long-running or independent requests, spawn a task per message:
```rust
async fn run(mut self) {
while let Ok(Some(msg)) = self.recv.recv().await {
tokio::task::spawn(async move {
if let Err(cause) = Self::handle(msg).await {
eprintln!("Error: {cause}");
}
});
}
}
```
This is useful for CPU-intensive or I/O-bound requests that shouldn't block other requests.
## Pattern 3: Local-Only Usage
irpc can be used without any RPC feature for pure in-process communication:
```rust
// Cargo.toml: default-features = false, features = ["derive"]
#[rpc_requests(message = StorageMessage, no_rpc, no_spans)]
#[derive(Serialize, Deserialize, Debug)]
enum StorageProtocol {
#[rpc(tx=oneshot::Sender<Option<String>>)]
Get(Get),
#[rpc(tx=oneshot::Sender<()>)]
Set(Set),
}
```
The `no_rpc` flag prevents `RemoteService` from being generated, and `no_spans` removes the tracing dependency. This leaves only the local channel mechanism, with minimal dependencies (serde, tokio, tokio-util).
## Pattern 4: API Type Wrapping Client
The recommended pattern is to wrap `Client<S>` in a higher-level API type:
```rust
struct StorageApi {
inner: Client<StorageProtocol>,
}
impl StorageApi {
// Local
pub fn spawn() -> Self {
let (tx, rx) = tokio::sync::mpsc::channel(16);
tokio::task::spawn(StorageActor::new(rx).run());
Self { inner: Client::local(tx) }
}
// Remote (noq)
pub fn connect(endpoint: noq::Endpoint, addr: SocketAddr) -> Self {
Self { inner: Client::noq(endpoint, addr) }
}
// Remote (iroh)
pub fn connect_iroh(endpoint: iroh::Endpoint, addr: EndpointAddr) -> Self {
Self { inner: irpc_iroh::client(endpoint, addr, ALPN) }
}
// Type-safe methods that work for both local and remote
pub async fn get(&self, key: String) -> irpc::Result<Option<String>> {
self.inner.rpc(Get { key }).await
}
pub async fn set(&self, key: String, value: String) -> irpc::Result<()> {
self.inner.rpc(Set { key, value }).await
}
pub async fn list(&self) -> irpc::Result<mpsc::Receiver<String>> {
self.inner.server_streaming(List, 16).await
}
}
```
This encapsulates the protocol details and provides a clean, type-safe API. The same `StorageApi` works identically whether connected locally or remotely.
## Pattern 5: Server Setup
### With noq
```rust
fn serve(api: &StorageApi, endpoint: noq::Endpoint) -> Result<JoinHandle<()>> {
let local = api.inner.as_local().context("cannot listen on remote service")?;
let handler = StorageProtocol::remote_handler(local);
Ok(tokio::task::spawn(irpc::rpc::listen(endpoint, handler)))
}
```
### With iroh
```rust
fn serve(api: &StorageApi, endpoint: iroh::Endpoint) -> Result<Router> {
let local = api.inner.as_local().context("cannot listen on remote service")?;
let protocol = IrohProtocol::with_sender(local);
Ok(Router::builder(endpoint).accept(ALPN, protocol).spawn())
}
```
## Pattern 6: Low-Level Request Handling
For more control than the `Client` methods provide, use `request()` directly:
```rust
async fn custom_request(&self, msg: Get) -> anyhow::Result<oneshot::Receiver<Option<String>>> {
match self.inner.request().await? {
Request::Local(request) => {
let (tx, rx) = oneshot::channel();
request.send((msg, tx)).await?;
Ok(rx)
}
Request::Remote(request) => {
let (_tx, rx) = request.write(msg).await?;
Ok(rx.into())
}
}
}
```
This allows custom channel creation logic, e.g., different buffer sizes for local vs remote.
## Pattern 7: Channel Filtering and Mapping
irpc channels support filtering and mapping, which work for both local and remote channels:
```rust
// Server-side: filter responses to only include values > 10
let filtered_tx = wc.tx.with_filter(|v: &i64| *v > 10);
// Server-side: transform responses
let mapped_tx = wc.tx.with_map(|v: i64| v * 2);
// Client-side: filter received updates
let filtered_rx = rx.filter(|update: &Update| update.is_relevant());
```
For remote channels, these create boxed wrappers. For local channels, they also create boxed wrappers. The overhead is negligible for remote (network latency dominates) but present for local.
## Pattern 8: Using the `wrap` Attribute
The `#[wrap]` attribute generates named structs from variant fields:
```rust
#[rpc_requests(message = StoreMessage)]
#[derive(Debug, Serialize, Deserialize)]
enum StoreProtocol {
#[rpc(tx=oneshot::Sender<Option<String>>)]
#[wrap(GetRequest, derive(Clone))]
Get(String), // Generates: pub struct GetRequest(pub String);
#[rpc(tx=oneshot::Sender<()>)]
#[wrap(SetRequest)]
Set { key: String, value: String }, // Generates: pub struct SetRequest { pub key: String, pub value: String }
}
```
Benefits:
- Named request types can be imported and constructed by name
- Additional derives (e.g., `Clone`) can be added
- Custom visibility can be specified: `#[wrap(pub(crate) GetRequest)]`
- The generated struct inherits the enum's visibility by default
## Pattern 9: 0-RTT Connections
For reduced latency on reconnections with iroh:
```rust
// Client side
let result = client.rpc_0rtt(Get { key: "x".into() }).await?;
// Server side (iroh)
let protocol = Iroh0RttProtocol::with_sender(local_sender);
let router = Router::builder(endpoint).accept(ALPN, protocol).spawn();
```
**Important:** Only use 0-RTT for idempotent operations, as the data may be replayed by an attacker.
## Pattern 10: Shared State in Actor
For actors that need shared state accessible from multiple handlers:
```rust
struct Actor {
recv: tokio::sync::mpsc::Receiver<Message>,
state: Arc<Mutex<SharedState>>,
}
```
Or use the actor pattern with internal mutation:
```rust
struct Actor {
recv: tokio::sync::mpsc::Receiver<Message>,
db: HashMap<String, String>, // owned state
}
```
Since the actor processes messages sequentially, no internal synchronization is needed.

View File

@@ -0,0 +1,230 @@
# irpc: Quick Reference
## Crate Info
- **Name:** `irpc`
- **Version:** 0.13.0
- **License:** Apache-2.0 OR MIT
- **Repository:** https://github.com/n0-computer/irpc
- **MSRV:** 1.89
## Feature Flags
| Feature | Default | Dependencies Added |
|---|---|---|
| `rpc` | ✅ | noq, postcard, smallvec, tracing, tokio/io-util |
| `derive` | ✅ | irpc-derive |
| `spans` | ✅ | tracing |
| `stream` | ✅ | futures-util |
| `noq_endpoint_setup` | ✅ | rustls, rcgen, futures-buffered |
| `varint-util` | ❌ | postcard, smallvec, tokio/io-util |
## Type Quick Reference
### Core Types
```
Service trait — implemented on protocol enum, defines Message type
Channels<S> trait — implemented on request types, defines Tx/Rx types
RpcMessage trait — blanket impl for Debug+Serialize+DeserializeOwned+Send+Sync+Unpin+'static
Sender trait — sealed marker for sender types
Receiver trait — sealed marker for receiver types
WithChannels<I,S> struct — wraps request I with tx/rx/span for service S
Client<S> struct — client to service S (local or remote)
LocalSender<S> struct — local sender wrapping mpsc::Sender<S::Message>
Request<L,R> enum — Local(L) or Remote(R) request
RemoteSender<S> struct — holds QUIC stream pair for sending initial message
```
### Channel Types
```
oneshot::Sender<T> — Tokio or Boxed; single value; async send
oneshot::Receiver<T> — Tokio or Boxed; single value; Future impl
mpsc::Sender<T> — Tokio or Arc<DynSender>; stream; async send/try_send
mpsc::Receiver<T> — Tokio or Box<DynReceiver>; stream; async recv
NoSender — No-op sender
NoReceiver — No-op receiver
```
### Remote Types (rpc feature)
```
RemoteConnection trait — open_bi(), zero_rtt_accepted(), clone_boxed()
NoqLazyRemoteConnection — lazy noq connection with cache
Handler<R> type — Arc<dyn Fn(R, RecvStream, SendStream) -> ...>
```
### irpc-iroh Types
```
IrohRemoteConnection — wraps iroh::Connection
IrohZrttRemoteConnection — wraps iroh::OutgoingZeroRttConnection
IrohLazyRemoteConnection — lazy iroh connection with cache
IrohProtocol<R> — ProtocolHandler for iroh Router
Iroh0RttProtocol<R> — ProtocolHandler with 0-RTT support
IncomingRemoteConnection trait — abstraction over Connection and ZeroRttConnection
```
## Interaction Patterns Cheatsheet
```rust
// ═══════════════════════════════════════════
// Protocol Definition
// ═══════════════════════════════════════════
#[rpc_requests(message = MyMessage)]
#[derive(Debug, Serialize, Deserialize)]
enum MyProtocol {
// Unary RPC
#[rpc(tx=oneshot::Sender<Response>)]
#[wrap(GetReq)]
Get(String),
// Server streaming
#[rpc(tx=mpsc::Sender<Item>)]
#[wrap(ListReq)]
List(ListParams),
// Client streaming
#[rpc(tx=oneshot::Sender<Count>, rx=mpsc::Receiver<Item>)]
#[wrap(UploadReq)]
Upload,
// Bidirectional streaming
#[rpc(tx=mpsc::Sender<Result>, rx=mpsc::Receiver<Update>)]
#[wrap(ProcessReq)]
Process(ProcessConfig),
// Fire and forget
#[rpc]
#[wrap(LogReq)]
Log(String),
}
// ═══════════════════════════════════════════
// Client Usage
// ═══════════════════════════════════════════
// Local
let (tx, rx) = tokio::sync::mpsc::channel(16);
tokio::task::spawn(actor(rx));
let client: Client<MyProtocol> = Client::local(tx);
// Remote (noq)
let client: Client<MyProtocol> = Client::noq(endpoint, addr);
// Remote (iroh)
let client: Client<MyProtocol> = irpc_iroh::client(endpoint, addr, alpn);
// ═══════════════════════════════════════════
// Making Requests
// ═══════════════════════════════════════════
// Unary
let result: Response = client.rpc(GetReq("key".into())).await?;
// Server streaming
let mut rx: mpsc::Receiver<Item> = client.server_streaming(ListReq(params), 16).await?;
while let Some(item) = rx.recv().await? { ... }
// Client streaming
let (update_tx, response_rx): (mpsc::Sender<Item>, oneshot::Receiver<Count>) =
client.client_streaming(Upload, 4).await?;
update_tx.send(item).await?;
let count = response_rx.await?;
// Bidirectional
let (update_tx, mut result_rx): (mpsc::Sender<Update>, mpsc::Receiver<Result>) =
client.bidi_streaming(ProcessReq(config), 4, 16).await?;
update_tx.send(update).await?;
while let Some(result) = result_rx.recv().await? { ... }
// Fire and forget
client.notify(LogReq("message".into())).await?;
// ═══════════════════════════════════════════
// Server Setup
// ═══════════════════════════════════════════
// noq
let handler = MyProtocol::remote_handler(local_sender);
irpc::rpc::listen(endpoint, handler).await;
// iroh
let protocol = IrohProtocol::with_sender(local_sender);
Router::builder(endpoint).accept(ALPN, protocol).spawn();
// ═══════════════════════════════════════════
// Actor Message Handling
// ═══════════════════════════════════════════
async fn handle(&mut self, msg: MyMessage) {
match msg {
MyMessage::Get(wc) => {
let WithChannels { inner, tx, .. } = wc;
let result = self.db.get(&inner.0).cloned();
tx.send(result).await.ok();
}
MyMessage::List(wc) => {
let WithChannels { tx, .. } = wc;
for item in &self.items {
if tx.send(item.clone()).await.is_err() { break; }
}
}
MyMessage::Upload(wc) => {
let WithChannels { tx, mut rx, .. } = wc;
let mut count = 0;
while let Ok(Some(item)) = rx.recv().await {
self.process(item);
count += 1;
}
tx.send(count).await.ok();
}
MyMessage::Process(wc) => {
let WithChannels { tx, mut rx, inner, .. } = wc;
tokio::task::spawn(async move {
while let Ok(Some(update)) = rx.recv().await {
if let Some(result) = process(update, &inner) {
if tx.send(result).await.is_err() { break; }
}
}
});
}
MyMessage::Log(wc) => {
let WithChannels { inner, .. } = wc;
println!("{}", inner.0);
}
}
}
```
## Error Handling Quick Reference
```rust
// Client-side errors
use irpc::{Error, RequestError, Result};
// Request errors (connection/stream open failures)
match client.rpc(GetReq("key".into())).await {
Ok(result) => { ... }
Err(Error::Request { source }) => { ... } // Connection failed
Err(Error::OneshotRecv { source }) => { ... } // Response channel error
}
// Channel errors
use irpc::channel::{SendError, mpsc::RecvError, oneshot::RecvError};
// SendError: ReceiverClosed | MaxMessageSizeExceeded | Io
// RecvError (oneshot): SenderClosed | MaxMessageSizeExceeded | Io
// RecvError (mpsc): MaxMessageSizeExceeded | Io
```
## Constants
```rust
pub const MAX_MESSAGE_SIZE: u64 = 16 * 1024 * 1024; // 16 MiB
pub const ERROR_CODE_MAX_MESSAGE_SIZE_EXCEEDED: u32 = 1;
pub const ERROR_CODE_INVALID_POSTCARD: u32 = 2;
// Connection close code 0 = clean shutdown
```