Skip to content

Commit 8de1ce8

Browse files
authored
docs: Write (/bring back) the page on writing a custom protocol (#322)
* docs: Write (/bring back) the page on writing a custom protocol * Is this *really* the only typo?
1 parent b2ff7c8 commit 8de1ce8

File tree

4 files changed

+345
-2
lines changed

4 files changed

+345
-2
lines changed

src/app/docs/concepts/protocol/page.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,4 @@ This makes sure that an endpoint that accepts a connection can gracefully indica
1717

1818
The accept loop is the main loop of an iroh server. It listens for incoming connections, and then processes them. The accept loop is the entry point for all iroh protocols, and is where you can add your own protocol to the iroh stack.
1919

20-
Coming from the world of HTTP servers, the accept loop is similar to the main loop of an HTTP server. The main difference is that the accept loop is more flexible, as it can run multiple protocols on the same endpoint. Normally HTTP servers hide the raw accept loop from you, and instead routing to the correct handler based on the URL. In iroh, the accept loop is exposed to you, and you can use it to route to the correct protocol based on the ALPN.
20+
Coming from the world of HTTP servers, the accept loop is similar to the main loop of an HTTP server. The main difference is that the accept loop is more flexible, as it can run multiple protocols on the same endpoint. Normally HTTP servers hide the raw accept loop from you, and instead route to the correct handler based on the URL. In iroh, the accept loop is exposed to you, and you can use it to route to the correct protocol based on the ALPN.

src/app/docs/layout.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export const navItems = [
3434
{title: 'Resources',
3535
links: [
3636
{title: 'Protocol Registry', href: '/proto'},
37+
{title: 'Write your own Protocol', href: '/docs/protocols/writing'},
3738
{title: 'Awesome List', href: 'https://github.com/n0-computer/awesome-iroh'},
3839
{title: 'FAQ', href: '/docs/faq' },
3940
{title: 'Wasm/Browser Support', href: '/docs/wasm-browser-support' },
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
export const metadata = {
2+
title: 'Writing your own Protocol',
3+
description:
4+
'A guide on how to implement your own Protocol with iroh',
5+
};
6+
7+
8+
# Writing your own Protocol
9+
10+
So you've read [what an iroh protocol is][protocol] and know [what an iroh router is][router], and you're eager to start implementing your own iroh protocol? This is a short guide to show you how.{{ className: 'lead' }}
11+
12+
13+
## What we'll build
14+
15+
In this guide we'll implement a very basic echo protocol.
16+
For simplicity, we're not going to implement a CLI for this (unlike what we did in [the quickstart][quickstart]), and instead run both sides of the protocol as a test run in the `main()` function.
17+
18+
The protocol itself works like this:
19+
1. The accepting side waits for the connecting side to open a connection.
20+
2. Once a connection is established, the accepting side waits for the connecting side to open a bi-directional stream.
21+
3. The connecting side transfers some payload on the bi-directional stream first.
22+
4. The accepting side reads the payload and transfers it back on the same bi-directional stream.
23+
5. Once the connecting side has finished sending, it reads "the echo" back and then closes the connection.
24+
25+
26+
## Listening for connections
27+
28+
As established in the [router] and [protocol] pages, you'll first need to decide on an "Application-Layer Protocol Negotiation" (ALPN) string.
29+
We'll use "iroh-example/echo/0":
30+
31+
```rs
32+
const ALPN: &[u8] = b"iroh-example/echo/0";
33+
```
34+
35+
The easiest way to start listening for incoming connections is by using iroh's [`Router` API][router API].
36+
37+
```rs
38+
async fn start_accept_side() -> anyhow::Result<iroh::protocol::Router> {
39+
let endpoint = iroh::Endpoint::builder().discovery_n0().bind().await?;
40+
41+
let router = iroh::protocol::Router::builder(endpoint)
42+
.spawn()
43+
.await?;
44+
45+
Ok(router)
46+
}
47+
```
48+
49+
The router's `spawn` function is what starts an accept loop.
50+
As you saw in the [quickstart], we would need to call `accept` on the router's builder before, to avoid rejecting every incoming connection attempt, though.
51+
The [`accept` function][router accept] expects two arguments:
52+
- The ALPN we defined for ourselves above and
53+
- something that implements `ProtocolHandler`.
54+
55+
In the [quickstart], we used the `Blobs` struct from the existing `iroh-blobs` protocol, [which implements `ProtocolHandler`](https://docs.rs/iroh-blobs/latest/iroh_blobs/net_protocol/struct.Blobs.html#impl-ProtocolHandler-for-Blobs%3CS%3E).
56+
In this example, we'll build our own struct and implement `ProtocolHandler` ourselves.
57+
Let's call this struct `Echo`.
58+
59+
```rs
60+
#[derive(Debug, Clone)]
61+
struct Echo;
62+
```
63+
64+
The struct is actually empty, because the protocol is fully stateless.
65+
66+
<Note>
67+
If we were building a protocol for a database, then this struct would contain a database connection or the database contents directly, so that all connections can access it.
68+
</Note>
69+
70+
We'll also stub out an implementation of `ProtocolHandler` for this trait:
71+
72+
```rs
73+
impl iroh::protocol::ProtocolHandler for Echo {
74+
/// The `accept` method is called for each incoming connection for our ALPN.
75+
///
76+
/// The returned future runs on a newly spawned tokio task, so it can run as long as
77+
/// the connection lasts without blocking other connections.
78+
fn accept(&self, connection: iroh::Connection) -> n0_future::boxed::BoxFuture<Result<()>> {
79+
Box::pin(async move {
80+
// TODO!
81+
82+
Ok(())
83+
})
84+
}
85+
}
86+
```
87+
88+
<Note>
89+
We're using the `n0-future` crate for the return type of `accept` here.
90+
This is just a shorthand for `std::pin::Pin<Box<dyn Future<Output = Result<()>> + Send + 'static>>` (which is a mouthful!).
91+
This shorthand is also provided by `futures-lite`, `futures-util` and many more.
92+
We simply use `n0-future` as it re-exports all the crates we've vetted and commonly use at number 0.
93+
</Note>
94+
95+
The `accept` function is going to get called once an incoming connection with the correct ALPN is established.
96+
97+
Now, we can modify our router so it handles incoming connections with our newly created custom protocol:
98+
99+
```rs
100+
async fn start_accept_side() -> anyhow::Result<iroh::protocol::Router> {
101+
let endpoint = iroh::Endpoint::builder().discovery_n0().bind().await?;
102+
103+
let router = iroh::protocol::Router::builder(endpoint)
104+
.accept(ALPN, Echo) // This makes the router handle incoming connections with our ALPN via Echo::accept!
105+
.spawn()
106+
.await?;
107+
108+
Ok(router)
109+
}
110+
```
111+
112+
113+
## Implementing the Accepting Side
114+
115+
At the moment, the `Echo::accept` function is still stubbed out.
116+
The way it is currently implemented, it would drop the `iroh::Connection` immediately, causing the connection to close.
117+
Instead, we need to hold on to either the connection or one of its streams for as long as we want to interact with it.
118+
We'll do that by moving the connection to the future we return from `Echo::accept` and handling the protocol logic within that future:
119+
120+
```rs
121+
impl ProtocolHandler for Echo {
122+
fn accept(&self, connection: Connection) -> BoxFuture<Result<()>> {
123+
Box::pin(async move {
124+
// We can get the remote's node id from the connection.
125+
let node_id = connection.remote_node_id()?;
126+
println!("accepted connection from {node_id}");
127+
128+
// Our protocol is a simple request-response protocol, so we expect the
129+
// connecting peer to open a single bi-directional stream.
130+
let (mut send, mut recv) = connection.accept_bi().await?;
131+
132+
// Echo any bytes received back directly.
133+
// This will keep copying until the sender signals the end of data on the stream.
134+
let bytes_sent = tokio::io::copy(&mut recv, &mut send).await?;
135+
println!("Copied over {bytes_sent} byte(s)");
136+
137+
// By calling `finish` on the send stream we signal that we will not send anything
138+
// further, which makes the receive stream on the other end terminate.
139+
send.finish()?;
140+
141+
// Wait until the remote closes the connection, which it does once it
142+
// received the response.
143+
connection.closed().await;
144+
145+
Ok(())
146+
})
147+
}
148+
}
149+
```
150+
151+
We're using `tokio::io::copy` here to just copy any bytes we receive via `recv` to the `send` side of the bi-directional stream.
152+
Before we drop the connection, we briefly wait for `connection.closed()`.
153+
This effectively allows the connecting side to be the side that acknowledges that it received all data.
154+
Remember: Dropping the connection essentially "interrupts" all work on that connection, including sending or retransmitting lost data.
155+
Calling `SendStream::finish()` only *indicates* that we're done sending data, but doesn't wait for all data to be sent.
156+
Instead, we'll make the connecting side - as the side that last *receives* data - indicate proper protocol procedure by being the side to close the connection.
157+
158+
<Note>
159+
Closing connections properly with QUIC can be quite hard sometimes.
160+
We've [written about it][closing connections] before, but it trips us up every now and then still.
161+
</Note>
162+
163+
164+
## Implementing the Connecting Side
165+
166+
The connecting side is going to be the mirror image of the accepting side:
167+
- An `accept_bi` corresponds to an `open_bi`,
168+
- when data is received, the other side sends data,
169+
- when one side waits for `connection.closed()`, the other calls `connection.close()`.
170+
171+
Summarizing our protocol again, the connecting side will open a connection, send some data, receives the echo, then finally closes the connection.
172+
173+
This is what that looks like:
174+
175+
```rs
176+
async fn connect_side(addr: NodeAddr) -> Result<()> {
177+
let endpoint = Endpoint::builder().discovery_n0().bind().await?;
178+
179+
// Open a connection to the accepting node
180+
let conn = endpoint.connect(addr, ALPN).await?;
181+
182+
// Open a bidirectional QUIC stream
183+
let (mut send, mut recv) = conn.open_bi().await?;
184+
185+
// Send some data to be echoed
186+
send.write_all(b"Hello, world!").await?;
187+
188+
// Signal the end of data for this particular stream
189+
send.finish()?;
190+
191+
// Receive the echo, but limit reading up to maximum 1000 bytes
192+
let response = recv.read_to_end(1000).await?;
193+
assert_eq!(&response, b"Hello, world!");
194+
195+
// Explicitly close the whole connection.
196+
conn.close(0u32.into(), b"bye!");
197+
198+
// The above call only queues a close message to be sent (see how it's not async!).
199+
// We need to actually call this to make sure this message is sent out.
200+
endpoint.close().await;
201+
// If we don't call this, but continue using the endpoint, we then the queued
202+
// close call will eventually be picked up and sent.
203+
// But always try to wait for endpoint.close().await to go through before dropping
204+
// the endpoint to ensure any queued messages are sent through and connections are
205+
// closed gracefully.
206+
Ok(())
207+
}
208+
```
209+
210+
In this example we simply hard-coded the echo message "Hello World!", and we'll assert that that's what we receive back.
211+
212+
Note that we also take a `NodeAddr` as a parameter.
213+
This is the address of the accepting side, so we can use it to tell the `Endpoint` where in the world to connect to in the `endpoint.connect(addr, ALPN)` call.
214+
215+
216+
## Putting it all together
217+
218+
Now we have both sides of our protocol implemented!
219+
The connect side in `connect_side` and the accepting side in `start_accept_side`.
220+
221+
In a simple `main` function we can start the accepting side and concurrently connect to it before shutting down the accepting side again:
222+
223+
```rs
224+
#[tokio::main]
225+
async fn main() -> Result<()> {
226+
let router = start_accept_side().await?;
227+
let node_addr = router.endpoint().node_addr().await?;
228+
229+
connect_side(node_addr).await?;
230+
231+
// This makes sure the endpoint in the router is closed properly and connections close gracefully
232+
router.shutdown().await?;
233+
234+
Ok(())
235+
}
236+
```
237+
238+
This is what the output can look like when running:
239+
240+
```
241+
accepted connection from fb970f941d38eb5ef357316f13a6dc24f35f74d3403b1b9de79bd698a6531a79
242+
Copied over 13 byte(s)
243+
```
244+
245+
You can find all of the code in the [`echo.rs` example] in the iroh repo.
246+
247+
248+
# Appendix
249+
250+
## No router no problem
251+
252+
The router can make writing code with iroh easier, but it's not required.
253+
If the [`Router` API][router API] is too limited or perhaps too complex for your use case, it's fairly simple to replace with your own accept loop based on only `iroh::Endpoint` APIs.
254+
255+
To replace the router accept loop, you need to spawn your own tokio task instead of calling `iroh::protocol::RouterBuilder::spawn`.
256+
This task then calls `iroh::Endpoint::accept` in a loop and passes the incoming connections on to the same handler we looked at before.
257+
You also need to make sure to configure the right ALPNs on the endpoint yourself.
258+
259+
Putting it all together, you only need to change the `start_accept_side` function:
260+
261+
```rs
262+
async fn start_accept_side() -> anyhow::Result<iroh::Endpoint> {
263+
let endpoint = Endpoint::builder()
264+
.discovery_n0()
265+
// The accept side needs to opt-in to the protocols it accepts,
266+
// as any connection attempts that can't be found with a matching ALPN
267+
// will be rejected.
268+
.alpns(vec![ALPN.to_vec()])
269+
.bind()
270+
.await?;
271+
272+
// spawn a task so that `start_accept_side` returns immediately and we can continue in main().
273+
tokio::spawn({
274+
let endpoint = endpoint.clone();
275+
async move {
276+
// This task won't leak, because we call `endpoint.close()` in `main()`,
277+
// which causes `endpoint.accept().await` to return `None`.
278+
// In a more serious environment, we recommend avoiding `tokio::spawn` and use either a `TaskTracker` or
279+
// `JoinSet` instead to make sure you're not accidentally leaking tasks.
280+
while let Some(incoming) = endpoint.accept().await {
281+
// spawn a task for each incoming connection, so we can serve multiple connections asynchronously
282+
tokio::spawn(async move {
283+
let connection = incoming.await?;
284+
let result = Echo.accept(connection).await?;
285+
result
286+
});
287+
}
288+
289+
anyhow::Ok(())
290+
}
291+
});
292+
293+
Ok(endpoint)
294+
}
295+
```
296+
297+
We also return an `iroh::Endpoint` instead of an `iroh::protocol::Router`.
298+
This means our `main` function would need to call `endpoint.close()` instead of `router.shutdown()`, but otherwise it's the same.
299+
300+
Note that in this case, you don't even need to implement the `ProtocolHandler` trait.
301+
The only reason it exists is to provide an interface between protocols and the `Router`.
302+
If we're not using the router, then we could replace our `Echo.accept(connection)` call above with whatever function we'd like.
303+
We could even inline the whole function call instead.
304+
305+
You can see a version of the echo example completely without using a router or protocol handler trait in the [`echo-no-router.rs` example].
306+
307+
308+
## General Guidance
309+
310+
The echo example is a very simple protocol.
311+
There's many ways in which a protocol in practice is going to be more complex.
312+
Here's some advice that might be useful if you write your own protocol:
313+
314+
- **Re-use connections**: The version of the echo protocol above simply closes the connection after having echo-ed one stream.
315+
This is needlessly wasteful, if e.g. you'd want to echo multiple times or periodically.
316+
Instead, you could put a loop around `connection.accept_bi()` to accept multiple streams to echo on for the same connection.
317+
In practice, protocols often re-use the same connection for performance.
318+
Opening a QUIC stream is *really* cheap, as it doesn't need extra round-trips for the stream to get established, which is not the case for connections (unless in special circumstances when you're using the QUIC 0-RTT feature).
319+
- **Beware: QUIC streams are lazy**: Make sure that when you call `connection.open_bi()`, you *always send first* before you receive data.
320+
This is because the other side doesn't even know about a stream unless you *send* data on the stream first.
321+
This property is called "laziness" - as opposed to being "eager".
322+
The other side that accepts the stream will know about it at the same time that it gets the first bits of data.
323+
- **Closing QUIC connections can be hard**: This was already mentioned above, but it's worth re-iterating.
324+
As a general rule of thumb: The side to last read data should be the side to close a connection.
325+
Also try to always wait for `Endpoint::close` before dropping your endpoint, as that's required to make connections close gracefully.
326+
For everything else, feel free to read our blog post about [closing connections].
327+
328+
---
329+
330+
We hope the above helps you write your own iroh protocol.
331+
Should you do so, we'd love you to share your new protocol in the [iroh discord]!
332+
Have fun.
333+
334+
[protocol]: /docs/concepts/protocol
335+
[router]: /docs/concepts/router
336+
[quickstart]: /docs/quickstart
337+
[router API]: https://docs.rs/iroh/latest/iroh/protocol/struct.Router.html
338+
[router accept]: https://docs.rs/iroh/latest/iroh/protocol/struct.RouterBuilder.html#method.accept
339+
[closing connections]: https://www.iroh.computer/blog/closing-a-quic-connection
340+
[`echo.rs` example]: https://github.com/n0-computer/iroh/blob/main/iroh/examples/echo.rs
341+
[`echo-no-router.rs` example]: https://github.com/n0-computer/iroh/blob/main/iroh/examples/echo-no-router.rs
342+
[iroh discord]: https://iroh.computer/discord

src/components/GithubStars.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export default function GithubStars(props) {
66
return (
77
<Link href="https://github.com/n0-computer/iroh" className='p-2 -mt-2 flex text-sm leading-5 fill-zinc-400 text-zinc-600 transition hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-600 dark:hover:fill-zinc-600 hover:bg-black/10 rounded'>
88
<GithubIcon className="h-5 w-5" />
9-
<span className='ml-2 mt-0'>4.1k</span>
9+
<span className='ml-2 mt-0'>4.5k</span>
1010
</Link>
1111
)
1212
}

0 commit comments

Comments
 (0)