Skip to main content

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 still returns
28    /// `Ok(())` — shutdown is best-effort.
29    ///
30    /// # Errors
31    ///
32    /// This implementation is infallible and always returns `Ok(())`; the
33    /// [`Result`] signature comes from the [`Task`] trait.
34    async fn shutdown(self) -> Result<()> {
35        let _ = self.shutdown_tx.send(());
36        if tokio::time::timeout(self.shutdown_timeout, self.handle)
37            .await
38            .is_err()
39        {
40            tracing::warn!("server shutdown timed out, forcing exit");
41        }
42        Ok(())
43    }
44}
45
46/// Bind a TCP listener and start serving `router`.
47///
48/// `router` may be any value that implements `Into<axum::Router>`, including
49/// [`axum::Router`] itself and [`HostRouter`](super::HostRouter). The router
50/// is wrapped in a `NormalizePathLayer` so trailing slashes are trimmed before
51/// path matching (root `/` is preserved).
52///
53/// Returns an [`HttpServer`] handle immediately; the server runs on a
54/// background Tokio task. Pass the handle to [`crate::run!`] so it is
55/// shut down gracefully when a signal arrives.
56///
57/// # Errors
58///
59/// Returns [`crate::Error::internal`] if the TCP port cannot be bound
60/// (address already in use, permission denied, malformed host) or if the
61/// bound socket's local address cannot be read.
62///
63/// # Example
64///
65/// ```rust,no_run
66/// use modo::server::{Config, http};
67///
68/// #[tokio::main]
69/// async fn main() -> modo::Result<()> {
70///     let config = Config::default();
71///     let router = modo::axum::Router::new();
72///     let server = http(router, &config).await?;
73///     modo::run!(server).await
74/// }
75/// ```
76///
77/// With a [`HostRouter`](super::HostRouter):
78///
79/// ```rust,no_run
80/// use modo::server::{self, Config, HostRouter};
81///
82/// #[tokio::main]
83/// async fn main() -> modo::Result<()> {
84///     let config = Config::default();
85///     let app = HostRouter::new()
86///         .host("acme.com", modo::axum::Router::new())
87///         .host("*.acme.com", modo::axum::Router::new());
88///     let server = server::http(app, &config).await?;
89///     modo::run!(server).await
90/// }
91/// ```
92pub async fn http(router: impl Into<axum::Router>, config: &Config) -> Result<HttpServer> {
93    let router = router.into();
94    let addr = format!("{}:{}", config.host, config.port);
95    let listener = tokio::net::TcpListener::bind(&addr)
96        .await
97        .map_err(|e| crate::error::Error::internal(format!("failed to bind to {addr}: {e}")))?;
98
99    let local_addr = listener
100        .local_addr()
101        .map_err(|e| crate::error::Error::internal(format!("failed to get local address: {e}")))?;
102
103    tracing::info!("server listening on {local_addr}");
104
105    let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
106
107    // Wrap the router with NormalizePathLayer so trailing slashes are stripped
108    // before axum performs path matching. Applied here (not via Router::layer)
109    // because Router::layer runs *after* path matching.
110    let service = NormalizePathLayer::trim_trailing_slash().layer(router);
111    let make_service = axum::ServiceExt::<axum::extract::Request>::into_make_service(service);
112
113    let handle = tokio::spawn(async move {
114        axum::serve(listener, make_service)
115            .with_graceful_shutdown(async {
116                let _ = shutdown_rx.await;
117            })
118            .await
119            .ok();
120    });
121
122    Ok(HttpServer {
123        shutdown_tx,
124        handle,
125        shutdown_timeout: Duration::from_secs(config.shutdown_timeout_secs),
126    })
127}