Hello World
This tutorial shows how to run a complete Interest/Data exchange using the ndn-rs library in a single Rust program. No external router is needed – everything runs in-process via InProcFace channel pairs.
How it works
In NDN, communication follows a pull-based model: a consumer sends an Interest packet naming the data it wants, and a producer responds with a matching Data packet. The forwarding engine sits between them, matching Interests to Data via the PIT (Pending Interest Table) and FIB (Forwarding Information Base).
💡 Key insight: No external router is needed for this example. The
InProcFacecreates an in-process channel pair – one side for the application, one side for the engine. Packets flow through the full forwarding pipeline (PIT, FIB, CS, strategy) but never touch the network. This is the same mechanism used for production in-process applications.
sequenceDiagram
participant C as Consumer
participant E as Engine (PIT + FIB)
participant P as Producer
C->>E: Interest /ndn/hello
Note over E: PIT entry created<br/>FIB lookup -> Producer face
E->>P: Interest /ndn/hello
P->>E: Data /ndn/hello "hello, NDN!"
Note over E: PIT entry satisfied<br/>Forward Data to Consumer face
E->>C: Data /ndn/hello "hello, NDN!"
graph LR
subgraph "Application Process"
APP["Application code<br/>(Consumer / Producer)"]
end
subgraph "InProcFace Channel Pair"
direction TB
TX1["app_handle.send()"] -->|"mpsc"| RX1["engine face.recv()"]
TX2["engine face.send()"] -->|"mpsc"| RX2["app_handle.recv()"]
end
subgraph "ForwarderEngine"
PIPE["Pipeline<br/>(FIB + PIT + CS + Strategy)"]
end
APP -->|"Interest"| TX1
RX1 -->|"Interest"| PIPE
PIPE -->|"Data"| TX2
RX2 -->|"Data"| APP
style APP fill:#e8f4fd,stroke:#2196F3
style PIPE fill:#fff3e0,stroke:#FF9800
Dependencies
Add these to your Cargo.toml:
[dependencies]
ndn-app = { path = "crates/engine/ndn-app" }
ndn-engine = { path = "crates/engine/ndn-engine" }
ndn-faces = { path = "crates/faces/ndn-faces" }
ndn-packet = { path = "crates/foundation/ndn-packet", features = ["std"] }
ndn-transport = { path = "crates/foundation/ndn-transport" }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
If you are working within the ndn-rs workspace, use workspace = true instead of path dependencies.
Full example
use ndn_app::{Consumer, EngineBuilder, Producer};
use ndn_engine::EngineConfig;
use ndn_faces::local::InProcFace;
use ndn_packet::Name;
use ndn_packet::encode::DataBuilder;
use ndn_store::FibNexthop;
use ndn_transport::FaceId;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 1. Create in-process face pairs.
// Each InProcFace::new() returns a face (for the engine) and a handle
// (for the application). They are connected by an mpsc channel.
let (consumer_face, consumer_handle) = InProcFace::new(FaceId(1), 64);
let (producer_face, producer_handle) = InProcFace::new(FaceId(2), 64);
// 2. Build the forwarding engine with both faces.
let (engine, shutdown) = EngineBuilder::new(EngineConfig::default())
.face(consumer_face)
.face(producer_face)
.build()
.await?;
// 3. Install a FIB route: Interests for /ndn/hello -> producer face.
let prefix: Name = "/ndn/hello".parse()?;
engine.fib().add_nexthop(&prefix, FibNexthop { face_id: 2, cost: 0 });
// 4. Create Consumer and Producer from their handles.
let mut consumer = Consumer::from_handle(consumer_handle);
let mut producer = Producer::from_handle(producer_handle, prefix.clone());
// 5. Spawn the producer in a background task.
// It loops, waiting for Interests and replying with Data.
let producer_task = tokio::spawn(async move {
producer
.serve(|interest| {
let name = (*interest.name).clone();
async move {
let wire = DataBuilder::new(name, b"hello, NDN!").build();
Some(wire)
}
})
.await
});
// 6. Consumer sends an Interest and waits for the Data reply.
let data = consumer.fetch(prefix.clone()).await?;
println!("Received Data: {}", data.name);
println!("Content: {:?}", std::str::from_utf8(
data.content().unwrap().as_ref()
));
// 7. Clean shutdown: drop consumer/engine, then await the shutdown handle.
drop(consumer);
drop(engine);
shutdown.shutdown().await;
let _ = producer_task.await;
Ok(())
}
Step-by-step walkthrough
1. Create InProcFace pairs
InProcFace::new(face_id, capacity) creates a face for the engine side and a handle for the application side, connected by a bounded channel. The capacity parameter controls backpressure – 64 is a good default.
2. Build the engine
EngineBuilder wires the PIT, FIB, content store, pipeline stages, and strategy table. Calling .face(f) registers a face with the engine. .build().await returns the running ForwarderEngine and a ShutdownHandle.
🔧 Implementation note:
EngineBuilderuses sensible defaults for everything:LruCsfor caching,BestRouteStrategyat the root prefix, and the standard Interest/Data pipeline stages. You only need to configure what you want to customize. The builder pattern ensures the engine is fully wired before it starts processing packets.
%%{init: {"layout": "elk"}}%%
graph TD
EB["EngineBuilder::new(config)"]
EB -->|".face(consumer_face)"| F1["Face: Consumer"]
EB -->|".face(producer_face)"| F2["Face: Producer"]
EB -->|"default"| CS["ContentStore (LruCs)"]
EB -->|"default"| PIT["PIT (DashMap)"]
EB -->|"default"| FIB["FIB (NameTrie)"]
EB -->|"default"| ST["StrategyTable"]
EB -->|"default"| PIPE["Pipeline Stages"]
EB ==>|".build().await"| ENGINE["ForwarderEngine"]
EB ==>|".build().await"| SH["ShutdownHandle"]
ENGINE --- F1
ENGINE --- F2
ENGINE --- CS
ENGINE --- PIT
ENGINE --- FIB
ENGINE --- ST
ENGINE --- PIPE
style EB fill:#e8f4fd,stroke:#2196F3
style ENGINE fill:#c8e6c9,stroke:#4CAF50
style SH fill:#fce4ec,stroke:#E91E63
3. Add a FIB route
The FIB maps name prefixes to outgoing faces. add_nexthop(&prefix, FibNexthop { face_id, cost }) tells the engine: “forward Interests matching this prefix to this face.” Cost is used when multiple nexthops exist (lower wins).
4. Consumer and Producer
Consumer::from_handle(handle)wraps the application-side handle with methods likefetch()andget().Producer::from_handle(handle, prefix)wraps the handle with aserve()loop that dispatches incoming Interests to a callback.
5. The exchange
When consumer.fetch(name) is called, it builds an Interest packet, sends it through the InProcFace channel into the engine, which looks up the FIB, finds the producer face, and forwards the Interest. The producer’s serve() callback receives it, builds a Data packet, and sends it back through the engine to the consumer.
Connecting to an external router
If you have a running ndn-fwd instead of an embedded engine, applications connect via the router’s face socket:
use ndn_app::Consumer;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Connect to the router's management socket.
let mut consumer = Consumer::connect("/run/nfd/nfd.sock").await?;
let data = consumer.fetch("/ndn/hello").await?;
println!("Got: {:?}", data.content());
Ok(())
}
This uses a Unix socket (with optional SHM data plane) instead of in-process channels, but the Consumer API is identical.
Next steps
- Running the Forwarder – deploy
ndn-fwdas a standalone forwarder - PIT, FIB, and Content Store – understand the data structures behind the exchange
- Pipeline Walkthrough – trace a packet through every pipeline stage