1use hyper::body::Incoming;
8use hyper::service::service_fn;
9use hyper::Request;
10use hyper_util::rt::TokioIo;
11use rust_dix::{ServiceCollection, ServiceProvider};
12use webx_core::app::IHost;
13use webx_core::config::{self, AppOptions};
14use webx_core::error::{Error, Result};
15use webx_core::handler::IHostedService;
16use webx_core::http::IHttpContext;
17use webx_core::localization::{IStringLocalizer, LocalizationOptions, StringLocalizer};
18use webx_core::middleware::IMiddleware;
19use webx_core::mode::AppMode;
20use webx_core::route::catalog::{EndpointCatalog, IEndpointCatalog};
21use webx_core::routing::{HttpMethod, IEndpoint, IRouter};
22use webx_core::security::{ISessionStore, ITokenIssuer};
23
24use crate::auth_jwt::{
25 init_jwt_secret, jwt_middleware, jwt_middleware_with_revocation, JwtAuth, JwtTokenIssuer,
26};
27use crate::authz::collect_authorizers;
28use crate::context::{BodyLimits, HttpContext, RespBody};
29use crate::cors::{CorsConfig, CorsMiddleware};
30use crate::endpoint::{StaticHtmlEndpoint, StaticJsonEndpoint, StubEndpoint};
31use crate::health::{HealthCheckEndpoint, HealthCheckRegistry, HealthStatus};
32use crate::memory_cache::MemoryCache;
33use crate::metrics::{HttpMetrics, MetricsEndpoint, MetricsMiddleware};
34use crate::pipeline::{HandlerFn, MiddlewarePipeline};
35use crate::problem_response::{build_problem, write_problem_response, write_unauthorized};
36use crate::rate_limit::RateLimitMiddleware;
37use crate::router::Router;
38use jsonwebtoken::{DecodingKey, Validation};
39use webx_core::route::scan::RouteEntry;
40use webx_core::DispatchRuntime;
41use webx_openapi::{generate_openapi_spec, APIUI_HTML};
42use webx_spa::{EmbeddedAssets, SpaMiddleware, SpaSource};
43
44use rustls::pki_types::{CertificateDer, PrivateKeyDer};
45use rustls::ServerConfig;
46use rustls_pemfile::{certs, pkcs8_private_keys, rsa_private_keys};
47use std::sync::Arc;
48use std::time::{Duration, Instant};
49use tokio::task::JoinSet;
50use tokio_rustls::TlsAcceptor;
51
52fn is_weak_jwt_secret(secret: &str) -> bool {
54 secret.is_empty()
55 || secret.len() < 32
56 || secret.contains("change-in-production")
57 || secret.contains("please-change")
58 || secret.contains("demo-secret")
59 || secret.contains("dev-secret")
60}
61
62fn log_spa_source(spa: &SpaMiddleware) {
67 tracing::info!("[Host] SPA root: {}", spa.resolved_root().display());
68 let Some(assets) = spa.embedded() else {
69 return;
70 };
71 tracing::info!(
72 "[Host] Embedded assets: {} files, {:.1} MiB embedded ({:.1} MiB uncompressed), built from `{}`",
73 assets.len(),
74 assets.total_bytes() as f64 / (1024.0 * 1024.0),
75 assets.total_raw_bytes() as f64 / (1024.0 * 1024.0),
76 assets.root(),
77 );
78 let shadowed = assets.shadowed_by(spa.resolved_root());
79 if !shadowed.is_empty() {
80 tracing::warn!(
81 "[Host] {} embedded file(s) are overridden on disk: {}. \
82 An override older than the current build stays in effect until it is \
83 updated or deleted.",
84 shadowed.len(),
85 shadowed.join(", "),
86 );
87 }
88}
89
90fn with_embedded_assets(
96 source: Option<SpaSource>,
97 assets: Option<EmbeddedAssets>,
98) -> Option<SpaSource> {
99 let Some(assets) = assets else {
100 return source;
101 };
102 let overlay_root = match source.as_ref() {
103 Some(existing) => existing.disk_root().to_string(),
104 None => assets.root().to_string(),
105 };
106 Some(SpaSource::Embedded {
107 assets,
108 overlay_root,
109 })
110}
111
112type ShutdownSignal = Arc<tokio::sync::watch::Sender<bool>>;
119
120pub struct Host {
121 #[allow(dead_code)]
122 provider: Arc<ServiceProvider>,
123 dispatch_runtime: Arc<DispatchRuntime>,
124 pub options: AppOptions,
125 endpoints: Arc<dyn IEndpointCatalog>,
127 localizer: Arc<dyn IStringLocalizer>,
129 body_limits: BodyLimits,
131 pipeline: Arc<MiddlewarePipeline>,
132 #[allow(dead_code)]
134 router: Arc<Router>,
135 router_handler: HandlerFn,
137 mode: AppMode,
138 #[allow(dead_code)]
140 spa_source: Option<SpaSource>,
141 shutdown: ShutdownSignal,
142 hosted_services: Arc<Vec<Arc<dyn IHostedService>>>,
145 hosted_stopped: Arc<tokio::sync::OnceCell<()>>,
149}
150
151type MiddlewareFactory = Box<dyn Fn() -> Arc<dyn IMiddleware> + Send + Sync>;
153
154struct MiddlewareSpec {
156 slot: webx_core::middleware::MiddlewareSlot,
157 factory: MiddlewareFactory,
158}
159
160#[allow(clippy::type_complexity)]
161pub struct HostBuilder {
162 service_configs: Vec<Box<dyn FnOnce(ServiceCollection) -> ServiceCollection + Send>>,
163 mode: AppMode,
164 spa_source: Option<SpaSource>,
165 spa_disabled: bool,
166 options_modifiers: Vec<Box<dyn FnOnce(&mut AppOptions) + Send>>,
167 cors_config: Option<CorsConfig>,
168 auth_enabled: bool,
169 localization: Option<LocalizationOptions>,
170 health_registry: Arc<HealthCheckRegistry>,
171 middleware_specs: Vec<MiddlewareSpec>,
174 lenient_scope_validation: bool,
177}
178
179struct PreparedHost {
185 builder: HostBuilder,
186 svc: ServiceCollection,
187 provider_options: rust_dix::ServiceProviderOptions,
188 options: AppOptions,
189 endpoints: Arc<dyn IEndpointCatalog>,
190 localizer: Arc<dyn IStringLocalizer>,
191 handler_cache: Arc<webx_core::route::scan::HandlerCache>,
192}
193
194#[allow(clippy::type_complexity)]
195pub struct HostAppBuilder {
196 options_modifiers: Vec<Box<dyn FnOnce(&mut AppOptions) + Send>>,
197}
198
199impl HostAppBuilder {
200 fn new() -> Self {
201 Self {
202 options_modifiers: Vec::new(),
203 }
204 }
205
206 #[allow(non_snake_case)]
207 pub fn useOptions<F>(&mut self, f: F)
208 where
209 F: FnOnce(&mut AppOptions) + Send + 'static,
210 {
211 self.options_modifiers.push(Box::new(f));
212 }
213}
214
215impl HostBuilder {
216 pub fn new() -> Self {
217 Self {
218 service_configs: Vec::new(),
219 mode: AppMode::from_env(),
222 spa_source: None,
223 spa_disabled: false,
224 options_modifiers: Vec::new(),
225 cors_config: None,
226 auth_enabled: false,
227 localization: None,
228 health_registry: HealthCheckRegistry::new(),
229 middleware_specs: Vec::new(),
230 lenient_scope_validation: false,
231 }
232 }
233
234 pub fn register<F>(mut self, f: F) -> Self
235 where
236 F: FnOnce(ServiceCollection) -> ServiceCollection + Send + 'static,
237 {
238 self.service_configs.push(Box::new(f));
239 self
240 }
241
242 pub fn configure<F>(mut self, f: F) -> Self
243 where
244 F: FnOnce(&mut HostAppBuilder) + Send + 'static,
245 {
246 let mut builder = HostAppBuilder::new();
247 f(&mut builder);
248 self.options_modifiers
249 .append(&mut builder.options_modifiers);
250 self
251 }
252
253 pub fn mode(mut self, mode: AppMode) -> Self {
254 self.mode = mode;
255 self
256 }
257
258 pub fn lenient_scope_validation(mut self) -> Self {
268 self.lenient_scope_validation = true;
269 self
270 }
271
272 pub fn add_options<T>(self, section: &str) -> Self
284 where
285 T: for<'de> serde::Deserialize<'de> + Default + Send + Sync + 'static,
286 {
287 let mode = self.mode;
288 let section = section.to_string();
289 self.register(move |svc| {
290 let appsettings = config::load_appsettings(mode).unwrap_or_default();
291 let bound: T = config::bind_config(&appsettings, §ion);
292 tracing::info!(
293 "[Host] add_options<{}>(\"{}\") registered",
294 std::any::type_name::<T>(),
295 section
296 );
297 svc.instance(Arc::new(bound))
298 })
299 }
300
301 pub fn use_spa(mut self, source: impl Into<SpaSource>) -> Self {
316 self.spa_source = Some(match (self.spa_source.take(), source.into()) {
317 (Some(SpaSource::Embedded { assets, .. }), SpaSource::Disk(root)) => {
320 SpaSource::Embedded {
321 assets,
322 overlay_root: root,
323 }
324 }
325 (_, incoming) => incoming,
326 });
327 self
328 }
329
330 pub fn no_spa(mut self) -> Self {
336 self.spa_disabled = true;
337 self
338 }
339
340 pub fn use_cors(mut self, config: CorsConfig) -> Self {
341 self.cors_config = Some(config);
342 self
343 }
344
345 pub fn add_authentication(mut self) -> Self {
359 self.auth_enabled = true;
360 self
361 }
362
363 pub fn use_session_store(mut self, store: Arc<dyn ISessionStore>) -> Self {
394 self.service_configs.push(Box::new(move |svc| {
395 let store = Arc::clone(&store);
396 svc.try_add_singleton::<dyn ISessionStore>(move |_| Arc::clone(&store))
397 }));
398 self
399 }
400
401 pub fn use_password_hasher(
417 mut self,
418 hasher: Arc<dyn webx_core::security::IPasswordHasher>,
419 ) -> Self {
420 self.service_configs.push(Box::new(move |svc| {
421 let hasher = Arc::clone(&hasher);
422 svc.try_add_singleton::<dyn webx_core::security::IPasswordHasher>(move |_| {
423 Arc::clone(&hasher)
424 })
425 }));
426 self
427 }
428
429 pub fn add_localization(mut self) -> Self {
445 self.localization = Some(LocalizationOptions::new());
446 self
447 }
448
449 pub fn add_localization_with(mut self, options: LocalizationOptions) -> Self {
465 self.localization = Some(options);
466 self
467 }
468
469 pub fn add_memory_cache(mut self) -> Self {
481 let cache = Arc::new(MemoryCache::new());
482 self.service_configs
483 .push(Box::new(move |svc| svc.instance(cache)));
484 self
485 }
486
487 pub fn add_memory_cache_with(mut self, max_entries: usize) -> Self {
489 let cache = Arc::new(MemoryCache::new().with_max_entries(max_entries));
490 self.service_configs
491 .push(Box::new(move |svc| svc.instance(Arc::clone(&cache))));
492 self
493 }
494
495 pub fn use_middleware<T: IMiddleware + Default + 'static>(self) -> Self {
508 self.use_middleware_at::<T>(webx_core::middleware::MiddlewareSlot::AfterAuth)
509 }
510
511 pub fn use_middleware_at<T: IMiddleware + Default + 'static>(
513 mut self,
514 slot: webx_core::middleware::MiddlewareSlot,
515 ) -> Self {
516 self.middleware_specs.push(MiddlewareSpec {
517 slot,
518 factory: Box::new(|| Arc::new(T::default()) as Arc<dyn IMiddleware>),
519 });
520 self
521 }
522
523 pub fn use_middleware_with<F>(self, factory: F) -> Self
536 where
537 F: Fn() -> Arc<dyn IMiddleware> + Send + Sync + 'static,
538 {
539 self.use_middleware_with_at(webx_core::middleware::MiddlewareSlot::AfterAuth, factory)
540 }
541
542 pub fn use_middleware_with_at<F>(
544 mut self,
545 slot: webx_core::middleware::MiddlewareSlot,
546 factory: F,
547 ) -> Self
548 where
549 F: Fn() -> Arc<dyn IMiddleware> + Send + Sync + 'static,
550 {
551 self.middleware_specs.push(MiddlewareSpec {
552 slot,
553 factory: Box::new(factory),
554 });
555 self
556 }
557
558 pub fn add_health_check<F>(self, name: impl Into<String>, check: F) -> Self
571 where
572 F: Fn() -> HealthStatus + Send + Sync + 'static,
573 {
574 self.health_registry.register(name, Arc::new(check));
575 self
576 }
577
578 pub fn try_build(self) -> Result<Host> {
594 let prepared = self.prepare()?;
595 let PreparedHost {
596 builder,
597 svc,
598 provider_options,
599 options,
600 endpoints,
601 localizer,
602 handler_cache,
603 } = prepared;
604 let provider = svc
605 .build_with(provider_options)
606 .map_err(build_provider_error)?;
607 builder.assemble(provider, handler_cache, options, endpoints, localizer)
608 }
609
610 pub async fn try_build_async(self) -> Result<Host> {
624 let prepared = self.prepare()?;
625 let PreparedHost {
626 builder,
627 svc,
628 provider_options,
629 options,
630 endpoints,
631 localizer,
632 handler_cache,
633 } = prepared;
634 let provider = svc
635 .build_async_with(provider_options)
636 .await
637 .map_err(build_provider_error)?;
638 builder.assemble(provider, handler_cache, options, endpoints, localizer)
639 }
640
641 fn prepare(mut self) -> Result<PreparedHost> {
642 let env_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into());
645 if self.mode == AppMode::Development {
646 let _ = tracing_subscriber::fmt()
647 .with_env_filter(env_filter)
648 .without_time()
649 .with_target(false)
650 .try_init();
651 } else {
652 let _ = tracing_subscriber::fmt()
653 .json()
654 .with_env_filter(env_filter)
655 .try_init();
656 }
657
658 let appsettings =
661 config::load_appsettings(self.mode).unwrap_or_else(|| serde_json::json!({}));
662 let mut options: AppOptions = config::bind_root(&appsettings);
663 for modifier in std::mem::take(&mut self.options_modifiers) {
664 modifier(&mut options);
665 }
666
667 let default_culture = if options.culture.default.trim().is_empty() {
668 "en".to_string()
669 } else {
670 options.culture.default.clone()
671 };
672 let localization = self
673 .localization
674 .take()
675 .unwrap_or_else(LocalizationOptions::none);
676 let localizer: Arc<dyn IStringLocalizer> = Arc::new(
677 StringLocalizer::new(default_culture, &localization)
678 .map_err(|error| Error::Di(format!("Invalid localization resources: {error}")))?,
679 );
680 let endpoints: Arc<dyn IEndpointCatalog> = Arc::new(EndpointCatalog::from_inventory());
681
682 let mut svc = ServiceCollection::from_injected();
683 for cfg in std::mem::take(&mut self.service_configs) {
684 svc = cfg(svc);
685 }
686
687 if self.auth_enabled && !options.jwt.secret.trim().is_empty() {
694 let secret = options.jwt.secret.clone();
695 let lifetime_minutes = match options.jwt.expiration_minutes {
696 0 => 60,
697 minutes => minutes,
698 };
699 let lifetime = Duration::from_secs(lifetime_minutes.saturating_mul(60));
700 svc = svc.try_add_singleton::<dyn ITokenIssuer>(move |_| {
701 Arc::new(JwtTokenIssuer::from_secret(secret.as_bytes(), lifetime))
702 });
703 }
704
705 #[cfg(feature = "password-argon2")]
710 {
711 svc = svc.try_add_singleton::<dyn webx_core::security::IPasswordHasher>(|_| {
712 Arc::new(crate::password_argon2::Argon2PasswordHasher::default())
713 });
714 }
715
716 let handler_cache = Arc::new(webx_core::route::scan::HandlerCache::build());
719 svc = svc.instance(Arc::clone(&handler_cache));
720
721 let health_registry = Arc::clone(&self.health_registry);
724 svc = svc.try_add(move |_| Arc::clone(&health_registry));
725
726 let endpoints_for_di = Arc::clone(&endpoints);
728 svc = svc.try_add_singleton::<dyn IEndpointCatalog>(move |_| Arc::clone(&endpoints_for_di));
729 let localizer_for_di = Arc::clone(&localizer);
730 svc = svc.try_add_singleton::<dyn IStringLocalizer>(move |_| Arc::clone(&localizer_for_di));
731
732 let provider_options = if self.lenient_scope_validation {
733 rust_dix::ServiceProviderOptions::lenient()
734 } else {
735 rust_dix::ServiceProviderOptions::default()
736 };
737 Ok(PreparedHost {
738 builder: self,
739 svc,
740 provider_options,
741 options,
742 endpoints,
743 localizer,
744 handler_cache,
745 })
746 }
747
748 #[allow(clippy::too_many_arguments)]
749 fn assemble(
750 self,
751 provider: Arc<ServiceProvider>,
752 handler_cache: Arc<webx_core::route::scan::HandlerCache>,
753 options: AppOptions,
754 endpoints: Arc<dyn IEndpointCatalog>,
755 localizer: Arc<dyn IStringLocalizer>,
756 ) -> Result<Host> {
757 let dispatch_runtime = Arc::new(DispatchRuntime::new(Arc::clone(&provider), handler_cache));
759
760 let body_limits = BodyLimits::from_options(&options);
762 body_limits.apply_spool_root();
763 tracing::info!(
764 "[Host] Uploads: max request {} bytes, max file {} bytes, spool >{} bytes to {}",
765 body_limits.multipart_body,
766 body_limits.file,
767 body_limits.memory_threshold,
768 body_limits.spool_dir.display()
769 );
770
771 let http_metrics = HttpMetrics::new();
772
773 let mut pipeline = MiddlewarePipeline::new();
774
775 pipeline.add_middleware(Arc::new(
785 crate::security_headers::SecurityHeadersMiddleware::new(),
786 ));
787 pipeline.add_middleware(Arc::new(crate::request_id::RequestIdMiddleware::new()));
788
789 for spec in self
790 .middleware_specs
791 .iter()
792 .filter(|spec| spec.slot == webx_core::middleware::MiddlewareSlot::BeforeCors)
793 {
794 pipeline.add_middleware((spec.factory)());
795 }
796
797 let cors = self.cors_config.unwrap_or_else(|| {
798 let cs = &options.cors;
799 CorsConfig {
800 origins: cs.origins.clone(),
801 methods: cs.methods.clone(),
802 headers: cs.headers.clone(),
803 expose_headers: cs.expose_headers.clone(),
804 allow_credentials: cs.allow_credentials,
805 max_age: cs.max_age,
806 }
807 });
808 pipeline.add_middleware(Arc::new(CorsMiddleware::new(cors)));
809
810 if options.metrics.enabled {
811 tracing::info!("[Host] Metrics enabled at GET /metrics");
812 pipeline.add_middleware(Arc::new(MetricsMiddleware::new(Arc::clone(&http_metrics))));
813 }
814
815 if options.rate_limit.enabled {
816 tracing::info!(
817 "[Host] RateLimit enabled: {} req/s, burst {}, max_ips {}",
818 options.rate_limit.requests_per_second,
819 options.rate_limit.burst_size,
820 options.rate_limit.max_tracked_ips
821 );
822 pipeline.add_middleware(Arc::new(RateLimitMiddleware::from_config(
823 &options.rate_limit,
824 )));
825 }
826
827 if self.auth_enabled {
828 let secret = options.jwt.secret.clone();
829 if !secret.is_empty() {
830 let jwt_auth = Arc::new(JwtAuth::new(
831 DecodingKey::from_secret(secret.as_bytes()),
832 Validation::default(),
833 ));
834 let session_store = provider.get_all::<dyn ISessionStore>().into_iter().next();
838 match &session_store {
839 Some(_) => tracing::debug!(
840 "[Host] JWT revocation enabled (a dyn ISessionStore is registered)"
841 ),
842 None => tracing::debug!(
843 "[Host] JWT revocation disabled (no dyn ISessionStore registered; \
844 use HostBuilder::use_session_store)"
845 ),
846 }
847 let auth_middleware: Arc<dyn IMiddleware> = match session_store {
848 Some(store) => Arc::new(jwt_middleware_with_revocation(jwt_auth, store)),
849 None => Arc::new(jwt_middleware(jwt_auth)),
850 };
851 pipeline.add_middleware(auth_middleware);
852 init_jwt_secret(&secret);
853 } else {
854 tracing::warn!(
855 "add_authentication() enabled but no JWT secret configured. Set Jwt.Secret in appsettings.json, JWT_SECRET, or APP__Jwt__Secret env var."
856 );
857 }
858 }
859
860 for spec in self
864 .middleware_specs
865 .iter()
866 .filter(|spec| spec.slot == webx_core::middleware::MiddlewareSlot::AfterAuth)
867 {
868 pipeline.add_middleware((spec.factory)());
869 }
870 for mw in provider.get_all::<dyn IMiddleware>() {
871 pipeline.add_middleware(mw);
872 }
873
874 let spa_source = if self.spa_disabled {
882 None
883 } else {
884 let base = self.spa_source.clone().or_else(|| {
885 let candidate = webx_core::paths::app_base().join("wwwroot");
886 if candidate.is_dir() {
887 tracing::info!("[Host] Auto-detected SPA root: {}", candidate.display());
888 Some(SpaSource::Disk(candidate.to_string_lossy().into_owned()))
889 } else {
890 None
891 }
892 });
893 with_embedded_assets(base, EmbeddedAssets::registered().copied())
894 };
895 let mut spa_fallback: Option<Arc<SpaMiddleware>> = None;
896 if let Some(ref source) = spa_source {
897 let spa = Arc::new(SpaMiddleware::from_source(source.clone()));
898 log_spa_source(&spa);
899 pipeline.add_middleware(Arc::clone(&spa) as Arc<dyn IMiddleware>);
900 spa_fallback = Some(spa);
904 }
905
906 for spec in self
908 .middleware_specs
909 .iter()
910 .filter(|spec| spec.slot == webx_core::middleware::MiddlewareSlot::AfterSpa)
911 {
912 pipeline.add_middleware((spec.factory)());
913 }
914
915 let pipeline = Arc::new(pipeline);
916
917 if self.mode == AppMode::Production
919 && self.auth_enabled
920 && is_weak_jwt_secret(&options.jwt.secret)
921 {
922 return Err(Error::Di(
923 "Production requires a strong JWT secret. Set APP__Jwt__Secret or JWT_SECRET env var (min 32 chars, not a placeholder)."
924 .to_string(),
925 ));
926 }
927
928 if self.mode == AppMode::Production && options.cors.origins.iter().any(|o| o == "*") {
930 return Err(Error::Di(
931 "Production forbids CORS origin '*'. Set explicit origins via Cors.Origins or APP__Cors__Origins."
932 .to_string(),
933 ));
934 }
935
936 let mut router = Router::new();
937 let mut route_count = 0usize;
938
939 crate::diagnostics::validate_route_configuration()?;
940
941 let mut dispatch_map: std::collections::HashMap<
943 &'static str,
944 webx_core::route::scan::RouteDispatchFn,
945 > = std::collections::HashMap::new();
946
947 for dispatch in inventory::iter::<webx_core::route::scan::RouteDispatch> {
948 dispatch_map.insert(dispatch.handler_type, dispatch.dispatch);
949 }
950
951 for entry in inventory::iter::<RouteEntry> {
952 route_count += 1;
953 let stub = Arc::new(StubEndpoint {
954 method: entry.method.as_str(),
955 path: entry.path,
956 handler_type: entry.handler_type,
957 dispatch_fn: dispatch_map.get(entry.handler_type).copied(),
958 authorization: entry.authorization,
959 authorizers: collect_authorizers(provider.as_ref()),
960 });
961 router.register(entry.method, entry.path, stub);
962 }
963
964 let openapi_spec = generate_openapi_spec(
965 "Rust WebX API",
966 env!("CARGO_PKG_VERSION"),
967 localizer.as_ref(),
968 );
969 if self.mode == AppMode::Development {
970 let openapi_bytes = serde_json::to_vec(&openapi_spec).unwrap_or_default();
971 router.register(
972 HttpMethod::Get,
973 "/api/openapi.json",
974 Arc::new(StaticJsonEndpoint::new(openapi_bytes)),
975 );
976 router.register(
977 HttpMethod::Get,
978 "/api/openapi.html",
979 Arc::new(StaticHtmlEndpoint { body: APIUI_HTML }),
980 );
981 }
982
983 let health_registry = Arc::clone(&self.health_registry);
986 let health_endpoint: Arc<dyn IEndpoint> =
987 Arc::new(HealthCheckEndpoint::new(Arc::clone(&health_registry)));
988 router.register(HttpMethod::Get, "/health", Arc::clone(&health_endpoint));
989 router.register(HttpMethod::Get, "/healthz", health_endpoint);
990
991 let live_body =
993 serde_json::to_vec(&serde_json::json!({"status":"pass"})).unwrap_or_default();
994 router.register(
995 HttpMethod::Get,
996 "/health/live",
997 Arc::new(StaticJsonEndpoint {
998 body: live_body,
999 content_type: "application/health+json",
1000 }),
1001 );
1002
1003 router.register(
1005 HttpMethod::Get,
1006 "/health/ready",
1007 Arc::new(HealthCheckEndpoint::new(health_registry)),
1008 );
1009
1010 if options.metrics.enabled {
1011 router.register(
1012 HttpMethod::Get,
1013 "/metrics",
1014 Arc::new(MetricsEndpoint::new(http_metrics)),
1015 );
1016 }
1017
1018 if self.mode == AppMode::Development {
1019 let version = env!("CARGO_PKG_VERSION");
1020 tracing::info!("");
1021 tracing::info!(" ----------------------------------------------------------------");
1022 tracing::info!(" Rust WebX Framework v{}", version);
1023 tracing::info!(" ----------------------------------------------------------------");
1024 tracing::info!(" App: {}", options.app.name);
1025 tracing::info!(" CORS: enabled");
1026 if let Some(source) = spa_source.as_ref() {
1027 tracing::info!(" SPA Root: {}", source.disk_root());
1028 if let Some(assets) = source.embedded() {
1029 tracing::info!(
1030 " Embedded: {} files, {:.1} MiB from {:.1} MiB (built from {})",
1031 assets.len(),
1032 assets.total_bytes() as f64 / (1024.0 * 1024.0),
1033 assets.total_raw_bytes() as f64 / (1024.0 * 1024.0),
1034 assets.root(),
1035 );
1036 }
1037 }
1038 if route_count > 0 {
1039 tracing::info!(" Routes: {} registered", route_count);
1040 }
1041 crate::diagnostics::log_startup_diagnostics();
1042 let banner_urls = if options.app.urls.is_empty() {
1043 vec!["http://localhost:5000".to_string()]
1044 } else {
1045 options
1046 .app
1047 .urls
1048 .iter()
1049 .map(|u| u.replace("0.0.0.0", "localhost"))
1050 .collect::<Vec<_>>()
1051 };
1052 for url in &banner_urls {
1053 tracing::info!(" OpenAPI: {}/api/openapi.html", url);
1054 tracing::info!(" OpenAPI: {}/api/openapi.json", url);
1055 }
1056 tracing::info!(" ----------------------------------------------------------------");
1057 tracing::info!("");
1058 } else if route_count > 0 {
1059 tracing::info!("{} route(s) registered", route_count);
1060 }
1061
1062 let router = Arc::new(router);
1063 let router_handler = make_router_handler(Arc::clone(&router), spa_fallback);
1064
1065 let mut hosted_services: Vec<Arc<dyn IHostedService>> =
1072 provider.get_all::<dyn IHostedService>();
1073 hosted_services.sort_by_key(|service| service.order());
1074
1075 Ok(Host {
1076 provider,
1077 dispatch_runtime,
1078 options,
1079 endpoints,
1080 localizer,
1081 body_limits,
1082 pipeline,
1083 router,
1084 router_handler,
1085 mode: self.mode,
1086 spa_source,
1087 shutdown: Arc::new(tokio::sync::watch::channel(false).0),
1088 hosted_services: Arc::new(hosted_services),
1089 hosted_stopped: Arc::new(tokio::sync::OnceCell::new()),
1090 })
1091 }
1092
1093 pub fn build(self) -> Host {
1100 match self.try_build() {
1101 Ok(host) => host,
1102 Err(error) => panic!("{}", build_failure_message(&error)),
1105 }
1106 }
1107}
1108
1109fn build_provider_error(error: rust_dix::RdiError) -> Error {
1111 Error::Di(format!(
1112 "Failed to build ServiceProvider: {}. Check your DI registrations.",
1113 error
1114 ))
1115}
1116
1117fn build_failure_message(error: &Error) -> String {
1120 match error {
1121 Error::Di(message) => message.clone(),
1122 other => other.to_string(),
1123 }
1124}
1125
1126pub struct ServerHandle {
1128 shutdown: ShutdownSignal,
1129}
1130
1131impl ServerHandle {
1132 pub fn shutdown(self) {
1138 trigger_shutdown(&self.shutdown);
1139 }
1140}
1141
1142pub struct Server {
1154 host: Host,
1155}
1156
1157impl Server {
1158 pub async fn run(self) -> Result<()> {
1160 self.host.run().await
1161 }
1162
1163 pub async fn run_at(self, addr: &str) -> Result<()> {
1165 self.host.run_at(addr).await
1166 }
1167
1168 pub fn handle(&self) -> ServerHandle {
1170 self.host.server_handle()
1171 }
1172}
1173
1174impl Default for HostBuilder {
1175 fn default() -> Self {
1176 Self::new()
1177 }
1178}
1179
1180impl Host {
1181 pub fn builder() -> HostBuilder {
1182 HostBuilder::new()
1183 }
1184
1185 pub fn options(&self) -> &AppOptions {
1186 &self.options
1187 }
1188
1189 pub fn provider(&self) -> &Arc<ServiceProvider> {
1191 &self.provider
1192 }
1193
1194 fn dispose_di(&self) {
1201 self.provider.dispose();
1202 tracing::debug!("[Host] DI provider disposed");
1203 }
1204
1205 async fn stop_hosted_services_once(&self) {
1212 let services = Arc::clone(&self.hosted_services);
1213 self.hosted_stopped
1214 .get_or_init(|| async move { stop_hosted_services(&services).await })
1215 .await;
1216 }
1217
1218 fn spawn_hosted_shutdown_watcher(&self) -> tokio::task::JoinHandle<()> {
1226 let mut shutdown = self.shutdown.subscribe();
1227 let stopped = Arc::clone(&self.hosted_stopped);
1228 let services = Arc::clone(&self.hosted_services);
1229 tokio::spawn(async move {
1230 if shutdown.wait_for(|signalled| *signalled).await.is_ok() {
1232 stopped
1233 .get_or_init(|| async move { stop_hosted_services(&services).await })
1234 .await;
1235 }
1236 })
1237 }
1238
1239 pub fn dispatch_runtime(&self) -> &Arc<DispatchRuntime> {
1241 &self.dispatch_runtime
1242 }
1243
1244 pub fn endpoints(&self) -> &dyn IEndpointCatalog {
1250 &*self.endpoints
1251 }
1252
1253 pub fn localizer(&self) -> &dyn IStringLocalizer {
1255 &*self.localizer
1256 }
1257
1258 pub async fn run(&self) -> Result<()> {
1272 let urls = if self.options.app.urls.is_empty() {
1273 vec!["http://0.0.0.0:5000".to_string()]
1274 } else {
1275 self.options.app.urls.clone()
1276 };
1277
1278 let mut http_addrs: Vec<String> = Vec::new();
1279 let mut https_addrs: Vec<String> = Vec::new();
1280
1281 for url in &urls {
1282 let (scheme, addr) = parse_url(url)?;
1283 match scheme {
1284 "http" => http_addrs.push(addr),
1285 "https" => https_addrs.push(addr),
1286 other => {
1287 return Err(webx_core::error::Error::Http(format!(
1288 "Unsupported URL scheme '{}' in '{}'",
1289 other, url
1290 )))
1291 }
1292 }
1293 }
1294
1295 let acceptor = if !https_addrs.is_empty() {
1296 let tls = &self.options.tls;
1297 if tls.cert_path.is_empty() || tls.key_path.is_empty() {
1298 return Err(webx_core::error::Error::Http(
1299 "HTTPS URLs require Tls.CertPath and Tls.KeyPath".into(),
1300 ));
1301 }
1302 Some(build_tls_acceptor(&tls.cert_path, &tls.key_path)?)
1303 } else {
1304 None
1305 };
1306
1307 let mut http_listeners = Vec::with_capacity(http_addrs.len());
1311 for addr in &http_addrs {
1312 http_listeners.push(bind_listener(addr, "HTTP").await?);
1313 }
1314 let mut https_listeners = Vec::with_capacity(https_addrs.len());
1315 for addr in &https_addrs {
1316 https_listeners.push(bind_listener(addr, "HTTPS").await?);
1317 }
1318
1319 if let Err(err) = start_hosted_services(&self.hosted_services).await {
1320 self.dispose_di();
1321 return Err(err);
1322 }
1323
1324 if self.mode == AppMode::Development {
1326 tracing::info!("");
1327 for url in &urls {
1328 let display_url = url.replace("0.0.0.0", "localhost");
1329 tracing::info!(" Listening on {}", display_url);
1330 }
1331 } else {
1332 tracing::info!("Listening on {} url(s)", urls.len());
1333 }
1334
1335 install_shutdown_handler(Arc::clone(&self.shutdown));
1336 spawn_async_shutdown_listener(Arc::clone(&self.shutdown));
1337 let hosted_watcher = self.spawn_hosted_shutdown_watcher();
1339
1340 let mut handles = Vec::new();
1341 let pipeline = Arc::clone(&self.pipeline);
1342 let router_handler = self.router_handler.clone();
1343 let dispatch_runtime = Arc::clone(&self.dispatch_runtime);
1344 let mode = self.mode;
1345 let body_limits = self.body_limits.clone();
1346 let max_connections = self.options.app.max_connections;
1347
1348 for (addr, listener) in http_addrs.into_iter().zip(http_listeners) {
1349 handles.push(tokio::spawn(serve_http(
1350 addr,
1351 listener,
1352 self.shutdown.subscribe(),
1353 Arc::clone(&pipeline),
1354 router_handler.clone(),
1355 Arc::clone(&dispatch_runtime),
1356 mode,
1357 body_limits.clone(),
1358 max_connections,
1359 )));
1360 }
1361
1362 if let Some(ref tls_acceptor) = acceptor {
1363 for (addr, listener) in https_addrs.into_iter().zip(https_listeners) {
1364 handles.push(tokio::spawn(serve_https(
1365 addr,
1366 tls_acceptor.clone(),
1367 listener,
1368 self.shutdown.subscribe(),
1369 Arc::clone(&pipeline),
1370 router_handler.clone(),
1371 Arc::clone(&dispatch_runtime),
1372 mode,
1373 body_limits.clone(),
1374 max_connections,
1375 )));
1376 }
1377 }
1378
1379 let mut run_error: Option<webx_core::error::Error> = None;
1382 for h in handles {
1383 if let Err(join_err) = h.await {
1384 if run_error.is_none() {
1385 run_error = Some(webx_core::error::Error::Internal(format!(
1386 "a listener task failed: {join_err}"
1387 )));
1388 }
1389 }
1390 }
1391
1392 self.stop_hosted_services_once().await;
1395 hosted_watcher.abort();
1396 self.dispose_di();
1397
1398 match run_error {
1399 Some(err) => Err(err),
1400 None => Ok(()),
1401 }
1402 }
1403
1404 pub async fn run_at(&self, addr: &str) -> Result<()> {
1406 let listener = bind_listener(addr, "HTTP").await?;
1407 if let Err(err) = start_hosted_services(&self.hosted_services).await {
1408 self.dispose_di();
1409 return Err(err);
1410 }
1411
1412 install_shutdown_handler(Arc::clone(&self.shutdown));
1413 spawn_async_shutdown_listener(Arc::clone(&self.shutdown));
1414 let hosted_watcher = self.spawn_hosted_shutdown_watcher();
1416 serve_http(
1417 addr.to_string(),
1418 listener,
1419 self.shutdown.subscribe(),
1420 Arc::clone(&self.pipeline),
1421 self.router_handler.clone(),
1422 Arc::clone(&self.dispatch_runtime),
1423 self.mode,
1424 self.body_limits.clone(),
1425 self.options.app.max_connections,
1426 )
1427 .await;
1428
1429 self.stop_hosted_services_once().await;
1432 hosted_watcher.abort();
1433 self.dispose_di();
1434 Ok(())
1435 }
1436
1437 pub fn server_handle(&self) -> ServerHandle {
1448 ServerHandle {
1449 shutdown: Arc::clone(&self.shutdown),
1450 }
1451 }
1452
1453 pub fn into_server(self) -> Server {
1458 Server { host: self }
1459 }
1460}
1461
1462#[async_trait::async_trait]
1463impl IHost for Host {
1464 async fn run(&self, addr: &str) -> Result<()> {
1465 self.run_at(addr).await
1466 }
1467 async fn stop(&self) -> Result<()> {
1468 tracing::info!("Stop requested.");
1469 trigger_shutdown(&self.shutdown);
1470 Ok(())
1471 }
1472}
1473
1474fn make_router_handler(router: Arc<Router>, spa_fallback: Option<Arc<SpaMiddleware>>) -> HandlerFn {
1475 Arc::new(move |ctx: &mut dyn IHttpContext| {
1476 let router = Arc::clone(&router);
1477 let spa_fallback = spa_fallback.clone();
1478 Box::pin(async move {
1479 if let Some(err) = ctx.take_early_error() {
1483 return Err(err);
1484 }
1485 match router.match_route(ctx).await? {
1486 Some((endpoint, params, pattern)) => {
1487 drop(router);
1488 for (key, value) in params {
1489 ctx.request_mut().route_params_mut().insert(key, value);
1490 }
1491 *ctx.request_mut().route_pattern_mut() = Some(pattern);
1492 endpoint.handle(ctx).await
1493 }
1494 None => {
1495 let path_exists = router.path_exists(ctx.request().path());
1496 let allowed = if path_exists {
1497 router.allowed_methods(ctx.request().path())
1498 } else {
1499 Vec::new()
1500 };
1501 drop(router);
1502 if !ctx.response().has_body() {
1505 if let Some(spa) = spa_fallback {
1508 if spa.try_index_fallback(ctx).await? {
1509 return Ok(());
1510 }
1511 }
1512 if path_exists {
1513 if ctx.request().method().eq_ignore_ascii_case("OPTIONS") {
1514 ctx.response_mut().set_status(204);
1519 ctx.response_mut().set_header("allow", &allowed.join(", "));
1520 } else {
1521 write_method_not_allowed(ctx, &allowed).await;
1523 }
1524 } else {
1525 write_plain_error(ctx, 404, "Not Found").await;
1526 }
1527 }
1528 Ok(())
1529 }
1530 }
1531 })
1532 })
1533}
1534
1535async fn handle_request(
1536 req: Request<Incoming>,
1537 pipeline: Arc<MiddlewarePipeline>,
1538 router_handler: HandlerFn,
1539 dispatch_runtime: Arc<DispatchRuntime>,
1540 body_limits: BodyLimits,
1541 remote_addr: Option<std::net::SocketAddr>,
1542) -> std::result::Result<hyper::Response<RespBody>, std::convert::Infallible> {
1543 let mut ctx = HttpContext::new(req, body_limits, remote_addr, Arc::clone(&dispatch_runtime));
1544
1545 let result = pipeline.execute(&mut ctx, router_handler).await;
1546 if let Err(e) = result {
1547 match e {
1548 webx_core::error::Error::Problem(problem) => {
1552 write_problem_response(&mut ctx, *problem).await
1553 }
1554 other => {
1555 write_framework_error(&mut ctx, &other).await;
1556 }
1557 }
1558 }
1559
1560 Ok(ctx.into_response().await)
1561}
1562
1563async fn write_framework_error(ctx: &mut dyn IHttpContext, error: &webx_core::error::Error) {
1575 let status = error.status_code();
1576 if status == 401 {
1577 write_unauthorized(
1578 ctx,
1579 error.public_detail().unwrap_or("authentication required"),
1580 )
1581 .await;
1582 } else if status >= 500 {
1583 tracing::error!(status, error = %error, "unhandled server error");
1584 write_problem_response(ctx, build_problem(status, "an unexpected error occurred")).await;
1585 } else {
1586 write_problem_response(
1587 ctx,
1588 build_problem(status, error.public_detail().unwrap_or("request failed")),
1589 )
1590 .await;
1591 }
1592}
1593
1594async fn write_plain_error(ctx: &mut dyn IHttpContext, status: u16, message: &str) {
1597 write_problem_response(ctx, build_problem(status, message)).await;
1598}
1599
1600async fn write_method_not_allowed(ctx: &mut dyn IHttpContext, allowed: &[&str]) {
1602 let allow = allowed.join(", ");
1603 ctx.response_mut().set_header("allow", &allow);
1604 write_plain_error(ctx, 405, "Method Not Allowed").await;
1605}
1606
1607fn trigger_shutdown(shutdown: &ShutdownSignal) {
1617 shutdown.send_replace(true);
1622}
1623
1624async fn wait_for_shutdown(shutdown: &mut tokio::sync::watch::Receiver<bool>) {
1629 if *shutdown.borrow() {
1630 return;
1631 }
1632 loop {
1633 if shutdown.changed().await.is_err() {
1634 return;
1635 }
1636 if *shutdown.borrow() {
1637 return;
1638 }
1639 }
1640}
1641
1642fn install_shutdown_handler(shutdown: ShutdownSignal) {
1653 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1654 use std::sync::{Mutex, OnceLock, Weak};
1655
1656 static HANDLERS: OnceLock<Mutex<Vec<Weak<tokio::sync::watch::Sender<bool>>>>> = OnceLock::new();
1657 static CTRL_COUNT: AtomicUsize = AtomicUsize::new(0);
1658 static INSTALLED: AtomicBool = AtomicBool::new(false);
1659
1660 let handlers = HANDLERS.get_or_init(|| Mutex::new(Vec::new()));
1661 {
1662 let mut registered = handlers
1663 .lock()
1664 .unwrap_or_else(|poisoned| poisoned.into_inner());
1665 registered.retain(|weak| weak.strong_count() > 0);
1667 registered.push(Arc::downgrade(&shutdown));
1668 }
1669
1670 if INSTALLED.swap(true, Ordering::SeqCst) {
1674 return;
1675 }
1676
1677 if let Err(e) = ctrlc::set_handler(move || {
1678 let n = CTRL_COUNT.fetch_add(1, Ordering::SeqCst);
1679 if n == 0 {
1680 tracing::info!("Shutdown signal received, draining connections...");
1681 let registered = handlers
1682 .lock()
1683 .unwrap_or_else(|poisoned| poisoned.into_inner());
1684 for weak in registered.iter() {
1685 if let Some(shutdown) = weak.upgrade() {
1686 trigger_shutdown(&shutdown);
1687 }
1688 }
1689 } else {
1690 tracing::warn!("Second interrupt — force exiting.");
1691 std::process::exit(130);
1692 }
1693 }) {
1694 tracing::warn!("Could not install Ctrl+C handler: {}", e);
1695 }
1696}
1697
1698fn spawn_async_shutdown_listener(shutdown: ShutdownSignal) {
1700 tokio::spawn(async move {
1701 wait_for_shutdown_signal().await;
1702 tracing::info!("Async shutdown signal received, draining connections...");
1703 trigger_shutdown(&shutdown);
1704 });
1705}
1706
1707async fn wait_for_shutdown_signal() {
1708 #[cfg(unix)]
1709 {
1710 use tokio::signal::unix::{signal, SignalKind};
1711 let mut sigterm = signal(SignalKind::terminate()).expect("install SIGTERM handler");
1712 tokio::select! {
1713 res = tokio::signal::ctrl_c() => {
1714 if let Err(e) = res {
1715 tracing::warn!("Ctrl+C listener error: {}", e);
1716 }
1717 }
1718 _ = sigterm.recv() => {}
1719 }
1720 }
1721 #[cfg(not(unix))]
1722 {
1723 if let Err(e) = tokio::signal::ctrl_c().await {
1724 tracing::warn!("Ctrl+C listener error: {}", e);
1725 }
1726 }
1727}
1728
1729const HOSTED_START_TIMEOUT_SECS: u64 = 30;
1731const HOSTED_STOP_TIMEOUT_SECS: u64 = 30;
1733
1734async fn start_hosted_services(services: &[Arc<dyn IHostedService>]) -> Result<()> {
1735 if services.is_empty() {
1736 return Ok(());
1737 }
1738 tracing::info!("Starting {} hosted service(s)...", services.len());
1739 let mut started = 0usize;
1740 let mut failure: Option<webx_core::error::Error> = None;
1741
1742 for svc in services {
1743 let attempt = tokio::time::timeout(
1747 std::time::Duration::from_secs(HOSTED_START_TIMEOUT_SECS),
1748 svc.start(),
1749 )
1750 .await;
1751 match attempt {
1752 Ok(Ok(())) => started += 1,
1753 Ok(Err(err)) => {
1754 failure = Some(err);
1755 break;
1756 }
1757 Err(_) => {
1758 failure = Some(webx_core::error::Error::Internal(format!(
1759 "hosted service start timed out after {HOSTED_START_TIMEOUT_SECS}s"
1760 )));
1761 break;
1762 }
1763 }
1764 }
1765
1766 match failure {
1767 None => {
1768 tracing::info!("All {started} hosted service(s) started.");
1769 Ok(())
1770 }
1771 Some(err) => {
1772 for (index, svc) in services[..started].iter().enumerate().rev() {
1775 stop_hosted_service(index, svc).await;
1776 }
1777 Err(err)
1778 }
1779 }
1780}
1781
1782async fn stop_hosted_services(services: &[Arc<dyn IHostedService>]) {
1783 if services.is_empty() {
1784 return;
1785 }
1786 tracing::info!("Stopping {} hosted service(s)...", services.len());
1787 for (index, svc) in services.iter().enumerate().rev() {
1788 stop_hosted_service(index, svc).await;
1789 }
1790 tracing::info!("All hosted services stopped.");
1791}
1792
1793async fn stop_hosted_service(index: usize, svc: &Arc<dyn IHostedService>) {
1795 match tokio::time::timeout(
1796 std::time::Duration::from_secs(HOSTED_STOP_TIMEOUT_SECS),
1797 svc.stop(),
1798 )
1799 .await
1800 {
1801 Ok(Ok(())) => {}
1802 Ok(Err(err)) => {
1803 tracing::warn!(service_index = index, error = %err, "hosted service stop error")
1804 }
1805 Err(_) => tracing::error!(
1806 service_index = index,
1807 timeout_secs = HOSTED_STOP_TIMEOUT_SECS,
1808 "hosted service stop timed out"
1809 ),
1810 }
1811}
1812
1813async fn drain_connections(join_set: &mut JoinSet<()>, label: &str, mode: AppMode) {
1814 let timeout_secs = if mode == AppMode::Development { 5 } else { 30 };
1815 let drain_future = async {
1816 while let Some(result) = join_set.join_next().await {
1817 if let Err(e) = result {
1818 tracing::error!("Connection task panicked: {:?}", e);
1819 }
1820 }
1821 };
1822
1823 match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), drain_future).await {
1824 Ok(_) => tracing::info!("{}: drained.", label),
1825 Err(_) => {
1826 tracing::warn!("{}: drain timeout, aborting connections.", label);
1827 join_set.abort_all();
1828 while join_set.join_next().await.is_some() {}
1829 }
1830 }
1831}
1832
1833async fn bind_listener(addr: &str, label: &str) -> Result<tokio::net::TcpListener> {
1843 tokio::net::TcpListener::bind(addr).await.map_err(|err| {
1844 webx_core::error::Error::Http(format!("failed to bind {label} on {addr}: {err}"))
1845 })
1846}
1847
1848fn parse_url(url: &str) -> Result<(&str, String)> {
1851 if let Some(rest) = url.strip_prefix("https://") {
1852 Ok(("https", rest.to_string()))
1853 } else if let Some(rest) = url.strip_prefix("http://") {
1854 Ok(("http", rest.to_string()))
1855 } else {
1856 Err(webx_core::error::Error::Http(format!(
1857 "Invalid URL '{}'. Use http://host:port or https://host:port",
1858 url
1859 )))
1860 }
1861}
1862
1863#[allow(clippy::too_many_arguments)]
1865async fn serve_http(
1866 addr: String,
1867 listener: tokio::net::TcpListener,
1868 mut shutdown: tokio::sync::watch::Receiver<bool>,
1869 pipeline: Arc<MiddlewarePipeline>,
1870 router_handler: HandlerFn,
1871 dispatch_runtime: Arc<DispatchRuntime>,
1872 mode: AppMode,
1873 body_limits: BodyLimits,
1874 max_connections: usize,
1875) {
1876 let sem = Arc::new(tokio::sync::Semaphore::new(max_connections));
1877 let mut join_set = JoinSet::new();
1878
1879 loop {
1880 if *shutdown.borrow() {
1884 tracing::info!("HTTP {}: stop accepting, draining...", addr);
1885 break;
1886 }
1887 tokio::select! {
1888 _ = shutdown.changed() => {
1889 tracing::info!("HTTP {}: stop accepting, draining...", addr);
1890 break;
1891 }
1892 accept_result = listener.accept() => {
1893 let (stream, peer_addr) = match accept_result {
1894 Ok(v) => v,
1895 Err(e) => {
1896 tracing::error!("Accept error (will retry): {}", e);
1897 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1898 continue;
1899 }
1900 };
1901 let remote_addr = Some(peer_addr);
1902
1903 while join_set.try_join_next().is_some() {}
1904
1905 let permit = match sem.clone().acquire_owned().await {
1906 Ok(p) => p,
1907 Err(_) => continue,
1908 };
1909
1910 let io = TokioIo::new(stream);
1911 let pipeline = Arc::clone(&pipeline);
1912 let router_handler = router_handler.clone();
1913 let dispatch_runtime = Arc::clone(&dispatch_runtime);
1914 let body_limits = body_limits.clone();
1915 let conn_shutdown = shutdown.clone();
1916
1917 join_set.spawn(async move {
1918 let _permit = permit;
1919 let svc_fn = service_fn(move |req: Request<Incoming>| {
1920 let pipeline = Arc::clone(&pipeline);
1921 let router_handler = router_handler.clone();
1922 let dispatch_runtime = Arc::clone(&dispatch_runtime);
1923 let body_limits = body_limits.clone();
1924 let mode = mode;
1925 let remote_addr = remote_addr;
1928 async move {
1929 let start = Instant::now();
1930 let method = req.method().to_string();
1931 let path = req.uri().path().to_string();
1932 let result = handle_request(
1933 req,
1934 pipeline,
1935 router_handler,
1936 dispatch_runtime,
1937 body_limits,
1938 remote_addr,
1939 )
1940 .await;
1941 let elapsed = start.elapsed();
1942 if mode == AppMode::Development {
1943 let status =
1944 result.as_ref().map(|r| r.status().as_u16()).unwrap_or(500);
1945 tracing::info!(
1946 "[{}] {} -> {} ({:.0}ms)",
1947 method,
1948 path,
1949 status,
1950 elapsed.as_secs_f64() * 1000.0
1951 );
1952 }
1953 result
1954 }
1955 });
1956
1957 let conn = hyper::server::conn::http1::Builder::new()
1958 .serve_connection(io, svc_fn);
1959 let mut conn = std::pin::pin!(conn);
1960 let mut conn_shutdown = conn_shutdown;
1961 tokio::select! {
1962 result = conn.as_mut() => {
1963 if let Err(err) = result {
1964 tracing::error!("Connection error: {}", err);
1965 }
1966 }
1967 _ = wait_for_shutdown(&mut conn_shutdown) => {
1968 conn.as_mut().graceful_shutdown();
1972 if let Err(err) = conn.await {
1973 tracing::debug!(error = %err, "connection closed during shutdown");
1974 }
1975 }
1976 }
1977 });
1978 }
1979 }
1980 }
1981
1982 drain_connections(&mut join_set, &format!("HTTP {}", addr), mode).await;
1983}
1984
1985#[allow(clippy::too_many_arguments)]
1987async fn serve_https(
1988 addr: String,
1989 acceptor: TlsAcceptor,
1990 listener: tokio::net::TcpListener,
1991 mut shutdown: tokio::sync::watch::Receiver<bool>,
1992 pipeline: Arc<MiddlewarePipeline>,
1993 router_handler: HandlerFn,
1994 dispatch_runtime: Arc<DispatchRuntime>,
1995 mode: AppMode,
1996 body_limits: BodyLimits,
1997 max_connections: usize,
1998) {
1999 let sem = Arc::new(tokio::sync::Semaphore::new(max_connections));
2000 let mut join_set = JoinSet::new();
2001
2002 loop {
2003 if *shutdown.borrow() {
2004 tracing::info!("HTTPS {}: stop accepting, draining...", addr);
2005 break;
2006 }
2007 tokio::select! {
2008 _ = shutdown.changed() => {
2009 tracing::info!("HTTPS {}: stop accepting, draining...", addr);
2010 break;
2011 }
2012 accept_result = listener.accept() => {
2013 let (stream, peer_addr) = match accept_result {
2014 Ok(v) => v,
2015 Err(e) => {
2016 tracing::error!("Accept error (will retry): {}", e);
2017 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2018 continue;
2019 }
2020 };
2021 let remote_addr = Some(peer_addr);
2022
2023 while join_set.try_join_next().is_some() {}
2024
2025 let permit = match sem.clone().acquire_owned().await {
2026 Ok(p) => p,
2027 Err(_) => continue,
2028 };
2029
2030 let acceptor = acceptor.clone();
2031 let pipeline = Arc::clone(&pipeline);
2032 let router_handler = router_handler.clone();
2033 let dispatch_runtime = Arc::clone(&dispatch_runtime);
2034 let body_limits = body_limits.clone();
2035 let conn_shutdown = shutdown.clone();
2036
2037 join_set.spawn(async move {
2038 let _permit = permit;
2039 match acceptor.accept(stream).await {
2040 Ok(tls_stream) => {
2041 let io = TokioIo::new(tls_stream);
2042 let svc_fn = service_fn(move |req: Request<Incoming>| {
2043 let pipeline = Arc::clone(&pipeline);
2044 let router_handler = router_handler.clone();
2045 let dispatch_runtime = Arc::clone(&dispatch_runtime);
2046 let body_limits = body_limits.clone();
2047 let mode = mode;
2048 let remote_addr = remote_addr;
2050 async move {
2051 let start = Instant::now();
2052 let method = req.method().to_string();
2053 let path = req.uri().path().to_string();
2054 let result = handle_request(
2055 req,
2056 pipeline,
2057 router_handler,
2058 dispatch_runtime,
2059 body_limits,
2060 remote_addr,
2061 )
2062 .await;
2063 let elapsed = start.elapsed();
2064 if mode == AppMode::Development {
2065 let status =
2066 result.as_ref().map(|r| r.status().as_u16()).unwrap_or(500);
2067 tracing::info!(
2068 "[{}] {} -> {} ({:.0}ms)",
2069 method,
2070 path,
2071 status,
2072 elapsed.as_secs_f64() * 1000.0
2073 );
2074 }
2075 result
2076 }
2077 });
2078
2079 let conn = hyper::server::conn::http1::Builder::new()
2080 .serve_connection(io, svc_fn);
2081 let mut conn = std::pin::pin!(conn);
2082 let mut conn_shutdown = conn_shutdown;
2083 tokio::select! {
2084 result = conn.as_mut() => {
2085 if let Err(err) = result {
2086 tracing::error!("TLS connection error: {}", err);
2087 }
2088 }
2089 _ = wait_for_shutdown(&mut conn_shutdown) => {
2090 conn.as_mut().graceful_shutdown();
2091 if let Err(err) = conn.await {
2092 tracing::debug!(error = %err, "TLS connection closed during shutdown");
2093 }
2094 }
2095 }
2096 }
2097 Err(e) => {
2098 tracing::error!("TLS handshake error: {}", e);
2099 }
2100 }
2101 });
2102 }
2103 }
2104 }
2105
2106 drain_connections(&mut join_set, &format!("HTTPS {}", addr), mode).await;
2107}
2108
2109fn build_tls_acceptor(cert_path: &str, key_path: &str) -> Result<TlsAcceptor> {
2115 use std::fs::File;
2116 use std::io::BufReader;
2117
2118 if cert_path.is_empty() || key_path.is_empty() {
2119 return Err(webx_core::error::Error::Http(
2120 "TLS certificate or key path not configured.".into(),
2121 ));
2122 }
2123
2124 let cert_file = File::open(cert_path).map_err(|e| {
2125 webx_core::error::Error::Http(format!("Cannot open cert '{}': {}", cert_path, e))
2126 })?;
2127 let mut cert_reader = BufReader::new(cert_file);
2128 let certs: Vec<CertificateDer> = certs(&mut cert_reader).filter_map(|r| r.ok()).collect();
2129 if certs.is_empty() {
2130 return Err(webx_core::error::Error::Http(format!(
2131 "No valid certs in '{}'",
2132 cert_path
2133 )));
2134 }
2135
2136 let key_file = File::open(key_path).map_err(|e| {
2137 webx_core::error::Error::Http(format!("Cannot open key '{}': {}", key_path, e))
2138 })?;
2139 let mut key_reader = BufReader::new(key_file);
2140 let key = pkcs8_private_keys(&mut key_reader)
2141 .filter_map(|r| r.ok())
2142 .map(PrivateKeyDer::from)
2143 .next()
2144 .or_else(|| {
2145 let key_file2 = File::open(key_path).map(BufReader::new).ok()?;
2146 let mut kr2 = key_file2;
2147 let rsa_keys: Vec<PrivateKeyDer> = rsa_private_keys(&mut kr2)
2148 .filter_map(|r| r.ok())
2149 .map(PrivateKeyDer::from)
2150 .collect();
2151 rsa_keys.into_iter().next()
2152 })
2153 .ok_or_else(|| {
2154 webx_core::error::Error::Http(format!("No valid private key in '{}'", key_path))
2155 })?;
2156
2157 let config = ServerConfig::builder()
2158 .with_no_client_auth()
2159 .with_single_cert(certs, key)
2160 .map_err(|e| webx_core::error::Error::Http(format!("TLS config error: {}", e)))?;
2161
2162 Ok(TlsAcceptor::from(std::sync::Arc::new(config)))
2163}
2164
2165#[cfg(test)]
2166mod embedded_attach_tests {
2167 use super::with_embedded_assets;
2168 use webx_spa::{ContentEncoding, EmbeddedAsset, EmbeddedAssets, SpaSource};
2169
2170 const FILES: &[EmbeddedAsset] = &[EmbeddedAsset {
2171 path: "index.html",
2172 bytes: b"hi",
2173 raw_len: 2,
2174 encoding: ContentEncoding::Identity,
2175 content_type: "text/html",
2176 etag: "\"x\"",
2177 }];
2178
2179 #[test]
2180 fn no_table_leaves_disk_source() {
2181 let source = Some(SpaSource::Disk("wwwroot".into()));
2182 let merged = with_embedded_assets(source, None);
2183 assert!(matches!(merged, Some(SpaSource::Disk(ref r)) if r == "wwwroot"));
2184 }
2185
2186 #[test]
2187 fn table_layers_under_use_spa_overlay() {
2188 let assets = EmbeddedAssets::new("../wwwroot", FILES);
2189 let merged = with_embedded_assets(Some(SpaSource::Disk("wwwroot".into())), Some(assets));
2190 match merged {
2191 Some(SpaSource::Embedded {
2192 overlay_root,
2193 assets,
2194 }) => {
2195 assert_eq!(overlay_root, "wwwroot");
2196 assert_eq!(assets.root(), "../wwwroot");
2197 }
2198 other => panic!("expected embedded, got {other:?}"),
2199 }
2200 }
2201
2202 #[test]
2203 fn table_alone_uses_build_root_as_overlay() {
2204 let assets = EmbeddedAssets::new("../wwwroot", FILES);
2205 let merged = with_embedded_assets(None, Some(assets));
2206 match merged {
2207 Some(SpaSource::Embedded { overlay_root, .. }) => {
2208 assert_eq!(overlay_root, "../wwwroot");
2209 }
2210 other => panic!("expected embedded, got {other:?}"),
2211 }
2212 }
2213
2214 #[test]
2215 fn host_crate_registers_no_asset_table() {
2216 assert!(EmbeddedAssets::registered().is_none());
2217 }
2218}