Skip to main content

moq_hls/server/
mod.rs

1//! HTTP server: serves HLS and DASH for MoQ broadcasts, fetching media on demand.
2//!
3//! Routes are path-based, so one server can expose many broadcasts:
4//!
5//! ```text
6//! GET /{broadcast}/master.m3u8
7//! GET /{broadcast}/manifest.mpd
8//! GET /{broadcast}/{kind}/{rendition}/media.m3u8
9//! GET /{broadcast}/{kind}/{rendition}/init.{hash}.mp4
10//! GET /{broadcast}/{kind}/{rendition}/seg/{segment}.m4s
11//! GET /{broadcast}/{kind}/{rendition}/seg/t{pts}.m4s
12//! ```
13//!
14//! `{kind}` is `video` or `audio`, so a video and an audio rendition that share a
15//! name address distinct resources. The two `seg/` forms name the same bytes: HLS
16//! playlists address a segment by its aligned number, the DASH manifest by its
17//! timeline `pts` (`$Time$`). `{hash}` is a hash of the init segment's bytes, so a
18//! reconfigured rendition gets a new init URL. A broadcaster given a generation
19//! ([`Broadcaster::set_generation`](crate::export::Broadcaster::set_generation))
20//! prefixes every segment file with it (`seg/{generation}.{segment}.m4s`), and
21//! refuses segment URLs carrying any other.
22//!
23//! Every request is served. To gate access, wrap [`Server::router`] in your own
24//! [`axum`] middleware. It runs before routing, so a rejected request never reaches
25//! the origin, but it also sees the raw request URI rather than the path segments
26//! the server decodes. A broadcast occupies every segment before the HLS resource
27//! suffix, still percent-encoded: `/project/li%76e/master.m3u8` serves the broadcast
28//! `project/live`. Decode each segment before matching it against a policy, or a
29//! name can be encoded past the check.
30//!
31//! An embedder that authorizes each request itself (a token scoping the broadcast, a
32//! stats-metered origin per tenant) skips the router instead: parse the path with
33//! [`Route::parse`], check [`Route::broadcast`], rewrite it relative to the scope, and
34//! answer with [`Server::respond`] on a `Server` built from that scope's origin. The
35//! decoded broadcast is what a policy should check.
36//!
37//! ```no_run
38//! use axum::http::StatusCode;
39//! use axum::middleware::{self, Next};
40//! use axum::extract::Request;
41//! use axum::response::Response;
42//!
43//! async fn gate(req: Request, next: Next) -> Result<Response, StatusCode> {
44//!     match req.headers().get("authorization") {
45//!         Some(token) if token == "secret" => Ok(next.run(req).await),
46//!         _ => Err(StatusCode::UNAUTHORIZED),
47//!     }
48//! }
49//!
50//! # fn build(server: moq_hls::Server) -> axum::Router {
51//! server.router().layer(middleware::from_fn(gate))
52//! # }
53//! ```
54
55mod routes;
56
57pub use routes::{Resource, Route};
58
59use std::collections::HashMap;
60use std::sync::{Arc, Mutex};
61use std::time::Duration;
62
63use axum::Router;
64
65use crate::export::{Broadcaster, Config};
66
67/// How long to wait for a requested broadcast to be announced by the relay.
68const RESOLVE_TIMEOUT: Duration = Duration::from_secs(5);
69
70/// HLS export HTTP server. Cheap to clone (shared inner).
71#[derive(Clone)]
72pub struct Server {
73	inner: Arc<Inner>,
74}
75
76struct Inner {
77	origin: moq_net::origin::Consumer,
78	config: Config,
79	broadcasters: Mutex<HashMap<String, Arc<Broadcaster>>>,
80}
81
82impl Server {
83	/// Build a server reading broadcasts from `origin`. Every request is served;
84	/// gate access by layering middleware onto [`router`](Self::router).
85	pub fn new(origin: moq_net::origin::Consumer, config: Config) -> Self {
86		Self {
87			inner: Arc::new(Inner {
88				origin,
89				config,
90				broadcasters: Mutex::new(HashMap::new()),
91			}),
92		}
93	}
94
95	/// The axum router for the HLS endpoints, ready to nest or wrap in middleware.
96	pub fn router(&self) -> Router {
97		routes::router(self.clone())
98	}
99
100	/// Get or create the [`Broadcaster`] for `name`, resolving the broadcast from
101	/// the relay (waiting briefly for its announcement). Returns `None` if the
102	/// broadcast never shows up.
103	pub async fn broadcaster(&self, name: &str) -> Option<Arc<Broadcaster>> {
104		{
105			let mut broadcasters = self.inner.broadcasters.lock().unwrap();
106			if let Some(existing) = broadcasters.get(name) {
107				if !existing.is_closed() {
108					return Some(existing.clone());
109				}
110				broadcasters.remove(name);
111			}
112		}
113
114		// Confirm a route covers the broadcast (and it is in scope) before building a
115		// broadcaster; `Broadcaster::new` re-resolves it through the origin, which also
116		// lets a rendition's catalog `broadcast` field reference a sibling broadcast.
117		tokio::time::timeout(RESOLVE_TIMEOUT, self.inner.origin.routed(name))
118			.await
119			.ok()
120			.flatten()?;
121
122		let source = moq_mux::Source::new(self.inner.origin.consume(), name);
123		let broadcaster = Broadcaster::new(source, self.inner.config.clone())
124			.await
125			.map_err(|err| tracing::warn!(%err, %name, "failed to resolve broadcast catalog"))
126			.ok()?;
127
128		let mut broadcasters = self.inner.broadcasters.lock().unwrap();
129		if let Some(existing) = broadcasters.get(name) {
130			if !existing.is_closed() {
131				return Some(existing.clone());
132			}
133			broadcasters.remove(name);
134		}
135
136		let name = name.to_string();
137		broadcasters.insert(name.clone(), broadcaster.clone());
138		tokio::spawn(evict_closed(self.inner.clone(), name, broadcaster.clone()));
139		Some(broadcaster)
140	}
141}
142
143async fn evict_closed(inner: Arc<Inner>, name: String, broadcaster: Arc<Broadcaster>) {
144	broadcaster.closed().await;
145
146	let mut broadcasters = inner.broadcasters.lock().unwrap();
147	if broadcasters
148		.get(&name)
149		.is_some_and(|current| Arc::ptr_eq(current, &broadcaster))
150	{
151		broadcasters.remove(&name);
152	}
153}
154
155#[cfg(test)]
156mod tests {
157	/// Build an origin producer, spawning its driver on the ambient runtime.
158	fn produce_origin() -> moq_net::origin::Producer {
159		let (producer, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::default());
160		if tokio::runtime::Handle::try_current().is_ok() {
161			tokio::spawn(moq_net::time::run(driver));
162		} else {
163			// A sync test: nothing polls the driver, and dropping it would tear
164			// the origin down, so leak it and rely on the synchronous half.
165			std::mem::forget(driver);
166		}
167		producer
168	}
169
170	use super::*;
171
172	/// Let the origin's spawned attach/detach tasks run: a created broadcast
173	/// becomes routable (and a finished one unroutable) asynchronously.
174	async fn settle() {
175		for _ in 0..10 {
176			tokio::task::yield_now().await;
177		}
178	}
179
180	/// Mirrors the middleware example in this module's docs, which `doctest = false`
181	/// keeps out of the compiler's reach. Paused time skips the allowed request's
182	/// RESOLVE_TIMEOUT wait for a broadcast that never arrives.
183	#[tokio::test(start_paused = true)]
184	async fn router_middleware_gates_requests() {
185		use axum::body::Body;
186		use axum::extract::Request;
187		use axum::http::{StatusCode, header};
188		use axum::middleware::{self, Next};
189		use axum::response::Response;
190		use tower::ServiceExt;
191
192		async fn gate(req: Request, next: Next) -> Result<Response, StatusCode> {
193			match req.headers().get(header::AUTHORIZATION) {
194				Some(token) if token == "secret" => Ok(next.run(req).await),
195				_ => Err(StatusCode::UNAUTHORIZED),
196			}
197		}
198
199		// An origin with no broadcasts: a request that reaches the handlers 404s after
200		// RESOLVE_TIMEOUT, so a 401 proves the middleware rejected it first.
201		let origin = produce_origin();
202		let server = Server::new(origin.consume(), Config::default());
203		let app = server.router().layer(middleware::from_fn(gate));
204
205		let denied = app
206			.clone()
207			.oneshot(Request::builder().uri("/live/master.m3u8").body(Body::empty()).unwrap())
208			.await
209			.unwrap();
210		assert_eq!(denied.status(), StatusCode::UNAUTHORIZED);
211
212		let allowed = app
213			.oneshot(
214				Request::builder()
215					.uri("/live/master.m3u8")
216					.header(header::AUTHORIZATION, "secret")
217					.body(Body::empty())
218					.unwrap(),
219			)
220			.await
221			.unwrap();
222		assert_eq!(allowed.status(), StatusCode::NOT_FOUND);
223		// Pin the 404 to our handler: an unmatched route would also 404, but without
224		// the no-store that not_found() sets, so a broken URI can't fake this pass.
225		assert_eq!(allowed.headers()[header::CACHE_CONTROL], "no-store");
226	}
227
228	#[tokio::test(start_paused = true)]
229	async fn router_matches_multisegment_broadcast_when_nested() {
230		use axum::body::Body;
231		use axum::extract::Request;
232		use axum::http::{StatusCode, header};
233		use tower::ServiceExt;
234
235		let origin = produce_origin();
236		let server = Server::new(origin.consume(), Config::default());
237		let app = Router::new().nest("/hls", server.router());
238
239		let response = app
240			.oneshot(
241				Request::builder()
242					.uri("/hls/project/live/master.m3u8")
243					.body(Body::empty())
244					.unwrap(),
245			)
246			.await
247			.unwrap();
248
249		assert_eq!(response.status(), StatusCode::NOT_FOUND);
250		// The handler adds no-store. Axum's unmatched-route 404 does not.
251		assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
252	}
253
254	async fn closed_broadcaster() -> Arc<Broadcaster> {
255		let origin = produce_origin();
256		let producer = origin.create_broadcast("gone").expect("publish allowed");
257		producer.announce(Default::default()).expect("publish allowed");
258		settle().await;
259		let source = moq_mux::Source::new(origin.consume(), "gone");
260		let broadcaster = Broadcaster::new(source, Config::default())
261			.await
262			.expect("catalog broadcast resolves while announced");
263		// Close the publisher so the resolved broadcast (and the broadcaster) reports closed.
264		producer.close();
265		settle().await;
266		broadcaster
267	}
268
269	#[tokio::test]
270	async fn broadcaster_replaces_finished_cached_instance() {
271		let origin = produce_origin();
272		let server = Server::new(origin.consume(), Config::default());
273		let stale = closed_broadcaster().await;
274
275		server
276			.inner
277			.broadcasters
278			.lock()
279			.unwrap()
280			.insert("live".to_string(), stale.clone());
281		let _producer = origin.create_broadcast("live").expect("publish allowed");
282		_producer.announce(Default::default()).expect("publish allowed");
283		settle().await;
284
285		let fresh = server.broadcaster("live").await.expect("broadcast announced");
286
287		assert!(!Arc::ptr_eq(&stale, &fresh));
288		assert!(server.inner.broadcasters.lock().unwrap().contains_key("live"));
289	}
290
291	#[tokio::test]
292	async fn eviction_keeps_newer_cached_instance() {
293		let origin = produce_origin();
294		let server = Server::new(origin.consume(), Config::default());
295		let old = closed_broadcaster().await;
296		let new_producer = origin.create_broadcast("live").expect("publish allowed");
297		new_producer.announce(Default::default()).expect("publish allowed");
298		settle().await;
299		let new = Broadcaster::new(moq_mux::Source::new(origin.consume(), "live"), Config::default())
300			.await
301			.expect("catalog broadcast resolves while announced");
302
303		server
304			.inner
305			.broadcasters
306			.lock()
307			.unwrap()
308			.insert("live".to_string(), new.clone());
309
310		evict_closed(server.inner.clone(), "live".to_string(), old).await;
311
312		let cached = server.inner.broadcasters.lock().unwrap().get("live").cloned();
313		assert!(cached.is_some_and(|cached| Arc::ptr_eq(&cached, &new)));
314		new_producer.close();
315	}
316}