1use std::future::IntoFuture;
8use std::path::{Path, PathBuf};
9use std::sync::OnceLock;
10use std::time::Duration;
11
12use axum::extract::{Request, State};
13use axum::response::{IntoResponse, Response};
14use axum::serve::ListenerExt;
15use leptos::config::{Env, LeptosOptions};
16use leptos_axum::LeptosRoutes;
17
18const IMAGE_UPLOAD_MAX_BYTES: usize = 10 * 1024 * 1024;
19const IMAGE_UPLOAD_TIMEOUT: Duration = Duration::from_secs(300);
20const BACKUP_IMPORT_TIMEOUT: Duration = Duration::from_secs(600);
21const EXPORT_TIMEOUT: Duration = Duration::from_secs(120);
22const APP_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
23const SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
26
27static BACKGROUND_TASKS_STARTED: OnceLock<()> = OnceLock::new();
28
29#[derive(Clone, Copy)]
30struct ServerOptions {
31 ssr_cache_secs: u64,
32 expose_version_headers: bool,
33}
34
35pub fn run() {
37 dotenvy::dotenv().ok();
39 init_tracing();
40 crate::build_info::log_build_info();
41 validate_required_configuration();
42 crate::api::csrf::warn_if_app_base_url_unset();
43
44 run_database_bootstrap();
45
46 crate::ssr_cache::invalidate_ssr_all_public();
48 crate::ssr_cache::invalidate_ssr_route("/admin/preview");
49
50 let options = ServerOptions {
51 ssr_cache_secs: crate::utils::server::parse_ssr_cache_secs(),
52 expose_version_headers: crate::utils::server::parse_env_bool(
53 "EXPOSE_VERSION_HEADERS",
54 true,
55 ),
56 };
57 tracing::info!(
58 ssr_cache_secs = options.ssr_cache_secs,
59 max_page_bytes = crate::config::ssr_cache().max_page_bytes,
60 "匿名公开页 HTML 缓存:32 MiB 上限、写入通知失效、TTL 兜底"
61 );
62 let edge_ttl_secs = crate::config::ssr_cache().edge_ttl_secs;
63 let edge_purge = crate::tasks::edge_purge::is_configured();
64 if edge_ttl_secs > 0 && !edge_purge {
65 tracing::warn!(
66 edge_ttl_secs,
67 "公开页 HTML 允许 CDN 缓存,但未配置 EdgeOne 清缓存凭据:内容变更要等 s-maxage 到期才在边缘生效"
68 );
69 } else {
70 tracing::info!(edge_ttl_secs, edge_purge, "公开页 HTML 边缘缓存");
71 }
72 tracing::info!(
73 expose_version_headers = options.expose_version_headers,
74 "版本响应头开关(Server / X-Yggdrasil-Version / X-Yggdrasil-Git / X-Yggdrasil-Hash)"
75 );
76
77 serve_application(options);
78}
79
80fn init_tracing() {
81 use tracing_subscriber::prelude::*;
82
83 let fmt_filter = tracing_subscriber::EnvFilter::try_from_default_env()
86 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
87 let fmt_layer = tracing_subscriber::fmt::layer().with_filter(fmt_filter);
88 let capture_layer = crate::api::logs::capture::CaptureLayer
89 .with_filter(crate::api::logs::capture::log_viewer_filter());
90 tracing_subscriber::registry()
91 .with(fmt_layer)
92 .with(capture_layer)
93 .init();
94}
95
96fn validate_required_configuration() {
97 if std::env::var("DATABASE_URL").is_err() {
98 tracing::error!(
99 "DATABASE_URL environment variable not set. Make sure .env exists or the variable is exported."
100 );
101 eprintln!("ERROR: DATABASE_URL environment variable not set");
102 eprintln!(
103 "HINT: create a .env file with DATABASE_URL=postgres://user:pass@host:5432/dbname"
104 );
105 std::process::exit(1);
106 }
107
108 if let Err(error) = crate::db::pool::validate_database_url() {
111 tracing::error!(%error);
112 eprintln!("ERROR: {error}");
113 if error.starts_with("DB_POOL_SIZE") {
114 eprintln!("HINT: DB_POOL_SIZE must be a positive integer (e.g. 20).");
115 } else {
116 eprintln!("HINT: expected something like postgres://user:pass@host:5432/dbname");
117 }
118 std::process::exit(1);
119 }
120}
121
122fn run_database_bootstrap() {
123 let migrate_rt = match tokio::runtime::Builder::new_current_thread()
126 .enable_all()
127 .build()
128 {
129 Ok(runtime) => runtime,
130 Err(error) => {
131 tracing::error!(%error, "failed to build migration runtime");
132 eprintln!("ERROR: failed to build migration runtime: {error}");
133 std::process::exit(1);
134 }
135 };
136
137 migrate_rt.block_on(async {
138 tracing::info!("running database migrations");
139
140 if let Err(error) = crate::db::pool::ensure_database().await {
141 tracing::error!("failed to ensure target database exists: {error}");
142 eprintln!("ERROR: failed to ensure target database exists: {error}");
143 eprintln!("HINT: verify DATABASE_URL; the role needs CREATEDB (or CREATE privilege on the 'postgres' DB) to auto-create the target database.");
144 std::process::exit(1);
145 }
146
147 let mut conn = match crate::db::pool::get_conn_for_startup().await {
148 Ok(conn) => conn,
149 Err(error) => {
150 let secs = crate::utils::server::parse_migrate_startup_timeout();
151 tracing::error!(%error, "could not connect to database within {secs}s startup window");
152 eprintln!("ERROR: could not connect to database within {secs}s startup window: {error}");
153 eprintln!("HINT: is PostgreSQL running and reachable at the configured DATABASE_URL?");
154 eprintln!("HINT: raise MIGRATE_STARTUP_TIMEOUT_SECS if the DB needs longer to start.");
155 std::process::exit(1);
156 }
157 };
158
159 if let Err(error) = crate::db::migrate::run_on_conn(&mut conn).await {
160 tracing::error!("database migration failed: {error}");
161 eprintln!("ERROR: database migration failed: {error}");
162 eprintln!("HINT: check the logs above; verify DATABASE_URL and that PostgreSQL is healthy.");
163 std::process::exit(1);
164 }
165
166 if let Err(error) = crate::api::settings::bootstrap_startup_settings(&conn).await {
167 tracing::error!(error = ?error, "critical startup settings failed to load");
168 eprintln!("ERROR: critical startup settings failed to load: {error:?}");
169 eprintln!("HINT: verify the settings table and PostgreSQL health; the server will not start with unknown security limits.");
170 std::process::exit(1);
171 }
172
173 if let Err(error) = crate::api::auth::sync_admin_from_env(&conn).await {
175 tracing::warn!(error = ?error, "初始管理员 env 同步失败");
176 }
177
178 });
179
180 drop(migrate_rt);
181}
182
183fn serve_application(options: ServerOptions) -> ! {
184 let runtime = match tokio::runtime::Builder::new_multi_thread()
185 .enable_all()
186 .build()
187 {
188 Ok(runtime) => runtime,
189 Err(error) => {
190 tracing::error!(%error, "failed to build server runtime");
191 eprintln!("ERROR: failed to build server runtime: {error}");
192 std::process::exit(1);
193 }
194 };
195
196 let result = runtime.block_on(async move {
197 let addr = server_addr();
198 let listener = match tokio::net::TcpListener::bind(addr).await {
199 Ok(listener) => listener,
200 Err(error) => fatal_bind_error(addr, error),
201 };
202 let listener = listener.tap_io(|tcp| {
205 if let Err(error) = tcp.set_nodelay(true) {
206 tracing::debug!(%error, "failed to set TCP_NODELAY");
207 }
208 });
209 let router = build_router(options);
211 tracing::info!("server listening on {addr}");
212 let server = axum::serve(
215 listener,
216 router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
217 )
218 .with_graceful_shutdown(shutdown_signal())
219 .into_future();
220 let drain_deadline = async {
221 crate::shutdown::requested().await;
222 tokio::time::sleep(SHUTDOWN_DRAIN_TIMEOUT).await;
223 };
224 tokio::select! {
225 result = server => result,
226 () = drain_deadline => {
227 tracing::warn!(
228 "在途请求 {} 秒内未结束,放弃等待并退出",
229 SHUTDOWN_DRAIN_TIMEOUT.as_secs()
230 );
231 Ok(())
232 }
233 }
234 });
235
236 match result {
237 Ok(()) => std::process::exit(0),
238 Err(error) => {
239 tracing::error!(%error, "server stopped unexpectedly");
240 eprintln!("ERROR: server stopped unexpectedly: {error}");
241 std::process::exit(1);
242 }
243 }
244}
245
246async fn shutdown_signal() {
248 let ctrl_c = async {
249 let _ = tokio::signal::ctrl_c().await;
250 };
251 #[cfg(unix)]
252 let terminate = async {
253 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
254 Ok(mut signal) => {
255 signal.recv().await;
256 }
257 Err(_) => std::future::pending::<()>().await,
258 }
259 };
260 #[cfg(not(unix))]
261 let terminate = std::future::pending::<()>();
262
263 tokio::select! {
264 _ = ctrl_c => {},
265 _ = terminate => {},
266 }
267 tracing::info!("收到退出信号,开始优雅关闭");
268 crate::shutdown::trigger();
269}
270
271fn fatal_bind_error(addr: std::net::SocketAddr, error: impl std::fmt::Display) -> ! {
272 tracing::error!(%error, "无法绑定监听地址 {addr}");
273 eprintln!("ERROR: 无法绑定监听地址 {addr}: {error}");
274 eprintln!(
275 "HINT: 端口 {} 可能已被占用。用 `lsof -i :{}` 查看占用进程,或设置 PORT 环境变量换一个端口。",
276 addr.port(),
277 addr.port()
278 );
279 std::process::exit(1);
280}
281
282fn spawn_background_tasks_once() {
283 if BACKGROUND_TASKS_STARTED.set(()).is_err() {
284 return;
285 }
286
287 use crate::tasks::{self, spawn_supervised};
288 spawn_supervised("ip_purge", tasks::ip_purge::run_purge());
289 spawn_supervised("session_cleanup", tasks::session_cleanup::run_cleanup());
290 spawn_supervised("post_purge", tasks::post_purge::run_purge());
291 spawn_supervised("backup", tasks::backup::run_scheduler());
292 spawn_supervised("cache_sync", tasks::cache_sync::run());
293 spawn_supervised("edge_purge", tasks::edge_purge::run());
294 spawn_supervised(
295 "image_cache_cleanup",
296 tasks::image_cache_cleanup::run_cleanup(),
297 );
298 spawn_supervised("orphan_asset_purge", tasks::orphan_asset_purge::run_purge());
299 spawn_supervised("log_writer", tasks::log_writer::run_writer());
300 spawn_supervised("log_purge", tasks::log_purge::run_purge());
301 tasks::sysinfo_sampler::spawn_sampler();
302 tokio::spawn(crate::api::code_runner::readiness::log_runner_readiness());
303}
304
305fn init_cache_namespace(mode: &str) {
310 let release = std::fs::read_to_string(site_root().join("release-manifest.json"))
311 .ok()
312 .and_then(|json| serde_json::from_str::<serde_json::Value>(&json).ok())
313 .and_then(|value| {
314 value
315 .get("release_id")
316 .and_then(|v| v.as_str())
317 .map(str::to_owned)
318 })
319 .unwrap_or_else(|| crate::build_info::BUILD_INFO.git_describe.to_owned());
320 crate::ssr_cache::set_release_namespace(&format!("{mode}/{release}"));
321}
322
323fn previous_public_dir() -> Option<PathBuf> {
326 let dir = std::env::var_os("YGG_PREVIOUS_PUBLIC_DIR").filter(|v| !v.is_empty())?;
327 let dir = PathBuf::from(dir);
328 if !dir.is_dir() {
329 tracing::warn!(
330 dir = %dir.display(),
331 "YGG_PREVIOUS_PUBLIC_DIR 不是目录,旧发布资源保留窗口停用"
332 );
333 return None;
334 }
335 Some(dir)
336}
337
338pub(crate) fn site_root() -> PathBuf {
343 match std::env::var_os("LEPTOS_SITE_ROOT").filter(|v| !v.is_empty()) {
344 Some(root) => PathBuf::from(root),
345 None => exe_dir().join("public"),
346 }
347}
348
349fn exe_dir() -> PathBuf {
351 let mut dir = std::env::current_exe().unwrap_or_else(|e| {
352 panic!("无法定位可执行文件以推导站点目录:{e}");
353 });
354 dir.pop();
355 dir
356}
357
358fn server_addr() -> std::net::SocketAddr {
360 fn env_or(key: &str, default: &str) -> String {
361 std::env::var(key)
362 .ok()
363 .filter(|v| !v.is_empty())
364 .unwrap_or_else(|| default.to_string())
365 }
366
367 let ip = env_or("IP", "127.0.0.1");
368 let port = env_or("PORT", "8080");
369 let ip = ip.parse::<std::net::IpAddr>().unwrap_or_else(|error| {
370 eprintln!("ERROR: IP 环境变量 {ip:?} 不是有效的 IP 地址:{error}");
371 std::process::exit(1);
372 });
373 let port = port.parse::<u16>().unwrap_or_else(|error| {
374 eprintln!("ERROR: PORT 环境变量 {port:?} 不是有效的端口号:{error}");
375 std::process::exit(1);
376 });
377 std::net::SocketAddr::new(ip, port)
378}
379
380fn leptos_options() -> LeptosOptions {
382 LeptosOptions::builder()
383 .output_name("yggdrasil")
384 .site_root(site_root().to_string_lossy().into_owned())
385 .site_pkg_dir("pkg")
386 .site_addr(server_addr())
387 .reload_port(3001)
388 .hash_files(true)
389 .env(if cfg!(debug_assertions) {
390 Env::DEV
391 } else {
392 Env::PROD
393 })
394 .build()
395}
396
397fn validate_site_assets(options: &LeptosOptions) -> bool {
403 let hash_path = exe_dir().join("hash.txt");
404 let hashes = std::fs::read_to_string(&hash_path).unwrap_or_else(|_| {
405 panic!(
406 "hash.txt 缺失({}):前端产物与服务端二进制不配套,拒绝启动",
407 hash_path.display()
408 )
409 });
410
411 let (mut js, mut wasm, mut manifest, mut entry_content_addressed) = (None, None, None, false);
412 for line in hashes.lines() {
413 if let Some((key, value)) = line.trim().split_once(':') {
414 let value = value.trim().to_string();
415 match key.trim() {
416 "js" => js = Some(value),
417 "wasm" => wasm = Some(value),
418 "manifest" => manifest = Some(value),
419 "entry" => entry_content_addressed = value == "content-addressed",
420 _ => {}
421 }
422 }
423 }
424
425 let pkg = Path::new(options.site_root.as_ref()).join(options.site_pkg_dir.as_ref());
426 let name = options.output_name.as_ref();
427 let mut missing: Vec<String> = Vec::new();
428 match js {
429 Some(hash) => {
430 let file = pkg.join(format!("{name}.{hash}.js"));
431 if !file.is_file() {
432 missing.push(file.display().to_string());
433 }
434 }
435 None => missing.push(format!("{}(缺少 js 行)", hash_path.display())),
436 }
437 match wasm {
438 Some(hash) => {
439 let file = pkg.join(format!("{name}.{hash}.wasm"));
440 if !file.is_file() {
441 missing.push(file.display().to_string());
442 }
443 }
444 None => missing.push(format!("{}(缺少 wasm 行)", hash_path.display())),
445 }
446 if let Some(hash) = manifest {
447 let file = pkg.join(format!("__wasm_split_manifest.{hash}.json"));
448 if !file.is_file() {
449 missing.push(file.display().to_string());
450 }
451 }
452 if !missing.is_empty() {
453 panic!("前端产物缺失:{missing:?}——前端产物与服务端二进制不配套,拒绝启动");
454 }
455 entry_content_addressed
456}
457
458async fn site_fallback(State(options): State<LeptosOptions>, req: Request) -> Response {
462 use axum::http::{Method, StatusCode};
463 use tower::ServiceExt;
464
465 if req.method() != Method::GET && req.method() != Method::HEAD {
466 return StatusCode::NOT_FOUND.into_response();
467 }
468
469 let mut static_request = axum::http::Request::builder()
470 .method(req.method())
471 .uri(req.uri().clone());
472 for (name, value) in req.headers() {
473 static_request = static_request.header(name, value);
474 }
475 if let Ok(static_request) = static_request.body(axum::body::Body::empty()) {
476 let served = tower_http::services::ServeDir::new(site_root())
477 .append_index_html_on_directories(false)
478 .precompressed_br()
479 .oneshot(static_request)
480 .await;
481 let Ok(asset) = served;
483 if asset.status() != StatusCode::NOT_FOUND {
484 return asset.map(axum::body::Body::new);
485 }
486 }
487
488 if req.uri().path().starts_with("/pkg/") {
489 return StatusCode::NOT_FOUND.into_response();
490 }
491
492 leptos_axum::render_app_async(move || crate::app::shell(options.clone()))(req).await
493}
494
495fn build_router(options: ServerOptions) -> axum::Router {
496 use axum::http::StatusCode;
497 use tower_http::timeout::TimeoutLayer;
498
499 let leptos_opts = leptos_options();
500 let entry_content_addressed = validate_site_assets(&leptos_opts);
501 init_cache_namespace("leptos");
502
503 spawn_background_tasks_once();
504
505 let routes = leptos_axum::generate_route_list(crate::app::App);
508 let leptos_app = axum::Router::new()
509 .leptos_routes(&leptos_opts, routes, {
510 let leptos_opts = leptos_opts.clone();
511 move || crate::app::shell(leptos_opts.clone())
512 })
513 .fallback(site_fallback)
514 .with_state(leptos_opts.clone());
515
516 let leptos_app = match previous_public_dir() {
520 Some(previous_root) => {
521 tracing::info!(
522 dir = %previous_root.display(),
523 "旧发布资源保留窗口已启用(/pkg 缺失时回退)"
524 );
525 leptos_app.layer(axum::middleware::from_fn(
526 move |req: axum::extract::Request, next: axum::middleware::Next| {
527 crate::middleware::previous_release_assets(previous_root.clone(), req, next)
528 },
529 ))
530 }
531 None => leptos_app,
532 };
533
534 let upload_route = axum::Router::new()
535 .route(
536 "/api/notes/upload",
537 axum::routing::post(crate::api::notes::attachments::upload),
538 )
539 .route(
540 "/api/upload",
541 axum::routing::post(crate::api::upload::upload_image),
542 )
543 .route(
544 "/api/comments/upload",
545 axum::routing::post(crate::api::upload::comment_upload_image),
546 )
547 .layer(axum::extract::DefaultBodyLimit::max(IMAGE_UPLOAD_MAX_BYTES))
548 .layer(TimeoutLayer::with_status_code(
549 StatusCode::REQUEST_TIMEOUT,
550 IMAGE_UPLOAD_TIMEOUT,
551 ))
552 .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
553
554 let mcp_upload_route = axum::Router::new()
555 .route(
556 "/api/mcp/upload",
557 axum::routing::post(crate::api::upload::mcp_upload_image),
558 )
559 .layer(axum::extract::DefaultBodyLimit::max(IMAGE_UPLOAD_MAX_BYTES))
560 .layer(TimeoutLayer::with_status_code(
561 StatusCode::REQUEST_TIMEOUT,
562 IMAGE_UPLOAD_TIMEOUT,
563 ));
564
565 let backup_import_max = crate::api::database::backup::import_max_bytes();
566 tracing::info!(
567 max_mb = backup_import_max / 1024 / 1024,
568 "备份导入单文件上限生效(BACKUP_IMPORT_MAX_MB)"
569 );
570 let backup_import_limit = backup_import_max
571 .saturating_add(crate::api::database::backup::MULTIPART_FRAME_SLACK)
572 .min(usize::MAX as u64) as usize;
573 let backup_import_route = axum::Router::new()
574 .route(
575 "/api/database/backups/import",
576 axum::routing::post(crate::api::database::backup::import_backup),
577 )
578 .layer(axum::extract::DefaultBodyLimit::max(backup_import_limit))
579 .layer(TimeoutLayer::with_status_code(
580 StatusCode::REQUEST_TIMEOUT,
581 BACKUP_IMPORT_TIMEOUT,
582 ))
583 .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
584
585 let export_route = axum::Router::new()
586 .route(
587 "/api/database/export",
588 axum::routing::get(crate::api::database::export::export_data).layer(
589 axum::middleware::from_fn(crate::api::csrf::same_origin_read_middleware),
590 ),
591 )
592 .route(
593 "/api/database/backups/{filename}",
594 axum::routing::get(crate::api::database::backup::download_backup),
595 )
596 .layer(TimeoutLayer::with_status_code(
597 StatusCode::REQUEST_TIMEOUT,
598 EXPORT_TIMEOUT,
599 ))
600 .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
601
602 let sse_route = axum::Router::new()
603 .route(
604 "/api/exec/stream",
605 axum::routing::get(crate::api::code_runner::sse::exec_stream),
606 )
607 .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
608
609 let logs_sse_route = axum::Router::new()
610 .route(
611 "/api/logs/stream",
612 axum::routing::get(crate::api::logs::sse::log_stream),
613 )
614 .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
615
616 let mut app_routes = leptos_app
617 .layer(axum::middleware::from_fn(
618 crate::middleware::share_context_middleware,
619 ))
620 .layer(axum::middleware::from_fn(
621 crate::ssr_cache::cache_public_html,
622 ))
623 .layer(axum::middleware::from_fn(
624 crate::middleware::ssr_generation_middleware,
625 ))
626 .layer(axum::middleware::from_fn(
627 move |req: axum::extract::Request, next: axum::middleware::Next| {
628 crate::middleware::add_cache_control(entry_content_addressed, req, next)
629 },
630 ))
631 .layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
632 let compression = crate::compression::algorithms().map(|algorithms| {
633 axum::middleware::from_fn(
634 move |req: axum::extract::Request, next: axum::middleware::Next| {
635 crate::compression::compress(algorithms, req, next)
636 },
637 )
638 });
639 if let Some(layer) = compression.clone() {
640 app_routes = app_routes.layer(layer);
641 }
642 let app_routes = app_routes.layer(TimeoutLayer::with_status_code(
643 StatusCode::REQUEST_TIMEOUT,
644 APP_REQUEST_TIMEOUT,
645 ));
646 let app_routes = app_routes.layer(axum::middleware::from_fn(crate::middleware::admin_guard));
647
648 let static_routes = axum::Router::new()
649 .route(
650 "/note-media/{id}",
651 axum::routing::get(crate::api::notes::attachments::read),
652 )
653 .route(
655 "/tags",
656 axum::routing::get(|| async { axum::response::Redirect::permanent("/archives") }),
657 )
658 .route(
659 "/og/site.png",
660 axum::routing::get(crate::api::og::site_image),
661 )
662 .route(
663 "/og/post/{file}",
664 axum::routing::get(crate::api::og::post_image),
665 )
666 .route("/healthz", axum::routing::get(crate::api::health::healthz))
667 .route("/readyz", axum::routing::get(crate::api::health::readyz))
668 .route(
669 "/uploads/{*path}",
670 axum::routing::get(crate::api::image::serve_image),
671 )
672 .route(
673 "/uploads",
674 axum::routing::get(|| async { StatusCode::NOT_FOUND }),
675 );
676
677 let mut feed_routes = axum::Router::new()
679 .route("/feed.xml", axum::routing::get(crate::api::feed::rss_feed))
680 .route(
681 "/feed.json",
682 axum::routing::get(crate::api::feed::json_feed),
683 );
684 if let Some(layer) = compression {
685 feed_routes = feed_routes.layer(layer);
686 }
687
688 let router = upload_route
689 .merge(mcp_upload_route)
690 .merge(backup_import_route)
691 .merge(export_route)
692 .merge(sse_route)
693 .merge(logs_sse_route)
694 .merge(app_routes)
695 .merge(static_routes)
696 .merge(feed_routes)
697 .merge(crate::mcp::router::mcp_route());
698
699 let router = router.layer(crate::middleware::catch_panic_layer());
701
702 if options.expose_version_headers {
703 router.layer(axum::middleware::from_fn(
704 crate::middleware::version_headers_middleware,
705 ))
706 } else {
707 router
708 }
709}