Skip to main content

moq_rtc/server/
mod.rs

1//! HTTP-server side: accept WHIP/WHEP offers from remote clients.
2//!
3//! Accepts offers that publish into [`moq_net::origin::Producer`] (WHIP
4//! / `server publish`) and pull from [`moq_net::origin::Consumer`] (WHEP /
5//! `server subscribe`). The HTTP listener itself is the caller's
6//! responsibility; the `moq-cli` `rtc` subcommand mounts the bundled axum
7//! routers (the `server` feature) under an HTTP server.
8
9pub mod whep;
10pub mod whip;
11
12mod mux;
13
14use std::collections::HashMap;
15use std::net::SocketAddr;
16use std::sync::{Arc, Mutex};
17use std::time::Duration;
18
19#[cfg(feature = "server")]
20use axum::Router;
21#[cfg(feature = "server")]
22use axum::http::{HeaderValue, StatusCode, Uri};
23use tokio::sync::{OnceCell, oneshot};
24
25use crate::{Error, Result};
26use mux::Mux;
27
28/// The result of a WHIP/WHEP [`whip::accept`] / [`whep::accept`]: the SDP answer
29/// to return to the client, plus an opaque resource id for the `Location` header
30/// (the RFC 9725 session resource URL).
31pub struct Response {
32	/// Opaque id identifying the negotiated session, for the `Location` header.
33	pub resource_id: String,
34	/// The SDP answer body (`Content-Type: application/sdp`).
35	pub answer: String,
36	session: AcceptedSession,
37}
38
39impl Response {
40	/// Run the negotiated media session until the peer disconnects, DELETE terminates it, or it errors.
41	pub async fn run(self) -> Result<()> {
42		self.session.run().await
43	}
44}
45
46impl std::fmt::Debug for Response {
47	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48		f.debug_struct("Response")
49			.field("resource_id", &self.resource_id)
50			.field("answer", &self.answer)
51			.finish_non_exhaustive()
52	}
53}
54
55/// The negotiated session runner behind [`Response::run`]: holds the mux
56/// registration for the session's lifetime, and unregisters the session from
57/// the server registry on drop. Built by [`whip::accept`] / [`whep::accept`]
58/// (descendant modules, so the private fields are in scope there).
59struct AcceptedSession {
60	server: Server,
61	resource_id: String,
62	session: Option<crate::session::Session>,
63	registration: Option<mux::Registration>,
64	cancel: Option<oneshot::Receiver<()>>,
65	role: &'static str,
66	// WHIP only: a clone of the ingest broadcast, so a deliberate DELETE can
67	// finish() it: a clean end instead of an abort error.
68	broadcast: Option<moq_net::broadcast::Producer>,
69}
70
71impl AcceptedSession {
72	async fn run(mut self) -> Result<()> {
73		let session = self.session.take().expect("accepted session missing driver");
74		let registration = self
75			.registration
76			.take()
77			.expect("accepted session missing mux registration");
78		let cancel = self.cancel.take().expect("accepted session missing cancel receiver");
79
80		let result = {
81			// Hold the mux registration for the session's lifetime; it
82			// unregisters on exit.
83			let _registration = registration;
84			tokio::select! {
85				res = session.run() => {
86					crate::session::log_session_end(self.role, &res);
87					res
88				}
89				_ = cancel => {
90					tracing::debug!(role = self.role, "webrtc session terminated by DELETE");
91					// A deliberate end: finish the broadcast so the origin
92					// unannounces it immediately.
93					if let Some(broadcast) = self.broadcast.take() {
94						broadcast.finish();
95					}
96					Ok(())
97				}
98			}
99		};
100		normalize_session_result(result)
101	}
102}
103
104impl Drop for AcceptedSession {
105	fn drop(&mut self) {
106		self.server.unregister_session(&self.resource_id);
107	}
108}
109
110/// Fold an ordinary peer disconnect into `Ok` so [`Response::run`] only errors
111/// on genuine failures.
112fn normalize_session_result(result: Result<()>) -> Result<()> {
113	match result {
114		Ok(()) | Err(Error::SessionClosed) => Ok(()),
115		Err(err) => Err(err),
116	}
117}
118
119/// Build the `Location` header for a negotiated session by appending the
120/// resource id to the request path, preserving whatever prefix the router is
121/// mounted under.
122#[cfg(feature = "server")]
123pub(crate) fn session_location(uri: &Uri, resource_id: &str) -> Option<HeaderValue> {
124	let base = uri.path().trim_end_matches('/');
125	let path = if base.is_empty() {
126		format!("/{resource_id}")
127	} else {
128		format!("{base}/{resource_id}")
129	};
130	HeaderValue::from_str(&path).ok()
131}
132
133/// Configuration shared by both `server publish` and `server subscribe`.
134#[derive(Clone, Debug)]
135#[non_exhaustive]
136pub struct Config {
137	/// Public UDP socket addresses that should be advertised as ICE host
138	/// candidates. Each is sent as a separate `candidate` line in the SDP
139	/// answer so a remote peer can reach us.
140	///
141	/// If empty, the mux advertises the bound address, substituting loopback
142	/// when the socket is bound to an unspecified address. That works for
143	/// loopback testing but not behind NAT.
144	pub ice_candidates: Vec<SocketAddr>,
145
146	/// Address the shared WebRTC media socket binds to. Every WHIP/WHEP session
147	/// shares this one UDP port (demuxed by ICE ufrag), so a deployment opens
148	/// exactly one media port in its firewall. `0.0.0.0:0` (the default) lets
149	/// the OS pick a port, which is fine for dev/loopback; production pins it.
150	pub udp_bind: SocketAddr,
151
152	/// How long relays keep a non-latest group of an ingested media track fetchable.
153	///
154	/// A retention budget, not a delivery one: it never makes a subscriber play further
155	/// behind live, it caps how far back a FETCH can still reach. `None` keeps hang's own
156	/// default, which suits a segmented egress (HLS/DASH) reading the broadcast downstream:
157	/// it may only advertise segments that are still fetchable. Lower it when nothing reads
158	/// history and the memory matters.
159	///
160	/// Ingest only (`server publish` / WHIP): WHEP egress reads a broadcast someone else
161	/// declared, so it ignores this.
162	pub max_age: Option<Duration>,
163
164	/// Connection allocator each ingested track claims its peak-hold bitrate on.
165	/// Ingest only (`server publish` / WHIP); WHEP egress ignores this.
166	pub bandwidth: moq_net::bandwidth::Allocator,
167}
168
169impl Default for Config {
170	fn default() -> Self {
171		Self {
172			ice_candidates: Vec::new(),
173			udp_bind: SocketAddr::from(([0, 0, 0, 0], 0)),
174			max_age: None,
175			bandwidth: moq_net::bandwidth::Allocator::unlimited(),
176		}
177	}
178}
179
180/// Shared WebRTC media state: the UDP mux and live sessions behind [`whip::accept`] and [`whep::accept`].
181#[derive(Clone)]
182pub struct Server {
183	inner: Arc<Inner>,
184}
185
186struct Inner {
187	config: Config,
188	/// The shared media socket + demux, bound lazily on the first accept so
189	/// `Server::new` can stay synchronous (and an idle server binds no port).
190	mux: OnceCell<Mux>,
191	/// Live sessions keyed by resource id, each holding a cancel sender the
192	/// session task selects on. Lets [`Server::terminate`] (and the bundled
193	/// `DELETE` route) end a session by its `Location` id.
194	sessions: Mutex<HashMap<String, oneshot::Sender<()>>>,
195}
196
197impl Server {
198	/// Build a server with shared ICE and media settings.
199	pub fn new(config: Config) -> Self {
200		Self {
201			inner: Arc::new(Inner {
202				config,
203				mux: OnceCell::new(),
204				sessions: Mutex::new(HashMap::new()),
205			}),
206		}
207	}
208
209	/// The shared media mux, bound (and its demux task spawned) on first use.
210	pub(crate) async fn mux(&self) -> Result<&Mux> {
211		self.inner
212			.mux
213			.get_or_try_init(|| Mux::bind(self.inner.config.udp_bind, &self.inner.config.ice_candidates))
214			.await
215	}
216
217	/// Router for `server publish` (WHIP). Mount under whichever HTTP path
218	/// the deployment prefers (`/whip`, `/`, ...).
219	///
220	/// The router derives the broadcast name from the request path and performs
221	/// no authentication. To own the route and authorize requests yourself
222	/// (resolving the broadcast name from a verified token), skip the router and
223	/// call [`whip::accept`] directly from your own handler. Only with the `server` feature.
224	#[cfg(feature = "server")]
225	pub fn publish_router(&self, publisher: moq_net::origin::Producer) -> Router {
226		whip::router(self.clone(), publisher)
227	}
228
229	/// Router for `server subscribe` (WHEP). Mount under whichever HTTP path
230	/// the deployment prefers (`/whep`, `/`, ...).
231	///
232	/// The router derives the broadcast name from the request path and performs
233	/// no authentication. To own the route and authorize requests yourself
234	/// (resolving the broadcast name from a verified token), skip the router and
235	/// call [`whep::accept`] directly from your own handler. Only with the `server` feature.
236	#[cfg(feature = "server")]
237	pub fn subscribe_router(&self, subscriber: moq_net::origin::Consumer) -> Router {
238		whep::router(self.clone(), subscriber)
239	}
240
241	pub(crate) fn config(&self) -> &Config {
242		&self.inner.config
243	}
244
245	/// Register a session under its resource id, returning the cancel receiver.
246	/// Called by [`whip::accept`] / [`whep::accept`] before returning the
247	/// negotiated session runner.
248	pub(crate) fn register_session(&self, resource_id: String) -> oneshot::Receiver<()> {
249		let (tx, rx) = oneshot::channel();
250		self.inner.sessions.lock().unwrap().insert(resource_id, tx);
251		rx
252	}
253
254	/// Drop a session's registry entry once it has ended on its own.
255	pub(crate) fn unregister_session(&self, resource_id: &str) {
256		self.inner.sessions.lock().unwrap().remove(resource_id);
257	}
258
259	/// Terminate a negotiated session by its resource id (the `Location` path
260	/// component from the WHIP/WHEP response). Returns `true` if a live session
261	/// was found and signalled to stop; the session task then releases its
262	/// broadcast announcement and mux registration. Embedders that own their own
263	/// HTTP routing call this to honor a WHIP/WHEP `DELETE`; the bundled routers
264	/// already wire it to the `DELETE` method.
265	pub fn terminate(&self, resource_id: &str) -> bool {
266		if let Some(cancel) = self.inner.sessions.lock().unwrap().remove(resource_id) {
267			let _ = cancel.send(());
268			true
269		} else {
270			false
271		}
272	}
273}
274
275/// Shared `DELETE` handler for both bundled routers: parse the resource id from
276/// the trailing path segment and terminate the matching session.
277#[cfg(feature = "server")]
278pub(crate) fn delete(server: &Server, path: &str) -> StatusCode {
279	match crate::sdp::parse_resource_id(path) {
280		Ok(id) if server.terminate(&id.to_string()) => StatusCode::OK,
281		Ok(_) => StatusCode::NOT_FOUND,
282		Err(_) => StatusCode::BAD_REQUEST,
283	}
284}
285
286#[cfg(test)]
287mod tests {
288	use super::*;
289
290	fn server() -> Server {
291		Server::new(Config::default())
292	}
293
294	#[test]
295	fn terminate_unknown_session_is_false() {
296		assert!(!server().terminate("00000000-0000-0000-0000-000000000000"));
297	}
298
299	#[test]
300	fn terminate_registered_session_once() {
301		let server = server();
302		let id = "11111111-1111-1111-1111-111111111111";
303		let _cancel = server.register_session(id.to_string());
304		assert!(server.terminate(id), "first terminate finds the session");
305		assert!(!server.terminate(id), "second terminate is a no-op");
306	}
307
308	#[test]
309	fn unregister_drops_the_entry() {
310		let server = server();
311		let id = "22222222-2222-2222-2222-222222222222";
312		let _cancel = server.register_session(id.to_string());
313		server.unregister_session(id);
314		assert!(!server.terminate(id), "unregistered session can't be terminated");
315	}
316
317	#[test]
318	fn peer_close_is_a_successful_session_result() {
319		assert!(normalize_session_result(Err(Error::SessionClosed)).is_ok());
320	}
321
322	#[cfg(feature = "server")]
323	#[test]
324	fn session_location_preserves_mount_path() {
325		let uri: Uri = "/whip/live/cam0?token=secret".parse().unwrap();
326		let location = session_location(&uri, "session-id").expect("header value");
327		assert_eq!(location, "/whip/live/cam0/session-id");
328	}
329}