Implementing Bi-Directional and Uni-Directional Streams in Iroh

Iroh implements bi-directional and uni-directional streams by wrapping transport protocols in types that implement both Stream and Sink traits from n0_future, allowing the same underlying connection to function as full-duplex channels or constrained read-only/write-only pipes.

The n0-computer/iroh repository provides a flexible networking stack that abstracts TCP, TLS, QUIC, and WebSocket transports behind unified streaming interfaces. Whether you need full-duplex communication between peers or constrained uni-directional data flow, implementing bi-directional and uni-directional streams in iroh relies on trait-based architecture and the WsBytesFramed and ProxyStream types defined in iroh-relay/src/protos/streams.rs.

Architecture Overview

Iroh organizes network streaming into four distinct layers, each serving a specific purpose in the transport stack.

  • Transport Layer handles raw I/O using Tokio's AsyncRead and AsyncWrite traits. Typical types include TcpStream, tokio_rustls::client::TlsStream, and tokio_websockets::WebSocketStream.

  • Byte-Framed Layer converts the raw transport into a Stream<Item = Result<Bytes, …>> and a Sink<Bytes>. The primary implementation is WsBytesFramed<T> for WebSocket connections and MaybeTlsStream<IO> for TLS or plain TCP.

  • Proxy Layer manages optional relay proxying and TLS termination through the ProxyStream enum, which variants between Raw and Proxied states while maintaining AsyncRead and AsyncWrite implementations.

  • Export-KM Layer supports TLS-based keying material extraction for noise handshakes via the ExportKeyingMaterial trait, implemented for WsBytesFramed, MaybeTlsStream, and ProxyStream.

Bi-Directional Stream Implementation

A bi-directional stream in iroh provides full-duplex communication where reading and writing occur simultaneously over the same underlying connection. This is achieved through the WsBytesFramed<T> struct in iroh-relay/src/protos/streams.rs, which implements both the Stream trait for inbound bytes and the Sink<Bytes> trait for outbound bytes.

The implementation filters WebSocket control frames (ping, pong, close) while yielding binary payloads through the Stream interface. Because the same tokio_websockets::WebSocketStream object handles both directions, callers can interleave calls to Stream::next() and Sink::start_send() without blocking.

Higher-level components consume any type implementing BytesStreamSink, a trait alias combining Stream<Item = Result<Bytes, …>> and Sink<Bytes>. This abstraction allows WsBytesFramed and MaybeTlsStream to be used interchangeably in the connection logic located in iroh/src/endpoint.rs.

Uni-Directional Stream Patterns

Uni-directional usage leverages the same underlying types but restricts the API surface to either read-only or write-only operations.

Read-only streams treat the type strictly as a Stream. You poll Stream::next() to receive Bytes payloads while never invoking Sink::start_send. This pattern appears when receiving files or server-sent events where no client response is required.

Write-only streams utilize only the Sink implementation. You call Sink::send() or Sink::start_send() to transmit data without ever polling the stream side. This suits telemetry uploads or command issuance scenarios where the peer expects no response payload.

Both patterns use identical type definitions—ProxyStream, WsBytesFramed, or MaybeTlsStream—differing only in which trait methods the caller invokes.

Type Composition Flow

Implementing streams in iroh follows a four-stage composition process that progressively wraps raw transports into application-ready abstractions.

  1. Transport Selection establishes the base connection. The client creates a TcpStream and optionally upgrades it via MaybeTlsStream, which enums between Raw and Tls variants.

  2. Proxy Handling inserts relay functionality when direct connectivity is unavailable. The ProxyStream type encapsulates either direct connections (Raw) or proxied chains (Proxied).

  3. WebSocket Framing applies when communicating through the relay protocol. The WsBytesFramed wrapper converts WebSocketStream into a byte-oriented interface implementing both Stream and Sink.

  4. Unified Interface exposes the final type through the BytesStreamSink trait alias. The endpoint logic in iroh/src/endpoint.rs accepts any impl BytesStreamSink, enabling polymorphic usage across transport types.

Practical Implementation Examples

Creating a Bi-Directional WebSocket Stream

The following example demonstrates establishing a full-duplex WebSocket connection using WsBytesFramed to wrap the raw WebSocket transport.

use iroh_relay::protos::streams::WsBytesFramed;
use tokio_websockets::WebSocketStream;
use tokio::net::TcpStream;
use bytes::Bytes;
use n0_future::{Stream, Sink};

async fn bi_directional_ws(url: &str) -> Result<(), Box<dyn std::error::Error>> {
    // 1️⃣ Connect a plain TCP socket (or TLS‑wrapped socket)
    let tcp = TcpStream::connect("example.com:443").await?;
    // 2️⃣ Upgrade to a WebSocket
    let ws_stream = WebSocketStream::new(tcp);
    // 3️⃣ Wrap in WsBytesFramed which implements Stream + Sink
    let mut ws = WsBytesFramed { io: ws_stream };

    // Send a message (write side)
    ws.start_send(Bytes::from_static(b"hello iroh")).await?;

    // Receive a message (read side)
    if let Some(Ok(payload)) = ws.next().await {
        println!("received: {}", std::str::from_utf8(&payload)?);
    }
    Ok(())
}

