Files
mfahampshire a70e68c7bd Max/smolmix docs (#6716)
* Smolmix documentation

* Add smolmix docs: landing page, tutorials, and developer page links

* Add Exit Gateway services page (NR vs IPR) and link from existing docs

* Update auto-generated command and API outputs

* Reorg of tutorials and architecture pages

* License information + remove TODO from docs.rs visibile comment + reorg
readme

* Add versions file for doc-wide versioning

* Relative -> absolute links

* Relative -> absolute links

* Update license + add old tutorial code as examples

* Streamline smolmix docs

* Clippy

* Clean up doc comments

* Last pass

* Add larger file download to list

* set new versions

* Clippy

* Remove blake pin from docs + add version range to root Cargo.toml

* Format example logging

* Remove crate blocked component

* Loose whitespace

* Add doc verification script for inline mdx

* Formatting

* Components regen

* Reorg + tighten text

* Voicing cohesion pass + remove bloated examples

* Voicing cont.

* Reduce max download size

* Small suggested clarifications

* Max/docs voicing consistency (#6769)

* Reduce max download size

* voicing consistency across docs

* New landing order w smolmix

* Tweaks

* Final tweaks
2026-05-13 11:19:44 +00:00

147 lines
8.7 KiB
Plaintext

---
title: "Stream Module: AsyncRead/AsyncWrite Over the Mixnet"
description: "The Nym Stream module provides persistent, bidirectional byte channels over the mixnet with standard Rust AsyncRead and AsyncWrite traits."
schemaType: "TechArticle"
section: "Developers"
lastUpdated: "2026-03-15"
---
# Stream Module
import { Callout } from 'nextra/components'
The Mixnet is fundamentally message-based: no persistent connections, no guaranteed ordering, no TCP. The default [message API](./mixnet) works at this level, sending individual payloads independently through Mix Nodes. This is effective for privacy but unlike how most networking code is structured.
The Stream module bridges the gap by providing persistent, bidirectional byte channels that behave like TCP sockets. Each `MixnetStream` implements [`AsyncRead`](https://docs.rs/tokio/latest/tokio/io/trait.AsyncRead.html) and [`AsyncWrite`](https://docs.rs/tokio/latest/tokio/io/trait.AsyncWrite.html), so `tokio::io::copy`, codecs, `BufReader`/`BufWriter`, and any other async I/O consumer work without modification.
All streams are multiplexed over a single `MixnetClient`. A background router task reads a small header on each incoming message and dispatches the payload to the correct stream by ID, so multiple concurrent streams require no additional connections or gateways.
## How it works
The two sides of a stream connection follow a client/server pattern:
1. The opener calls `client.open_stream(recipient, surbs)`. This generates a random `StreamId`, registers the stream locally, and sends an `Open` message through the Mixnet.
2. The listener calls `listener.accept()`, which blocks until an `Open` arrives, registers the new stream, and returns a `MixnetStream` ready for reading and writing.
3. Both sides read and write using standard `AsyncRead`/`AsyncWrite`. Bytes are wrapped in a 16-byte LP frame header (stream ID, message type, sequence number), routed through the Mixnet, and demultiplexed on arrival.
4. On drop, the stream deregisters from the local router. No close message is sent over the wire, since a close could race ahead of in-flight data.
```text
┌─────────────────────────────────────────────────────────┐
│ MixnetClient │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ MixnetStream │ │ MixnetStream │ ... │
│ │ (peer A) │ │ (peer B) │ │
│ └──────┬───────┘ └──────┬───────┘ │
│ │writes │writes │
│ ▼ ▼ │
│ ┌─────────────────────────────────┐ │
│ │ ClientInput.input_sender │ │
│ └──────────────┬──────────────────┘ │
│ │ │
│ ▼ │
│ ── mixnet ── │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────┐ │
│ │ reconstructed_receiver │ │
│ └──────────────┬──────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────┐ │
│ │ Router task │ │
│ │ decode header → dispatch by ID │ │
│ └──┬──────────────────────────┬───┘ │
│ │ Open messages │ Data messages │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │MixnetListener│ │ StreamMap lookup │ │
│ │ .accept() │ │ → per-stream tx │ │
│ └──────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
## Complete example
A minimal example with two clients on the same machine: one opens a stream to the other, sends a message, and reads a reply.
```rust
use nym_sdk::mixnet;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use std::time::Duration;
const TIMEOUT: Duration = Duration::from_secs(60);
#[tokio::main]
async fn main() {
// Connect two ephemeral clients
let mut sender = mixnet::MixnetClient::connect_new().await.unwrap();
let mut receiver = mixnet::MixnetClient::connect_new().await.unwrap();
let receiver_addr = *receiver.nym_address();
// The receiver creates a listener (activates stream mode)
let mut listener = receiver.listener().unwrap();
// The sender opens a stream to the receiver's Nym address
let mut outbound = sender.open_stream(receiver_addr, None).await.unwrap();
// The receiver accepts the incoming stream
let mut inbound = tokio::time::timeout(TIMEOUT, listener.accept())
.await
.expect("timed out")
.expect("listener closed");
// Send data and read it back, just like a TCP socket
outbound.write_all(b"hello from sender").await.unwrap();
outbound.flush().await.unwrap();
let mut buf = vec![0u8; 1024];
let n = tokio::time::timeout(TIMEOUT, inbound.read(&mut buf))
.await
.expect("timed out")
.expect("read failed");
println!("Receiver got: {}", String::from_utf8_lossy(&buf[..n]));
// Reply back through the same stream
inbound.write_all(b"hello from receiver").await.unwrap();
inbound.flush().await.unwrap();
let n = tokio::time::timeout(TIMEOUT, outbound.read(&mut buf))
.await
.expect("timed out")
.expect("read failed");
println!("Sender got: {}", String::from_utf8_lossy(&buf[..n]));
// Streams deregister on drop, then disconnect clients
drop(outbound);
drop(inbound);
sender.disconnect().await;
receiver.disconnect().await;
}
```
<Callout type="info">
The receiver replies via **reply SURBs** (Single Use Reply Blocks) and never learns the sender's Nym address.
</Callout>
## When to use streams vs messages
| | Messages | Streams | TcpProxy |
|---|---|---|---|
| **Pattern** | Raw message payloads | Persistent bidirectional channels | TCP socket proxying |
| **API** | `send_plain_message()` / `wait_for_messages()` | `AsyncRead` + `AsyncWrite` | Localhost TCP socket |
| **Multiplexing** | N/A | Multiple streams per client | One client per TCP connection |
| **Ordering** | No guarantees | Sequence-based reordering | Session-based ordering |
| **Best for** | Simple notifications, one-shot requests | Interactive protocols, streaming data, any code expecting async I/O | Wrapping existing TCP applications |
| **Status** | Stable | New | Deprecated |
<Callout type="warning">
Streams and messages are mutually exclusive. Once you call `open_stream()` or `listener()`, the message-based API (`send_plain_message`, `wait_for_messages`) is permanently disabled on that client. This is a one-way transition: no switching back without disconnecting and reconnecting. See the [`stream_mode_guard.rs` example](https://github.com/nymtech/nym/blob/develop/sdk/rust/nym-sdk/examples/stream_mode_guard.rs) for details.
</Callout>
## Next steps
- [Tutorial: Build a private echo server](./stream/tutorial): server and client communicating over streams
- [Architecture](./stream/architecture): wire protocol, router task, data flow, stream cleanup, and known limitations
- [Examples](./stream/examples): annotated walkthroughs of the SDK examples (multi-stream, idle timeout, throughput testing)