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 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}