The WsBytesFramed type implements both Stream and Sink, enabling full-duplex communication over the same underlying socket.

Read-Only Stream Usage

This pattern consumes only the Stream implementation, ignoring the Sink capabilities of the underlying type.

use iroh_relay::client::streams::ProxyStream;
use futures::StreamExt; // for `.next()`

async fn read_only(proxy: ProxyStream) -> Result<(), Box<dyn std::error::Error>> {
    let mut read = proxy; // we only use the read side
    while let Some(Ok(chunk)) = read.next().await {
        // Process inbound data
        println!("got {} bytes", chunk.len());
    }
    Ok(())
}

Only the Stream part is exercised; the Sink methods are never called, creating a uni-directional read pipe.

Write-Only Stream Usage

Conversely, this example demonstrates uni-directional writing by utilizing only the Sink trait.

use iroh_relay::client::streams::ProxyStream;
use bytes::Bytes;
use n0_future::SinkExt; // for `.send()`

async fn write_only(mut proxy: ProxyStream) -> Result<(), Box<dyn std::error::Error>> {
    let data = Bytes::from_static(b"file contents");
    proxy.send(data).await?;
    Ok(())
}

Only the Sink trait is used; no reads are performed, establishing a uni-directional write channel.

Key Source Files

The stream implementations are distributed across several critical files in the n0-computer/iroh repository.

  • iroh-relay/src/protos/streams.rs implements WsBytesFramed, the core bi-directional WebSocket stream wrapper that bridges WebSocketStream to byte-oriented Stream and Sink traits.

  • iroh-relay/src/client/streams.rs defines ProxyStream and MaybeTlsStream, handling raw TCP streams, TLS-wrapped connections, and optional proxy chaining logic.

  • iroh-relay/src/server/streams.rs provides the server-side counterpart that accepts inbound WebSocket connections and presents them as WsBytesFramed instances to the relay server logic.

  • iroh/src/endpoint.rs contains the higher-level endpoint abstraction that consumes any impl BytesStreamSink, agnostic to whether the underlying transport is TCP, TLS, or WebSocket.

  • iroh/src/lib.rs re-exports the public stream types for library users, exposing WsBytesFramed and related traits at the crate root.

Summary

  • Iroh unifies TCP, TLS, and WebSocket transports behind the Stream and Sink traits from n0_future, enabling protocol-agnostic streaming.
  • Bi-directional streams use WsBytesFramed in iroh-relay/src/protos/streams.rs to provide full-duplex communication over WebSocket connections.
  • Uni-directional streams reuse the same types—ProxyStream, WsBytesFramed, or MaybeTlsStream—but restrict API usage to either Stream::next() for reading or Sink::send() for writing.
  • The type composition flow progresses from raw TcpStream through MaybeTlsStream and ProxyStream to the final framed interface, culminating in the BytesStreamSink trait alias consumed by iroh/src/endpoint.rs.
  • All transport wrappers implement keying material extraction via the ExportKeyingMaterial trait to support cryptographic handshakes.

Frequently Asked Questions

How does iroh handle bi-directional communication over WebSockets?

Iroh handles bi-directional WebSocket communication through the WsBytesFramed type in iroh-relay/src/protos/streams.rs. This struct wraps tokio_websockets::WebSocketStream and implements both Stream<Item = Result<Bytes, …>> for reading binary payloads and Sink<Bytes> for writing them. The implementation filters out WebSocket control frames like ping and close, exposing only the byte payload to callers while maintaining full-duplex capability.

Can I use the same stream type for both reading and writing in iroh?

Yes, the same concrete types—WsBytesFramed, ProxyStream, or MaybeTlsStream—serve both reading and writing purposes. These types implement the BytesStreamSink trait alias, which combines Stream and Sink capabilities. For bi-directional usage, you interleave calls to Stream::next() and Sink::start_send(). For uni-directional usage, you simply ignore the methods corresponding to the direction you do not need.

What is the difference between WsBytesFramed and ProxyStream?

WsBytesFramed is a protocol-specific wrapper that converts WebSocket message frames into byte streams, implementing Stream and Sink for binary payloads. ProxyStream is a higher-level abstraction in iroh-relay/src/client/streams.rs that represents either a direct connection (Raw) or a relay-proxied connection (Proxied). ProxyStream may internally contain a WsBytesFramed when communicating through the relay protocol, but it also handles raw TCP and TLS streams without WebSocket framing.

How do I implement a read-only stream in iroh?

To implement a read-only stream, obtain any type implementing Stream—such as ProxyStream or WsBytesFramed—and poll it using Stream::next() or the next() method from futures::StreamExt. Do not call any Sink methods such as send() or start_send(). The type system allows this because Stream and Sink are separate traits, and consuming only the Stream interface creates a uni-directional read pipe without affecting the underlying connection state.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →