Files
mfahampshire 7c890ea0c5 TS SDK docs (#6840)
* First sweep packages + some minor tweaking

* Second sweep

* Regenerate lockfile + package.json mods

* Regenerate lockfile again

* Fix CI

* Fix CI again

* All building properly

* unblock

* Tweak examples

* Comments + readme + fix rotten unit test

* First pass docs

* Big pass

* Massive pass on new docs

* Update integrations.md w mobile

* Partial overhaul review

* new playground + big pass

* new fix lychee err

* IPR notice tweak
2026-06-09 13:31:08 +00:00

372 lines
12 KiB
Plaintext

---
title: "Stream Tutorial: Build a Private Echo Server"
description: "Step-by-step Rust tutorial to build an echo server and client communicating through the Nym mixnet using AsyncRead and AsyncWrite streams."
schemaType: "HowTo"
section: "Developers"
lastUpdated: "2026-04-17"
---
# Tutorial: Build a Private Echo Server
import { Callout } from 'nextra/components'
import { CodeVerified } from '../../../../components/code-verified'
import { RUST_MSRV } from '../../../../components/versions'
Two programs: a server that listens for incoming streams and echoes back what it receives, and a client that opens a stream, writes data, and reads the echo. Both communicate through the Nym Mixnet using `AsyncRead` and `AsyncWrite`, like a TCP socket pair.
## What you'll learn
- Setting up a `MixnetListener` to accept incoming streams
- Opening an outbound stream with `open_stream()`
- Reading and writing with standard tokio I/O traits
- How streams are multiplexed over a single `MixnetClient`
- Clean shutdown and stream lifecycle
<CodeVerified />
## Prerequisites
- Rust toolchain ({RUST_MSRV}+)
- A working internet connection (clients connect to the live Nym Mixnet)
## Step 1: Set up the project
```sh
cargo init nym-echo
cd nym-echo
rm src/main.rs
```
Add dependencies to `Cargo.toml`:
```toml
[dependencies]
nym-sdk = "1.21.1"
nym-bin-common = { version = "1.21.1", features = ["basic_tracing"] }
tokio = { version = "1", features = ["full"] }
rand = "0.8"
```
## Step 2: Build the echo server
The server connects a `MixnetClient`, creates a listener, and accepts streams in a loop. Each stream gets its own task that reads data and writes it back.
Create `src/bin/server.rs`:
```rust
use nym_sdk::mixnet;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[tokio::main]
async fn main() {
nym_bin_common::logging::setup_tracing_logger();
// Connect to the Mixnet
let mut client = mixnet::MixnetClient::connect_new().await.unwrap();
println!("Echo server listening at: {}", client.nym_address());
// Create a listener; this activates stream mode.
// From this point, message-based methods are disabled.
let mut listener = client.listener().unwrap();
// Accept streams in a loop
loop {
let mut stream = match listener.accept().await {
Some(s) => s,
None => {
println!("Listener closed");
break;
}
};
let stream_id = stream.id();
println!("Accepted stream {stream_id}");
// Spawn a task to handle each stream concurrently
tokio::spawn(async move {
let mut buf = vec![0u8; 32_000];
loop {
let n = match stream.read(&mut buf).await {
Ok(0) => break, // EOF, stream closed
Ok(n) => n,
Err(e) => {
eprintln!("Stream {stream_id} read error: {e}");
break;
}
};
let data = &buf[..n];
println!("Stream {stream_id} received {n} bytes");
// Echo it back
if let Err(e) = stream.write_all(data).await {
eprintln!("Stream {stream_id} write error: {e}");
break;
}
stream.flush().await.unwrap();
}
println!("Stream {stream_id} closed");
});
}
}
```
<Callout type="info">
`listener()` can only be called once per client. It takes exclusive ownership of the inbound message channel; a second call returns `Error::ListenerAlreadyTaken`.
</Callout>
## Step 3: Build the client
The client connects, opens a stream to the server, sends a few messages, reads back the echoes, and disconnects.
Create `src/bin/client.rs`:
```rust
use nym_sdk::mixnet::{self, Recipient};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
const TIMEOUT: Duration = Duration::from_secs(60);
#[tokio::main]
async fn main() {
nym_bin_common::logging::setup_tracing_logger();
// Read the server's Nym address from the command line
let server_addr: Recipient = std::env::args()
.nth(1)
.expect("Usage: client <SERVER_NYM_ADDRESS>")
.parse()
.expect("Invalid Nym address");
// Connect to the Mixnet
let mut client = mixnet::MixnetClient::connect_new().await.unwrap();
println!("Client address: {}", client.nym_address());
// Open a stream to the server.
// The second argument (None) uses the default number of reply SURBs.
let mut stream = client.open_stream(server_addr, None).await.unwrap();
println!("Stream opened: {}", stream.id());
// Give the Open message time to traverse the mixnet and reach the server.
// open_stream() returns immediately after sending; it doesn't wait for
// the server to accept. Writing too soon risks the data arriving before
// the Open, which the server would drop.
tokio::time::sleep(Duration::from_secs(5)).await;
// Send three payloads of different sizes and verify the echo.
// Random bytes show that streams are binary-safe, not just text.
let sizes = [320, 25_000, 1280];
for (i, &size) in sizes.iter().enumerate() {
let payload: Vec<u8> = (0..size).map(|_| rand::random::<u8>()).collect();
println!("Sending message {} ({size} bytes)", i + 1);
stream.write_all(&payload).await.unwrap();
stream.flush().await.unwrap();
// Read the echo
let mut buf = vec![0u8; 32_000];
let n = tokio::time::timeout(TIMEOUT, stream.read(&mut buf))
.await
.expect("timed out waiting for echo")
.expect("read failed");
assert_eq!(&buf[..n], &payload[..], "echo mismatch on message {}", i + 1);
println!("Received echo: {n} bytes ok");
}
// Drop the stream to deregister it from the router
drop(stream);
// Disconnect the client
client.disconnect().await;
println!("Done!");
}
```
## Step 4: Run it
In one terminal, start the server:
```sh
RUST_LOG=info cargo run --bin server
```
It prints its Nym address:
```
Echo server listening at: 8gk4Y...@2xU4d...
```
In a second terminal, start the client with the server's address:
```sh
RUST_LOG=info cargo run --bin client -- 8gk4Y...@2xU4d...
```
You'll see the messages traverse the Mixnet and echo back:
```
Client address: F3qR7...@9nK2m...
Stream opened: 12345678
Sending message 1 (320 bytes)
Received echo: 320 bytes ok
Sending message 2 (25000 bytes)
Received echo: 25000 bytes ok
Sending message 3 (1280 bytes)
Received echo: 1280 bytes ok
Done!
```
On the server side:
```
Accepted stream 12345678
Stream 12345678 received 320 bytes
Stream 12345678 received 25000 bytes
Stream 12345678 received 1280 bytes
Stream 12345678 closed
```
## How it works internally
1. The server's `listener()` activates **stream mode**, which spawns a **router task** that decodes incoming Mixnet messages and dispatches them by stream ID.
2. The client's `open_stream()` generates a random 8-byte `StreamId`, sends an `Open` message through the Mixnet, and registers the stream in a local routing table.
3. When the server's router receives the `Open` message, it delivers it to `listener.accept()`, which creates the inbound `MixnetStream`.
4. Each `write_all()` prepends a 16-byte LP frame header (`[LpFrameKind: 2B][StreamId: 8B][MsgType: 1B][SequenceNum: 4B][Reserved: 1B]`) and sends the data through the Mixnet as a Sphinx packet.
5. On arrival, the router reads the `LpFrameKind` to identify it as stream traffic, decodes the header, finds the matching stream by ID, and delivers the raw payload to `read()`.
6. The inbound stream replies via reply SURBs, the same anonymous reply mechanism as the message API. The server never learns the client's Nym address.
7. When a stream is dropped, it deregisters from the local router. No close message is sent over the wire, since a close could race ahead of in-flight data.
See the [Architecture](./architecture) page for the full technical details.
## What you've learned
- `client.listener()` activates stream mode and returns a `MixnetListener`
- `listener.accept()` blocks until a remote peer opens a stream
- `client.open_stream(recipient, surbs)` opens an outbound stream to a Nym address
- `MixnetStream` implements `AsyncRead + AsyncWrite`, so standard tokio I/O works unchanged
- Multiple streams are multiplexed over a single client
- Streams deregister on `drop`; no close handshake is needed
- The server replies via SURBs and never learns the client's address
## Complete code
### Server (`src/bin/server.rs`)
```rust
use nym_sdk::mixnet;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[tokio::main]
async fn main() {
nym_bin_common::logging::setup_tracing_logger();
let mut client = mixnet::MixnetClient::connect_new().await.unwrap();
println!("Echo server listening at: {}", client.nym_address());
let mut listener = client.listener().unwrap();
loop {
let mut stream = match listener.accept().await {
Some(s) => s,
None => {
println!("Listener closed");
break;
}
};
let stream_id = stream.id();
println!("Accepted stream {stream_id}");
tokio::spawn(async move {
let mut buf = vec![0u8; 32_000];
loop {
let n = match stream.read(&mut buf).await {
Ok(0) => break,
Ok(n) => n,
Err(e) => {
eprintln!("Stream {stream_id} read error: {e}");
break;
}
};
let data = &buf[..n];
println!("Stream {stream_id} received {n} bytes");
if let Err(e) = stream.write_all(data).await {
eprintln!("Stream {stream_id} write error: {e}");
break;
}
stream.flush().await.unwrap();
}
println!("Stream {stream_id} closed");
});
}
}
```
### Client (`src/bin/client.rs`)
```rust
use nym_sdk::mixnet::{self, Recipient};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
const TIMEOUT: Duration = Duration::from_secs(60);
#[tokio::main]
async fn main() {
nym_bin_common::logging::setup_tracing_logger();
let server_addr: Recipient = std::env::args()
.nth(1)
.expect("Usage: client <SERVER_NYM_ADDRESS>")
.parse()
.expect("Invalid Nym address");
let mut client = mixnet::MixnetClient::connect_new().await.unwrap();
println!("Client address: {}", client.nym_address());
let mut stream = client.open_stream(server_addr, None).await.unwrap();
println!("Stream opened: {}", stream.id());
// Wait for the Open message to reach the server through the mixnet
tokio::time::sleep(Duration::from_secs(5)).await;
let sizes = [320, 25_000, 1280];
for (i, &size) in sizes.iter().enumerate() {
let payload: Vec<u8> = (0..size).map(|_| rand::random::<u8>()).collect();
println!("Sending message {} ({size} bytes)", i + 1);
stream.write_all(&payload).await.unwrap();
stream.flush().await.unwrap();
let mut buf = vec![0u8; 32_000];
let n = tokio::time::timeout(TIMEOUT, stream.read(&mut buf))
.await
.expect("timed out waiting for echo")
.expect("read failed");
assert_eq!(&buf[..n], &payload[..], "echo mismatch on message {}", i + 1);
println!("Received echo: {n} bytes ok");
}
drop(stream);
client.disconnect().await;
println!("Done!");
}
```