use crate::{
debug, dev_reload, error, error_pages, lifecycle, AppError, AppPaths, AppState, Config,
ErrorPages, GracefulShutdown,
};
use axum::http::{header, HeaderName, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Router;
use std::any::Any;
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tower_http::catch_panic::CatchPanicLayer;
use tower_http::services::ServeDir;
use tower_http::set_header::SetResponseHeaderLayer;
const HANDOFF_READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
const DEV_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
fn warn_if_session_driver_is_unsupported(config: &Config) {
if !is_supported_session_driver(&config.session_driver) {
tracing::warn!(
session_driver = %config.session_driver,
"SESSION_DRIVER is set to an unsupported value - Larust only supports \"database\" \
(the default) or \"file\". Falling back to \"database\". See \
docs/the-basics/middleware-sessions-and-csrf.md for what each one means."
);
}
}
fn is_supported_session_driver(value: &str) -> bool {
matches!(value, "database" | "file")
}
const STDIO_DETACHED_ENV: &str = "LARUST_STDIO_DETACHED";
fn default_log_channel_to_file_if_stdio_detached(config: &mut Config) {
let user_set_log_channel = std::env::var("LOG_CHANNEL").is_ok();
let stdio_is_detached = std::env::var_os(STDIO_DETACHED_ENV).is_some();
if !user_set_log_channel && stdio_is_detached {
config.log_channel = "file".to_string();
}
}
pub struct Application {
config: Config,
paths: AppPaths,
state: AppState,
router: Router,
graceful_shutdown: Option<GracefulShutdown>,
health_route: Option<String>,
}
impl Application {
pub fn new(config: fn() -> serde_json::Value) -> Result<Self, AppError> {
Self::with_paths(AppPaths::default(), config)
}
pub fn at_root(
root: impl Into<std::path::PathBuf>,
config: fn() -> serde_json::Value,
) -> Result<Self, AppError> {
Self::with_paths(AppPaths::new(root), config)
}
fn with_paths(paths: AppPaths, config: fn() -> serde_json::Value) -> Result<Self, AppError> {
dotenvy::from_path(paths.env()).ok();
let mut config = Config::from_value(&config())?;
default_log_channel_to_file_if_stdio_detached(&mut config);
crate::logging::init(&config, &paths);
warn_if_session_driver_is_unsupported(&config);
debug::set(config.app_debug);
config.clone().publish();
paths.clone().publish();
let state = AppState::new(config.clone(), paths.clone());
Ok(Self {
config,
paths,
state,
router: Router::new(),
graceful_shutdown: None,
health_route: None,
})
}
pub fn router(mut self, router: Router) -> Self {
self.router = router;
self
}
pub fn config(&self) -> &Config {
&self.config
}
pub fn state(&self) -> AppState {
self.state.clone()
}
pub fn paths(&self) -> &AppPaths {
&self.paths
}
pub fn with_graceful_shutdown(mut self, config: GracefulShutdown) -> Self {
self.graceful_shutdown = Some(config);
self
}
pub fn with_health_route(mut self, path: impl Into<String>) -> Self {
let path = path.into();
assert!(
path.starts_with('/'),
"health route must start with '/'; received {path:?}"
);
self.health_route = Some(path);
self
}
pub fn with_error_pages(self, pages: ErrorPages) -> Self {
error_pages::set(pages);
self
}
pub async fn serve(self) -> Result<(), AppError> {
let addr = SocketAddr::from(([127, 0, 0, 1], self.config.app_port));
tracing::info!(%addr, app = %self.config.app_name, env = %self.config.app_env, "starting server");
let is_dev_reload = std::env::var_os("LARUST_DEV_RELOAD").is_some();
let graceful_shutdown = self.graceful_shutdown.or_else(|| {
is_dev_reload.then_some(GracefulShutdown {
drain_timeout: DEV_DRAIN_TIMEOUT,
restart_channel: true,
})
});
let app_name = self.config.app_name.clone();
let is_handoff_replacement =
std::env::var_os(lifecycle::listener::INHERIT_LISTENER_ENV).is_some();
if is_handoff_replacement {
lifecycle::supervisor::arm_pdeathsig();
}
let std_listener = if is_handoff_replacement {
let mut line = String::new();
tokio::io::AsyncBufReadExt::read_line(
&mut tokio::io::BufReader::new(tokio::io::stdin()),
&mut line,
)
.await
.map_err(|source| AppError::Internal(Box::new(source)))?;
lifecycle::listener::inherit(&line)
.map_err(|source| AppError::Internal(Box::new(source)))?
} else {
lifecycle::listener::bind(addr)
.map_err(|source| AppError::Internal(Box::new(source)))?
};
let admin_listener = std_listener
.try_clone()
.map_err(|source| AppError::Internal(Box::new(source)))?;
std_listener
.set_nonblocking(true)
.map_err(|source| AppError::Internal(Box::new(source)))?;
let listener = tokio::net::TcpListener::from_std(std_listener)
.map_err(|source| AppError::Internal(Box::new(source)))?;
let router = if is_dev_reload {
self.router
.route("/__larust_dev", axum::routing::get(dev_reload::handler))
} else {
self.router
};
let router = if let Some(path) = self.health_route {
router.route(&path, axum::routing::get(health))
} else {
router
};
let router = router.fallback_service(ServeDir::new(self.paths.public()).not_found_service(
tower::service_fn(|_req: axum::extract::Request| async {
Ok::<_, std::convert::Infallible>(AppError::NotFound.into_response())
}),
));
let router = router.layer(SetResponseHeaderLayer::overriding(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
));
let router = router.layer(SetResponseHeaderLayer::if_not_present(
HeaderName::from_static("referrer-policy"),
HeaderValue::from_static("strict-origin-when-cross-origin"),
));
let router = router.layer(SetResponseHeaderLayer::if_not_present(
HeaderName::from_static("x-frame-options"),
HeaderValue::from_static("DENY"),
));
install_backtrace_capturing_panic_hook();
let router = router.layer(CatchPanicLayer::custom(handle_panic));
if is_handoff_replacement {
lifecycle::readiness::announce_ready();
lifecycle::supervisor::disarm_pdeathsig();
}
let Some(graceful_shutdown) = graceful_shutdown else {
axum::serve(
listener,
router.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.map_err(|source| AppError::Internal(Box::new(source)))?;
return Ok(());
};
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
let drain_timeout = graceful_shutdown.drain_timeout;
let restart_channel_enabled = graceful_shutdown.restart_channel;
let handed_off_child = Arc::new(AtomicBool::new(false));
tokio::spawn({
let handed_off_child = Arc::clone(&handed_off_child);
async move {
if restart_channel_enabled {
let address = lifecycle::admin::channel_address(&app_name);
tokio::select! {
_ = lifecycle::wait_for_termination() => {
tracing::info!(
?drain_timeout,
"shutdown signal received; draining in-flight requests"
);
}
outcome = lifecycle::admin::run_until_command(
&address,
&admin_listener,
HANDOFF_READY_TIMEOUT,
) => {
match outcome {
lifecycle::admin::AdminOutcome::Handoff(child) => {
tracing::info!(
pid = child.id(),
?drain_timeout,
"restart handoff succeeded; draining in-flight requests"
);
handed_off_child.store(true, Ordering::SeqCst);
}
lifecycle::admin::AdminOutcome::Stop => {
tracing::info!(
?drain_timeout,
"stop command received; draining in-flight requests"
);
}
}
}
}
} else {
lifecycle::wait_for_termination().await;
tracing::info!(
?drain_timeout,
"shutdown signal received; draining in-flight requests"
);
}
let _ = shutdown_tx.send(());
tokio::time::sleep(drain_timeout).await;
tracing::warn!(
"drain timeout elapsed; forcing exit with any remaining connections dropped"
);
std::process::exit(0);
}
});
axum::serve(
listener,
router.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(async move {
let _ = shutdown_rx.await;
})
.await
.map_err(|source| AppError::Internal(Box::new(source)))?;
if handed_off_child.load(Ordering::SeqCst) {
std::process::exit(0);
}
Ok(())
}
}
async fn health() -> Response {
let html = r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Application</title>
<style>
:root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f3f4f6; color: #111827; }
main { width: min(36rem, calc(100% - 3rem)); padding: 1.5rem; }
article { display: flex; gap: 1.25rem; align-items: flex-start; padding: 1.5rem; border-radius: .75rem; background: #fff; box-shadow: 0 20px 45px rgb(17 24 39 / .15); }
.status { position: relative; flex: 0 0 auto; width: .75rem; height: .75rem; margin-top: .38rem; border-radius: 999px; background: #4ade80; }
.status::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: #4ade80; animation: pulse 1.5s ease-out infinite; }
h1 { margin: 0; font-size: 1.25rem; }
p { margin: .5rem 0 0; color: #6b7280; font-size: .875rem; }
@keyframes pulse { from { transform: scale(1); opacity: .8; } to { transform: scale(2.4); opacity: 0; } }
@media (prefers-color-scheme: dark) { body { background: #111827; color: #f9fafb; } article { background: #1f2937; } p { color: #9ca3af; } }
@media (prefers-reduced-motion: reduce) { .status::before { animation: none; } }
</style>
</head>
<body>
<main>
<article role="status" aria-live="polite">
<span class="status" aria-hidden="true"></span>
<div>
<h1>Application up</h1>
<p>HTTP request received. Response rendered in <span id="response-time">--</span>.</p>
</div>
</article>
</main>
<script>
const responseTime = document.getElementById('response-time');
responseTime.textContent = `${Math.max(0, Math.round(performance.now()))}ms`;
</script>
</body>
</html>"#
.to_string();
(
StatusCode::OK,
[
(header::CONTENT_TYPE, "text/html; charset=utf-8"),
(header::CACHE_CONTROL, "no-store"),
],
html,
)
.into_response()
}
thread_local! {
static LAST_PANIC_BACKTRACE: std::cell::RefCell<Option<error::PanicBacktrace>> =
const { std::cell::RefCell::new(None) };
}
fn install_backtrace_capturing_panic_hook() {
static INSTALLED: std::sync::Once = std::sync::Once::new();
INSTALLED.call_once(|| {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
use std::backtrace::{Backtrace, BacktraceStatus};
let backtrace = Backtrace::capture();
let captured = match backtrace.status() {
BacktraceStatus::Captured => error::PanicBacktrace::Captured(backtrace.to_string()),
BacktraceStatus::Unsupported => error::PanicBacktrace::Unsupported,
_ => error::PanicBacktrace::Disabled,
};
LAST_PANIC_BACKTRACE.with(|cell| *cell.borrow_mut() = Some(captured));
previous(info);
}));
});
}
fn handle_panic(payload: Box<dyn Any + Send + 'static>) -> Response {
let message = match payload.downcast::<String>() {
Ok(s) => *s,
Err(payload) => match payload.downcast_ref::<&str>() {
Some(s) => s.to_string(),
None => "unknown panic payload".to_string(),
},
};
let backtrace = LAST_PANIC_BACKTRACE
.with(|cell| cell.borrow_mut().take())
.unwrap_or(error::PanicBacktrace::Disabled);
error::render_panic(&message, backtrace)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::routing::get;
use tower::ServiceExt;
#[tokio::test]
async fn panicking_handler_is_caught_and_rendered_instead_of_dropping_the_connection() {
async fn always_panics() -> &'static str {
panic!("boom");
}
let router = Router::new()
.route("/panic", get(always_panics))
.layer(CatchPanicLayer::custom(handle_panic));
let response = router
.oneshot(Request::get("/panic").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(body, crate::default_internal_html());
assert!(
!body.contains("boom"),
"panic message must not leak: {body}"
);
}
#[tokio::test]
async fn laravel_style_health_handler_returns_a_status_page() {
let response = health().await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get(header::CONTENT_TYPE).unwrap(),
"text/html; charset=utf-8"
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8(body.to_vec()).unwrap();
assert!(body.contains("Application up"));
assert!(body.contains("HTTP request received. Response rendered in"));
assert!(body.contains("performance.now()"));
}
#[tokio::test]
async fn fallback_service_serves_static_files_but_registered_routes_still_win() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("logo.png"), b"fake-image-bytes").unwrap();
std::fs::write(
dir.path().join("app.js"),
b"real file, should lose to the route",
)
.unwrap();
async fn app_js_route() -> &'static str {
"handled by a registered route"
}
let router = Router::new()
.route("/app.js", get(app_js_route))
.fallback_service(
ServeDir::new(dir.path()).not_found_service(tower::service_fn(
|_req: axum::extract::Request| async {
Ok::<_, std::convert::Infallible>(AppError::NotFound.into_response())
},
)),
);
let logo_response = router
.clone()
.oneshot(Request::get("/logo.png").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(logo_response.status(), StatusCode::OK);
let logo_bytes = axum::body::to_bytes(logo_response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(logo_bytes, "fake-image-bytes".as_bytes());
let app_js_response = router
.clone()
.oneshot(Request::get("/app.js").body(Body::empty()).unwrap())
.await
.unwrap();
let app_js_bytes = axum::body::to_bytes(app_js_response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(app_js_bytes, "handled by a registered route".as_bytes());
let missing_response = router
.oneshot(
Request::get("/does-not-exist.css")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(missing_response.status(), StatusCode::NOT_FOUND);
let missing_bytes = axum::body::to_bytes(missing_response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(
String::from_utf8(missing_bytes.to_vec()).unwrap(),
crate::default_not_found_html()
);
}
fn env_lock() -> &'static std::sync::Mutex<()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
}
#[test]
fn defaults_log_channel_to_file_when_stdio_is_detached_and_the_user_never_set_one() {
let _guard = env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
std::env::remove_var("LOG_CHANNEL");
std::env::set_var(STDIO_DETACHED_ENV, "1");
let mut config = Config::from_value(&serde_json::json!({})).unwrap();
assert_eq!(
config.log_channel, "stdout",
"sanity check on Config's own default"
);
default_log_channel_to_file_if_stdio_detached(&mut config);
assert_eq!(config.log_channel, "file");
std::env::remove_var(STDIO_DETACHED_ENV);
}
#[test]
fn does_not_override_an_explicitly_set_log_channel_even_if_it_matches_the_default() {
let _guard = env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
std::env::set_var("LOG_CHANNEL", "stdout");
std::env::set_var(STDIO_DETACHED_ENV, "1");
let mut config = Config::from_value(&serde_json::json!({})).unwrap();
default_log_channel_to_file_if_stdio_detached(&mut config);
assert_eq!(config.log_channel, "stdout");
std::env::remove_var("LOG_CHANNEL");
std::env::remove_var(STDIO_DETACHED_ENV);
}
#[test]
fn does_nothing_on_an_ordinary_boot_where_stdio_was_never_detached() {
let _guard = env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
std::env::remove_var("LOG_CHANNEL");
std::env::remove_var(STDIO_DETACHED_ENV);
let mut config = Config::from_value(&serde_json::json!({})).unwrap();
default_log_channel_to_file_if_stdio_detached(&mut config);
assert_eq!(
config.log_channel, "stdout",
"an ordinary cargo run/xr dev boot must be completely unaffected"
);
}
#[test]
fn is_supported_session_driver_accepts_exactly_the_two_real_values() {
assert!(is_supported_session_driver("database"));
assert!(is_supported_session_driver("file"));
}
#[test]
fn is_supported_session_driver_rejects_a_laravel_value_this_framework_does_not_implement() {
for unsupported in ["array", "redis", "cookie", "memcached", "dynamodb"] {
assert!(
!is_supported_session_driver(unsupported),
"{unsupported:?} must not be treated as a supported driver"
);
}
}
#[test]
fn is_supported_session_driver_rejects_an_empty_or_typo_d_value() {
assert!(!is_supported_session_driver(""));
assert!(!is_supported_session_driver("Database")); assert!(!is_supported_session_driver("fiel")); }
}