modo/server/http.rs
1use std::time::Duration;
2
3use tower::Layer;
4use tower_http::normalize_path::NormalizePathLayer;
5
6use super::Config;
7use crate::error::Result;
8use crate::runtime::Task;
9
10/// An opaque handle to the running HTTP server.
11///
12/// Implements [`Task`] so it can be passed to the [`crate::run!`] macro for
13/// coordinated graceful shutdown alongside other services.
14///
15/// Obtain a handle by calling [`http()`].
16pub struct HttpServer {
17 shutdown_tx: tokio::sync::oneshot::Sender<()>,
18 handle: tokio::task::JoinHandle<()>,
19 shutdown_timeout: Duration,
20}
21
22impl Task for HttpServer {
23 /// Signal the server to stop accepting new connections and wait for
24 /// in-flight requests to drain.
25 ///
26 /// If the drain does not complete within `shutdown_timeout_secs` (from
27 /// [`Config`]), a warning is logged and the function returns `Ok(())`.
28 async fn shutdown(self) -> Result<()> {
29 let _ = self.shutdown_tx.send(());
30 if tokio::time::timeout(self.shutdown_timeout, self.handle)
31 .await
32 .is_err()
33 {
34 tracing::warn!("server shutdown timed out, forcing exit");
35 }
36 Ok(())
37 }
38}
39
40/// Bind a TCP listener and start serving `router`.
41///
42/// Returns an [`HttpServer`] handle immediately; the server runs on a
43/// background Tokio task. Pass the handle to [`crate::run!`] so it is
44/// shut down gracefully when a signal arrives.
45///
46/// # Errors
47///
48/// Returns [`crate::Error`] if the TCP port cannot be bound.
49///
50/// # Example
51///
52/// ```no_run
53/// use modo::server::{Config, http};
54///
55/// #[tokio::main]
56/// async fn main() -> modo::Result<()> {
57/// let config = Config::default();
58/// let router = modo::axum::Router::new();
59/// let server = http(router, &config).await?;
60/// modo::run!(server).await
61/// }
62/// ```
63///
64/// With a [`HostRouter`](super::HostRouter):
65///
66/// ```no_run
67/// use modo::server::{self, Config, HostRouter};
68///
69/// #[tokio::main]
70/// async fn main() -> modo::Result<()> {
71/// let config = Config::default();
72/// let app = HostRouter::new()
73/// .host("acme.com", modo::axum::Router::new())
74/// .host("*.acme.com", modo::axum::Router::new());
75/// let server = server::http(app, &config).await?;
76/// modo::run!(server).await
77/// }
78/// ```
79pub async fn http(router: impl Into<axum::Router>, config: &Config) -> Result<HttpServer> {
80 let router = router.into();
81 let addr = format!("{}:{}", config.host, config.port);
82 let listener = tokio::net::TcpListener::bind(&addr)
83 .await
84 .map_err(|e| crate::error::Error::internal(format!("failed to bind to {addr}: {e}")))?;
85
86 let local_addr = listener
87 .local_addr()
88 .map_err(|e| crate::error::Error::internal(format!("failed to get local address: {e}")))?;
89
90 tracing::info!("server listening on {local_addr}");
91
92 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
93
94 // Wrap the router with NormalizePathLayer so trailing slashes are stripped
95 // before axum performs path matching. Applied here (not via Router::layer)
96 // because Router::layer runs *after* path matching.
97 let service = NormalizePathLayer::trim_trailing_slash().layer(router);
98 let make_service = axum::ServiceExt::<axum::extract::Request>::into_make_service(service);
99
100 let handle = tokio::spawn(async move {
101 axum::serve(listener, make_service)
102 .with_graceful_shutdown(async {
103 let _ = shutdown_rx.await;
104 })
105 .await
106 .ok();
107 });
108
109 Ok(HttpServer {
110 shutdown_tx,
111 handle,
112 shutdown_timeout: Duration::from_secs(config.shutdown_timeout_secs),
113 })
114}