moq_rtc/client/mod.rs
1//! HTTP-client side: dial a remote WHIP/WHEP endpoint over an SDP exchange.
2//!
3//! Counterpart to [`crate::server`]. Whereas the server accepts POSTed
4//! offers, the client mints the offer with `str0m::Rtc::sdp_api` and POSTs
5//! it to the remote URL. Once the answer arrives the same internal session
6//! driver takes over, so the per-codec bridges and UDP socket loop are shared.
7
8mod whep;
9mod whip;
10
11use std::net::SocketAddr;
12use std::time::Duration;
13
14use url::Url;
15
16/// Configuration shared by both `client publish` and `client subscribe`.
17#[derive(Clone, Debug, Default)]
18#[non_exhaustive]
19pub struct Config {
20 /// Public UDP socket addresses to advertise as ICE host candidates in
21 /// our outbound offer. Same semantics as [`crate::server::Config::ice_candidates`].
22 pub ice_candidates: Vec<SocketAddr>,
23
24 /// How long relays keep a non-latest group of an ingested media track fetchable.
25 /// Same semantics as [`crate::server::Config::max_age`].
26 ///
27 /// Ingest only ([`subscribe`](Client::subscribe) / WHEP): a WHIP
28 /// [`publish`](Client::publish) reads a broadcast someone else declared, so it
29 /// ignores this.
30 pub max_age: Option<Duration>,
31
32 /// Connection allocator each ingested track claims its peak-hold bitrate on.
33 /// Ingest only ([`subscribe`](Client::subscribe) / WHEP); a WHIP
34 /// [`publish`](Client::publish) ignores this.
35 pub bandwidth: moq_net::bandwidth::Allocator,
36}
37
38/// Outbound WHIP/WHEP dialer.
39///
40/// Owns a [`reqwest::Client`] reused across calls so connection pooling and
41/// rustls config survive between resources.
42#[derive(Clone)]
43pub struct Client {
44 config: Config,
45 http: reqwest::Client,
46}
47
48impl Client {
49 /// Build a dialer from the shared client [`Config`]. The underlying
50 /// [`reqwest::Client`] (with its connection pool and rustls config) is created
51 /// once here and reused across every [`subscribe`](Self::subscribe) /
52 /// [`publish`](Self::publish) call.
53 pub fn new(config: Config) -> Self {
54 Self {
55 config,
56 http: reqwest::Client::new(),
57 }
58 }
59
60 pub(crate) fn config(&self) -> &Config {
61 &self.config
62 }
63
64 pub(crate) fn http(&self) -> &reqwest::Client {
65 &self.http
66 }
67
68 /// `client subscribe`: pull a remote WHEP feed and publish it as
69 /// `broadcast` on the local origin. Returns once the session is
70 /// running in the background.
71 pub async fn subscribe(&self, url: Url, broadcast: moq_net::broadcast::Producer) -> crate::Result<()> {
72 whep::dial(self, url, broadcast).await
73 }
74
75 /// `client publish`: pull the broadcast at `path` from `origin` and push it to a
76 /// remote WHIP endpoint. Gated on the per-codec re-packetizer.
77 ///
78 /// Taking the origin plus path (rather than a resolved [`moq_net::broadcast::Consumer`])
79 /// lets the egress resolve a rendition whose catalog `broadcast` field references a
80 /// sibling broadcast, against the same origin.
81 pub async fn publish(
82 &self,
83 url: Url,
84 origin: moq_net::origin::Consumer,
85 path: impl moq_net::AsPath,
86 ) -> crate::Result<()> {
87 whip::dial(self, url, origin, path).await
88 }
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94
95 #[tokio::test]
96 async fn http_error_redacts_url() {
97 // A freed port refuses the connection, so reqwest fails with the dialed URL attached.
98 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
99 let address = listener.local_addr().expect("local address");
100 drop(listener);
101
102 let origin = moq_tokio::origin::spawn();
103 let broadcast = origin.create_broadcast("test").expect("create broadcast");
104 let url = format!("http://user:pass@{address}/whep/test?jwt=secret")
105 .parse()
106 .expect("WHEP URL");
107 let err = Client::new(Config::default())
108 .subscribe(url, broadcast)
109 .await
110 .expect_err("dial a closed port");
111
112 assert!(matches!(err, crate::Error::Http(_)), "unexpected error: {err}");
113 let printed = format!("{err} {err:?}");
114 for secret in ["jwt", "secret", "user:pass"] {
115 assert!(!printed.contains(secret), "error leaked {secret}: {printed}");
116 }
117 }
118}