diff --git a/Cargo.lock b/Cargo.lock
index 42378f90fe0..98f2503fa2f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3234,6 +3234,22 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "libp2p-stream"
+version = "0.1.0-alpha"
+dependencies = [
+ "futures",
+ "libp2p-core",
+ "libp2p-identity",
+ "libp2p-swarm",
+ "libp2p-swarm-test",
+ "rand 0.8.5",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+ "void",
+]
+
[[package]]
name = "libp2p-swarm"
version = "0.44.1"
@@ -5565,6 +5581,20 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+[[package]]
+name = "stream-example"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "futures",
+ "libp2p",
+ "libp2p-stream",
+ "rand 0.8.5",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+]
+
[[package]]
name = "stringmatch"
version = "0.4.0"
diff --git a/Cargo.toml b/Cargo.toml
index d10ed7e3bbf..9215d68dd9c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,6 +14,7 @@ members = [
"examples/ping",
"examples/relay-server",
"examples/rendezvous",
+ "examples/stream",
"examples/upnp",
"hole-punching-tests",
"identity",
@@ -45,10 +46,11 @@ members = [
"protocols/relay",
"protocols/rendezvous",
"protocols/request-response",
+ "protocols/stream",
"protocols/upnp",
- "swarm",
"swarm-derive",
"swarm-test",
+ "swarm",
"transports/dns",
"transports/noise",
"transports/plaintext",
@@ -57,11 +59,11 @@ members = [
"transports/tcp",
"transports/tls",
"transports/uds",
- "transports/webrtc",
"transports/webrtc-websys",
+ "transports/webrtc",
+ "transports/websocket-websys",
"transports/websocket",
"transports/webtransport-websys",
- "transports/websocket-websys",
"wasm-tests/webtransport-tests",
]
resolver = "2"
@@ -99,6 +101,7 @@ libp2p-relay = { version = "0.17.1", path = "protocols/relay" }
libp2p-rendezvous = { version = "0.14.0", path = "protocols/rendezvous" }
libp2p-request-response = { version = "0.26.1", path = "protocols/request-response" }
libp2p-server = { version = "0.12.5", path = "misc/server" }
+libp2p-stream = { version = "0.1.0-alpha", path = "protocols/stream" }
libp2p-swarm = { version = "0.44.1", path = "swarm" }
libp2p-swarm-derive = { version = "=0.34.2", path = "swarm-derive" } # `libp2p-swarm-derive` may not be compatible with different `libp2p-swarm` non-breaking releases. E.g. `libp2p-swarm` might introduce a new enum variant `FromSwarm` (which is `#[non-exhaustive]`) in a non-breaking release. Older versions of `libp2p-swarm-derive` would not forward this enum variant within the `NetworkBehaviour` hierarchy. Thus the version pinning is required.
libp2p-swarm-test = { version = "0.3.0", path = "swarm-test" }
diff --git a/examples/stream/Cargo.toml b/examples/stream/Cargo.toml
new file mode 100644
index 00000000000..5aab488358b
--- /dev/null
+++ b/examples/stream/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "stream-example"
+version = "0.1.0"
+edition = "2021"
+publish = false
+license = "MIT"
+
+[package.metadata.release]
+release = false
+
+[dependencies]
+anyhow = "1"
+futures = "0.3.29"
+libp2p = { path = "../../libp2p", features = [ "tokio", "quic"] }
+libp2p-stream = { path = "../../protocols/stream", version = "0.1.0-alpha" }
+rand = "0.8"
+tokio = { version = "1.35", features = ["full"] }
+tracing = "0.1.37"
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+
+[lints]
+workspace = true
diff --git a/examples/stream/README.md b/examples/stream/README.md
new file mode 100644
index 00000000000..8437a5ea21e
--- /dev/null
+++ b/examples/stream/README.md
@@ -0,0 +1,35 @@
+## Description
+
+This example shows the usage of the `stream::Behaviour`.
+As a counter-part to the `request_response::Behaviour`, the `stream::Behaviour` allows users to write stream-oriented protocols whilst having minimal interaction with the `Swarm`.
+
+In this showcase, we implement an echo protocol: All incoming data is echoed back to the dialer, until the stream is closed.
+
+## Usage
+
+To run the example, follow these steps:
+
+1. Start an instance of the example in one terminal:
+
+ ```sh
+ cargo run --bin stream-example
+ ```
+
+ Observe printed listen address.
+
+2. Start another instance in a new terminal, providing the listen address of the first one.
+
+ ```sh
+ cargo run --bin stream-example --
+ ```
+
+3. Both terminals should now continuosly print messages.
+
+## Conclusion
+
+The `stream::Behaviour` is an "escape-hatch" from the way typical rust-libp2p protocols are written.
+It is suitable for several scenarios including:
+
+- prototyping of new protocols
+- experimentation with rust-libp2p
+- integration in `async/await`-heavy applications
\ No newline at end of file
diff --git a/examples/stream/src/main.rs b/examples/stream/src/main.rs
new file mode 100644
index 00000000000..872ab8c3b98
--- /dev/null
+++ b/examples/stream/src/main.rs
@@ -0,0 +1,154 @@
+use std::{io, time::Duration};
+
+use anyhow::{Context, Result};
+use futures::{AsyncReadExt, AsyncWriteExt, StreamExt};
+use libp2p::{multiaddr::Protocol, Multiaddr, PeerId, Stream, StreamProtocol};
+use libp2p_stream as stream;
+use rand::RngCore;
+use tracing::level_filters::LevelFilter;
+use tracing_subscriber::EnvFilter;
+
+const ECHO_PROTOCOL: StreamProtocol = StreamProtocol::new("/echo");
+
+#[tokio::main]
+async fn main() -> Result<()> {
+ tracing_subscriber::fmt()
+ .with_env_filter(
+ EnvFilter::builder()
+ .with_default_directive(LevelFilter::INFO.into())
+ .from_env()?,
+ )
+ .init();
+
+ let maybe_address = std::env::args()
+ .nth(1)
+ .map(|arg| arg.parse::())
+ .transpose()
+ .context("Failed to parse argument as `Multiaddr`")?;
+
+ let mut swarm = libp2p::SwarmBuilder::with_new_identity()
+ .with_tokio()
+ .with_quic()
+ .with_behaviour(|_| stream::Behaviour::new())?
+ .with_swarm_config(|c| c.with_idle_connection_timeout(Duration::from_secs(10)))
+ .build();
+
+ swarm.listen_on("/ip4/127.0.0.1/udp/0/quic-v1".parse()?)?;
+
+ let mut incoming_streams = swarm
+ .behaviour()
+ .new_control()
+ .accept(ECHO_PROTOCOL)
+ .unwrap();
+
+ // Deal with incoming streams.
+ // Spawning a dedicated task is just one way of doing this.
+ // libp2p doesn't care how you handle incoming streams but you _must_ handle them somehow.
+ // To mitigate DoS attacks, libp2p will internally drop incoming streams if your application cannot keep up processing them.
+ tokio::spawn(async move {
+ // This loop handles incoming streams _sequentially_ but that doesn't have to be the case.
+ // You can also spawn a dedicated task per stream if you want to.
+ // Be aware that this breaks backpressure though as spawning new tasks is equivalent to an unbounded buffer.
+ // Each task needs memory meaning an aggressive remote peer may force you OOM this way.
+
+ while let Some((peer, stream)) = incoming_streams.next().await {
+ match echo(stream).await {
+ Ok(n) => {
+ tracing::info!(%peer, "Echoed {n} bytes!");
+ }
+ Err(e) => {
+ tracing::warn!(%peer, "Echo failed: {e}");
+ continue;
+ }
+ };
+ }
+ });
+
+ // In this demo application, the dialing peer initiates the protocol.
+ if let Some(address) = maybe_address {
+ let Some(Protocol::P2p(peer_id)) = address.iter().last() else {
+ anyhow::bail!("Provided address does not end in `/p2p`");
+ };
+
+ swarm.dial(address)?;
+
+ tokio::spawn(connection_handler(peer_id, swarm.behaviour().new_control()));
+ }
+
+ // Poll the swarm to make progress.
+ loop {
+ let event = swarm.next().await.expect("never terminates");
+
+ match event {
+ libp2p::swarm::SwarmEvent::NewListenAddr { address, .. } => {
+ let listen_address = address.with_p2p(*swarm.local_peer_id()).unwrap();
+ tracing::info!(%listen_address);
+ }
+ event => tracing::trace!(?event),
+ }
+ }
+}
+
+/// A very simple, `async fn`-based connection handler for our custom echo protocol.
+async fn connection_handler(peer: PeerId, mut control: stream::Control) {
+ loop {
+ tokio::time::sleep(Duration::from_secs(1)).await; // Wait a second between echos.
+
+ let stream = match control.open_stream(peer, ECHO_PROTOCOL).await {
+ Ok(stream) => stream,
+ Err(error @ stream::OpenStreamError::UnsupportedProtocol(_)) => {
+ tracing::info!(%peer, %error);
+ return;
+ }
+ Err(error) => {
+ // Other errors may be temporary.
+ // In production, something like an exponential backoff / circuit-breaker may be more appropriate.
+ tracing::debug!(%peer, %error);
+ continue;
+ }
+ };
+
+ if let Err(e) = send(stream).await {
+ tracing::warn!(%peer, "Echo protocol failed: {e}");
+ continue;
+ }
+
+ tracing::info!(%peer, "Echo complete!")
+ }
+}
+
+async fn echo(mut stream: Stream) -> io::Result {
+ let mut total = 0;
+
+ let mut buf = [0u8; 100];
+
+ loop {
+ let read = stream.read(&mut buf).await?;
+ if read == 0 {
+ return Ok(total);
+ }
+
+ total += read;
+ stream.write_all(&buf[..read]).await?;
+ }
+}
+
+async fn send(mut stream: Stream) -> io::Result<()> {
+ let num_bytes = rand::random::() % 1000;
+
+ let mut bytes = vec![0; num_bytes];
+ rand::thread_rng().fill_bytes(&mut bytes);
+
+ stream.write_all(&bytes).await?;
+
+ let mut buf = vec![0; num_bytes];
+ stream.read_exact(&mut buf).await?;
+
+ if bytes != buf {
+ return Err(io::Error::new(io::ErrorKind::Other, "incorrect echo"));
+ }
+
+ stream.close().await?;
+
+ Ok(())
+}
diff --git a/protocols/stream/CHANGELOG.md b/protocols/stream/CHANGELOG.md
new file mode 100644
index 00000000000..2e177e2f1bc
--- /dev/null
+++ b/protocols/stream/CHANGELOG.md
@@ -0,0 +1,3 @@
+## 0.1.0-alpha
+
+Initial release.
diff --git a/protocols/stream/Cargo.toml b/protocols/stream/Cargo.toml
new file mode 100644
index 00000000000..be340939720
--- /dev/null
+++ b/protocols/stream/Cargo.toml
@@ -0,0 +1,27 @@
+[package]
+name = "libp2p-stream"
+version = "0.1.0-alpha"
+edition = "2021"
+rust-version.workspace = true
+description = "Generic stream protocols for libp2p"
+license = "MIT"
+repository = "https://github.com/libp2p/rust-libp2p"
+keywords = ["peer-to-peer", "libp2p", "networking"]
+categories = ["network-programming", "asynchronous"]
+
+[dependencies]
+futures = "0.3.29"
+libp2p-core = { workspace = true }
+libp2p-identity = { workspace = true, features = ["peerid"] }
+libp2p-swarm = { workspace = true }
+tracing = "0.1.37"
+void = "1"
+rand = "0.8"
+
+[dev-dependencies]
+libp2p-swarm-test = { workspace = true }
+tokio = { version = "1", features = ["full"] }
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+
+[lints]
+workspace = true
diff --git a/protocols/stream/README.md b/protocols/stream/README.md
new file mode 100644
index 00000000000..c8a56e119ca
--- /dev/null
+++ b/protocols/stream/README.md
@@ -0,0 +1,69 @@
+# Generic (stream) protocols
+
+This module provides a generic [`NetworkBehaviour`](libp2p_swarm::NetworkBehaviour) for stream-oriented protocols.
+Streams are the fundamental primitive of libp2p and all other protocols are implemented using streams.
+In contrast to other [`NetworkBehaviour`](libp2p_swarm::NetworkBehaviour)s, this module takes a different design approach.
+All interaction happens through a [`Control`] that can be obtained via [`Behaviour::new_control`].
+[`Control`]s can be cloned and thus shared across your application.
+
+## Inbound
+
+To accept streams for a particular [`StreamProtocol`](libp2p_swarm::StreamProtocol) using this module, use [`Control::accept`]:
+
+### Example
+
+```rust,no_run
+# fn main() {
+# use libp2p_swarm::{Swarm, StreamProtocol};
+# use libp2p_stream as stream;
+# use futures::StreamExt as _;
+let mut swarm: Swarm = todo!();
+
+let mut control = swarm.behaviour().new_control();
+let mut incoming = control.accept(StreamProtocol::new("/my-protocol")).unwrap();
+
+let handler_future = async move {
+ while let Some((peer, stream)) = incoming.next().await {
+ // Execute your protocol using `stream`.
+ }
+};
+# }
+```
+
+### Resource management
+
+[`Control::accept`] returns you an instance of [`IncomingStreams`].
+This struct implements [`Stream`](futures::Stream) and like other streams, is lazy.
+You must continuously poll it to make progress.
+In the example above, this taken care of by using the [`StreamExt::next`](futures::StreamExt::next) helper.
+
+Internally, we will drop streams if your application falls behind in processing these incoming streams, i.e. if whatever loop calls `.next()` is not fast enough.
+
+### Drop
+
+As soon as you drop [`IncomingStreams`], the protocol will be de-registered.
+Any further attempt by remote peers to open a stream using the provided protocol will result in a negotiation error.
+
+## Outbound
+
+To open a new outbound stream for a particular protocol, use [`Control::open_stream`].
+
+### Example
+
+```rust,no_run
+# fn main() {
+# use libp2p_swarm::{Swarm, StreamProtocol};
+# use libp2p_stream as stream;
+# use libp2p_identity::PeerId;
+let mut swarm: Swarm = todo!();
+let peer_id: PeerId = todo!();
+
+let mut control = swarm.behaviour().new_control();
+
+let protocol_future = async move {
+ let stream = control.open_stream(peer_id, StreamProtocol::new("/my-protocol")).await.unwrap();
+
+ // Execute your protocol here using `stream`.
+};
+# }
+```
\ No newline at end of file
diff --git a/protocols/stream/src/behaviour.rs b/protocols/stream/src/behaviour.rs
new file mode 100644
index 00000000000..e02aca884b7
--- /dev/null
+++ b/protocols/stream/src/behaviour.rs
@@ -0,0 +1,143 @@
+use core::fmt;
+use std::{
+ sync::{Arc, Mutex},
+ task::{Context, Poll},
+};
+
+use futures::{channel::mpsc, StreamExt};
+use libp2p_core::{Endpoint, Multiaddr};
+use libp2p_identity::PeerId;
+use libp2p_swarm::{
+ self as swarm, dial_opts::DialOpts, ConnectionDenied, ConnectionId, FromSwarm,
+ NetworkBehaviour, THandler, THandlerInEvent, THandlerOutEvent, ToSwarm,
+};
+use swarm::{
+ behaviour::ConnectionEstablished, dial_opts::PeerCondition, ConnectionClosed, DialError,
+ DialFailure,
+};
+
+use crate::{handler::Handler, shared::Shared, Control};
+
+/// A generic behaviour for stream-oriented protocols.
+pub struct Behaviour {
+ shared: Arc>,
+ dial_receiver: mpsc::Receiver,
+}
+
+impl Default for Behaviour {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl Behaviour {
+ pub fn new() -> Self {
+ let (dial_sender, dial_receiver) = mpsc::channel(0);
+
+ Self {
+ shared: Arc::new(Mutex::new(Shared::new(dial_sender))),
+ dial_receiver,
+ }
+ }
+
+ /// Obtain a new [`Control`].
+ pub fn new_control(&self) -> Control {
+ Control::new(self.shared.clone())
+ }
+}
+
+/// The protocol is already registered.
+#[derive(Debug)]
+pub struct AlreadyRegistered;
+
+impl fmt::Display for AlreadyRegistered {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "The protocol is already registered")
+ }
+}
+
+impl std::error::Error for AlreadyRegistered {}
+
+impl NetworkBehaviour for Behaviour {
+ type ConnectionHandler = Handler;
+ type ToSwarm = ();
+
+ fn handle_established_inbound_connection(
+ &mut self,
+ connection_id: ConnectionId,
+ peer: PeerId,
+ _: &Multiaddr,
+ _: &Multiaddr,
+ ) -> Result, ConnectionDenied> {
+ Ok(Handler::new(
+ peer,
+ self.shared.clone(),
+ Shared::lock(&self.shared).receiver(peer, connection_id),
+ ))
+ }
+
+ fn handle_established_outbound_connection(
+ &mut self,
+ connection_id: ConnectionId,
+ peer: PeerId,
+ _: &Multiaddr,
+ _: Endpoint,
+ ) -> Result, ConnectionDenied> {
+ Ok(Handler::new(
+ peer,
+ self.shared.clone(),
+ Shared::lock(&self.shared).receiver(peer, connection_id),
+ ))
+ }
+
+ fn on_swarm_event(&mut self, event: FromSwarm) {
+ match event {
+ FromSwarm::ConnectionEstablished(ConnectionEstablished {
+ peer_id,
+ connection_id,
+ ..
+ }) => Shared::lock(&self.shared).on_connection_established(connection_id, peer_id),
+ FromSwarm::ConnectionClosed(ConnectionClosed { connection_id, .. }) => {
+ Shared::lock(&self.shared).on_connection_closed(connection_id)
+ }
+ FromSwarm::DialFailure(DialFailure {
+ peer_id: Some(peer_id),
+ error:
+ error @ (DialError::Transport(_)
+ | DialError::Denied { .. }
+ | DialError::NoAddresses
+ | DialError::WrongPeerId { .. }),
+ ..
+ }) => {
+ let reason = error.to_string(); // We can only forward the string repr but it is better than nothing.
+
+ Shared::lock(&self.shared).on_dial_failure(peer_id, reason)
+ }
+ _ => {}
+ }
+ }
+
+ fn on_connection_handler_event(
+ &mut self,
+ _peer_id: PeerId,
+ _connection_id: ConnectionId,
+ event: THandlerOutEvent,
+ ) {
+ void::unreachable(event);
+ }
+
+ fn poll(
+ &mut self,
+ cx: &mut Context<'_>,
+ ) -> Poll>> {
+ if let Poll::Ready(Some(peer)) = self.dial_receiver.poll_next_unpin(cx) {
+ return Poll::Ready(ToSwarm::Dial {
+ opts: DialOpts::peer_id(peer)
+ .condition(PeerCondition::DisconnectedAndNotDialing)
+ .build(),
+ });
+ }
+
+ Poll::Pending
+ }
+}
diff --git a/protocols/stream/src/control.rs b/protocols/stream/src/control.rs
new file mode 100644
index 00000000000..6aabaaff30e
--- /dev/null
+++ b/protocols/stream/src/control.rs
@@ -0,0 +1,124 @@
+use core::fmt;
+use std::{
+ io,
+ pin::Pin,
+ sync::{Arc, Mutex},
+ task::{Context, Poll},
+};
+
+use crate::AlreadyRegistered;
+use crate::{handler::NewStream, shared::Shared};
+
+use futures::{
+ channel::{mpsc, oneshot},
+ SinkExt as _, StreamExt as _,
+};
+use libp2p_identity::PeerId;
+use libp2p_swarm::{Stream, StreamProtocol};
+
+/// A (remote) control for opening new streams and registration of inbound protocols.
+///
+/// A [`Control`] can be cloned and thus allows for concurrent access.
+#[derive(Clone)]
+pub struct Control {
+ shared: Arc>,
+}
+
+impl Control {
+ pub(crate) fn new(shared: Arc>) -> Self {
+ Self { shared }
+ }
+
+ /// Attempt to open a new stream for the given protocol and peer.
+ ///
+ /// In case we are currently not connected to the peer, we will attempt to make a new connection.
+ ///
+ /// ## Backpressure
+ ///
+ /// [`Control`]s support backpressure similarly to bounded channels:
+ /// Each [`Control`] has a guaranteed slot for internal messages.
+ /// A single control will always open one stream at a time which is enforced by requiring `&mut self`.
+ ///
+ /// This backpressure mechanism breaks if you clone [`Control`]s excessively.
+ pub async fn open_stream(
+ &mut self,
+ peer: PeerId,
+ protocol: StreamProtocol,
+ ) -> Result {
+ tracing::debug!(%peer, "Requesting new stream");
+
+ let mut new_stream_sender = Shared::lock(&self.shared).sender(peer);
+
+ let (sender, receiver) = oneshot::channel();
+
+ new_stream_sender
+ .send(NewStream { protocol, sender })
+ .await
+ .map_err(|e| io::Error::new(io::ErrorKind::ConnectionReset, e))?;
+
+ let stream = receiver
+ .await
+ .map_err(|e| io::Error::new(io::ErrorKind::ConnectionReset, e))??;
+
+ Ok(stream)
+ }
+
+ /// Accept inbound streams for the provided protocol.
+ ///
+ /// To stop accepting streams, simply drop the returned [`IncomingStreams`] handle.
+ pub fn accept(
+ &mut self,
+ protocol: StreamProtocol,
+ ) -> Result {
+ Shared::lock(&self.shared).accept(protocol)
+ }
+}
+
+/// Errors while opening a new stream.
+#[derive(Debug)]
+#[non_exhaustive]
+pub enum OpenStreamError {
+ /// The remote does not support the requested protocol.
+ UnsupportedProtocol(StreamProtocol),
+ /// IO Error that occurred during the protocol handshake.
+ Io(std::io::Error),
+}
+
+impl From for OpenStreamError {
+ fn from(v: std::io::Error) -> Self {
+ Self::Io(v)
+ }
+}
+
+impl fmt::Display for OpenStreamError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ OpenStreamError::UnsupportedProtocol(p) => {
+ write!(f, "failed to open stream: remote peer does not support {p}")
+ }
+ OpenStreamError::Io(e) => {
+ write!(f, "failed to open stream: io error: {e}")
+ }
+ }
+ }
+}
+
+/// A handle to inbound streams for a particular protocol.
+#[must_use = "Streams do nothing unless polled."]
+pub struct IncomingStreams {
+ receiver: mpsc::Receiver<(PeerId, Stream)>,
+}
+
+impl IncomingStreams {
+ pub(crate) fn new(receiver: mpsc::Receiver<(PeerId, Stream)>) -> Self {
+ Self { receiver }
+ }
+}
+
+impl futures::Stream for IncomingStreams {
+ type Item = (PeerId, Stream);
+
+ fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll